156 lines
5.1 KiB
TypeScript
156 lines
5.1 KiB
TypeScript
/**
|
|
* 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<string, number>()
|
|
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<string, number>()
|
|
const spendingIncoming = new Map<string, number>()
|
|
|
|
// Store updated funnel data
|
|
const updatedFunnels = new Map<string, FunnelNodeData>()
|
|
|
|
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
|
|
})
|
|
}
|