Files
Dune/game/smuggler-battle.ts
T

153 lines
9.3 KiB
TypeScript

import { FACTIONS } from './catalog';
import { nativeExpansionLeaderSkillsProfile, ordinaryLeaderSkillModeSupported, type LeaderSkillProfile } from './leader-skill-profile';
import type { Card, Leader } from './cards';
import { splitLocation, validGameLocation } from './board';
import { leaderSkillBattleBonus, usesSurvivingSkilledLeader, type BattleLeaderSkill } from './leader-skill-combat';
export type SmugglerBattleReceipt = {
event: string; turn: number; territory: string; player: string; leader: string;
frame: string; strength: number; key: string | null; before: number; amount: number;
piles?: Record<string, number>; allocations?: Record<string, number>;
stage: 'pending' | 'collected' | 'void'; signature: string;
};
export type SmugglerBattleCollectionOffer = {
event: string; player: string; territory: string; amount: number; piles: Record<string, number>;
};
/** Minimal legal participation; strategy must not invent hidden pile information. */
export function defaultSmugglerBattleAllocation(piles: Readonly<Record<string, number>>, amount: number) {
const allocations: Record<string, number> = {};
let remaining = amount;
for (const key of Object.keys(piles).sort()) {
const collected = Math.min(remaining, piles[key]);
if (collected > 0) allocations[key] = collected;
remaining -= collected;
if (!remaining) break;
}
return allocations;
}
export function smugglerBattleModeSupported(g: LeaderSkillProfile & { players: readonly { faction: string }[] }) {
return ordinaryLeaderSkillModeSupported(g) &&
(nativeExpansionLeaderSkillsProfile(g) || g.players.every(p => FACTIONS.some(f => f.id === p.faction && f.expansion === 'base')));
}
export type SmugglerBattlePlan = {
assignments: readonly BattleLeaderSkill[];
leader?: Pick<Leader, 'id' | 'strength'>;
weapon?: Card; defense?: Card; kwisatz?: boolean;
};
export function smugglerBattlePlanBlock(plan: SmugglerBattlePlan, supported: boolean,
pile?: { territory: string; spice: Readonly<Record<string, number>> }): string | null {
if (!usesSurvivingSkilledLeader(plan.assignments, 'smuggler', plan.leader?.id, true)) return null;
if (!supported) return 'Smuggler battle collection with expansion factions or other optional modules is still being integrated.';
if (pile) {
try { smugglerBattlePile(pile.territory, pile.spice); }
catch (error) { return error instanceof Error ? error.message : 'Smuggler spice is unavailable.'; }
}
return null;
}
export function smugglerBattleSignature(receipt: SmugglerBattleReceipt): string {
return JSON.stringify({ ...receipt, signature: undefined });
}
export function smugglerBattlePile(territory: string, spice: Readonly<Record<string, number>>): {
key: string | null; before: number; piles?: Record<string, number>;
} {
const piles = Object.entries(spice).filter(([key]) => splitLocation(key).territory === territory);
for (const [key, amount] of piles) {
const at = splitLocation(key);
if (!validGameLocation({}, at.territory, at.sector) || key !== `${at.territory}:${at.sector}` ||
!Number.isSafeInteger(amount) || amount < 0) throw new Error('Smuggler needs a valid spice pile.');
}
const positive = piles.filter(([, amount]) => amount > 0).sort(([a], [b]) => a.localeCompare(b));
const before = positive.reduce((total, [, amount]) => total + amount, 0);
if (!Number.isSafeInteger(before)) throw new Error('Smuggler needs a safe aggregate spice amount.');
return positive.length > 1 ? { key: null, before, piles: Object.fromEntries(positive) } :
positive.length ? { key: positive[0][0], before } : { key: null, before: 0 };
}
export function createSmugglerBattle(input: {
event: string; turn: number; territory: string; player: string; frame: string;
plan: SmugglerBattlePlan; supported: boolean; spice: Readonly<Record<string, number>>;
}): SmugglerBattleReceipt | null {
if (!usesSurvivingSkilledLeader(input.plan.assignments, 'smuggler', input.plan.leader?.id, true)) return null;
const blocked = smugglerBattlePlanBlock(input.plan, input.supported);
if (blocked) throw new Error(blocked);
// Provisional reveal-time cap: copied disc value plus the existing leader
// bonuses and Kwisatz. Actual weapon zeroing is applied with survival.
const strength = input.plan.leader!.strength + leaderSkillBattleBonus({
assignments: input.plan.assignments,
selectedLeader: { id: input.plan.leader!.id, kind: 'disc' },
weapon: input.plan.weapon, defense: input.plan.defense, skilledLeaderSurvives: true,
}).bonus + (input.plan.kwisatz ? 2 : 0);
if (!input.event || !input.player || !Number.isSafeInteger(input.turn) || input.turn < 1 ||
!Number.isSafeInteger(strength) || strength < 0) throw new Error('Smuggler needs the current battle and a valid leader strength.');
const pile = smugglerBattlePile(input.territory, input.spice);
const receipt: SmugglerBattleReceipt = { event: input.event, turn: input.turn,
territory: input.territory, player: input.player, leader: input.plan.leader!.id,
frame: input.frame, strength, ...pile, amount: Math.min(strength, pile.before), stage: 'pending', signature: '' };
receipt.signature = smugglerBattleSignature(receipt);
return receipt;
}
function recordedPiles(receipt: SmugglerBattleReceipt): Record<string, number> {
return receipt.piles ?? (receipt.key ? { [receipt.key]: receipt.before } : {});
}
function validateSmugglerBattleReceipt(receipt: SmugglerBattleReceipt) {
if (receipt.stage !== 'pending' || receipt.signature !== smugglerBattleSignature(receipt) ||
!Number.isSafeInteger(receipt.strength) || receipt.strength < 0 ||
!Number.isSafeInteger(receipt.before) || receipt.before < 0 ||
receipt.amount !== Math.min(receipt.strength, receipt.before))
throw new Error('The saved Smuggler collection changed its reveal-time spice or receipt.');
const snapshot = smugglerBattlePile(receipt.territory, recordedPiles(receipt));
if (snapshot.key !== receipt.key || snapshot.before !== receipt.before ||
(!!snapshot.piles !== !!receipt.piles) ||
(receipt.piles && Object.keys(receipt.piles).length !== Object.keys(snapshot.piles!).length))
throw new Error('The saved Smuggler collection changed its reveal-time spice or receipt.');
if (receipt.allocations !== undefined) normalizeSmugglerBattleAllocation(receipt, receipt.allocations);
}
function normalizeSmugglerBattleAllocation(receipt: SmugglerBattleReceipt,
allocations: Record<string, number>): Record<string, number> {
if (!allocations || typeof allocations !== 'object' || Array.isArray(allocations))
throw new Error('Choose a Smuggler allocation among the revealed spice piles.');
const piles = recordedPiles(receipt);
let total = 0;
for (const [key, amount] of Object.entries(allocations)) {
if (!Object.prototype.hasOwnProperty.call(piles, key) || piles[key] <= 0 ||
!Number.isSafeInteger(amount) || amount < 0 || amount > piles[key])
throw new Error('Smuggler allocation must use whole spice within each revealed positive pile.');
total += amount;
}
if (total !== receipt.amount) throw new Error('Smuggler allocation must collect the exact pending amount.');
return Object.fromEntries(Object.entries(allocations).filter(([, amount]) => amount > 0)
.sort(([a], [b]) => a.localeCompare(b)));
}
/** A partial multi-pile collection needs a choice only when collection is eligible. */
export function smugglerBattleAllocationRequired(receipt: SmugglerBattleReceipt, eligible: boolean): boolean {
return eligible && receipt.stage === 'pending' && receipt.allocations === undefined &&
Object.keys(recordedPiles(receipt)).length > 1 && receipt.amount > 0 && receipt.amount < receipt.before;
}
export function chooseSmugglerBattleAllocation(receipt: SmugglerBattleReceipt,
allocations: Record<string, number>): SmugglerBattleReceipt {
validateSmugglerBattleReceipt(receipt);
const chosen: SmugglerBattleReceipt = { ...receipt,
...(receipt.piles ? { piles: { ...receipt.piles } } : {}),
allocations: normalizeSmugglerBattleAllocation(receipt, allocations), signature: '' };
chosen.signature = smugglerBattleSignature(chosen);
return chosen;
}
/** Eligibility requires survival without a resolved zero-strength weapon effect. */
export function settleSmugglerBattle(receipt: SmugglerBattleReceipt, eligible: boolean, spice: Readonly<Record<string, number>>) {
validateSmugglerBattleReceipt(receipt);
const snapshot = smugglerBattlePile(receipt.territory, spice);
const expected = smugglerBattlePile(receipt.territory, recordedPiles(receipt));
if (JSON.stringify(snapshot) !== JSON.stringify(expected))
throw new Error('The saved Smuggler collection changed its reveal-time spice or receipt.');
if (smugglerBattleAllocationRequired(receipt, eligible))
throw new Error('Choose the Smuggler collection sectors before settling the surviving leader.');
const amount = eligible ? receipt.amount : 0;
const allocations = !eligible || !amount ? {} : receipt.allocations ??
(receipt.key ? { [receipt.key]: amount } : recordedPiles(receipt));
const remaining = { ...spice };
for (const [key, collected] of Object.entries(allocations)) remaining[key] -= collected;
const settled: SmugglerBattleReceipt = { ...receipt, stage: eligible ? 'collected' : 'void', signature: '' };
settled.signature = smugglerBattleSignature(settled);
return { receipt: settled, amount, spice: remaining };
}