Derive cycles/months from the reserve-simulation engine (Marty-approved model): recycle carried campaigns, bank profits, roll final payout; carryover across phases; L7 lanes camp-only. All consumers (table, timeline, chart, Year-1, ROI, compare, plan text, ladder, My Progress) read one engine. Honest timelines (19d cycles). Tests extract the live code and cross-check an independent simulator.
This commit is contained in:
+101
-92
@@ -1,105 +1,114 @@
|
||||
const LEVELS = [
|
||||
{level:1, act:1, camp:13, total:14, payout:17.17, netProfit:4.17},
|
||||
{level:2, act:7, camp:77, total:84, payout:118.91, netProfit:34.91},
|
||||
{level:3, act:14, camp:163, total:177, payout:211.57, netProfit:48.57},
|
||||
{level:4, act:36, camp:310, total:346, payout:388.80, netProfit:78.80},
|
||||
{level:5, act:72, camp:620, total:692, payout:777.60, netProfit:157.60},
|
||||
{level:6, act:120, camp:1200, total:1320, payout:1555.20,netProfit:355.20},
|
||||
{level:7, act:240, camp:2400, total:2640, payout:3240.00,netProfit:840.00},
|
||||
];
|
||||
// CBP calculator engine tests. Extracts the LIVE code from index.html (no
|
||||
// duplicated constants — the old copy of this file drifted) and cross-checks
|
||||
// computePlan against an independently written event simulator.
|
||||
// node test_scenarios.js [path-to-index.html]
|
||||
const fs = require("fs");
|
||||
const path = process.argv[2] || __dirname + "/index.html";
|
||||
const html = fs.readFileSync(path, "utf8");
|
||||
|
||||
const HYBRIDS = {
|
||||
'L1-only': { label: 'L1 Only', reinvest: 13, payout: 17.17, profit: 4.17, cycles: 9, months: 3.0, daysPer: 19 },
|
||||
'L2+L1': { label: 'L2 + L1', reinvest: 90, payout: 118.91, profit: 28.91, cycles: 4, months: 2.5, daysPer: 19 },
|
||||
'L3+L1': { label: 'L3 + L1', reinvest: 163, payout: 211.57, profit: 48.57, cycles: 4, months: 2.5, daysPer: 19 },
|
||||
'L4+L3': { label: 'L4 + L3', reinvest: 450, payout: 583.20, profit: 133.20,cycles: 5, months: 3.0, daysPer: 19 },
|
||||
'L5+L4': { label: 'L5 + L4', reinvest: 900, payout: 1166.40,profit: 266.40,cycles: 4, months: 2.0, daysPer: 19 },
|
||||
'L6': { label: 'L6', reinvest: 1200, payout: 1555.20,profit: 355.20,cycles: 4, months: 2.0, daysPer: 19 },
|
||||
'L7': { label: 'Single L7', reinvest: 2400, payout: 3240.00,profit: 840.00,cycles: 7, months: 3.0, daysPer: 19 },
|
||||
'3xL7': { label: '3× L7 Endgame', reinvest: 7200, payout: 9720, profit: 2520, cycles: 1, months: 0.63, daysPer: 19 },
|
||||
};
|
||||
const start = html.indexOf("const LEVELS = [");
|
||||
const end = html.indexOf("let chart1");
|
||||
if (start < 0 || end < 0) { console.error("FATAL: extraction markers missing"); process.exit(1); }
|
||||
// Indirect eval: keeps the extracted declarations out of this module's scope;
|
||||
// the trailing expression hands back everything we need.
|
||||
const { LEVELS, HYBRIDS, PHASE_SLOTS, computePlan, phaseInfo, getPhaseSequence,
|
||||
estimateMonths, estimateYear1Cum, buildMonthlyTimeline } = (0, eval)(
|
||||
html.slice(start, end) +
|
||||
";({LEVELS, HYBRIDS, PHASE_SLOTS, computePlan, phaseInfo, getPhaseSequence, estimateMonths, estimateYear1Cum, buildMonthlyTimeline})"
|
||||
);
|
||||
|
||||
function getLevelCost(upTo) {
|
||||
if (upTo <= 0) return 0;
|
||||
return LEVELS.slice(0, upTo).reduce((s, l) => s + l.total, 0);
|
||||
let failures = 0;
|
||||
function check(label, cond, detail) {
|
||||
if (cond) { console.log(" ok " + label); }
|
||||
else { failures++; console.log(" FAIL " + label + (detail ? " — " + detail : "")); }
|
||||
}
|
||||
|
||||
function getEffectiveStart(gift, selfF) {
|
||||
return Math.max(gift, selfF);
|
||||
// ── Independent simulator: same rules, different formulation ────────────────
|
||||
function simulate(phases) {
|
||||
const out = {};
|
||||
let bal = 0;
|
||||
const activated = new Set();
|
||||
for (let lv = 1; lv <= Math.max(...PHASE_SLOTS[phases[0]]); lv++) activated.add(lv);
|
||||
for (let i = 0; i < phases.length; i++) {
|
||||
const k = phases[i];
|
||||
if (k === "3xL7") { out[k] = 1; break; }
|
||||
const cur = PHASE_SLOTS[k];
|
||||
const nxt = [...PHASE_SLOTS[phases[i + 1]]];
|
||||
const keep = [];
|
||||
for (const lv of cur) { const j = nxt.indexOf(lv); if (j >= 0) { keep.push(lv); nxt.splice(j, 1); } }
|
||||
let buyIn = 0;
|
||||
for (const lv of nxt) { buyIn += LEVELS[lv - 1].camp + (activated.has(lv) ? 0 : LEVELS[lv - 1].act); activated.add(lv); }
|
||||
let cycles = 0;
|
||||
while (cycles < 500) {
|
||||
cycles++;
|
||||
// payout event for the whole hybrid
|
||||
let cash = bal;
|
||||
for (const lv of cur) cash += LEVELS[lv - 1].payout;
|
||||
// to upgrade now: re-buy only kept campaigns, then afford buy-in
|
||||
const keptCamp = keep.reduce((s, lv) => s + LEVELS[lv - 1].camp, 0);
|
||||
if (cash - keptCamp >= buyIn) { bal = cash - keptCamp - buyIn; break; }
|
||||
// otherwise recycle everything, bank the profit
|
||||
for (const lv of cur) cash -= LEVELS[lv - 1].camp;
|
||||
bal = cash;
|
||||
}
|
||||
out[k] = cycles;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getPhaseSequence(effectiveStart) {
|
||||
if (effectiveStart >= 5) return ['L5+L4','L6','L7','3xL7'];
|
||||
if (effectiveStart >= 4) return ['L4+L3','L5+L4','L6','L7','3xL7'];
|
||||
if (effectiveStart >= 3) return ['L3+L1','L4+L3','L5+L4','L6','L7','3xL7'];
|
||||
if (effectiveStart >= 2) return ['L2+L1','L3+L1','L4+L3','L5+L4','L6','L7','3xL7'];
|
||||
return ['L1-only','L2+L1','L3+L1','L4+L3','L5+L4','L6','L7','3xL7'];
|
||||
}
|
||||
|
||||
function estimateMonths(phases) {
|
||||
let total = 0;
|
||||
console.log("1) computePlan vs independent simulator, every start level 0-7:");
|
||||
for (let eff = 0; eff <= 7; eff++) {
|
||||
const phases = getPhaseSequence(eff);
|
||||
const plan = computePlan(phases);
|
||||
const sim = simulate(phases);
|
||||
for (const k of phases) {
|
||||
if (k === '3xL7') break;
|
||||
total += HYBRIDS[k].months;
|
||||
check(`eff=${eff} ${k}: ${plan[k].cycles}`, plan[k].cycles === sim[k], `sim says ${sim[k]}`);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
function estimateYear1Cum(phases, commPct, giftLevel) {
|
||||
let cum = 0;
|
||||
let months = 0;
|
||||
console.log("2) hybrid payout/profit constants reconcile with LEVELS:");
|
||||
for (const [k, slots] of Object.entries(PHASE_SLOTS)) {
|
||||
const p = HYBRIDS[k];
|
||||
const payout = slots.reduce((s, lv) => s + LEVELS[lv - 1].payout, 0);
|
||||
const camp = slots.reduce((s, lv) => s + LEVELS[lv - 1].camp, 0);
|
||||
check(`${k} payout ${p.payout}`, Math.abs(p.payout - payout) < 0.01, `slots say ${payout.toFixed(2)}`);
|
||||
check(`${k} profit ${p.profit}`, Math.abs(p.profit - (payout - camp)) < 0.01, `slots say ${(payout - camp).toFixed(2)}`);
|
||||
}
|
||||
|
||||
console.log("3) months honest (cycles x 19d / 30.4):");
|
||||
{
|
||||
const phases = getPhaseSequence(0);
|
||||
const PI = phaseInfo(phases);
|
||||
for (const k of phases) {
|
||||
if (k === '3xL7') break;
|
||||
const p = HYBRIDS[k];
|
||||
const yourCut = p.payout * (commPct / 100);
|
||||
const cyclesInYear1 = Math.min(p.cycles, Math.max(0, (12 - months) / (p.months / p.cycles)));
|
||||
const actual = Math.floor(cyclesInYear1);
|
||||
for (let i = 0; i < actual; i++) cum += yourCut;
|
||||
months += p.months;
|
||||
if (months >= 12) break;
|
||||
}
|
||||
return Math.round(cum);
|
||||
}
|
||||
|
||||
function buildSCENARIOS() {
|
||||
const levels = [
|
||||
{ giftLvl: 1, selfLvl: 0, id: 'gift1', label: 'L1 Gift ($14)', yourCost: 14 },
|
||||
{ giftLvl: 0, selfLvl: 1, id: 'self1', label: 'Self L1 ($14)', yourCost: 0 },
|
||||
{ giftLvl: 2, selfLvl: 0, id: 'gift2', label: 'L1-L2 Gift ($98)', yourCost: 98 },
|
||||
{ giftLvl: 0, selfLvl: 2, id: 'self2', label: 'Self L2 ($98)', yourCost: 0 },
|
||||
{ giftLvl: 3, selfLvl: 0, id: 'gift3', label: 'L1-L3 Gift ($263)', yourCost: 263},
|
||||
{ giftLvl: 0, selfLvl: 3, id: 'self3', label: 'Self L3 ($263)', yourCost: 0 },
|
||||
{ giftLvl: 4, selfLvl: 0, id: 'gift4', label: 'L1-L4 Gift ($593)', yourCost: 593},
|
||||
{ giftLvl: 0, selfLvl: 4, id: 'self4', label: 'Self L4 ($593)', yourCost: 0 },
|
||||
{ giftLvl: 5, selfLvl: 0, id: 'gift5', label: 'L1-L5 Gift ($1,253)', yourCost:1253},
|
||||
{ giftLvl: 0, selfLvl: 5, id: 'self5', label: 'Self L5 ($1,253)', yourCost: 0 },
|
||||
];
|
||||
return levels.map(function(s) {
|
||||
const eff = Math.max(s.giftLvl, s.selfLvl);
|
||||
const phases = getPhaseSequence(eff);
|
||||
const t = Math.round(estimateMonths(phases));
|
||||
const y = estimateYear1Cum(phases, 10, s.giftLvl);
|
||||
const eg = phases.filter(function(k) { return k !== '3xL7'; })
|
||||
.reduce(function(sum, k) { return sum + HYBRIDS[k].payout * 0.10 * HYBRIDS[k].cycles; }, 0);
|
||||
return { id: s.id, label: s.label, yourCost: s.yourCost, time: t, yr1: Math.round(y), totalEG: Math.round(eg), monthly: 1860 };
|
||||
});
|
||||
}
|
||||
|
||||
const SCENARIOS = buildSCENARIOS();
|
||||
|
||||
console.log("=== SCENARIOS ===");
|
||||
for (var i = 0; i < SCENARIOS.length; i++) {
|
||||
var s = SCENARIOS[i];
|
||||
console.log(s.id + ": time=" + s.time + "mo, yr1=$" + s.yr1 + ", totalEG=$" + s.totalEG);
|
||||
}
|
||||
|
||||
// Verify consistency: rebuild should give same result
|
||||
var r = buildSCENARIOS();
|
||||
var ok = true;
|
||||
for (var i = 0; i < SCENARIOS.length; i++) {
|
||||
if (SCENARIOS[i].time !== r[i].time || SCENARIOS[i].yr1 !== r[i].yr1) {
|
||||
console.log("MISMATCH at " + SCENARIOS[i].id);
|
||||
ok = false;
|
||||
if (k === "3xL7") continue;
|
||||
const want = Math.round(PI[k].cycles * 19 / 30.4 * 10) / 10;
|
||||
check(`${k} months ${PI[k].months}`, PI[k].months === want, `want ${want}`);
|
||||
}
|
||||
}
|
||||
if (ok) console.log("=== ALL CONSISTENT ===");
|
||||
|
||||
console.log("4) anchor values (hand-derived, reserve model with carryover):");
|
||||
{
|
||||
const plan = computePlan(getPhaseSequence(0));
|
||||
check("L1-only from scratch = 21 cycles", plan["L1-only"].cycles === 21, `got ${plan["L1-only"].cycles}`);
|
||||
const plan7 = computePlan(getPhaseSequence(7));
|
||||
check("L7 start -> 3xL7 = 6 cycles (extra lanes camp-only)", plan7["L7"].cycles === 6, `got ${plan7["L7"].cycles}`);
|
||||
}
|
||||
|
||||
console.log("5) Year-1 includes endgame cycles for funded starts:");
|
||||
{
|
||||
const y = estimateYear1Cum(getPhaseSequence(7), 10, 7);
|
||||
// 6 L7 cycles (114d) at $324 + floor(251/19)=13 endgame cycles at $972
|
||||
check(`gift7 year1 = ${y}`, y === Math.round(6 * 324 + 13 * 972), `want ${6 * 324 + 13 * 972}`);
|
||||
}
|
||||
|
||||
console.log("6) timeline months advance ~19d per cycle (not 1 month per cycle):");
|
||||
{
|
||||
const rows = buildMonthlyTimeline(getPhaseSequence(4), 10);
|
||||
const last = rows[rows.length - 1];
|
||||
const cycles = rows.length;
|
||||
check(`last month ${last.m} ≈ cycles*19/30.4 (${Math.ceil(cycles * 19 / 30.4)})`,
|
||||
last.m === Math.ceil(cycles * 19 / 30.4), `rows=${cycles}`);
|
||||
}
|
||||
|
||||
console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILURE(S)`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
|
||||
Reference in New Issue
Block a user