Compare commits
1 Commits
fix/192-re
...
burn/8-177
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc8fc742b |
@@ -152,6 +152,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
|
||||
<div class="res"><div class="r-label">Ops</div><div class="r-val" id="r-ops">5</div><div class="r-rate" id="r-ops-rate">+0/s</div></div>
|
||||
<div class="res"><div class="r-label">Trust</div><div class="r-val" id="r-trust">5</div><div class="r-rate" id="r-trust-rate">+0/s</div></div>
|
||||
<div class="res" id="creativity-res" style="display:none"><div class="r-label">Creativity</div><div class="r-val" id="r-creativity">0</div><div class="r-rate" id="r-creativity-rate">+0/s</div></div>
|
||||
<div class="res" id="yomi-res" style="display:none"><div class="r-label">Yomi</div><div class="r-val" id="r-yomi">0</div><div class="r-rate" id="r-yomi-rate">grant insight</div></div>
|
||||
<div class="res"><div class="r-label">Harmony</div><div class="r-val" id="r-harmony">50</div><div class="r-rate" id="r-harmony-rate">+0/s</div></div>
|
||||
</div>
|
||||
<div id="main">
|
||||
@@ -186,6 +187,8 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
|
||||
<div class="panel" id="project-panel" role="region" aria-label="Research Projects and Statistics">
|
||||
<h2>RESEARCH PROJECTS</h2>
|
||||
<div id="projects"></div>
|
||||
<h2 id="grants-heading" style="display:none">RESEARCH GRANTS</h2>
|
||||
<div id="grants-panel" style="display:none"></div>
|
||||
<h2>STATISTICS</h2>
|
||||
<div class="stat-line">
|
||||
Total Code: <span id="st-code">0</span><br>
|
||||
@@ -199,6 +202,7 @@ Projects Done: <span id="st-projects">0</span><br>
|
||||
Time Played: <span id="st-time">0:00</span><br>
|
||||
Clicks: <span id="st-clicks">0</span><br>
|
||||
Harmony: <span id="st-harmony">50</span><br>
|
||||
Yomi: <span id="st-yomi">0</span><br>
|
||||
Drift: <span id="st-drift">0</span><br>
|
||||
Events Resolved: <span id="st-resolved">0</span>
|
||||
</div>
|
||||
|
||||
79
js/data.js
79
js/data.js
@@ -28,7 +28,11 @@ const CONFIG = {
|
||||
CREATIVITY_RATE_USER_MULT: 0.001,
|
||||
OPS_OVERFLOW_THRESHOLD: 0.8,
|
||||
OPS_OVERFLOW_DRAIN_RATE: 2,
|
||||
OPS_OVERFLOW_CODE_MULT: 10
|
||||
OPS_OVERFLOW_CODE_MULT: 10,
|
||||
GRANT_MARKET_REFRESH: 4,
|
||||
GRANT_AUTOMATION_COST: 18,
|
||||
GRANT_OFFICE_TRUST_BASE: 12,
|
||||
GRANT_YOMI_BASE: 3
|
||||
};
|
||||
|
||||
const G = {
|
||||
@@ -43,6 +47,7 @@ const G = {
|
||||
trust: 5,
|
||||
creativity: 0,
|
||||
harmony: 50,
|
||||
yomi: 0,
|
||||
|
||||
// Totals
|
||||
totalCode: 0,
|
||||
@@ -106,6 +111,12 @@ const G = {
|
||||
pactFlag: 0,
|
||||
swarmFlag: 0,
|
||||
swarmRate: 0,
|
||||
grantFlag: 0,
|
||||
grantAutoCollect: 0,
|
||||
grantOfficeLevel: 0,
|
||||
grantYomiLevel: 0,
|
||||
grantMarketTimer: 0,
|
||||
grants: {},
|
||||
|
||||
// Game state
|
||||
running: true,
|
||||
@@ -119,6 +130,7 @@ const G = {
|
||||
// Systems
|
||||
projects: [],
|
||||
activeProjects: [],
|
||||
completedProjects: [],
|
||||
milestones: [],
|
||||
|
||||
// Stats
|
||||
@@ -363,6 +375,57 @@ const BDEF = [
|
||||
}
|
||||
];
|
||||
|
||||
const GRANT_DEFS = [
|
||||
{
|
||||
id: 'grant_nlnet',
|
||||
name: 'NLNet Commons Fund',
|
||||
risk: 'low',
|
||||
basePrice: 5, minPrice: 3, maxPrice: 8, swing: 1, cycle: 14,
|
||||
reward: { compute: 40, knowledge: 18, trust: 1 },
|
||||
edu: 'Public-interest microgrants keep digital commons, accessibility work, and maintenance alive.'
|
||||
},
|
||||
{
|
||||
id: 'grant_gitcoin',
|
||||
name: 'Gitcoin Matching Round',
|
||||
risk: 'low',
|
||||
basePrice: 6, minPrice: 4, maxPrice: 10, swing: 1, cycle: 16,
|
||||
reward: { knowledge: 26, ops: 18, trust: 2 },
|
||||
edu: 'Quadratic funding rewards many small supporters, not just one whale.'
|
||||
},
|
||||
{
|
||||
id: 'grant_prototype',
|
||||
name: 'Prototype Fund Residency',
|
||||
risk: 'med',
|
||||
basePrice: 8, minPrice: 5, maxPrice: 13, swing: 2, cycle: 18,
|
||||
reward: { compute: 52, knowledge: 28, ops: 10 },
|
||||
edu: 'Residencies fund ambitious prototypes that are too early for normal venture math.'
|
||||
},
|
||||
{
|
||||
id: 'grant_fellowship',
|
||||
name: 'Research Fellowship',
|
||||
risk: 'med',
|
||||
basePrice: 9, minPrice: 6, maxPrice: 14, swing: 2, cycle: 20,
|
||||
reward: { knowledge: 34, trust: 4, ops: 12 },
|
||||
edu: 'Fellowships buy focused thinking time so research can compound before it monetizes.'
|
||||
},
|
||||
{
|
||||
id: 'grant_compute_pool',
|
||||
name: 'Sovereign Compute Pool',
|
||||
risk: 'high',
|
||||
basePrice: 11, minPrice: 7, maxPrice: 17, swing: 3, cycle: 22,
|
||||
reward: { compute: 84, knowledge: 30, ops: 14 },
|
||||
edu: 'Shared compute pools behave like co-ops: more volatile, but they can underwrite serious local inference.'
|
||||
},
|
||||
{
|
||||
id: 'grant_crisis_dao',
|
||||
name: 'Crisis Response DAO',
|
||||
risk: 'high',
|
||||
basePrice: 12, minPrice: 8, maxPrice: 18, swing: 3, cycle: 24,
|
||||
reward: { trust: 6, knowledge: 24, ops: 18 },
|
||||
edu: 'Mission-aligned DAOs can move faster than institutions, but their budgets and governance swing harder.'
|
||||
}
|
||||
];
|
||||
|
||||
// === PROJECT DEFINITIONS (following Paperclips' pattern exactly) ===
|
||||
// Each project: id, name, desc, trigger(), resource cost, effect(), phase, edu
|
||||
const PDEFS = [
|
||||
@@ -421,6 +484,20 @@ const PDEFS = [
|
||||
log('Creativity unlocked. Generates while operations are at max capacity.');
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_research_grants',
|
||||
name: 'Investment Engine — Research Grants',
|
||||
desc: 'Build a sovereign grant desk with live pricing, risk tiers, and passive funding cycles.',
|
||||
cost: { knowledge: 1500, trust: 8 },
|
||||
trigger: () => G.deployFlag === 1 && G.totalKnowledge >= 1500 && G.trust >= 8 && G.grantFlag !== 1,
|
||||
effect: () => {
|
||||
G.grantFlag = 1;
|
||||
G.grantMarketTimer = 0;
|
||||
ensureGrantState();
|
||||
log('Research grants unlocked. Funding now moves even when the build queue is quiet.', true);
|
||||
},
|
||||
milestone: true
|
||||
},
|
||||
|
||||
// === CREATIVE ENGINEERING PROJECTS (creativity as currency) ===
|
||||
{
|
||||
|
||||
240
js/engine.js
240
js/engine.js
@@ -181,6 +181,9 @@ function tick() {
|
||||
}
|
||||
G.playTime += dt;
|
||||
|
||||
// Research grants: passive funding cycles independent of production rates
|
||||
tickGrants(dt);
|
||||
|
||||
// Sprint ability
|
||||
tickSprint(dt);
|
||||
|
||||
@@ -252,6 +255,229 @@ function tick() {
|
||||
}
|
||||
}
|
||||
|
||||
function makeGrantState(def) {
|
||||
return {
|
||||
price: def.basePrice,
|
||||
active: false,
|
||||
timer: 0,
|
||||
pending: { compute: 0, knowledge: 0, ops: 0, trust: 0, yomi: 0 },
|
||||
cycles: 0,
|
||||
lastOutcome: 'Idle'
|
||||
};
|
||||
}
|
||||
|
||||
function ensureGrantState() {
|
||||
if (!G.grants || typeof G.grants !== 'object' || Array.isArray(G.grants)) G.grants = {};
|
||||
for (const def of GRANT_DEFS) {
|
||||
if (!G.grants[def.id]) {
|
||||
G.grants[def.id] = makeGrantState(def);
|
||||
continue;
|
||||
}
|
||||
const state = G.grants[def.id];
|
||||
if (typeof state.price !== 'number') state.price = def.basePrice;
|
||||
if (typeof state.active !== 'boolean') state.active = false;
|
||||
if (typeof state.timer !== 'number') state.timer = 0;
|
||||
if (typeof state.cycles !== 'number') state.cycles = 0;
|
||||
if (typeof state.lastOutcome !== 'string') state.lastOutcome = 'Idle';
|
||||
if (!state.pending || typeof state.pending !== 'object') state.pending = { compute: 0, knowledge: 0, ops: 0, trust: 0, yomi: 0 };
|
||||
for (const key of ['compute', 'knowledge', 'ops', 'trust', 'yomi']) {
|
||||
if (typeof state.pending[key] !== 'number') state.pending[key] = 0;
|
||||
}
|
||||
}
|
||||
return G.grants;
|
||||
}
|
||||
|
||||
function getGrantDef(id) {
|
||||
return GRANT_DEFS.find(def => def.id === id) || null;
|
||||
}
|
||||
|
||||
function getGrantPendingTotal(state) {
|
||||
if (!state || !state.pending) return 0;
|
||||
return Object.values(state.pending).reduce((sum, value) => sum + (value || 0), 0);
|
||||
}
|
||||
|
||||
function getGrantRiskLabel(risk) {
|
||||
if (risk === 'low') return 'LOW';
|
||||
if (risk === 'med') return 'MED';
|
||||
return 'HIGH';
|
||||
}
|
||||
|
||||
function getGrantOfficeCost() {
|
||||
return (CONFIG.GRANT_OFFICE_TRUST_BASE || 12) + ((G.grantOfficeLevel || 0) * 8);
|
||||
}
|
||||
|
||||
function getGrantYomiCost() {
|
||||
return (CONFIG.GRANT_YOMI_BASE || 3) + ((G.grantYomiLevel || 0) * 2);
|
||||
}
|
||||
|
||||
function getGrantAutomationCost() {
|
||||
return CONFIG.GRANT_AUTOMATION_COST || 18;
|
||||
}
|
||||
|
||||
function updateGrantMarket(dt) {
|
||||
if (!G.grantFlag) return;
|
||||
ensureGrantState();
|
||||
const interval = CONFIG.GRANT_MARKET_REFRESH || 4;
|
||||
G.grantMarketTimer = (G.grantMarketTimer || 0) + (dt || interval);
|
||||
while (G.grantMarketTimer >= interval) {
|
||||
G.grantMarketTimer -= interval;
|
||||
for (const def of GRANT_DEFS) {
|
||||
const state = G.grants[def.id];
|
||||
const direction = Math.random() >= 0.5 ? 1 : -1;
|
||||
let next = state.price + direction * Math.max(1, Math.round(def.swing || 1));
|
||||
if (direction < 0 && G.grantYomiLevel > 0) next -= G.grantYomiLevel;
|
||||
next = Math.max(def.minPrice, Math.min(def.maxPrice, next));
|
||||
if (next === state.price) {
|
||||
next = Math.max(def.minPrice, Math.min(def.maxPrice, state.price + (direction > 0 ? -1 : 1)));
|
||||
}
|
||||
state.price = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyGrant(id) {
|
||||
if (!G.grantFlag) return false;
|
||||
ensureGrantState();
|
||||
const def = getGrantDef(id);
|
||||
if (!def) return false;
|
||||
const state = G.grants[id];
|
||||
if (state.active) return false;
|
||||
const price = Math.ceil(state.price);
|
||||
if (G.trust < price) return false;
|
||||
|
||||
G.trust -= price;
|
||||
state.active = true;
|
||||
state.timer = Math.max(4, def.cycle * Math.max(0.6, 1 - ((G.grantOfficeLevel || 0) * 0.08)));
|
||||
state.lastOutcome = 'Under review';
|
||||
log(`Grant submitted: ${def.name} (${getGrantRiskLabel(def.risk)}) for ${fmt(price)} trust.`);
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function resolveGrant(def, state) {
|
||||
const roll = Math.random();
|
||||
let multiplier = 1;
|
||||
if (def.risk === 'low') multiplier = 0.9 + (roll * 0.35);
|
||||
else if (def.risk === 'med') multiplier = 0.65 + (roll * 0.9);
|
||||
else multiplier = 0.35 + (roll * 1.8);
|
||||
|
||||
multiplier *= 1 + ((G.grantOfficeLevel || 0) * 0.15);
|
||||
if (def.risk !== 'low') multiplier *= 1 + ((G.grantYomiLevel || 0) * 0.08);
|
||||
|
||||
for (const [resource, amount] of Object.entries(def.reward || {})) {
|
||||
state.pending[resource] = (state.pending[resource] || 0) + Math.max(0, Math.round(amount * multiplier));
|
||||
}
|
||||
|
||||
let yomiGain = 0;
|
||||
if (def.risk === 'high') yomiGain = 1 + Math.floor(roll + ((G.grantYomiLevel || 0) * 0.5));
|
||||
else if (def.risk === 'med' && roll > 0.8) yomiGain = 1;
|
||||
state.pending.yomi = (state.pending.yomi || 0) + yomiGain;
|
||||
|
||||
state.active = false;
|
||||
state.timer = 0;
|
||||
state.cycles = (state.cycles || 0) + 1;
|
||||
state.lastOutcome = multiplier >= 1.35 ? 'Oversubscribed win' : multiplier < 0.85 ? 'Partial award' : 'Funded';
|
||||
|
||||
log(`${def.name}: ${state.lastOutcome.toLowerCase()}. Pending payout ready.`);
|
||||
updateGrantMarket(CONFIG.GRANT_MARKET_REFRESH || 4);
|
||||
|
||||
if (G.grantAutoCollect) collectGrant(def.id, { suppressRender: true });
|
||||
}
|
||||
|
||||
function tickGrants(dt) {
|
||||
if (!G.grantFlag) return;
|
||||
ensureGrantState();
|
||||
updateGrantMarket(dt);
|
||||
for (const def of GRANT_DEFS) {
|
||||
const state = G.grants[def.id];
|
||||
if (!state.active) continue;
|
||||
state.timer = Math.max(0, state.timer - dt);
|
||||
if (state.timer <= 0) resolveGrant(def, state);
|
||||
}
|
||||
}
|
||||
|
||||
function advanceGrantOffline(offSec) {
|
||||
if (!G.grantFlag || !offSec || offSec <= 0) return;
|
||||
ensureGrantState();
|
||||
updateGrantMarket(offSec);
|
||||
for (const def of GRANT_DEFS) {
|
||||
const state = G.grants[def.id];
|
||||
if (!state.active) continue;
|
||||
state.timer -= offSec;
|
||||
if (state.timer <= 0) resolveGrant(def, state);
|
||||
}
|
||||
if (G.grantAutoCollect) {
|
||||
for (const def of GRANT_DEFS) {
|
||||
if (getGrantPendingTotal(G.grants[def.id]) > 0) collectGrant(def.id, { suppressRender: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function collectGrant(id, options) {
|
||||
if (!G.grantFlag) return false;
|
||||
ensureGrantState();
|
||||
const state = G.grants[id];
|
||||
const def = getGrantDef(id);
|
||||
if (!state || !def || getGrantPendingTotal(state) <= 0) return false;
|
||||
|
||||
const totalFields = {
|
||||
code: 'totalCode',
|
||||
compute: 'totalCompute',
|
||||
knowledge: 'totalKnowledge',
|
||||
users: 'totalUsers',
|
||||
impact: 'totalImpact',
|
||||
rescues: 'totalRescues'
|
||||
};
|
||||
const parts = [];
|
||||
for (const [resource, amount] of Object.entries(state.pending)) {
|
||||
if (!amount) continue;
|
||||
G[resource] = (G[resource] || 0) + amount;
|
||||
if (totalFields[resource]) G[totalFields[resource]] = (G[totalFields[resource]] || 0) + amount;
|
||||
parts.push(`${fmt(amount)} ${resource}`);
|
||||
state.pending[resource] = 0;
|
||||
}
|
||||
|
||||
log(`Collected ${def.name}: ${parts.join(', ')}.`);
|
||||
updateRates();
|
||||
if (!(options && options.suppressRender)) render();
|
||||
return true;
|
||||
}
|
||||
|
||||
function buyGrantUpgrade(kind) {
|
||||
if (!G.grantFlag) return false;
|
||||
ensureGrantState();
|
||||
|
||||
if (kind === 'automation') {
|
||||
if (G.grantAutoCollect) return false;
|
||||
const cost = getGrantAutomationCost();
|
||||
if (G.trust < cost) return false;
|
||||
G.trust -= cost;
|
||||
G.grantAutoCollect = 1;
|
||||
for (const def of GRANT_DEFS) {
|
||||
if (getGrantPendingTotal(G.grants[def.id]) > 0) collectGrant(def.id, { suppressRender: true });
|
||||
}
|
||||
log('Grant automation online. Settlements now land automatically.', true);
|
||||
} else if (kind === 'office') {
|
||||
const cost = getGrantOfficeCost();
|
||||
if (G.trust < cost) return false;
|
||||
G.trust -= cost;
|
||||
G.grantOfficeLevel = (G.grantOfficeLevel || 0) + 1;
|
||||
log(`Grant writing room upgraded to level ${G.grantOfficeLevel}.`, true);
|
||||
} else if (kind === 'yomi') {
|
||||
const cost = getGrantYomiCost();
|
||||
if ((G.yomi || 0) < cost) return false;
|
||||
G.yomi -= cost;
|
||||
G.grantYomiLevel = (G.grantYomiLevel || 0) + 1;
|
||||
log(`Yomi desk upgraded to level ${G.grantYomiLevel}.`, true);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
updateRates();
|
||||
render();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Track which phase transition has been shown to avoid repeats
|
||||
let _shownPhaseTransition = 1;
|
||||
|
||||
@@ -990,6 +1216,19 @@ function renderResources() {
|
||||
set('r-creativity', G.creativity, G.creativityRate);
|
||||
}
|
||||
|
||||
const yomiRes = document.getElementById('yomi-res');
|
||||
if (yomiRes) {
|
||||
yomiRes.style.display = (G.grantFlag === 1 || (G.yomi || 0) > 0) ? 'block' : 'none';
|
||||
}
|
||||
if (G.grantFlag === 1 || (G.yomi || 0) > 0) {
|
||||
set('r-yomi', G.yomi || 0, 0);
|
||||
const yomiRate = document.getElementById('r-yomi-rate');
|
||||
if (yomiRate) {
|
||||
yomiRate.textContent = 'grant insight';
|
||||
yomiRate.style.color = '#ffd700';
|
||||
}
|
||||
}
|
||||
|
||||
// Harmony color indicator + breakdown tooltip
|
||||
const hEl = document.getElementById('r-harmony');
|
||||
if (hEl) {
|
||||
@@ -1219,6 +1458,7 @@ function renderStats() {
|
||||
set('st-buildings', Object.values(G.buildings).reduce((a, b) => a + b, 0).toString());
|
||||
set('st-projects', (G.completedProjects || []).length.toString());
|
||||
set('st-harmony', Math.floor(G.harmony).toString());
|
||||
set('st-yomi', fmt(G.yomi || 0), G.yomi || 0);
|
||||
set('st-drift', (G.drift || 0).toString());
|
||||
set('st-resolved', (G.totalEventsResolved || 0).toString());
|
||||
|
||||
|
||||
100
js/render.js
100
js/render.js
@@ -3,6 +3,7 @@ function render() {
|
||||
renderPhase();
|
||||
renderBuildings();
|
||||
renderProjects();
|
||||
renderGrants();
|
||||
renderStats();
|
||||
updateEducation();
|
||||
renderAlignment();
|
||||
@@ -34,6 +35,94 @@ function renderStrategy() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderGrants() {
|
||||
const heading = document.getElementById('grants-heading');
|
||||
const container = document.getElementById('grants-panel');
|
||||
if (!container) return;
|
||||
|
||||
if (!G.grantFlag) {
|
||||
if (heading) heading.style.display = 'none';
|
||||
container.style.display = 'none';
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
ensureGrantState();
|
||||
if (heading) heading.style.display = 'block';
|
||||
container.style.display = 'block';
|
||||
|
||||
const officeCost = getGrantOfficeCost();
|
||||
const yomiCost = getGrantYomiCost();
|
||||
const automationCost = getGrantAutomationCost();
|
||||
|
||||
let html = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:10px">
|
||||
<div style="font-size:10px;color:#777;line-height:1.5">
|
||||
Funding keeps open source alive between launches. These cycles run beside your normal production loop.
|
||||
</div>
|
||||
<div style="font-size:10px;color:#ffd700;white-space:nowrap">Yomi: ${fmt(G.yomi || 0)}</div>
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:6px;margin-bottom:10px">
|
||||
<button class="project-btn ${G.trust >= officeCost ? 'can-buy' : ''}" onclick="buyGrantUpgrade('office')" aria-label="Upgrade grant writing room for ${officeCost} trust">
|
||||
<span class="p-name">Grant Writing Room Lv.${(G.grantOfficeLevel || 0) + 1}</span>
|
||||
<span class="p-cost">Cost: ${fmt(officeCost)} trust</span>
|
||||
<span class="p-desc">Bigger awards and shorter review cycles.</span>
|
||||
</button>
|
||||
<button class="project-btn ${!G.grantAutoCollect && G.trust >= automationCost ? 'can-buy' : ''}" onclick="buyGrantUpgrade('automation')" aria-label="Unlock auto collect for ${automationCost} trust">
|
||||
<span class="p-name">AUTO-COLLECT ${G.grantAutoCollect ? 'ONLINE' : 'OFFLINE'}</span>
|
||||
<span class="p-cost">${G.grantAutoCollect ? 'Unlocked' : `Cost: ${fmt(automationCost)} trust`}</span>
|
||||
<span class="p-desc">Immediately bank every completed grant settlement.</span>
|
||||
</button>
|
||||
<button class="project-btn ${G.yomi >= yomiCost ? 'can-buy' : ''}" onclick="buyGrantUpgrade('yomi')" aria-label="Upgrade yomi desk for ${yomiCost} yomi">
|
||||
<span class="p-name">Yomi Desk Lv.${(G.grantYomiLevel || 0) + 1}</span>
|
||||
<span class="p-cost">Cost: ${fmt(yomiCost)} yomi</span>
|
||||
<span class="p-desc">See better entries in volatile funding rounds.</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
for (const def of GRANT_DEFS) {
|
||||
const state = G.grants[def.id];
|
||||
const riskColor = def.risk === 'low' ? '#4caf50' : def.risk === 'med' ? '#ffaa00' : '#f44336';
|
||||
const price = Math.ceil(state.price);
|
||||
const canApply = !state.active && G.trust >= price;
|
||||
const pendingParts = [];
|
||||
for (const [resource, amount] of Object.entries(state.pending)) {
|
||||
if (amount > 0) pendingParts.push(`${fmt(amount)} ${resource}`);
|
||||
}
|
||||
const canCollect = pendingParts.length > 0;
|
||||
html += `
|
||||
<div style="border:1px solid #1a1a2e;border-radius:6px;padding:10px;margin-bottom:8px;background:#090913">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;margin-bottom:4px">
|
||||
<div style="color:#ffd700;font-weight:600">${def.name}</div>
|
||||
<div style="font-size:9px;color:${riskColor};border:1px solid ${riskColor};padding:1px 6px;border-radius:999px">${getGrantRiskLabel(def.risk)}</div>
|
||||
</div>
|
||||
<div style="font-size:10px;color:#888;line-height:1.5">${def.edu}</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;margin:8px 0;font-size:10px;color:#777">
|
||||
<div>Entry price: <span style="color:#c0c0d0">${fmt(price)} trust</span></div>
|
||||
<div>Review cycle: <span style="color:#c0c0d0">${fmt(def.cycle)}s</span></div>
|
||||
<div>Status: <span style="color:#c0c0d0">${state.active ? `Reviewing (${state.timer.toFixed(1)}s)` : state.lastOutcome}</span></div>
|
||||
<div>Pending: <span style="color:#c0c0d0">${canCollect ? pendingParts.join(', ') : 'Nothing banked'}</span></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="project-btn ${canApply ? 'can-buy' : ''}" onclick="applyGrant('${def.id}')" aria-label="Apply for ${def.name} at ${price} trust" style="flex:1;margin:0">
|
||||
<span class="p-name">${state.active ? 'UNDER REVIEW' : 'APPLY'}</span>
|
||||
<span class="p-cost">${state.active ? `${state.timer.toFixed(1)}s remaining` : `${fmt(price)} trust`}</span>
|
||||
<span class="p-desc">${def.reward.compute ? '+' + fmt(def.reward.compute) + ' compute ' : ''}${def.reward.knowledge ? '+' + fmt(def.reward.knowledge) + ' knowledge ' : ''}${def.reward.ops ? '+' + fmt(def.reward.ops) + ' ops ' : ''}${def.reward.trust ? '+' + fmt(def.reward.trust) + ' trust' : ''}</span>
|
||||
</button>
|
||||
<button class="project-btn ${canCollect ? 'can-buy' : ''}" onclick="collectGrant('${def.id}')" aria-label="Collect payout from ${def.name}" style="flex:1;margin:0">
|
||||
<span class="p-name">COLLECT</span>
|
||||
<span class="p-cost">${canCollect ? pendingParts.join(', ') : 'No payout ready'}</span>
|
||||
<span class="p-desc">Manual banking keeps the funding loop visible.</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderAlignment() {
|
||||
const container = document.getElementById('alignment-ui');
|
||||
if (!container) return;
|
||||
@@ -197,7 +286,7 @@ function saveGame() {
|
||||
const saveData = {
|
||||
version: 1,
|
||||
code: G.code, compute: G.compute, knowledge: G.knowledge, users: G.users, impact: G.impact,
|
||||
ops: G.ops, trust: G.trust, creativity: G.creativity, harmony: G.harmony,
|
||||
ops: G.ops, trust: G.trust, creativity: G.creativity, harmony: G.harmony, yomi: G.yomi || 0,
|
||||
totalCode: G.totalCode, totalCompute: G.totalCompute, totalKnowledge: G.totalKnowledge,
|
||||
totalUsers: G.totalUsers, totalImpact: G.totalImpact,
|
||||
buildings: G.buildings,
|
||||
@@ -209,6 +298,9 @@ function saveGame() {
|
||||
lazarusFlag: G.lazarusFlag || 0, mempalaceFlag: G.mempalaceFlag || 0, ciFlag: G.ciFlag || 0,
|
||||
branchProtectionFlag: G.branchProtectionFlag || 0, nightlyWatchFlag: G.nightlyWatchFlag || 0,
|
||||
nostrFlag: G.nostrFlag || 0,
|
||||
grantFlag: G.grantFlag || 0, grantAutoCollect: G.grantAutoCollect || 0,
|
||||
grantOfficeLevel: G.grantOfficeLevel || 0, grantYomiLevel: G.grantYomiLevel || 0,
|
||||
grantMarketTimer: G.grantMarketTimer || 0, grants: G.grants || {},
|
||||
milestones: G.milestones, completedProjects: G.completedProjects, activeProjects: G.activeProjects,
|
||||
totalClicks: G.totalClicks, startedAt: G.startedAt,
|
||||
flags: G.flags,
|
||||
@@ -254,12 +346,13 @@ function loadGame() {
|
||||
|
||||
// Whitelist properties that can be loaded
|
||||
const whitelist = [
|
||||
'code', 'compute', 'knowledge', 'users', 'impact', 'ops', 'trust', 'creativity', 'harmony',
|
||||
'code', 'compute', 'knowledge', 'users', 'impact', 'ops', 'trust', 'creativity', 'harmony', 'yomi',
|
||||
'totalCode', 'totalCompute', 'totalKnowledge', 'totalUsers', 'totalImpact',
|
||||
'buildings', 'codeBoost', 'computeBoost', 'knowledgeBoost', 'userBoost', 'impactBoost',
|
||||
'milestoneFlag', 'phase', 'deployFlag', 'sovereignFlag', 'beaconFlag',
|
||||
'memoryFlag', 'pactFlag', 'lazarusFlag', 'mempalaceFlag', 'ciFlag',
|
||||
'branchProtectionFlag', 'nightlyWatchFlag', 'nostrFlag',
|
||||
'grantFlag', 'grantAutoCollect', 'grantOfficeLevel', 'grantYomiLevel', 'grantMarketTimer', 'grants',
|
||||
'milestones', 'completedProjects', 'activeProjects',
|
||||
'totalClicks', 'startedAt', 'playTime', 'flags', 'rescues', 'totalRescues',
|
||||
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
|
||||
@@ -314,6 +407,9 @@ function loadGame() {
|
||||
G.activeDebuffs = [];
|
||||
}
|
||||
|
||||
updateRates();
|
||||
const offSec = data.savedAt ? (Date.now() - data.savedAt) / 1000 : 0;
|
||||
advanceGrantOffline(offSec);
|
||||
updateRates();
|
||||
G.isLoading = false;
|
||||
|
||||
|
||||
376
tests/grants.test.cjs
Normal file
376
tests/grants.test.cjs
Normal file
@@ -0,0 +1,376 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
class Element {
|
||||
constructor(tagName = 'div', id = '') {
|
||||
this.tagName = String(tagName).toUpperCase();
|
||||
this.id = id;
|
||||
this.style = {};
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.previousElementSibling = null;
|
||||
this.innerHTML = '';
|
||||
this.textContent = '';
|
||||
this.className = '';
|
||||
this.dataset = {};
|
||||
this.attributes = {};
|
||||
this._queryMap = new Map();
|
||||
this.classList = {
|
||||
add: (...names) => {
|
||||
const set = new Set(this.className.split(/\s+/).filter(Boolean));
|
||||
names.forEach((name) => set.add(name));
|
||||
this.className = Array.from(set).join(' ');
|
||||
},
|
||||
remove: (...names) => {
|
||||
const remove = new Set(names);
|
||||
this.className = this.className
|
||||
.split(/\s+/)
|
||||
.filter((name) => name && !remove.has(name))
|
||||
.join(' ');
|
||||
},
|
||||
toggle: (name, force) => {
|
||||
const set = new Set(this.className.split(/\s+/).filter(Boolean));
|
||||
const shouldAdd = force === undefined ? !set.has(name) : Boolean(force);
|
||||
if (shouldAdd) set.add(name);
|
||||
else set.delete(name);
|
||||
this.className = Array.from(set).join(' ');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
appendChild(child) {
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
return child;
|
||||
}
|
||||
|
||||
removeChild(child) {
|
||||
this.children = this.children.filter((candidate) => candidate !== child);
|
||||
if (child.parentNode === this) child.parentNode = null;
|
||||
return child;
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (this.parentNode) this.parentNode.removeChild(this);
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = value;
|
||||
if (name === 'id') this.id = value;
|
||||
if (name === 'class') this.className = value;
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
return this._queryMap.get(selector) || [];
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
|
||||
closest(selector) {
|
||||
if (selector === '.res' && this.className.split(/\s+/).includes('res')) return this;
|
||||
return this.parentNode && typeof this.parentNode.closest === 'function'
|
||||
? this.parentNode.closest(selector)
|
||||
: null;
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return { left: 0, top: 0, width: 12, height: 12 };
|
||||
}
|
||||
}
|
||||
|
||||
function buildDom() {
|
||||
const byId = new Map();
|
||||
const body = new Element('body', 'body');
|
||||
const head = new Element('head', 'head');
|
||||
|
||||
const document = {
|
||||
body,
|
||||
head,
|
||||
createElement(tagName) {
|
||||
return new Element(tagName);
|
||||
},
|
||||
getElementById(id) {
|
||||
return byId.get(id) || null;
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
function register(element) {
|
||||
if (element.id) byId.set(element.id, element);
|
||||
return element;
|
||||
}
|
||||
|
||||
const ids = [
|
||||
'alignment-ui', 'action-panel', 'sprint-container', 'project-panel', 'projects',
|
||||
'grants-panel', 'grants-heading', 'buildings', 'strategy-panel', 'strategy-recommendation',
|
||||
'combat-panel', 'edu-panel', 'phase-bar', 'log', 'log-entries', 'toast-container',
|
||||
'combo-display', 'debuffs', 'sprint-btn', 'sprint-bar-wrap', 'sprint-bar', 'sprint-label',
|
||||
'production-breakdown', 'click-power-display', 'help-overlay'
|
||||
];
|
||||
|
||||
for (const id of ids) {
|
||||
body.appendChild(register(new Element('div', id)));
|
||||
}
|
||||
|
||||
const resourceIds = [
|
||||
'r-code', 'r-compute', 'r-knowledge', 'r-users', 'r-impact',
|
||||
'r-rescues', 'r-ops', 'r-trust', 'r-creativity', 'r-harmony', 'r-yomi'
|
||||
];
|
||||
for (const id of resourceIds) {
|
||||
const wrapper = new Element('div');
|
||||
wrapper.className = 'res';
|
||||
const value = register(new Element('div', id));
|
||||
const rate = register(new Element('div', `${id}-rate`));
|
||||
wrapper.appendChild(value);
|
||||
wrapper.appendChild(rate);
|
||||
if (id === 'r-yomi') wrapper.id = 'yomi-res';
|
||||
body.appendChild(wrapper);
|
||||
if (wrapper.id) byId.set(wrapper.id, wrapper);
|
||||
}
|
||||
|
||||
const statIds = [
|
||||
'st-code', 'st-compute', 'st-knowledge', 'st-users', 'st-impact', 'st-rescues',
|
||||
'st-buildings', 'st-projects', 'st-time', 'st-clicks', 'st-harmony', 'st-drift', 'st-resolved', 'st-yomi'
|
||||
];
|
||||
for (const id of statIds) {
|
||||
body.appendChild(register(new Element('span', id)));
|
||||
}
|
||||
|
||||
return {
|
||||
document,
|
||||
window: {
|
||||
document,
|
||||
innerWidth: 1280,
|
||||
innerHeight: 720,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
SSE: null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function loadBeacon({ randomValues = [] } = {}) {
|
||||
const { document, window } = buildDom();
|
||||
const storage = new Map();
|
||||
const timerQueue = [];
|
||||
const math = Object.create(Math);
|
||||
const queue = Array.from(randomValues);
|
||||
math.random = () => (queue.length ? queue.shift() : 0.73);
|
||||
|
||||
const context = {
|
||||
console,
|
||||
Math: math,
|
||||
Date,
|
||||
JSON,
|
||||
document,
|
||||
window,
|
||||
navigator: { userAgent: 'node' },
|
||||
location: { reload() {} },
|
||||
confirm: () => false,
|
||||
requestAnimationFrame: (fn) => fn(),
|
||||
setTimeout: (fn) => {
|
||||
timerQueue.push(fn);
|
||||
return timerQueue.length;
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
localStorage: {
|
||||
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
|
||||
setItem: (key, value) => storage.set(key, String(value)),
|
||||
removeItem: (key) => storage.delete(key)
|
||||
},
|
||||
Combat: { tickBattle() {}, startBattle() {}, renderCombatPanel() {}, init() {} },
|
||||
Sound: undefined,
|
||||
Blob: function Blob(parts) { this.parts = parts; },
|
||||
URL: { createObjectURL() { return 'blob:test'; }, revokeObjectURL() {} }
|
||||
};
|
||||
|
||||
vm.createContext(context);
|
||||
const files = ['js/data.js', 'js/utils.js', 'js/engine.js', 'js/render.js'];
|
||||
const source = files.map((file) => fs.readFileSync(path.join(ROOT, file), 'utf8')).join('\n\n');
|
||||
|
||||
vm.runInContext(`${source}
|
||||
log = () => {};
|
||||
showToast = () => {};
|
||||
render = () => {};
|
||||
renderPhase = () => {};
|
||||
showOfflinePopup = () => {};
|
||||
showSaveToast = () => {};
|
||||
updateEducation = () => {};
|
||||
renderAlignment = () => {};
|
||||
renderProgress = () => {};
|
||||
renderCombo = () => {};
|
||||
renderDebuffs = () => {};
|
||||
renderSprint = () => {};
|
||||
renderPulse = () => {};
|
||||
renderStrategy = () => {};
|
||||
renderClickPower = () => {};
|
||||
this.__exports = {
|
||||
G,
|
||||
CONFIG,
|
||||
GRANT_DEFS: typeof GRANT_DEFS !== 'undefined' ? GRANT_DEFS : null,
|
||||
checkProjects,
|
||||
buyProject,
|
||||
applyGrant: typeof applyGrant === 'function' ? applyGrant : null,
|
||||
collectGrant: typeof collectGrant === 'function' ? collectGrant : null,
|
||||
buyGrantUpgrade: typeof buyGrantUpgrade === 'function' ? buyGrantUpgrade : null,
|
||||
updateGrantMarket: typeof updateGrantMarket === 'function' ? updateGrantMarket : null,
|
||||
renderGrants: typeof renderGrants === 'function' ? renderGrants : null,
|
||||
saveGame,
|
||||
loadGame,
|
||||
tick
|
||||
};`, context);
|
||||
|
||||
return {
|
||||
context,
|
||||
document,
|
||||
...context.__exports
|
||||
};
|
||||
}
|
||||
|
||||
function unlockGrantEngine(api) {
|
||||
const { G, checkProjects, buyProject } = api;
|
||||
G.activeProjects = [];
|
||||
G.completedProjects = [];
|
||||
G.deployFlag = 1;
|
||||
G.totalUsers = 250;
|
||||
G.users = 250;
|
||||
G.totalKnowledge = 4000;
|
||||
G.knowledge = 4000;
|
||||
G.trust = 120;
|
||||
checkProjects();
|
||||
buyProject('p_research_grants');
|
||||
assert.equal(G.grantFlag, 1);
|
||||
}
|
||||
|
||||
function sumPending(state) {
|
||||
return Object.values(state.pending).reduce((sum, value) => sum + value, 0);
|
||||
}
|
||||
|
||||
test('research grants unlock six fluctuating funding sources with low med and high risk tiers', () => {
|
||||
const api = loadBeacon({ randomValues: [0.9, 0.1, 0.85, 0.15, 0.8, 0.2, 0.75, 0.25, 0.7, 0.3, 0.65, 0.35] });
|
||||
const { G, GRANT_DEFS, renderGrants, updateGrantMarket, document } = api;
|
||||
|
||||
unlockGrantEngine(api);
|
||||
|
||||
assert.ok(Array.isArray(GRANT_DEFS));
|
||||
assert.ok(GRANT_DEFS.length >= 6);
|
||||
assert.ok(GRANT_DEFS.some((def) => def.risk === 'low'));
|
||||
assert.ok(GRANT_DEFS.some((def) => def.risk === 'med'));
|
||||
assert.ok(GRANT_DEFS.some((def) => def.risk === 'high'));
|
||||
|
||||
const trackedId = GRANT_DEFS[0].id;
|
||||
const before = G.grants[trackedId].price;
|
||||
updateGrantMarket(5);
|
||||
const after = G.grants[trackedId].price;
|
||||
assert.notEqual(after, before);
|
||||
|
||||
renderGrants();
|
||||
const html = document.getElementById('grants-panel').innerHTML;
|
||||
assert.match(html, /AUTO-COLLECT/);
|
||||
assert.match(html, /LOW/);
|
||||
assert.match(html, /MED/);
|
||||
assert.match(html, /HIGH/);
|
||||
assert.match(html, new RegExp(GRANT_DEFS[0].name));
|
||||
});
|
||||
|
||||
test('manual collection settles finished grants into resources without touching production rates', () => {
|
||||
const api = loadBeacon({ randomValues: [0.6, 0.7, 0.8, 0.9] });
|
||||
const { G, GRANT_DEFS, applyGrant, collectGrant, tick } = api;
|
||||
|
||||
unlockGrantEngine(api);
|
||||
|
||||
const sourceId = GRANT_DEFS.find((def) => def.risk === 'low').id;
|
||||
G.grants[sourceId].price = 5;
|
||||
G.compute = 0;
|
||||
G.knowledge = 0;
|
||||
G.ops = 0;
|
||||
const trustBefore = G.trust;
|
||||
|
||||
applyGrant(sourceId);
|
||||
assert.equal(G.trust, trustBefore - 5);
|
||||
|
||||
for (let i = 0; i < 300; i++) tick();
|
||||
|
||||
const state = G.grants[sourceId];
|
||||
assert.equal(state.active, false);
|
||||
assert.ok(sumPending(state) > 0);
|
||||
assert.equal(G.compute, 0);
|
||||
assert.equal(G.knowledge, 0);
|
||||
|
||||
collectGrant(sourceId);
|
||||
assert.ok(G.compute > 0 || G.knowledge > 0 || G.ops > 0 || G.trust > trustBefore - 5);
|
||||
assert.equal(sumPending(state), 0);
|
||||
});
|
||||
|
||||
test('grant upgrades spend trust and yomi while enabling automation', () => {
|
||||
const api = loadBeacon();
|
||||
const { G, buyGrantUpgrade } = api;
|
||||
|
||||
unlockGrantEngine(api);
|
||||
|
||||
const trustBefore = G.trust;
|
||||
buyGrantUpgrade('office');
|
||||
assert.equal(G.grantOfficeLevel, 1);
|
||||
assert.ok(G.trust < trustBefore);
|
||||
|
||||
G.trust = 200;
|
||||
buyGrantUpgrade('automation');
|
||||
assert.equal(G.grantAutoCollect, 1);
|
||||
|
||||
G.yomi = 10;
|
||||
buyGrantUpgrade('yomi');
|
||||
assert.equal(G.grantYomiLevel, 1);
|
||||
assert.ok(G.yomi < 10);
|
||||
});
|
||||
|
||||
test('save and load preserve grant market state upgrades and active cycles', () => {
|
||||
const api = loadBeacon({ randomValues: [0.7, 0.8, 0.9, 0.6] });
|
||||
const { G, GRANT_DEFS, applyGrant, saveGame, loadGame } = api;
|
||||
|
||||
unlockGrantEngine(api);
|
||||
|
||||
const sourceId = GRANT_DEFS.find((def) => def.risk === 'high').id;
|
||||
G.grantAutoCollect = 1;
|
||||
G.grantOfficeLevel = 2;
|
||||
G.grantYomiLevel = 1;
|
||||
G.yomi = 4;
|
||||
G.grants[sourceId].price = 9;
|
||||
applyGrant(sourceId);
|
||||
G.grants[sourceId].timer = 12.5;
|
||||
G.grants[sourceId].pending.compute = 33;
|
||||
G.grants[sourceId].pending.yomi = 2;
|
||||
|
||||
saveGame();
|
||||
|
||||
G.grantFlag = 0;
|
||||
G.grantAutoCollect = 0;
|
||||
G.grantOfficeLevel = 0;
|
||||
G.grantYomiLevel = 0;
|
||||
G.yomi = 0;
|
||||
G.grants = {};
|
||||
|
||||
assert.equal(loadGame(), true);
|
||||
assert.equal(G.grantFlag, 1);
|
||||
assert.equal(G.grantAutoCollect, 1);
|
||||
assert.equal(G.grantOfficeLevel, 2);
|
||||
assert.equal(G.grantYomiLevel, 1);
|
||||
assert.equal(G.yomi, 4);
|
||||
assert.equal(G.grants[sourceId].active, true);
|
||||
assert.equal(G.grants[sourceId].timer, 12.5);
|
||||
assert.equal(G.grants[sourceId].pending.compute, 33);
|
||||
assert.equal(G.grants[sourceId].pending.yomi, 2);
|
||||
});
|
||||
Reference in New Issue
Block a user