181 lines
5.5 KiB
TypeScript
181 lines
5.5 KiB
TypeScript
/**
|
|
* 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<string, number> {
|
|
const nodeMap = new Map(allNodes.map((n) => [n.id, n]));
|
|
const needs = new Map<string, number>();
|
|
|
|
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<string, number>();
|
|
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<string, number>();
|
|
const spendingIncoming = new Map<string, number>();
|
|
const updatedFunnels = new Map<string, FunnelNodeData>();
|
|
|
|
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;
|
|
});
|
|
}
|