import { casualtyOptions, validCombatForces, maxCombatDial, maxCombatSupport, type CombatForces, } from './combat'; export type StoneBurnerSide = 'attacker' | 'defender'; export type StoneBurnerComparison = { winner: StoneBurnerSide | null; attacker: number[]; defender: number[]; }; const opposingChoices = new Map(); const MAX_CACHED_POOLS = 128; function validSide(side: StoneBurnerSide): boolean { return side === 'attacker' || side === 'defender'; } function sorted(values: Iterable): number[] { return [...new Set(values)].sort((a, b) => a - b); } function totals(forces: CombatForces, dial: number, support: number): number[] { if ( !validCombatForces(forces) || !Number.isFinite(dial) || dial < 0 || !Number.isInteger(dial * 2) || !Number.isSafeInteger(support) || support < 0 || support > maxCombatSupport(forces) ) return []; return sorted( casualtyOptions(forces, dial, support).map( (loss) => forces.normal + forces.elite - loss.normal - loss.elite, ), ); } /** The same side must win for every permitted pair; never select an allocation. */ function invariantWinner( attacker: readonly number[], defender: readonly number[], aggressor: StoneBurnerSide, ): StoneBurnerSide | null { if (!attacker.length || !defender.length || !validSide(aggressor)) return null; const aMin = attacker[0], aMax = attacker[attacker.length - 1]; const dMin = defender[0], dMax = defender[defender.length - 1]; if (aMin > dMax || (aMin === dMax && aggressor === 'attacker')) return 'attacker'; if (dMin > aMax || (dMin === aMax && aggressor === 'defender')) return 'defender'; return null; } /** Pure revealed-plan arithmetic: physical tokens, not dial or paid strength. */ export function stoneBurnerComparison( a: CombatForces, aDial: number, aSupport: number, d: CombatForces, dDial: number, dSupport: number, aggressor: StoneBurnerSide = 'attacker', ): StoneBurnerComparison { const attacker = totals(a, aDial, aSupport), defender = totals(d, dDial, dSupport); return { winner: invariantWinner(attacker, defender, aggressor), attacker, defender, }; } /** * Group all physically feasible token/support assignments by public dial/support. * No player's selected plan or spice balance is an input. Support assignments * sharing a token loss are equivalent, as in casualtyOptions. */ function allOpposingTotals( forces: CombatForces, ): readonly (readonly number[])[] { const key = [ forces.normal, forces.elite, forces.eliteStrength, forces.temporaryElite ?? 0, !!forces.normalFixedHalf, !!forces.normalFreeSupport, forces.freeSupport, !!forces.eliteFreeSupport, ].join(':'); const cached = opposingChoices.get(key); if (cached) return cached; const result: number[][] = []; const maximumHalfDial = maxCombatDial(forces) * 2; const maximumSupport = maxCombatSupport(forces); // Use the same legality and typed-support arithmetic as sealed plans. Cache // public pool results so repeated preflights do not repeat this enumeration. for (let halfDial = 0; halfDial <= maximumHalfDial; halfDial++) for (let support = 0; support <= maximumSupport; support++) { const choices = totals(forces, halfDial / 2, support); if (choices.length) result.push(choices); } if (opposingChoices.size >= MAX_CACHED_POOLS) opposingChoices.delete(opposingChoices.keys().next().value!); opposingChoices.set(key, result); return result; } /** * Conservative pre-commit finishability, including every opposing dial/support * regardless of secret funding. A successful result does not select casualties. */ export function stoneBurnerPlanBlock( own: CombatForces, dial: number, support: number, opponent: CombatForces, side: StoneBurnerSide, aggressor: StoneBurnerSide = 'attacker', ): string | null { if (!validSide(side) || !validSide(aggressor)) return 'Choose valid Stone Burner combatant roles.'; if (!validCombatForces(own) || !validCombatForces(opponent)) return 'Stone Burner needs valid supported physical force pools of at most 20 tokens.'; const ownTotals = totals(own, dial, support); if (!ownTotals.length) return 'Your dial and support do not permit a legal Stone Burner force allocation.'; for (const otherTotals of allOpposingTotals(opponent)) { const winner = side === 'attacker' ? invariantWinner(ownTotals, otherTotals, aggressor) : invariantWinner(otherTotals, ownTotals, aggressor); if (winner === null) return 'Stone Burner can change the winner with different legal force allocations; this combined allocation timing is not yet supported.'; } return null; } /** A public Voice check must leave a completion even with no spendable spice. */ export function stoneBurnerCompulsionBlock( own: CombatForces, opponent: CombatForces, side: StoneBurnerSide, aggressor: StoneBurnerSide = 'attacker', ): string | null { if (!validSide(side) || !validSide(aggressor)) return 'Choose valid Stone Burner combatant roles.'; if (!validCombatForces(own) || !validCombatForces(opponent)) return 'Stone Burner needs valid supported physical force pools of at most 20 tokens.'; const maximumStrength = maxCombatDial(own); for (let halfDial = 0; halfDial <= maximumStrength * 2; halfDial++) { if (!totals(own, halfDial / 2, 0).length) continue; if ( stoneBurnerPlanBlock(own, halfDial / 2, 0, opponent, side, aggressor) === null ) return null; } return 'Compelling Stone Burner has no supported zero-spice plan for every opposing allocation; this combined allocation timing is not yet supported.'; }