rspace-online/modules/funds/lib/simulation.ts

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;
});
}