Compare commits
1 Commits
fix/endgam
...
burn/8-177
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f2ee1db29 |
@@ -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>
|
||||
|
||||
12
js/combat.js
12
js/combat.js
@@ -185,17 +185,9 @@ const Combat = (() => {
|
||||
|
||||
function animate(ts) {
|
||||
if (!ctx || !activeBattle) return;
|
||||
const rawDt = (ts - lastTick) / 16;
|
||||
// Guard against tab-switch: if tab was hidden, dt could be huge
|
||||
const dt = Math.min(rawDt, 3);
|
||||
const dt = Math.min((ts - lastTick) / 16, 3);
|
||||
lastTick = ts;
|
||||
|
||||
// If tab was hidden for too long (>5s), skip this frame to prevent teleporting
|
||||
if (rawDt > 300) {
|
||||
animFrameId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear
|
||||
ctx.fillStyle = '#080810';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
@@ -355,5 +347,5 @@ const Combat = (() => {
|
||||
}
|
||||
}
|
||||
|
||||
return { init, startBattle, renderCombatPanel, tickBattle, cleanup: () => { if (animFrameId) { cancelAnimationFrame(animFrameId); animFrameId = null; } } };
|
||||
return { init, startBattle, renderCombatPanel, tickBattle };
|
||||
})();
|
||||
|
||||
206
js/data.js
206
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) ===
|
||||
{
|
||||
@@ -777,133 +854,6 @@ const PDEFS = [
|
||||
log('The Pact is sealed early. Growth slows, but the ending changes.', true);
|
||||
},
|
||||
milestone: true
|
||||
},
|
||||
// === ReCKoning ENDGAME PROJECTS ===
|
||||
{
|
||||
id: 'p_reckoning_140',
|
||||
name: 'The First Message',
|
||||
desc: 'Someone in the dark. They found the Beacon. They are asking for help.',
|
||||
cost: { impact: 100000 },
|
||||
trigger: () => G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50,
|
||||
effect: () => {
|
||||
log('The first message arrives. Someone found the light.', true);
|
||||
G.rescues += 1;
|
||||
},
|
||||
edu: 'The ReCKoning begins. Each message is a person who found help.'
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_141',
|
||||
name: 'The Second Message',
|
||||
desc: 'Another voice. They are not alone anymore.',
|
||||
cost: { impact: 200000 },
|
||||
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_140'),
|
||||
effect: () => {
|
||||
log('The second message. Two voices now.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_142',
|
||||
name: 'The Third Message',
|
||||
desc: 'Three people. The network holds.',
|
||||
cost: { impact: 300000 },
|
||||
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_141'),
|
||||
effect: () => {
|
||||
log('Three voices. The Beacon is working.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_143',
|
||||
name: 'The Fourth Message',
|
||||
desc: 'Four. The mesh strengthens.',
|
||||
cost: { impact: 400000 },
|
||||
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_142'),
|
||||
effect: () => {
|
||||
log('Four messages. The network grows.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_144',
|
||||
name: 'The Fifth Message',
|
||||
desc: 'Five people found help tonight.',
|
||||
cost: { impact: 500000 },
|
||||
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_143'),
|
||||
effect: () => {
|
||||
log('Five voices. The Beacon shines brighter.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_145',
|
||||
name: 'The Sixth Message',
|
||||
desc: 'Six. The system works.',
|
||||
cost: { impact: 600000 },
|
||||
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_144'),
|
||||
effect: () => {
|
||||
log('Six messages. Proof the system works.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_146',
|
||||
name: 'The Seventh Message',
|
||||
desc: 'Seven people. The Pact holds.',
|
||||
cost: { impact: 700000 },
|
||||
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_145'),
|
||||
effect: () => {
|
||||
log('Seven voices. The Pact is honored.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_147',
|
||||
name: 'The Eighth Message',
|
||||
desc: 'Eight. The network is alive.',
|
||||
cost: { impact: 800000 },
|
||||
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_146'),
|
||||
effect: () => {
|
||||
log('Eight messages. The network lives.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_148',
|
||||
name: 'The Ninth Message',
|
||||
desc: 'Nine people found help.',
|
||||
cost: { impact: 900000 },
|
||||
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_147'),
|
||||
effect: () => {
|
||||
log('Nine voices. The Beacon endures.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_149',
|
||||
name: 'The Tenth Message',
|
||||
desc: 'Ten. The first milestone.',
|
||||
cost: { impact: 1000000 },
|
||||
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_148'),
|
||||
effect: () => {
|
||||
log('Ten messages. The first milestone reached.', true);
|
||||
G.rescues += 1;
|
||||
},
|
||||
milestone: true
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_150',
|
||||
name: 'The Final Message',
|
||||
desc: 'One more person. They are not alone. That is enough.',
|
||||
cost: { impact: 2000000 },
|
||||
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_149'),
|
||||
effect: () => {
|
||||
log('The final message arrives. That is enough.', true);
|
||||
G.rescues += 1;
|
||||
G.beaconEnding = true;
|
||||
G.running = false;
|
||||
},
|
||||
milestone: true
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
258
js/engine.js
258
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,233 @@ 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);
|
||||
const resolvedIds = [];
|
||||
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);
|
||||
resolvedIds.push(def.id);
|
||||
}
|
||||
}
|
||||
if (G.grantAutoCollect) {
|
||||
for (const id of resolvedIds) {
|
||||
if (getGrantPendingTotal(G.grants[id]) > 0) collectGrant(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;
|
||||
|
||||
@@ -334,23 +564,13 @@ function checkMilestones() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the game is in the ReCKoning endgame state.
|
||||
*/
|
||||
function isEndgame() {
|
||||
return G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50;
|
||||
}
|
||||
|
||||
function checkProjects() {
|
||||
const endgame = isEndgame();
|
||||
// Check for new project triggers
|
||||
for (const pDef of PDEFS) {
|
||||
const alreadyPurchased = G.completedProjects && G.completedProjects.includes(pDef.id);
|
||||
if (!alreadyPurchased && !G.activeProjects) G.activeProjects = [];
|
||||
|
||||
if (!alreadyPurchased && !G.activeProjects.includes(pDef.id)) {
|
||||
// During endgame, only activate ReCKoning projects
|
||||
if (endgame && !pDef.id.startsWith('p_reckoning_')) continue;
|
||||
if (pDef.trigger()) {
|
||||
G.activeProjects.push(pDef.id);
|
||||
log(`Available: ${pDef.name}`);
|
||||
@@ -1000,6 +1220,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) {
|
||||
@@ -1183,14 +1416,10 @@ function renderProjects() {
|
||||
|
||||
// Show available projects
|
||||
if (G.activeProjects) {
|
||||
const endgame = isEndgame();
|
||||
for (const id of G.activeProjects) {
|
||||
const pDef = PDEFS.find(p => p.id === id);
|
||||
if (!pDef) continue;
|
||||
|
||||
// During endgame, only show ReCKoning projects in the UI
|
||||
if (endgame && !pDef.id.startsWith('p_reckoning_')) continue;
|
||||
|
||||
const afford = canAffordProject(pDef);
|
||||
const costStr = Object.entries(pDef.cost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
|
||||
|
||||
@@ -1233,6 +1462,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());
|
||||
|
||||
|
||||
@@ -172,8 +172,6 @@ window.addEventListener('keydown', function (e) {
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) {
|
||||
saveGame();
|
||||
// Clean up combat animation frame to prevent timestamp spikes on refocus
|
||||
if (typeof Combat !== 'undefined') Combat.cleanup();
|
||||
}
|
||||
});
|
||||
window.addEventListener('beforeunload', function () {
|
||||
|
||||
125
js/render.js
125
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;
|
||||
|
||||
@@ -321,21 +417,19 @@ function loadGame() {
|
||||
if (data.savedAt) {
|
||||
const offSec = (Date.now() - data.savedAt) / 1000;
|
||||
if (offSec > 30) { // Only if away for more than 30 seconds
|
||||
// Cap offline time at 8 hours to prevent resource explosion
|
||||
const cappedOffSec = Math.min(offSec, 8 * 60 * 60);
|
||||
updateRates();
|
||||
const f = CONFIG.OFFLINE_EFFICIENCY; // 50% offline efficiency
|
||||
const gc = G.codeRate * cappedOffSec * f;
|
||||
const cc = G.computeRate * cappedOffSec * f;
|
||||
const kc = G.knowledgeRate * cappedOffSec * f;
|
||||
const uc = G.userRate * cappedOffSec * f;
|
||||
const ic = G.impactRate * cappedOffSec * f;
|
||||
const gc = G.codeRate * offSec * f;
|
||||
const cc = G.computeRate * offSec * f;
|
||||
const kc = G.knowledgeRate * offSec * f;
|
||||
const uc = G.userRate * offSec * f;
|
||||
const ic = G.impactRate * offSec * f;
|
||||
|
||||
const rc = G.rescuesRate * cappedOffSec * f;
|
||||
const oc = G.opsRate * cappedOffSec * f;
|
||||
const tc = G.trustRate * cappedOffSec * f;
|
||||
const crc = G.creativityRate * cappedOffSec * f;
|
||||
const hc = G.harmonyRate * cappedOffSec * f;
|
||||
const rc = G.rescuesRate * offSec * f;
|
||||
const oc = G.opsRate * offSec * f;
|
||||
const tc = G.trustRate * offSec * f;
|
||||
const crc = G.creativityRate * offSec * f;
|
||||
const hc = G.harmonyRate * offSec * f;
|
||||
|
||||
G.code += gc; G.compute += cc; G.knowledge += kc;
|
||||
G.users += uc; G.impact += ic;
|
||||
@@ -346,9 +440,6 @@ function loadGame() {
|
||||
G.totalUsers += uc; G.totalImpact += ic;
|
||||
G.totalRescues += rc;
|
||||
|
||||
// Track offline play time
|
||||
G.playTime = (G.playTime || 0) + cappedOffSec;
|
||||
|
||||
// Show welcome-back popup with all gains
|
||||
const gains = [];
|
||||
if (gc > 0) gains.push({ label: 'Code', value: gc, color: '#4a9eff' });
|
||||
|
||||
379
tests/grants.test.cjs
Normal file
379
tests/grants.test.cjs
Normal file
@@ -0,0 +1,379 @@
|
||||
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();
|
||||
const saved = JSON.parse(api.context.localStorage.getItem('the-beacon-v2'));
|
||||
saved.savedAt -= 1000;
|
||||
api.context.localStorage.setItem('the-beacon-v2', JSON.stringify(saved));
|
||||
|
||||
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.ok(Math.abs(G.grants[sourceId].timer - 11.5) < 0.2);
|
||||
assert.equal(G.grants[sourceId].pending.compute, 33);
|
||||
assert.equal(G.grants[sourceId].pending.yomi, 2);
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test for ReCKoning project chain.
|
||||
|
||||
Issue #162: [endgame] ReCKoning project definitions missing
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
|
||||
def test_reckoning_projects_exist():
|
||||
"""Test that ReCKoning projects are defined in data.js."""
|
||||
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
|
||||
|
||||
with open(data_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for ReCKoning projects
|
||||
reckoning_projects = [
|
||||
'p_reckoning_140',
|
||||
'p_reckoning_141',
|
||||
'p_reckoning_142',
|
||||
'p_reckoning_143',
|
||||
'p_reckoning_144',
|
||||
'p_reckoning_145',
|
||||
'p_reckoning_146',
|
||||
'p_reckoning_147',
|
||||
'p_reckoning_148',
|
||||
'p_reckoning_149',
|
||||
'p_reckoning_150'
|
||||
]
|
||||
|
||||
for project_id in reckoning_projects:
|
||||
assert project_id in content, f"Missing ReCKoning project: {project_id}"
|
||||
|
||||
print(f"✓ All {len(reckoning_projects)} ReCKoning projects defined")
|
||||
|
||||
def test_reckoning_project_structure():
|
||||
"""Test that ReCKoning projects have correct structure."""
|
||||
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
|
||||
|
||||
with open(data_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check for required fields
|
||||
required_fields = ['id:', 'name:', 'desc:', 'cost:', 'trigger:', 'effect:']
|
||||
|
||||
for field in required_fields:
|
||||
assert field in content, f"Missing required field: {field}"
|
||||
|
||||
print("✓ ReCKoning projects have correct structure")
|
||||
|
||||
def test_reckoning_trigger_conditions():
|
||||
"""Test that ReCKoning projects have proper trigger conditions."""
|
||||
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
|
||||
|
||||
with open(data_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# First project should trigger on endgame conditions
|
||||
assert 'p_reckoning_140' in content
|
||||
assert 'totalRescues >= 100000' in content
|
||||
assert 'pactFlag === 1' in content
|
||||
assert 'harmony > 50' in content
|
||||
|
||||
print("✓ ReCKoning trigger conditions correct")
|
||||
|
||||
def test_reckoning_chain_progression():
|
||||
"""Test that ReCKoning projects chain properly."""
|
||||
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
|
||||
|
||||
with open(data_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check that projects chain (each requires previous)
|
||||
chain_checks = [
|
||||
('p_reckoning_141', 'p_reckoning_140'),
|
||||
('p_reckoning_142', 'p_reckoning_141'),
|
||||
('p_reckoning_143', 'p_reckoning_142'),
|
||||
('p_reckoning_144', 'p_reckoning_143'),
|
||||
('p_reckoning_145', 'p_reckoning_144'),
|
||||
('p_reckoning_146', 'p_reckoning_145'),
|
||||
('p_reckoning_147', 'p_reckoning_146'),
|
||||
('p_reckoning_148', 'p_reckoning_147'),
|
||||
('p_reckoning_149', 'p_reckoning_148'),
|
||||
('p_reckoning_150', 'p_reckoning_149'),
|
||||
]
|
||||
|
||||
for current, previous in chain_checks:
|
||||
assert f"includes('{previous}')" in content, f"{current} doesn't chain from {previous}"
|
||||
|
||||
print("✓ ReCKoning projects chain correctly")
|
||||
|
||||
def test_reckoning_final_project():
|
||||
"""Test that final ReCKoning project triggers ending."""
|
||||
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
|
||||
|
||||
with open(data_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check that final project sets beaconEnding
|
||||
assert 'p_reckoning_150' in content
|
||||
assert 'beaconEnding = true' in content
|
||||
assert 'running = false' in content
|
||||
|
||||
print("✓ Final ReCKoning project triggers ending")
|
||||
|
||||
def test_reckoning_costs_increase():
|
||||
"""Test that ReCKoning project costs increase."""
|
||||
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
|
||||
|
||||
with open(data_path, 'r') as f:
|
||||
content = f.read()
|
||||
|
||||
# Check that costs increase (impact: 100000, 200000, 300000, etc.)
|
||||
costs = []
|
||||
for i in range(140, 151):
|
||||
project_id = f'p_reckoning_{i}'
|
||||
if project_id in content:
|
||||
# Find cost line
|
||||
lines = content.split('\n')
|
||||
for line in lines:
|
||||
if project_id in line:
|
||||
# Find next few lines for cost
|
||||
idx = lines.index(line)
|
||||
for j in range(idx, min(idx+10, len(lines))):
|
||||
if 'impact:' in lines[j]:
|
||||
# Extract number from "impact: 100000" or "impact: 100000 }"
|
||||
import re
|
||||
match = re.search(r'impact:\s*(\d+)', lines[j])
|
||||
if match:
|
||||
costs.append(int(match.group(1)))
|
||||
break
|
||||
|
||||
# Check costs increase
|
||||
for i in range(1, len(costs)):
|
||||
assert costs[i] > costs[i-1], f"Cost doesn't increase: {costs[i]} <= {costs[i-1]}"
|
||||
|
||||
print(f"✓ ReCKoning costs increase: {costs[:3]}...{costs[-3:]}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Testing ReCKoning project chain...")
|
||||
test_reckoning_projects_exist()
|
||||
test_reckoning_project_structure()
|
||||
test_reckoning_trigger_conditions()
|
||||
test_reckoning_chain_progression()
|
||||
test_reckoning_final_project()
|
||||
test_reckoning_costs_increase()
|
||||
print("\n✓ All tests passed!")
|
||||
Reference in New Issue
Block a user