Files
Dune/tests/fixture-stronghold-factions.ts
T

428 lines
25 KiB
TypeScript

import assert from 'node:assert/strict';
import { applyAction, createGame, initializeStrongholdFactionsGameForAudit, joinGame, newPlayer, viewGame, type Action, type Game } from '../game/engine';
import { botActions } from '../game/bots';
import { MOBILE_LOCATION, MOBILE_STRONGHOLD, territory } from '../game/board';
import { isAuditorLeader, type Card } from '../game/cards';
import type { FactionId } from '../game/catalog';
import { STRONGHOLD_CARDS, strongholdControllers, type StrongholdId } from '../game/stronghold-cards';
import { quoteBattleResolution, type BattleResolutionQuote, type ResolutionCombatant } from '../game/battle-resolution-quote';
export type StrongholdFactionsNativeStep = { actor: string; action: Action };
export type StrongholdFactionsFixtureOptions = {
initial?: Game;
seatIds?: string[];
kind?: StrongholdId;
copy?: StrongholdId;
copyChoices?: 0 | 1 | 2;
ownerFaction?: FactionId;
attackerIsOwner?: boolean;
ownerDial?: number;
opponentDial?: number;
support?: number;
opponentSupport?: number;
allyPayment?: number;
choamFunding?: number;
ownerSpice?: number;
ownerLeader?: 'auditor';
ownerWeapon?: Card['kind'];
ownerDefense?: Card['kind'];
opponentWeapon?: Card['kind'];
opponentDefense?: Card['kind'];
traitors?: 'owner' | 'both';
counter?: boolean;
mobileRoute?: string[];
};
export type StrongholdFactionsSourceFacts = {
event: string;
turn: number;
territory: StrongholdId;
attacker: string;
defender: string;
owner: string;
copyChoices: StrongholdId[];
cardOwners: Record<StrongholdId, string | null>;
currentControllers: Record<StrongholdId, string | null>;
physicalForces: { player: string; forces: Record<string, number>; eliteForces: Record<string, number> }[];
};
export type StrongholdFactionsFixture = {
initial: Game;
afterSetup: Game;
beforeFirstMentat: Game;
firstMentatStep: StrongholdFactionsNativeStep;
afterFirstMentat: Game;
beforeBattle: Game;
battleAction: Action;
actor: string;
owner: string;
opponent: string;
choam: string;
game: Game;
event: string;
kind: StrongholdId;
copyAction: Action | null;
fundingAction: StrongholdFactionsNativeStep | null;
planActions: StrongholdFactionsNativeStep[];
staging: string[];
sourceFacts: StrongholdFactionsSourceFacts;
};
const seat = (game: Game, id: string) => {
const player = game.players.find(p => p.id === id);
assert.ok(player, `Missing original seat ${id}`);
return player;
};
const key = (id: StrongholdId) => id === MOBILE_STRONGHOLD ? MOBILE_LOCATION : `${id}:${territory(id).sectors[0]}`;
/** Only consumes actual native decisions. A null result with a live battle means
* a human must seal a plan; the helper never reads or invents that private plan. */
export function nextStrongholdFactionsNativeStep(game: Game): StrongholdFactionsNativeStep | null {
if (game.status === 'finished') return null;
if (game.pendingTreacheryDiscard) {
const pending = game.pendingTreacheryDiscard, reaction = pending.reaction;
if (reaction?.stage === 'offer') {
const actor = game.players.find(p => !reaction.passed.includes(p.id)); assert.ok(actor);
return { actor: actor.id, action: { type: 'semutaPass', event: pending.batch.event } };
}
if (reaction?.stage === 'select') {
const action = botActions(viewGame(game, reaction.player))[0]; assert.ok(action);
return { actor: reaction.player, action };
}
return { actor: game.host, action: { type: 'advanceBots' } };
}
if (game.phaseOpening) {
const actor = game.players.find(p => !game.phaseOpening!.passed.includes(p.id))?.id;
assert.ok(actor);
return { actor, action: { type: 'ready' } };
}
if (game.response) {
// The persisted pass queue defines legality; a policy recommendation is not a legal-action menu.
const actor = game.players.find(player => !game.response!.passed.includes(player.id))?.id;
assert.ok(actor, `No remaining original response at ${game.response.kind}`);
return { actor, action: { type: 'passResponse' } };
}
const decision = game.decision;
if (decision) {
const actor = decision.player;
if (decision.kind === 'nexusFremenProtection')
return { actor, action: { type: 'nexusFremenProtection', event: decision.event, use: false } };
if (decision.kind === 'nexusEmperorPayment')
return { actor, action: { type: 'nexusEmperorPayment', event: decision.event, use: false } };
if (decision.kind === 'nexusEmperorSellerPurchase')
return { actor, action: { type: 'nexusEmperorSellerPurchase', event: decision.event, use: false } };
if (decision.kind === 'ixSetup') return { actor, action: { type: 'decision', card: game.ixSetupCards![0].id } };
if (decision.kind === 'mobileStronghold') return { actor, action: decision.placement ? { type: 'decision', location: 'polar_sink:0' } : { type: 'decision', decline: true } };
if (decision.kind === 'ixTechnology' || decision.kind === 'ixSubstitution' || decision.kind === 'captureOffer' || decision.kind === 'fullPlanOffer' || decision.kind === 'choamTradeReply')
return { actor, action: { type: 'decision', decline: true, ...(decision.kind === 'captureOffer' ? { accept: false } : {}) } };
if (decision.kind === 'choamMarket') return { actor, action: { type: 'decision', done: true } };
if (decision.kind === 'choamFreeRevival') return { actor, action: { type: 'decision', decline: true } };
if (decision.kind === 'choamBattleFunding') return { actor, action: { type: 'decision', amount: 0 } };
if (decision.kind === 'strongholdCopy') return { actor, action: { type: 'decision', event: decision.event, stronghold: decision.choices[0] } };
if (decision.kind === 'auctionPayment') return { actor, action: { type: 'decision', karama: false } };
if (decision.kind === 'guildShipment') return { actor, action: { type: 'decision', allow: true } };
if (decision.kind === 'poisonTooth') return { actor, action: { type: 'decision', activate: true } };
if (decision.kind === 'battleLosses') return { actor, action: { type: 'decision', choice: 0 } };
if (decision.kind === 'battleCards') return { actor, action: { type: 'decision', discard: [] } };
if (decision.kind === 'choamAudit') return { actor, action: { type: 'decision', event: decision.event, audit: false } };
if (decision.kind === 'choamAuditPayment') return { actor, action: { type: 'decision', event: decision.event, pay: false } };
}
if (game.battle?.preLeader && !game.battle.preLeader.closed) {
const actor = [game.battle.attacker, game.battle.defender].find(id => !game.battle!.preLeader!.ready.includes(id));
if (actor) return { actor, action: { type: 'battlePreparationReady', event: game.battle.event } };
}
if (game.battle?.preparation?.kind === 'nexusVoice')
return { actor: game.battle.preparation.owner,
action: { type: 'nexusBorrowedVoice', event: game.battle.nexusBorrowedVoice!.event, use: false } };
if (game.battle?.preparation) return { actor: game.battle.preparation.owner, action: { type: 'declineBattlePower' } };
if (game.battle?.revealed) {
const actor = viewGame(game, game.host).battle!.traitorVoters.find(id => game.battle!.traitorCalls[id] === undefined);
if (actor) return { actor, action: { type: 'traitorCall', call: false } };
}
if (game.battle && !game.battle.revealed && !game.decision) return null;
if (game.status === 'playing' && !game.decision) {
if (game.phase === 0 && game.stormPending === null) {
const actor = game.stormDialers.find(id => game.stormDials[id] === undefined);
if (actor) return { actor, action: { type: 'stormDial', amount: game.turn === 1 ? 0 : 1 } };
}
if (game.phase === 3 && game.auction) return { actor: game.auction.active, action: { type: 'passBid' } };
if (game.phase === 5) return { actor: game.active!, action: { type: 'endMovement' } };
if (game.phase === 4 && game.revivalOrder) {
const actor = game.revivalOrder.remaining[0];
return actor ? {actor,action:{type:'ready'}} : null;
}
if ([1, 2, 4, 7, 8].includes(game.phase) || (game.phase === 0 && game.stormPending !== null)) {
const actor = game.players.find(p => !game.ready.includes(p.id))?.id;
if (actor) return { actor, action: { type: 'ready' } };
}
}
for (const player of game.players) {
if (decision && decision.player !== player.id) continue;
const view = viewGame(game, player.id);
view.players.find(p => p.id === player.id)!.bot = 'Easy';
const actions = botActions(view);
const action = actions.find(a => a.type === 'ready') ?? actions[0];
if (action) return { actor: player.id, action };
}
throw Error(`No native Stronghold continuation at ${game.status}/${game.turn}/${game.phase}/${decision?.kind}`);
}
function step(game: Game): Game {
const next = nextStrongholdFactionsNativeStep(game);
assert.ok(next, 'Native continuation stopped for an unsealed human plan');
return applyAction(game, next.actor, next.action);
}
function advance(game: Game, predicate: (state: Game) => boolean): Game {
for (let count = 0; count < 1800; count++) {
if (predicate(game)) return game;
assert.equal(game.status, 'playing');
game = step(game);
}
throw Error('Native Stronghold phase did not reach its requested boundary');
}
const clean = (game: Game) => !game.phaseOpening && !game.response && !game.decision;
/** Determinism is scoped only to the original native initializer. Saved decks,
* offers and native remainder shuffles are never reordered or redealt. */
function initialize(game: Game): Game {
const descriptor = Object.getOwnPropertyDescriptor(crypto, 'getRandomValues');
crypto.getRandomValues = <T extends ArrayBufferView | null>(array: T): T => {
assert.ok(array);
new Uint8Array(array.buffer, array.byteOffset, array.byteLength).fill(255);
return array;
};
try { return initializeStrongholdFactionsGameForAudit(game); }
finally {
if (descriptor) Object.defineProperty(crypto, 'getRandomValues', descriptor);
else Reflect.deleteProperty(crypto, 'getRandomValues');
}
}
/** Canonical conserved staging, never hand replacement or face manufacture. */
function heldCard(game: Game, actor: string, matches: (card: Card) => boolean, staging: string[], excluded: string[] = []): Card {
const player = seat(game, actor);
const held = player.hand.find(card => matches(card) && !excluded.includes(card.id));
if (held) return held;
const index = game.deck.findIndex(card => matches(card) && !excluded.includes(card.id));
assert.ok(index >= 0, 'The required canonical card is neither held nor in the remaining native deck');
const card = game.deck.splice(index, 1)[0];
assert.ok(player.hand.length < (player.faction === 'harkonnen' ? 8 : player.faction === 'choam' ? 5 : 4));
player.hand.push(card);
staging.push(`Conserved ${card.id}: remaining native deck → ${actor}'s hand`);
return card;
}
/** Return board counters to their own reserves without changing subtype totals. */
function reserveBoard(game: Game): void {
for (const player of game.players) {
player.reserves += Object.values(player.forces).reduce((sum, count) => sum + count, 0);
player.forces = {};
if (player.elites) {
player.elites.reserves += Object.values(player.elites.forces).reduce((sum, count) => sum + count, 0);
player.elites.forces = {};
}
if (player.advisors) player.advisors = {};
}
}
function place(game: Game, actor: string, location: string, count: number, elite = 0): void {
const player = seat(game, actor);
assert.ok(player.reserves >= count && count >= elite && (player.elites?.reserves ?? 0) >= elite);
player.reserves -= count;
player.forces[location] = (player.forces[location] ?? 0) + count;
if (elite) {
player.elites!.reserves -= elite;
player.elites!.forces[location] = (player.elites!.forces[location] ?? 0) + elite;
}
}
function conserveTraitor(game: Game, actor: string, identity: string, staging: string[]): void {
const player = seat(game, actor);
if (player.traitors.includes(identity)) return;
const returned = player.traitors[0];
assert.ok(returned);
const index = game.traitorReserve?.indexOf(identity) ?? -1;
const donor = game.players.find(p => p.id !== actor && p.traitors.includes(identity));
assert.ok(index >= 0 || donor, 'Requested canonical traitor retains native reserve or hand custody');
if (index >= 0) game.traitorReserve!.splice(index, 1, returned);
else donor!.traitors[donor!.traitors.indexOf(identity)] = returned;
player.traitors[0] = identity;
staging.push(`Conserved traitor ${identity}: native ${donor?.id ?? 'reserve'} custody exchanged with ${actor}'s ${returned}`);
}
/** Genuine Advanced native setup, first full turn and actual END Mentat custody.
* Only labeled conserved board/card/bank positions are staged. Never assign
* owners, phases, source receipts, quotations, pending windows or responses. */
export function createStrongholdFactionsFixture(options: StrongholdFactionsFixtureOptions = {}): StrongholdFactionsFixture {
const kind = options.kind ?? MOBILE_STRONGHOLD;
const staging: string[] = [];
let game: Game;
if (options.initial) {
game = structuredClone(options.initial);
assert.ok(game.status === 'lobby' || game.status === 'setup' && game.turn === 1 && game.phase === 0 && game.players.every(p => p.hand.length === 0), 'Continue only the original fresh undealt CLI setup');
assert.equal(game.advanced, true);
assert.deepEqual([...game.expansions].sort(), ['choam', 'ix']);
assert.ok(game.players.some(p => p.faction === 'ixians') && game.players.some(p => p.faction === 'choam'));
} else {
const ids = options.seatIds ?? ['ix', 'choam', 'guild'];
assert.ok(ids.length >= 2 && ids.length <= 6 && new Set(ids).size === ids.length);
const factions: FactionId[] = ['ixians', 'choam', 'guild', 'emperor', 'fremen', 'harkonnen'];
game = createGame('STRONGHOLDFACTIONS', newPlayer(ids[0], 'Ixians', factions[0]), true, ['ix', 'choam']);
for (let index = 1; index < ids.length; index++) joinGame(game, newPlayer(ids[index], factions[index], factions[index]));
}
if (game.status === 'lobby') {
for (const player of game.players) if (!player.ready) game = applyAction(game, player.id, { type: 'ready' });
game = initialize(game);
}
const initial = structuredClone(game);
assert.equal(game.strongholdCards!.claimedTurn, 0);
assert.ok(Object.values(game.strongholdCards!.owners).every(owner => owner === null));
for (let count = 0; game.status === 'setup' && count < 200; count++) game = step(game);
assert.equal(game.status, 'playing');
const afterSetup = structuredClone(game);
const ix = game.players.find(p => p.faction === 'ixians')!.id;
const choam = game.players.find(p => p.faction === 'choam')!.id;
const owner = game.players.find(p => p.faction === (options.ownerFaction ?? 'ixians'))?.id;
assert.ok(owner, 'Requested owner must be an original native seat');
const opponent = game.players.find(p => p.id !== owner && p.id !== choam)?.id ?? game.players.find(p => p.id !== owner)!.id;
if (options.choamFunding || options.allyPayment) assert.ok(owner !== choam && opponent !== choam, 'Native donor needs a separate CHOAM seat');
// Select real first dials, not the storm field. A later 1..6 native storm
// cannot obstruct this test's single printed battle sector.
for (let count = 0; !(game.phase === 8 && clean(game)) && count < 1600; count++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
if (next.action.type === 'stormDial' && game.turn === 1) next.action.amount = kind === 'tueks_sietch' || kind === 'habbanya_ridge_sietch' ? 4 : 0;
game = applyAction(game, next.actor, next.action);
}
assert.equal(game.phase, 8); assert.equal(game.turn, 1);
reserveBoard(game);
staging.push('Before actual first END Mentat: relocate existing physical counters through their own reserves; nobody controls three strongholds');
place(game, ix, MOBILE_LOCATION, 6, 3);
const firstCopy = options.copy && options.copy !== MOBILE_STRONGHOLD ? options.copy : 'arrakeen';
if (kind === MOBILE_STRONGHOLD) {
if (owner !== ix) {
reserveBoard(game);
place(game, owner, MOBILE_LOCATION, 6);
}
place(game, owner, key(firstCopy), 1);
} else place(game, owner, key(kind), 1);
const occupied = new Set([kind, MOBILE_STRONGHOLD, ...(kind === MOBILE_STRONGHOLD ? [firstCopy] : [])]);
const others = game.players.filter(p => p.id !== owner && p.id !== ix);
for (const [index, card] of STRONGHOLD_CARDS.filter(card => !occupied.has(card.id)).entries()) {
const recipient = others[Math.floor(index / 2)];
if (recipient) place(game, recipient.id, key(card.id), 1);
}
// Keep every first-turn owner below the solo victory threshold.
for (const player of game.players) assert.ok(Object.keys(player.forces).length <= 2);
let beforeFirstMentat: Game | undefined;
let firstMentatStep: StrongholdFactionsNativeStep | undefined;
for (let count = 0; game.turn === 1 && count < 200; count++) {
const next = nextStrongholdFactionsNativeStep(game); assert.ok(next);
const before = structuredClone(game);
game = applyAction(game, next.actor, next.action);
if (game.turn === 2) { beforeFirstMentat = before; firstMentatStep = next; }
}
assert.ok(beforeFirstMentat && firstMentatStep);
assert.equal(game.status, 'playing'); assert.equal(game.strongholdCards!.claimedTurn, 1);
assert.equal(game.strongholdCards!.owners[kind], owner);
const afterFirstMentat = structuredClone(game);
if (options.mobileRoute) {
game = advance(game, state => state.decision?.kind === 'mobileStronghold');
assert.equal(game.decision?.kind, 'mobileStronghold');
game = applyAction(game, ix, { type: 'decision', route: options.mobileRoute, collect: false });
}
if (options.choamFunding || options.allyPayment) {
game = advance(game, state => state.phase === 1 && clean(state) && !state.spiceSequence && !state.spiceResolution && !state.spiceWindow && !state.nexus);
const thumperActor = game.players.find(player => player.hand.some(card => card.effect === 'thumper'))?.id ?? owner;
const thumper = heldCard(game, thumperActor, card => card.effect === 'thumper', staging);
game = applyAction(game, thumperActor, { type: 'card', card: thumper.id });
game = advance(game, state => state.nexus && clean(state) && !state.spiceWindow && !state.spiceResolution);
game = applyAction(game, owner, { type: 'alliance', target: choam });
game = applyAction(game, choam, { type: 'alliance', target: owner });
assert.equal(seat(game, owner).ally, choam);
}
game = advance(game, state => state.phase === 5 && clean(state));
reserveBoard(game);
staging.push('Before native turn-two movement completion: conserve six battle counters per combatant and current copy-control counters; retain actual first-Mentat card custody');
for (const id of [owner, opponent]) place(game, id, key(kind), 6, seat(game, id).faction === 'ixians' ? 3 : 0);
if (kind !== MOBILE_STRONGHOLD && ix !== owner && ix !== opponent) place(game, ix, MOBILE_LOCATION, 6, 3);
if (kind === MOBILE_STRONGHOLD) {
const choices = options.copyChoices ?? 2;
if (choices) place(game, owner, key(firstCopy), 1);
if (choices === 2) place(game, owner, key(firstCopy === 'sietch_tabr' ? 'arrakeen' : 'sietch_tabr'), 1);
}
for (const player of game.players) {
player.spice = player.id === owner ? options.ownerSpice ?? 20 : 20;
}
staging.push('Bank funding staging: original seats receive explicit twenty-spice test balances (ownerSpice may set the affordability boundary); no aid or support receipt is injected');
const used: string[] = [];
const card = (actor: string, type: Card['kind'] | undefined) => {
if (!type) return null;
const selected = heldCard(game, actor, c => c.kind === type, staging, used);
used.push(selected.id); return selected.id;
};
const ownerWeapon = card(owner, options.ownerWeapon);
const ownerDefense = card(owner, options.ownerDefense);
const opponentWeapon = card(opponent, options.opponentWeapon);
const opponentDefense = card(opponent, options.opponentDefense);
if (options.counter) heldCard(game, opponent, c => c.effect === 'karama', staging);
const ownerLeader = seat(game, owner).leaders.find(l => options.ownerLeader === 'auditor' ? isAuditorLeader(l) : !isAuditorLeader(l) && l.strength === 5)
?? seat(game, owner).leaders.find(l => !isAuditorLeader(l));
const opponentLeader = seat(game, opponent).leaders.find(l => !isAuditorLeader(l) && l.strength === (options.ownerLeader === 'auditor' ? 2 : ownerLeader!.strength))
?? seat(game, opponent).leaders.find(l => !isAuditorLeader(l));
assert.ok(ownerLeader && opponentLeader);
if (options.ownerLeader === 'auditor') assert.ok(isAuditorLeader(ownerLeader), 'The actual native CHOAM Auditor is required');
if (options.traitors) {
conserveTraitor(game, owner, opponentLeader.id, staging);
if (options.traitors === 'both') conserveTraitor(game, opponent, ownerLeader.id, staging);
}
// Finish genuine movement; the battle queue determines its real chooser.
game = advance(game, state => state.phase === 6 && clean(state));
const beforeBattle = structuredClone(game);
const actor = game.active!;
assert.ok([owner, opponent].includes(actor));
if (options.attackerIsOwner !== undefined) assert.equal(actor === owner, options.attackerIsOwner, 'Native storm order decides physical attacker; this option does not rewrite it');
const battleAction: Action = { type: 'chooseBattle', territory: kind, target: actor === owner ? opponent : owner };
game = applyAction(game, actor, battleAction);
for (let count = 0; count < 120; count++) {
if (game.decision?.kind === 'strongholdCopy') break;
const next = nextStrongholdFactionsNativeStep(game);
if (!next) break;
if (game.decision?.kind === 'choamBattleFunding') next.action.amount = options.choamFunding ?? 0;
game = applyAction(game, next.actor, next.action);
}
assert.ok(game.battle?.event && !game.battle.revealed);
const copyAction: Action | null = game.decision?.kind === 'strongholdCopy' ? { type: 'decision', event: game.decision.event, stronghold: options.copy ?? firstCopy } : null;
const planActions: StrongholdFactionsNativeStep[] = [
{ actor: owner, action: { type: 'battlePlan', dial: options.ownerDial ?? 0, support: options.support ?? 0, allyPayment: options.allyPayment ?? 0, leader: ownerLeader.id, weapon: ownerWeapon, defense: ownerDefense } },
{ actor: opponent, action: { type: 'battlePlan', dial: options.opponentDial ?? 0, support: options.opponentSupport ?? 0, leader: opponentLeader.id, weapon: opponentWeapon, defense: opponentDefense } },
];
const fundingAction: StrongholdFactionsNativeStep | null = options.choamFunding || options.allyPayment ? { actor: choam, action: { type: 'decision', amount: options.choamFunding ?? options.allyPayment ?? 0 } } : null;
const currentControllers = strongholdControllers(game.players, !!game.mobileStronghold?.location);
const sourceFacts: StrongholdFactionsSourceFacts = {
event: game.battle.event, turn: game.turn, territory: kind,
attacker: game.battle.attacker, defender: game.battle.defender, owner,
copyChoices: STRONGHOLD_CARDS.filter(card => card.id !== MOBILE_STRONGHOLD && currentControllers[card.id] === owner).map(card => card.id),
cardOwners: { ...game.strongholdCards!.owners }, currentControllers,
physicalForces: game.players.map(player => ({ player: player.id, forces: { ...player.forces }, eliteForces: { ...player.elites?.forces } })),
};
return { initial, afterSetup, beforeFirstMentat, firstMentatStep, afterFirstMentat, beforeBattle, battleAction, actor, owner, opponent, choam, game, event: game.battle.event, kind, copyAction, fundingAction, planActions, staging, sourceFacts };
}
/** Independent shared quote on the actual revealed source, including native
* subtype pools, donor escrow, bounty and bank income as separate legs. */
export function quoteStrongholdFactionsBattle(game: Game): BattleResolutionQuote {
const battle = game.battle; assert.ok(battle?.revealed);
const side = (actor: string): ResolutionCombatant => {
const player = seat(game, actor), view = viewGame(game, actor).battle!;
const aid = player.ally ? game.aid[player.ally] : undefined;
assert.ok(view.ownForces);
return { id: actor, faction: player.faction, ally: player.ally, spice: player.spice, hand: player.hand,
plan: battle.plans[actor], leader: player.leaders.find(l => l.id === battle.plans[actor].leader),
forces: view.ownForces, stronghold: view.strongholdEffects[actor], poisonTooth: battle.poisonTooth?.[actor],
lateDefense: battle.lateDefense?.[actor], stoneMode: battle.stoneBurner?.[actor],
...(aid?.recipient === actor ? { aid: { donor: player.ally!, amount: aid.amount } } : {}) };
};
return quoteBattleResolution({ advanced: game.advanced, turn: game.turn, territory: battle.territory,
aggressor: viewGame(game, game.host).battle!.aggressor,
attacker: side(battle.attacker), defender: side(battle.defender),
voters: viewGame(game, game.host).battle!.traitorVoters.map(actor => ({ id: actor,
beneficiary: [battle.attacker, battle.defender].includes(actor) ? actor : seat(game, actor).ally!,
called: battle.traitorCalls[actor] ?? false, traitors: seat(game, actor).traitors })),
participants: game.players, physicalCards: [...game.deck, ...game.discard, ...game.players.flatMap(p => p.hand)],
pendingAuditorPresent: !!game.pendingAuditor, pendingRetentionPresent: false });
}
export type StrongholdFactionsBattleQuote = BattleResolutionQuote;