From 5a6a3f0a04585defaed884ad1ebc9f5ea3befdb3 Mon Sep 17 00:00:00 2001 From: martbost Date: Fri, 31 Jul 2026 13:54:17 -0500 Subject: [PATCH] =?UTF-8?q?Boost=20Planner:=20'extra=20cash=20now'=20input?= =?UTF-8?q?=20finds=20the=20best=20campaign=20combo=20(any=20level=20mix,?= =?UTF-8?q?=20up=20to=203=20lanes=20=E2=80=94=20slots=20rule=20confirmed?= =?UTF-8?q?=20by=20Marty)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - boostSim(): generic lane simulator to 3xL7, exact same reserve rules as computePlan (verified: agrees on all 8 baseline starts) - enumerateBoostCombos(): every affordable add-on for the free slots, activation once per level - Overview card ranks every combo vs 'just bank it' by time to endgame; share link carries &boost= - test_scenarios section 8: baseline consistency, combo costs, ranking, the +$300 scenario (best: add L3+L2, 3 cycles faster than banking) --- index.html | 162 +++++++++++++++++++++++++++++++++++++++++++++- test_scenarios.js | 48 +++++++++++++- 2 files changed, 207 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index e6a4400..24a1308 100644 --- a/index.html +++ b/index.html @@ -1271,6 +1271,11 @@ +
+ + +
The Boost Planner below finds the best campaign combo those dollars can buy
+
@@ -1319,6 +1324,15 @@
+

๐Ÿ“ˆ Cumulative Profit Over Time

@@ -1641,6 +1655,146 @@ function phaseInfo(phases, pocketPct = 0) { return out; } +// โ”€โ”€ Boost Planner (Marty 2026-07-31) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +// "I have extra cash right now โ€” what's the best combination of campaigns to +// buy?" Slots rule confirmed by Marty: any level mix, up to 3 simultaneous +// campaigns, no sequential requirement. The planner enumerates every +// affordable add-on combo for the free slots, runs each through a generic +// lane simulator that follows the SAME reserve rules as computePlan (bank +// profits, recycle carried campaigns, roll the full payout on the upgrade +// cycle, activation once per level), and ranks by time to 3ร— L7. The +// baseline ("just bank it") gets the same cash as reserves, so the +// comparison is honest: buying lanes has to BEAT banking to win. +const LEVEL_TO_PHASE = { 1:'L1-only', 2:'L2+L1', 3:'L3+L1', 4:'L4+L3', 5:'L5+L4', 6:'L6', 7:'L7' }; + +function laneStats(lanes) { + let payout = 0, profit = 0, camp = 0; + for (const lv of lanes) { + const L = LEVELS[lv - 1]; + payout += L.payout; profit += L.netProfit; camp += L.camp; + } + return { payout, profit, camp }; +} + +// Simulate from an arbitrary lane set + starting reserve to 3ร—L7. +// Mirrors computePlan's rules exactly; capped for safety. +function boostSim(startLanes, bal0, pocketPct = 0) { + const keepFrac = 1 - pocketPct / 100; + let lanes = [...startLanes].sort((a, b) => b - a); + let bal = bal0; + let cycles = 0; + const activated = new Set(lanes); + const path = [lanes.join('+')]; + let guard = 0; + while (!(lanes.length === 3 && lanes.every(l => l === 7)) && guard++ < 40) { + const maxLane = Math.max(...lanes); + const targetKey = maxLane >= 7 ? '3xL7' : LEVEL_TO_PHASE[maxLane + 1]; + const targetSlots = targetKey === '3xL7' ? [7, 7, 7] : [...PHASE_SLOTS[targetKey]]; + // Carried lanes: current campaigns that keep running in the target phase. + const remaining = [...targetSlots]; + const carried = []; + for (const lv of lanes) { + const j = remaining.indexOf(lv); + if (j !== -1) { carried.push(lv); remaining.splice(j, 1); } + } + let cost = 0; + for (const lv of remaining) { + const L = LEVELS[lv - 1]; + cost += L.camp + (activated.has(lv) ? 0 : L.act); + activated.add(lv); + } + const s = laneStats(lanes); + const carriedCamp = carried.reduce((sum, lv) => sum + LEVELS[lv - 1].camp, 0); + let spin = 0; + while (spin++ < 500) { + cycles++; + if (bal + s.payout - carriedCamp >= cost) { bal += s.payout - carriedCamp - cost; break; } + bal += s.profit * keepFrac; + } + lanes = targetSlots.sort((a, b) => b - a); + path.push(targetKey); + } + cycles += 1; // the first 3ร—L7 endgame cycle (matches computePlan's 3xL7 entry) + return { cycles, months: Math.round(cycles * 19 / 30.4 * 10) / 10, path }; +} + +// Every add-on combo (multiset of levels, up to the free slots) affordable +// within `budget`. Activation charged once per newly-activated level. +function enumerateBoostCombos(currentLanes, budget) { + const freeSlots = 3 - currentLanes.length; + const activated = new Set(); + for (let lv = 1; lv <= Math.max(...currentLanes); lv++) activated.add(lv); + const combos = []; + const walk = (startLv, picked) => { + if (picked.length > 0) { + const seen = new Set(activated); + let cost = 0; + for (const lv of picked) { + const L = LEVELS[lv - 1]; + cost += L.camp + (seen.has(lv) ? 0 : L.act); + seen.add(lv); + } + if (cost <= budget) combos.push({ lanes: [...picked], cost }); + } + if (picked.length >= freeSlots) return; + for (let lv = startLv; lv >= 1; lv--) walk(lv, [...picked, lv]); + }; + walk(7, []); + return combos; +} + +// Rank every affordable move for `extra` dollars from the current phase. +function boostPlan(phaseKey, extra, pocketPct = 0) { + const currentLanes = [...PHASE_SLOTS[phaseKey]]; + const baseline = boostSim(currentLanes, extra, pocketPct); + const options = enumerateBoostCombos(currentLanes, extra).map(c => { + const lanes = [...currentLanes, ...c.lanes]; + const sim = boostSim(lanes, extra - c.cost, pocketPct); + return { ...c, lanes, sim, profitPerCycle: laneStats(lanes).profit }; + }); + options.sort((a, b) => a.sim.cycles - b.sim.cycles || a.cost - b.cost); + return { baseline, currentLanes, options }; +} + +function renderBoost(phaseKey, mode, pocketPct) { + const wrap = document.getElementById('boostWrap'); + if (!wrap) return; + const amount = parseInt(document.getElementById('boostFunds')?.value) || 0; + if (amount <= 0 || phaseKey === '3xL7') { wrap.style.display = 'none'; return; } + wrap.style.display = ''; + const plan = boostPlan(phaseKey, amount, pocketPct); + const cur = plan.currentLanes.map(l => 'L' + l).join(' + '); + document.getElementById('boostSubtitle').textContent = + `$${amount.toLocaleString()} extra while running ${cur} โ€” every affordable combo, simulated to 3ร— L7`; + const money = n => '$' + n.toLocaleString(undefined, { maximumFractionDigits: 0 }); + const rows = []; + const base = plan.baseline; + const deltaTxt = sim => { + const d = base.cycles - sim.cycles; + if (d <= 0) return d === 0 ? 'same speed' : `${-d} cycle${d === -1 ? '' : 's'} slower`; + const mo = Math.round(d * 19 / 30.4 * 10) / 10; + return `${d} cycle${d === 1 ? '' : 's'} (~${mo} mo) faster`; + }; + rows.push(`๐Ÿฆ Bank it (no new campaigns)$0` + + `${money(laneStats(plan.currentLanes).profit)}` + + `${base.cycles} cycles ยท ~${base.months} moโ€”`); + plan.options.slice(0, 5).forEach((o, i) => { + const label = 'Add ' + o.lanes.slice(plan.currentLanes.length).map(l => 'L' + l).join(' + '); + const best = i === 0 && o.sim.cycles < base.cycles; + rows.push(`` + + `${best ? 'โญ ' : ''}${label}${money(o.cost)}` + + `${money(o.profitPerCycle)}` + + `${o.sim.cycles} cycles ยท ~${o.sim.months} mo${deltaTxt(o.sim)}`); + }); + document.getElementById('boostBody').innerHTML = rows.join(''); + document.getElementById('boostNote').textContent = + 'Rules per the program: up to 3 campaigns at once, any level mix โ€” no need to climb sequentially. ' + + 'Activation is charged once per level; unspent cash goes into reserves either way. ' + + (mode === 'park' ? 'Shown on the climb-to-endgame basis; if you park, the best combo is simply the highest profit/cycle you can afford.' : + mode === 'pocket' ? `Simulated with your ${pocketPct}% pocket setting.` : + 'Simulated in Accelerate mode, same reserve math as the rest of the calculator.'); +} + function getLevelCost(upTo) { if (upTo <= 0) return 0; return LEVELS.slice(0, upTo).reduce((s, l) => s + l.total, 0); @@ -1756,6 +1910,7 @@ function updateAll() { const selfCost = getLevelCost(selfLevel); const effective = getEffectiveStart(giftLevel, selfLevel); let phases = getPhaseSequence(effective); + renderBoost(phases[0], mode, pocketPct); // Park & collect: keep the park-level select in sync with the sequence, // then truncate the journey at the chosen level (terminal = collect there). const parkSel = document.getElementById('parkPhase'); @@ -2121,7 +2276,7 @@ ${numPeople > 0 ? ` } // โ”€โ”€โ”€ Event Wiring โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -['numPeople','commissionPct','giftLevel','selfFundLevel','showReferral','alreadyJoined','strategyMode','pocketPct','parkPhase'].forEach(id => { +['numPeople','commissionPct','giftLevel','selfFundLevel','showReferral','alreadyJoined','strategyMode','pocketPct','parkPhase','boostFunds'].forEach(id => { const el = document.getElementById(id); el.addEventListener('input', updateAll); el.addEventListener('change', updateAll); @@ -2160,6 +2315,8 @@ document.querySelectorAll('.tab-btn').forEach(btn => { const park = p.get('park'); if (park) document.getElementById('parkPhase').dataset.want = park; } + const boost = p.get('boost'); + if (boost && /^\d{1,6}$/.test(boost)) document.getElementById('boostFunds').value = boost; })(); updateAll(); @@ -2189,6 +2346,9 @@ function getShareUrl(refUser) { if (modeSel.value === 'pocket') url += '&pk=' + document.getElementById('pocketPct').value; if (modeSel.value === 'park') url += '&park=' + encodeURIComponent(document.getElementById('parkPhase').value); } + const boostEl = document.getElementById('boostFunds'); + const boostVal = boostEl ? parseInt(boostEl.value) || 0 : 0; + if (boostVal > 0) url += '&boost=' + boostVal; return url; } diff --git a/test_scenarios.js b/test_scenarios.js index 0ed938b..823fa59 100644 --- a/test_scenarios.js +++ b/test_scenarios.js @@ -12,9 +12,10 @@ if (start < 0 || end < 0) { console.error("FATAL: extraction markers missing"); // 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)( + estimateMonths, estimateYear1Cum, buildMonthlyTimeline, + boostSim, enumerateBoostCombos, boostPlan } = (0, eval)( html.slice(start, end) + - ";({LEVELS, HYBRIDS, PHASE_SLOTS, computePlan, phaseInfo, getPhaseSequence, estimateMonths, estimateYear1Cum, buildMonthlyTimeline})" + ";({LEVELS, HYBRIDS, PHASE_SLOTS, computePlan, phaseInfo, getPhaseSequence, estimateMonths, estimateYear1Cum, buildMonthlyTimeline, boostSim, enumerateBoostCombos, boostPlan})" ); let failures = 0; @@ -134,5 +135,48 @@ console.log("7) strategy modes:"); check("parked timeline runs 10 collection cycles", tl.length === 10); } +console.log("8) Boost Planner (extra-funds combos, Marty 2026-07-31):"); +{ + // 8a. boostSim with $0 extra must agree with computePlan for every start. + for (let eff = 0; eff <= 7; eff++) { + const phases = getPhaseSequence(eff); + const plan = computePlan(phases); + const planTotal = phases.reduce((s, k) => s + plan[k].cycles, 0); + const sim = boostSim([...PHASE_SLOTS[phases[0]]], 0); + check(`eff=${eff}: boostSim baseline ${sim.cycles} == computePlan total ${planTotal}`, + sim.cycles === planTotal, `sim path ${sim.path.join(" โ†’ ")}`); + } + // 8b. combo enumeration: costs + slot/activation rules from an L1-only start. + const combos = enumerateBoostCombos([1], 300); + const byKey = Object.fromEntries(combos.map(c => [c.lanes.join("+"), c.cost])); + check("add L3 costs 165 (camp 150 + act 15)", byKey["3"] === 165, `got ${byKey["3"]}`); + check("add L3+L1 costs 178 (L1 already activated: camp only)", byKey["3+1"] === 178, `got ${byKey["3+1"]}`); + check("add L1+L1 costs 26 (two camp-only lanes)", byKey["1+1"] === 26, `got ${byKey["1+1"]}`); + check("L4 (330) not affordable at $300", !("4" in byKey)); + check("no combo exceeds 2 added lanes (3 slots total)", combos.every(c => c.lanes.length <= 2)); + // 8c. Marty's scenario: extra cash buys a real speedup over banking it. + const p300 = boostPlan("L1-only", 300); + const best = p300.options[0]; + check(`+$300 from L1: best combo [add L${best.lanes.slice(1).join("+L")}] beats banking ` + + `(${best.sim.cycles} < ${p300.baseline.cycles} cycles)`, + best.sim.cycles < p300.baseline.cycles); + // 8d. options are ranked (cycles ascending, cost tiebreak). + const sorted = p300.options.every((o, i, a) => + i === 0 || a[i - 1].sim.cycles < o.sim.cycles || + (a[i - 1].sim.cycles === o.sim.cycles && a[i - 1].cost <= o.cost)); + check("options ranked by cycles then cost", sorted); + // 8e. community claim probe: report best 1-added-lane vs 2-added-lanes at +$1000. + const p1k = boostPlan("L1-only", 1000); + const best1 = p1k.options.filter(o => o.lanes.length === 2)[0]; + const best2 = p1k.options.filter(o => o.lanes.length === 3)[0]; + if (best1 && best2) { + console.log(` info: +$1000 from L1 โ€” best 2-lane total [${best1.lanes.join("+")}] ${best1.sim.cycles}cy` + + ` vs best 3-lane total [${best2.lanes.join("+")}] ${best2.sim.cycles}cy`); + } + console.log(` info: +$300 from L1 top 3: ` + p300.options.slice(0, 3) + .map(o => `[add ${o.lanes.slice(1).map(l => "L" + l).join("+") || "?"}] $${o.cost} โ†’ ${o.sim.cycles}cy/${o.sim.months}mo`) + .join(" ยท ") + ` ยท bank: ${p300.baseline.cycles}cy/${p300.baseline.months}mo`); +} + console.log(failures === 0 ? "\nALL PASS" : `\n${failures} FAILURE(S)`); process.exit(failures === 0 ? 0 : 1);