UNPKG

@swrpg-online/monte-carlo

Version:

A library for performing Monte Carlo simulations with the Star Wars RPG narrative dice system by Fantasy Flight Games

491 lines (490 loc) 21.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.MonteCarlo = exports.MonteCarloError = void 0; const dice_1 = require("@swrpg-online/dice"); class MonteCarloError extends Error { constructor(message) { super(message); this.name = "MonteCarloError"; } } exports.MonteCarloError = MonteCarloError; class MonteCarlo { constructor(dicePool, iterations = 10000, runSimulate = true) { this.histogram = { netSuccesses: {}, netAdvantages: {}, triumphs: {}, despairs: {}, lightSide: {}, darkSide: {}, }; this.statsCache = new Map(); this.runningStats = { successCount: 0, criticalSuccessCount: 0, criticalFailureCount: 0, netPositiveCount: 0, sumSuccesses: 0, sumAdvantages: 0, sumTriumphs: 0, sumFailures: 0, sumThreats: 0, sumDespair: 0, sumLightSide: 0, sumDarkSide: 0, sumSquaredSuccesses: 0, sumSquaredAdvantages: 0, sumSquaredThreats: 0, sumSquaredFailures: 0, sumSquaredDespair: 0, sumSquaredLightSide: 0, sumSquaredDarkSide: 0, sumSquaredTriumphs: 0, }; this.results = []; this.validateDicePool(dicePool); this.validateIterations(iterations); this.dicePool = dicePool; this.iterations = iterations; this.resetRunningStats(); if (runSimulate) { this.simulate(); } } validateDicePool(dicePool) { if (!dicePool || typeof dicePool !== "object") { throw new MonteCarloError("Invalid dice pool: must be a valid DicePool object"); } const diceTypes = [ "abilityDice", "proficiencyDice", "boostDice", "setBackDice", "difficultyDice", "challengeDice", "forceDice", ]; // Check if at least one die type is present const hasAnyDice = diceTypes.some((type) => dicePool[type] && dicePool[type] > 0); if (!hasAnyDice) { throw new MonteCarloError("Invalid dice pool: must contain at least one die"); } // Validate each die count is non-negative diceTypes.forEach((type) => { const count = dicePool[type]; if (count !== undefined && (count < 0 || !Number.isInteger(count))) { throw new MonteCarloError(`Invalid ${type}: must be a non-negative integer`); } }); } validateIterations(iterations) { if (!Number.isInteger(iterations)) { throw new MonteCarloError("Iterations must be an integer"); } if (iterations < MonteCarlo.MIN_ITERATIONS) { throw new MonteCarloError(`Iterations must be at least ${MonteCarlo.MIN_ITERATIONS}`); } if (iterations > MonteCarlo.MAX_ITERATIONS) { throw new MonteCarloError(`Iterations must not exceed ${MonteCarlo.MAX_ITERATIONS}`); } } calculateHistogramStats(histogram, totalCount) { let sum = 0; let sumSquares = 0; let count = 0; // Single pass to calculate sum and sum of squares for (const [value, freq] of Object.entries(histogram)) { const val = parseInt(value); sum += val * freq; sumSquares += val * val * freq; count += freq; } const mean = sum / count; const variance = sumSquares / count - mean * mean; const stdDev = Math.sqrt(Math.max(0, variance)); // Avoid negative values due to floating point errors return { mean, stdDev, sum, sumSquares }; } calculateSkewness(histogram, stats) { if (stats.stdDev === 0) return 0; let sumCubedDeviations = 0; let totalCount = 0; for (const [value, freq] of Object.entries(histogram)) { const deviation = (parseInt(value) - stats.mean) / stats.stdDev; sumCubedDeviations += Math.pow(deviation, 3) * freq; totalCount += freq; } return sumCubedDeviations / totalCount; } calculateKurtosis(histogram, stats) { if (stats.stdDev === 0) return 0; let sumFourthPowerDeviations = 0; let totalCount = 0; for (const [value, freq] of Object.entries(histogram)) { const deviation = (parseInt(value) - stats.mean) / stats.stdDev; sumFourthPowerDeviations += Math.pow(deviation, 4) * freq; totalCount += freq; } return sumFourthPowerDeviations / totalCount - 3; } findOutliers(histogram, stats) { if (stats.stdDev === 0) return []; const threshold = 2; return Object.entries(histogram) .filter(([value]) => Math.abs(parseInt(value) - stats.mean) > threshold * stats.stdDev) .map(([value]) => parseInt(value)); } analyzeDistribution(histogram, totalCount) { // Calculate basic statistics in a single pass const stats = this.calculateHistogramStats(histogram, totalCount); return { skewness: this.calculateSkewness(histogram, stats), kurtosis: this.calculateKurtosis(histogram, stats), outliers: this.findOutliers(histogram, stats), modes: this.findModes(histogram), percentiles: this.calculatePercentiles(histogram, totalCount), }; } average(selector) { const selectorName = typeof selector === "function" ? selector.name || "custom" : selector.name; const cacheKey = `avg_${selectorName}`; if (this.statsCache.has(cacheKey)) { return this.statsCache.get(cacheKey); } let sum = 0; if (typeof selector === "function") { // For function selectors, calculate sum directly sum = this.results.reduce((acc, roll) => { const value = selector(roll); if (typeof value !== "number" || isNaN(value)) { throw new MonteCarloError(`Invalid selector result: ${value}`); } return acc + value; }, 0); } else { // For named selectors, use running stats switch (selector.name) { case "successes": sum = this.runningStats.sumSuccesses; break; case "advantages": sum = this.runningStats.sumAdvantages; break; case "triumphs": sum = this.runningStats.sumTriumphs; break; case "failures": sum = this.runningStats.sumFailures; break; case "threats": sum = this.runningStats.sumThreats; break; case "despair": sum = this.runningStats.sumDespair; break; case "lightSide": sum = this.runningStats.sumLightSide; break; case "darkSide": sum = this.runningStats.sumDarkSide; break; default: throw new MonteCarloError(`Unknown selector: ${selector.name}`); } } const avg = sum / this.iterations; this.statsCache.set(cacheKey, avg); return avg; } standardDeviation(selector) { const selectorName = typeof selector === "function" ? selector.name || "custom" : selector.name; const cacheKey = `std_${selectorName}`; if (this.statsCache.has(cacheKey)) { return this.statsCache.get(cacheKey); } const avg = this.average(selector); let squareSum = 0; if (typeof selector === "function") { // For function selectors, calculate square sum directly squareSum = this.results.reduce((acc, roll) => { const value = selector(roll); if (typeof value !== "number" || isNaN(value)) { throw new MonteCarloError(`Invalid selector result: ${value}`); } return acc + value * value; }, 0); } else { // For named selectors, use running stats switch (selector.name) { case "successes": squareSum = this.runningStats.sumSquaredSuccesses; break; case "advantages": squareSum = this.runningStats.sumSquaredAdvantages; break; case "threats": squareSum = this.runningStats.sumSquaredThreats; break; case "triumphs": squareSum = this.runningStats.sumSquaredTriumphs; break; case "failures": squareSum = this.runningStats.sumSquaredFailures; break; case "despair": squareSum = this.runningStats.sumSquaredDespair; break; case "lightSide": squareSum = this.runningStats.sumSquaredLightSide; break; case "darkSide": squareSum = this.runningStats.sumSquaredDarkSide; break; default: throw new MonteCarloError(`Unknown selector: ${selector.name}`); } } const stdDev = Math.sqrt(Math.abs(squareSum / this.iterations - avg * avg)); this.statsCache.set(cacheKey, stdDev); return stdDev; } resetRunningStats() { this.runningStats = { successCount: 0, criticalSuccessCount: 0, criticalFailureCount: 0, netPositiveCount: 0, sumSuccesses: 0, sumAdvantages: 0, sumTriumphs: 0, sumFailures: 0, sumThreats: 0, sumDespair: 0, sumLightSide: 0, sumDarkSide: 0, sumSquaredSuccesses: 0, sumSquaredAdvantages: 0, sumSquaredThreats: 0, sumSquaredFailures: 0, sumSquaredDespair: 0, sumSquaredLightSide: 0, sumSquaredDarkSide: 0, sumSquaredTriumphs: 0, }; } updateHistogram(result) { // Update net successes with direct array access const netSuccesses = result.successes - result.failures; this.histogram.netSuccesses[netSuccesses] = (this.histogram.netSuccesses[netSuccesses] || 0) + 1; // Update net advantages with direct array access const netAdvantages = result.advantages - result.threats; this.histogram.netAdvantages[netAdvantages] = (this.histogram.netAdvantages[netAdvantages] || 0) + 1; // Update other histograms with direct array access this.histogram.triumphs[result.triumphs] = (this.histogram.triumphs[result.triumphs] || 0) + 1; this.histogram.despairs[result.despair] = (this.histogram.despairs[result.despair] || 0) + 1; this.histogram.lightSide[result.lightSide] = (this.histogram.lightSide[result.lightSide] || 0) + 1; this.histogram.darkSide[result.darkSide] = (this.histogram.darkSide[result.darkSide] || 0) + 1; // Update running statistics this.runningStats.sumSuccesses += result.successes; this.runningStats.sumAdvantages += result.advantages; this.runningStats.sumTriumphs += result.triumphs; this.runningStats.sumFailures += result.failures; this.runningStats.sumThreats += result.threats; this.runningStats.sumDespair += result.despair; this.runningStats.sumLightSide += result.lightSide; this.runningStats.sumDarkSide += result.darkSide; this.runningStats.sumSquaredSuccesses += result.successes * result.successes; this.runningStats.sumSquaredAdvantages += result.advantages * result.advantages; this.runningStats.sumSquaredThreats += result.threats * result.threats; this.runningStats.sumSquaredFailures += result.failures * result.failures; this.runningStats.sumSquaredDespair += result.despair * result.despair; this.runningStats.sumSquaredLightSide += result.lightSide * result.lightSide; this.runningStats.sumSquaredDarkSide += result.darkSide * result.darkSide; this.runningStats.sumSquaredTriumphs += result.triumphs * result.triumphs; if (netSuccesses > 0) { this.runningStats.successCount++; if (netAdvantages > 0) { this.runningStats.netPositiveCount++; } } if (result.triumphs > 0) this.runningStats.criticalSuccessCount++; if (result.despair > 0) this.runningStats.criticalFailureCount++; } simulate() { try { this.resetHistogram(); this.resetRunningStats(); this.statsCache.clear(); this.results = []; // Run simulations and update histograms in a single pass for (let i = 0; i < this.iterations; i++) { const rollResult = (0, dice_1.roll)(this.dicePool); this.results.push(rollResult.summary); this.updateHistogram(rollResult.summary); } // Calculate probabilities using running statistics const successProbability = this.runningStats.successCount / this.iterations; const criticalSuccessProbability = this.runningStats.criticalSuccessCount / this.iterations; const criticalFailureProbability = this.runningStats.criticalFailureCount / this.iterations; const netPositiveProbability = this.runningStats.netPositiveCount / this.iterations; // Calculate averages using running statistics const averages = { successes: this.runningStats.sumSuccesses / this.iterations, advantages: this.runningStats.sumAdvantages / this.iterations, triumphs: this.runningStats.sumTriumphs / this.iterations, failures: this.runningStats.sumFailures / this.iterations, threats: this.runningStats.sumThreats / this.iterations, despair: this.runningStats.sumDespair / this.iterations, lightSide: this.runningStats.sumLightSide / this.iterations, darkSide: this.runningStats.sumDarkSide / this.iterations, }; // Calculate standard deviations using running statistics const standardDeviations = { successes: Math.sqrt(this.runningStats.sumSquaredSuccesses / this.iterations - averages.successes * averages.successes), advantages: Math.sqrt(this.runningStats.sumSquaredAdvantages / this.iterations - averages.advantages * averages.advantages), triumphs: Math.sqrt(this.runningStats.sumSquaredTriumphs / this.iterations - averages.triumphs * averages.triumphs), failures: Math.sqrt(this.runningStats.sumSquaredFailures / this.iterations - averages.failures * averages.failures), threats: Math.sqrt(this.runningStats.sumSquaredThreats / this.iterations - averages.threats * averages.threats), despair: Math.sqrt(this.runningStats.sumSquaredDespair / this.iterations - averages.despair * averages.despair), lightSide: Math.sqrt(this.runningStats.sumSquaredLightSide / this.iterations - averages.lightSide * averages.lightSide), darkSide: Math.sqrt(this.runningStats.sumSquaredDarkSide / this.iterations - averages.darkSide * averages.darkSide), }; // Calculate medians using histogram data const medians = { successes: this.calculateMedianFromHistogram(this.histogram.netSuccesses), advantages: this.calculateMedianFromHistogram(this.histogram.netAdvantages), triumphs: this.calculateMedianFromHistogram(this.histogram.triumphs), failures: this.calculateMedianFromHistogram(this.histogram.despairs), threats: this.calculateMedianFromHistogram(this.histogram.netAdvantages), despair: this.calculateMedianFromHistogram(this.histogram.despairs), lightSide: this.calculateMedianFromHistogram(this.histogram.lightSide), darkSide: this.calculateMedianFromHistogram(this.histogram.darkSide), }; // Calculate analysis for each histogram category const analysis = { netSuccesses: this.analyzeDistribution(this.histogram.netSuccesses, this.iterations), netAdvantages: this.analyzeDistribution(this.histogram.netAdvantages, this.iterations), triumphs: this.analyzeDistribution(this.histogram.triumphs, this.iterations), despairs: this.analyzeDistribution(this.histogram.despairs, this.iterations), lightSide: this.analyzeDistribution(this.histogram.lightSide, this.iterations), darkSide: this.analyzeDistribution(this.histogram.darkSide, this.iterations), }; return { averages, medians, standardDeviations, successProbability, criticalSuccessProbability, criticalFailureProbability, netPositiveProbability, histogram: this.histogram, analysis, }; } catch (error) { if (error instanceof Error) { throw new MonteCarloError(`Simulation failed: ${error.message}`); } throw new MonteCarloError("Simulation failed with unknown error"); } } resetHistogram() { this.histogram = { netSuccesses: {}, netAdvantages: {}, triumphs: {}, despairs: {}, lightSide: {}, darkSide: {}, }; } calculateMedianFromHistogram(histogram) { const entries = Object.entries(histogram) .map(([value, count]) => ({ value: parseInt(value), count })) .sort((a, b) => a.value - b.value); if (entries.length === 0) { return 0; } let runningCount = 0; const targetCount = this.iterations / 2; for (const { value, count } of entries) { runningCount += count; if (runningCount >= targetCount) { return value; } } return entries[entries.length - 1].value; } findModes(histogram) { const entries = Object.entries(histogram); if (entries.length === 0) return []; const maxCount = Math.max(...entries.map(([, count]) => count)); return entries .filter(([, count]) => count === maxCount) .map(([value]) => parseInt(value)); } calculatePercentiles(histogram, totalCount) { const sortedEntries = Object.entries(histogram) .map(([value, count]) => ({ value: parseInt(value), count })) .sort((a, b) => a.value - b.value); if (sortedEntries.length === 0) { return {}; } const percentiles = {}; let runningCount = 0; // Calculate percentiles at specific points const targetPercentiles = [25, 50, 75, 90]; let currentTargetIndex = 0; // Find the value for each percentile for (const { value, count } of sortedEntries) { runningCount += count; const currentPercentile = (runningCount / totalCount) * 100; // Check if we've passed any target percentiles while (currentTargetIndex < targetPercentiles.length && currentPercentile >= targetPercentiles[currentTargetIndex]) { percentiles[targetPercentiles[currentTargetIndex]] = value; currentTargetIndex++; } } // If we haven't reached all target percentiles, use the maximum value const maxValue = sortedEntries[sortedEntries.length - 1].value; while (currentTargetIndex < targetPercentiles.length) { percentiles[targetPercentiles[currentTargetIndex]] = maxValue; currentTargetIndex++; } return percentiles; } } exports.MonteCarlo = MonteCarlo; MonteCarlo.MIN_ITERATIONS = 100; MonteCarlo.MAX_ITERATIONS = 1000000;