Combine current battle decisions with turn commitments
This commit is contained in:
1 parent
1a79fd3131
commit
f76c8dfb93
15 files changed
+1380
-66
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user