diff --git a/index.html b/index.html index e5429c8..4e1282a 100644 --- a/index.html +++ b/index.html @@ -152,6 +152,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
Ops
5
+0/s
Trust
5
+0/s
+
Harmony
50
+0/s
@@ -186,6 +187,8 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code

RESEARCH PROJECTS

+ +

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 = ` +
+
+ 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 += ` +
+
+
${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'}
+
+
+ + +
+
+ `; + } + + 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); +});