/** * Flow simulation engine — pure function, no React dependencies. * * Replaces the random-noise simulation with actual flow logic: * inflow → overflow distribution → spending drain → outcome accumulation */ import type { FlowNode, FunnelNodeData, OutcomeNodeData, SourceNodeData } from './types' export interface SimulationConfig { tickDivisor: number // inflowRate divided by this per tick spendingRateHealthy: number // drain multiplier in healthy zone spendingRateOverflow: number // drain multiplier above maxThreshold spendingRateCritical: number // drain multiplier below minThreshold } export const DEFAULT_CONFIG: SimulationConfig = { tickDivisor: 10, spendingRateHealthy: 0.5, spendingRateOverflow: 0.8, spendingRateCritical: 0.1, } export function simulateTick( nodes: FlowNode[], config: SimulationConfig = DEFAULT_CONFIG, ): FlowNode[] { const { tickDivisor, spendingRateHealthy, spendingRateOverflow, spendingRateCritical } = config // Separate funnels (sorted top-to-bottom) from outcomes const funnelNodes = nodes .filter((n) => n.type === 'funnel') .sort((a, b) => a.position.y - b.position.y) // Source nodes: sum allocations into funnel inflow const sourceInflow = new Map() nodes.forEach((node) => { if (node.type !== 'source') return const data = node.data as SourceNodeData for (const alloc of data.targetAllocations) { const share = (data.flowRate / tickDivisor) * (alloc.percentage / 100) sourceInflow.set(alloc.targetId, (sourceInflow.get(alloc.targetId) ?? 0) + share) } }) // Accumulators for inter-node transfers const overflowIncoming = new Map() const spendingIncoming = new Map() // Store updated funnel data const updatedFunnels = new Map() for (const node of funnelNodes) { const src = node.data as FunnelNodeData const data: FunnelNodeData = { ...src } // 1. Natural inflow + source node inflow let value = data.currentValue + data.inflowRate / tickDivisor + (sourceInflow.get(node.id) ?? 0) // 2. Overflow received from upstream funnels value += overflowIncoming.get(node.id) ?? 0 // 3. Cap at maxCapacity value = Math.min(value, data.maxCapacity) // 4. Distribute overflow when above maxThreshold if (value > data.maxThreshold && data.overflowAllocations.length > 0) { const excess = value - data.maxThreshold 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 } // 5. Spending drain (gated by zone) 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) // cannot drain below 0 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) } // Rebuild node array with updated 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 (newStatus === 'blocked') { // Don't auto-change blocked status } else if (data.phases && data.phases.length > 0) { // Phase-aware status const anyUnlocked = data.phases.some(p => newReceived >= p.fundingThreshold) const allUnlocked = data.phases.every(p => newReceived >= p.fundingThreshold) const allTasksDone = data.phases.every(p => p.tasks.every(t => t.completed)) if (allUnlocked && allTasksDone) { newStatus = 'completed' } else if (anyUnlocked) { newStatus = 'in-progress' } } else { // Fallback: no phases defined if (data.fundingTarget > 0 && newReceived >= data.fundingTarget) { newStatus = 'completed' } else if (newReceived > 0 && newStatus === 'not-started') { newStatus = 'in-progress' } } return { ...node, data: { ...data, fundingReceived: newReceived, status: newStatus }, } } return node }) }