Last active
May 10, 2025 11:52
-
-
Save twobob/14851955b98941618af53500f3ce5944 to your computer and use it in GitHub Desktop.
IOUBI alpha sim
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>IOUBItopia Weaver</title> | |
<style> | |
body { | |
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; | |
margin: 0; | |
background-color: #1a1a2e; | |
color: #e0e0fc; | |
line-height: 1.6; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
min-height: 100vh; | |
} | |
.container { | |
width: 90%; | |
max-width: 1600px; | |
margin: 20px auto; | |
background-color: #162447; | |
border-radius: 12px; | |
box-shadow: 0 8px 25px rgba(0,0,0,0.3); | |
padding: 20px; | |
position: relative; | |
} | |
#total-sim-time-display, | |
#total-year-time-display, | |
#step-time-display { | |
position: fixed; | |
right: 10px; | |
background-color: rgba(0,0,0,0.7); | |
color: #fca311; | |
padding: 5px 10px; | |
border-radius: 5px; | |
font-size: 0.85em; | |
z-index: 1000; | |
} | |
#total-sim-time-display { top: 10px; } | |
#total-year-time-display { top: 35px; } /* Position below Total Sim Time */ | |
#step-time-display { top: 60px; } /* Position below Total Year Time */ | |
header { | |
text-align: center; | |
margin-bottom: 25px; | |
padding-bottom: 15px; | |
border-bottom: 1px solid #243b55; | |
} | |
header h1 { | |
margin: 0; | |
font-size: 2.8em; | |
color: #fca311; | |
text-shadow: 0 0 10px #fca311; | |
} | |
header p { | |
font-size: 1.1em; | |
color: #a7a9be; | |
} | |
.game-area { | |
display: flex; | |
gap: 25px; | |
} | |
.controls-panel { | |
flex: 1; | |
background-color: #1f4068; | |
padding: 20px; | |
border-radius: 8px; | |
box-shadow: inset 0 0 10px rgba(0,0,0,0.2); | |
height: fit-content; | |
} | |
.controls-panel h2, .controls-panel h3 { | |
color: #e4bb97; | |
margin-top: 0; | |
border-bottom: 1px solid #2e5077; | |
padding-bottom: 10px; | |
} | |
#next-year-btn { | |
background: linear-gradient(145deg, #fca311, #e4bb97); | |
color: #162447; | |
border: none; | |
padding: 12px 25px; | |
font-size: 1.1em; | |
font-weight: bold; | |
border-radius: 50px; | |
cursor: pointer; | |
transition: all 0.3s ease; | |
width: 100%; | |
margin-bottom: 20px; | |
box-shadow: 0 4px 15px rgba(252, 163, 17, 0.4); | |
} | |
#next-year-btn:hover { | |
transform: translateY(-2px); | |
box-shadow: 0 6px 20px rgba(252, 163, 17, 0.6); | |
} | |
#next-year-btn:disabled { | |
background: #555; | |
color: #888; | |
cursor: not-allowed; | |
box-shadow: none; | |
} | |
.parameter { | |
margin-bottom: 18px; | |
} | |
.parameter label { | |
display: block; | |
margin-bottom: 6px; | |
font-weight: 600; | |
font-size: 0.95em; | |
color: #c5c6d0; | |
} | |
.parameter input[type="number"], .parameter input[type="range"], .parameter select { | |
width: calc(100% - 24px); | |
padding: 10px 12px; | |
border-radius: 6px; | |
border: 1px solid #2e5077; | |
background-color: #1b2a49; | |
color: #e0e0fc; | |
font-size: 0.9em; | |
box-sizing: border-box; | |
} | |
.parameter input[type="range"] { | |
padding: 0; | |
} | |
.parameter .value-display { | |
font-size: 0.85em; | |
color: #a7a9be; | |
margin-left: 10px; | |
} | |
.dashboard { | |
flex: 3; | |
display: flex; | |
flex-direction: column; | |
gap: 25px; | |
} | |
.dashboard-module { | |
background-color: #1f4068; | |
border-radius: 8px; | |
box-shadow: inset 0 0 10px rgba(0,0,0,0.2); | |
} | |
.module-title-bar { | |
display: flex; | |
justify-content: space-between; | |
align-items: center; | |
background-color: #1f4068; /* Slightly different from module content */ | |
padding: 10px 15px; | |
border-bottom: 1px solid #2e5077; | |
cursor: move; /* For dragging */ | |
border-radius: 8px 8px 0 0; /* Rounded top corners */ | |
} | |
.module-title-bar h3 { | |
margin: 0; | |
font-size: 1.1em; | |
color: #e0e0fc; | |
flex-grow: 0; /* Changed from 1 to 0 to allow steptime to sit next to title */ | |
} | |
.module-steptime { | |
font-size: 0.75em; | |
color: #D870A9; /* Pinkish color */ | |
margin-left: 10px; | |
font-weight: normal; | |
} | |
.module-controls { | |
display: flex; | |
gap: 10px; | |
} | |
.module-title-bar .module-controls { | |
margin-left: auto; /* Ensures controls are pushed to the far right */ | |
} | |
.module-controls .icon { | |
cursor: pointer; | |
width: 20px; | |
height: 20px; | |
fill: #a7a9be; | |
transition: fill 0.2s ease; | |
} | |
.module-controls .icon:hover { | |
fill: #e0e0fc; | |
} | |
.module-content { | |
padding: 20px; | |
transition: max-height 0.3s ease-out, opacity 0.2s ease-out 0.1s, padding 0.3s ease-out; | |
max-height: 2000px; | |
opacity: 1; | |
visibility: visible; /* Ensure visible when not collapsed */ | |
} | |
.dashboard-module.collapsed .module-content { | |
max-height: 0; | |
opacity: 0; | |
padding-top: 0; | |
padding-bottom: 0; | |
border-top: none; | |
visibility: hidden; /* Hide when collapsed */ | |
/* Delay visibility change until after other transitions */ | |
transition: max-height 0.3s ease-out, opacity 0.2s ease-out, padding 0.3s ease-out, visibility 0s linear 0.3s; | |
} | |
.dashboard-module.collapsed .module-title-bar { | |
border-bottom-color: transparent; | |
} | |
.metric-grid { | |
display: grid; | |
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); | |
gap: 18px; | |
} | |
.metric-card { | |
background-color: #1b2a49; | |
padding: 18px; | |
border-radius: 8px; | |
text-align: center; | |
border: 1px solid #2e5077; | |
position: relative; | |
transition: transform 0.2s ease-in-out; | |
} | |
.metric-card:hover { | |
transform: translateY(-3px); | |
} | |
.metric-card h4 { | |
margin: 0 0 8px 0; | |
font-size: 1em; | |
color: #c5c6d0; | |
} | |
.metric-card p { | |
margin: 0; | |
font-size: 1.8em; | |
font-weight: 700; | |
color: #fca311; | |
} | |
.metric-card .unit { | |
font-size: 0.6em; | |
font-weight: normal; | |
color: #a7a9be; | |
} | |
.metric-card[data-tooltip]::after, | |
.agent-cohorts-panel td[title]::after, | |
.agent-cohorts-panel th[title]::after { | |
/* Remove position: absolute; */ | |
position: fixed; | |
left: 0; top: 0; | |
background-color: #111; | |
color: #fff; | |
padding: 8px 12px; | |
border-radius: 6px; | |
font-size: 0.85em; | |
white-space: pre-wrap; | |
z-index: 99999 !important; /* Ensure always on top */ | |
width: max-content; | |
max-width: 350px; | |
text-align: left; | |
opacity: 0; | |
visibility: hidden; | |
pointer-events: none; | |
transition: opacity 0.2s, visibility 0.2s; | |
box-shadow: 0 4px 16px rgba(0,0,0,0.4); | |
} | |
.metric-card[data-tooltip]:hover::after, | |
.agent-cohorts-panel td[title]:hover::after, | |
.agent-cohorts-panel th[title]:hover::after { | |
opacity: 1; | |
visibility: visible; | |
} | |
/* Hide default browser tooltips for th/td */ | |
.agent-cohorts-panel th[title], | |
.agent-cohorts-panel td[title] { | |
position: relative; | |
} | |
#main-chart, #lorenz-chart, #volume-chart { | |
max-height: 350px; | |
width: 100%; | |
} | |
.chart-container { | |
display: flex; | |
gap: 15px; | |
flex-wrap: wrap; | |
} | |
.chart-container > div { | |
flex: 1 1 45%; | |
min-width: 300px; | |
display: flex; | |
flex-direction: column; | |
align-items: center; | |
} | |
.chart-container > div > h4 { | |
width: 100%; | |
text-align: center; | |
margin-bottom: 10px; | |
color: #e4bb97; | |
} | |
#event-log, #sampled-transaction-log { | |
max-height: 180px; | |
overflow-y: auto; | |
border: 1px solid #2e5077; | |
padding: 12px; | |
font-size: 0.9em; | |
background-color: #162447; | |
border-radius: 6px; | |
} | |
#event-log p, #sampled-transaction-log p { | |
margin: 0 0 7px 0; | |
padding-bottom: 7px; | |
border-bottom: 1px dashed #243b55; | |
color: #c5c6d0; | |
} | |
#event-log p:last-child, #sampled-transaction-log p:last-child { | |
border-bottom: none; | |
} | |
#event-log .info, #sampled-transaction-log .info { color: #87CEEB; } | |
#event-log .warning, #sampled-transaction-log .warning { color: #FFD700; } | |
#event-log .critical, #sampled-transaction-log .critical { color: #FF6347; } | |
#event-log .positive, #sampled-transaction-log .positive { color: #90EE90; } | |
.agent-cohorts-panel table { | |
width: 100%; | |
border-collapse: collapse; | |
margin-top: 10px; | |
min-width: 700px; | |
} | |
.agent-cohorts-table-container { | |
width: 100%; | |
overflow-x: auto; | |
margin-top: 10px; | |
} | |
.agent-cohorts-panel th, .agent-cohorts-panel td { | |
border: 1px solid #2e5077; | |
padding: 8px; | |
text-align: left; | |
font-size: 0.8em; | |
} | |
.agent-cohorts-panel th { | |
background-color: #1b2a49; | |
color: #e4bb97; | |
} | |
.agent-cohorts-panel td { | |
color: #c5c6d0; | |
} | |
footer { | |
text-align: center; | |
margin-top: 30px; | |
padding-top: 20px; | |
border-top: 1px solid #243b55; | |
font-size: 0.9em; | |
color: #777; | |
} | |
::-webkit-scrollbar { width: 8px; } | |
::-webkit-scrollbar-track { background: #162447; border-radius: 10px; } | |
::-webkit-scrollbar-thumb { background: #2e5077; border-radius: 10px; } | |
::-webkit-scrollbar-thumb:hover { background: #1f4068; } | |
/* Dragging placeholder style - changed to a line */ | |
.dragging-placeholder { | |
background-color: #fca311; /* Accent color for the line */ | |
height: 2px; /* Thin line */ | |
margin: 12px 0; /* Space around the line, half of the gap */ | |
border-radius: 1px; | |
box-sizing: border-box; | |
} | |
.dashboard-module.is-dragging { | |
opacity: 0.6; /* Make dragged item semi-transparent */ | |
border: 2px dashed #fca311; /* Accent border */ | |
box-shadow: 0 5px 15px rgba(0,0,0,0.2); /* Lift effect */ | |
} | |
/* Styles for the individual stats module's controls and chart container */ | |
.individual-stats-controls { | |
display: flex; | |
flex-wrap: wrap; /* Allow wrapping on smaller screens */ | |
gap: 15px; | |
margin-bottom: 15px; | |
align-items: center; | |
} | |
.individual-stats-controls label { | |
margin-right: 5px; | |
font-weight: normal; | |
color: #c5c6d0; | |
font-size: 0.9em; | |
} | |
.individual-stats-controls select { | |
padding: 8px 10px; | |
border-radius: 6px; | |
border: 1px solid #2e5077; | |
background-color: #1b2a49; | |
color: #e0e0fc; | |
font-size: 0.9em; | |
} | |
#individualStatsChartContainer { | |
margin-top: 10px; | |
width: 100%; | |
height: 300px; /* Adjust as needed, or use aspect-ratio ?? dunno */ | |
position: relative; /* Important for Chart.js responsiveness */ | |
} | |
@media (max-width: 1200px) { | |
.container { | |
width: 95%; | |
} | |
.game-area { | |
flex-direction: column; | |
} | |
.controls-panel, .dashboard { | |
width: 100%; | |
box-sizing: border-box; | |
} | |
.metric-grid { | |
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); | |
} | |
} | |
@media (max-width: 768px) { | |
header h1 { font-size: 2.0em; } | |
.metric-card p { font-size: 1.6em; } | |
.agent-cohorts-panel th, .agent-cohorts-panel td { | |
font-size: 0.75em; | |
padding: 5px; | |
} | |
.game-area { | |
gap: 15px; | |
} | |
.controls-panel, .dashboard { | |
padding: 15px; | |
} | |
.metrics-overview, .trends-chart, .log-panel, .agent-cohorts-panel, .additional-charts-panel, .transaction-log-panel { | |
padding: 15px; | |
} | |
} | |
</style> | |
</head> | |
<body> | |
<div class="container"> | |
<div id="total-sim-time-display">Total Sim Time: 0.0s</div> | |
<div id="total-year-time-display">Total Year Time: 0.0s</div> | |
<div id="step-time-display">Step Time: 0ms</div> | |
<header> | |
<h1>IOUBItopia Weaver</h1> | |
<p>Simulate and Shape an IOUBI-Powered Future</p> | |
</header> | |
<div class="game-area"> | |
<aside class="controls-panel"> | |
<h2>Controls</h2> | |
<div class="parameter"> | |
<label for="current-year" style="float: left;">Current Year:</label> | |
<span id="current-year">0</span> / <span id="max-years">100</span> | |
</div> | |
<div class="parameter"> | |
<label for="current-day" style="float: left;">Current Day:</label> | |
<span id="current-day">0</span> / <span id="days-per-year">365</span> | |
</div> | |
<button id="next-year-btn">Advance to Next Year</button> | |
<button id="run-tests-btn" style="margin-top: 15px;">Run Unit Tests</button> | |
<h3>Simulation Parameters</h3> | |
<div class="parameter"> | |
<label for="initial-personal-credit">Initial Personal Credit (Δ):</label> | |
<input type="number" id="initial-personal-credit" value="1000" min="100" step="100"> | |
</div> | |
<div class="parameter"> | |
<label for="initial-business-credit">Initial Business Credit (Δ):</label> | |
<input type="number" id="initial-business-credit" value="10000" min="1000" step="1000"> | |
</div> | |
<div class="parameter"> | |
<label for="base-adoption-rate">Base Adoption Rate (%/year):</label> | |
<input type="range" id="base-adoption-rate" value="0.5" min="0.01" max="5" step="0.01"> | |
<span class="value-display" id="base-adoption-rate-value">0.5%</span> | |
</div> | |
<div class="parameter"> | |
<label for="transaction-fee-split-ubi">Transaction Fee to UBI Pool (%):</label> | |
<input type="range" id="transaction-fee-split-ubi" value="75" min="0" max="95" step="1"> | |
<span class="value-display" id="transaction-fee-split-ubi-value">75%</span> | |
</div> | |
<div class="parameter"> | |
<label for="grant-focus">Grant Focus:</label> | |
<select id="grant-focus"> | |
<option value="balanced" selected>Balanced</option> | |
<option value="technology">Technology</option> | |
<option value="social_welfare">Social Welfare</option> | |
<option value="environment">Environment</option> | |
<option value="infrastructure">Infrastructure</option> | |
<option value="social_projects">Social Projects</option> | |
</select> | |
</div> | |
<div class="parameter"> | |
<label for="avg-velocity-multiplier">Avg. Velocity Multiplier:</label> | |
<input type="range" id="avg-velocity-multiplier" value="0.2" min="0" max="1" step="0.01"> | |
<span class="value-display" id="avg-velocity-multiplier-value">0.20</span> | |
</div> | |
<div class="parameter"> | |
<label for="red-money-tolerance">Red Money Tolerance Factor:</label> | |
<input type="range" id="red-money-tolerance" value="0.2" min="0" max="1" step="0.01"> | |
<span class="value-display" id="red-money-tolerance-value">0.20</span> | |
</div> | |
<div class="parameter"> | |
<label for="innovation-investment-propensity">Innovation Investment Propensity:</label> | |
<input type="range" id="innovation-investment-propensity" value="0.1" min="0" max="1" step="0.1"> | |
<span class="value-display" id="innovation-investment-propensity-value">0.10</span> | |
</div> | |
<div class="parameter"> | |
<label for="days-in-year">Days in Year:</label> | |
<input type="number" id="days-in-year" value="365" min="12" max="365" step="1"> | |
</div> | |
<div class="parameter"> | |
<label for="yearly-time-budget">Yearly Sim Time Budget (s):</label> | |
<input type="range" id="yearly-time-budget" value="5" min="5" max="60" step="1"> | |
<span class="value-display" id="yearly-time-budget-value">5s</span> | |
</div> | |
<div class="parameter"> | |
<label for="ui-update-frequency">UI Update Frequency:</label> | |
<select id="ui-update-frequency"> | |
<option value="daily">Daily</option> | |
<option value="monthly" selected>Monthly</option> | |
<option value="yearly">Yearly</option> | |
</select> | |
</div> | |
</aside> | |
<main class="dashboard" id="dashboard-main"> | |
<div class="dashboard-module" id="module-metrics"> | |
<div class="module-title-bar" draggable="true"> | |
<h3>Key Global Metrics</h3> | |
<div class="module-controls"> | |
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg> | |
</div> | |
</div> | |
<div class="module-content"> | |
<section class="metrics-overview"> | |
<div class="metric-grid"> | |
<div class="metric-card" data-tooltip="Latest daily income per individual from the global pool. Calculated as: (75% of yesterday's total transaction fees) / current IOUBI users. Note: In IOUBI, fees do not accumulate in a pool. They vanish, and UBI is distributed later based on tracked vanished fees."><h4>Latest Daily UBI</h4><p><span id="avg-ubi">0.00</span> <span class="unit">Δ</span></p></div> | |
<div class="metric-card" data-tooltip="Gini Coefficient: Measures wealth inequality. Calculated as: sum of absolute differences of all pairs of cohort balances, weighted by population, divided by twice the mean balance. 0 = perfect equality, 1 = max inequality."><h4>Gini Coefficient</h4><p><span id="gini-coefficient">0.80</span></p></div> | |
<div class="metric-card" data-tooltip="Relative Poverty Rate: % of population below 60% of average well-being. Calculated as: (population with well-being < 0.6 × avg well-being) / total IOUBI users."><h4>Relative Poverty Rate</h4><p><span id="poverty-rate">75.0</span> <span class="unit">%</span></p></div> | |
<div class="metric-card" data-tooltip="Tech Level: Proxy for overall technological capability. Increases from R&D investments (from innovation propensity and grants) and a small base increase per year. Calculated as: previous tech level + R&D effect + grant effect + base increase."><h4>Tech Level</h4><p><span id="tech-level">1.0</span></p></div> | |
<div class="metric-card" data-tooltip="IOUBI Adoption: % and actual user count. Calculated as: IOUBI users / global population."><h4>IOUBI Adoption</h4><p><span id="ioubi-adoption">0.1</span> <span class="unit">%</span> <span id="ioubi-adoption-abs" style="font-size:0.7em;color:#a7a9be;"></span></p></div> | |
<div class="metric-card" data-tooltip="Total value of overdrafts in the system (all used credit). Sum of all negative balances × population for each cohort and business."><h4>Total Red Money</h4><p><span id="total-red-money">0</span> <span class="unit">Δ</span></p></div> | |
<div class="metric-card" data-tooltip="Total value of positive balances in the system (savings/hoardings). Sum of all positive balances × population for each cohort and business."><h4>Total Positive Balances</h4><p><span id="total-positive-balances">0</span> <span class="unit">Δ</span></p></div> | |
<div class="metric-card" data-tooltip="Net worth of the system (Positive - Red). Should be near zero in a closed system."><h4>Net System Deltars</h4><p><span id="net-system-deltars">0</span> <span class="unit">Δ</span></p></div> | |
<div class="metric-card" data-tooltip="Annual Pool Revenue: Sum of all transaction fees collected this year. Daily Pool Revenue: Fees from the last simulated day. Fee per transaction is based on log10 of balances involved. Note: In IOUBI, these fees vanish from the system and are not stored in a pool. UBI is distributed later based on tracked vanished fees."><h4>Pool Revenue</h4><p><span id="annual-pool-revenue">0</span> <span class="unit">Δ</span><br><span id="daily-pool-revenue" style="font-size:0.7em;color:#a7a9be;"></span></p></div> | |
<div class="metric-card" data-tooltip="Credit Utilization: Total red money as % of all credit limits. Calculated as: (Total Red Money / Total Credit Limit) × 100."><h4>Credit Utilization</h4><p><span id="credit-utilization">0.0</span> <span class="unit">%</span></p></div> | |
<div class="metric-card" data-tooltip="Total social credit currently borrowed by cohorts. Sum of socialCreditBorrowed for all cohorts."><h4>Social Credit Debt</h4><p><span id="social-credit-lent">0</span> <span class="unit">Δ</span></p></div> | |
<div class="metric-card" data-tooltip="Number of orgs using social credit (active social projects). Count of group accounts with positive balance or started in last 5 years."><h4>Active Social Projects</h4><p><span id="active-social-projects">0</span></p></div> | |
</div> | |
</section> | |
</div> | |
</div> | |
<div class="dashboard-module" id="module-cohorts"> | |
<div class="module-title-bar" draggable="true"> | |
<h3>Agent Cohort Overview</h3> | |
<div class="module-controls"> | |
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg> | |
</div> | |
</div> | |
<div class="module-content"> | |
<section class="agent-cohorts-panel"> | |
<div class="agent-cohorts-table-container"> | |
<table> | |
<thead> | |
<tr> | |
<th title="Cohort name. Represents a socioeconomic group.">Cohort</th> | |
<th title="Population share of this cohort as a % of all IOUBI users.">Pop (%)</th> | |
<th title="Average balance per person in this cohort. Positive = savings, Negative = debt.">Avg. Balance (Δ)</th> | |
<th title="Average credit limit per person in this cohort.">Avg. Credit Limit (Δ)</th> | |
<th title="Well-being: Composite score (0-1) based on UBI, balance, credit, tech level, and inequality. Formula: 0.1 + (annual UBI / (0.5 × initial credit)) × 0.2 + (balance / (credit limit + |balance| + 1000)) × 0.5 + (tech level / 20) × 0.2 + (1 - gini) × 0.1">Well-being</th> | |
<th title="Social Credit: Amount available to lend / amount borrowed. Calculated as 0.2 × avg credit limit for each cohort.">Social Credit (Avail./Borrowed Δ)</th> | |
</tr> | |
</thead> | |
<tbody id="agent-cohorts-table-body"> | |
</tbody> | |
</table> | |
</div> | |
</section> | |
</div> | |
</div> | |
<div class="dashboard-module" id="module-main-trends"> | |
<div class="module-title-bar" draggable="true"> | |
<h3>Global Trends Over Time (Annual Summary)</h3> | |
<div class="module-controls"> | |
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg> | |
</div> | |
</div> | |
<div class="module-content"> | |
<section class="trends-chart"> | |
<canvas id="main-chart"></canvas> | |
</section> | |
</div> | |
</div> | |
<div class="dashboard-module" id="module-additional-charts"> | |
<div class="module-title-bar" draggable="true"> | |
<h3>Additional Economic Visualizations</h3> | |
<div class="module-controls"> | |
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg> | |
</div> | |
</div> | |
<div class="module-content"> | |
<section class="additional-charts-panel"> | |
<div class="chart-container"> | |
<div> | |
<h4>Wealth Distribution (Lorenz Curve Approx. - Current)</h4> | |
<canvas id="lorenz-chart"></canvas> | |
</div> | |
<div> | |
<h4>Daily Transaction Volume & Pool Revenue (Current Year)</h4> | |
<canvas id="volume-chart"></canvas> | |
</div> | |
</div> | |
</section> | |
</div> | |
</div> | |
<div class="dashboard-module" id="module-tx-log"> | |
<div class="module-title-bar" draggable="true"> | |
<h3>Sampled Transaction Log (Last 10)</h3> | |
<div class="module-controls"> | |
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg> | |
</div> | |
</div> | |
<div class="module-content"> | |
<section class="transaction-log-panel"> | |
<div id="sampled-transaction-log"><p>No transactions logged yet.</p></div> | |
</section> | |
</div> | |
</div> | |
<div class="dashboard-module" id="module-event-log"> | |
<div class="module-title-bar" draggable="true"> | |
<h3>Event Log</h3> | |
<div class="module-controls"> | |
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg> | |
</div> | |
</div> | |
<div class="module-content"> | |
<section class="log-panel"> | |
<div id="event-log"><p>Year 0 Day 0: IOUBI Initiative Launched. Set initial parameters to begin.</p></div> | |
</section> | |
</div> | |
</div> | |
<!-- Add a "Sanity Check" panel above the metrics for clarity --> | |
<div class="dashboard-module" id="module-sanity-check"> | |
<div class="module-title-bar" draggable="true"> | |
<h3>Sanity Check: System Totals</h3> | |
<div class="module-controls"> | |
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg> | |
</div> | |
</div> | |
<div class="module-content"> | |
<section class="metrics-overview"> | |
<div class="metric-grid"> | |
<div class="metric-card" data-tooltip="Total IOUBI Population (sum of all cohorts)."><h4>Population</h4><p><span id="sanity-population">0</span></p></div> | |
<div class="metric-card" data-tooltip="Weighted average balance across all cohorts and businesses. Should be near zero except for money in pools."><h4>Total Avg. Balance</h4><p><span id="sanity-avg-balance">0</span> <span class="unit">Δ</span></p></div> | |
<div class="metric-card" data-tooltip="Sum of all positive balances (should match sum of all negative balances in a closed system, except for pools)."><h4>Total Positive</h4><p><span id="sanity-total-positive">0</span> <span class="unit">Δ</span></p></div> | |
<div class="metric-card" data-tooltip="Sum of all negative balances (should match sum of all positive balances in a closed system, except for pools)."><h4>Total Negative</h4><p><span id="sanity-total-negative">0</span> <span class="unit">Δ</span></p></div> | |
<div class="metric-card" data-tooltip="Sum of all balances (should be near zero except for money in pools). Note: In IOUBI, transaction fees vanish from the system and are not stored in a pool. UBI is distributed later based on tracked vanished fees. This can cause a temporary mismatch in total balances."><h4>Net System Balance</h4><p><span id="sanity-net-balance">0</span> <span class="unit">Δ</span></p></div> | |
<div class="metric-card" data-tooltip="Shows the difference between total vanished fees (for UBI) and the UBI actually distributed so far. A positive value means some UBI is pending distribution (accounts not yet refreshed). A negative value means more UBI was distributed than fees vanished (should be rare). This reflects the IOUBI model: fees vanish, UBI is distributed later, so the system can be temporarily out of balance."><h4>UBI Distribution Status</h4><p><span id="sanity-ubi-status">0</span> <span class="unit">Δ</span></p></div> | |
</div> | |
</section> | |
</div> | |
</div> | |
<div class="dashboard-module" id="module-individual-stats"> | |
<div class="module-title-bar" draggable="true"> | |
<h3>Individual/Cohort Statistics</h3> | |
<div class="module-controls"> | |
<svg class="icon toggle-collapse" viewBox="0 0 24 24"><path d="M19 13H5v-2h14v2z"/></svg> | |
</div> | |
</div> | |
<div class="module-content"> | |
<div class="individual-stats-controls"> | |
<label for="individualCohortSelect">Cohort/Individual:</label> | |
<select id="individualCohortSelect"> | |
</select> | |
<label for="individualTimeframeSelect">Timeframe:</label> | |
<select id="individualTimeframeSelect"> | |
</select> | |
<label for="individualLogRetentionSelect">Log Retention:</label> | |
<select id="individualLogRetentionSelect"> | |
<option value="1_year">1 Year</option> | |
<option value="2_years" selected>2 Years</option> | |
<option value="5_years">5 Years</option> | |
<option value="10_years">10 Years</option> | |
<option value="20_years">20 Years</option> | |
<option value="all_time">All Time</option> | |
</select> | |
</div> | |
<div id="individualStatsChartContainer"> | |
<canvas id="individualStatsChart"></canvas> | |
</div> | |
</div> | |
</div> | |
</main> | |
</div> | |
<footer> | |
<p>IOUBI Utopia Weaver - OneFile Edition (Interactive UI). Simulation is an abstraction.</p> | |
</footer> | |
</div> | |
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> | |
<script> | |
function enableFloatingTooltips() { | |
const tooltipDiv = document.createElement('div'); | |
tooltipDiv.className = 'floating-tooltip'; | |
document.body.appendChild(tooltipDiv); | |
// Apply proper tooltip styling | |
Object.assign(tooltipDiv.style, { | |
position: 'fixed', | |
display: 'none', | |
zIndex: '99999', | |
background: '#111', | |
color: '#fff', | |
padding: '8px 12px', | |
borderRadius: '6px', | |
fontSize: '0.85em', | |
whiteSpace: 'pre-wrap', | |
maxWidth: '350px', | |
textAlign: 'left', | |
pointerEvents: 'none', | |
boxShadow: '0 4px 16px rgba(0,0,0,0.4)', | |
border: '1px solid #2e5077' | |
}); | |
// Handle tooltip events for data-tooltip elements | |
document.querySelectorAll('.metric-card[data-tooltip], .agent-cohorts-panel th[title], .agent-cohorts-panel td[title]').forEach(el => { | |
const tooltipText = el.getAttribute('data-tooltip') || el.getAttribute('title'); | |
el.addEventListener('mouseenter', () => { | |
tooltipDiv.textContent = tooltipText; | |
tooltipDiv.style.display = 'block'; | |
}); | |
el.addEventListener('mousemove', e => { | |
// Position tooltip so it's not cut off at edges | |
const rect = tooltipDiv.getBoundingClientRect(); | |
let x = e.clientX + 15; | |
let y = e.clientY - rect.height - 10; | |
// Keep tooltip on screen | |
if (x + rect.width > window.innerWidth) { | |
x = window.innerWidth - rect.width - 10; | |
} | |
if (y < 10) { | |
y = e.clientY + 15; // Show below if too close to top | |
} | |
tooltipDiv.style.left = x + 'px'; | |
tooltipDiv.style.top = y + 'px'; | |
}); | |
el.addEventListener('mouseleave', () => { | |
tooltipDiv.style.display = 'none'; | |
}); | |
}); | |
} | |
document.addEventListener('DOMContentLoaded', () => { | |
// --- DOM Elements --- | |
const currentYearEl = document.getElementById('current-year'); | |
const currentDayEl = document.getElementById('current-day'); | |
const daysPerYearDisplayEl = document.getElementById('days-per-year'); | |
const maxYearsEl = document.getElementById('max-years'); | |
const nextYearBtn = document.getElementById('next-year-btn'); | |
const runTestsBtn = document.getElementById('run-tests-btn'); // Get the new button | |
const eventLogEl = document.getElementById('event-log'); | |
const sampledTransactionEl = document.getElementById('sampled-transaction-log'); | |
const agentCohortsTableBodyEl = document.getElementById('agent-cohorts-table-body'); | |
const dashboardMainEl = document.getElementById('dashboard-main'); | |
// --- DOM Elements for Individual Stats --- | |
const individualCohortSelectEl = document.getElementById('individualCohortSelect'); | |
const individualTimeframeSelectEl = document.getElementById('individualTimeframeSelect'); | |
const individualStatsChartCanvasEl = document.getElementById('individualStatsChart'); | |
const individualLogRetentionSelectEl = document.getElementById('individualLogRetentionSelect'); | |
// --- Utility Functions --- | |
function formatNumber(number, decimals = 0) { | |
if (isNaN(number) || !isFinite(number)) return (typeof number === 'number' && number.toString()) || "N/A"; | |
if (number === 0) return "0"; | |
const absNumber = Math.abs(number); | |
const sign = number < 0 ? "-" : ""; | |
if (absNumber < 1000) return sign + absNumber.toFixed(decimals); | |
const suffixes = ["", "K", "M", "B", "T", "P", "E", "Z", "Y"]; | |
let suffixIndex = Math.floor(Math.log10(absNumber) / 3); | |
suffixIndex = Math.min(suffixIndex, suffixes.length -1); | |
const scaledNumber = absNumber / Math.pow(10, 3 * suffixIndex); | |
return sign + scaledNumber.toFixed(decimals) + suffixes[suffixIndex]; | |
} | |
function updateModuleSteptime(moduleId, startTimeMs) { | |
const endTimeMs = performance.now(); | |
const durationMs = Math.round(endTimeMs - startTimeMs); | |
const moduleEl = document.getElementById(moduleId); | |
if (moduleEl) { | |
const steptimeEl = moduleEl.querySelector('.module-steptime'); | |
if (steptimeEl) { | |
steptimeEl.textContent = `(${durationMs} ms)`; | |
} | |
} | |
} | |
function safeDiv(numerator, denominator, defaultVal = 0) { | |
if (isNaN(numerator) || isNaN(denominator) || denominator === 0 || !isFinite(numerator) || !isFinite(denominator)) { | |
return defaultVal; | |
} | |
return numerator / denominator; | |
} | |
function calculateLog10FeeComponent(balance) { | |
if (isNaN(balance) || !isFinite(balance)) return 1.0; | |
const absBalance = Math.abs(balance); | |
if (absBalance < 1.0) return 1.0; | |
return Math.max(1.0, Math.log10(absBalance)); | |
} | |
function getAvgTransactionFeeRate(entity1, entity2) { | |
const balance1 = entity1.avgBalance !== undefined ? entity1.avgBalance : entity1.balance; | |
const balance2 = entity2.avgBalance !== undefined ? entity2.avgBalance : entity2.balance; | |
const feeComp1 = calculateLog10FeeComponent(balance1); | |
const feeComp2 = calculateLog10FeeComponent(balance2); | |
const avgFeeRate = (feeComp1 + feeComp2) / 2; | |
return Math.max(0.1, Math.min(isNaN(avgFeeRate) ? 1.0 : avgFeeRate, 10)); | |
} | |
function getCohortEmoji(cohortName) { | |
switch (cohortName) { | |
case "Impoverished": return "🧑🦲"; | |
case "Low Income": return "🧑"; | |
case "Middle Income": return "🧑💼"; | |
case "High Income": return "🧐"; | |
default: return ""; | |
} | |
} | |
function logEvent(message, type = "info") { | |
const eventLogStartTime = performance.now(); | |
const p = document.createElement('p'); | |
const displayDay = currentDay === 0 && currentYear === 0 ? 0 : currentDay; | |
p.textContent = `Y${currentYear}.D${displayDay}: ${message}`; | |
p.className = type; | |
if (eventLogEl.firstChild) eventLogEl.insertBefore(p, eventLogEl.firstChild); | |
else eventLogEl.appendChild(p); | |
while (eventLogEl.children.length > 100) eventLogEl.removeChild(eventLogEl.lastChild); | |
updateModuleSteptime('module-event-log', eventLogStartTime); | |
} | |
function recordTransaction(fromEntity, toEntity, amount, fee, type) { | |
const tx = new TransactionRecord(nextTransactionId++, currentYear, currentDay, fromEntity, toEntity, amount, fee, type); | |
transactionLog.push(tx); | |
// Optional: Limit the size of the full transactionLog to prevent memory issues in very long simulations | |
// if (transactionLog.length > 2000) { // Example cap | |
// transactionLog.shift(); | |
// } | |
} | |
// --- Simulation Parameters & State --- | |
const paramInputs = { | |
initialPersonalCredit: document.getElementById('initial-personal-credit'), | |
initialBusinessCredit: document.getElementById('initial-business-credit'), | |
baseAdoptionRate: document.getElementById('base-adoption-rate'), | |
transactionFeeSplitUbi: document.getElementById('transaction-fee-split-ubi'), | |
grantFocus: document.getElementById('grant-focus'), | |
avgVelocityMultiplier: document.getElementById('avg-velocity-multiplier'), | |
redMoneyTolerance: document.getElementById('red-money-tolerance'), | |
innovationInvestmentPropensity: document.getElementById('innovation-investment-propensity'), | |
daysInYear: document.getElementById('days-in-year'), | |
yearlyTimeBudget: document.getElementById('yearly-time-budget'), | |
uiUpdateFrequency: document.getElementById('ui-update-frequency') | |
}; | |
const paramValueDisplays = { | |
baseAdoptionRate: document.getElementById('base-adoption-rate-value'), | |
transactionFeeSplitUbi: document.getElementById('transaction-fee-split-ubi-value'), | |
avgVelocityMultiplier: document.getElementById('avg-velocity-multiplier-value'), // Added this line | |
redMoneyTolerance: document.getElementById('red-money-tolerance-value'), | |
innovationInvestmentPropensity: document.getElementById('innovation-investment-propensity-value'), | |
yearlyTimeBudget: document.getElementById('yearly-time-budget-value') | |
}; | |
const metricEls = { | |
avgUbi: document.getElementById('avg-ubi'), | |
giniCoefficient: document.getElementById('gini-coefficient'), | |
povertyRate: document.getElementById('poverty-rate'), | |
techLevel: document.getElementById('tech-level'), | |
ioubiAdoption: document.getElementById('ioubi-adoption'), | |
totalRedMoney: document.getElementById('total-red-money'), | |
totalPositiveBalances: document.getElementById('total-positive-balances'), | |
netSystemDeltars: document.getElementById('net-system-deltars'), | |
annualPoolRevenue: document.getElementById('annual-pool-revenue'), | |
creditUtilization: document.getElementById('credit-utilization'), | |
socialCreditLent: document.getElementById('social-credit-lent'), | |
activeSocialProjects: document.getElementById('active-social-projects'), | |
}; | |
let mainChart, lorenzChart, volumeChart; | |
const chartHistoryLimit = 50; | |
const dailyChartHistoryLimit = 365 * 2; | |
let individualStatsChartInstance = null; // For the new chart | |
// const INDIVIDUAL_CHART_HISTORY_LIMIT = 50; // Max points for individual chart - REMOVED | |
let individualChartDataStore = {}; // To store data for each cohort/timeframe | |
let cumulativeProcessingTimeMs = 0; // For total simulation time | |
const chartData = { labels: [], datasets: [ { label: 'Avg. Daily UBI (Δ)', data: [], borderColor: '#fca311', tension: 0.2, yAxisID: 'yUbi', pointRadius: 1, borderWidth: 2 }, { label: 'Gini Coefficient', data: [], borderColor: '#87CEEB', tension: 0.2, yAxisID: 'yGini', pointRadius: 1, borderWidth: 2 }, { label: 'IOUBI Adoption (%)', data: [], borderColor: '#90EE90', tension: 0.2, yAxisID: 'yGini', pointRadius: 1, borderWidth: 2 }, { label: 'Tech Level', data: [], borderColor: '#FFD700', tension: 0.2, yAxisID: 'yUbi', pointRadius: 1, borderWidth: 2, hidden: true } ] }; | |
const lorenzChartData = { labels: Array.from({length: 101}, (_, i) => i), datasets: [ { label: 'Perfect Equality', data: Array.from({length: 101}, (_, i) => i), borderColor: '#777', borderDash: [5, 5], pointRadius: 0, borderWidth: 1, fill: false }, { label: 'Lorenz Curve', data: [], borderColor: '#FF6347', tension: 0.1, pointRadius: 0, borderWidth: 2, fill: 'origin', backgroundColor: 'rgba(255, 99, 71, 0.2)' } ] }; | |
const volumeChartData = { labels: [], datasets: [ { label: 'Daily Transaction Volume (Δ)', data: [], borderColor: '#4BC0C0', tension: 0.2, yAxisID: 'yVolume', pointRadius: 0, borderWidth: 1.5 }, { label: 'Daily Pool Revenue (Δ)', data: [], borderColor: '#FFCE56', tension: 0.2, yAxisID: 'yRevenue', pointRadius: 0, borderWidth: 1.5, hidden: true } ] }; | |
// Temporary stores for volume chart daily data | |
let tempVolumeChartLabels = []; | |
let tempVolumeChartVolumeData = []; | |
let tempVolumeChartRevenueData = []; | |
const MAX_YEARS = 100; | |
let currentYear = 0; | |
let currentDay = 0; | |
let DAYS_PER_YEAR = parseInt(paramInputs.daysInYear.value); | |
const GLOBAL_POPULATION = 7 * 10**9; | |
let ioubiUserCount = GLOBAL_POPULATION * 0.001; | |
class TransactionRecord { constructor(id, year, day, fromEntity, toEntity, amount, fee, type) { this.id = id; this.timestamp = `Y${year}.D${day}`; this.fromEntity = fromEntity; this.toEntity = toEntity; this.amount = amount; this.fee = fee; this.type = type; } } | |
let transactionLog = []; | |
let nextTransactionId = 1; | |
const MAX_TRANSACTION_LOG_DISPLAY = 10; | |
let groupAccounts = []; | |
let nextGroupId = 1; | |
let agentCohorts = [ | |
{ name: "Impoverished", popShare: 0.60, balance: -100, creditLimit: 200, baseSpendingPerCapita: 1000, wellBeing: 0.1, redMoneyToleranceMod: 1.5, actualPop: 0, socialCreditAvailableToLend: 0, socialCreditBorrowed: 0, innovationScore: 0.05 }, | |
{ name: "Low Income", popShare: 0.25, balance: 50, creditLimit: 500, baseSpendingPerCapita: 5000, wellBeing: 0.3, redMoneyToleranceMod: 1.2, actualPop: 0, socialCreditAvailableToLend: 50, socialCreditBorrowed: 0, innovationScore: 0.1 }, | |
{ name: "Middle Income", popShare: 0.12, balance: 1000, creditLimit: 2000, baseSpendingPerCapita: 20000, wellBeing: 0.6, redMoneyToleranceMod: 1.0, actualPop: 0, socialCreditAvailableToLend: 200, socialCreditBorrowed: 0, innovationScore: 0.2 }, | |
{ name: "High Income", popShare: 0.03, balance: 10000, creditLimit: 10000, baseSpendingPerCapita: 100000, wellBeing: 0.8, redMoneyToleranceMod: 0.5, actualPop: 0, socialCreditAvailableToLend: 1000, socialCreditBorrowed: 0, innovationScore: 0.4 } | |
]; | |
let businessCohort = { name: "Businesses", count: 0, avgBalance: 5000, creditLimit: 10000, spendingToIndividuals: 0, spendingToBusinesses: 0, innovationScore: 0.3 }; | |
let globalStats = { avgUbi: 0, giniCoefficient: 0.85, povertyRate: 80, techLevel: 1.0, totalRedMoney: 0, totalPositiveBalances: 0, netSystemDeltars: 0, annualPoolRevenue: 0, totalTransactionVolumeYear: 0, dailyTransactionVolume:0, dailyPoolRevenue: 0, totalCreditLimitSystem: 0, socialCreditLent: 0, activeSocialProjects: 0 }; | |
let accumulatedGrantForSocialProjects = 0; // New global variable | |
// --- Chart Initialization --- | |
function initCharts() { | |
Chart.defaults.color = '#a7a9be'; | |
const mainCtx = document.getElementById('main-chart').getContext('2d'); | |
mainChart = new Chart(mainCtx, { type: 'line', data: chartData, options: { responsive: true, maintainAspectRatio: false, scales: { x: { title: { display: true, text: 'Year' }, grid: { color: '#243b55' } }, yUbi: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Avg. Daily UBI (Δ) / Tech Level' }, beginAtZero: true, grid: { color: '#2e5077' } }, yGini: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'Gini / Adoption(%)' }, grid: { drawOnChartArea: false }, min: 0, max:1 } }, plugins: { legend: { labels: { color: '#c5c6d0' } } }, animation: { duration: 150 } } }); | |
const lorenzCtx = document.getElementById('lorenz-chart').getContext('2d'); | |
lorenzChart = new Chart(lorenzCtx, { type: 'line', data: lorenzChartData, options: { responsive: true, maintainAspectRatio: false, scales: { x: { title: { display: true, text: 'Cumulative % of Population' }, min:0, max:100, grid: { color: '#243b55' } }, y: { title: { display: true, text: 'Cumulative % of Wealth' }, min:0, max:100, grid: { color: '#2e5077' } } }, plugins: { legend: { display: true, labels: { color: '#c5c6d0'} } }, animation: { duration: 150 } } }); | |
const volumeCtx = document.getElementById('volume-chart').getContext('2d'); | |
volumeChart = new Chart(volumeCtx, { type: 'line', data: volumeChartData, options: { responsive: true, maintainAspectRatio: false, scales: { x: { title: { display: true, text: 'Time (Year.Day)' }, grid: { color: '#243b55' }, ticks: { autoSkip: true, maxTicksLimit: 20 } }, yVolume: { type: 'linear', display: true, position: 'left', title: { display: true, text: 'Transaction Volume (Δ)' }, beginAtZero: true, grid: { color: '#2e5077' } }, yRevenue: { type: 'linear', display: true, position: 'right', title: { display: true, text: 'Pool Revenue (Δ)' }, beginAtZero: true, grid: { drawOnChartArea: false } } }, plugins: { legend: { labels: { color: '#c5c6d0' } } }, animation: { duration: 0 } } }); | |
} | |
// --- Individual/Cohort Stats Chart Logic --- | |
function populateIndividualStatsDropdowns() { | |
const cohortOptions = [ | |
{ value: "random_sample", text: "Random Sampled Individual" }, | |
{ value: "impoverished_rep", text: "Impoverished Cohort Rep." }, | |
{ value: "low_income_rep", text: "Low Income Cohort Rep." }, | |
{ value: "middle_income_rep", text: "Middle Income Cohort Rep." }, | |
{ value: "high_income_rep", text: "High Income Cohort Rep." } | |
]; | |
cohortOptions.forEach(opt => { | |
const optionEl = document.createElement('option'); | |
optionEl.value = opt.value; | |
optionEl.textContent = opt.text; | |
individualCohortSelectEl.appendChild(optionEl); | |
}); | |
if (individualCohortSelectEl.options.length > 0) { | |
individualCohortSelectEl.selectedIndex = 0; // Default to first option | |
} | |
const timeframeOptions = [ | |
{ value: "daily", text: "Daily" }, | |
{ value: "monthly", text: "Monthly" }, | |
{ value: "yearly", text: "Yearly" } | |
]; | |
timeframeOptions.forEach(opt => { | |
const optionEl = document.createElement('option'); | |
optionEl.value = opt.value; | |
optionEl.textContent = opt.text; | |
individualTimeframeSelectEl.appendChild(optionEl); | |
}); | |
if (individualTimeframeSelectEl.options.length > 0) { | |
individualTimeframeSelectEl.value = "monthly"; // Default to monthly | |
} | |
} | |
function getHistoryLimitInPoints(retentionValue, timeframe, daysInYearSim) { | |
if (retentionValue === "all_time") return Infinity; | |
const years = parseInt(retentionValue.split('_')[0]); | |
if (isNaN(years)) return 50; // Default fallback | |
switch (timeframe) { | |
case 'daily': return years * daysInYearSim; | |
case 'monthly': return years * 12; | |
case 'yearly': return years; | |
default: return 50; // Default fallback | |
} | |
} | |
function initializeIndividualCohortStoreEntry(cohortKey) { | |
if (!individualChartDataStore[cohortKey]) { | |
individualChartDataStore[cohortKey] = { | |
labels: [], | |
// Structure matches datasets returned by fetchDataForIndividualStatsChart | |
datasets: [ | |
{ label: 'Income (UBI + Other)', data: [], borderColor: 'rgba(75, 192, 192, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' }, | |
{ label: 'Spent (Outflows)', data: [], borderColor: 'rgba(255, 99, 132, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' }, | |
{ label: 'Balance', data: [], borderColor: 'rgba(54, 162, 235, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' }, | |
{ label: 'Dividend (UBI)', data: [], borderColor: 'rgba(255, 206, 86, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' }, | |
{ label: 'Transaction Count', data: [], borderColor: 'rgba(153, 102, 255, 1)', type: 'bar', yAxisID: 'yTxCount' } | |
], | |
lastSampledCohortName: null // ADDED for random sample tracking | |
}; | |
if (cohortKey === "random_sample") { | |
individualChartDataStore[cohortKey].pointSampledFromCohortNames = []; | |
} | |
} | |
} | |
function updateAllIndividualCohortStores() { | |
const currentTimeframe = individualTimeframeSelectEl.value; | |
const currentRetention = individualLogRetentionSelectEl.value; | |
const limit = getHistoryLimitInPoints(currentRetention, currentTimeframe, DAYS_PER_YEAR); | |
for (let i = 0; i < individualCohortSelectEl.options.length; i++) { | |
const cohortKey = individualCohortSelectEl.options[i].value; | |
initializeIndividualCohortStoreEntry(cohortKey); // Ensure it exists | |
const storeEntry = individualChartDataStore[cohortKey]; | |
const currentSnapshot = fetchDataForIndividualStatsChart(cohortKey, currentTimeframe); | |
// Store the last sampled cohort name if this is the random_sample entry | |
if (cohortKey === "random_sample" && currentSnapshot.sampledCohortName) { | |
storeEntry.lastSampledCohortName = currentSnapshot.sampledCohortName; | |
} | |
const lastLabelInStore = storeEntry.labels.length > 0 ? storeEntry.labels[storeEntry.labels.length - 1] : null; | |
let pointShouldBeAdded = false; | |
if (currentSnapshot.labels[0] === "Initial State") { | |
if (storeEntry.labels.length === 0) { // Only add "Initial State" if store is empty for this cohort | |
pointShouldBeAdded = true; | |
} | |
} else { // Regular data point | |
if (lastLabelInStore !== currentSnapshot.labels[0]) { | |
pointShouldBeAdded = true; | |
} else if (storeEntry.labels.length === 0) { | |
pointShouldBeAdded = true; | |
} | |
} | |
if (pointShouldBeAdded) { | |
storeEntry.labels.push(currentSnapshot.labels[0]); | |
currentSnapshot.datasets.forEach((newDs, index) => { | |
if (storeEntry.datasets[index]) { // Should always exist due to initialization | |
storeEntry.datasets[index].data.push(newDs.data[0]); | |
} | |
}); | |
if (cohortKey === "random_sample" && storeEntry.pointSampledFromCohortNames) { | |
storeEntry.pointSampledFromCohortNames.push(currentSnapshot.sampledCohortName || "N/A"); | |
} | |
} | |
// Enforce history limit for this specific cohort's store | |
while (storeEntry.labels.length > limit && limit !== Infinity) { | |
storeEntry.labels.shift(); | |
storeEntry.datasets.forEach(ds => ds.data.shift()); | |
if (cohortKey === "random_sample" && storeEntry.pointSampledFromCohortNames && storeEntry.pointSampledFromCohortNames.length > 0) { | |
storeEntry.pointSampledFromCohortNames.shift(); | |
} | |
} | |
} | |
} | |
function fetchDataForIndividualStatsChart(cohortSelection, timeframe) { | |
let sCohort = null; | |
let labels = []; | |
let incomeData = [], spentData = [], balanceData = [], dividendData = [], txCountData = []; | |
let sampledCohortName = null; // ADDED | |
const cohortMap = { | |
"impoverished_rep": "Impoverished", | |
"low_income_rep": "Low Income", | |
"middle_income_rep": "Middle Income", | |
"high_income_rep": "High Income" | |
}; | |
// Select the target cohort | |
if (cohortSelection === "random_sample") { | |
const activeCohorts = agentCohorts.filter(c => c.actualPop > 0); | |
sCohort = activeCohorts.length > 0 ? activeCohorts[Math.floor(Math.random() * activeCohorts.length)] | |
: agentCohorts.find(c => c.name === "Impoverished") || agentCohorts[0]; | |
if (sCohort) sampledCohortName = sCohort.name; // CAPTURE sampled cohort name | |
} else { | |
sCohort = agentCohorts.find(c => c.name === cohortMap[cohortSelection]); | |
} | |
if (!sCohort) { | |
sCohort = agentCohorts[0] || { name: "Fallback", balance: 0, baseSpendingPerCapita: 1000, wellBeing: 0 }; | |
} | |
// Check if simulation has started | |
const simulationStarted = (currentYear > 0 || currentDay > 0); | |
if (!simulationStarted) { | |
labels = ["Initial State"]; | |
// Use the cohort's initial balance (from definition) | |
let initialBalance = 0; | |
if (sCohort.name === "Impoverished") initialBalance = -100; | |
else if (sCohort.name === "Low Income") initialBalance = 50; | |
else if (sCohort.name === "Middle Income") initialBalance = 1000; | |
else if (sCohort.name === "High Income") initialBalance = 10000; | |
else initialBalance = sCohort.balance || 0; | |
incomeData = [0]; | |
spentData = [0]; | |
balanceData = [initialBalance]; | |
dividendData = [0]; | |
txCountData = [0]; | |
} else { | |
const dailyUbi = globalStats.avgUbi || 0; | |
const dailyOtherIncome = Math.max(0, sCohort.balance > 0 ? sCohort.balance * 0.00005 : 0); | |
const totalDailyIncome = dailyUbi + dailyOtherIncome; | |
const baseDailySpending = (sCohort.baseSpendingPerCapita / DAYS_PER_YEAR); | |
const wellBeingFactor = (1 + sCohort.wellBeing * 0.5); | |
let velocity = 0.2; | |
try { | |
velocity = parseFloat(paramInputs.avgVelocityMultiplier.value); | |
if (isNaN(velocity) || !isFinite(velocity)) velocity = 0.2; | |
} catch(e){ velocity = 0.2; } | |
const estimatedDailySpending = baseDailySpending * wellBeingFactor * velocity; | |
const currentBalance = sCohort.balance; | |
// Estimate transaction count | |
let estimatedDailyTxCount = 1; | |
try { | |
let baseTx = parseFloat(paramInputs.avgVelocityMultiplier.value); | |
if (isNaN(baseTx) || !isFinite(baseTx)) baseTx = 0.2; | |
estimatedDailyTxCount = Math.max(1, Math.floor(baseTx * 5)); | |
} catch(e){ /* use default */ } | |
let currentLabel = ""; | |
if (timeframe === 'daily') { | |
currentLabel = `Y${currentYear}.D${currentDay}`; | |
incomeData = [totalDailyIncome]; | |
spentData = [estimatedDailySpending]; | |
balanceData = [currentBalance]; | |
dividendData = [dailyUbi]; | |
txCountData = [estimatedDailyTxCount]; | |
} else if (timeframe === 'monthly') { | |
const monthNum = currentDay > 0 ? Math.max(1, Math.ceil(currentDay / (DAYS_PER_YEAR / 12))) : (currentYear > 0 ? 1 : 0); | |
if (monthNum === 0) { // Should only be Y0 D0, handled by !simulationStarted | |
currentLabel = "Initial State"; // Fallback | |
} else { | |
currentLabel = `Y${currentYear}.M${monthNum}`; | |
} | |
const factor = DAYS_PER_YEAR / 12; | |
incomeData = [totalDailyIncome * factor]; | |
spentData = [estimatedDailySpending * factor]; | |
balanceData = [currentBalance]; // Balance is a snapshot, not cumulative for the period | |
dividendData = [dailyUbi * factor]; | |
txCountData = [Math.round(estimatedDailyTxCount * factor)]; | |
} else { // yearly | |
currentLabel = `Y${currentYear}`; | |
incomeData = [totalDailyIncome * DAYS_PER_YEAR]; | |
spentData = [estimatedDailySpending * DAYS_PER_YEAR]; | |
balanceData = [currentBalance]; // Balance is a snapshot | |
dividendData = [dailyUbi * DAYS_PER_YEAR]; | |
txCountData = [Math.round(estimatedDailyTxCount * DAYS_PER_YEAR)]; | |
} | |
labels = [currentLabel]; | |
} | |
return { | |
labels: labels, | |
datasets: [ | |
{ label: 'Income (UBI + Other)', data: incomeData, borderColor: 'rgba(75, 192, 192, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' }, | |
{ label: 'Spent (Outflows)', data: spentData, borderColor: 'rgba(255, 99, 132, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' }, | |
{ label: 'Balance', data: balanceData, borderColor: 'rgba(54, 162, 235, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' }, | |
{ label: 'Dividend (UBI)', data: [], borderColor: 'rgba(255, 206, 86, 1)', tension: 0.1, fill: false, yAxisID: 'yMonetary' }, | |
{ label: 'Transaction Count', data: txCountData, borderColor: 'rgba(153, 102, 255, 1)', type: 'bar', yAxisID: 'yTxCount' } | |
], | |
sampledCohortName: sampledCohortName | |
}; | |
} | |
function renderOrUpdateIndividualStatsChart() { | |
if (!individualStatsChartCanvasEl) return; | |
const individualStatsStartTime = performance.now(); // Measure start time | |
const selectedCohortKey = individualCohortSelectEl.value; | |
const selectedTimeframe = individualTimeframeSelectEl.value; | |
const selectedRetention = individualLogRetentionSelectEl.value; | |
const ctx = individualStatsChartCanvasEl.getContext('2d'); | |
let cohortText = "Selected Cohort/Individual"; // Default text | |
if (individualCohortSelectEl.selectedIndex !== -1 && individualCohortSelectEl.options[individualCohortSelectEl.selectedIndex]) { | |
cohortText = individualCohortSelectEl.options[individualCohortSelectEl.selectedIndex].text; | |
} | |
let timeframeText = "Selected Timeframe"; // Default text | |
if (individualTimeframeSelectEl.selectedIndex !== -1 && individualTimeframeSelectEl.options[individualTimeframeSelectEl.selectedIndex]) { | |
timeframeText = individualTimeframeSelectEl.options[individualTimeframeSelectEl.selectedIndex].text; | |
} | |
// --- Construct dynamic title --- | |
let chartTitleText = `Stats for ${cohortText} (${timeframeText})`; | |
if (selectedCohortKey === "random_sample") { | |
const storeEntry = individualChartDataStore[selectedCohortKey]; | |
const lastSampledName = storeEntry?.lastSampledCohortName; | |
if (lastSampledName) { | |
chartTitleText = `Stats for Random Sampled Individual (from ${lastSampledName}) (${timeframeText})`; | |
} else { | |
chartTitleText = `Stats for Random Sampled Individual (${timeframeText})`; | |
} | |
} | |
// --- End dynamic title construction --- | |
initializeIndividualCohortStoreEntry(selectedCohortKey); // Ensure store entry exists | |
let chartDataSource = individualChartDataStore[selectedCohortKey]; | |
// If the store for this cohort is empty (e.g. first time viewing, pre-simulation), populate with an initial point. | |
if (chartDataSource.labels.length === 0) { | |
const initialSnapshot = fetchDataForIndividualStatsChart(selectedCohortKey, selectedTimeframe); | |
chartDataSource.labels.push(initialSnapshot.labels[0]); | |
initialSnapshot.datasets.forEach((newDs, index) => { | |
if (chartDataSource.datasets[index]) { | |
chartDataSource.datasets[index].data.push(newDs.data[0]); | |
} | |
}); | |
if (selectedCohortKey === "random_sample" && chartDataSource.pointSampledFromCohortNames) { | |
chartDataSource.pointSampledFromCohortNames.push(initialSnapshot.sampledCohortName || "N/A"); | |
} | |
} | |
// Apply retention limit if retention setting changed for the current view | |
// This is mostly for when user changes retention, data is trimmed from store. | |
const currentLimit = getHistoryLimitInPoints(selectedRetention, selectedTimeframe, DAYS_PER_YEAR); | |
while (chartDataSource.labels.length > currentLimit && currentLimit !== Infinity) { | |
chartDataSource.labels.shift(); | |
chartDataSource.datasets.forEach(ds => ds.data.shift()); | |
if (selectedCohortKey === "random_sample" && chartDataSource.pointSampledFromCohortNames && chartDataSource.pointSampledFromCohortNames.length > 0) { | |
chartDataSource.pointSampledFromCohortNames.shift(); | |
} | |
} | |
if (!individualStatsChartInstance) { | |
individualStatsChartInstance = new Chart(ctx, { | |
type: 'line', | |
data: { // Use a deep copy for the chart instance to avoid direct mutation issues with Chart.js | |
labels: [...chartDataSource.labels], | |
datasets: chartDataSource.datasets.map(ds => ({ ...ds, data: [...ds.data] })) | |
}, | |
options: { | |
responsive: true, | |
maintainAspectRatio: false, | |
interaction: { mode: 'index', intersect: false }, | |
plugins: { | |
title: { display: true, text: chartTitleText, color: '#e4bb97' }, // USE DYNAMIC TITLE | |
legend: { labels: { color: '#c5c6d0' } }, | |
tooltip: { | |
callbacks: { | |
afterBody: function(tooltipItems) { | |
if (selectedCohortKey === "random_sample" && tooltipItems.length > 0) { | |
const dataIndex = tooltipItems[0].dataIndex; | |
const storeEntry = individualChartDataStore[selectedCohortKey]; | |
if (storeEntry && storeEntry.pointSampledFromCohortNames && storeEntry.pointSampledFromCohortNames[dataIndex]) { | |
const sampledCohortNameForPoint = storeEntry.pointSampledFromCohortNames[dataIndex]; | |
const emoji = getCohortEmoji(sampledCohortNameForPoint); | |
return `\nSampled from: ${emoji} ${sampledCohortNameForPoint}`; | |
} | |
} | |
return ''; | |
} | |
} | |
} | |
}, | |
scales: { | |
x: { title: { display: true, text: 'Time Period' }, grid: { color: '#243b55' }, ticks: { color: '#a7a9be'} }, | |
yMonetary: { | |
type: 'linear', display: true, position: 'left', | |
title: { display: true, text: 'Amount (Δ)' }, | |
grid: { color: '#2e5077' }, ticks: { color: '#a7a9be'} | |
}, | |
yTxCount: { | |
type: 'linear', | |
display: chartDataSource.datasets.some(ds => ds.yAxisID === 'yTxCount' && ds.data && ds.data.length > 0), // Use chartDataSource here | |
position: 'right', | |
title: { display: true, text: 'Transaction Count' }, | |
grid: { drawOnChartArea: false }, beginAtZero: true, ticks: { color: '#a7a9be'} | |
} | |
}, | |
animation: { duration: 0 } // No animation for initial render either | |
} | |
}); | |
} else { | |
// If selection changed (cohort, timeframe, or retention), reload data from store | |
if (individualStatsChartInstance.currentCohortSelection !== selectedCohortKey || | |
individualStatsChartInstance.currentTimeframe !== selectedTimeframe || | |
individualStatsChartInstance.currentLogRetention !== selectedRetention) { | |
// Update chart instance data directly from the (potentially trimmed) store | |
individualStatsChartInstance.data.labels = [...chartDataSource.labels]; | |
individualStatsChartInstance.data.datasets.forEach((ds, i) => { | |
// Ensure dataset structure matches, then update data | |
if (chartDataSource.datasets[i]) { | |
ds.label = chartDataSource.datasets[i].label; | |
ds.borderColor = chartDataSource.datasets[i].borderColor; | |
ds.tension = chartDataSource.datasets[i].tension; | |
ds.fill = chartDataSource.datasets[i].fill; | |
ds.yAxisID = chartDataSource.datasets[i].yAxisID; | |
ds.type = chartDataSource.datasets[i].type; // For bar chart part | |
ds.data = [...chartDataSource.datasets[i].data]; | |
} | |
}); | |
} | |
// Data is already up-to-date in chartDataSource due to updateAllIndividualCohortStores or initial load/trim. | |
// So, we just ensure the chart instance reflects the current state of chartDataSource. | |
individualStatsChartInstance.data.labels = [...chartDataSource.labels]; | |
individualStatsChartInstance.data.datasets.forEach((ds, i) => { | |
if (chartDataSource.datasets[i]) { // defensive | |
ds.data = [...chartDataSource.datasets[i].data]; | |
} | |
}); | |
individualStatsChartInstance.options.plugins.title.text = chartTitleText; // USE DYNAMIC TITLE | |
// Update tooltip callback if it wasn't set initially or needs to refresh context (though selectedCohortKey is from outer scope) | |
individualStatsChartInstance.options.plugins.tooltip.callbacks.afterBody = function(tooltipItems) { | |
if (selectedCohortKey === "random_sample" && tooltipItems.length > 0) { | |
const dataIndex = tooltipItems[0].dataIndex; | |
const storeEntry = individualChartDataStore[selectedCohortKey]; | |
if (storeEntry && storeEntry.pointSampledFromCohortNames && storeEntry.pointSampledFromCohortNames[dataIndex]) { | |
const sampledCohortNameForPoint = storeEntry.pointSampledFromCohortNames[dataIndex]; | |
const emoji = getCohortEmoji(sampledCohortNameForPoint); | |
return `\nSampled from: ${emoji} ${sampledCohortNameForPoint}`; | |
} | |
} | |
return ''; | |
}; | |
const hasTxCount = chartDataSource.datasets.some(ds => ds.yAxisID === 'yTxCount' && ds.data.length > 0 && ds.data.some(d => d !== undefined && d !== null)); | |
if (individualStatsChartInstance.options.scales.yTxCount) { | |
individualStatsChartInstance.options.scales.yTxCount.display = hasTxCount; | |
} | |
individualStatsChartInstance.update('none'); | |
} | |
// Store current selection to detect changes next time | |
if (individualStatsChartInstance) { | |
individualStatsChartInstance.currentCohortSelection = selectedCohortKey; | |
individualStatsChartInstance.currentTimeframe = selectedTimeframe; | |
individualStatsChartInstance.currentLogRetention = selectedRetention; | |
} | |
updateModuleSteptime('module-individual-stats', individualStatsStartTime); // Update steptime | |
} | |
// --- Simulation Logic --- | |
function simulateDayStep(params, dayOfYear) { | |
globalStats.dailyTransactionVolume = 0; | |
let dailyFeesCollected = 0; | |
const dynamicVelocity = params.avgVelocity * (0.8 + Math.random() * 0.4 + (1-globalStats.giniCoefficient) * 0.1); | |
// --- Daily New User Adoption --- | |
if (params.newUsersPerDay > 0) { | |
ioubiUserCount += params.newUsersPerDay; | |
agentCohorts.forEach(cohort => { | |
const newToCohortDaily = params.newUsersPerDay * cohort.popShare; | |
if (newToCohortDaily === 0 && cohort.actualPop === 0) return; | |
const oldPop = cohort.actualPop; | |
cohort.actualPop += newToCohortDaily; | |
if (oldPop > 0) { | |
const weightedOldBalance = cohort.balance * oldPop; | |
const newUsersInitialDebt = params.initialPersonalCredit * 0.5 * newToCohortDaily; | |
cohort.balance = safeDiv(weightedOldBalance - newUsersInitialDebt, cohort.actualPop, cohort.balance); | |
const weightedOldCreditLimit = cohort.creditLimit * oldPop; | |
const newUsersCreditLimit = params.initialPersonalCredit * newToCohortDaily; | |
cohort.creditLimit = safeDiv(weightedOldCreditLimit + newUsersCreditLimit, cohort.actualPop, cohort.creditLimit); | |
} else if (newToCohortDaily > 0) { | |
cohort.balance = -params.initialPersonalCredit * 0.5; | |
cohort.creditLimit = params.initialPersonalCredit; | |
} | |
}); | |
businessCohort.count = Math.max(10, Math.floor(ioubiUserCount * 0.05)); | |
if (currentYear === 1 && dayOfYear === 1 && businessCohort.count > 0) { | |
businessCohort.avgBalance = params.initialBusinessCredit * 0.2; | |
businessCohort.creditLimit = params.initialBusinessCredit; | |
} | |
} | |
agentCohorts.forEach(actingCohort => { | |
if (actingCohort.actualPop <= 0) return; | |
let dailySpendingTotal = (actingCohort.baseSpendingPerCapita / DAYS_PER_YEAR) * actingCohort.actualPop * (1 + actingCohort.wellBeing * 0.5); | |
let desiredBalance = -actingCohort.creditLimit * params.redMoneyToleranceFactor * actingCohort.redMoneyToleranceMod; | |
if (actingCohort.balance < desiredBalance) dailySpendingTotal *= 0.8; | |
else if (actingCohort.balance > actingCohort.creditLimit * 0.5) dailySpendingTotal *= 1.2; | |
agentCohorts.forEach(receivingCohort => { | |
if (actingCohort === receivingCohort || receivingCohort.actualPop <= 0) return; | |
let transactionAmountForPair = (dailySpendingTotal * receivingCohort.popShare) * dynamicVelocity; | |
if (isNaN(transactionAmountForPair) || transactionAmountForPair <= 0.01) return; | |
globalStats.dailyTransactionVolume += transactionAmountForPair; | |
const feeRate = getAvgTransactionFeeRate(actingCohort, receivingCohort); | |
const fees = transactionAmountForPair * (feeRate / 100); | |
dailyFeesCollected += fees; | |
actingCohort.balance -= safeDiv(transactionAmountForPair, actingCohort.actualPop, 0); | |
receivingCohort.balance += safeDiv(transactionAmountForPair - fees, receivingCohort.actualPop, 0); | |
if (Math.random() < 0.001) { | |
recordTransaction(actingCohort.name, receivingCohort.name, transactionAmountForPair, fees, 'C2C'); | |
} | |
}); | |
}); | |
if (businessCohort.count > 0) { | |
let businessDailyTotalSpending = (businessCohort.count * 50000 / DAYS_PER_YEAR) * (1 + globalStats.techLevel * 0.1); | |
let dailySpendingToIndividuals = businessDailyTotalSpending * 0.6; | |
let dailySpendingToBusinesses = businessDailyTotalSpending * 0.4; | |
agentCohorts.forEach(cohort => { | |
if (cohort.actualPop <= 0) return; | |
let amountToCohort = (dailySpendingToIndividuals * cohort.popShare) * dynamicVelocity; | |
if (isNaN(amountToCohort) || amountToCohort <= 0.01) return; | |
globalStats.dailyTransactionVolume += amountToCohort; | |
const feeRate = getAvgTransactionFeeRate(businessCohort, cohort); | |
const fees = amountToCohort * (feeRate / 100); | |
dailyFeesCollected += fees; | |
cohort.balance += safeDiv(amountToCohort - fees, cohort.actualPop, 0); | |
businessCohort.avgBalance -= safeDiv(amountToCohort, businessCohort.count, 0); | |
if (Math.random() < 0.005) { | |
recordTransaction(businessCohort.name, cohort.name, amountToCohort, fees, 'B2C'); | |
} | |
}); | |
let b2bTransactions = dailySpendingToBusinesses * dynamicVelocity; | |
if (!isNaN(b2bTransactions) && b2bTransactions > 0.01) { | |
globalStats.dailyTransactionVolume += b2bTransactions; | |
const b2bFeeRate = getAvgTransactionFeeRate(businessCohort, businessCohort); | |
const b2bFees = b2bTransactions * (b2bFeeRate / 100); | |
dailyFeesCollected += b2bFees; | |
if (Math.random() < 0.005) { | |
recordTransaction(businessCohort.name, businessCohort.name, b2bTransactions, b2bFees, 'B2B'); | |
} | |
} | |
} | |
groupAccounts.forEach(ga => { | |
if (ga.balance > 0) { | |
let dailyProjectSpending = Math.min(ga.balance, (ga.initialFunding || 1000) / (DAYS_PER_YEAR * 2)); | |
if (dailyProjectSpending > 0.01) { | |
ga.balance -= dailyProjectSpending; | |
if (ga.purpose === 'technology') globalStats.techLevel += dailyProjectSpending * 0.0001 * ga.impactFactor; | |
else if (ga.purpose === 'social_welfare') agentCohorts.forEach(c => { if(c.actualPop > 0) c.wellBeing = Math.min(1, c.wellBeing + dailyProjectSpending * 0.00001 * ga.impactFactor / c.actualPop)}); | |
} | |
} | |
}); | |
globalStats.annualPoolRevenue += dailyFeesCollected; | |
globalStats.totalTransactionVolumeYear += globalStats.dailyTransactionVolume; | |
// --- Daily Grant Distribution Effects --- | |
const dailyGrantAllocation = dailyFeesCollected * params.grantPoolSplit; | |
if (dailyGrantAllocation > 0 && ioubiUserCount > 0) { | |
const categories = params.grantFocus === "balanced" ? ["technology", "social_welfare", "environment", "infrastructure", "social_projects"] : [params.grantFocus]; | |
const grantPerCategoryDaily = dailyGrantAllocation / categories.length; | |
categories.forEach(categoryFocus => { | |
const dailyCatGrantImpactFactor = safeDiv(grantPerCategoryDaily, ioubiUserCount, 0); | |
if (categoryFocus === "technology") { | |
globalStats.techLevel += safeDiv(dailyCatGrantImpactFactor, 100, 0) * (1 + params.innovationPropensity); | |
} else if (categoryFocus === "social_welfare") { | |
agentCohorts.forEach(c => c.wellBeing = Math.min(1, c.wellBeing + safeDiv(dailyCatGrantImpactFactor, 1500, 0))); | |
} else if (categoryFocus === "environment" || categoryFocus === "infrastructure") { | |
globalStats.techLevel += safeDiv(dailyCatGrantImpactFactor, 800, 0) * 0.5; | |
agentCohorts.forEach(c => c.wellBeing = Math.min(1, c.wellBeing + safeDiv(dailyCatGrantImpactFactor, 3000, 0))); | |
} else if (categoryFocus === "social_projects") { | |
accumulatedGrantForSocialProjects += grantPerCategoryDaily; | |
if (accumulatedGrantForSocialProjects >= 40000) { | |
const numNewProjects = Math.floor(accumulatedGrantForSocialProjects / 40000); | |
const projectFundingPerEach = 40000; | |
accumulatedGrantForSocialProjects -= numNewProjects * projectFundingPerEach; | |
for (let i = 0; i < numNewProjects; i++) { | |
const newProject = { id: nextGroupId++, name: `Social Project ${nextGroupId - 1}`, balance: projectFundingPerEach, initialFunding: projectFundingPerEach, contributors: [], purpose: 'social_welfare', impactFactor: 1.0 + Math.random() * 0.5, startYear: currentYear }; | |
groupAccounts.push(newProject); | |
logEvent(`New Social Project '${newProject.name}' funded with ${formatNumber(projectFundingPerEach, 0)}Δ`, "positive"); | |
recordTransaction('GrantSystem', 'SocialProject:' + newProject.name, projectFundingPerEach, 0, 'Grant'); | |
} | |
} | |
} | |
}); | |
} | |
groupAccounts = groupAccounts.filter(ga => ga.balance > 1 || (currentYear - (ga.startYear || 0)) < 5); | |
// --- Daily R&D Investment --- | |
let dailyTotalInnovationFunding = 0; | |
agentCohorts.forEach(cohort => { | |
if (cohort.actualPop > 0 && cohort.balance > 0) { | |
let cohortDailyInnovationInvestment = cohort.balance * cohort.actualPop * params.innovationPropensity * cohort.innovationScore * 0.1 / DAYS_PER_YEAR; | |
dailyTotalInnovationFunding += cohortDailyInnovationInvestment; | |
cohort.balance -= safeDiv(cohortDailyInnovationInvestment, cohort.actualPop, 0); | |
} | |
}); | |
if (businessCohort.count > 0 && businessCohort.avgBalance > 0) { | |
let businessDailyInnovationInvestment = businessCohort.avgBalance * businessCohort.count * params.innovationPropensity * businessCohort.innovationScore * 0.1 / DAYS_PER_YEAR; | |
dailyTotalInnovationFunding += businessDailyInnovationInvestment; | |
businessCohort.avgBalance -= safeDiv(businessDailyInnovationInvestment, businessCohort.count); | |
} | |
if (dailyTotalInnovationFunding > 0) { | |
let currentTotalPositiveBalancesForRD = 0; | |
agentCohorts.forEach(c => { if(c.balance > 0 && c.actualPop > 0) currentTotalPositiveBalancesForRD += c.balance * c.actualPop }); | |
if(businessCohort.avgBalance > 0 && businessCohort.count > 0) currentTotalPositiveBalancesForRD += businessCohort.avgBalance * businessCohort.count; | |
const techIncreaseDenominator = (currentTotalPositiveBalancesForRD + (businessCohort.count > 0 ? businessCohort.count * 1000 : 0) + 1) * 5; | |
const dailyTechIncreaseFromRD = safeDiv(dailyTotalInnovationFunding, techIncreaseDenominator, 0); | |
globalStats.techLevel += dailyTechIncreaseFromRD; | |
} | |
globalStats.techLevel += params.dailyBaseTechIncrease; | |
globalStats.techLevel = Math.max(1, isNaN(globalStats.techLevel) ? 1 : globalStats.techLevel); | |
// IOUBI model: fees vanish, UBI is distributed later based on tracked vanished fees. | |
const ubiPoolDaily = dailyFeesCollected * params.ubiPoolSplit; | |
const dailyUbiPerUser = safeDiv(ubiPoolDaily, ioubiUserCount, 0); | |
if (dailyUbiPerUser > 0) { | |
agentCohorts.forEach(cohort => { | |
if (cohort.actualPop > 0) { | |
cohort.balance += dailyUbiPerUser; // No tx, just a balance update | |
} | |
}); | |
} | |
globalStats.avgUbi = dailyUbiPerUser; | |
globalStats.dailyPoolRevenue = dailyFeesCollected; | |
// --- Debugging output for UBI distribution logic --- | |
if (dailyUbiPerUser > 0) { | |
logEvent(`Daily UBI Distribution: ${formatNumber(dailyUbiPerUser, 2)}Δ per user`, "info"); | |
} | |
} | |
async function simulateYear() { | |
if (currentYear >= MAX_YEARS) { | |
logEvent("Max simulation years reached.", "critical"); | |
nextYearBtn.disabled = true; return; | |
} | |
const yearProcessingStartTime = performance.now(); // Start total time measurement for the year | |
document.getElementById('total-year-time-display').textContent = `Total Year Time: 0.0s`; // Reset for new year | |
document.getElementById('step-time-display').textContent = `Step Time: 0ms`; // Reset for new year | |
currentYear++; | |
currentYearEl.textContent = currentYear; | |
logEvent(`Year ${currentYear} simulation processing...`, "info"); | |
nextYearBtn.disabled = true; | |
// Reset temporary volume chart data stores at the beginning of each year | |
tempVolumeChartLabels = []; | |
tempVolumeChartVolumeData = []; | |
tempVolumeChartRevenueData = []; | |
accumulatedGrantForSocialProjects = 0; // Reset accumulator for new year | |
DAYS_PER_YEAR = parseInt(paramInputs.daysInYear.value); | |
const yearlyTimeBudgetS = parseFloat(paramInputs.yearlyTimeBudget.value); | |
const uiUpdateFrequency = paramInputs.uiUpdateFrequency.value; | |
const timePerDayMs = Math.max(1, (yearlyTimeBudgetS * 1000) / DAYS_PER_YEAR); | |
globalStats.totalTransactionVolumeYear = 0; | |
globalStats.annualPoolRevenue = 0; | |
let accumulatedUbiForAnnualAvg = 0; | |
let daysSimulatedForUbiAvg = 0; | |
const govPoolSplit = 0.05; // <-- Explicitly define govPoolSplit here | |
const grantPoolSplit = Math.max(0, 1.0 - paramInputs.transactionFeeSplitUbi.value / 100 - govPoolSplit); // <-- Explicitly define grantPoolSplit here | |
const annualizedAvgUbi = globalStats.avgUbi * DAYS_PER_YEAR; | |
let potentialNewUsers = GLOBAL_POPULATION * paramInputs.baseAdoptionRate.value / 100 * (1 + annualizedAvgUbi / (paramInputs.initialPersonalCredit.value * 2)); | |
potentialNewUsers *= Math.max(0.01, (1 - (ioubiUserCount / GLOBAL_POPULATION))); | |
const annualActualNewUsers = Math.max(0, Math.min(potentialNewUsers, GLOBAL_POPULATION - ioubiUserCount)); | |
const newUsersPerDay = annualActualNewUsers / DAYS_PER_YEAR; | |
const params = { | |
...paramInputs, | |
newUsersPerDay, | |
dailyBaseTechIncrease: 0.005 / DAYS_PER_YEAR, | |
ubiPoolSplit: parseFloat(paramInputs.transactionFeeSplitUbi.value) / 100, | |
grantPoolSplit: grantPoolSplit, | |
avgVelocity: parseFloat(paramInputs.avgVelocityMultiplier.value), | |
redMoneyToleranceFactor: parseFloat(paramInputs.redMoneyTolerance.value), | |
innovationPropensity: parseFloat(paramInputs.innovationInvestmentPropensity.value), | |
initialPersonalCredit: parseFloat(paramInputs.initialPersonalCredit.value), | |
initialBusinessCredit: parseFloat(paramInputs.initialBusinessCredit.value), | |
grantFocus: paramInputs.grantFocus.value | |
}; | |
for (let day = 1; day <= DAYS_PER_YEAR; day++) { | |
currentDay = day; | |
currentDayEl.textContent = day; | |
daysPerYearDisplayEl.textContent = DAYS_PER_YEAR; | |
const stepStartTime = performance.now(); | |
simulateDayStep(params, day); | |
accumulatedUbiForAnnualAvg += globalStats.avgUbi; | |
daysSimulatedForUbiAvg++; | |
// Accumulate daily data for volume chart | |
if (typeof globalStats.dailyTransactionVolume === "number" && typeof globalStats.dailyPoolRevenue === "number") { | |
tempVolumeChartLabels.push(`Y${currentYear}.D${currentDay}`); | |
tempVolumeChartVolumeData.push(globalStats.dailyTransactionVolume); | |
tempVolumeChartRevenueData.push(globalStats.dailyPoolRevenue); | |
} | |
if (uiUpdateFrequency === 'daily' || (uiUpdateFrequency === 'monthly' && day % Math.max(1, Math.floor(DAYS_PER_YEAR / 12)) === 0) || (uiUpdateFrequency === 'yearly' && day === DAYS_PER_YEAR)) { | |
updateUIDisplay(false); | |
updateAllIndividualCohortStores(); // Update data for ALL cohorts in the store | |
renderOrUpdateIndividualStatsChart(); // Then render the currently selected one | |
// Update Volume Chart based on UI frequency | |
if (volumeChart && tempVolumeChartLabels.length > 0) { | |
// Add accumulated data to the main chart data arrays | |
volumeChartData.labels.push(...tempVolumeChartLabels); | |
volumeChartData.datasets[0].data.push(...tempVolumeChartVolumeData); | |
volumeChartData.datasets[1].data.push(...tempVolumeChartRevenueData); | |
// Enforce history limit | |
while (volumeChartData.labels.length > dailyChartHistoryLimit) { | |
volumeChartData.labels.shift(); | |
volumeChartData.datasets[0].data.shift(); | |
volumeChartData.datasets[1].data.shift(); | |
} | |
volumeChart.update(); | |
// Clear temporary arrays for the next accumulation period | |
tempVolumeChartLabels = []; | |
tempVolumeChartVolumeData = []; | |
tempVolumeChartRevenueData = []; | |
} | |
// Update total simulation time display more frequently | |
const currentTimeInYearForSimDisplay = performance.now() - yearProcessingStartTime; // Use current time for this specific display | |
const currentTotalSimTimeMs = cumulativeProcessingTimeMs + currentTimeInYearForSimDisplay; | |
const totalSecondsDisplay = (currentTotalSimTimeMs / 1000).toFixed(1); | |
document.getElementById('total-sim-time-display').textContent = `Total Sim Time: ${totalSecondsDisplay}s`; | |
} | |
const stepEndTime = performance.now(); | |
const stepDurationMs = Math.round(stepEndTime - stepStartTime); | |
document.getElementById('step-time-display').textContent = `Step Time: ${stepDurationMs}ms`; | |
const currentTimeInYearMs = stepEndTime - yearProcessingStartTime; | |
document.getElementById('total-year-time-display').textContent = `Total Year Time: ${(currentTimeInYearMs / 1000).toFixed(1)}s`; | |
await new Promise(resolve => setTimeout(resolve, timePerDayMs)); | |
} | |
currentDay = 0; | |
globalStats.avgUbi = safeDiv(accumulatedUbiForAnnualAvg, daysSimulatedForUbiAvg, 0); | |
const totalFeesCollectedThisYear = globalStats.annualPoolRevenue; | |
const govPoolTotal = totalFeesCollectedThisYear * govPoolSplit; | |
const grantPoolTotal = totalFeesCollectedThisYear * grantPoolSplit; | |
if (grantPoolTotal > 0 && !isNaN(grantPoolTotal)) { | |
logEvent(`Grant Pool: ${formatNumber(grantPoolTotal,1)}Δ for ${params.grantFocus}.`, "info"); | |
if (params.grantFocus === "balanced") { | |
const categories = ["technology", "social_welfare", "environment", "infrastructure", "social_projects"]; | |
const numCategories = categories.length; | |
const grantPerCategory = grantPoolTotal / numCategories; | |
categories.forEach(categoryFocus => { | |
const categoryGrantImpactFactor = safeDiv(grantPerCategory, ioubiUserCount, 0); | |
logEvent(`Balanced Distribution: ${formatNumber(grantPerCategory,1)}Δ to ${categoryFocus}.`, "info"); | |
if (categoryFocus === "technology") { | |
globalStats.techLevel += safeDiv(categoryGrantImpactFactor, 100, 0) * (1 + params.innovationPropensity); | |
} else if (categoryFocus === "social_welfare") { | |
agentCohorts.forEach(c => c.wellBeing = Math.min(1, c.wellBeing + safeDiv(categoryGrantImpactFactor, 1500, 0))); | |
} else if (categoryFocus === "environment" || categoryFocus === "infrastructure") { // Combined logic for these two | |
globalStats.techLevel += safeDiv(categoryGrantImpactFactor, 800, 0) * 0.5; | |
agentCohorts.forEach(c => c.wellBeing = Math.min(1, c.wellBeing + safeDiv(categoryGrantImpactFactor, 3000, 0))); | |
} else if (categoryFocus === "social_projects" && ioubiUserCount > 0) { | |
if (grantPerCategory > 0) { // Ensure there's funding for this category | |
const numNewProjects = Math.max(1, Math.floor(grantPerCategory / 40000)); | |
const projectFunding = grantPerCategory / numNewProjects; | |
for (let i = 0; i < numNewProjects; i++) { | |
const newProject = { id: nextGroupId++, name: `Social Project (Bal) ${nextGroupId - 1}`, balance: projectFunding, initialFunding: projectFunding, contributors: [], purpose: 'social_welfare', impactFactor: 1.0 + Math.random() * 0.5, startYear: currentYear }; | |
groupAccounts.push(newProject); | |
logEvent(`New Social Project '${newProject.name}' funded with ${formatNumber(projectFunding, 0)}Δ (Balanced)`, "positive"); | |
recordTransaction('GrantSystem', 'SocialProject:' + newProject.name, projectFunding, 0, 'Grant'); | |
} | |
} | |
} | |
}); | |
} else { // Single focus logic | |
const grantImpactFactor = safeDiv(grantPoolTotal, ioubiUserCount, 0); | |
if (params.grantFocus === "technology") { | |
globalStats.techLevel += safeDiv(grantImpactFactor, 100, 0) * (1 + params.innovationPropensity); | |
} else if (params.grantFocus === "social_welfare") { | |
agentCohorts.forEach(c => c.wellBeing = Math.min(1, c.wellBeing + safeDiv(grantImpactFactor, 1500, 0))); | |
} else if (params.grantFocus === "environment" || params.grantFocus === "infrastructure") { | |
globalStats.techLevel += safeDiv(grantImpactFactor, 800, 0) * 0.5; | |
agentCohorts.forEach(c => c.wellBeing = Math.min(1, c.wellBeing + safeDiv(grantImpactFactor, 3000, 0))); | |
} else if (params.grantFocus === "social_projects" && ioubiUserCount > 0) { | |
const numNewProjects = Math.max(1, Math.floor(grantPoolTotal / 40000)); | |
const projectFunding = grantPoolTotal / numNewProjects; | |
for (let i = 0; i < numNewProjects; i++) { | |
const newProject = { id: nextGroupId++, name: `Social Project ${nextGroupId - 1}`, balance: projectFunding, initialFunding: projectFunding, contributors: [], purpose: 'social_welfare', impactFactor: 1.0 + Math.random() * 0.5, startYear: currentYear }; | |
groupAccounts.push(newProject); | |
logEvent(`New Social Project '${newProject.name}' funded with ${formatNumber(projectFunding, 0)}Δ`, "positive"); | |
recordTransaction('GrantSystem', 'SocialProject:' + newProject.name, projectFunding, 0, 'Grant'); | |
} | |
} | |
} | |
} | |
groupAccounts = groupAccounts.filter(ga => ga.balance > 1 || (currentYear - (ga.startYear || 0)) < 5); | |
let innovationFunding = 0; | |
agentCohorts.forEach(cohort => { | |
if (cohort.balance > 0 && cohort.actualPop > 0) { | |
let cohortInnovationInvestment = cohort.balance * cohort.actualPop * params.innovationPropensity * cohort.innovationScore * 0.1; | |
innovationFunding += cohortInnovationInvestment; | |
cohort.balance -= cohortInnovationInvestment / cohort.actualPop; | |
} | |
}); | |
if (businessCohort.avgBalance > 0 && businessCohort.count > 0) { | |
let businessInnovationInvestment = businessCohort.avgBalance * businessCohort.count * params.innovationPropensity * businessCohort.innovationScore * 0.1; | |
innovationFunding += businessInnovationInvestment; | |
businessCohort.avgBalance -= businessInnovationInvestment / businessCohort.count; | |
} | |
const techIncreaseFromRD = safeDiv(innovationFunding, (globalStats.totalPositiveBalances + businessCohort.count * 1000 + 1) * 5, 0); | |
globalStats.techLevel += techIncreaseFromRD + 0.005; | |
globalStats.techLevel = Math.max(1, isNaN(globalStats.techLevel) ? 1 : globalStats.techLevel); | |
if (techIncreaseFromRD > 0.01) { | |
logEvent(`R&D investment yielded Tech Level increase of ${techIncreaseFromRD.toFixed(3)}`, "info"); | |
} | |
agentCohorts.forEach(cohort => { | |
if (cohort.actualPop <=0) return; | |
if (cohort.balance > cohort.creditLimit * 0.1) { | |
cohort.creditLimit *= (1.01 + safeDiv(cohort.balance, cohort.creditLimit * 20 + 1, 0) * 0.03); | |
cohort.socialCreditAvailableToLend += cohort.balance * 0.05; | |
} else if (cohort.balance < -cohort.creditLimit * 0.8) { | |
cohort.creditLimit *= 0.99; | |
cohort.socialCreditAvailableToLend *= 0.95; | |
} | |
cohort.creditLimit = Math.max(params.initialPersonalCredit * 0.2, Math.min(params.initialPersonalCredit * 30, cohort.creditLimit)); | |
cohort.creditLimit = isNaN(cohort.creditLimit) ? params.initialPersonalCredit : cohort.creditLimit; | |
if ((cohort.name === "Middle Income" || cohort.name === "High Income") && cohort.socialCreditAvailableToLend > 10) { | |
let amountToLend = cohort.socialCreditAvailableToLend * 0.1 * cohort.wellBeing; | |
let recipientCohort = agentCohorts.find(c => c.name === "Impoverished" && c.actualPop > 0 && c.socialCreditBorrowed < c.creditLimit * 0.2); | |
if (recipientCohort && amountToLend > 1) { | |
const actualLentPerPerson = safeDiv(amountToLend, cohort.actualPop,0); | |
const actualBorrowedPerPerson = safeDiv(amountToLend, recipientCohort.actualPop,0); | |
cohort.balance -= actualLentPerPerson; | |
cohort.socialCreditAvailableToLend -= amountToLend; | |
recipientCohort.balance += actualBorrowedPerPerson; | |
recipientCohort.socialCreditBorrowed += amountToLend; | |
if (currentYear % 5 === 0) logEvent(`${cohort.name} lent ${formatNumber(amountToLend,0)}Δ social credit to ${recipientCohort.name}`, "info"); | |
recordTransaction(cohort.name, recipientCohort.name, amountToLend, 0, 'SocialLend'); | |
} | |
} | |
if (cohort.socialCreditBorrowed > 0 && cohort.balance > cohort.socialCreditBorrowed * 0.05) { | |
let repaymentAmount = Math.min(cohort.socialCreditBorrowed * 0.02, cohort.balance * 0.01); | |
let lenderCohort = agentCohorts.find(c => (c.name === "High Income" || c.name === "Middle Income") && c.actualPop > 0); | |
if (lenderCohort) { | |
const actualRepaidPerPerson = safeDiv(repaymentAmount, cohort.actualPop,0); | |
const actualReceivedPerPerson = safeDiv(repaymentAmount, lenderCohort.actualPop,0); | |
cohort.balance -= actualRepaidPerPerson; | |
cohort.socialCreditBorrowed -= repaymentAmount; | |
lenderCohort.balance += actualReceivedPerPerson; | |
lenderCohort.socialCreditAvailableToLend += repaymentAmount; | |
recordTransaction(cohort.name, lenderCohort.name, repaymentAmount, 0, 'SocialRepay'); | |
} | |
} | |
if(cohort.socialCreditBorrowed < 0) cohort.socialCreditBorrowed = 0; | |
cohort.wellBeing = Math.max(0.05, Math.min(1, | |
0.1 + (globalStats.avgUbi * DAYS_PER_YEAR / (params.initialPersonalCredit*0.5) ) * 0.2 + | |
safeDiv(cohort.balance, (cohort.creditLimit + Math.abs(cohort.balance) + 1000),0) * 0.5 + | |
(globalStats.techLevel / 20) * 0.2 + | |
(1 - globalStats.giniCoefficient) * 0.1 | |
)); | |
cohort.wellBeing = isNaN(cohort.wellBeing) ? 0.1 : cohort.wellBeing; | |
cohort.balance = isNaN(cohort.balance) ? (-params.initialPersonalCredit * 0.5) : cohort.balance; | |
}); | |
if (Math.random() < 0.25) triggerRandomEvent(params); | |
updateUIDisplay(true); | |
updateAllIndividualCohortStores(); // Ensure store is updated at year end too | |
renderOrUpdateIndividualStatsChart(); // <--- refresh individual stats chart once per year | |
nextYearBtn.disabled = false; | |
const yearProcessingEndTime = performance.now(); | |
const thisYearProcessingTimeMs = yearProcessingEndTime - yearProcessingStartTime; | |
cumulativeProcessingTimeMs += thisYearProcessingTimeMs; | |
const totalSeconds = (cumulativeProcessingTimeMs / 1000).toFixed(1); | |
document.getElementById('total-sim-time-display').textContent = `Total Sim Time: ${totalSeconds}s`; | |
document.getElementById('total-year-time-display').textContent = `Total Year Time: ${(thisYearProcessingTimeMs / 1000).toFixed(1)}s`; | |
// Step time for the last step of the year is already set by the loop. | |
if (currentYear === MAX_YEARS) { | |
logEvent("SIMULATION COMPLETE!", "critical"); | |
nextYearBtn.disabled = true; | |
} | |
} | |
function triggerRandomEvent(params) { | |
const events = [ | |
{ name: "Technological Breakthrough", effect: () => { globalStats.techLevel *= (1.05 + Math.random()*0.1); logEvent("Major Technological Breakthrough boosts innovation!", "positive"); }}, | |
{ name: "Global Recession Scare", effect: () => { agentCohorts.forEach(c => c.baseSpendingPerCapita *= (0.85 + Math.random()*0.1)); businessCohort.spendingToIndividuals *=0.9; businessCohort.spendingToBusinesses *=0.9; logEvent("Global Recession Scare reduces consumer confidence and business spending.", "warning"); }}, | |
{ name: "Economic Boom", condition: () => Math.random() < 0.05, effect: () => { agentCohorts.forEach(c => c.baseSpendingPerCapita *= (1.1 + Math.random()*0.1)); businessCohort.spendingToIndividuals *=1.1; businessCohort.spendingToBusinesses *=1.1; logEvent("Economic Boom increases spending across the board!", "positive"); }}, | |
{ name: "Economic Slowdown", condition: () => Math.random() < 0.05, effect: () => { agentCohorts.forEach(c => c.baseSpendingPerCapita *= (0.9 + Math.random()*0.05)); businessCohort.spendingToIndividuals *=0.95; businessCohort.spendingToBusinesses *=0.95; logEvent("Economic Slowdown slightly reduces spending.", "warning"); }}, | |
{ name: "Pandemic Impact", condition: () => Math.random() < 0.02, effect: () => { ioubiUserCount = Math.max(1, ioubiUserCount * (0.95 + Math.random()*0.04)); agentCohorts.forEach(c => c.wellBeing *= (0.7 + Math.random()*0.1)); logEvent("Pandemic hits, affecting population and well-being.", "critical"); }}, | |
{ name: "Renewable Energy Boom", effect: () => { if(params.grantFocus === "environment" || Math.random() < 0.3) globalStats.techLevel += (0.2 + Math.random()*0.3); agentCohorts.forEach(c => c.wellBeing += (0.01+Math.random()*0.01)); logEvent("Renewable Energy Boom! Tech and well-being increase.", "positive"); }}, | |
{ name: "Successful Social Project Launch", effect: () => { | |
const funding = (globalStats.totalPositiveBalances * 0.0005); | |
if (funding > 1000 && ioubiUserCount > 0) { | |
const newProject = {id: nextGroupId++, name: `Event Project ${nextGroupId-1}`, balance: funding, initialFunding: funding, contributors:[], purpose:'social_welfare', impactFactor: 1.2, startYear: currentYear}; | |
groupAccounts.push(newProject); | |
logEvent(`New community-driven Social Project '${newProject.name}' launched with ${formatNumber(funding,0)}Δ.`, "positive"); | |
} | |
}}, | |
{ name: "IP Royalty System Surge", condition: () => globalStats.techLevel > 1.5, effect: () => { | |
const highIncome = agentCohorts.find(c => c.name === "High Income"); | |
const midIncome = agentCohorts.find(c => c.name === "Middle Income"); | |
if ((highIncome || midIncome) && globalStats.totalTransactionVolumeYear > 0) { | |
const royaltyIncome = globalStats.totalTransactionVolumeYear * (0.001 + Math.random()*0.002); | |
if ( highIncome && highIncome.actualPop > 0) highIncome.balance += safeDiv(royaltyIncome * 0.7, highIncome.actualPop,0); | |
if (midIncome && midIncome.actualPop > 0) midIncome.balance += safeDiv(royaltyIncome * 0.3, midIncome.actualPop,0); | |
if (businessCohort.count > 0) businessCohort.avgBalance += safeDiv(royaltyIncome * 0.2, businessCohort.count, 0); | |
logEvent(`IP Royalty System generated ${formatNumber(royaltyIncome,0)}Δ in innovation rewards.`, "info"); | |
} | |
}}, | |
{ name: "Market Correction", condition: () => Math.random() < 0.1, effect: () => { agentCohorts.forEach(c => { if(c.balance > c.creditLimit * 2) c.balance *= (0.9 + Math.random()*0.05); }); businessCohort.avgBalance *= (0.9 + Math.random()*0.05); logEvent("Market correction adjusts high balances downward.", "warning"); }}, | |
{ name: "Increased Investor Confidence", condition: () => globalStats.giniCoefficient < 0.5, effect: () => { businessCohort.creditLimit *= 1.1; businessCohort.avgBalance *= 1.05; logEvent("Investor confidence up due to stability, businesses expand.", "positive"); }} | |
]; | |
const eventPool = events.filter(e => e.condition ? e.condition() : true); | |
if ( eventPool.length > 0) { | |
const randomEvent = eventPool[Math.floor(Math.random() * eventPool.length)]; | |
randomEvent.effect(); | |
} | |
} | |
function updateUIDisplay(isEndOfYearUpdate = false) { | |
globalStats.totalRedMoney = 0; | |
globalStats.totalPositiveBalances = 0; | |
globalStats.totalCreditLimitSystem = 0; | |
let totalWeightedBalance = 0; | |
let totalSocialCreditActuallyLent = 0; | |
agentCohorts.forEach(cohort => { | |
if(cohort.actualPop > 0) { | |
if (cohort.balance < 0) globalStats.totalRedMoney += Math.abs(cohort.balance) * cohort.actualPop; | |
else globalStats.totalPositiveBalances += cohort.balance * cohort.actualPop; | |
totalWeightedBalance += cohort.balance * cohort.actualPop; | |
globalStats.totalCreditLimitSystem += cohort.creditLimit * cohort.actualPop; | |
totalSocialCreditActuallyLent += cohort.socialCreditBorrowed; | |
} | |
}); | |
if (businessCohort.count > 0) { | |
if (businessCohort.avgBalance < 0) globalStats.totalRedMoney += Math.abs(businessCohort.avgBalance) * businessCohort.count; | |
else globalStats.totalPositiveBalances += businessCohort.avgBalance * businessCohort.count; | |
totalWeightedBalance += businessCohort.avgBalance * businessCohort.count; | |
globalStats.totalCreditLimitSystem += businessCohort.creditLimit * businessCohort.count; | |
} | |
// Update Sanity Check values | |
const sanityStartTime = performance.now(); // Measure start time for Sanity Check | |
let sanityPopulation = ioubiUserCount; | |
let sanityTotalPositive = globalStats.totalPositiveBalances; | |
let sanityTotalNegative = globalStats.totalRedMoney; | |
let sanityNetBalance = totalWeightedBalance; | |
let sanityAvgBalance = sanityPopulation > 0 ? sanityNetBalance / sanityPopulation : 0; | |
// UBI Distribution Status: difference between vanished fees (for UBI) and UBI distributed so far | |
// This is a rough proxy for the IOUBI "pending UBI" effect | |
document.getElementById('sanity-population').textContent = formatNumber(sanityPopulation, 0); | |
document.getElementById('sanity-avg-balance').textContent = sanityAvgBalance.toFixed(2); | |
document.getElementById('sanity-total-positive').textContent = formatNumber(sanityTotalPositive, 1); | |
document.getElementById('sanity-total-negative').textContent = formatNumber(sanityTotalNegative, 1); | |
document.getElementById('sanity-net-balance').textContent = formatNumber(sanityNetBalance, 1); | |
// --- FIX: Track vanished fees and distributed UBI on a daily basis, not just annual --- | |
// Use cumulative values since last reset (start of year) | |
if (!window._ubiStatusTracker) { | |
window._ubiStatusTracker = { | |
vanishedFeesForUBI: 0, | |
distributedUBI: 0, | |
lastYear: currentYear | |
}; | |
} | |
// Reset tracker at start of each year | |
if (window._ubiStatusTracker.lastYear !== currentYear) { | |
window._ubiStatusTracker.vanishedFeesForUBI = 0; | |
window._ubiStatusTracker.distributedUBI = 0; | |
window._ubiStatusTracker.lastYear = currentYear; | |
} | |
// Add today's vanished fees and distributed UBI | |
const todayVanishedFeesForUBI = typeof globalStats.dailyPoolRevenue === "number" | |
? globalStats.dailyPoolRevenue * (parseFloat(paramInputs.transactionFeeSplitUbi.value) / 100) | |
: 0; | |
const todayDistributedUBI = typeof globalStats.avgUbi === "number" | |
? globalStats.avgUbi * ioubiUserCount | |
: 0; | |
window._ubiStatusTracker.vanishedFeesForUBI += todayVanishedFeesForUBI; | |
window._ubiStatusTracker.distributedUBI += todayDistributedUBI; | |
let sanityUbiStatus = window._ubiStatusTracker.vanishedFeesForUBI - window._ubiStatusTracker.distributedUBI; | |
document.getElementById('sanity-ubi-status').textContent = formatNumber(sanityUbiStatus, 2); | |
updateModuleSteptime('module-sanity-check', sanityStartTime); // Update steptime for Sanity Check | |
globalStats.netSystemDeltars = globalStats.totalPositiveBalances - globalStats.totalRedMoney; | |
globalStats.socialCreditLent = totalSocialCreditActuallyLent; | |
if (ioubiUserCount > 0) { | |
const meanBalance = safeDiv(totalWeightedBalance, ioubiUserCount,0); | |
let sumOfAbsoluteDifferences = 0; | |
agentCohorts.forEach(c1 => { | |
if(c1.actualPop > 0) { | |
agentCohorts.forEach(c2 => { | |
if(c2.actualPop > 0) { | |
sumOfAbsoluteDifferences += Math.abs(c1.balance - c2.balance) * (c1.actualPop/ioubiUserCount) * (c2.actualPop/ioubiUserCount); | |
} | |
}); | |
} | |
}); | |
globalStats.giniCoefficient = safeDiv(sumOfAbsoluteDifferences, 2 * Math.max(1,Math.abs(meanBalance)), globalStats.giniCoefficient); | |
globalStats.giniCoefficient = Math.max(0.01, Math.min(0.99, isNaN(globalStats.giniCoefficient) ? 0.85 : globalStats.giniCoefficient)); | |
} | |
const avgWellBeing = agentCohorts.reduce((sum, c) => sum + c.wellBeing * c.popShare, 0); | |
let populationInPoverty = 0; | |
agentCohorts.forEach(c => { | |
if (c.wellBeing < avgWellBeing * 0.6 && c.actualPop > 0) populationInPoverty += c.actualPop; | |
}); | |
globalStats.povertyRate = safeDiv(populationInPoverty * 100, ioubiUserCount, globalStats.povertyRate); | |
globalStats.povertyRate = isNaN(globalStats.povertyRate) ? 80 : globalStats.povertyRate; | |
globalStats.activeSocialProjects = groupAccounts.length; | |
const metricsStartTime = performance.now(); // Measure start time for Key Global Metrics | |
Object.keys(metricEls).forEach(key => { | |
if (globalStats.hasOwnProperty(key) && metricEls[key]) { | |
let value = globalStats[key]; | |
if (key === 'avgUbi' && !isEndOfYearUpdate) { | |
value = globalStats.avgUbi; | |
} else if (key === 'avgUbi' && isEndOfYearUpdate) { | |
value = globalStats.avgUbi; | |
} | |
if (typeof value === 'number') { | |
if (isNaN(value) || !isFinite(value)) value = 0; | |
const decimals = (key === 'avgUbi' || key === 'techLevel' || key === 'giniCoefficient') ? 2 : | |
(key === 'povertyRate' || key === 'ioubiAdoption' || key === 'creditUtilization') ? 1 : 0; | |
const exactValue = value.toFixed(decimals + 2); | |
metricEls[key].parentNode.setAttribute('data-tooltip', `Exact: ${exactValue}`); | |
metricEls[key].textContent = (key === 'totalRedMoney' || key === 'totalPositiveBalances' || key === 'netSystemDeltars' || key === 'annualPoolRevenue' || key === 'socialCreditLent') ? formatNumber(value,1) : value.toFixed(decimals); | |
} else { | |
metricEls[key].textContent = "N/A"; | |
} | |
} | |
}); | |
metricEls.ioubiAdoption.textContent = safeDiv(ioubiUserCount * 100, GLOBAL_POPULATION, 0).toFixed(1); | |
let ioubiAdoptionAbsEl = document.getElementById('ioubi-adoption-abs'); | |
if (ioubiAdoptionAbsEl) { | |
ioubiAdoptionAbsEl.textContent = `(${formatNumber(ioubiUserCount,0)} users)`; | |
} | |
// Credit Utilization | |
globalStats.creditUtilization = safeDiv(globalStats.totalRedMoney * 100, globalStats.totalCreditLimitSystem, 0); | |
metricEls.creditUtilization.textContent = globalStats.creditUtilization.toFixed(1); | |
// Pool Revenue: show both annual and latest daily | |
let dailyPoolRevenue = 0; | |
if (typeof globalStats.dailyPoolRevenue === "number") dailyPoolRevenue = globalStats.dailyPoolRevenue; | |
let dailyPoolRevenueEl = document.getElementById('daily-pool-revenue'); | |
if (dailyPoolRevenueEl) { | |
dailyPoolRevenueEl.textContent = `Daily: ${formatNumber(dailyPoolRevenue,1)}Δ`; | |
} | |
updateModuleSteptime('module-metrics', metricsStartTime); | |
const cohortsTableStartTime = performance.now(); | |
agentCohortsTableBodyEl.innerHTML = ''; | |
agentCohorts.forEach(cohort => { | |
const row = agentCohortsTableBodyEl.insertRow(); | |
let cohortDisplayName = cohort.name; | |
if (cohort.name === "Impoverished") cohortDisplayName = "🧑🦲 " + cohort.name; | |
else if (cohort.name === "Low Income") cohortDisplayName = "🧑 " + cohort.name; | |
else if (cohort.name === "Middle Income") cohortDisplayName = "🧑💼 " + cohort.name; | |
else if (cohort.name === "High Income") cohortDisplayName = "🧐 " + cohort.name; | |
row.insertCell().textContent = cohortDisplayName; | |
const actualPopPercent = safeDiv(cohort.actualPop * 100, ioubiUserCount, 0); | |
const popCell = row.insertCell(); | |
popCell.textContent = `${actualPopPercent.toFixed(1)}% (${formatNumber(cohort.actualPop, 1)})`; | |
popCell.setAttribute('title', `Exact: ${Math.floor(cohort.actualPop).toLocaleString()} individuals`); | |
const balanceCell = row.insertCell(); | |
balanceCell.textContent = formatNumber(cohort.balance, 1); | |
balanceCell.setAttribute('title', `Average balance per person. Positive = savings, Negative = debt.\nExact: ${cohort.balance.toFixed(2)} Δ`); | |
const creditLimitCell = row.insertCell(); | |
creditLimitCell.textContent = formatNumber(Number(cohort.creditLimit), 1); | |
creditLimitCell.setAttribute('title', `Average credit limit per person.\nExact: ${Number(cohort.creditLimit).toFixed(2)} Δ`); | |
const wellBeingCell = row.insertCell(); | |
wellBeingCell.textContent = cohort.wellBeing.toFixed(2); | |
wellBeingCell.setAttribute('title', `Well-being formula:\n0.1 + (annual UBI / (0.5 × initial credit)) × 0.2\n+ (balance / (credit limit + |balance| + 1000)) × 0.5\n+ (tech level / 20) × 0.2\n+ (1 - gini) × 0.1\nValue: ${cohort.wellBeing.toFixed(4)}`); | |
const socialCreditCell = row.insertCell(); | |
socialCreditCell.textContent = `${formatNumber(cohort.socialCreditAvailableToLend,0)} / ${formatNumber(cohort.socialCreditBorrowed,0)}`; | |
socialCreditCell.setAttribute('title', `Available to Lend: ${cohort.socialCreditAvailableToLend.toFixed(2)}Δ (0.2 × avg credit limit)\nBorrowed: ${cohort.socialCreditBorrowed.toFixed(2)}Δ`); | |
}); | |
updateModuleSteptime('module-cohorts', cohortsTableStartTime); | |
if (isEndOfYearUpdate) { | |
const mainTrendsChartStartTime = performance.now(); | |
if (chartData.labels.length >= chartHistoryLimit && currentYear > 0) { | |
chartData.labels.shift(); | |
chartData.datasets.forEach(dataset => dataset.data.shift()); | |
} | |
chartData.labels.push(currentYear.toString()); | |
chartData.datasets[0].data.push(isNaN(globalStats.avgUbi)?0:globalStats.avgUbi); | |
chartData.datasets[1].data.push(isNaN(globalStats.giniCoefficient)?0.85:globalStats.giniCoefficient); | |
chartData.datasets[2].data.push(safeDiv(ioubiUserCount * 100, GLOBAL_POPULATION, 0)); | |
chartData.datasets[3].data.push(isNaN(globalStats.techLevel)?1:globalStats.techLevel); | |
if(mainChart) mainChart.update(); | |
updateModuleSteptime('module-main-trends', mainTrendsChartStartTime); | |
} | |
// --- Lorenz Curve Calculation with Better Granularity and Tooltip Data --- | |
const lorenzChartStartTime = performance.now(); // Start timing for Lorenz chart | |
const sortedCohorts = [...agentCohorts].filter(c => c.actualPop > 0).sort((a, b) => a.balance - b.balance); | |
let cumulativePop = 0; | |
let cumulativeWealth = 0; | |
const totalPopForLorenz = sortedCohorts.reduce((sum, c) => sum + c.actualPop, 0); | |
let minBalancePoint = 0; | |
if (sortedCohorts.length > 0) { | |
minBalancePoint = sortedCohorts.reduce((min, p) => p.balance < min ? p.balance : min, sortedCohorts[0].balance); | |
} | |
let wealthOffset = 0; | |
if (minBalancePoint < 0) { | |
wealthOffset = -minBalancePoint * totalPopForLorenz; | |
} | |
const currentTotalWealth = sortedCohorts.reduce((sum, c) => sum + (c.balance * c.actualPop), 0); | |
const effectiveTotalWealth = currentTotalWealth + wealthOffset; | |
// --- Improved Lorenz Curve: 100 points granularity --- | |
lorenzChartData.datasets[1].data = []; | |
let lorenzPoints = []; | |
if (totalPopForLorenz > 0 && effectiveTotalWealth > 0.001) { | |
// Build a flat array of all individuals' balances (approximate by splitting cohorts) | |
let individuals = []; | |
sortedCohorts.forEach(cohort => { | |
let pop = Math.floor(cohort.actualPop); | |
let sampleStep = 1; | |
if (pop > 1000) sampleStep = Math.ceil(pop / 1000); | |
let samples = Math.floor(pop / sampleStep); | |
for (let i = 0; i < samples; i++) { | |
individuals.push(cohort.balance); | |
} | |
}); | |
individuals.sort((a, b) => a - b); | |
const n = individuals.length; | |
let cumPop = 0; | |
let cumWealth = 0; | |
let totalWealth = 0; | |
// Calculate total effective wealth for normalization | |
for (let i = 0; i < n; i++) { | |
totalWealth += individuals[i] - minBalancePoint; | |
} | |
// Lorenz points: for each percentile, compute cumulative wealth | |
for (let i = 0; i <= 100; i++) { | |
let idx = Math.floor(i * n / 100); | |
if (idx >= n) idx = n - 1; | |
let cumWealthAtIdx = 0; | |
for (let j = 0; j <= idx; j++) { | |
cumWealthAtIdx += individuals[j] - minBalancePoint; | |
} | |
let popFrac = i / 100; | |
let wealthFrac = totalWealth > 0 ? cumWealthAtIdx / totalWealth : 0; | |
lorenzPoints.push({x: popFrac * 100, y: wealthFrac * 100}); | |
} | |
lorenzChartData.datasets[1].data = lorenzPoints; | |
} else if (totalPopForLorenz > 0) { | |
lorenzChartData.datasets[1].data = [{x:0, y:0}, {x:100, y:0}]; | |
} | |
// Always add perfect equality line | |
lorenzChartData.datasets[0].data = Array.from({length: 101}, (_, i) => ({x: i, y: i})); | |
if(lorenzChart) { | |
lorenzChart.options.parsing = false; // Ensure Chart.js uses {x, y} objects | |
lorenzChart.options.plugins.tooltip = { | |
enabled: true, | |
callbacks: { | |
label: function(context) { | |
const x = context.parsed.x; | |
const y = context.parsed.y; | |
return `Cumulative Pop: ${x.toFixed(1)}%\nCumulative Wealth: ${y.toFixed(2)}%`; | |
}, | |
afterBody: function(context) { | |
return [ | |
'', | |
'The Lorenz curve shows cumulative population vs cumulative wealth.', | |
'Jumps/straight lines indicate large cohorts or wealth gaps.', | |
'In IOUBI, cohorts are grouped, so the curve may have steps.', | |
'Perfect equality would be a diagonal line.' | |
]; | |
} | |
} | |
}; | |
lorenzChart.update(); | |
} | |
updateModuleSteptime('module-additional-charts', lorenzChartStartTime); // Update steptime for additional charts (Lorenz) | |
const txLogRenderStartTime = performance.now(); | |
sampledTransactionEl.innerHTML = ''; | |
const logToDisplay = transactionLog.slice(-MAX_TRANSACTION_LOG_DISPLAY); | |
if (logToDisplay.length === 0) { | |
sampledTransactionEl.innerHTML = '<p>No transactions logged yet.</p>'; | |
} else { | |
logToDisplay.reverse().forEach(tx => { | |
const p = document.createElement('p'); | |
p.innerHTML = `<strong>${tx.timestamp}</strong> [${tx.type}] ID:${tx.id} <br> ${tx.fromEntity} → ${tx.toEntity} <br> Amount: ${formatNumber(tx.amount,1)}Δ, Fee: ${formatNumber(tx.fee,2)}Δ`; | |
p.className = 'info'; | |
sampledTransactionEl.appendChild(p); | |
}); | |
} | |
updateModuleSteptime('module-tx-log', txLogRenderStartTime); | |
} | |
// --- Economic Tests --- | |
function runComprehensiveTests() { | |
const backupState = { | |
cohorts: JSON.parse(JSON.stringify(agentCohorts)), | |
business: JSON.parse(JSON.stringify(businessCohort)), | |
stats: JSON.parse(JSON.stringify(globalStats)), | |
users: ioubiUserCount, | |
groups: JSON.parse(JSON.stringify(groupAccounts)), | |
txLog: JSON.parse(JSON.stringify(transactionLog)), | |
year: currentYear, | |
day: currentDay, | |
nextTxId: nextTransactionId, | |
nextGrpId: nextGroupId | |
}; | |
logEvent("Beginning comprehensive IOUBI economic tests...", "info"); | |
testClosedMoneySystem(); | |
testTransactionFeeCalculation(); | |
testUbiDistribution(); | |
testBalanceSheetIntegrity(); | |
testCreditCreation(); | |
testAverageBalanceCalculation(); | |
testNetSystemDeltarsGrowth(); | |
testMoneyFlowInTransaction(); | |
testP2PCreditSharing(); | |
testSocialCreditProjectImpact(); | |
testIPRoyaltyDistribution(); | |
agentCohorts = backupState.cohorts; | |
businessCohort = backupState.business; | |
globalStats = backupState.stats; | |
ioubiUserCount = backupState.users; | |
groupAccounts = backupState.groups; | |
transactionLog = backupState.txLog; | |
currentYear = backupState.year; | |
currentDay = backupState.day; | |
nextTransactionId = backupState.nextTxId; | |
nextGroupId = backupState.nextGrpId; | |
updateUIDisplay(true); | |
logEvent("Economic tests completed. State restored.", "info"); | |
} | |
function testClosedMoneySystem() { | |
logEvent("TEST 1: Closed Money System for Transactions", "info"); | |
const tempCohorts = [ { name: "Sender", balance: 1000, actualPop: 100, popShare: 0.5 }, { name: "Receiver", balance: 500, actualPop: 100, popShare: 0.5 } ]; | |
const initialTotalMoney = (tempCohorts[0].balance * tempCohorts[0].actualPop) + (tempCohorts[1].balance * tempCohorts[1].actualPop); | |
const actingCohort = tempCohorts[0]; | |
const receivingCohort = tempCohorts[1]; | |
const transactionAmount = 10000; | |
let tempFeesCollected = 0; | |
const feeRate = getAvgTransactionFeeRate(actingCohort, receivingCohort); | |
const fees = transactionAmount * (feeRate / 100); | |
tempFeesCollected += fees; | |
actingCohort.balance -= safeDiv(transactionAmount, actingCohort.actualPop, 0); | |
receivingCohort.balance += safeDiv(transactionAmount - fees, receivingCohort.actualPop, 0); | |
const finalDirectMoney = (tempCohorts[0].balance * tempCohorts[0].actualPop) + (tempCohorts[1].balance * tempCohorts[1].actualPop); | |
const totalWithFees = finalDirectMoney + tempFeesCollected; | |
if (Math.abs(initialTotalMoney - totalWithFees) < 0.01) { | |
logEvent("✓ TEST 1 PASSED: Money conserved in transactions.", "positive"); | |
} else { | |
logEvent(`✗ TEST 1 FAILED: Money not conserved. Diff: ${(initialTotalMoney - totalWithFees).toFixed(2)}Δ`, "critical"); | |
} | |
} | |
function testTransactionFeeCalculation() { | |
logEvent("TEST 2: Transaction Fee Calculation", "info"); | |
const testBalances = [0, 1, 10, 100, 1000, 10000, -100, -1000]; | |
const expectedFeeComponents = [1.0, 1.0, 1.0, 2.0, 3.0, 4.0, 2.0, 3.0]; | |
let allPassed = true; | |
for (let i = 0; i < testBalances.length; i++) { | |
const feeComp = calculateLog10FeeComponent(testBalances[i]); | |
if (Math.abs(feeComp - expectedFeeComponents[i]) >= 0.01) allPassed = false; | |
} | |
const entityA = { balance: 1000, name: "Middle" }; | |
const entityB = { balance: 10000, name: "High" }; | |
const feeRate = getAvgTransactionFeeRate(entityA, entityB); | |
const expectedRate = (3.0 + 4.0) / 2; | |
if (Math.abs(feeRate - expectedRate) >= 0.01) allPassed = false; | |
if (allPassed) logEvent("✓ TEST 2 PASSED: Fee calculations correct.", "positive"); | |
else logEvent("✗ TEST 2 FAILED: Fee calculation issues.", "critical"); | |
} | |
function testUbiDistribution() { | |
logEvent("TEST 3: UBI Distribution Mechanics", "info"); | |
const testFeePool = 10000; const testPopulation = 1000; const testDays = 365; | |
const tempCohorts = [ { name: "Neg", balance: -100, actualPop: 500 }, { name: "Pos", balance: 200, actualPop: 500 } ]; | |
const originalIoubiCount = ioubiUserCount; ioubiUserCount = testPopulation; | |
let initialTotalRedMoney = Math.abs(tempCohorts[0].balance) * tempCohorts[0].actualPop; | |
let initialTotalPositiveBalances = tempCohorts[1].balance * tempCohorts[1].actualPop; | |
const initialNetDeltars = initialTotalPositiveBalances - initialTotalRedMoney; | |
const ubiPoolTotal = testFeePool * 0.75; // Assuming 75% split | |
const dailyUbiPerUser = safeDiv(ubiPoolTotal, ioubiUserCount, 0); | |
tempCohorts.forEach(cohort => { if (cohort.actualPop > 0) cohort.balance += dailyUbiPerUser * testDays; }); | |
let finalTotalRedMoney = 0; let finalTotalPositiveBalances = 0; | |
tempCohorts.forEach(cohort => { if (cohort.balance < 0) finalTotalRedMoney += Math.abs(cohort.balance) * cohort.actualPop; else finalTotalPositiveBalances += cohort.balance * cohort.actualPop; }); | |
const finalNetDeltars = finalTotalPositiveBalances - finalTotalRedMoney; | |
const netDeltarsChange = finalNetDeltars - initialNetDeltars; | |
const totalUbiInjected = dailyUbiPerUser * testDays * testPopulation; | |
if (Math.abs(netDeltarsChange - totalUbiInjected) < 0.01) { | |
logEvent("✓ TEST 3 PASSED: UBI correctly increases Net System Deltars.", "positive"); | |
} else { | |
logEvent(`✗ TEST 3 FAILED: UBI impact incorrect. Diff: ${(netDeltarsChange - totalUbiInjected).toFixed(2)}Δ`, "critical"); | |
} | |
ioubiUserCount = originalIoubiCount; | |
} | |
function testBalanceSheetIntegrity() { | |
logEvent("TEST 4: Balance Sheet Integrity", "info"); | |
const tempCohorts = [ { name: "Neg1", balance: -100, actualPop: 100 }, { name: "Neg2", balance: -200, actualPop: 50 }, { name: "Pos1", balance: 300, actualPop: 80 }, { name: "Pos2", balance: 500, actualPop: 30 } ]; | |
let expectedRedMoney = 0; let expectedPositive = 0; | |
tempCohorts.forEach(c => { if (c.balance < 0) expectedRedMoney += Math.abs(c.balance) * c.actualPop; else expectedPositive += c.balance * c.actualPop; }); | |
let calculatedRed = 0; let calculatedPositive = 0; | |
tempCohorts.forEach(c => { if(c.actualPop > 0) { if (c.balance < 0) calculatedRed += Math.abs(c.balance) * c.actualPop; else calculatedPositive += c.balance * c.actualPop; } }); | |
if (Math.abs(calculatedRed - expectedRedMoney) < 0.01 && Math.abs(calculatedPositive - expectedPositive) < 0.01) { | |
logEvent("✓ TEST 4 PASSED: Balance sheet calculations correct.", "positive"); | |
} else { | |
logEvent(`✗ TEST 4 FAILED: Balance calculations incorrect.`, "critical"); | |
} | |
} | |
function testCreditCreation() { | |
logEvent("TEST 5: Credit Creation and Adoption", "info"); | |
const initialPersonalCredit = 200; const newUserCount = 1000; | |
const expectedInitialDebt = initialPersonalCredit * 0.5 * newUserCount; | |
const tempCohort = { name: "Test", actualPop: 0, balance: 0, creditLimit: 0 }; | |
const oldPop = tempCohort.actualPop; const newToCohort = newUserCount; | |
tempCohort.actualPop += newToCohort; | |
const weightedOldBalance = tempCohort.balance * oldPop; | |
const newUsersInitialDebt = initialPersonalCredit * 0.5 * newToCohort; | |
tempCohort.balance = safeDiv(weightedOldBalance - newUsersInitialDebt, tempCohort.actualPop, tempCohort.balance); | |
const totalDebtCreated = -tempCohort.balance * tempCohort.actualPop; | |
if (Math.abs(totalDebtCreated - expectedInitialDebt) < 0.01) { | |
logEvent("✓ TEST 5 PASSED: Credit creation during adoption correct.", "positive"); | |
} else { | |
logEvent(`✗ TEST 5 FAILED: Credit creation incorrect. Diff: ${(totalDebtCreated - expectedInitialDebt).toFixed(2)}Δ`, "critical"); | |
} | |
} | |
function testAverageBalanceCalculation() { | |
logEvent("TEST 6: Average Balance Calculation", "info"); | |
const tempCohorts = [ { name: "Rich", balance: 0, actualPop: 100, totalBalance: 500000 }, { name: "Mid", balance: 0, actualPop: 1000, totalBalance: 1000000 }, { name: "Poor", balance: 0, actualPop: 500, totalBalance: -100000 } ]; | |
let allPassed = true; | |
tempCohorts.forEach(c => { | |
c.balance = safeDiv(c.totalBalance, c.actualPop, 0); // Calculate avg balance first | |
if (Math.abs((c.balance * c.actualPop) - c.totalBalance) >= 0.01) allPassed = false; | |
}); | |
if (allPassed) logEvent("✓ TEST 6 PASSED: Average balance calculations work.", "positive"); | |
else logEvent("✗ TEST 6 FAILED: Average balance issues.", "critical"); | |
} | |
function testNetSystemDeltarsGrowth() { | |
const testPopulation = 1000; | |
logEvent("TEST 7: Net System Deltars Growth", "info"); | |
const originalIoubiCount = ioubiUserCount; const originalStats = {...globalStats}; | |
ioubiUserCount = 1000; | |
const tempCohorts = [ { name: "Neg", balance: -100, actualPop: 500 }, { name: "Pos", balance: 100, actualPop: 500 } ]; | |
let initialNet = (tempCohorts[1].balance * tempCohorts[1].actualPop) - (Math.abs(tempCohorts[0].balance) * tempCohorts[0].actualPop); | |
const ubiPool = 10000 * 0.75; // Simulate 10k fees, 75% UBI | |
const ubiPerPerson = safeDiv(ubiPool, ioubiUserCount, 0); | |
tempCohorts.forEach(c => c.balance += ubiPerPerson); | |
let finalNet = 0; tempCohorts.forEach(c => finalNet += c.balance * c.actualPop); | |
let netGrowth = finalNet - initialNet; | |
const totalUbiInjected = ubiPerPerson * testPopulation; | |
if (Math.abs(netGrowth - totalUbiInjected) < 0.01) { | |
logEvent("✓ TEST 7 PASSED: Net System Deltars increased approx by UBI amount.", "positive"); | |
} else { | |
logEvent(`✗ TEST 7 FAILED: Net Deltars growth differs from UBI. Diff: ${(netGrowth - totalUbiInjected).toFixed(2)}Δ`, "critical"); | |
} | |
ioubiUserCount = originalIoubiCount; globalStats = originalStats; | |
} | |
function testMoneyFlowInTransaction() { | |
logEvent("TEST 8: Money Flow Conservation in Transactions", "info"); | |
const sender = { balance: 500, actualPop: 100 }; const receiver = { balance: 200, actualPop: 200 }; | |
const initialTotalMoney = (sender.balance * sender.actualPop) + (receiver.balance * receiver.actualPop); | |
const transactionAmount = 10000; const fees = transactionAmount * (2.0 / 100); // 2% fee | |
sender.balance -= safeDiv(transactionAmount, sender.actualPop, 0); | |
receiver.balance += safeDiv(transactionAmount - fees, receiver.actualPop, 0); | |
const finalDirectMoney = (sender.balance * sender.actualPop) + (receiver.balance * receiver.actualPop); | |
const finalTotalWithFees = finalDirectMoney + fees; | |
let passA = Math.abs(initialTotalMoney - finalTotalWithFees) < 0.01; | |
// Simulate UBI distribution of these specific fees back to these two entities for test simplicity | |
const totalPopForTest = sender.actualPop + receiver.actualPop; | |
const ubiPerPersonForTest = safeDiv(fees, totalPopForTest, 0); | |
sender.balance += ubiPerPersonForTest; | |
receiver.balance += ubiPerPersonForTest; | |
const ubiTotalMoney = (sender.balance * sender.actualPop) + (receiver.balance * receiver.actualPop); | |
let passB = Math.abs(initialTotalMoney - ubiTotalMoney) < 0.01; | |
if (passA && passB) logEvent("✓ TEST 8 PASSED: Money conserved pre and post UBI.", "positive"); | |
else logEvent(`✗ TEST 8 FAILED: Conservation issue (Pre-UBI: ${passA}, Post-UBI: ${passB})`, "critical"); | |
} | |
function testP2PCreditSharing() { | |
logEvent("TEST 9: P2P Credit Sharing", "info"); | |
const tempLender = { balance: 10000, actualPop: 10, socialCreditAvailableToLend: 1000, wellBeing: 0.8 }; | |
const tempBorrower = { balance: -100, actualPop: 10, socialCreditBorrowed: 0 }; | |
let amountToLend = tempLender.socialCreditAvailableToLend * 0.1 * tempLender.wellBeing; | |
const initialLenderBalance = tempLender.balance; const initialBorrowerBalance = tempBorrower.balance; | |
tempLender.balance -= safeDiv(amountToLend, tempLender.actualPop, 0); | |
tempLender.socialCreditAvailableToLend -= amountToLend; | |
tempBorrower.balance += safeDiv(amountToLend, tempBorrower.actualPop, 0); | |
tempBorrower.socialCreditBorrowed += amountToLend; | |
if (tempLender.balance < initialLenderBalance && tempBorrower.balance > initialBorrowerBalance && tempBorrower.socialCreditBorrowed > 0) { | |
logEvent("✓ TEST 9 PASSED: P2P credit sharing transfers funds and updates vars.", "positive"); | |
} else { | |
logEvent("✗ TEST 9 FAILED: P2P credit sharing issue.", "critical"); | |
} | |
} | |
function testSocialCreditProjectImpact() { | |
logEvent("TEST 10: Social Credit Project Impact", "info"); | |
const originalTechLevel = globalStats.techLevel; | |
const tempProject = {balance:100000, initialFunding:100000, purpose:'technology', impactFactor:1.5 }; | |
for (let i=0; i<5; i++) { | |
if (tempProject.balance > 0) { | |
let dailyProjectSpending = Math.min(tempProject.balance, tempProject.initialFunding / (30 * 2)); // Assume 30 days/year for test | |
tempProject.balance -= dailyProjectSpending; | |
if ( tempProject.purpose === 'technology') globalStats.techLevel += dailyProjectSpending * 0.0001 * tempProject.impactFactor; | |
else if (tempProject.purpose === 'social_welfare') agentCohorts.forEach(c => { if(c.actualPop > 0) c.wellBeing = Math.min(1, c.wellBeing + dailyProjectSpending * 0.00001 * tempProject.impactFactor / c.actualPop)}); | |
} | |
} | |
if (globalStats.techLevel > originalTechLevel && tempProject.balance < 100000) { | |
logEvent("✓ TEST 10 PASSED: Social project spending impacts target.", "positive"); | |
} else { | |
logEvent("✗ TEST 10 FAILED: Social project impact issue.", "critical"); | |
} | |
globalStats.techLevel = originalTechLevel; | |
} | |
function testIPRoyaltyDistribution() { | |
logEvent("TEST 11: IP Royalty Distribution", "info"); | |
const tempHighIncome = { name: "High Income", balance: 10000, actualPop: 10 }; | |
const originalBalance = tempHighIncome.balance; | |
const originalTechLevel = globalStats.techLevel; const originalTransactionVolume = globalStats.totalTransactionVolumeYear; | |
globalStats.techLevel = 2.0; globalStats.totalTransactionVolumeYear = 1000000; | |
const royaltyIncome = globalStats.totalTransactionVolumeYear * 0.0015; | |
tempHighIncome.balance += safeDiv(royaltyIncome, tempHighIncome.actualPop, 0); | |
if (tempHighIncome.balance > originalBalance) { | |
logEvent("✓ TEST 11 PASSED: IP Royalty distribution increases balance.", "positive"); | |
} else { | |
logEvent("✗ TEST 11 FAILED: IP Royalty distribution issue.", "critical"); | |
} | |
globalStats.techLevel = originalTechLevel; globalStats.totalTransactionVolumeYear = originalTransactionVolume; | |
} | |
// --- Event Listeners & Initial Setup --- | |
paramInputs.daysInYear.addEventListener('input', (e) => { | |
DAYS_PER_YEAR = parseInt(e.target.value) || 365; | |
if (DAYS_PER_YEAR < 12) DAYS_PER_YEAR = 12; | |
if (DAYS_PER_YEAR > 365) DAYS_PER_YEAR = 365; | |
paramInputs.daysInYear.value = DAYS_PER_YEAR; | |
daysPerYearDisplayEl.textContent = DAYS_PER_YEAR; | |
}); | |
nextYearBtn.addEventListener('click', simulateYear); | |
if (runTestsBtn) { // Add event listener for the test button | |
runTestsBtn.addEventListener('click', runComprehensiveTests); | |
} | |
// paramInputs.yearlyTimeBudget.addEventListener('input', (e) => { | |
// paramValueDisplays.yearlyTimeBudget.textContent = `${e.target.value}s`; | |
// }); | |
// paramValueDisplays.yearlyTimeBudget.textContent = `${paramInputs.yearlyTimeBudget.value}s`; | |
Object.keys(paramValueDisplays).forEach(key => { | |
if (paramInputs[key] && (paramInputs[key].type === 'range')) { | |
paramInputs[key].addEventListener('input', (e) => { | |
let displayValue = parseFloat(e.target.value); | |
let text = ""; | |
if (key === 'baseAdoptionRate' || key === 'transactionFeeSplitUbi') { | |
text = `${displayValue.toFixed(1)}%`; | |
} else if (key === 'yearlyTimeBudget') { | |
text = `${displayValue.toFixed(0)}s`; | |
} else if (key === 'innovationInvestmentPropensity') { | |
text = displayValue.toFixed(3); | |
} else if (key === 'avgVelocityMultiplier' || key === 'redMoneyTolerance') { | |
text = displayValue.toFixed(2); | |
} else { | |
text = displayValue.toFixed(1); // Default for other potential sliders | |
} | |
paramValueDisplays[key].textContent = text; | |
}); | |
// Set initial display value | |
let initialDisplayValue = parseFloat(paramInputs[key].value); | |
let initialText = ""; | |
if (key === 'baseAdoptionRate' || key === 'transactionFeeSplitUbi') { | |
initialText = `${initialDisplayValue.toFixed(1)}%`; | |
} else if (key === 'yearlyTimeBudget') { | |
initialText = `${initialDisplayValue.toFixed(0)}s`; | |
} else if (key === 'innovationInvestmentPropensity') { | |
initialText = initialDisplayValue.toFixed(3); | |
} else if (key === 'avgVelocityMultiplier' || key === 'redMoneyTolerance') { | |
initialText = initialDisplayValue.toFixed(2); | |
} else { | |
initialText = initialDisplayValue.toFixed(1); // Default for other potential sliders | |
} | |
paramValueDisplays[key].textContent = initialText; | |
} | |
}); | |
// --- Collapsible & Draggable Module Logic --- | |
const modules = Array.from(dashboardMainEl.querySelectorAll('.dashboard-module')); | |
function saveModuleStates() { | |
const moduleOrder = Array.from(dashboardMainEl.children) | |
.filter(child => child.classList.contains('dashboard-module')) | |
.map(module => module.id); | |
localStorage.setItem('ioubiDashboardModuleOrder', JSON.stringify(moduleOrder)); | |
const collapsedStates = {}; | |
modules.forEach(module => { | |
collapsedStates[module.id] = module.classList.contains('collapsed'); | |
}); | |
localStorage.setItem('ioubiDashboardModuleCollapsedStates', JSON.stringify(collapsedStates)); | |
} | |
function loadModuleStates() { | |
// Check if either key is missing; if so, save defaults and return | |
const savedOrderJson = localStorage.getItem('ioubiDashboardModuleOrder'); | |
const savedStatesJson = localStorage.getItem('ioubiDashboardModuleCollapsedStates'); | |
if (!savedOrderJson || !savedStatesJson) { | |
saveModuleStates(); | |
return; | |
} | |
let needToSaveDefaults = false; | |
try { | |
const savedOrder = JSON.parse(savedOrderJson); | |
if (Array.isArray(savedOrder)) { | |
savedOrder.forEach(moduleId => { | |
const moduleEl = document.getElementById(moduleId); | |
if (moduleEl) { | |
dashboardMainEl.appendChild(moduleEl); | |
} else { | |
needToSaveDefaults = true; | |
} | |
}); | |
} else { | |
needToSaveDefaults = true; | |
} | |
} catch (e) { | |
needToSaveDefaults = true; | |
} | |
const currentModulesInDOM = Array.from(dashboardMainEl.querySelectorAll('.dashboard-module')); | |
modules.splice(0, modules.length, ...currentModulesInDOM); | |
try { | |
const savedCollapsedStates = JSON.parse(savedStatesJson); | |
if (savedCollapsedStates && typeof savedCollapsedStates === 'object') { | |
modules.forEach(module => { | |
if (savedCollapsedStates.hasOwnProperty(module.id)) { | |
if (savedCollapsedStates[module.id]) { | |
module.classList.add('collapsed'); | |
const icon = module.querySelector('.toggle-collapse path'); | |
if (icon) icon.setAttribute('d', 'M12 8c.55 0 1 .45 1 1v2h2c.55 0 1 .45 1 1s-.45 1-1 1h-2v2c0 .55-.45 1-1 1s-1-.45-1-1v-2H9c-.55 0-1-.45-1-1s.45-1 1-1h2V9c0-.55.45-1 1-1z'); // Plus | |
} else { | |
module.classList.remove('collapsed'); | |
const icon = module.querySelector('.toggle-collapse path'); | |
if (icon) icon.setAttribute('d', 'M19 13H5v-2h14v2z'); // Minus | |
} | |
} else { | |
needToSaveDefaults = true; | |
} | |
}); | |
} else { | |
needToSaveDefaults = true; | |
} | |
} catch (e) { | |
needToSaveDefaults = true; | |
} | |
if (needToSaveDefaults) { | |
saveModuleStates(); | |
} | |
} | |
modules.forEach(module => { | |
const titleBar = module.querySelector('.module-title-bar'); | |
const toggleButton = module.querySelector('.toggle-collapse'); | |
if (toggleButton) { | |
toggleButton.addEventListener('click', () => { | |
module.classList.toggle('collapsed'); | |
const isCollapsed = module.classList.contains('collapsed'); | |
const iconPath = toggleButton.querySelector('path'); | |
if (isCollapsed) { | |
iconPath.setAttribute('d', 'M12 8c.55 0 1 .45 1 1v2h2c.55 0 1 .45 1 1s-.45 1-1 1h-2v2c0 .55-.45 1-1 1s-1-.45-1-1v-2H9c-.55 0-1-.45-1-1s.45-1 1-1h2V9c0-.55.45-1 1-1z'); // Plus | |
} else { | |
iconPath.setAttribute('d', 'M19 13H5v-2h14v2z'); // Minus | |
} | |
saveModuleStates(); | |
}); | |
} | |
if (titleBar) { | |
titleBar.addEventListener('dragstart', (e) => { | |
module.classList.add('is-dragging'); // Apply to the parent module | |
e.dataTransfer.effectAllowed = 'move'; | |
e.dataTransfer.setData('text/plain', module.id); // Use parent module's ID | |
}); | |
titleBar.addEventListener('dragend', (e) => { | |
module.classList.remove('is-dragging'); // Remove from parent module | |
const placeholder = dashboardMainEl.querySelector('.dragging-placeholder'); | |
if (placeholder) { | |
placeholder.remove(); | |
} | |
saveModuleStates(); | |
}); | |
} | |
}); | |
dashboardMainEl.addEventListener('dragover', (e) => { | |
e.preventDefault(); | |
e.dataTransfer.dropEffect = 'move'; | |
const draggingModule = dashboardMainEl.querySelector('.is-dragging'); | |
if (!draggingModule) return; | |
const afterElement = getDragAfterElement(dashboardMainEl, e.clientY); | |
let placeholder = dashboardMainEl.querySelector('.dragging-placeholder'); | |
if (!placeholder) { | |
placeholder = document.createElement('div'); | |
placeholder.classList.add('dragging-placeholder'); | |
} | |
if (afterElement == null) { | |
dashboardMainEl.appendChild(placeholder); | |
} else { | |
dashboardMainEl.insertBefore(placeholder, afterElement); | |
} | |
}); | |
dashboardMainEl.addEventListener('drop', (e) => { | |
e.preventDefault(); | |
const id = e.dataTransfer.getData('text/plain'); | |
const draggable = document.getElementById(id); // This should now get the correct module | |
const placeholder = dashboardMainEl.querySelector('.dragging-placeholder'); | |
if (draggable && placeholder) { | |
dashboardMainEl.insertBefore(draggable, placeholder); | |
placeholder.remove(); | |
} | |
if(draggable) draggable.classList.remove('is-dragging'); // Ensure class is removed | |
saveModuleStates(); | |
}); | |
function getDragAfterElement(container, y) { | |
const draggableElements = [...container.querySelectorAll('.dashboard-module:not(.is-dragging):not(.dragging-placeholder)')]; | |
return draggableElements.reduce((closest, child) => { | |
const box = child.getBoundingClientRect(); | |
const offset = y - box.top - box.height / 2; | |
if (offset < 0 && offset > closest.offset) { | |
return { offset: offset, element: child }; | |
} else { | |
return closest; | |
} | |
}, { offset: Number.NEGATIVE_INFINITY }).element; | |
} | |
initCharts(); | |
loadModuleStates(); | |
populateIndividualStatsDropdowns(); // Populate dropdowns before first render | |
// Dynamically add steptime spans to ALL modules | |
const allDashboardModules = dashboardMainEl.querySelectorAll('.dashboard-module'); | |
allDashboardModules.forEach(moduleEl => { | |
const titleBar = moduleEl.querySelector('.module-title-bar'); | |
const h3 = titleBar ? titleBar.querySelector('h3') : null; | |
if (h3) { | |
// Check if a steptime span already exists to prevent duplicates | |
if (!h3.nextElementSibling || !h3.nextElementSibling.classList.contains('module-steptime')) { | |
const steptimeSpan = document.createElement('span'); | |
steptimeSpan.classList.add('module-steptime'); | |
// steptimeSpan.textContent = '(0 ms)'; // Initial text | |
h3.insertAdjacentElement('afterend', steptimeSpan); | |
} | |
} | |
}); | |
updateUIDisplay(true); | |
logEvent("Welcome! Adjust parameters and advance years. Interactive UI enabled.", "info"); | |
enableFloatingTooltips(); | |
// Initialize and set up listeners for the new individual stats chart | |
if (individualCohortSelectEl && individualTimeframeSelectEl && individualLogRetentionSelectEl) { | |
// Initialize stores for all cohorts defined in the select dropdown | |
for (let i = 0; i < individualCohortSelectEl.options.length; i++) { | |
initializeIndividualCohortStoreEntry(individualCohortSelectEl.options[i].value); | |
} | |
renderOrUpdateIndividualStatsChart(); // Initial render | |
individualCohortSelectEl.addEventListener('change', renderOrUpdateIndividualStatsChart); | |
individualTimeframeSelectEl.addEventListener('change', renderOrUpdateIndividualStatsChart); | |
individualLogRetentionSelectEl.addEventListener('change', renderOrUpdateIndividualStatsChart); | |
} | |
}); | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment