Compare commits

...

1 Commits

Author SHA1 Message Date
Timmy
3dc8fc742b feat: add research grant investment engine (#8)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 16s
Smoke Test / smoke (pull_request) Failing after 23s
2026-04-14 22:17:51 -04:00
5 changed files with 796 additions and 3 deletions

View File

@@ -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>

View File

@@ -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) ===
{

View File

@@ -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());

View File

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