import type { OutcomeType } from "../common/types.js"; import { PMF } from "./pmf.js"; import type { DamageAttributionChartModel } from "./pmf.js"; /** * Query interface for analyzing dice roll probability distributions. * * Combines multiple attack PMFs and provides statistical analysis methods for: * - Basic statistics (mean, variance, min/max, percentiles) * - Probability queries (hit chances, success rates, exact counts) * - Damage analysis (ranges by outcome type, expected values) * - Data export (charts, tables, visualizations) * */ export declare class DiceQuery { readonly singles: PMF[]; private readonly _eps; private readonly _combinedProvided; private _combined?; private _combinedWithAttr?; constructor(singles: PMF | PMF[], combined?: PMF, eps?: number); /** * The combined damage distribution of all single PMFs (their convolution), * normalized to total probability 1. * * Computed lazily on first access and cached. Queries that only need * additive statistics — {@link DiceQuery.mean}, {@link DiceQuery.variance}, * {@link DiceQuery.stddev} — never trigger this convolution. */ get combined(): PMF; private static readonly DEFAULT_OUTCOMES; /** * Returns a new PMF with damage attribution metadata populated. * * This method computes attribution on-demand for builder-generated PMFs, * enabling them to work with damage attribution charts. The `attr` field * tracks how much damage each outcome type contributes at each damage value. * * For each bin at damage D: sum(attr.values()) ≈ D × P(damage = D) * * Performance: Cached after first call. Adds minimal overhead vs `combined`. * * @returns PMF with attr field populated for damage attribution charts * * @example * const attack = d20.plus(5).ac(15).onHit(d(2,6).plus(3)).onCrit(d(2,6)) * const query = attack.toQuery() * const pmf = query.combinedWithAttribution() * // Now pmf can be used with attributionByValue() / damageAttributionChartModel() */ combinedWithAttribution(): PMF; /** * Per-label `damage value → probability mass` series for the combined, * attribution-carrying distribution — the provenance core of the stacked * damage-attribution chart. Convenience for * `combinedWithAttribution().attributionByValue()`; see * {@link PMF.attributionByValue}. */ attributionByValue(): Map>; /** * Full numeric model for the stacked damage-attribution chart. Convenience for * `combinedWithAttribution().damageAttributionChartModel(options)`; see * {@link PMF.damageAttributionChartModel}. */ damageAttributionChartModel(options?: { maxBuckets?: number; stackOrder?: readonly string[]; epsilon?: number; }): DamageAttributionChartModel; /** * How many of the independent single PMFs can produce the given outcome * label. Useful for "all of them succeeded" style probabilities where the * exponent is the number of contributing attacks (see * {@link DiceQuery.probExactlyK}). */ countSinglesWith(label: string): number; /** * Returns the expected damage across all possible outcomes. * * Example: `query.mean()` → 12.5 * Use case: "What's my average damage per round?" */ mean(): number; /** * Returns the variance of the damage distribution. * * Example: `query.variance()` → 45.2 * Use case: "How much does my damage vary from the average?" * High variance means higher risk/reward. Lower variance means more consistent damage. */ variance(): number; /** * Returns the standard deviation of the damage distribution. * * Example: `query.stdev()` → 6.7 * Use case: "What's the typical spread around my average damage?" * Used to determine how consistent the damage is. */ stddev(): number; /** Alias of {@link DiceQuery.stddev}, matching {@link PMF.stdev}. */ stdev(): number; /** * Returns the Cumulative Distribution Function. */ cdf(x: number): number; /** * Returns the probability of dealing X damage or less. * In statistics, this is called the cumulative distribution function (CDF). * Example: `query.cdf(20)` → 0.75 * Use case: "What's the chance I deal 20 damage or less?" */ probTotalAtMost(x: number): number; /** * Returns the Complementary Cumulative Distribution Function. */ ccdf(x: number): number; /** * Returns the probability of dealing at least X damage. * * Example: `query.probTotalAtLeast(25)` → 0.35 * Use case: "What's the chance I deal at least 25 damage to finish the enemy?" */ probTotalAtLeast(threshold: number): number; /** * Returns damage values at specific percentiles. * * Example: `query.percentiles([0.25, 0.5, 0.75])` → [8, 12, 18] * Use case: "What are my 25th, 50th, and 75th percentile damage values?" */ percentiles(percentileValues: number[]): number[]; /** * Returns the minimum possible damage. * * Example: `query.min()` → 0 * Use case: "What's the worst-case damage if everything misses?" */ min(): number; /** * Returns the maximum possible damage. * * Example: `query.max()` → 56 * Use case: "What's the best-case damage if everything crits and rolls max?" */ max(): number; private singleProb; /** * Full count distribution [P(0), P(1), …, P(n)] for "an attack succeeds if it * carries ANY of `labels`", over the n independent singles. * * Each single's per-event success probability is the Poisson-binomial * marginal P(≥1 of labels) from {@link probabilityOf} (i.e. probAtLeastOne), * computed exactly once. The binomial DP then runs once to produce the whole * distribution, so the array-label paths of probExactlyK / probAtLeastK / * probAtMostK can slice or sum from it instead of rebuilding a DiceQuery and * re-running the DP per requested k. */ private countDistribution; probAtLeastK(labels: OutcomeType | OutcomeType[], k: number): number; /** * Returns the probability that at least one attack has the specified outcome(s). * - This is the complement of probAtMostK(labels, 0) * * Examples: * - `query.probAtLeastOne('hit')` → 0.88 (88% chance at least one attack hits) * - `query.probAtLeastOne(['hit', 'crit'])` → 0.96 (96% chance at least one succeeds) * * Use cases: * - "What's the chance at least one of my attacks connects?" * * Note: * * - You have to pass in an array of labels to avoid double-counting if you are * using multiple labels. You cannot just add them. */ probAtLeastOne(labels: OutcomeType | OutcomeType[]): number; /** * Computes binomial probabilities for exactly 0, 1, 2, ..., maxK occurrences of a label. * * Uses dynamic programming to efficiently calculate the probability distribution * of how many attacks will have the specified outcome, accounting for different * success probabilities across individual attacks. * * Example: For 3 attacks with 50% hit chance each, returns: * [0.125, 0.375, 0.375, 0.125] = [P(0 hits), P(1 hit), P(2 hits), P(3 hits)] * * @param label - The outcome type to count * @param maxK - Maximum number of occurrences to calculate (usually number of attacks) * @returns Array where index K contains P(exactly K attacks have the label) */ private computeBinomialProbabilities; /** * Returns the probability that exactly K attacks result in the specified outcome(s). * * Single label examples: * - probExactlyK('hit', 2) = probability exactly 2 attacks hit * - probExactlyK('crit', 1) = probability exactly 1 attack crits * - probExactlyK('crit', 0) = probability no attacks crit * * Array examples: * - probExactlyK(['hit', 'crit'], 2) = probability exactly 2 attacks succeed * - probExactlyK(['hit', 'crit'], 1) = probability exactly 1 attack succeeds * - probExactlyK(['missDamage', 'missNone'], 0) = probability no attacks miss * * Use cases: * - "What's the chance exactly one of my attacks hits?" * - "How likely am I to get exactly 2 successes out of 3 attacks?" * - "What's the probability that exactly half my attacks succeed?" * * Note: For arrays, an attack counts as a "success" if it has any of the specified labels. * This is different from probAtMostK, which counts an attack as a "success" if it has ALL of the specified labels. */ probExactlyK(labels: OutcomeType | OutcomeType[], k: number): number; /** * Returns the probability that AT MOST K attacks result in the specified outcome(s). * * Single label examples: * - probAtMostK('hit', 1) = probability 0 or 1 attacks hit (at most 1) * - probAtMostK('crit', 0) = probability no attacks crit * - probAtMostK('missDamage', 2) = probability at most 2 attacks miss * * Array examples: * - probAtMostK(['hit', 'crit'], 1) = probability at most 1 attack succeeds * - probAtMostK(['hit', 'crit'], 0) = probability no attacks succeed (all miss) * * Use cases: * - "What's the chance that at most one attack hits?" (rest miss) * - "How likely am I to have mostly failures?" (at most 1 success) * - "What's the probability of a really bad turn?" (at most 0 successes) * */ probAtMostK(labels: OutcomeType | OutcomeType[], k: number): number; /** * Returns the expected damage attributed to specific outcome types. * * Single label examples: * - expectedDamageFrom('hit') = expected damage from hit components * - expectedDamageFrom('crit') = expected damage from crit components * * Array examples: * - expectedDamageFrom(['hit', 'crit']) = expected damage from any success * - expectedDamageFrom(['missDamage', 'missNone']) = expected damage from misses * * Use cases: * - "How much damage do I expect from successful attacks?" * - "What's the damage contribution from critical hits specifically?" * - "How much damage comes from miss effects (like save-for-half spells)?" */ expectedDamageFrom(labels: OutcomeType | OutcomeType[]): number; /** * Returns damage statistics for scenarios where AT LEAST ONE attack results in * the specified outcome(s). * * This method answers "What happens when things go reasonably well?" rather than * "What's the theoretical maximum?" It includes mixed scenarios which are more * common and tactically relevant than pure scenarios. * * Single label examples: * - damageStatsFrom('hit') = damage range when at least one attack hits * - damageStatsFrom('crit') = damage range when at least one attack crits * * Array examples: * - damageStatsFrom(['hit', 'crit']) = damage range when at least one attack succeeds * - damageStatsFrom(['missDamage', 'missNone']) = damage range when at least one attack misses * * Tactical Use Cases: * - "Given that I don't completely whiff (99% of turns), what damage should I expect?" * - "When planning to kill a 60 HP enemy, what's my damage range on successful turns?" * - "Should I use this risky spell if it has good damage when it works?" * - "What's my damage potential when something goes right?" (vs pure failure) * * Combat Planning Examples: * - 4 attacks with 90% hit chance: "96% of the time you'll do 25-150 damage, avg 52" * (Much more useful than "You average 50 damage including complete misses") * - Risk assessment: "80% of successful turns do 40-80 damage, but 20% do 80-150" * - Resource management: "If I hit anything, I'll likely finish this enemy" * * Statistical Note: * This includes mixed scenarios (2 hits + 1 crit, 3 hits + 1 miss, etc.) which * occur far more frequently than pure scenarios. For pure scenarios, use combinedDamageStats. * * KNOWN LIMITATION (multi-attack, single label): the returned `count` is an * EXPECTED COUNT (E[#label], so > 1 for N≥2 attacks, not a probability), and * `avg` is the size-biased conditional mean E[dmg·#label]/E[#label] rather than * E[dmg | the label occurs]. For a single attack both are the plain * conditional figures. Use {@link probAtLeastOne} for the scenario probability. * * @example * // High-level tactical planning * const successStats = query.damageStatsFrom('hit') * const successChance = query.probAtLeastOne('hit') * console.log(`${(successChance*100).toFixed(1)}% chance to do ${successStats.min}-${successStats.max} damage`) */ damageStatsFrom(labels: OutcomeType | OutcomeType[]): { min: number; max: number; avg: number; count: number; }; /** * Returns damage statistics for scenarios where ALL attacks result in the specified * outcome, calculated by leveraging the pure partition of singles. * * This method answers "What's the theoretical best/worst case?" and "What are the * clean mathematical boundaries?" It provides pure scenarios without mixing outcomes. * * Examples: * - combinedDamageStats('hit') = damage range when all attacks hit (none crit, none miss) * - combinedDamageStats('crit') = damage range when all attacks crit (none just hit) * * UI and Display Use Cases: * - Statistics panels showing "MAX Hit Damage" (users expect pure hits, not mixed) * - "Best case scenario" vs "worst case scenario" analysis * - Mathematical verification: "Does our hit damage calculation match manual math?" * - Clean damage type attribution: "How much comes from base hits vs crits?" * * Design and Balance Use Cases: * - Game designers: "What's the damage ceiling if someone gets lucky?" * - Character optimization: "What's my absolute maximum potential?" * - Ability comparison: "Which build has higher crit ceiling?" * - Minimum guaranteed damage: "What's the worst I can do if everything hits?" * * Mathematical Use Cases: * - Validating complex calculations against simple manual math * - Understanding damage component contributions in isolation * - Separating luck (crit variance) from consistency (hit variance) * - Building intuition about damage sources * * When to Use This vs damageStatsFrom(): * - Use THIS for: UI max/min displays, theoretical limits, clean comparisons * - Use damageStatsFrom() for: tactical planning, realistic expectations, mixed scenarios * * Statistical Note: * Pure scenarios (all hits, all crits) are rare but represent clear mathematical * boundaries. These stats help understand the "shape" of your damage potential. * * @example * // UI display logic * const pureHitMax = query.combinedDamageStats('hit').max // Clean "MAX Hit Damage: 90" * const pureCritMax = query.combinedDamageStats('crit').max // Clean "MAX Crit Damage: 168" * * // vs tactical planning (use damageStatsFrom instead) * const realisticRange = query.damageStatsFrom('hit') // Includes mixed scenarios */ combinedDamageStats(targetLabel: OutcomeType): { min: number; max: number; avg: number; count: number; }; /** * Returns the probability that at least one attack carries ANY of the * specified labels (the marginal P(≥1) across the independent attacks). * * Examples: * - `query.probabilityOf('hit')` → 0.88 (probability at least one hit occurs) * - `query.probabilityOf(['hit', 'crit'])` → 0.96 (probability of any success) * * Use cases: * - "What's the chance my resolution includes a success label?" * - "How likely am I to get any hits or crits across all attacks?" * * Note: this must NOT be computed by summing `combined` bin probabilities. A * single combined damage total is reachable by many outcome combinations and * a bin can hold several labels at once, so summing `bin.p` over bins that * contain a label over-counts. The correct marginal is the Poisson-binomial * complement over the per-attack probabilities, i.e. {@link probAtLeastOne}. */ probabilityOf(labels: OutcomeType | OutcomeType[]): number; /** * Returns the probability of missing (any type of miss). * * Example: `query.missChance()` → 0.04 * Use case: "What's the chance I miss completely this turn?" */ missChance(): number; /** * Returns data formatted for plotting damage probability distribution. * * Example: `query.toChartSeries()` → [{x: 0, y: 0.04}, {x: 6, y: 0.1}, ...] * Use case: "I want to visualize my damage distribution in a chart." */ toChartSeries(): Array<{ x: number; y: number; }>; /** * Returns tabular data showing damage values and their probability breakdowns. * * Example: `query.toLabeledTable(['hit', 'crit'])` → * [{damage: 6, total: 0.01, hit: 0.008, crit: 0}, ...] * * Use case: "I want to see exactly how hit/crit probabilities contribute to each damage value." */ toLabeledTable(labels?: OutcomeType[]): Array<{ damage: number; total: number; } & Record>; /** * Returns data for stacked charts with unconditional per-label probability mass per damage. * * - Each dataset value equals the unconditional probability mass for that label at that damage * (i.e., `bin.count[label]`). * - Column sums may be less than the total probability `bin.p` when you omit labels or when * there is unlabeled mass. Include all relevant outcome labels if you need the sum to match. * - This behavior matches tests that expect raw per-label mass (not proportional scaling). * - NOTE: This implementation may break dprcalc.com chart binning at large n, need to test it more. * * @example * query.toStackedChartData(['hit', 'crit']) * // → {labels: [0, 6, 12, ...], datasets: [{label: 'hit', data: [0, 0.03, ...]}, ...]} */ toStackedChartData(labels?: OutcomeType[], epsilon?: number): { labels: number[]; datasets: Array<{ label: string; data: number[]; }>; }; /** * Returns pure mathematical data for cumulative distribution function (CDF). * Shows P(X ≤ x) - the probability of getting at most x damage. * * @param asPercentages Whether to return percentages (0-100) or probabilities (0-1) * @returns Pure data structure with support and cumulative probabilities * * @example * query.toCDFSeries() * // → {support: [0, 6, 12], data: [5.2, 18.3, 45.1]} */ toCDFSeries(asPercentages?: boolean): { support: number[]; data: number[]; }; /** * Returns pure mathematical data for complementary cumulative distribution function (CCDF). * Shows P(X ≥ x) - the probability of getting at least x damage. * * @param asPercentages Whether to return percentages (0-100) or probabilities (0-1) * @returns Pure data structure with support and complementary cumulative probabilities * * @example * query.toCCDFSeries() * // → {support: [0, 6, 12], data: [100, 94.8, 81.7]} */ toCCDFSeries(asPercentages?: boolean): { support: number[]; data: number[]; }; /** Probability of doing strictly more than threshold damage (default >0). */ probDamageGreaterThan(threshold?: number): number; /** All outcome keys actually present (typed & ordered if you pass an order). */ outcomeKeys(order?: OutcomeType[]): OutcomeType[]; /** Total probability per outcome across the PMF. */ outcomeTotals(outcomes?: OutcomeType[]): Map; /** Conditional damage range per outcome (min/avg/max of X | outcome). */ outcomeDamageRanges(outcomes?: OutcomeType[]): Map; /** * Per-outcome probabilities and damage ranges, aggregated over the individual * singles rather than read off the combined distribution. * * `damageRange` is the sum, over every single that can produce the outcome, of * that single's own conditional damage range: "what this outcome contributes * across the whole turn when every attack that can produce it does". Linear in * the number of attacks by construction. * * Prefer this over {@link DiceQuery.snapshot} for a multi-attack query. * `snapshot` reads `damageRange` off the combined PMF's `count`, which the * convolution accumulates as an expected count, so its `avg` is size-biased * for N≥2 (its own doc comment says so). The two agree for a single attack. * * Only outcomes that actually occur appear in the result. * * Like every `singles`-based helper on this class, it describes the singles * and not an explicitly provided `combined`. `Turn.toQuery()` supplies one whose * distribution also contains rider attacks that are absent from `singles` * (an `otherwise([unarmed, unarmed])` flurry, say), so those attacks do not * appear here. For rider-inclusive figures read the combined distribution * directly: {@link DiceQuery.outcomeTotals}, {@link DiceQuery.outcomeDamageRanges}. * * @param outcomes Which outcomes to consider; defaults to every canonical one. */ outcomeStats(outcomes?: readonly OutcomeType[]): Map; /** * Snapshot of the distribution in the exact shape the UI consumes. * - outcome probabilities are "at least one" (and equal to "all" for a single PMF) * - damageRange is conditional on the outcome occurring * * The outcome probabilities use the correct Poisson-binomial marginals * (`atLeastOneProbability` = P(≥1 attack has it), `allProbability` = P(all do)), * so they are always valid probabilities in [0,1]. * * KNOWN LIMITATION (multi-attack): `damageRange.avg` is still aggregated from * the combined PMF's `count`, which the convolution accumulates as an EXPECTED * COUNT, so for N≥2 attacks it is the size-biased mean E[dmg·#label]/E[#label] * rather than a clean conditional expectation. It is correct for a single * attack. */ snapshot(order?: readonly OutcomeType[]): Snapshot; /** * PMF Transformation Methods * * These methods provide a fluent API for transforming dice queries by wrapping * the underlying PMF transformation methods. All operations work on the combined * PMF and return new DiceQuery instances. */ /** * Returns a new DiceQuery with normalized probabilities (ensuring they sum to 1.0). * * @returns New DiceQuery with normalized combined PMF */ normalize(): DiceQuery; /** * Returns a new DiceQuery with low-probability outcomes removed. * * @param eps Minimum probability threshold (defaults to PMF epsilon) * @param keepFinalBin Whether to keep the highest damage bin regardless of probability * @returns New DiceQuery with compacted combined PMF */ compact(eps?: number, keepFinalBin?: boolean): DiceQuery; /** * Returns a new DiceQuery with an additional scaled branch added. * Useful for conditional outcomes like "30% chance of opportunity attack". * * @param branch DiceQuery to add as a scaled branch * @param probability Probability of the branch occurring (0-1) * @returns New DiceQuery combining this query with the scaled branch * * @example * const baseAttack = parse("(d20 + 5 AC 15) * (2d6 + 3)"); * const opportunityAttack = parse("(d20 + 5 AC 15) * (1d8 + 3)"); * const withOpportunity = baseAttack.addScaled(opportunityAttack, 0.3); */ addScaled(branch: DiceQuery, probability: number): DiceQuery; /** * Returns a new DiceQuery with all probabilities scaled by a factor. * Used for conditional scenarios where the entire outcome has reduced probability. * * @param factor Scaling factor for probabilities * @returns New DiceQuery with scaled probabilities * * @example * const fullAttack = parse("(d20 + 5 AC 15) * (2d6 + 3)"); * const conditionalAttack = fullAttack.scaleMass(0.3); // 30% chance scenario */ scaleMass(factor: number): DiceQuery; totalMass(): number; /** * Returns a new DiceQuery with damage values transformed by a function. * Useful for applying modifiers, resistances, or other damage transformations. * * @param damageTransformFunction Function to transform each damage value * @returns New DiceQuery with transformed damage values * * @example * const baseAttack = parse("2d6 + 3"); * const withResistance = baseAttack.mapDamage(dmg => Math.floor(dmg / 2)); // Half damage * const withBonus = baseAttack.mapDamage(dmg => dmg + 5); // +5 damage */ mapDamage(damageTransformFunction: (damageValue: number) => number): DiceQuery; /** * Returns a new DiceQuery with damage values scaled by a factor. * Convenient wrapper around mapDamage for multiplicative scaling. * * @param factor Scaling factor for damage values * @param rounding Rounding method: "floor" (default), "round", or "ceil" * @returns New DiceQuery with scaled damage values * * @example * const baseAttack = parse("2d6 + 3"); * const doubled = baseAttack.scaleDamage(2); // Double damage * const halfDamage = baseAttack.scaleDamage(0.5, "round"); // Half damage, rounded */ scaleDamage(factor: number, rounding?: "floor" | "round" | "ceil"): DiceQuery; /** * Returns a new DiceQuery combining this query with another via convolution. * Equivalent to rolling both queries independently and adding results. * It is important to use this rather than combing()ing the PMFs directly! * This method maintains the provenance of the PMFs which is needed for damage attribution. * Combining the .combined PMFs directly is still valid for DPR calculations but * is not statistically sound for queries. * * @param other DiceQuery to combine with * @param eps Optional epsilon for precision control * @returns New DiceQuery representing the combined outcome * * @example * const mainAttack = parse("(d20 + 5 AC 15) * (2d6 + 3)"); * const bonusAttack = parse("(d20 + 3 AC 15) * (1d6 + 1)"); * const bothAttacks = mainAttack.convolve(bonusAttack); */ convolve(other: DiceQuery): DiceQuery; /** * First-success split over an ordered list of DISTINCT single-swing PMFs. * Each PMF may have different success/subset probabilities (from labels). * * successOutcome: e.g., ["success"] or ["hit", "crit"] * subsetOutcome: e.g., ["subset"] or ["crit"] where subset ⊆ success * * Returns tuple: [pFirstNonSubset, pFirstSubset, pAnySuccess, pNone] */ firstSuccessSplit(successOutcome: OutcomeType | OutcomeType[], subsetOutcome: OutcomeType | OutcomeType[], eps?: number): readonly [pSuccess: number, pSubset: number, pAny: number, pNone: number]; } export type OutcomeSnapshot = { atLeastOneProbability: number; allProbability: number; damageRange: { min: number; avg: number; max: number; }; }; export type Snapshot = { averageDPR: number; damageChance: number; percentiles: { p25: number; p50: number; p75: number; }; outcomes: Map; }; //# sourceMappingURL=query.d.ts.map