/** * Flow simulation engine — pure function, no framework dependencies. * Ported from rfunds-online/lib/simulation.ts. */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, SufficiencyState } from "./types"; export interface SimulationConfig { tickDivisor: number; spendingRateHealthy: number; spendingRateOverflow: number; spendingRateCritical: number; } export const DEFAULT_CONFIG: SimulationConfig = { tickDivisor: 10, spendingRateHealthy: 0.5, spendingRateOverflow: 0.8, spendingRateCritical: 0.1, }; export function computeSufficiencyState(data: FunnelNodeData): SufficiencyState { const threshold = data.sufficientThreshold ?? data.maxThreshold; if (data.currentValue >= data.maxCapacity) return "abundant"; if (data.currentValue >= threshold) return "sufficient"; return "seeking"; } export function computeNeedWeights( targetIds: string[], allNodes: FlowNode[], ): Map { const nodeMap = new Map(allNodes.map((n) => [n.id, n])); const needs = new Map(); for (const tid of targetIds) { const node = nodeMap.get(tid); if (!node) { needs.set(tid, 0); continue; } if (node.type === "funnel") { const d = node.data as FunnelNodeData; const threshold = d.sufficientThreshold ?? d.maxThreshold; const need = Math.max(0, 1 - d.currentValue / (threshold || 1)); needs.set(tid, need); } else if (node.type === "outcome") { const d = node.data as OutcomeNodeData; const need = Math.max(0, 1 - d.fundingReceived / Math.max(d.fundingTarget, 1)); needs.set(tid, need); } else { needs.set(tid, 0); } } const totalNeed = Array.from(needs.values()).reduce((s, n) => s + n, 0); const weights = new Map(); if (totalNeed === 0) { const equal = targetIds.length > 0 ? 100 / targetIds.length : 0; targetIds.forEach((id) => weights.set(id, equal)); } else { needs.forEach((need, id) => { weights.set(id, (need / totalNeed) * 100); }); } return weights; } export function computeSystemSufficiency(nodes: FlowNode[]): number { let sum = 0; let count = 0; for (const node of nodes) { if (node.type === "funnel") { const d = node.data as FunnelNodeData; const threshold = d.sufficientThreshold ?? d.maxThreshold; sum += Math.min(1, d.currentValue / (threshold || 1)); count++; } else if (node.type === "outcome") { const d = node.data as OutcomeNodeData; sum += Math.min(1, d.fundingReceived / Math.max(d.fundingTarget, 1)); count++; } } return count > 0 ? sum / count : 0; } export function simulateTick( nodes: FlowNode[], config: SimulationConfig = DEFAULT_CONFIG, ): FlowNode[] { const { tickDivisor, spendingRateHealthy, spendingRateOverflow, spendingRateCritical } = config; const funnelNodes = nodes .filter((n) => n.type === "funnel") .sort((a, b) => a.position.y - b.position.y); const overflowIncoming = new Map(); const spendingIncoming = new Map(); const updatedFunnels = new Map(); for (const node of funnelNodes) { const src = node.data as FunnelNodeData; const data: FunnelNodeData = { ...src }; let value = data.currentValue + data.inflowRate / tickDivisor; value += overflowIncoming.get(node.id) ?? 0; value = Math.min(value, data.maxCapacity); if (value > data.maxThreshold && data.overflowAllocations.length > 0) { const excess = value - data.maxThreshold; if (data.dynamicOverflow) { const targetIds = data.overflowAllocations.map((a) => a.targetId); const needWeights = computeNeedWeights(targetIds, nodes); for (const alloc of data.overflowAllocations) { const weight = needWeights.get(alloc.targetId) ?? 0; const share = excess * (weight / 100); overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); } } else { for (const alloc of data.overflowAllocations) { const share = excess * (alloc.percentage / 100); overflowIncoming.set(alloc.targetId, (overflowIncoming.get(alloc.targetId) ?? 0) + share); } } value = data.maxThreshold; } if (value > 0 && data.spendingAllocations.length > 0) { let rateMultiplier: number; if (value > data.maxThreshold) { rateMultiplier = spendingRateOverflow; } else if (value >= data.minThreshold) { rateMultiplier = spendingRateHealthy; } else { rateMultiplier = spendingRateCritical; } let drain = (data.inflowRate / tickDivisor) * rateMultiplier; drain = Math.min(drain, value); value -= drain; for (const alloc of data.spendingAllocations) { const share = drain * (alloc.percentage / 100); spendingIncoming.set(alloc.targetId, (spendingIncoming.get(alloc.targetId) ?? 0) + share); } } data.currentValue = Math.max(0, value); updatedFunnels.set(node.id, data); } return nodes.map((node) => { if (node.type === "funnel" && updatedFunnels.has(node.id)) { return { ...node, data: updatedFunnels.get(node.id)! }; } if (node.type === "outcome") { const data = node.data as OutcomeNodeData; const incoming = spendingIncoming.get(node.id) ?? 0; if (incoming <= 0) return node; const newReceived = Math.min( data.fundingTarget > 0 ? data.fundingTarget * 1.05 : Infinity, data.fundingReceived + incoming, ); let newStatus = data.status; if (data.fundingTarget > 0 && newReceived >= data.fundingTarget && newStatus !== "blocked") { newStatus = "completed"; } else if (newReceived > 0 && newStatus === "not-started") { newStatus = "in-progress"; } return { ...node, data: { ...data, fundingReceived: newReceived, status: newStatus } }; } return node; }); }