169 lines
12 KiB
TypeScript
169 lines
12 KiB
TypeScript
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);
|
|
}
|