Combine current battle decisions with turn commitments

This commit is contained in:
Quintine committed 2026-10-26 23:11:48 +11:00
1 parent 1a79fd3131
commit f76c8dfb93
15 files changed
+1380 -66

No files matched your search

+168
View File
@@ -0,0 +1,168 @@
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 { TruthFact, TruthQuestion } from '../game/truthtrance';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { createTruthMixedBattleFixture } from './fixture-truth-mixed-battle';
import { createTruthMixedTraitorFixture, createTruthMixedTraitorHarkonnenFixture } from './fixture-truth-mixed-traitor';
import { truthTraitorDecisionTraitors } from './fixture-truth-traitor-decision';
import {
actTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer, type TruthTurnActionStep,
} from './fixture-truth-turn-action';
import type { TruthTurnAllianceLeaf } from './fixture-truth-turn-alliance';
export type TruthTurnCombatLeaf = TruthTurnAllianceLeaf |
{ kind: 'battlePlan'; battle: string; territory: string; claim: MixedBattleActionClaim } |
{ kind: 'traitorCall'; battle: string; territory: string; call: boolean };
export type TruthTurnCombatExpression = MixedActionExpression<TruthTurnCombatLeaf>;
export type TruthTurnCombatStep = TruthTurnActionStep;
export const actTruthTurnCombat = actTruthTurnAction;
export const truthTurnCombatPlayer = truthTurnActionPlayer;
export const truthTurnCombatInventory = truthTurnActionInventory;
export const truthTurnCombatTraitors = truthTraitorDecisionTraitors;
export const truthTurnCombatClean = (game: Game) => !game.truthtrance && !game.response && !game.decision &&
!game.phaseOpening && !game.pendingTreacheryDiscard && !game.pendingShipment && !game.pendingRevival;
export const truthTurnCombatFact = (fact: TruthFact): TruthTurnCombatExpression => ({ kind: 'fact', fact });
export const truthTurnCombatPayment = (recipient: string, minimum = 2): TruthTurnCombatExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTurnCombatPlan = (battle: string, territory: string, claim: MixedBattleActionClaim): TruthTurnCombatExpression =>
({ kind: 'action', action: { kind: 'battlePlan', battle, territory, claim: structuredClone(claim) } });
export const truthTurnCombatCall = (battle: string, territory: string, call = true): TruthTurnCombatExpression =>
({ kind: 'action', action: { kind: 'traitorCall', battle, territory, call } });
export const truthTurnCombatGroup = (kind: 'and' | 'or', ...terms: TruthTurnCombatExpression[]): TruthTurnCombatExpression =>
({ kind, terms });
/** Original Basic24 beforePlans, original Basic26 unsubmitted Traitor voter,
* or original remote allied Harkonnen72. The no-match variant actually seals
* another living original Atreides disc; it never exchanges a Traitor or
* installs plans. Optional-module Cunning stays outside the native envelope. */
export function createTruthTurnCombatFixture(options: {
source?: 'plan' | 'traitor' | 'noMatch' | 'harkonnen'; mixed?: TruthTurnCombatExpression;
} = {}) {
const sourceKind = options.source ?? 'plan';
const planSource = sourceKind === 'plan' ? createTruthMixedBattleFixture() : null;
const traitorSource = sourceKind === 'traitor' || sourceKind === 'noMatch' ? createTruthMixedTraitorFixture() : null;
const harkonnenSource = sourceKind === 'harkonnen' ? createTruthMixedTraitorHarkonnenFixture({ kind: 'ally' }) : null;
const original = planSource ?? traitorSource ?? harkonnenSource!;
let game = structuredClone(original.game), ownPlan = structuredClone(original.ownPlan);
let actions = structuredClone(original.sourceActions);
if (sourceKind === 'noMatch') {
assert.ok(traitorSource);
game = structuredClone(traitorSource.beforePlans);
actions = structuredClone(traitorSource.sourceActions.slice(0, -traitorSource.planSteps.length));
const other = truthTurnCombatPlayer(game, traitorSource.asker).leaders.find(disc =>
!disc.dead && !disc.usedAt && disc.id !== traitorSource.traitorHeld); assert.ok(other);
ownPlan = { ...traitorSource.ownPlan, leader: other.id };
game = actTruthTurnCombat(game, traitorSource.asker, ownPlan, actions);
game = actTruthTurnCombat(game, traitorSource.target, traitorSource.targetPlan, actions);
assert.ok(game.battle?.revealed);
assert.deepEqual(viewGame(game, traitorSource.target).battle!.traitorCallChoices, [false]);
}
assert.ok(game.battle?.event);
const event = game.battle.event, territory = game.battle.territory;
const asker = original.asker, target = original.target, recipient = asker;
const weaponClaim: MixedBattleActionClaim = planSource?.weaponClaim ?? { kind: 'weapon', name: null };
const mixed = structuredClone(options.mixed ?? truthTurnCombatGroup('and', planSource
? truthTurnCombatPlan(event, territory, weaponClaim) : truthTurnCombatCall(event, territory), truthTurnCombatPayment(recipient)));
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnCombatPlayer(game, target).spice };
const traitorHeld = planSource?.base.base.traitorHeld ?? traitorSource?.traitorHeld ?? harkonnenSource!.traitorHeld;
assert.ok(truthTurnCombatPlayer(game, asker).hand.some(card => card.id === original.truth));
assert.equal(truthTurnCombatPlayer(game, target).ally === recipient, false);
assert.deepEqual(truthTurnCombatInventory(game), original.originalCards);
assert.deepEqual(truthTurnCombatTraitors(game), original.originalTraitors);
assert.equal(game.turnActionPromises?.length ?? 0, 0);
return {
game, source: structuredClone(game), sourceKind, planSource, traitorSource, harkonnenSource,
asker, target, recipient, truth: original.truth, event, battle: event, territory, mixed, question,
initial: original.initial, setup: original.setup, beforeStartingDeal: original.beforeStartingDeal,
afterSetup: original.afterSetup, originalCards: original.originalCards, originalTraitors: original.originalTraitors,
originalSpice: original.originalSpice, actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { inherited: original.sourceActionCount, selected: actions.length,
originalPlanSeals: sourceKind === 'plan' ? 0 : 2 },
weaponClaim, spiceFact, traitorHeld, ownPlan, targetPlan: structuredClone(original.targetPlan),
projectilePlan: planSource ? structuredClone(planSource.projectilePlan) : null,
ordinary: planSource?.ordinary ?? traitorSource?.ordinary ?? null, karama: harkonnenSource?.karama ?? null,
beneficiary: harkonnenSource?.beneficiary ?? target,
payment: { type: 'bribe', target: recipient, amount: 2 } satisfies Action,
reveal: { type: 'traitorCall', call: true } satisfies Action,
decline: { type: 'traitorCall', call: false } satisfies Action,
sourceDisclosure: `${original.sourceDisclosure} This adapter changes only the raw combined current-battle question. ` +
(sourceKind === 'noMatch' ? 'The original beforePlans prefix is followed by two actual seals using another living original Atreides disc; no original Traitor card is moved. ' : '') +
'Only actual plan reveal and native Traitor declaration supply future receipts. No hand, cash, physical card, force, death, battle, plan, vote, clock or promise is installed.',
};
}
export type TruthTurnCombatFixture = ReturnType<typeof createTruthTurnCombatFixture>;
export function declareTruthTurnCombat(f: TruthTurnCombatFixture, source = f.game, actions: TruthTurnCombatStep[] = []) {
const before = structuredClone(source), from = actions.length;
let game = actTruthTurnCombat(source, f.asker, { type: 'card', card: f.truth }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(candidate => !game.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
game = actTruthTurnCombat(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask');
return { game, before, actions, actionCount: actions.length - from };
}
export function askTruthTurnCombat(game: Game, asker: string, target: string, mixed: TruthTurnCombatExpression,
actions: TruthTurnCombatStep[] = []): Game {
return actTruthTurnCombat(game, asker, { type: 'truthAsk', question: {
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTurnCombat(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
actions: TruthTurnCombatStep[] = []): Game {
return actTruthTurnCombat(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTurnCombat(f: TruthTurnCombatFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
source = f.game, actions: TruthTurnCombatStep[] = []) {
const from = actions.length, declared = declareTruthTurnCombat(f, source, actions);
const asked = askTruthTurnCombat(declared.game, f.asker, f.target, mixed, actions);
const game = answerTruthTurnCombat(asked, f.target, answer, actions);
return { game, declared, asked, actions, actionCount: actions.length - from };
}
export function truthTurnCombatPromise(game: Game, target = game.turnActionPromises?.[0]?.player ?? game.players[0].id) {
const row = viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target); assert.ok(row); return row;
}
export function revealTruthTurnCombat(game: Game, f: TruthTurnCombatFixture, plan: Action = f.projectilePlan!,
actions: TruthTurnCombatStep[] = []) {
assert.ok(f.planSource, 'Only the original unsealed-plan source supplies both actual plan controls');
game = actTruthTurnCombat(game, f.target, plan, actions); const afterTargetPlan = structuredClone(game);
game = actTruthTurnCombat(game, f.asker, f.ownPlan, actions); assert.ok(game.battle?.revealed);
return { game, afterTargetPlan, actions };
}
export function nextTruthTurnCombatNative(game: Game): TruthTurnCombatStep | null {
return nextStrongholdFactionsNativeStep(game);
}
export function nextTruthTurnCombat(game: Game): TruthTurnCombatStep | null {
if (game.truthtrance) return null;
for (const player of game.players) {
const guide = viewGame(game, player.id).turnActionGuide;
if (guide) return { actor: player.id, action: guide.action };
}
return nextTruthTurnCombatNative(game);
}
export function advanceTruthTurnCombat(game: Game, until: (state: Game) => boolean,
actions: TruthTurnCombatStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1200; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The original combat source ended before its requested native boundary');
const next = nativeOnly ? nextTruthTurnCombatNative(game) : nextTruthTurnCombat(game);
assert.ok(next, 'Supply the actual original private plan or current native vote before continuing');
game = actTruthTurnCombat(game, next.actor, next.action, actions);
}
throw Error('The genuine combat source did not reach its finite boundary');
}
export function settleTruthTurnCombat(game: Game, actions: TruthTurnCombatStep[] = []): Game {
return advanceTruthTurnCombat(game, truthTurnCombatClean, actions);
}
export function finishTruthTurnCombatBattle(game: Game, f: TruthTurnCombatFixture, actions: TruthTurnCombatStep[] = []): Game {
assert.ok(game.battle?.revealed, 'Actually reveal both original plans before cleanup');
if (!f.planSource) assert.notEqual(game.battle.traitorCalls[f.target], undefined, 'Actually declare the bound native Traitor choice');
return advanceTruthTurnCombat(game, state => !state.battle || state.battle.event !== f.event, actions, true);
}
export function finishTruthTurnCombat(game: Game, actions: TruthTurnCombatStep[] = []): Game {
const turn = game.turn;
return advanceTruthTurnCombat(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnCombatClean(state), actions);
}
+106 -1
View File
@@ -5,8 +5,10 @@ import { renderToStaticMarkup } from 'react-dom/server';
import { TurnActionFields, turnActionInputError } from '../components/turn-action-fields';
import { TurnActionAnswers, TurnActionGuideButton, TurnActionPromises } from '../components/turn-action-promises';
import { TruthFactFields } from '../components/mixed-shipment-fields';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView } from '../game/engine';
import { BattleClaimFields } from '../components/battle-promises';
import { createGame, joinGame, newPlayer, viewGame, type Action, type GameView, type Plan } from '../game/engine';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { MixedBattleActionClaim } from '../game/mixed-battle-question';
import { createRevivalOrder } from '../game/revival-order';
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
import type { TurnActionLeaf } from '../game/turn-action-promises';
@@ -26,6 +28,10 @@ const forceRevival = (counter: 'total' | 'normal' | 'elite' = 'total', minimum =
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
const leaderRevival = (leader: string): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const finalAlliance = (partner = 'r'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq', value: 3 }): Expression =>
({ kind: 'action', action: { kind: 'battlePlan', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
const traitorClaim = (call = true): Expression =>
({ kind: 'action', action: { kind: 'traitorCall', battle: 'controlled-native-battle', territory: 'arrakeen', call } });
const claim = (): Expression => ({ kind: 'or', terms: [payment(), { kind: 'and', terms: [fact(), physical('ship', 3), physical('move', 4, 'arrakeen')] }] });
function elements(node: ReactNode): Element[] {
@@ -84,6 +90,17 @@ function nexus(next: Action = { type: 'alliance', target: 'r' }): GameView {
game.turnActionPromises[0].mixed = { kind: 'or', terms: [finalAlliance(), payment()] };
return game;
}
function combat(revealed = false, next: Action = { type: 'battlePlan', dial: 3, support: 2, bankerSpice: 1, allyPayment: 1,
leader: 'controlled-leader', weapon: 'controlled-weapon', defense: 'controlled-defense', kwisatz: true,
planetologistSpecial: true, reinforcementsSources: { arrakeen: { normal: 1, elite: 0 } } }): GameView {
const game = projected(6, next);
game.battle = { event: 'controlled-native-battle', territory: 'arrakeen', attacker: 'p', defender: 'a', revealed,
submitted: [], traitorVoters: ['p', 'a'], traitorSubmitted: [], plans: {}, truthPromises: [],
} as unknown as NonNullable<GameView['battle']>;
game.truthMixedBattleTargets = ['p', 'a']; game.truthMixedTraitorTargets = revealed ? ['p', 'a'] : [];
game.turnActionPromises[0].mixed = { kind: 'or', terms: [revealed ? traitorClaim() : planClaim(), payment()] };
return game;
}
function revival(next: Action = { type: 'revive', amount: 3, elite: 1 }): GameView {
const game = projected(4, next);
game.revivalOrder = createRevivalOrder({ turn: 2, order: ['p', 'a', 'r'], ready: [] });
@@ -116,6 +133,11 @@ function answering(phase = 5): GameView {
game.truthTurnActionAnswers = ['yes', 'no', 'unknown'];
return game;
}
function editPlan(tree: ReactNode, path: string, claim: MixedBattleActionClaim) {
const fields = elements(find(tree, 'data-turn-action-plan-fields', `truth-turn-action-${path}`))
.find(element => element.type === BattleClaimFields); assert.ok(fields);
(fields.props.onChange as (claim: MixedBattleActionClaim) => void)(claim);
}
void test('turn-action editor keeps all action families and optional facts in the original whole expression', () => {
const e = editor(physical('ship', 3)); assert.equal(e.error(), null);
@@ -374,6 +396,89 @@ void test('turn-action Nexus callbacks expire with public consent, membership, r
source.nexus = true; press(retained);
});
void test('turn-action current battle editor reuses atomic plan fields and public declaration sources within the global logical budget', () => {
const game = combat(), e = editor(physical(), false, game);
for (const player of game.players) for (const key of ['hand', 'leaders', 'traitors', 'faceDancers', 'spice', 'forces', 'reserves', 'tanks'])
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
for (const key of ['plans', 'cards', 'ownForces', 'compliantPlan', 'compliantPreparation'])
Object.defineProperty(game.battle!, key, { get() { throw Error(`No private battle ${key} inspection`); }, configurable: true });
select(e.tree(), '1', 'action', 'battlePlan'); editPlan(e.tree(), '1', { kind: 'dial', compare: 'eq', value: 3 });
assert.deepEqual(e.value, planClaim()); assert.equal(e.error(), null);
for (const invalid of [NaN, -1, 0.25, Infinity, 40.5]) {
editPlan(e.tree(), '1', { kind: 'dial', compare: 'eq', value: invalid }); assert.ok(e.error());
editPlan(e.tree(), '1', { kind: 'dial', compare: 'gte', value: 0.5 }); assert.equal(e.error(), null);
}
const foreign = game.allLeaders.find(leader => leader.faction === 'atreides'); assert.ok(foreign);
editPlan(e.tree(), '1', { kind: 'leader', leader: foreign.id }); assert.equal(e.error(), null);
assert.match(e.html(), /Cheap Hero \/ Heroine/);
editPlan(e.tree(), '1', { kind: 'weapon', name: 'Shield' }); assert.equal(e.error(), null);
assert.match(e.html(), /already sealed or revealed/);
game.battle!.event = 'later-native-battle'; assert.ok(e.error()); select(e.tree(), '1', 'battle', 'later-native-battle'); assert.equal(e.error(), null);
game.battle!.revealed = true; assert.equal(e.error(), null, 'A publicly fixed plan remains a valid question source.');
select(e.tree(), '1', 'action', 'traitorCall'); assert.ok(e.error()); game.truthMixedTraitorTargets = ['p', 'a'];
select(e.tree(), '1', 'call', 'decline'); assert.equal(e.error(), null);
game.battle!.traitorSubmitted.push('p'); assert.ok(e.error());
const frozen = editor(planClaim(), true, combat()); editPlan(frozen.tree(), '1', { kind: 'support', compare: 'eq', value: 7 });
select(frozen.tree(), '1', 'battle', ''); assert.equal(frozen.changes, 0); assert.deepEqual(frozen.value, planClaim());
const fullGame = combat(), full = editor({ kind: 'or', terms: [planClaim(), fact()] }, false, fullGame);
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'battlePlan'); }
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/);
assert.doesNotMatch(full.html(), /truth-battle-plan-expression/);
assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
const deep = editor({ kind: 'and', terms: [fact(), planClaim()] }, false, fullGame);
click(control(deep.tree(), 'or-1.2')); click(control(deep.tree(), 'and-1.2.1')); click(control(deep.tree(), 'or-1.2.1.1'));
assert.equal(deep.error(), null); assert.match(deep.html(), /4 \/ 4 levels of grouping/);
});
void test('turn-action battle-plan guide fills the exact native packet for human review and never seals automatically', () => {
const game = combat(), filled: Plan[] = [], sent: Action[] = [];
for (const key of ['combat', 'compiled', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
for (const key of ['plans', 'ownForces', 'compliantPlan', 'compliantPreparation'])
Object.defineProperty(game.battle!, key, { get() { throw Error(`No private battle ${key} inspection`); }, configurable: true });
const props = { game, busy: false, fillPlan(plan: Plan) { filled.push(plan); }, act(action: Action) { sent.push(action); } };
const button = guide(props); assert.equal(button.props.disabled, false); press(button);
assert.equal(filled[0], game.turnActionGuide!.action); assert.deepEqual(sent, []);
assert.equal(filled[0].dial, 3); assert.equal(filled[0].support, 2); assert.equal(filled[0].allyPayment, 1);
assert.equal(filled[0].bankerSpice, 1); assert.equal(filled[0].weapon, 'controlled-weapon'); assert.equal(filled[0].defense, 'controlled-defense');
assert.deepEqual(filled[0].reinforcementsSources, { arrakeen: { normal: 1, elite: 0 } });
assert.equal(filled[0].kwisatz, true); assert.equal(filled[0].planetologistSpecial, true);
assert.equal((filled[0] as Plan & Action).turnActionPromiseEvent, event);
assert.match(renderToStaticMarkup(createElement(TurnActionPromises, props)), /Fill the quoted battle plan for review before sealing/);
const noFill = guide({ game, busy: false, act() { assert.fail('A human plan guide cannot auto seal without a fill callback'); } });
assert.equal(noFill.props.disabled, true); press(noFill);
props.fillPlan = () => assert.fail('A replaced fill callback cannot receive a saved plan'); press(button); assert.equal(filled.length, 1);
const preparation = combat(false, { type: 'card', card: 'controlled-ghola', leader: 'controlled-dead-leader' }), prepared: Action[] = [];
press(guide({ game: preparation, busy: false, act(action) { prepared.push(action); } }));
assert.equal(prepared[0], preparation.turnActionGuide!.action);
const declaration = combat(true, { type: 'traitorCall', call: false }), declared: Action[] = [];
declaration.battle!.attacker = 'r'; declaration.battle!.traitorVoters = ['p'];
press(guide({ game: declaration, busy: false, act(action) { declared.push(action); } }));
assert.equal(declared[0], declaration.turnActionGuide!.action, 'A native allied voter retains their own declaration packet.');
});
void test('turn-action combat callbacks retain current battle ownership and raw history without private stage reconstruction', () => {
const cases: [() => GameView, ((game: GameView) => void)[]][] = [
[() => combat(), [game => { game.battle!.submitted.push('p'); }, game => { game.battle!.revealed = true; }, game => { game.battle!.attacker = 'r'; }]],
[() => combat(true, { type: 'traitorCall', call: true }), [game => { game.battle!.traitorSubmitted.push('p'); },
game => { game.battle!.revealed = false; }, game => { game.battle!.traitorVoters = ['a']; }]],
];
for (const [build, specific] of cases) for (const change of [...specific,
(game: GameView) => { game.battle = { ...game.battle! }; }, (game: GameView) => { game.battle!.event = 'later-battle'; },
(game: GameView) => { game.battle!.territory = 'carthag'; }, (game: GameView) => { game.phase = 5; },
(game: GameView) => { game.truthtrance = answering(6).truthtrance; },
]) {
const game = build(), button = guide({ game, busy: false, fillPlan() { assert.fail('Stale battle cannot fill'); }, act() { assert.fail('Stale battle cannot send'); } });
assert.equal(button.props.disabled, false); change(game); press(button);
}
const game = combat(); game.turnActionGuide = null; game.battle = null;
for (const key of ['combat', 'compiled', 'binding', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} inspection`); }, configurable: true });
const html = markup(game);
assert.match(html, /sealing your own plan does not finish it/); assert.match(html, /original Reveal or decline declaration/);
assert.match(html, /You answered Yes/); assert.doesNotMatch(html, /controlled-native-battle|Fill the quoted battle plan|compiled/);
});
void test('turn-action history retains original own whole questions and terminal status without private compiled branches', () => {
const game = projected(); game.turnActionGuide = null;
game.turnActionPromises.push(
+304
View File
@@ -0,0 +1,304 @@
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 TruthQuestion } from '../game/truthtrance';
import { createTruthTurnActionFixture } from './fixture-truth-turn-action';
import {
actTruthTurnCombat as act, advanceTruthTurnCombat as advance, answerTruthTurnCombat as answer,
askTruthTurnCombat as ask, bindTruthTurnCombat as bind, createTruthTurnCombatFixture as fixture,
declareTruthTurnCombat as declare, finishTruthTurnCombat as finish, finishTruthTurnCombatBattle as cleanup,
nextTruthTurnCombatNative as nativeStep, revealTruthTurnCombat as reveal, settleTruthTurnCombat as settle,
truthTurnCombatCall as call, truthTurnCombatClean as clean, truthTurnCombatFact as fact,
truthTurnCombatGroup as group, truthTurnCombatInventory as inventory, truthTurnCombatPayment as payment,
truthTurnCombatPlan as plan, truthTurnCombatPlayer as player, truthTurnCombatPromise as promise,
truthTurnCombatTraitors as traitors, type TruthTurnCombatFixture, type TruthTurnCombatStep,
} from './fixture-truth-turn-combat';
function rejected(game: Game, actor: string, action: Action) {
const before = structuredClone(game); assert.throws(() => act(game, actor, action)); assert.deepEqual(game, before);
}
function physical(game: Game, f: TruthTurnCombatFixture) {
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
}
void test('native combat adapters keep the original beforePlans and unsubmitted Traitor sources without installed played state', () => {
for (const source of ['plan', 'traitor', 'noMatch', 'harkonnen'] as const) {
const f = fixture({ source }); 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(candidate => candidate.hand.length === 0));
assert.equal(f.sourceActionCount, f.sourceActions.length); assert.equal(f.sourceCounts.selected, f.sourceActionCount);
assert.equal(f.sourceActionCount, source === 'plan' ? 24 : source === 'harkonnen' ? 72 : 26);
assert.equal(f.originalCards.length, 33); assert.equal(f.game.advanced, false); assert.equal(f.game.nexusCards, undefined);
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.truth));
assert.ok(f.game.battle?.event); assert.equal(f.game.battle.event, f.event); assert.equal(f.game.battle.territory, f.territory);
assert.equal(f.game.battle.revealed, source !== 'plan'); assert.deepEqual(f.game.battle.traitorCalls, {});
assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
if (source === 'noMatch') {
assert.notEqual(f.game.battle.plans[f.asker].leader, f.traitorHeld);
assert.ok(player(f.game, f.target).traitors.includes(f.traitorHeld));
assert.deepEqual(viewGame(f.game, f.target).battle!.traitorCallChoices, [false]);
}
}
});
void test('native plan AND payment freezes current spice and uses the exact originally held projectile through actual reveal', () => {
const f = fixture(); assert.ok(f.projectilePlan && f.ordinary && f.planSource);
assert.equal(player(f.planSource.beforeShipment, f.target).spice - player(f.game, f.target).spice, 1);
assert.equal(player(f.game, f.target).spice, 9);
const mixed = group('and', fact(f.spiceFact), plan(f.event, f.territory, f.weaponClaim), payment(f.recipient));
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
assert.equal(player(game, f.target).spice, 7); assert.equal(player(game, f.recipient).bribes, 2);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
rejected(game, f.target, f.targetPlan);
const sealed = reveal(game, f, f.projectilePlan); game = sealed.game;
assert.equal(sealed.afterTargetPlan.battle?.revealed, false);
assert.equal(sealed.afterTargetPlan.turnActionPromises![0].combat?.plan?.closed, false);
assert.equal(game.battle!.plans[f.target].weapon, f.ordinary);
assert.equal(player(game, f.target).spice, 7, 'Original Basic zero-dial/zero-support plans add no invented spice charge');
assert.deepEqual(game.turnActionPromises![0].combat?.plan?.values, [{ claim: f.weaponClaim, value: true }]);
assert.equal(game.turnActionPromises![0].combat?.plan?.closed, true);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(promise(game, f.target).mixed, mixed);
game = cleanup(game, f); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('native plan OR payment preserves either manual exact-weapon plan or real payment route', () => {
const f = fixture(); assert.ok(f.projectilePlan && f.ordinary);
const mixed = group('or', plan(f.event, f.territory, f.weaponClaim), payment(f.recipient));
const bound = bind(f, 'yes', mixed).game;
let armed = reveal(bound, f, f.projectilePlan).game;
assert.equal(promise(armed, f.target).fulfilled, true); assert.equal(player(armed, f.recipient).bribes, 0);
let paid = act(bound, f.target, f.payment); assert.equal(promise(paid, f.target).fulfilled, true);
paid = reveal(paid, f, f.targetPlan).game;
assert.equal(paid.battle!.plans[f.target].weapon, null);
assert.ok(player(paid, f.target).hand.some(card => card.id === f.ordinary));
armed = cleanup(armed, f); paid = cleanup(paid, f);
for (const state of [armed, paid]) { assert.equal(promise(state, f.target).fulfilled, true); physical(state, f); }
});
void test('native whole plan AND payment No retains both alternatives and rejects only completion of the exact predicate', () => {
const f = fixture(); assert.ok(f.projectilePlan && f.ordinary);
const mixed = group('and', plan(f.event, f.territory, f.weaponClaim), payment(f.recipient));
for (const first of ['payment', 'plan'] as const) {
let game = bind(f, 'no', mixed).game;
if (first === 'payment') {
game = act(game, f.target, f.payment); rejected(game, f.target, f.projectilePlan);
game = reveal(game, f, f.targetPlan).game;
assert.ok(player(game, f.target).hand.some(card => card.id === f.ordinary));
} else {
game = reveal(game, f, f.projectilePlan).game; rejected(game, f.target, f.payment);
game = act(game, f.target, { ...f.payment, amount: 1 });
rejected(game, f.target, { ...f.payment, amount: 1 });
}
assert.ok(!promise(game, f.target).released); game = cleanup(game, f); game = finish(game);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('native false revealed plan receipt survives original battle cleanup while its payment OR remains due until late Mentat', () => {
const f = fixture(), mixed = group('or', plan(f.event, f.territory, f.weaponClaim), payment(f.recipient));
let game = bind(f, 'yes', mixed).game; game = reveal(game, f, f.targetPlan).game;
assert.deepEqual(game.turnActionPromises![0].combat?.plan?.values, [{ claim: f.weaponClaim, value: false }]);
assert.ok(!promise(game, f.target).fulfilled); game = cleanup(game, f);
assert.equal(game.battle, null); assert.ok(!promise(game, f.target).released);
const receipt = structuredClone(game.turnActionPromises![0].combat);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
rejected(game, f.target, { type: 'ready' }); game = act(game, f.target, f.payment);
assert.equal(promise(game, f.target).fulfilled, true); assert.deepEqual(game.turnActionPromises![0].combat, receipt);
physical(game, f);
});
void test('native already submitted own plan is a fixed answer-time predicate without resubmitting or revealing a foreign plan', () => {
const f = fixture(); assert.ok(f.projectilePlan);
let game = act(f.game, f.target, f.projectilePlan); assert.equal(game.battle?.revealed, false);
const fixed = structuredClone(game.battle!.plans[f.target]);
game = bind(f, 'yes', f.mixed, game).game;
assert.equal(game.turnActionPromises![0].combat?.plan?.closed, true);
assert.deepEqual(game.turnActionPromises![0].combat?.plan?.values, [{ claim: f.weaponClaim, value: true }]);
assert.ok(!promise(game, f.target).fulfilled); assert.equal(game.battle!.plans[f.asker], undefined);
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true);
assert.deepEqual(game.battle!.plans[f.target], fixed); game = act(game, f.asker, f.ownPlan);
assert.ok(game.battle?.revealed); game = cleanup(game, f); physical(game, f);
});
void test('native matching Reveal and Decline each combine with actual ordinary payment at the original Traitor declaration', () => {
const f = fixture({ source: 'traitor' });
for (const selected of [true, false]) {
const mixed = group('and', call(f.event, f.territory, selected), payment(f.recipient));
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [selected]);
rejected(game, f.target, { type: 'traitorCall', call: !selected });
game = act(game, f.target, { type: 'traitorCall', call: selected });
assert.equal(game.turnActionPromises![0].combat?.traitor?.closed, true);
assert.equal(game.turnActionPromises![0].combat?.traitor?.call, selected);
assert.equal(promise(game, f.target).fulfilled, true); game = cleanup(game, f);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('native whole Reveal AND payment No keeps payment or the actual opposite declaration as independent alternatives', () => {
const f = fixture({ source: 'traitor' }), mixed = group('and', call(f.event, f.territory), payment(f.recipient));
for (const first of ['payment', 'reveal'] as const) {
let game = bind(f, 'no', mixed).game;
if (first === 'payment') {
game = act(game, f.target, f.payment); rejected(game, f.target, f.reveal);
game = act(game, f.target, f.decline);
} else {
game = act(game, f.target, f.reveal); rejected(game, f.target, f.payment);
game = act(game, f.target, { ...f.payment, amount: 1 });
rejected(game, f.target, { ...f.payment, amount: 1 });
}
assert.ok(!promise(game, f.target).released); game = cleanup(game, f); game = finish(game);
assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('native genuinely unmatched opposing leader preserves a funded payment OR without inventing a Traitor identity', () => {
const f = fixture({ source: 'noMatch' }), mixed = group('or', call(f.event, f.territory), payment(f.recipient));
let game = bind(f, 'yes', mixed).game;
assert.deepEqual(viewGame(game, f.target).battle!.traitorCallChoices, [false]); rejected(game, f.target, f.reveal);
game = act(game, f.target, f.decline); assert.ok(!promise(game, f.target).fulfilled);
assert.equal(game.turnActionPromises![0].combat?.traitor?.call, false);
game = cleanup(game, f); assert.ok(!promise(game, f.target).released);
game = advance(game, state => state.phase === 8 && clean(state), [], true);
game = act(game, f.target, f.payment); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('native remote allied Harkonnen Reveal fulfills at declaration before its actually allowed or prevented effect', () => {
const f = fixture({ source: 'harkonnen' }); assert.ok(f.karama);
assert.equal(player(f.game, f.target).ally, f.beneficiary);
assert.ok(![f.game.battle!.attacker, f.game.battle!.defender].includes(f.target));
for (const prevented of [false, true]) {
let game = bind(f).game; game = act(game, f.target, f.payment); game = act(game, f.target, f.reveal);
assert.equal(game.response?.kind, 'harkonnenTraitor'); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(game.turnActionPromises![0].combat?.traitor?.beneficiary, f.beneficiary);
assert.equal(game.turnActionPromises![0].combat?.traitor?.call, true);
const receipt = structuredClone(game.turnActionPromises![0].combat);
if (prevented) game = act(game, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
else game = settle(game);
assert.equal(game.battle!.traitorCalls[f.target], !prevented);
assert.deepEqual(game.turnActionPromises![0].combat, receipt); assert.equal(promise(game, f.target).fulfilled, true);
game = cleanup(game, f); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('native current combat leaves and exact guides enforce event territory owner phase and original raw history', () => {
for (const source of ['plan', 'traitor'] as const) {
const f = fixture({ source }), declared = declare(f);
const native = source === 'plan' ? plan(f.event, f.territory, f.weaponClaim) : call(f.event, f.territory);
const stale = source === 'plan' ? plan('stale-combat', f.territory, f.weaponClaim) : call('stale-combat', f.territory);
const wrongTerritory = source === 'plan' ? plan(f.event, 'carthag', f.weaponClaim) : call(f.event, 'carthag');
for (const invalid of [stale, wrongTerritory]) rejected(declared.game, f.asker, { type: 'truthAsk', question: {
...f.question, mixed: group('or', payment(f.recipient), invalid),
} });
rejected(declared.game, f.target, { type: 'truthAsk', question: f.question });
let game = answer(ask(declared.game, f.asker, f.target, group('and', native, payment(f.recipient))), f.target, 'yes');
game = act(game, f.target, f.payment);
const row = promise(game, f.target), allowed = ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'];
assert.ok(Object.keys(row).every(key => allowed.includes(key)));
assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []); assert.equal(viewGame(game, f.asker).turnActionGuide, null);
assert.deepEqual(viewGame(game, f.target).truthHistory.at(-1)?.question, f.question);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, source === 'plan' ? 'battlePlan' : 'traitorCall');
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
assert.equal(guide.action.turnActionPromisePhase, 6); rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-guide-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 5 });
const wire: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, wire);
rejected(game, f.target, wire);
if (source === 'plan') game = act(game, f.asker, f.ownPlan);
assert.equal(promise(game, f.target).fulfilled, true); game = cleanup(game, f);
rejected(game, f.target, wire); physical(game, f);
}
});
void test('all four minimal profiles actually answer and use native plan or Traitor guides through combined fulfillment', () => {
for (const source of ['plan', 'traitor'] as const) {
const f = fixture({ source }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTurnCombatStep[] = [];
const answering = viewGame(game, f.target); answering.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const response = botActions(answering).find(candidate => candidate.type === 'truthAnswer'); assert.ok(response, difficulty);
assert.equal(response.answer, 'yes'); game = act(game, f.target, response, actions);
for (let count = 0; count < 24 && !promise(game, f.target).fulfilled; count++) {
const view = viewGame(game, f.target); view.players.find(candidate => candidate.id === f.target)!.bot = difficulty;
const guide = view.turnActionGuide;
if (guide) {
const action = botActions(view).find(candidate => candidate.turnActionPromiseEvent === guide.event);
assert.ok(action, `${source}/${difficulty}`); assert.deepEqual(action, guide.action);
game = act(game, f.target, action, actions);
} else if (source === 'plan' && game.battle?.plans[f.target] && !game.battle.plans[f.asker]) {
game = act(game, f.asker, f.ownPlan, actions);
} else {
const next = nativeStep(game); assert.ok(next, `${source}/${difficulty}`);
game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game, f.target).fulfilled, true, `${source}/${difficulty}`);
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe'));
assert.ok(actions.some(step => step.actor === f.target && step.action.type === (source === 'plan' ? 'battlePlan' : 'traitorCall')));
game = cleanup(game, f); physical(game, f);
}
}
});
void test('two genuine Truths keep a later native plan candidate compatible with an already paid whole No', () => {
// The inherited original H/A/E prefix really holds both Harkonnen Truths.
// Actual A endMovement, E's paid reserve shipment and native battle controls
// earn this battle. No second card, army, purse, battle or ledger is set.
const f = createTruthTurnActionFixture(), actions = structuredClone(f.sourceActions);
const originalTraitors = traitors(f.game), opponent = f.observer;
let game = act(f.game, f.target, { type: 'endMovement' }, actions);
game = advance(game, state => state.phase === 5 && state.active === opponent && clean(state), actions, true);
const beforeShipment = player(game, opponent).spice;
game = settle(act(game, opponent, { type: 'ship', territory: 'arrakeen', sector: 10,
amount: 1, elite: 0, allyPayment: 0 }, actions), actions);
assert.equal(player(game, opponent).spice, beforeShipment - 1);
game = act(game, opponent, { type: 'endMovement' }, actions);
game = advance(game, state => state.phase === 6 && !!state.active && !state.battle && clean(state), actions, true);
const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === 'arrakeen' &&
[candidate.attacker, candidate.defender].includes(f.target) && [candidate.attacker, candidate.defender].includes(opponent));
assert.ok(choice);
game = act(game, choice.chooser, { type: 'chooseBattle', territory: choice.territory,
target: choice.attacker === choice.chooser ? choice.defender : choice.attacker }, actions);
game = advance(game, state => !!state.battle && clean(state) && !state.battle.preparation &&
state.battle.preLeader?.closed !== false, actions, true);
assert.ok(game.battle?.event); assert.equal(player(game, f.target).spice, 10);
assert.deepEqual(player(game, f.asker).hand.map(card => card.id), f.truths);
assert.ok(actions.length > f.sourceActionCount); assert.deepEqual(inventory(game), f.originalCards);
const event = game.battle.event, territory = game.battle.territory;
const dialClaim = { kind: 'dial', compare: 'gte', value: 1 } as const;
const mixed = group('and', plan(event, territory, dialClaim), payment(f.asker));
const open = (state: Game, card: string, question: TruthQuestion): Game => {
let next = act(state, f.asker, { type: 'card', card }, actions);
while (next.truthtrance?.stage === 'priority') {
const actor = next.players.find(candidate => !next.truthtrance!.passed.includes(candidate.id)); assert.ok(actor);
next = act(next, actor.id, { type: 'truthPass' }, actions);
}
return act(next, f.asker, { type: 'truthAsk', question }, actions);
};
game = open(game, f.truths[0], { kind: 'turnActions', target: f.target, turn: game.turn, mixed });
game = answer(game, f.target, 'no', actions);
game = act(game, f.target, { type: 'bribe', target: f.asker, amount: 2 }, actions);
const earlier = structuredClone(game.turnActionPromises![0]), earlierHistory = structuredClone(game.truthHistory![0]);
assert.equal(player(game, f.target).spice, 8); assert.equal(game.bribePromises?.length ?? 0, 0);
game = open(game, f.truths[1], { kind: 'battlePlan', target: f.target, territory, claim: dialClaim });
assert.deepEqual(viewGame(game, f.target).truthBattleAnswers, ['no']);
rejected(game, f.target, { type: 'truthAnswer', answer: 'yes' });
assert.deepEqual(game.turnActionPromises![0], earlier); assert.deepEqual(game.truthHistory![0], earlierHistory);
assert.ok(player(game, f.asker).hand.some(card => card.id === f.truths[1]));
game = answer(game, f.target, 'no', actions);
for (const actor of [f.target, opponent]) {
const leader = player(game, actor).leaders.find(disc => !disc.dead && !disc.usedAt); assert.ok(leader);
game = act(game, actor, { type: 'battlePlan', leader: leader.id, dial: 0, support: 0,
weapon: null, defense: null }, actions);
}
assert.ok(game.battle?.revealed); assert.equal(game.battle.plans[f.target].dial, 0);
game = finish(game, actions);
assert.equal(promise(game, f.target).fulfilled, true); assert.ok(!promise(game, f.target).released);
assert.equal(game.truthHistory!.length, 2);
for (const truth of f.truths) assert.equal(game.discard.filter(card => card.id === truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), originalTraitors);
});
+189
View File
@@ -9,12 +9,15 @@ import {
TURN_ACTION_MAX_LEAVES,
TurnActionError,
closeTurnActionBidding,
closeTurnActionCombat,
closeTurnActionRevival,
closeTurnActionTransport,
finishTurnActionTurn,
liveTurnActionPromises,
parseTurnActionLeaf,
recordTurnActionBid,
recordTurnActionPlan,
recordTurnActionTraitor,
recordTurnActionMovement,
recordTurnActionPayment,
recordTurnActionShipment,
@@ -27,6 +30,7 @@ import {
type TurnActionBranchLeaf,
type TurnActionLeaf,
type TurnActionPromise,
type TurnActionPlanValue,
} from '../game/turn-action-promises';
type Expression = MixedActionExpression<TurnActionLeaf>;
@@ -40,6 +44,10 @@ const forces = (minimum = 3, counter: 'total' | 'normal' | 'elite' = 'total'): E
({ kind: 'action', action: { kind: 'forces', counter, minimum } });
const leader = (leader = 'atreides-0'): Expression => ({ kind: 'action', action: { kind: 'leader', leader } });
const alliance = (partner = 'recipient'): Expression => ({ kind: 'action', action: { kind: 'alliance', partner } });
const plan = (claim: TurnActionPlanValue['claim'] = { kind: 'dial', compare: 'gte', value: 2 }, battle = 'battle:1'): Expression =>
({ kind: 'action', action: { kind: 'battlePlan', battle, territory: 'carthag', claim } });
const call = (call = true, battle = 'battle:1'): Expression =>
({ kind: 'action', action: { kind: 'traitorCall', battle, territory: 'carthag', call } });
const fact = (): Expression => ({ kind: 'fact', fact: { kind: 'spice', compare: 'gte', value: 5 } });
const group = (kind: 'and' | 'or', ...terms: Expression[]): Expression => ({ kind, terms });
const signedTravel = (kind: 'ship' | 'move', territory = 'carthag', minimum = 3,
@@ -66,6 +74,12 @@ function prepared(mixed: Expression, answer = true, id = 'turn-action:1'): TurnA
function nexus(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
return { ...promise(mixed, answer, id), alliance: { source: 'nexus:1', partner: null, closed: false } };
}
function combat(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
return { ...promise(mixed, answer, id), combat: { event: 'battle:1', territory: 'carthag',
plan: { closed: false, values: null }, traitor: { beneficiary: 'respondent', target: 'other',
leader: 'other-0', closed: false, call: null } } };
}
const dial = (value = true): TurnActionPlanValue => ({ claim: { kind: 'dial', compare: 'gte', value: 2 }, value });
void test('turn-action leaves retain exact native fields, safe payment minima and no payment twenty-force cap', () => {
for (const kind of ['ship', 'move'] as const) {
@@ -683,3 +697,178 @@ void test('final-membership stages reject temporary pairs, extra metadata, missi
const foreignCompiled = { ...original, compiled: { kind: 'action', action: { kind: 'alliance', partner: 'other' } } };
assert.throws(() => turnActionValue(foreignCompiled as TurnActionPromise), TurnActionError);
});
void test('combat leaves accept only one original native plan atom or actual declaration in the original battle', () => {
const claims: TurnActionPlanValue['claim'][] = [{ kind: 'dial', compare: 'eq', value: 1.5 },
{ kind: 'support', compare: 'gte', value: 2 }, { kind: 'weapon', name: null },
{ kind: 'defense', name: null }, { kind: 'leader', leader: 'structural-leader-id' }, { kind: 'kwisatz', use: false }];
for (const claim of claims) {
const leaf = { kind: 'battlePlan', battle: 'battle:1', territory: 'carthag', claim };
assert.deepEqual(parseTurnActionLeaf(leaf), leaf);
assert.notEqual(parseTurnActionLeaf(leaf), leaf);
}
for (const choice of [true, false]) assert.deepEqual(parseTurnActionLeaf({ kind: 'traitorCall', battle: 'battle:1',
territory: 'carthag', call: choice }), { kind: 'traitorCall', battle: 'battle:1', territory: 'carthag', call: choice });
const original = { kind: 'battlePlan', battle: 'battle:1', territory: 'carthag', claim: dial().claim };
for (const leaf of [{ ...original, battle: '' }, { ...original, territory: '' }, { ...original, answer: true },
{ ...original, claim: { kind: 'and', terms: [dial().claim, dial().claim] } },
{ ...original, claim: { kind: 'dial', compare: 'eq', value: 1.25 } },
{ ...original, claim: { kind: 'leader', leader: ' untrimmed' } },
{ kind: 'traitorCall', battle: 'battle:1', territory: 'carthag', call: 'yes' },
{ kind: 'traitorCall', battle: 'battle:1', territory: 'carthag', call: true, canReveal: true }])
assert.throws(() => parseTurnActionLeaf(leaf), TurnActionError);
for (const mixed of [plan(), call(), group('or', fact(), plan()), group('or', fact(), call())])
assert.throws(() => turnActionValue(promise(mixed)), TurnActionError);
});
void test('combat whole AND OR and No branches retain original plan atoms and actual declaration polarity', () => {
const planLeaf = { kind: 'battlePlan', battle: 'battle:1', territory: 'carthag', claim: dial().claim, answer: true };
const traitorLeaf = { kind: 'traitorCall', battle: 'battle:1', territory: 'carthag', call: true, answer: true,
beneficiary: 'respondent', target: 'other', leader: 'other-0' };
assert.deepEqual(branches(combat(group('and', plan(), call(), pay()))), [[planLeaf, traitorLeaf, signedPay()]]);
assert.deepEqual(branches(combat(group('or', plan(), call(), pay()))), [[planLeaf], [traitorLeaf], [signedPay()]]);
assert.deepEqual(branches(combat(group('and', plan(), call()), false)), [
[{ ...planLeaf, answer: false }], [{ ...traitorLeaf, answer: false }],
]);
assert.deepEqual(branches(combat(group('or', call(true), call(false)), false)), [],
'No declaration satisfies No to the OR of actual Reveal and actual Decline');
assert.deepEqual(branches(combat(group('and', call(true), call(false)))), [],
'One original native declaration cannot both Reveal and Decline');
assert.deepEqual(branches(combat(group('and', plan(), plan()))), [[planLeaf]]);
});
void test('one actual plan receipt projects exact original predicates into distinct answer intervals and then permits later payment', () => {
const weapon: TurnActionPlanValue = { claim: { kind: 'weapon', name: null }, value: true };
const first = combat(group('and', plan(), pay()));
const second = combat(group('and', plan(weapon.claim), plan(weapon.claim), pay()), true, 'turn-action:2');
const rows = recordTurnActionPlan([first, second], 'respondent', 1, 'battle:1', 'carthag', [dial(), weapon]);
assert.deepEqual(rows[0].combat!.plan, { closed: true, values: [dial()] });
assert.deepEqual(rows[1].combat!.plan, { closed: true, values: [weapon] });
assert.deepEqual(branches(rows[0]), [[signedPay()]]);
assert.equal(rows[0].fulfilled, false);
assert.equal(recordTurnActionPayment(rows, 'respondent', 1, payment())[0].fulfilled, true);
const fresh = combat(group('and', plan(), pay()), true, 'turn-action:3');
assert.equal(fresh.combat!.plan!.values, null);
assert.equal(recordTurnActionPlan(rows, 'respondent', 1, 'battle:1', 'carthag', [dial(false), weapon])[0], rows[0]);
assert.equal(recordTurnActionPlan([fresh], 'respondent', 1, 'other-battle', 'carthag', [dial()])[0], fresh);
assert.equal(recordTurnActionPlan([fresh], 'respondent', 1, 'battle:1', 'arrakeen', [dial()])[0], fresh);
assert.equal(recordTurnActionPlan([fresh], 'respondent', 2, 'battle:1', 'carthag', [dial()])[0], fresh);
assert.equal(recordTurnActionPlan([fresh], 'other', 1, 'battle:1', 'carthag', [dial()])[0], fresh);
assert.equal(first.combat!.plan!.values, null);
validateTurnActionPromises(rows, seats);
});
void test('actual plan and declaration events reject whole No violations atomically and retain dynamic alternatives', () => {
const no = combat(group('and', plan(), call()), false);
const planned = recordTurnActionPlan([no], 'respondent', 1, 'battle:1', 'carthag', [dial()]);
const before = structuredClone(planned);
assert.throws(() => recordTurnActionTraitor(planned, 'respondent', 1, 'battle:1', 'carthag', true), TurnActionError);
assert.deepEqual(planned, before);
const declined = recordTurnActionTraitor(planned, 'respondent', 1, 'battle:1', 'carthag', false);
assert.equal(declined[0].fulfilled, true);
const yes = combat(group('and', plan(), pay()), true, 'turn-action:2');
const planNo = combat(plan(), false, 'turn-action:3');
assert.throws(() => recordTurnActionPlan([yes, planNo], 'respondent', 1, 'battle:1', 'carthag', [dial()]), TurnActionError);
assert.equal(yes.combat!.plan!.values, null);
const declineNo = combat(call(false), false);
assert.throws(() => recordTurnActionTraitor([declineNo], 'respondent', 1, 'battle:1', 'carthag', false), TurnActionError);
const revealed = recordTurnActionTraitor([combat(group('and', call(), pay()))], 'respondent', 1, 'battle:1', 'carthag', true);
assert.equal(revealed[0].combat!.traitor!.call, true);
assert.equal(recordTurnActionTraitor(revealed, 'respondent', 1, 'battle:1', 'carthag', false)[0], revealed[0],
'Later prevention/effect handling cannot retract the original declaration');
});
void test('combat closure without a receipt makes every plan and both declaration predicates false', () => {
const zero = plan({ kind: 'dial', compare: 'eq', value: 0 });
const yes = combat(group('or', zero, call(false)));
const no = combat(group('or', zero, call(false)), false, 'turn-action:2');
const closed = closeTurnActionCombat([yes, no], 1, 'battle:1');
assert.equal(turnActionValue(closed[0]), false);
assert.deepEqual(closed[0].combat!.plan, { closed: true, values: null });
assert.equal(closed[0].combat!.traitor!.closed, true);
assert.equal(closed[0].combat!.traitor!.call, null);
assert.equal(closed[0].fulfilled, false);
assert.equal(closed[1].fulfilled, true);
assert.equal(closeTurnActionCombat([yes], 1, 'other-battle')[0], yes);
const finished = finishTurnActionTurn(closed, 'respondent', 1);
assert.equal(finished[0].released, true);
const received = recordTurnActionPlan([combat(group('and', plan(), pay()))], 'respondent', 1, 'battle:1', 'carthag', [dial()]);
const preserved = closeTurnActionCombat(received, 1, 'battle:1');
assert.deepEqual(preserved[0].combat!.plan, { closed: true, values: [dial()] });
assert.deepEqual(branches(preserved[0]), [[signedPay()]]);
});
void test('combat expiry releases unresolved original stages without inventing a plan or declaration and preserves terminal rows', () => {
const yes = combat(plan()), no = combat(call(false), false, 'turn-action:2');
const expired = finishTurnActionTurn([yes, no], 'respondent', 1);
for (const row of expired) {
assert.equal(row.released, true);
assert.equal(row.fulfilled, false);
assert.deepEqual(row.combat, yes.combat);
}
const terminal = recordTurnActionPlan([combat(plan(), true, 'turn-action:3')], 'respondent', 1, 'battle:1', 'carthag', [dial()])[0];
assert.equal(terminal.fulfilled, true);
assert.equal(finishTurnActionTurn([terminal], 'respondent', 1)[0], terminal);
for (const transform of [
(rows: TurnActionPromise[]) => closeTurnActionCombat(rows, 1, 'battle:1'),
(rows: TurnActionPromise[]) => recordTurnActionPlan(rows, 'respondent', 1, 'battle:1', 'carthag', [dial()]),
(rows: TurnActionPromise[]) => recordTurnActionTraitor(rows, 'respondent', 1, 'battle:1', 'carthag', true),
]) assert.ok(transform([...expired, terminal]).every((row, index) => row === [...expired, terminal][index]));
validateTurnActionPromises([...expired, terminal], seats);
});
void test('combat metadata rejects missing original stages, mismatched source, premature values and incomplete or foreign receipts', () => {
const original = combat(group('and', plan(), call(), pay()));
const stage = original.combat!;
for (const value of [undefined, null, {}, { ...stage, event: 'other-battle' }, { ...stage, territory: 'arrakeen' },
{ ...stage, plan: null }, { ...stage, traitor: null }, { ...stage, canReveal: true },
{ ...stage, plan: { closed: false, values: [dial()] } },
{ ...stage, plan: { closed: true, values: [] } },
{ ...stage, plan: { closed: true, values: [dial(), dial()] } },
{ ...stage, plan: { closed: true, values: [{ claim: { kind: 'weapon', name: null }, value: true }] } },
{ ...stage, traitor: { ...stage.traitor!, call: false } },
{ ...stage, traitor: { ...stage.traitor!, beneficiary: 'other', target: 'other' } }])
assert.throws(() => turnActionValue({ ...original, combat: value } as TurnActionPromise), TurnActionError);
assert.throws(() => recordTurnActionPlan([original], 'respondent', 1, 'battle:1', 'carthag', []), TurnActionError);
assert.throws(() => recordTurnActionPlan([original], 'respondent', 1, 'battle:1', 'carthag', [dial(), dial()]), TurnActionError);
assert.throws(() => recordTurnActionPlan([original], 'respondent', 1, 'battle:1', 'carthag',
[{ ...dial(), value: 'yes' }] as never), TurnActionError);
assert.throws(() => recordTurnActionTraitor([original], 'respondent', 1, 'battle:1', 'carthag', null as never), TurnActionError);
const foreign = { ...original, combat: { ...stage, traitor: { ...stage.traitor!, target: 'missing' } } };
assert.throws(() => validateTurnActionPromises([foreign], seats), TurnActionError);
});
void test('combat clauses share the outer logical budget and can coexist with strict earlier optional stages', () => {
const sixteen = combat(group('or', ...Array.from({ length: 16 }, () => plan())));
assert.doesNotThrow(() => turnActionValue(sixteen));
assert.throws(() => turnActionValue(combat(group('or', ...Array.from({ length: 17 }, () => plan())))), TurnActionError);
const mixed = group('and', plan(), call(), bid(), forces(), alliance(), pay());
const row = { ...prepared(mixed), alliance: { source: 'nexus:1', partner: null, closed: false }, combat: combat(mixed).combat };
validateTurnActionPromises([row], seats);
const old = prepared(group('and', bid(), forces(), pay()));
assert.equal('combat' in closeTurnActionCombat([old], 1, 'battle:1')[0], false);
});
void test('native off-board combat locations retain exact event and location bindings without widening travel destinations', () => {
const territory = 'homeworld:guild';
const mixed: Expression = { kind: 'and', terms: [
{ kind: 'action', action: { kind: 'battlePlan', battle: 'battle:1', territory, claim: dial().claim } },
{ kind: 'action', action: { kind: 'traitorCall', battle: 'battle:1', territory, call: false } },
pay(),
] };
const original = combat(mixed);
original.combat!.territory = territory;
validateTurnActionPromises([original], seats);
assert.equal(recordTurnActionPlan([original], 'respondent', 1, 'other-battle', territory, [dial()])[0], original);
assert.equal(recordTurnActionPlan([original], 'respondent', 1, 'battle:1', 'homeworld:emperor', [dial()])[0], original);
const planned = recordTurnActionPlan([original], 'respondent', 1, 'battle:1', territory, [dial()]);
assert.equal(recordTurnActionTraitor(planned, 'respondent', 1, 'other-battle', territory, false)[0], planned[0]);
assert.equal(recordTurnActionTraitor(planned, 'respondent', 1, 'battle:1', 'homeworld:emperor', false)[0], planned[0]);
const declared = recordTurnActionTraitor(planned, 'respondent', 1, 'battle:1', territory, false);
assert.deepEqual(branches(declared[0]), [[signedPay()]]);
assert.equal(recordTurnActionPayment(declared, 'respondent', 1, payment())[0].fulfilled, true);
assert.equal(closeTurnActionCombat([original], 1, 'other-battle')[0], original);
assert.equal(closeTurnActionCombat([original], 1, 'battle:1')[0].combat!.plan!.closed, true);
for (const kind of ['ship', 'move'])
assert.throws(() => parseTurnActionLeaf({ kind, territory, minimum: 1 }), TurnActionError);
});