RESEARCH PROJECTS
+RESEARCH GRANTS
+STATISTICS
Total Code: 0
@@ -199,6 +202,7 @@ Projects Done: 0
Time Played: 0:00
Clicks: 0
Harmony: 50
+Yomi: 0
Drift: 0
Events Resolved: 0
diff --git a/js/data.js b/js/data.js
index dc44f79..315e003 100644
--- a/js/data.js
+++ b/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) ===
{
diff --git a/js/engine.js b/js/engine.js
index 4eb1d2c..aefbb7f 100644
--- a/js/engine.js
+++ b/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());
diff --git a/js/render.js b/js/render.js
index 77049c8..8f6fc92 100644
--- a/js/render.js
+++ b/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 = `
+ @@ -199,6 +202,7 @@ Projects Done: 0
Time Played: 0:00
Clicks: 0
Harmony: 50
+Yomi: 0
Drift: 0
Events Resolved: 0
+
+
+ Funding keeps open source alive between launches. These cycles run beside your normal production loop.
+
+ Yomi: ${fmt(G.yomi || 0)}
+
+
+
+
+
+ `;
+
+ 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 += `
+
+
+ `;
+ }
+
+ 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;
diff --git a/tests/grants.test.cjs b/tests/grants.test.cjs
new file mode 100644
index 0000000..51390e6
--- /dev/null
+++ b/tests/grants.test.cjs
@@ -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);
+});
+
+ ${def.name}
+ ${getGrantRiskLabel(def.risk)}
+ ${def.edu}
+
+
+ Entry price: ${fmt(price)} trust
+ Review cycle: ${fmt(def.cycle)}s
+ Status: ${state.active ? `Reviewing (${state.timer.toFixed(1)}s)` : state.lastOutcome}
+ Pending: ${canCollect ? pendingParts.join(', ') : 'Nothing banked'}
+
+
+
+
+