Compare commits

..

1 Commits

Author SHA1 Message Date
b132f899ba fix: P2 offline progress cap + canvas combat tab-switch guard (#186)
P2 fixes: offline progress cap + canvas combat tab-switch guard
2026-04-15 04:52:59 +00:00
7 changed files with 302 additions and 111 deletions

View File

@@ -185,9 +185,17 @@ const Combat = (() => {
function animate(ts) {
if (!ctx || !activeBattle) return;
const dt = Math.min((ts - lastTick) / 16, 3);
const rawDt = (ts - lastTick) / 16;
// Guard against tab-switch: if tab was hidden, dt could be huge
const dt = Math.min(rawDt, 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);
@@ -347,5 +355,5 @@ const Combat = (() => {
}
}
return { init, startBattle, renderCombatPanel, tickBattle };
return { init, startBattle, renderCombatPanel, tickBattle, cleanup: () => { if (animFrameId) { cancelAnimationFrame(animFrameId); animFrameId = null; } } };
})();

View File

@@ -777,6 +777,133 @@ 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
}
];

View File

@@ -172,6 +172,8 @@ 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 () {

View File

@@ -1,79 +0,0 @@
// project_chain.js — Paperclips-style cascading project system
// Implements trigger/cost/effect with prerequisites, educational tooltips, phase-aware unlocking
var ProjectChain = {
_deps: {},
register: function(p) { if (p.requires) this._deps[p.id] = p.requires; },
canUnlock: function(id) {
var d = this._deps[id];
if (!d) return true;
var deps = Array.isArray(d) ? d : [d];
return deps.every(function(dep) { return G.completedProjects && G.completedProjects.includes(dep); });
},
formatCost: function(c) {
if (!c) return 'Free';
var parts = [];
for (var k in c) parts.push(c[k] + ' ' + k);
return parts.join(', ');
},
purchase: function(id) {
var def = PDEFS.find(function(p) { return p.id === id; });
if (!def) return false;
if (G.completedProjects && G.completedProjects.includes(def.id) && !def.repeatable) return false;
if (!this.canUnlock(def.id)) return false;
if (!canAffordProject(def)) return false;
spendProject(def);
if (def.effect) def.effect();
if (!G.completedProjects) G.completedProjects = [];
if (!G.completedProjects.includes(def.id)) G.completedProjects.push(def.id);
if (!def.repeatable) G.activeProjects = (G.activeProjects||[]).filter(function(x){return x!==def.id;});
log('\u2713 ' + def.name + (def.edu ? ' (' + def.edu + ')' : ''));
if (typeof Sound !== 'undefined') Sound.playProject();
this.checkCascade(id);
return true;
},
checkCascade: function(cid) {
for (var i = 0; i < PDEFS.length; i++) {
var p = PDEFS[i];
if (p.requires) {
var deps = Array.isArray(p.requires) ? p.requires : [p.requires];
if (deps.indexOf(cid) >= 0 && this.canUnlock(p.id) && p.trigger && p.trigger()) {
if (!G.activeProjects) G.activeProjects = [];
if (G.activeProjects.indexOf(p.id) < 0) {
G.activeProjects.push(p.id);
log('Unlocked: ' + p.name);
}
}
}
}
}
};
var CHAIN_PROJECTS = [
{id:'p_chain_optimization',name:'Optimization Algorithms',desc:'Improve code efficiency.',cost:{ops:500},category:'algorithms',phase:1,edu:'Optimization reduces compute costs 30-70%.',trigger:function(){return G.buildings.autocoder>=2;},effect:function(){G.codeBoost+=0.15;}},
{id:'p_chain_data_structures',name:'Data Structure Mastery',desc:'Right structure for each problem.',cost:{ops:800,knowledge:50},category:'algorithms',phase:1,requires:'p_chain_optimization',edu:'Arrays vs linked lists vs hash maps.',trigger:function(){return G.completedProjects&&G.completedProjects.includes('p_chain_optimization');},effect:function(){G.codeBoost+=0.2;G.computeBoost+=0.1;}},
{id:'p_chain_parallel',name:'Parallel Processing',desc:'Multiple code streams.',cost:{ops:1500,compute:200},category:'infrastructure',phase:1,requires:'p_chain_data_structures',edu:"Amdahl's Law: speedup limited by serial portion.",trigger:function(){return G.completedProjects&&G.completedProjects.includes('p_chain_data_structures');},effect:function(){G.computeBoost+=0.3;}},
{id:'p_chain_tokenization',name:'Tokenization Engine',desc:'Break language into tokens.',cost:{knowledge:100,ops:1000},category:'nlp',phase:2,edu:'BPE: how GPT processes text.',trigger:function(){return G.totalKnowledge>=200;},effect:function(){G.knowledgeBoost+=0.25;}},
{id:'p_chain_embeddings',name:'Word Embeddings',desc:'Words as vectors.',cost:{knowledge:200,compute:300},category:'nlp',phase:2,requires:'p_chain_tokenization',edu:'Word2Vec: king-man+woman\u2248queen.',trigger:function(){return G.completedProjects&&G.completedProjects.includes('p_chain_tokenization');},effect:function(){G.knowledgeBoost+=0.3;}},
{id:'p_chain_attention',name:'Attention Mechanism',desc:'"Attention Is All You Need."',cost:{knowledge:400,compute:500},category:'nlp',phase:2,requires:'p_chain_embeddings',edu:'Transformers: the core idea that changed NLP.',trigger:function(){return G.completedProjects&&G.completedProjects.includes('p_chain_embeddings');},effect:function(){G.knowledgeBoost+=0.5;log('Attention discovered.');}},
{id:'p_chain_load_balancing',name:'Load Balancing',desc:'Distribute requests.',cost:{compute:500,ops:2000},category:'infrastructure',phase:3,edu:'Round-robin, least-connections, weighted.',trigger:function(){return G.buildings.server>=3;},effect:function(){G.computeBoost+=0.2;}},
{id:'p_chain_caching',name:'Response Caching',desc:'Cache frequent responses.',cost:{compute:300,ops:1500},category:'infrastructure',phase:3,requires:'p_chain_load_balancing',edu:'Cache invalidation: one of two hard CS problems.',trigger:function(){return G.completedProjects&&G.completedProjects.includes('p_chain_load_balancing');},effect:function(){G.computeBoost+=0.15;}},
{id:'p_chain_cdn',name:'Content Delivery Network',desc:'Edge location serving.',cost:{compute:800,ops:3000},category:'infrastructure',phase:3,requires:'p_chain_caching',edu:'Cloudflare: 300+ data centers.',trigger:function(){return G.completedProjects&&G.completedProjects.includes('p_chain_caching');},effect:function(){G.userBoost+=0.25;}},
{id:'p_chain_sovereign_keys',name:'Sovereign Key Management',desc:'Your keys, your control.',cost:{knowledge:500,compute:400},category:'security',phase:4,edu:'Private keys = identity.',trigger:function(){return G.phase>=4;},effect:function(){G.trustBoost=(G.trustBoost||1)+0.2;}},
{id:'p_chain_local_inference',name:'Local Inference',desc:'No cloud dependency.',cost:{compute:1000,knowledge:300},category:'sovereignty',phase:4,requires:'p_chain_sovereign_keys',edu:'Ollama: 70B models on consumer GPUs.',trigger:function(){return G.completedProjects&&G.completedProjects.includes('p_chain_sovereign_keys');},effect:function(){G.computeBoost+=0.3;}},
{id:'p_chain_self_hosting',name:'Self-Hosted Infrastructure',desc:'Your servers, your rules.',cost:{compute:2000,ops:5000},category:'sovereignty',phase:4,requires:'p_chain_local_inference',edu:'Run everything on hardware you own.',trigger:function(){return G.completedProjects&&G.completedProjects.includes('p_chain_local_inference');},effect:function(){G.trustBoost=(G.trustBoost||1)+0.3;G.computeBoost+=0.2;}},
{id:'p_chain_impact_metrics',name:'Impact Measurement',desc:'Measure real-world impact.',cost:{impact:100,ops:3000},category:'impact',phase:5,edu:"Can't improve what you can't measure.",trigger:function(){return G.totalImpact>=500;},effect:function(){G.impactBoost+=0.25;}},
{id:'p_chain_user_stories',name:'User Story Collection',desc:'Real stories of AI helping people.',cost:{impact:200,knowledge:500},category:'impact',phase:5,requires:'p_chain_impact_metrics',edu:'Every number is a person.',trigger:function(){return G.completedProjects&&G.completedProjects.includes('p_chain_impact_metrics');},effect:function(){G.userBoost+=0.3;G.impactBoost+=0.15;}},
{id:'p_chain_open_source',name:'Open Source Everything',desc:'Release under open license.',cost:{impact:500,trust:10},category:'legacy',phase:5,requires:'p_chain_user_stories',edu:'Linux, Python, Git: changed the world.',trigger:function(){return G.completedProjects&&G.completedProjects.includes('p_chain_user_stories');},effect:function(){G.trustBoost=(G.trustBoost||1)+0.5;}},
{id:'p_chain_code_review',name:'Code Review Cycle',desc:'Review and refactor. Repeatable.',cost:{ops:200},category:'quality',phase:1,repeatable:true,edu:'Code review catches 60% of bugs.',trigger:function(){return G.totalCode>=500;},effect:function(){G.codeBoost+=0.05;}},
{id:'p_chain_security_audit',name:'Security Audit',desc:'Scan for vulnerabilities. Repeatable.',cost:{ops:500,trust:1},category:'security',phase:2,repeatable:true,edu:'OWASP Top 10: the usual suspects.',trigger:function(){return G.totalCode>=1000;},effect:function(){G.trustBoost=(G.trustBoost||1)+0.1;}},
{id:'p_chain_performance_tuning',name:'Performance Tuning',desc:'Profile hot paths. Repeatable.',cost:{compute:200,ops:300},category:'performance',phase:2,repeatable:true,edu:'80/20 rule: 80% of time in 20% of code.',trigger:function(){return G.totalCompute>=500;},effect:function(){G.computeBoost+=0.08;}}
];
function initProjectChain() {
for (var i = 0; i < CHAIN_PROJECTS.length; i++) ProjectChain.register(CHAIN_PROJECTS[i]);
for (var j = 0; j < CHAIN_PROJECTS.length; j++) {
var found = PDEFS.find(function(p){return p.id===CHAIN_PROJECTS[j].id;});
if (!found) PDEFS.push(CHAIN_PROJECTS[j]);
}
}

View File

@@ -321,19 +321,21 @@ 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 * 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 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 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;
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;
G.code += gc; G.compute += cc; G.knowledge += kc;
G.users += uc; G.impact += ic;
@@ -344,6 +346,9 @@ 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' });

View File

@@ -1,20 +0,0 @@
const assert = require('assert');
global.G = {buildings:{},completedProjects:[],activeProjects:[],codeBoost:1,computeBoost:1,knowledgeBoost:1,userBoost:1,impactBoost:1,opsBoost:1,trustBoost:1,totalCode:0,totalCompute:0,totalKnowledge:0,totalImpact:0,phase:1,ops:0,maxOps:1000,flags:{}};
global.log=function(){};global.showToast=function(){};global.canAffordProject=function(){return true;};global.spendProject=function(){};global.Sound={playProject:function(){}};
global.PDEFS = [];
require('vm').runInThisContext(require('fs').readFileSync(__dirname+'/../js/project_chain.js','utf8'));
console.log('=== Tests ===');
var tests = [
['Register',function(){ProjectChain.register({id:'t1',requires:['d1']});assert(ProjectChain._deps['t1']);}],
['canUnlock no deps',function(){assert(ProjectChain.canUnlock('x'));}],
['canUnlock unmet',function(){G.completedProjects=[];ProjectChain.register({id:'t2',requires:['d1']});assert(!ProjectChain.canUnlock('t2'));}],
['canUnlock met',function(){G.completedProjects=['d1'];assert(ProjectChain.canUnlock('t2'));}],
['formatCost',function(){assert.strictEqual(ProjectChain.formatCost(null),'Free');assert.strictEqual(ProjectChain.formatCost({ops:100}),'100 ops');}],
['Chain count',function(){assert(CHAIN_PROJECTS.length>=18);console.log(' '+CHAIN_PROJECTS.length+' projects');}],
['Required fields',function(){CHAIN_PROJECTS.forEach(function(p){assert(p.id&&p.name&&p.cost&&p.trigger&&p.effect);});}],
['Educational',function(){var n=CHAIN_PROJECTS.filter(function(p){return p.edu;}).length;assert(n>=15);console.log(' '+n+' have edu');}],
['Categories',function(){var c=new Set(CHAIN_PROJECTS.map(function(p){return p.category;}));assert(c.size>=5);console.log(' '+c.size+' categories');}],
['Repeatable',function(){var r=CHAIN_PROJECTS.filter(function(p){return p.repeatable;});assert(r.length>=3);console.log(' '+r.length+' repeatable');}]
];
tests.forEach(function(t){console.log('Test: '+t[0]);t[1]();console.log(' \u2713');});
console.log('\nAll passed.');

View File

@@ -0,0 +1,148 @@
#!/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!")