Connect owned multi-pile battle skill choices

This commit is contained in:
Quintine committed 2026-10-09 03:14:14 +11:00
1 parent 8e204218be
commit a1517ec949
20 files changed
+705 -71

No files matched your search

+82 -8
View File
@@ -7,8 +7,25 @@ import { leaderSkillBattleBonus, usesSurvivingSkilledLeader, type BattleLeaderSk
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')));
@@ -35,16 +52,20 @@ export function smugglerBattlePlanBlock(plan: SmugglerBattlePlan, supported: boo
export function smugglerBattleSignature(receipt: SmugglerBattleReceipt): string {
return JSON.stringify({ ...receipt, signature: undefined });
}
export function smugglerBattlePile(territory: string, spice: Readonly<Record<string, number>>) {
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);
if (positive.length > 1) throw new Error('Smuggler collection among multiple spice piles awaits its allocation ruling.');
return positive.length ? { key: positive[0][0], before: positive[0][1] } : { key: null, before: 0 };
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;
@@ -63,14 +84,67 @@ export function createSmugglerBattle(input: {
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)));
}
/** Only a surviving leader's partial collection from multiple piles needs an owner choice. */
export function smugglerBattleAllocationRequired(receipt: SmugglerBattleReceipt, survives: boolean): boolean {
return survives && 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;
}
/** Survival controls both winning and losing leaders; this is not bank/player income. */
export function settleSmugglerBattle(receipt: SmugglerBattleReceipt, survives: boolean, spice: Readonly<Record<string, number>>) {
if (receipt.stage !== 'pending' || receipt.signature !== smugglerBattleSignature(receipt) ||
receipt.amount !== Math.min(receipt.strength, receipt.before) ||
JSON.stringify(smugglerBattlePile(receipt.territory, spice)) !== JSON.stringify({ key: receipt.key, before: receipt.before }))
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, survives))
throw new Error('Choose the Smuggler collection sectors before settling the surviving leader.');
const amount = survives ? receipt.amount : 0;
const allocations = !survives || !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: survives ? 'collected' : 'void', signature: '' };
settled.signature = smugglerBattleSignature(settled);
return { receipt: settled, amount, spice: receipt.key ? { ...spice, [receipt.key]: receipt.before - amount } : { ...spice } };
return { receipt: settled, amount, spice: remaining };
}