Bind Truthtrance answers to native battle power declarations

This commit is contained in:
Quintine committed 2026-10-27 01:31:56 +11:00
1 parent c93b693137
commit 09a81a9b65
17 files changed
+1598 -28

No files matched your search

+134
View File
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { VOICE_KINDS } from '../game/battle-cards';
import {
BattlePowerQuestionError,
battlePowerMatches,
battlePowerOf,
nativeBattlePowerOpportunity,
parseBattlePowerClaim,
type BattlePowerClaim,
type BattlePowerDeclaration,
type NativeBattlePowerGame,
} from '../game/battle-power-question';
function game(): NativeBattlePowerGame {
return { status: 'playing', phase: 6, players: [
{ id: 'bg', faction: 'beneGesserit', ally: null }, { id: 'a', faction: 'atreides', ally: null },
{ id: 'e', faction: 'emperor', ally: null },
], battle: { event: 'battle:1', territory: 'carthag', attacker: 'bg', defender: 'a', revealed: false,
prepared: true, preparation: { kind: 'voice', owner: 'bg', beneficiary: 'bg' } } };
}
void test('native battle-power claims parse exact declaration predicates without hidden values or groups', () => {
const claims: BattlePowerClaim[] = [{ kind: 'voice', use: true }, { kind: 'voice', use: false },
{ kind: 'prescience', use: true }, { kind: 'prescience', use: false },
...VOICE_KINDS.map(cardType => ({ kind: 'voiceCommand' as const, cardType, must: false })),
...(['leader', 'weapon', 'defense', 'dial'] as const).map(field => ({ kind: 'prescienceField' as const, field }))];
for (const claim of claims) {
assert.deepEqual(parseBattlePowerClaim(claim), claim);
assert.notEqual(parseBattlePowerClaim(claim), claim);
assert.equal(battlePowerOf(claim), claim.kind === 'voice' || claim.kind === 'voiceCommand' ? 'voice' : 'prescience');
}
for (const claim of [null, [], {}, { kind: 'voice', use: 'yes' }, { kind: 'voice', use: true, source: 'battle:1' },
{ kind: 'voiceCommand', cardType: 'unknown', must: true }, { kind: 'voiceCommand', cardType: 'shield', must: 1 },
{ kind: 'prescienceField', field: 'support' }, { kind: 'prescienceField', field: 'weapon', value: 'Lasgun' },
{ kind: 'and', terms: [{ kind: 'voice', use: true }, { kind: 'voice', use: false }] }])
assert.throws(() => parseBattlePowerClaim(claim), BattlePowerQuestionError);
});
void test('actual Voice use, exact command and explicit Decline match independently of later effect success', () => {
const declaration: BattlePowerDeclaration = { power: 'voice', use: true, cardType: 'projectile', must: true };
assert.equal(battlePowerMatches({ kind: 'voice', use: true }, declaration), true);
assert.equal(battlePowerMatches({ kind: 'voice', use: false }, declaration), false);
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'projectile', must: true }, declaration), true);
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'projectile', must: false }, declaration), false);
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'poison', must: true }, declaration), false);
const decline: BattlePowerDeclaration = { power: 'voice', use: false };
assert.equal(battlePowerMatches({ kind: 'voice', use: false }, decline), true);
assert.equal(battlePowerMatches({ kind: 'voiceCommand', cardType: 'projectile', must: true }, decline), false);
assert.equal(battlePowerMatches({ kind: 'prescience', use: false }, decline), false);
});
void test('Prescience predicates refer to use or requested field, never the inspected answer', () => {
for (const field of ['leader', 'weapon', 'defense', 'dial'] as const) {
const declaration: BattlePowerDeclaration = { power: 'prescience', use: true, field };
assert.equal(battlePowerMatches({ kind: 'prescience', use: true }, declaration), true);
assert.equal(battlePowerMatches({ kind: 'prescienceField', field }, declaration), true);
assert.equal(battlePowerMatches({ kind: 'voice', use: true }, declaration), false);
assert.equal(battlePowerMatches({ kind: 'prescience', use: false }, declaration), false);
}
assert.equal(battlePowerMatches({ kind: 'prescienceField', field: 'weapon' },
{ power: 'prescience', use: true, field: 'dial' }), false);
assert.equal(battlePowerMatches({ kind: 'prescience', use: false }, { power: 'prescience', use: false }), true);
for (const declaration of [null, {}, { power: 'voice', use: false, cardType: 'shield' },
{ power: 'voice', use: true, cardType: 'unknown', must: false }, { power: 'prescience', use: true, field: 'support' },
{ power: 'prescience', use: true, field: 'weapon', value: 'Lasgun' }, { power: 'voice', use: true, cardType: 'shield', must: true, prevented: true }])
assert.throws(() => battlePowerMatches({ kind: 'voice', use: true }, declaration as never), BattlePowerQuestionError);
});
void test('actual prepared native Voice and ordered future Prescience retain distinct owners, sides and readiness', () => {
const source = game(), before = structuredClone(source);
assert.equal(source.battle!.prepared, true);
assert.deepEqual(nativeBattlePowerOpportunity(source, 'bg', 'voice'), { event: 'battle:1', territory: 'carthag',
power: 'voice', owner: 'bg', beneficiary: 'bg', target: 'a', ready: true });
assert.deepEqual(nativeBattlePowerOpportunity(source, 'a', 'prescience'), { event: 'battle:1', territory: 'carthag',
power: 'prescience', owner: 'a', beneficiary: 'a', target: 'bg', ready: false });
source.battle!.preparation = { kind: 'prescience', owner: 'a', beneficiary: 'a' };
assert.equal(nativeBattlePowerOpportunity(source, 'a', 'prescience')!.ready, true);
assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice'), null);
assert.deepEqual(before.battle!.preparation, { kind: 'voice', owner: 'bg', beneficiary: 'bg' });
});
void test('native power quotation supports actual reciprocal allied beneficiaries without borrowing their faction identity', () => {
const source = game();
source.players = source.players.map(player => ({ ...player, ally: player.id === 'bg' ? 'e' : player.id === 'e' ? 'bg' : null }));
source.battle!.attacker = 'e';
source.battle!.preparation = { kind: 'voice', owner: 'bg', beneficiary: 'e' };
assert.deepEqual(nativeBattlePowerOpportunity(source, 'bg', 'voice'), { event: 'battle:1', territory: 'carthag',
power: 'voice', owner: 'bg', beneficiary: 'e', target: 'a', ready: true });
assert.equal(nativeBattlePowerOpportunity(source, 'e', 'voice'), null);
source.players = source.players.map(player => player.id === 'e' ? { ...player, ally: null } : player);
assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice'), null);
});
void test('modal and Truth priority retain the original source with readiness suspended while blocked use still permits Decline', () => {
for (const blocker of ['response', 'decision', 'phaseOpening', 'truthtrance', 'pendingTreacheryDiscard'] as const) {
const source = { ...game(), [blocker]: {} };
const quote = nativeBattlePowerOpportunity(source, 'bg', 'voice');
assert.equal(quote!.event, 'battle:1');
assert.equal(quote!.ready, false);
}
const blocked = game();
blocked.battle!.preparation!.blocked = 'This Voice use is blocked.';
assert.equal(nativeBattlePowerOpportunity(blocked, 'bg', 'voice')!.ready, true,
'Decline remains an executable owned choice; use legality is a separate engine quote');
});
void test('borrowed powers, completed declarations, foreign owners and crossed public sources create no native opportunity', () => {
for (const kind of ['nexusVoice', 'prescienceAnswer', 'nexusPrescienceAnswer']) {
const source = game(); source.battle!.preparation!.kind = kind;
assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice'), null);
assert.equal(nativeBattlePowerOpportunity(source, 'a', 'prescience'), null);
}
for (const mutate of [
(source: NativeBattlePowerGame) => { source.status = 'finished'; },
(source: NativeBattlePowerGame) => { source.phase = 5; },
(source: NativeBattlePowerGame) => { source.battle!.revealed = true; },
(source: NativeBattlePowerGame) => { source.battle!.event = ''; },
(source: NativeBattlePowerGame) => { source.battle!.preparation!.owner = 'e'; },
(source: NativeBattlePowerGame) => { source.battle!.preparation!.beneficiary = 'a'; },
(source: NativeBattlePowerGame) => { source.battle!.defender = 'missing'; },
]) {
const source = game(); mutate(source);
assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice'), null);
}
});
void test('native event/location quotation is pure and accepts original off-board battle locations', () => {
const source = game(); source.battle!.territory = 'homeworld:guild';
const before = structuredClone(source);
assert.equal(nativeBattlePowerOpportunity(source, 'bg', 'voice')!.territory, 'homeworld:guild');
assert.deepEqual(source, before);
assert.equal(nativeBattlePowerOpportunity(source, 'other', 'voice'), null);
});
+245
View File
@@ -0,0 +1,245 @@
import assert from 'node:assert/strict';
import { createGame, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import type { BattlePowerClaim, NativeBattlePower } from '../game/battle-power-question';
import type { MixedActionExpression } from '../game/mixed-action-question';
import type { TruthFact, TruthQuestion } from '../game/truthtrance';
import { beforeIxSpiceDraw, orderIxSpiceCards } from './fixture-ix-spice-cards';
import { nextStrongholdFactionsNativeStep } from './fixture-stronghold-factions';
import { truthTraitorDecisionTraitors } from './fixture-truth-traitor-decision';
import { actTruthTurnAction, truthTurnActionInventory, truthTurnActionPlayer,
type TruthTurnActionStep } from './fixture-truth-turn-action';
import type { TruthTurnCombatLeaf } from './fixture-truth-turn-combat';
export type TruthTurnPowerLeaf = TruthTurnCombatLeaf |
{ kind: 'battlePower'; battle: string; territory: string; claim: BattlePowerClaim };
export type TruthTurnPowerExpression = MixedActionExpression<TruthTurnPowerLeaf>;
export type TruthTurnPowerStep = TruthTurnActionStep;
export const actTruthTurnPower = actTruthTurnAction;
export const truthTurnPowerPlayer = truthTurnActionPlayer;
export const truthTurnPowerInventory = truthTurnActionInventory;
export const truthTurnPowerTraitors = truthTraitorDecisionTraitors;
export const truthTurnPowerClean = (game: Game) => !game.truthtrance && !game.response && !game.phaseOpening &&
!game.decision && !game.pendingTreacheryDiscard && !game.pendingShipment && !game.pendingRevival;
export const truthTurnPowerFact = (fact: TruthFact): TruthTurnPowerExpression => ({ kind: 'fact', fact });
export const truthTurnPowerPayment = (recipient: string, minimum = 2): TruthTurnPowerExpression =>
({ kind: 'action', action: { kind: 'payment', recipient, minimum } });
export const truthTurnPowerAction = (battle: string, territory: string, claim: BattlePowerClaim): TruthTurnPowerExpression =>
({ kind: 'action', action: { kind: 'battlePower', battle, territory, claim: structuredClone(claim) } });
export const truthTurnPowerGroup = (kind: 'and' | 'or', ...terms: TruthTurnPowerExpression[]): TruthTurnPowerExpression =>
({ kind, terms });
/** Ordinary native continuation. A requested field is answered with an actual
* legal original unarmed zero-dial plan element; caller-owned private plans
* and Truthtrance questions are never submitted by this helper. */
export function nextTruthTurnPowerNative(game: Game): TruthTurnPowerStep | null {
if (game.truthtrance) return null;
if (game.status === 'setup' && game.setupStage === 'prediction')
return { actor: 'beneGesserit', action: { type: 'predict', faction: 'atreides', turn: 10 } };
if (truthTurnPowerClean(game) && game.battle?.preparation?.kind === 'prescienceAnswer') {
const owner = game.battle.preparation.owner, field = game.battle.prescience!.field;
const leader = truthTurnPowerPlayer(game, owner).leaders.find(disc => !disc.dead && !disc.usedAt); assert.ok(leader);
return { actor: owner, action: { type: 'prescienceAnswer', value: field === 'dial' ? 0 : field === 'leader' ? leader.id : null } };
}
return nextStrongholdFactionsNativeStep(game);
}
export function nextTruthTurnPower(game: Game): TruthTurnPowerStep | 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 nextTruthTurnPowerNative(game);
}
export function advanceTruthTurnPower(game: Game, until: (state: Game) => boolean,
actions: TruthTurnPowerStep[] = [], nativeOnly = false): Game {
for (let count = 0; count < 1800; count++) {
if (until(game)) return game;
assert.notEqual(game.status, 'finished', 'The genuine power source ended before its native boundary');
const next = nativeOnly ? nextTruthTurnPowerNative(game) : nextTruthTurnPower(game);
assert.ok(next, 'Supply the actual private plan or Truthtrance control');
game = actTruthTurnPower(game, next.actor, next.action, actions);
}
throw Error('The original native power chronology did not reach its finite boundary');
}
export function settleTruthTurnPower(game: Game, actions: TruthTurnPowerStep[] = []): Game {
return advanceTruthTurnPower(game, truthTurnPowerClean, actions, true);
}
/** Original public Basic A/BG/E base33. Select unopened deal/lot/Spice
* positions and scalar lotteries only. All setup, auction, Nexus consent,
* paid shipment and battle preparation are actual engine actions. */
export function createTruthTurnPowerFixture(options: {
power?: NativeBattlePower; remote?: boolean; window?: NativeBattlePower;
} = {}) {
const power = options.power ?? 'voice', remote = options.remote ?? false;
const atreides = 'atreides', bg = 'beneGesserit', emperor = 'emperor';
const target = power === 'voice' ? bg : atreides;
const asker = remote ? (power === 'voice' ? atreides : bg) : emperor;
const recipient = asker, beneficiary = remote ? emperor : target;
let game = createGame('TRUTHTURNPOWER', newPlayer(atreides, 'Atreides', 'atreides'), false);
joinGame(game, newPlayer(bg, 'Bene Gesserit', 'beneGesserit'));
joinGame(game, newPlayer(emperor, 'Emperor', 'emperor'));
const initial = structuredClone(game), actions: TruthTurnPowerStep[] = [];
const act = (actor: string, action: Action) => { game = actTruthTurnPower(game, actor, action, actions); };
const reach = (until: (state: Game) => boolean) => { game = advanceTruthTurnPower(game, until, actions, true); };
for (const player of game.players) act(player.id, { type: 'ready' });
const lobby = structuredClone(game); act(game.host, { type: 'start' });
const setup = structuredClone(game); assert.equal(game.status, 'setup'); assert.equal(game.deck.length, 33);
assert.ok(game.players.every(player => player.hand.length === 0));
act(bg, { type: 'predict', faction: atreides, turn: 10 });
const originalCards = truthTurnPowerInventory(game), originalTraitors = truthTurnPowerTraitors(game);
assert.equal(originalTraitors.length, 15);
const truth = game.deck.find(card => card.effect === 'truthtrance');
const shield = game.deck.find(card => card.kind === 'shield');
const projectile = game.deck.find(card => card.kind === 'projectile');
const karama = game.deck.find(card => card.effect === 'karama');
const poison = game.deck.find(card => card.kind === 'poison');
const snooper = game.deck.find(card => card.kind === 'snooper');
assert.ok(truth && shield && projectile && karama && poison && snooper);
const ordinary = [shield, projectile], deal = game.players.map(player =>
player.id === asker ? truth.id : ordinary.shift()!.id);
const dealOrder = [...deal, karama.id, poison.id, snooper.id].map((id, position) => {
const from = game.deck.findIndex(card => card.id === id); assert.ok(from >= position);
[game.deck[position], game.deck[from]] = [game.deck[from], game.deck[position]];
return { position, from, card: id };
});
const beforeStartingDeal = structuredClone(game); reach(state => state.status === 'playing');
const afterSetup = structuredClone(game);
assert.deepEqual(truthTurnPowerPlayer(game, asker).hand.map(card => card.id), [truth.id]);
assert.deepEqual(truthTurnPowerPlayer(game, bg).forces, { 'polar_sink:0': 1 });
assert.deepEqual(truthTurnPowerPlayer(game, atreides).forces, { 'arrakeen:10': 10 });
const originalSpice = game.spiceDeck.map(card => JSON.stringify(card)).sort();
reach(beforeIxSpiceDraw); const spiceOrder = orderIxSpiceCards(game, ['land', 'land']);
reach(state => state.phase === 3 && truthTurnPowerClean(state) && !!state.auction);
assert.equal(game.auction!.cards[0].id, karama.id);
reach(state => state.phase === 3 && truthTurnPowerClean(state) && state.auction?.active === asker);
const beforePurchase = structuredClone(game), purchaseAt = actions.length;
act(asker, { type: 'bid', amount: 1 });
reach(state => truthTurnPowerClean(state) && truthTurnPowerPlayer(state, asker).hand.some(card => card.id === karama.id));
const purchase = { before: beforePurchase, steps: structuredClone(actions.slice(purchaseAt)), after: structuredClone(game) };
assert.ok(truthTurnPowerPlayer(game, asker).hand.some(card => card.id === karama.id));
let beforeNexus: Game | null = null, nexusActions: TruthTurnPowerStep[] = [];
let nexusSpiceOrder: ReturnType<typeof orderIxSpiceCards> = [];
if (remote) {
reach(state => state.turn === 2 && beforeIxSpiceDraw(state));
nexusSpiceOrder = orderIxSpiceCards(game, ['worm', 'land']);
reach(state => state.turn === 2 && state.phase === 1 && !!state.nexus && !state.spiceWindow &&
!state.spiceResolution && truthTurnPowerClean(state));
beforeNexus = structuredClone(game); const from = actions.length;
act(target, { type: 'alliance', target: emperor }); act(emperor, { type: 'alliance', target });
assert.equal(truthTurnPowerPlayer(game, target).ally, emperor);
assert.equal(truthTurnPowerPlayer(game, emperor).ally, target);
reach(state => !state.nexus && truthTurnPowerClean(state));
nexusActions = structuredClone(actions.slice(from));
}
const shipper = remote ? emperor : bg, territory = remote && power === 'prescience' ? 'imperial_basin' : 'arrakeen';
const sector = 10;
const shipmentAction: Action = { type: 'ship', territory, sector, amount: 1, elite: 0, allyPayment: 0 };
type Shipment = { before: Game; steps: TruthTurnPowerStep[]; after: Game };
let shipment: Shipment | null = null, opponentShipment: Shipment | null = null;
reach(state => state.turn === (remote ? 2 : 1) && state.phase === 5 && truthTurnPowerClean(state));
const shippers = game.order.filter(actor => actor === shipper || (remote && power === 'prescience' && actor === bg));
for (const actor of shippers) {
reach(state => state.phase === 5 && state.active === actor && truthTurnPowerClean(state));
const before = structuredClone(game), from = actions.length;
act(actor, shipmentAction); reach(truthTurnPowerClean);
const transition = { before, steps: structuredClone(actions.slice(from)), after: structuredClone(game) };
assert.equal(truthTurnPowerPlayer(before, actor).spice - truthTurnPowerPlayer(game, actor).spice,
territory === 'imperial_basin' ? 2 : 1);
if (actor === shipper) shipment = transition; else opponentShipment = transition;
act(actor, { type: 'endMovement' });
}
assert.ok(shipment); const beforeShipment = shipment.before;
reach(state => state.phase === 6 && !!state.active && !state.battle && truthTurnPowerClean(state));
const choice = viewGame(game, game.active!).battleChoices.find(candidate => candidate.territory === territory &&
[candidate.attacker, candidate.defender].includes(beneficiary)); assert.ok(choice);
act(choice.chooser, { type: 'chooseBattle', territory,
target: choice.chooser === choice.attacker ? choice.defender : choice.attacker });
reach(state => !!state.battle?.preparation && truthTurnPowerClean(state));
const beforeVoice = structuredClone(game), beforeVoiceActions = structuredClone(actions);
const window = options.window ?? power;
if (window === 'prescience') reach(state => state.battle?.preparation?.kind === 'prescience' && truthTurnPowerClean(state));
assert.ok(game.battle?.event); const event = game.battle.event;
assert.equal(game.battle.preparation?.kind, window);
assert.ok(truthTurnPowerPlayer(game, asker).hand.some(card => card.id === truth.id));
assert.equal(truthTurnPowerPlayer(game, target).ally === recipient, false);
assert.ok(!game.nexusCards && !game.homeworlds && !game.leaderSkills && !game.techTokens && !game.strongholdCards);
assert.deepEqual(truthTurnPowerInventory(game), originalCards);
assert.deepEqual(truthTurnPowerTraitors(game), originalTraitors);
const claim: BattlePowerClaim = power === 'voice' ? { kind: 'voiceCommand', cardType: 'shield', must: false }
: { kind: 'prescienceField', field: 'dial' };
const nativeAction: Action = power === 'voice' ? { type: 'voice', kind: 'shield', must: false }
: { type: 'prescience', field: 'dial' };
const payment: Action = { type: 'bribe', target: recipient, amount: 2 };
const mixed = truthTurnPowerGroup('and', truthTurnPowerAction(event, territory, claim), truthTurnPowerPayment(recipient));
const question = { kind: 'turnActions', target, turn: game.turn, mixed } satisfies TruthQuestion;
const spiceFact: TruthFact = { kind: 'spice', compare: 'eq', value: truthTurnPowerPlayer(game, target).spice };
return { game, source: structuredClone(game), initial, lobby, setup, beforeStartingDeal, afterSetup,
asker, target, recipient, power, remote, window, beneficiary, event, battle: event, territory,
truth: truth.id, karama: karama.id, claim, nativeAction, payment, mixed, question, spiceFact,
atreides, bg, emperor, shipper, beforeShipment, shipmentAction, shipment, opponentShipment, beforeNexus, nexusActions,
beforeVoice, beforeVoiceActions, purchase, dealOrder, spiceOrder, nexusSpiceOrder,
originalCards, originalTraitors, originalSpice, actions, sourceActions: actions, sourceActionCount: actions.length,
sourceCounts: { selected: actions.length, beforeVoice: beforeVoiceActions.length,
nativeKaramaPurchase: purchase.steps.length, nativeNexusConsentAndClose: nexusActions.length,
nativeShipment: shipment.steps.length, nativeOpponentShipment: opponentShipment?.steps.length ?? 0 },
sourceDisclosure: `Actual public Basic A/BG/E base33 setup, original prediction/Traitor choices and native first Storm. ` +
'Select only original unopened starting deal, ordinary Karama lot and Spice positions plus scalar lotteries. The asker really buys its original Karama for one spice. ' +
(remote ? 'Actually finish turn1, open the turn2 ordinary Worm Nexus, submit reciprocal native alliance invitations and close the Nexus. ' +
(power === 'voice' ? 'Emperor pays one spice for an original reserve in original Atreides Arrakeen. '
: 'BG and Emperor each pay two spice to ship one original reserve into Imperial Basin; the sanctuary Polar Sink never supplies a battle. ')
: 'BG pays one spice to ship one original reserve into Atreides original Arrakeen10. ') +
'Choose the resulting original battle and stop at native Voice or ordered Prescience. Counts derive from the actual action arrays. No hand, wallet, force, alliance, battle, declaration, power, clock, receipt or promise is installed.',
};
}
export type TruthTurnPowerFixture = ReturnType<typeof createTruthTurnPowerFixture>;
export function declareTruthTurnPower(f: TruthTurnPowerFixture, source = f.game, actions: TruthTurnPowerStep[] = []) {
let game = actTruthTurnPower(source, f.asker, { type: 'card', card: f.truth }, actions);
while (game.truthtrance?.stage === 'priority') {
const actor = game.players.find(player => !game.truthtrance!.passed.includes(player.id)); assert.ok(actor);
game = actTruthTurnPower(game, actor.id, { type: 'truthPass' }, actions);
}
assert.equal(game.truthtrance?.stage, 'ask'); return { game, actions };
}
export function askTruthTurnPower(game: Game, asker: string, target: string, mixed: TruthTurnPowerExpression,
actions: TruthTurnPowerStep[] = []): Game {
return actTruthTurnPower(game, asker, { type: 'truthAsk', question: {
kind: 'turnActions', target, turn: game.turn, mixed: structuredClone(mixed),
} }, actions);
}
export function answerTruthTurnPower(game: Game, target: string, answer: 'yes' | 'no' | 'unknown',
actions: TruthTurnPowerStep[] = []): Game {
return actTruthTurnPower(game, target, { type: 'truthAnswer', answer }, actions);
}
export function bindTruthTurnPower(f: TruthTurnPowerFixture, answer: 'yes' | 'no' = 'yes', mixed = f.mixed,
source = f.game, actions: TruthTurnPowerStep[] = []) {
const declared = declareTruthTurnPower(f, source, actions);
const asked = askTruthTurnPower(declared.game, f.asker, f.target, mixed, actions);
return { game: answerTruthTurnPower(asked, f.target, answer, actions), asked, declared, actions };
}
export function truthTurnPowerPromise(game: Game, target = game.turnActionPromises![0].player) {
const row = viewGame(game, target).turnActionPromises.find(candidate => candidate.player === target); assert.ok(row); return row;
}
export function finishTruthTurnPowerBattle(game: Game, f: TruthTurnPowerFixture,
actions: TruthTurnPowerStep[] = []): Game {
game = advanceTruthTurnPower(game, state => !state.battle || (!state.battle.preparation && truthTurnPowerClean(state)), actions, true);
if (!game.battle) return game;
for (const actor of [game.battle.attacker, game.battle.defender]) {
if (game.battle?.plans[actor]) continue;
const inspection = game.battle?.prescience;
const inspected = inspection && inspection.player !== actor;
const leader = truthTurnPowerPlayer(game, actor).leaders.find(disc => !disc.dead && !disc.usedAt); assert.ok(leader);
game = actTruthTurnPower(game, actor, { type: 'battlePlan',
leader: inspected && inspection.field === 'leader' ? inspection.value : leader.id,
dial: inspected && inspection.field === 'dial' ? inspection.value : 0, support: 0,
weapon: inspected && inspection.field === 'weapon' ? inspection.value : null,
defense: inspected && inspection.field === 'defense' ? inspection.value : null }, actions);
}
assert.ok(game.battle?.revealed);
return advanceTruthTurnPower(game, state => !state.battle || state.battle.event !== f.event, actions, true);
}
export function finishTruthTurnPower(game: Game, actions: TruthTurnPowerStep[] = []): Game {
const turn = game.turn;
return advanceTruthTurnPower(game, state => state.turn === turn + 1 && state.phase === 0 && truthTurnPowerClean(state), actions);
}
+142
View File
@@ -10,6 +10,7 @@ import { baseDeck } from '../game/cards';
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 type { BattlePowerClaim } from '../game/battle-power-question';
import { createRevivalOrder } from '../game/revival-order';
import { createRicheseAuction, projectRicheseAuction } from '../game/richese-auction';
import type { TurnActionLeaf } from '../game/turn-action-promises';
@@ -34,6 +35,8 @@ const planClaim = (claim: MixedBattleActionClaim = { kind: 'dial', compare: 'eq'
({ 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 powerClaim = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
({ kind: 'action', action: { kind: 'battlePower', battle: 'controlled-native-battle', territory: 'arrakeen', claim } });
const claim = (): Expression => ({ kind: 'or', terms: [payment(), { kind: 'and', terms: [fact(), physical('ship', 3), physical('move', 4, 'arrakeen')] }] });
function elements(node: ReactNode): Element[] {
@@ -114,6 +117,16 @@ function combat(revealed = false, next: Action = { type: 'battlePlan', dial: 3,
game.turnActionPromises[0].mixed = { kind: 'or', terms: [revealed ? traitorClaim() : planClaim(), payment()] };
return game;
}
function battlePower(power: 'voice' | 'prescience' = 'voice', next?: Action, allied = false): GameView {
const game = combat(false, next ?? (power === 'voice' ? { type: 'voice', kind: 'poison', must: false } : { type: 'prescience', field: 'weapon' }));
const me = game.players.find(player => player.id === 'p')!, other = game.players.find(player => player.id === 'a')!;
me.faction = power === 'voice' ? 'beneGesserit' : 'atreides'; other.faction = 'emperor';
game.battle!.attacker = allied ? 'a' : 'p'; game.battle!.defender = 'r';
if (allied) { me.ally = 'a'; other.ally = 'p'; }
game.battle!.preparation = { kind: power, owner: 'p', beneficiary: allied ? 'a' : 'p' };
game.turnActionPromises[0].mixed = { kind: 'or', terms: [powerClaim({ kind: power, use: true }), 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: [] });
@@ -492,6 +505,135 @@ void test('turn-action combat callbacks retain current battle ownership and raw
assert.match(html, /You answered Yes/); assert.doesNotMatch(html, /controlled-native-battle|Fill the quoted battle plan|compiled/);
});
void test('native battle-power editor uses public owned opportunities and repairs all four strict declaration forms', () => {
const game = battlePower(), e = editor(physical(), false, game);
for (const player of game.players) for (const key of ['hand', 'leaders', 'traitors', 'spice', 'tanks', 'forces'])
Object.defineProperty(player, key, { get() { throw Error(`No private ${key} power authoring read`); }, configurable: true });
for (const key of ['plans', 'prescience', 'voice', 'compliantPlan'])
Object.defineProperty(game.battle!, key, { get() { throw Error(`No hidden battle ${key} read`); }, configurable: true });
for (const key of ['combat', 'compiled', 'battlePowers'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} read`); }, configurable: true });
select(e.tree(), '1', 'action', 'battlePower'); assert.deepEqual(e.value, powerClaim()); assert.equal(e.error(), null);
select(e.tree(), '1', 'power-use', 'decline'); assert.deepEqual(e.value, powerClaim({ kind: 'voice', use: false }));
select(e.tree(), '1', 'power-claim', 'voiceCommand'); select(e.tree(), '1', 'power-card-type', 'shield');
select(e.tree(), '1', 'power-must', 'must'); assert.deepEqual(e.value, powerClaim({ kind: 'voiceCommand', cardType: 'shield', must: true }));
select(e.tree(), '1', 'power-card-type', ''); assert.ok(e.error()); assert.match(e.html(), /aria-invalid="true"/);
select(e.tree(), '1', 'power-card-type', 'projectile'); assert.equal(e.error(), null);
game.battle!.event = 'new-native-battle'; assert.ok(e.error()); assert.match(e.html(), /Choose an owned native opportunity/);
select(e.tree(), '1', 'power-battle', 'new-native-battle'); assert.equal(e.error(), null);
select(e.tree(), '1', 'power-claim', 'prescience'); assert.ok(e.error(), 'Voice ownership grants no Prescience entitlement.');
game.players.find(player => player.id === 'p')!.faction = 'atreides';
game.battle!.preparation = { kind: 'prescience', owner: 'p', beneficiary: 'p' };
select(e.tree(), '1', 'power-battle', 'new-native-battle'); assert.equal(e.error(), null);
select(e.tree(), '1', 'power-use', 'decline');
select(e.tree(), '1', 'power-claim', 'prescienceField'); select(e.tree(), '1', 'power-field', 'dial'); assert.equal(e.error(), null);
assert.match(e.html(), /requested field is not the opponent/);
for (const claim of [{ kind: 'voice', use: 'yes' }, { kind: 'voiceCommand', cardType: 'invented', must: true },
{ kind: 'prescienceField', field: 'weapon', value: 'private-card' }, { kind: 'prescienceField', field: 'support' }])
assert.ok(turnActionInputError({ kind: 'action', action: { kind: 'battlePower', battle: 'new-native-battle', territory: 'arrakeen', claim } }, game, 'p'));
});
void test('native power defaults follow actual owned ordering and share the full sixteen-leaf four-level editor budget', () => {
const game = battlePower('prescience'), e = editor(physical(), false, game);
select(e.tree(), '1', 'action', 'battlePower'); assert.deepEqual(e.value, powerClaim({ kind: 'prescience', use: true }));
const full = editor({ kind: 'or', terms: [powerClaim({ kind: 'prescience', use: true }), fact()] }, false, game);
for (let count = 3; count <= 16; count++) { click(control(full.tree(), 'add-action-1')); select(full.tree(), `1.${count}`, 'action', 'battlePower'); }
assert.equal(full.error(), null); assert.match(full.html(), /16 \/ 16 conditions/); assert.equal(control(full.tree(), 'add-action-1').props.disabled, true);
const deep = editor({ kind: 'and', terms: [fact(), powerClaim({ kind: 'prescienceField', field: 'leader' })] }, false, game);
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/);
const frozen = editor(powerClaim({ kind: 'prescienceField', field: 'leader' }), true, game);
select(frozen.tree(), '1', 'power-claim', 'voice'); select(frozen.tree(), '1', 'power-field', 'dial');
select(frozen.tree(), '1', 'power-battle', ''); assert.equal(frozen.changes, 0);
const future = battlePower('prescience'); future.players.find(player => player.id === 'a')!.faction = 'beneGesserit';
future.players.find(player => player.id === 'a')!.ally = 'r'; future.players.find(player => player.id === 'r')!.ally = 'a';
future.battle!.preparation = { kind: 'voice', owner: 'a', beneficiary: 'r' };
const futureEditor = editor(physical(), false, future); select(futureEditor.tree(), '1', 'action', 'battlePower');
assert.deepEqual(futureEditor.value, powerClaim({ kind: 'prescience', use: true })); assert.equal(futureEditor.error(), null);
});
void test('native power guides forward exact original allied-owner declarations and explicit declines without inspecting answers', () => {
for (const power of ['voice', 'prescience'] as const) for (const decline of [false, true]) {
const game = battlePower(power, decline ? { type: 'declineBattlePower' } : undefined, true), sent: Action[] = [];
for (const key of ['combat', 'compiled', 'battlePowers'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private ${key} guide read`); }, configurable: true });
for (const key of ['plans', 'prescience', 'voice', 'insight'])
Object.defineProperty(game.battle!, key, { get() { throw Error(`No inspected ${key} guide read`); }, configurable: true });
const button = guide({ game, busy: false, act(action) { sent.push(action); } }); assert.equal(button.props.disabled, false); press(button);
assert.equal(sent[0], game.turnActionGuide!.action); assert.equal(sent[0].turnActionPromiseEvent, event);
assert.equal(sent[0].turnActionPromisePhase, 6);
if (decline) assert.match(renderToStaticMarkup(button), /Explicitly decline native/);
else if (power === 'voice') { assert.equal(sent[0].kind, 'poison'); assert.equal(sent[0].must, false); }
else { assert.equal(sent[0].field, 'weapon'); assert.match(renderToStaticMarkup(button), /Request the weapon field/); }
}
});
void test('saved native power callbacks reject changed battle source, preparation, beneficiary, owner and modal priority', () => {
for (const power of ['voice', 'prescience'] as const) for (const change of [
(game: GameView) => { game.battle!.event = 'later-native-battle'; },
(game: GameView) => { game.battle!.territory = 'carthag'; },
(game: GameView) => { game.battle!.preparation = { ...game.battle!.preparation! }; },
(game: GameView) => { game.battle!.preparation!.owner = 'a'; },
(game: GameView) => { game.battle!.preparation!.beneficiary = 'r'; },
(game: GameView) => { game.battle!.preparation!.kind = 'prescienceAnswer'; },
(game: GameView) => { game.players.find(player => player.id === 'p')!.ally = null; },
(game: GameView) => { game.battle!.defender = 'p'; },
(game: GameView) => { game.battle!.revealed = true; },
(game: GameView) => { game.response = { kind: 'voice', owner: 'p', passed: [] }; },
(game: GameView) => { game.truthtrance = answering(6).truthtrance; },
(game: GameView) => { game.phase = 5; },
(game: GameView) => { game.turnActionGuide!.action.field = 'dial'; },
]) {
const game = battlePower(power, undefined, true), button = guide({ game, busy: false, act() { assert.fail('A stale native declaration cannot send'); } });
assert.equal(button.props.disabled, false); change(game); press(button);
}
const game = battlePower(), props: Props = { game, busy: false, act() { assert.fail('A stale callback cannot send'); } };
const button = guide(props); props.act = () => assert.fail('A replaced action callback cannot send'); press(button);
const busy = battlePower(), busyProps = { game: busy, busy: false, act() { assert.fail('A busy power cannot send'); } };
const busyButton = guide(busyProps); busyProps.busy = true; press(busyButton);
});
void test('native power guides refuse future stages, borrowed powers, inspected answers and forged packet fields', () => {
for (const [power, action, kind] of [
['voice', { type: 'voice', kind: 'invented', must: true }, 'voice'],
['voice', { type: 'voice', kind: 'poison', must: 'yes' }, 'voice'],
['voice', { type: 'voice', kind: 'poison', must: true, value: 'private-answer' }, 'voice'],
['voice', { type: 'declineBattlePower', field: 'weapon' }, 'voice'],
['voice', { type: 'voice', kind: 'poison', must: true }, 'nexusVoice'],
['prescience', { type: 'prescience', field: 'support' }, 'prescience'],
['prescience', { type: 'prescience', field: 'weapon', value: 'private-card' }, 'prescience'],
['prescience', { type: 'prescience', field: 'weapon' }, 'prescienceAnswer'],
['prescience', { type: 'declineBattlePower' }, 'nexusPrescienceAnswer'],
] as const) {
const game = battlePower(power, action); game.battle!.preparation!.kind = kind;
const button = guide({ game, busy: false, act() { assert.fail('An invalid power packet cannot send'); } });
assert.equal(button.props.disabled, true); press(button);
}
const future = battlePower('prescience'); future.players.find(player => player.id === 'a')!.faction = 'beneGesserit';
future.players.find(player => player.id === 'a')!.ally = 'r'; future.players.find(player => player.id === 'r')!.ally = 'a';
future.battle!.preparation = { kind: 'voice', owner: 'a', beneficiary: 'r' };
const button = guide({ game: future, busy: false, act() { assert.fail('Ordered future Prescience cannot send yet'); } });
assert.equal(button.props.disabled, true); press(button);
for (const power of ['voice', 'prescience'] as const) for (const decline of [false, true]) {
const game = battlePower(power, decline ? { type: 'declineBattlePower' } : undefined), sent: Action[] = [];
game.battle!.preparation!.blocked = 'Native use is currently blocked; explicit Decline remains available.';
const button = guide({ game, busy: false, act(action) { sent.push(action); } });
assert.equal(button.props.disabled, !decline); press(button); assert.equal(sent.length, decline ? 1 : 0);
}
});
void test('native power history keeps raw declarations distinct from effects and inspected values', () => {
const game = battlePower(); game.turnActionGuide = null; game.battle = null;
game.turnActionPromises[0].mixed = { kind: 'or', terms: [powerClaim({ kind: 'voiceCommand', cardType: 'shield', must: true }),
powerClaim({ kind: 'prescienceField', field: 'weapon' }), fact()] };
for (const key of ['combat', 'compiled', 'battlePowers', 'declarations', 'preparation'])
Object.defineProperty(game.turnActionPromises[0], key, { get() { throw Error(`No private history ${key} read`); }, configurable: true });
const html = markup(game); assert.match(html, /Voice/); assert.match(html, /Prescience/); assert.match(html, /You answered Yes/);
assert.match(html, /before later Karama prevention/); assert.match(html, /requested field is not the inspected answer or value/);
assert.match(html, /close at the actual native choice or when its original source expires/);
assert.doesNotMatch(html, /controlled-native-battle|battlePowers|declarations|private-card/);
});
void test('turn-action printed-role editor uses public names without holdings or private stages and retains the shared logical budget', () => {
const game = projected();
for (const player of game.players) for (const key of ['hand', 'spice', 'traitors', 'faceDancers', 'tanks', 'leaders', 'hajrAvailable'])
+266
View File
@@ -0,0 +1,266 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { botActions } from '../game/bots';
import { DIFFICULTIES } from '../game/bot-profiles';
import type { BattlePowerClaim } from '../game/battle-power-question';
import { viewGame, type Action, type Game } from '../game/engine';
import { truthFactAnswer } from '../game/truthtrance';
import {
actTruthTurnPower as act, advanceTruthTurnPower as advance,
askTruthTurnPower as ask, bindTruthTurnPower as bind, createTruthTurnPowerFixture as fixture,
declareTruthTurnPower as declare, finishTruthTurnPower as finish, finishTruthTurnPowerBattle as cleanup,
nextTruthTurnPowerNative as nativeStep, settleTruthTurnPower as settle,
truthTurnPowerAction as power, truthTurnPowerClean as clean, truthTurnPowerFact as fact,
truthTurnPowerGroup as group, truthTurnPowerInventory as inventory, truthTurnPowerPayment as payment,
truthTurnPowerPlayer as player, truthTurnPowerPromise as promise, truthTurnPowerTraitors as traitors,
type TruthTurnPowerFixture, type TruthTurnPowerStep,
} from './fixture-truth-turn-power';
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: TruthTurnPowerFixture) {
assert.equal(game.discard.filter(card => card.id === f.truth).length, 1);
assert.deepEqual(inventory(game), f.originalCards); assert.deepEqual(traitors(game), f.originalTraitors);
}
function receipt(game: Game, f: TruthTurnPowerFixture) {
const stage = game.turnActionPromises![0].combat?.powers?.[f.power]; assert.ok(stage); return stage;
}
const use = (f: TruthTurnPowerFixture, selected = true) => power(f.event, f.territory,
{ kind: f.power, use: selected });
void test('native own and remote power sources retain genuine original cards, native cash and action-array chronology', () => {
for (const remote of [false, true]) for (const selected of ['voice', 'prescience'] as const) {
const f = fixture({ power: selected, remote });
assert.equal(f.initial.status, 'lobby'); assert.equal(f.setup.status, 'setup');
assert.equal(f.setup.deck.length, 33); assert.equal(f.originalCards.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.actions.length);
assert.equal(f.sourceCounts.beforeVoice, f.beforeVoiceActions.length);
assert.equal(f.sourceCounts.nativeKaramaPurchase, f.purchase.steps.length);
assert.ok(f.purchase.steps.some(step => step.actor === f.asker && step.action.type === 'bid' && step.action.amount === 1));
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.truth));
assert.ok(player(f.game, f.asker).hand.some(card => card.id === f.karama));
assert.equal(f.game.advanced, false); assert.equal(f.game.nexusCards, undefined);
assert.equal(f.game.turn, remote ? 2 : 1); assert.equal(f.game.phase, 6);
assert.equal(f.game.battle?.event, f.event); assert.equal(f.game.battle?.preparation?.owner, f.target);
assert.equal(f.game.battle?.preparation?.beneficiary, f.beneficiary);
assert.equal(f.game.battle?.preparation?.kind, selected);
assert.equal(f.game.turnActionPromises?.length ?? 0, 0);
if (remote) {
assert.ok(f.beforeNexus?.nexus); assert.equal(f.nexusActions.filter(step => step.action.type === 'alliance').length, 2);
assert.equal(player(f.game, f.target).ally, f.emperor); assert.notEqual(f.target, f.beneficiary);
} else assert.equal(player(f.game, f.bg).spice, 4, 'The native BG Arrakeen reserve costs one real spice');
assert.deepEqual(inventory(f.game), f.originalCards); assert.deepEqual(traitors(f.game), f.originalTraitors);
}
});
void test('native exact Voice AND payment freezes the original spice fact and records actual declaration before its response', () => {
const f = fixture(), mixed = group('and', fact(f.spiceFact), power(f.event, f.territory, f.claim), payment(f.recipient));
let game = bind(f, 'yes', mixed).game;
assert.equal(receipt(game, f).closed, false); assert.equal(receipt(game, f).values, null);
game = act(game, f.target, f.payment); assert.equal(player(game, f.target).spice, 2);
assert.equal(truthFactAnswer(player(game, f.target), f.spiceFact), 'no');
rejected(game, f.target, { type: 'declineBattlePower' });
rejected(game, f.target, { type: 'voice', kind: 'shield', must: true });
game = act(game, f.target, f.nativeAction);
assert.equal(game.response?.kind, 'voice'); assert.equal(receipt(game, f).closed, true);
assert.deepEqual(receipt(game, f).values, [{ claim: f.claim, value: true }]);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.recipient).bribes, 2);
game = cleanup(game, f); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
});
void test('native requested Prescience field AND payment fulfills at the request without requiring or exposing its hidden answer', () => {
const f = fixture({ power: 'prescience' });
let game = bind(f).game; game = act(game, f.target, f.payment);
rejected(game, f.target, { type: 'declineBattlePower' });
rejected(game, f.target, { type: 'prescience', field: 'weapon' });
game = act(game, f.target, f.nativeAction);
assert.equal(game.response?.kind, 'prescience'); assert.equal(game.battle?.preparation?.kind, 'prescienceAnswer');
assert.equal(Object.hasOwn(game.battle!.prescience!, 'value'), false);
assert.deepEqual(receipt(game, f).values, [{ claim: f.claim, value: true }]);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.target).spice, 8);
game = cleanup(game, f); physical(game, f);
});
void test('native Voice OR payment preserves both manual routes without choosing a fixed branch', () => {
const f = fixture(), mixed = group('or', power(f.event, f.territory, f.claim), payment(f.recipient));
const bound = bind(f, 'yes', mixed).game;
let voiced = act(bound, f.target, f.nativeAction);
assert.equal(promise(voiced, f.target).fulfilled, true); assert.equal(player(voiced, f.recipient).bribes, 0);
let paid = act(bound, f.target, f.payment);
const terminal = structuredClone(receipt(paid, f));
paid = act(paid, f.target, { type: 'declineBattlePower' });
assert.deepEqual(receipt(paid, f), terminal, 'A later native choice does not rewrite an already fulfilled answer');
assert.equal(promise(paid, f.target).fulfilled, true);
voiced = cleanup(voiced, f); paid = cleanup(paid, f);
physical(voiced, f); physical(paid, f);
});
void test('whole native use AND payment No permits either earlier action and caps only actual completion', () => {
const f = fixture(), mixed = group('and', use(f), payment(f.recipient));
for (const first of ['payment', 'voice'] as const) {
let game = bind(f, 'no', mixed).game;
if (first === 'payment') {
game = act(game, f.target, f.payment); rejected(game, f.target, f.nativeAction);
game = act(game, f.target, { type: 'declineBattlePower' });
} else {
game = settle(act(game, f.target, f.nativeAction)); rejected(game, f.target, f.payment);
game = act(game, f.target, { type: 'bribe', target: f.recipient, amount: 1 });
assert.equal(player(game, f.recipient).bribes, 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('same-decision Voice use and command are correlated while contradictory use and explicit Decline have no Yes', () => {
const f = fixture(), command = power(f.event, f.territory, f.claim);
const impossible = group('and', use(f), use(f, false));
const asked = ask(declare(f).game, f.asker, f.target, impossible);
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
rejected(asked, f.target, { type: 'truthAnswer', answer: 'yes' });
const mixed = group('and', use(f), command, payment(f.recipient));
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
game = act(game, f.target, f.nativeAction);
const values = receipt(game, f).values; assert.ok(values);
assert.equal(values.length, 2); assert.ok(values.every(entry => entry.value));
assert.equal(promise(game, f.target).fulfilled, true); game = cleanup(game, f); physical(game, f);
});
void test('one native Prescience request cannot satisfy two different fields but either exact OR field remains manual', () => {
const f = fixture({ power: 'prescience' });
const dial = power(f.event, f.territory, { kind: 'prescienceField', field: 'dial' });
const weapon = power(f.event, f.territory, { kind: 'prescienceField', field: 'weapon' });
const asked = ask(declare(f).game, f.asker, f.target, group('and', dial, weapon));
assert.deepEqual(viewGame(asked, f.target).truthTurnActionAnswers, ['no']);
const mixed = group('and', group('or', dial, weapon), payment(f.recipient));
for (const field of ['dial', 'weapon'] as const) {
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
game = act(game, f.target, { type: 'prescience', field });
const values = receipt(game, f).values; assert.ok(values);
assert.equal(values.filter(entry => entry.value).length, 1);
assert.equal(promise(game, f.target).fulfilled, true); game = cleanup(game, f); physical(game, f);
}
});
void test('native explicit Decline is a real future declaration for either power rather than an absent effect', () => {
for (const selected of ['voice', 'prescience'] as const) {
const f = fixture({ power: selected }), claim: BattlePowerClaim = { kind: selected, use: false };
const mixed = group('and', power(f.event, f.territory, claim), payment(f.recipient));
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, f.payment);
rejected(game, f.target, f.nativeAction); game = act(game, f.target, { type: 'declineBattlePower' });
assert.deepEqual(receipt(game, f).values, [{ claim, value: true }]);
assert.equal(receipt(game, f).closed, true); assert.equal(promise(game, f.target).fulfilled, true);
game = cleanup(game, f); physical(game, f);
}
});
void test('actual Voice and Prescience receipts survive later printed Karama prevention without counting a successful effect', () => {
for (const selected of ['voice', 'prescience'] as const) {
const f = fixture({ power: selected }); let game = bind(f).game;
game = act(game, f.target, f.payment); game = act(game, f.target, f.nativeAction);
const evidence = structuredClone(receipt(game, f)); assert.equal(game.response?.kind, selected);
assert.equal(promise(game, f.target).fulfilled, true);
game = act(game, f.asker, { type: 'card', card: f.karama, mode: 'cancel' });
game = settle(game); assert.equal(game.battle?.[selected], undefined);
assert.deepEqual(receipt(game, f), evidence); assert.equal(promise(game, f.target).fulfilled, true);
assert.equal(game.discard.filter(card => card.id === f.karama).length, 1);
game = cleanup(game, f); assert.deepEqual(receipt(game, f), evidence); physical(game, f);
}
});
void test('a declined native power leaves its payment OR alive after battle cleanup through late Mentat', () => {
const f = fixture(), mixed = group('or', use(f), payment(f.recipient));
let game = bind(f, 'yes', mixed).game; game = act(game, f.target, { type: 'declineBattlePower' });
assert.deepEqual(receipt(game, f).values, [{ claim: { kind: 'voice', use: true }, value: false }]);
assert.ok(!promise(game, f.target).fulfilled); const evidence = structuredClone(receipt(game, f));
game = cleanup(game, f); assert.equal(game.battle, null); assert.deepEqual(receipt(game, f), evidence);
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(receipt(game, f), evidence); physical(game, f);
});
void test('remote ordinary allies declare their own Voice or Prescience for the original allied beneficiary', () => {
for (const selected of ['voice', 'prescience'] as const) {
const f = fixture({ power: selected, remote: true }); let game = bind(f).game;
assert.equal(receipt(game, f).beneficiary, f.emperor); assert.notEqual(f.target, f.emperor);
assert.equal(receipt(game, f).target, f.asker); game = act(game, f.target, f.payment);
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide); assert.equal(guide.action.type, selected);
rejected(game, f.beneficiary, guide.action); game = act(game, f.target, guide.action);
assert.equal(promise(game, f.target).fulfilled, true); assert.equal(player(game, f.recipient).bribes, 2);
game = cleanup(game, f); assert.equal(promise(game, f.target).fulfilled, true); physical(game, f);
}
});
void test('ordered native Prescience waits for the foreign Voice choice before exposing an executable field guide', () => {
const f = fixture({ power: 'prescience', window: 'voice' });
assert.equal(f.game.battle?.preparation?.owner, f.bg);
let game = bind(f).game; game = act(game, f.target, f.payment);
assert.equal(viewGame(game, f.target).turnActionGuide, null);
rejected(game, f.target, f.nativeAction);
game = act(game, f.bg, { type: 'declineBattlePower' });
const guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.action.type, 'prescience'); assert.equal(guide.action.field, 'dial');
game = act(game, f.target, guide.action); assert.equal(promise(game, f.target).fulfilled, true);
game = cleanup(game, f); physical(game, f);
});
void test('raw native power ownership and event markers reject stale questions, prior declarations and stale executable guides', () => {
for (const selected of ['voice', 'prescience'] as const) {
const f = fixture({ power: selected }), declared = declare(f);
for (const mixed of [power('other-battle', f.territory, f.claim), power(f.event, 'carthag', f.claim)])
rejected(declared.game, f.asker, { type: 'truthAsk', question: { kind: 'turnActions', target: f.target, turn: f.game.turn, mixed } });
rejected(declared.game, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: selected === 'voice' ? f.atreides : f.bg,
turn: f.game.turn, mixed: power(f.event, f.territory, f.claim),
} });
rejected(declared.game, f.asker, { type: 'truthAsk', question: {
kind: 'turnActions', target: f.target, turn: f.game.turn, mixed: { kind: 'action', action: {
kind: 'battlePower', battle: f.event, territory: f.territory, claim: { ...f.claim, value: 0 },
} },
} });
let prior = settle(act(f.game, f.target, f.nativeAction)); prior = declare(f, prior).game;
rejected(prior, f.asker, { type: 'truthAsk', question: f.question });
let game = bind(f).game; game = act(game, f.target, f.payment);
const row = promise(game, f.target), guide = viewGame(game, f.target).turnActionGuide; assert.ok(guide);
assert.equal(guide.event, row.event); assert.equal(guide.action.turnActionPromiseEvent, row.event);
assert.equal(guide.action.turnActionPromisePhase, 6);
assert.ok(Object.keys(row).every(key => ['event', 'player', 'asker', 'turn', 'mixed', 'answer', 'fulfilled', 'released'].includes(key)));
assert.deepEqual(viewGame(game, f.asker).turnActionPromises, []); assert.equal(viewGame(game, f.asker).turnActionGuide, null);
rejected(game, f.asker, guide.action);
rejected(game, f.target, { ...guide.action, turnActionPromiseEvent: 'stale-event' });
rejected(game, f.target, { ...guide.action, turnActionPromisePhase: 5 });
const packet: Action = JSON.parse(JSON.stringify(guide.action)); game = act(game, f.target, packet);
rejected(game, f.target, packet); assert.equal(promise(game, f.target).fulfilled, true);
game = cleanup(game, f); rejected(game, f.target, packet); physical(game, f);
}
});
void test('all four minimal profiles answer and submit exact native Voice or Prescience guide packets through fulfillment', () => {
for (const selected of ['voice', 'prescience'] as const) {
const f = fixture({ power: selected }), asked = ask(declare(f).game, f.asker, f.target, f.mixed);
for (const difficulty of DIFFICULTIES) {
let game = structuredClone(asked); const actions: TruthTurnPowerStep[] = [];
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, `${selected}/${difficulty}`); assert.deepEqual(action, guide.action);
game = act(game, f.target, action, actions);
} else {
const next = nativeStep(game); assert.ok(next, `${selected}/${difficulty}`);
game = act(game, next.actor, next.action, actions);
}
}
assert.equal(promise(game, f.target).fulfilled, true, `${selected}/${difficulty}`);
assert.ok(actions.some(step => step.actor === f.target && step.action.type === selected));
assert.ok(actions.some(step => step.actor === f.target && step.action.type === 'bribe'));
game = cleanup(game, f); physical(game, f);
}
}
});
+183
View File
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { battlePowerOf, type BattlePowerClaim, type BattlePowerDeclaration } from '../game/battle-power-question';
import {
compileMixedActionExpression,
type MixedActionExpression,
@@ -10,6 +11,7 @@ import {
TurnActionError,
closeTurnActionBidding,
closeTurnActionCombat,
closeTurnActionBattlePower,
closeTurnActionPayments,
closeTurnActionRevival,
closeTurnActionTransport,
@@ -18,6 +20,7 @@ import {
parseTurnActionLeaf,
recordTurnActionBid,
recordTurnActionCardPlay,
recordTurnActionBattlePower,
recordTurnActionPlan,
recordTurnActionTraitor,
recordTurnActionMovement,
@@ -51,6 +54,8 @@ const plan = (claim: TurnActionPlanValue['claim'] = { kind: 'dial', compare: 'gt
const call = (call = true, battle = 'battle:1'): Expression =>
({ kind: 'action', action: { kind: 'traitorCall', battle, territory: 'carthag', call } });
const card = (effect: 'hajr' | 'ghola' = 'hajr'): Expression => ({ kind: 'action', action: { kind: 'cardPlay', effect } });
const power = (claim: BattlePowerClaim = { kind: 'voice', use: true }): Expression =>
({ kind: 'action', action: { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim } });
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,
@@ -88,6 +93,17 @@ function cards(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActi
hajr: { played: false, closed: false }, ghola: { played: false, closed: false },
} };
}
function powers(mixed: Expression, answer = true, id = 'turn-action:1'): TurnActionPromise {
const row: TurnActionPromise = { ...promise(mixed, answer, id), combat: { event: 'battle:1', territory: 'carthag',
plan: null, traitor: null, powers: { voice: null, prescience: null } } };
const visit = (node: Expression): void => {
if (node.kind === 'action' && node.action.kind === 'battlePower')
row.combat!.powers![battlePowerOf(node.action.claim)] = { beneficiary: 'respondent', target: 'other', closed: false, values: null };
else if (node.kind !== 'action' && node.kind !== 'fact') node.terms.forEach(visit);
};
visit(mixed);
return row;
}
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) {
@@ -1068,3 +1084,170 @@ void test('payment-only closure keeps whole No binding through later Ghola and v
assert.throws(() => closeTurnActionPayments([malformed as unknown as TurnActionPromise], 'respondent', 1), TurnActionError);
validateTurnActionPromises(closed, seats);
});
void test('battle-power raw atoms and folded alternatives require exact original per-power stages', () => {
const original = { kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim: { kind: 'voice', use: true } };
assert.deepEqual(parseTurnActionLeaf(original), original);
for (const value of [{ ...original, owner: 'respondent' }, { ...original, answer: true }, { ...original, battle: '' },
{ ...original, claim: { kind: 'prescienceField', field: 'dial', value: 4 } }])
assert.throws(() => parseTurnActionLeaf(value), TurnActionError);
assert.throws(() => turnActionValue(promise(power())), TurnActionError);
assert.throws(() => turnActionValue(promise(group('or', fact(), power()))), TurnActionError);
const old = combat(plan());
validateTurnActionPromises([old], seats);
assert.equal('powers' in closeTurnActionCombat([old], 1, 'battle:1')[0].combat!, false);
});
void test('whole power AND keeps exact native source context and one declaration evaluates all original predicates', () => {
const command: BattlePowerClaim = { kind: 'voiceCommand', cardType: 'projectile', must: true };
const row = powers(group('and', power(), power(command), pay()));
assert.deepEqual(branches(row), [[
{ kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim: { kind: 'voice', use: true },
owner: 'respondent', beneficiary: 'respondent', target: 'other', answer: true },
{ kind: 'battlePower', battle: 'battle:1', territory: 'carthag', claim: command,
owner: 'respondent', beneficiary: 'respondent', target: 'other', answer: true }, signedPay(),
]]);
const declared = recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag',
{ power: 'voice', use: true, cardType: 'projectile', must: true });
assert.deepEqual(declared[0].combat!.powers!.voice!.values, [
{ claim: { kind: 'voice', use: true }, value: true }, { claim: command, value: true },
]);
assert.deepEqual(branches(declared[0]), [[signedPay()]]);
assert.equal(declared[0].fulfilled, false);
assert.equal(recordTurnActionPayment(declared, 'respondent', 1, payment())[0].fulfilled, true);
assert.equal(row.combat!.powers!.voice!.values, null);
});
void test('whole OR retains actual Voice and Prescience alternatives without storing a selected route', () => {
const mixed = group('or', power({ kind: 'voiceCommand', cardType: 'poison', must: false }),
power({ kind: 'prescienceField', field: 'weapon' }));
const row = powers(mixed);
assert.equal(branches(row).length, 2);
const voice = recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag',
{ power: 'voice', use: true, cardType: 'poison', must: false });
assert.equal(voice[0].fulfilled, true);
const prescience = recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag',
{ power: 'prescience', use: true, field: 'weapon' });
assert.equal(prescience[0].fulfilled, true);
assert.deepEqual(prescience[0].mixed, mixed);
const otherVoice = recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag',
{ power: 'voice', use: true, cardType: 'projectile', must: true });
assert.equal(otherVoice[0].fulfilled, false);
assert.equal(branches(otherVoice[0]).length, 1);
});
void test('explicit power Decline differs from absent opportunity and preserves requested-field semantics', () => {
const mixed = group('or', power({ kind: 'prescience', use: false }), pay());
const row = powers(mixed);
const declined = recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag', { power: 'prescience', use: false });
assert.equal(declined[0].fulfilled, true);
assert.deepEqual(declined[0].combat!.powers!.prescience!.values,
[{ claim: { kind: 'prescience', use: false }, value: true }]);
const absent = closeTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag', 'prescience');
assert.equal(absent[0].fulfilled, false);
assert.equal(absent[0].combat!.powers!.prescience!.values, null);
assert.deepEqual(branches(absent[0]), [[signedPay()]]);
const field = powers(group('and', power({ kind: 'prescienceField', field: 'dial' }), pay()));
const requested = recordTurnActionBattlePower([field], 'respondent', 1, 'battle:1', 'carthag', { power: 'prescience', use: true, field: 'dial' });
assert.deepEqual(branches(requested[0]), [[signedPay()]]);
});
void test('whole power No applies De Morgan and actual declarations or later payments reject aggregate violations atomically', () => {
const no = powers(group('and', power(), pay()), false);
assert.equal(branches(no).length, 2);
const declaration: BattlePowerDeclaration = { power: 'voice', use: true, cardType: 'shield', must: false };
const declared = recordTurnActionBattlePower([no], 'respondent', 1, 'battle:1', 'carthag', declaration);
assert.deepEqual(branches(declared[0]), [[signedPay('recipient', 3, 0, false)]]);
const before = structuredClone(declared);
assert.throws(() => recordTurnActionPayment(declared, 'respondent', 1, payment()), TurnActionError);
assert.deepEqual(declared, before);
const paid = recordTurnActionPayment([no], 'respondent', 1, payment());
assert.throws(() => recordTurnActionBattlePower(paid, 'respondent', 1, 'battle:1', 'carthag', declaration), TurnActionError);
const yes = powers(group('and', power(), pay()), true, 'turn-action:2');
const plainNo = powers(power(), false, 'turn-action:3');
assert.throws(() => recordTurnActionBattlePower([yes, plainNo], 'respondent', 1, 'battle:1', 'carthag', declaration), TurnActionError);
assert.equal(yes.combat!.powers!.voice!.values, null);
assert.equal(plainNo.combat!.powers!.voice!.values, null);
});
void test('closing one power preserves the open plan, Traitor and other power rather than expiring the entire battle', () => {
const row = powers(group('or', power(), power({ kind: 'prescience', use: true }), plan(), call(), pay()));
row.combat!.plan = { closed: false, values: null };
row.combat!.traitor = { beneficiary: 'respondent', target: 'other', leader: 'other-0', closed: false, call: null };
const closed = closeTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag', 'voice')[0];
assert.deepEqual(closed.combat!.powers!.voice, { beneficiary: 'respondent', target: 'other', closed: true, values: null });
assert.equal(closed.combat!.powers!.prescience, row.combat!.powers!.prescience);
assert.equal(closed.combat!.plan, row.combat!.plan);
assert.equal(closed.combat!.traitor, row.combat!.traitor);
assert.equal(branches(closed).length, 4);
const allClosed = closeTurnActionCombat([closed], 1, 'battle:1')[0];
assert.equal(allClosed.combat!.powers!.prescience!.closed, true);
assert.equal(allClosed.combat!.plan!.closed, true);
assert.equal(allClosed.combat!.traitor!.closed, true);
assert.deepEqual(branches(allClosed), [[signedPay()]]);
});
void test('original power source and owner bind receipts once before any later prevention or alternative declaration', () => {
const row = powers(group('and', power(), pay()));
const declaration: BattlePowerDeclaration = { power: 'voice', use: true, cardType: 'projectile', must: true };
for (const [player, turn, event, territory] of [['other', 1, 'battle:1', 'carthag'], ['respondent', 2, 'battle:1', 'carthag'],
['respondent', 1, 'other-battle', 'carthag'], ['respondent', 1, 'battle:1', 'homeworld:guild']] as const)
assert.equal(recordTurnActionBattlePower([row], player, turn, event, territory, declaration)[0], row);
const actual = recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag', declaration)[0];
assert.equal(recordTurnActionBattlePower([actual], 'respondent', 1, 'battle:1', 'carthag', { power: 'voice', use: false })[0], actual);
assert.equal(closeTurnActionBattlePower([actual], 'respondent', 1, 'battle:1', 'carthag', 'voice')[0], actual);
const expired = closeTurnActionCombat([actual], 1, 'battle:1')[0];
assert.deepEqual(expired.combat!.powers!.voice!.values, actual.combat!.powers!.voice!.values);
const fresh = powers(group('and', power(), pay()), true, 'turn-action:2');
assert.equal(fresh.combat!.powers!.voice!.values, null);
validateTurnActionPromises([actual], seats);
});
void test('unresolved power expiry releases without inventing Decline while terminal and actual receipts remain immutable', () => {
const pending = powers(power({ kind: 'voice', use: false }));
const expired = finishTurnActionTurn([pending], 'respondent', 1)[0];
assert.equal(expired.released, true);
assert.equal(expired.combat!.powers!.voice!.closed, false);
assert.equal(expired.combat!.powers!.voice!.values, null);
const no = powers(power(), false, 'turn-action:2');
const closedNo = closeTurnActionBattlePower([no], 'respondent', 1, 'battle:1', 'carthag', 'voice')[0];
assert.equal(closedNo.fulfilled, true);
const used = recordTurnActionBattlePower([powers(power(), true, 'turn-action:3')], 'respondent', 1, 'battle:1', 'carthag',
{ power: 'voice', use: true, cardType: 'poison', must: false })[0];
const terminal = [expired, closedNo, used];
for (const transform of [
(rows: TurnActionPromise[]) => closeTurnActionCombat(rows, 1, 'battle:1'),
(rows: TurnActionPromise[]) => closeTurnActionBattlePower(rows, 'respondent', 1, 'battle:1', 'carthag', 'voice'),
(rows: TurnActionPromise[]) => recordTurnActionBattlePower(rows, 'respondent', 1, 'battle:1', 'carthag', { power: 'voice', use: false }),
]) assert.ok(transform(terminal).every((record, index) => record === terminal[index]));
validateTurnActionPromises(terminal, seats);
});
void test('native power stages reject premature, incomplete, duplicate and wrong-power predicate receipts', () => {
const row = powers(group('and', power(), pay()));
const stage = row.combat!.powers!.voice!;
for (const powerStages of [undefined, null, {}, { voice: null, prescience: null },
{ ...row.combat!.powers, hidden: true },
{ ...row.combat!.powers, voice: { ...stage, values: [{ claim: { kind: 'voice', use: true }, value: true }] } },
{ ...row.combat!.powers, voice: { ...stage, closed: true, values: [] } },
{ ...row.combat!.powers, voice: { ...stage, closed: true, values: [
{ claim: { kind: 'voice', use: true }, value: true }, { claim: { kind: 'voice', use: true }, value: true }] } },
{ ...row.combat!.powers, voice: { ...stage, closed: true, values: [{ claim: { kind: 'prescience', use: true }, value: true }] } },
{ ...row.combat!.powers, voice: { ...stage, beneficiary: 'other' } },
{ ...row.combat!.powers, voice: { ...stage, closed: 'yes' } }])
assert.throws(() => turnActionValue({ ...row, combat: { ...row.combat!, powers: powerStages } } as TurnActionPromise), TurnActionError);
const foreign = { ...row, combat: { ...row.combat!, powers: { ...row.combat!.powers!, voice: { ...stage, target: 'missing' } } } };
assert.throws(() => validateTurnActionPromises([foreign], seats), TurnActionError);
assert.throws(() => recordTurnActionBattlePower([row], 'respondent', 1, 'battle:1', 'carthag',
{ power: 'prescience', use: true, field: 'weapon', value: 'Lasgun' } as never), TurnActionError);
});
void test('power predicates deduplicate only exact original clauses and retain the shared sixteen-leaf limit', () => {
const repeated = powers(group('and', power(), power(), pay()));
assert.equal(branches(repeated)[0].length, 2);
const actual = recordTurnActionBattlePower([repeated], 'respondent', 1, 'battle:1', 'carthag',
{ power: 'voice', use: true, cardType: 'shield', must: true });
assert.equal(actual[0].combat!.powers!.voice!.values!.length, 1);
assert.doesNotThrow(() => turnActionValue(powers(group('or', ...Array.from({ length: 16 }, () => power())))));
assert.throws(() => turnActionValue(powers(group('or', ...Array.from({ length: 17 }, () => power())))), TurnActionError);
});