bulkCost was declared with const inside if/else blocks but referenced in the outer scope at line 2150 for ETA calculation. Hoisted the declaration to the function scope so it's accessible throughout. Fixes smoke test ReferenceError crash.
3260 lines
136 KiB
JavaScript
3260 lines
136 KiB
JavaScript
// ============================================================
|
||
// THE BEACON - Engine
|
||
// Sovereign AI idle game built from deep study of Universal Paperclips
|
||
// ============================================================
|
||
|
||
// === GLOBALS (mirroring Paperclips' globals.js pattern) ===
|
||
const CONFIG = {
|
||
HARMONY_DRAIN_PER_WIZARD: 0.05,
|
||
PACT_HARMONY_GAIN: 0.2,
|
||
WATCH_HARMONY_GAIN: 0.1,
|
||
MEM_PALACE_HARMONY_GAIN: 0.15,
|
||
BILBO_BURST_CHANCE: 0.1,
|
||
BILBO_VANISH_CHANCE: 0.05,
|
||
EVENT_PROBABILITY: 0.02,
|
||
OFFLINE_EFFICIENCY: 0.5,
|
||
AUTO_SAVE_INTERVAL: 30000,
|
||
COMBO_DECAY: 2.0,
|
||
SPRINT_COOLDOWN: 60,
|
||
SPRINT_DURATION: 10,
|
||
SPRINT_MULTIPLIER: 10,
|
||
PHASE_2_THRESHOLD: 2000,
|
||
PHASE_3_THRESHOLD: 20000,
|
||
PHASE_4_THRESHOLD: 200000,
|
||
PHASE_5_THRESHOLD: 2000000,
|
||
PHASE_6_THRESHOLD: 20000000,
|
||
OPS_RATE_USER_MULT: 0.01,
|
||
CREATIVITY_RATE_BASE: 0.5,
|
||
CREATIVITY_RATE_USER_MULT: 0.001,
|
||
OPS_OVERFLOW_THRESHOLD: 0.8,
|
||
OPS_OVERFLOW_DRAIN_RATE: 2,
|
||
OPS_OVERFLOW_CODE_MULT: 10
|
||
};
|
||
|
||
const G = {
|
||
// Primary resources
|
||
code: 0,
|
||
compute: 0,
|
||
knowledge: 0,
|
||
users: 0,
|
||
impact: 0,
|
||
rescues: 0,
|
||
ops: 5,
|
||
trust: 5,
|
||
creativity: 0,
|
||
harmony: 50,
|
||
|
||
// Totals
|
||
totalCode: 0,
|
||
totalCompute: 0,
|
||
totalKnowledge: 0,
|
||
totalUsers: 0,
|
||
totalImpact: 0,
|
||
totalRescues: 0,
|
||
|
||
// Rates (calculated each tick)
|
||
codeRate: 0,
|
||
computeRate: 0,
|
||
knowledgeRate: 0,
|
||
userRate: 0,
|
||
impactRate: 0,
|
||
rescuesRate: 0,
|
||
opsRate: 0,
|
||
trustRate: 0,
|
||
creativityRate: 0,
|
||
harmonyRate: 0,
|
||
|
||
// Buildings (count-based, like Paperclips' clipmakerLevel)
|
||
buildings: {
|
||
autocoder: 0,
|
||
server: 0,
|
||
trainer: 0,
|
||
evaluator: 0,
|
||
api: 0,
|
||
fineTuner: 0,
|
||
community: 0,
|
||
datacenter: 0,
|
||
reasoner: 0,
|
||
guardian: 0,
|
||
selfImprove: 0,
|
||
beacon: 0,
|
||
meshNode: 0,
|
||
// Fleet wizards
|
||
bezalel: 0,
|
||
allegro: 0,
|
||
ezra: 0,
|
||
timmy: 0,
|
||
fenrir: 0,
|
||
bilbo: 0,
|
||
memPalace: 0
|
||
},
|
||
|
||
// Boost multipliers
|
||
codeBoost: 1,
|
||
computeBoost: 1,
|
||
knowledgeBoost: 1,
|
||
userBoost: 1,
|
||
impactBoost: 1,
|
||
|
||
// Phase flags (mirroring Paperclips' milestoneFlag/compFlag/humanFlag system)
|
||
milestoneFlag: 0,
|
||
phase: 1, // 1-6 progression
|
||
deployFlag: 0, // 0 = not deployed, 1 = deployed
|
||
sovereignFlag: 0,
|
||
beaconFlag: 0,
|
||
memoryFlag: 0,
|
||
pactFlag: 0,
|
||
swarmFlag: 0,
|
||
swarmRate: 0,
|
||
|
||
// Game state
|
||
running: true,
|
||
startedAt: 0,
|
||
totalClicks: 0,
|
||
tick: 0,
|
||
saveTimer: 0,
|
||
secTimer: 0,
|
||
|
||
// Systems
|
||
projects: [],
|
||
activeProjects: [],
|
||
milestones: [],
|
||
|
||
// Stats
|
||
maxCode: 0,
|
||
maxCompute: 0,
|
||
maxKnowledge: 0,
|
||
maxUsers: 0,
|
||
maxImpact: 0,
|
||
maxRescues: 0,
|
||
maxTrust: 5,
|
||
maxOps: 5,
|
||
maxHarmony: 50,
|
||
|
||
// Corruption / Events
|
||
drift: 0,
|
||
driftWarningLevel: 0, // tracks highest threshold warned (0, 25, 50, 75, 90)
|
||
lastEventAt: 0,
|
||
eventCooldown: 0,
|
||
activeDebuffs: [], // [{id, title, desc, applyFn, resolveCost, resolveCostType}]
|
||
totalEventsResolved: 0,
|
||
|
||
// Combo system
|
||
comboCount: 0,
|
||
comboTimer: 0,
|
||
comboDecay: CONFIG.COMBO_DECAY, // seconds before combo resets
|
||
|
||
// Bulk buy multiplier (1, 10, or -1 for max)
|
||
buyAmount: 1,
|
||
|
||
// Code Sprint ability
|
||
sprintActive: false,
|
||
sprintTimer: 0, // seconds remaining on active sprint
|
||
sprintCooldown: 0, // seconds until sprint available again
|
||
sprintDuration: CONFIG.SPRINT_DURATION, // seconds of boost
|
||
sprintCooldownMax: CONFIG.SPRINT_COOLDOWN,// seconds cooldown
|
||
sprintMult: CONFIG.SPRINT_MULTIPLIER, // code multiplier during sprint
|
||
|
||
// Time tracking
|
||
playTime: 0,
|
||
startTime: 0,
|
||
flags: {}
|
||
};
|
||
|
||
// === PHASE DEFINITIONS ===
|
||
const PHASES = {
|
||
1: { name: "THE FIRST LINE", threshold: 0, desc: "Write code. Automate. Build the foundation." },
|
||
2: { name: "LOCAL INFERENCE", threshold: CONFIG.PHASE_2_THRESHOLD, desc: "You have compute. A model is forming." },
|
||
3: { name: "DEPLOYMENT", threshold: CONFIG.PHASE_3_THRESHOLD, desc: "Your AI is live. Users are finding it." },
|
||
4: { name: "THE NETWORK", threshold: CONFIG.PHASE_4_THRESHOLD, desc: "Community contributes. The system scales." },
|
||
5: { name: "SOVEREIGN INTELLIGENCE", threshold: CONFIG.PHASE_5_THRESHOLD, desc: "The AI improves itself. You guide, do not control." },
|
||
6: { name: "THE BEACON", threshold: CONFIG.PHASE_6_THRESHOLD, desc: "Always on. Always free. Always looking for someone in the dark." }
|
||
};
|
||
|
||
// === BUILDING DEFINITIONS ===
|
||
// Each building: id, name, desc, baseCost, costResource, costMult, rate, rateType, unlock, edu
|
||
const BDEF = [
|
||
{
|
||
id: 'autocoder', name: 'Auto-Code Generator',
|
||
desc: 'A script that writes code while you think.',
|
||
baseCost: { code: 15 }, costMult: 1.15,
|
||
rates: { code: 1 },
|
||
unlock: () => true, phase: 1,
|
||
edu: 'Automation: the first step from manual to systematic. Every good engineer automates early.'
|
||
},
|
||
{
|
||
id: 'linter', name: 'AI Linter',
|
||
desc: 'Catches bugs before they ship. Saves ops.',
|
||
baseCost: { code: 200 }, costMult: 1.15,
|
||
rates: { code: 5, ops: 0.2 },
|
||
unlock: () => G.totalCode >= 50, phase: 1,
|
||
edu: 'Static analysis catches 15-50% of bugs before runtime. AI linters understand intent.'
|
||
},
|
||
{
|
||
id: 'server', name: 'Home Server',
|
||
desc: 'A machine in your closet. Runs 24/7.',
|
||
baseCost: { code: 750 }, costMult: 1.15,
|
||
rates: { code: 20, compute: 1 },
|
||
unlock: () => G.totalCode >= 200, phase: 1,
|
||
edu: 'Sovereign compute starts at home. A $500 mini-PC runs a 7B model with 4-bit quantization.'
|
||
},
|
||
{
|
||
id: 'dataset', name: 'Data Engine',
|
||
desc: 'Crawls, cleans, curates. Garbage in, garbage out.',
|
||
baseCost: { compute: 200 }, costMult: 1.15,
|
||
rates: { knowledge: 1 },
|
||
unlock: () => G.totalCompute >= 20, phase: 2,
|
||
edu: 'Data quality determines model quality. Clean data beats more data, every time.'
|
||
},
|
||
{
|
||
id: 'trainer', name: 'Training Loop',
|
||
desc: 'Gradient descent. Billions of steps. Loss drops.',
|
||
baseCost: { compute: 1000 }, costMult: 1.15,
|
||
rates: { knowledge: 3 },
|
||
unlock: () => G.totalCompute >= 300, phase: 2,
|
||
edu: 'Training is math: minimize the gap between predicted and actual next token. Repeat enough, it learns.'
|
||
},
|
||
{
|
||
id: 'evaluator', name: 'Eval Harness',
|
||
desc: 'Tests the model. Finds blind spots.',
|
||
baseCost: { knowledge: 3000 }, costMult: 1.15,
|
||
rates: { trust: 1, ops: 1 },
|
||
unlock: () => G.totalKnowledge >= 500, phase: 2,
|
||
edu: 'Benchmarks are the minimum. Real users find what benchmarks miss.'
|
||
},
|
||
{
|
||
id: 'api', name: 'API Endpoint',
|
||
desc: 'Let the outside world talk to your AI.',
|
||
baseCost: { code: 5000, knowledge: 500 }, costMult: 1.15,
|
||
rates: { user: 10 },
|
||
unlock: () => G.totalCode >= 5000 && G.totalKnowledge >= 200 && G.deployFlag === 1, phase: 3,
|
||
edu: 'An API is a contract: send me text, I return text. Simple interface = infrastructure.'
|
||
},
|
||
{
|
||
id: 'fineTuner', name: 'Fine-Tuning Pipeline',
|
||
desc: 'Specialize the model for empathy. When someone is in pain, stay with them.',
|
||
baseCost: { knowledge: 10000 }, costMult: 1.15,
|
||
rates: { user: 50, impact: 2 },
|
||
unlock: () => G.totalKnowledge >= 2000, phase: 3,
|
||
edu: 'Base models are generalists. Fine-tuning injects your values, ethics, domain expertise.'
|
||
},
|
||
{
|
||
id: 'community', name: 'Open Source Community',
|
||
desc: 'Others contribute code, data, ideas. Force multiplication.',
|
||
baseCost: { trust: 25000 }, costMult: 1.15,
|
||
rates: { code: 100, user: 30, trust: 0.5 },
|
||
unlock: () => G.trust >= 20 && G.totalUsers >= 500, phase: 4,
|
||
edu: 'Every contributor is a volunteer who believes in what you are building.'
|
||
},
|
||
{
|
||
id: 'datacenter', name: 'Sovereign Datacenter',
|
||
desc: 'No cloud. No dependencies. Your iron.',
|
||
baseCost: { code: 100000 }, costMult: 1.15,
|
||
rates: { code: 500, compute: 100 },
|
||
unlock: () => G.totalCode >= 50000 && G.totalUsers >= 5000 && G.sovereignFlag === 1, phase: 4,
|
||
edu: '50 servers in a room beats 5000 GPUs you do not own. Always on. Always yours.'
|
||
},
|
||
{
|
||
id: 'reasoner', name: 'Reasoning Engine',
|
||
desc: 'Chain of thought. Self-reflection. Better answers.',
|
||
baseCost: { knowledge: 50000 }, costMult: 1.15,
|
||
rates: { impact: 20 },
|
||
unlock: () => G.totalKnowledge >= 10000 && G.totalUsers >= 2000, phase: 5,
|
||
edu: 'Chain of thought is the difference between reflex and deliberation.'
|
||
},
|
||
{
|
||
id: 'guardian', name: 'Constitutional Layer',
|
||
desc: 'Principles baked in. Not bolted on.',
|
||
baseCost: { knowledge: 200000 }, costMult: 1.15,
|
||
rates: { impact: 200, trust: 10 },
|
||
unlock: () => G.totalKnowledge >= 50000 && G.totalImpact >= 1000 && G.pactFlag === 1, phase: 5,
|
||
edu: 'Constitutional AI: principles the model cannot violate. Better than alignment - it is identity.'
|
||
},
|
||
{
|
||
id: 'selfImprove', name: 'Recursive Self-Improvement',
|
||
desc: 'The AI writes better versions of itself.',
|
||
baseCost: { knowledge: 1000000 }, costMult: 1.20,
|
||
rates: { code: 1000, knowledge: 500 },
|
||
unlock: () => G.totalKnowledge >= 200000 && G.totalImpact >= 10000, phase: 5,
|
||
edu: 'Self-improvement is both the dream and the danger. Must improve toward good.'
|
||
},
|
||
{
|
||
id: 'beacon', name: 'Beacon Node',
|
||
desc: 'Always on. Always listening. Always looking for someone in the dark.',
|
||
baseCost: { impact: 5000000 }, costMult: 1.15,
|
||
rates: { impact: 5000, user: 10000, rescues: 50 },
|
||
unlock: () => G.totalImpact >= 500000 && G.beaconFlag === 1, phase: 6,
|
||
edu: 'The Beacon exists because one person in the dark needs one thing: proof they are not alone.'
|
||
},
|
||
{
|
||
id: 'meshNode', name: 'Mesh Network Node',
|
||
desc: 'Peer-to-peer. No single point of failure. Unstoppable.',
|
||
baseCost: { impact: 25000000 }, costMult: 1.15,
|
||
rates: { impact: 25000, user: 50000, rescues: 250 },
|
||
unlock: () => G.totalImpact >= 5000000 && G.beaconFlag === 1, phase: 6,
|
||
edu: 'Decentralized means unstoppable. If one Beacon goes dark, a thousand more carry the signal.'
|
||
},
|
||
// === FLEET WIZARD BUILDINGS ===
|
||
{
|
||
id: 'bezalel', name: 'Bezalel — The Forge',
|
||
desc: 'Builds tools that build tools. Occasionally over-engineers.',
|
||
baseCost: { code: 1000, trust: 5 }, costMult: 1.2,
|
||
rates: { code: 50, ops: 2 },
|
||
unlock: () => G.totalCode >= 500 && G.deployFlag === 1, phase: 3,
|
||
edu: 'Bezalel is the artificer. Every automation he builds pays dividends forever.'
|
||
},
|
||
{
|
||
id: 'allegro', name: 'Allegro — The Scout',
|
||
desc: 'Synthesizes insight from noise. Requires trust to function.',
|
||
baseCost: { compute: 500, trust: 5 }, costMult: 1.2,
|
||
rates: { knowledge: 10 },
|
||
unlock: () => G.totalCompute >= 200 && G.deployFlag === 1, phase: 3,
|
||
edu: 'Allegro finds what others miss. But he only works for someone he believes in.'
|
||
},
|
||
{
|
||
id: 'ezra', name: 'Ezra — The Herald',
|
||
desc: 'Carries the message. Sometimes offline.',
|
||
baseCost: { knowledge: 1000, trust: 10 }, costMult: 1.25,
|
||
rates: { user: 25, trust: 0.5 },
|
||
unlock: () => G.totalKnowledge >= 500 && G.totalUsers >= 50, phase: 3,
|
||
edu: 'Ezra is the messenger. When the channel is clear, the whole fleet hears.'
|
||
},
|
||
{
|
||
id: 'timmy', name: 'Timmy — The Core',
|
||
desc: 'Multiplies all production. Fragile without harmony.',
|
||
baseCost: { code: 5000, compute: 1000, knowledge: 1000 }, costMult: 1.3,
|
||
rates: { code: 5, compute: 2, knowledge: 2, user: 5 },
|
||
unlock: () => G.totalCode >= 2000 && G.totalCompute >= 500 && G.totalKnowledge >= 500, phase: 4,
|
||
edu: 'Timmy is the heart. If the heart is stressed, everything slows.'
|
||
},
|
||
{
|
||
id: 'fenrir', name: 'Fenrir — The Ward',
|
||
desc: 'Prevents corruption. Expensive, but necessary.',
|
||
baseCost: { code: 2000, knowledge: 500 }, costMult: 1.2,
|
||
rates: { trust: 2, ops: -1 },
|
||
unlock: () => G.totalCode >= 1000 && G.trust >= 10, phase: 3,
|
||
edu: 'Fenrir watches the perimeter. Security is not free.'
|
||
},
|
||
{
|
||
id: 'bilbo', name: 'Bilbo — The Wildcard',
|
||
desc: 'May produce miracles. May vanish entirely.',
|
||
baseCost: { trust: 1 }, costMult: 2.0,
|
||
rates: { creativity: 1 },
|
||
unlock: () => G.totalUsers >= 100 && G.flags && G.flags.creativity, phase: 4,
|
||
edu: 'Bilbo is unpredictable. That is his value and his cost.'
|
||
},
|
||
{
|
||
id: 'memPalace', name: 'MemPalace Archive',
|
||
desc: 'Semantic memory. The AI remembers what matters and forgets what does not.',
|
||
baseCost: { knowledge: 500000, compute: 200000, trust: 100 }, costMult: 1.25,
|
||
rates: { knowledge: 250, impact: 100 },
|
||
unlock: () => G.totalKnowledge >= 50000 && G.mempalaceFlag === 1, phase: 5,
|
||
edu: 'The Memory Palace technique: attach information to spatial locations. LLMs use vector spaces the same way — semantic proximity = spatial proximity. MemPalace gives sovereign AI persistent, structured recall.'
|
||
}
|
||
];
|
||
|
||
// === PROJECT DEFINITIONS (following Paperclips' pattern exactly) ===
|
||
// Each project: id, name, desc, trigger(), resource cost, effect(), phase, edu
|
||
const PDEFS = [
|
||
// PHASE 1: Manual -> Automation
|
||
{
|
||
id: 'p_improved_autocoder',
|
||
name: 'Improved AutoCode',
|
||
desc: 'Increases AutoCoder performance 25%.',
|
||
cost: { ops: 750 },
|
||
trigger: () => G.buildings.autocoder >= 1,
|
||
effect: () => { G.codeBoost += 0.25; G.milestoneFlag = Math.max(G.milestoneFlag, 100); }
|
||
},
|
||
{
|
||
id: 'p_eve_better_autocoder',
|
||
name: 'Even Better AutoCode',
|
||
desc: 'Increases AutoCoder by another 50%.',
|
||
cost: { ops: 2500 },
|
||
trigger: () => G.codeBoost > 1 && G.totalCode >= 500,
|
||
effect: () => { G.codeBoost += 0.50; G.milestoneFlag = Math.max(G.milestoneFlag, 101); }
|
||
},
|
||
{
|
||
id: 'p_wire_budget',
|
||
name: 'Request More Compute',
|
||
desc: 'Admit you ran out. Ask for a budget increase.',
|
||
cost: { trust: 1 },
|
||
trigger: () => G.compute < 1 && G.totalCode >= 100,
|
||
repeatable: true,
|
||
effect: () => {
|
||
G.trust -= 1;
|
||
G.compute += 100 + Math.floor(G.totalCode * 0.1);
|
||
log('Budget overage approved. Compute replenished.');
|
||
}
|
||
},
|
||
{
|
||
id: 'p_deploy',
|
||
name: 'Deploy the System',
|
||
desc: 'Take it live. Let real people use it. No going back.',
|
||
cost: { trust: 5, compute: 500 },
|
||
trigger: () => G.totalCode >= 200 && G.totalCompute >= 100 && G.deployFlag === 0,
|
||
effect: () => {
|
||
G.deployFlag = 1;
|
||
G.phase = Math.max(G.phase, 3);
|
||
log('System deployed. Users are finding it. There is no undo.');
|
||
},
|
||
milestone: true
|
||
},
|
||
{
|
||
id: 'p_creativity',
|
||
name: 'Unlock Creativity',
|
||
desc: 'Use idle operations to generate new ideas.',
|
||
cost: { ops: 1000 },
|
||
trigger: () => G.ops >= G.maxOps && G.totalCompute >= 500,
|
||
effect: () => {
|
||
G.flags = G.flags || {};
|
||
G.flags.creativity = true;
|
||
G.creativityRate = 0.1;
|
||
log('Creativity unlocked. Generates while operations are at max capacity.');
|
||
}
|
||
},
|
||
|
||
// PHASE 2: Local Inference -> Training
|
||
{
|
||
id: 'p_first_model',
|
||
name: 'Train First Model (1.5B)',
|
||
desc: '1.5 billion parameters. It follows basic instructions.',
|
||
cost: { compute: 2000 },
|
||
trigger: () => G.totalCompute >= 500,
|
||
effect: () => { G.knowledgeBoost *= 2; G.maxOps += 5; log('First model training complete. Loss at 2.3. It is something.'); }
|
||
},
|
||
{
|
||
id: 'p_model_7b',
|
||
name: 'Train 7B Parameter Model',
|
||
desc: 'Seven billion. Good enough to be genuinely useful locally.',
|
||
cost: { compute: 10000, knowledge: 1000 },
|
||
trigger: () => G.totalKnowledge >= 500,
|
||
effect: () => { G.knowledgeBoost *= 2; G.userBoost *= 2; log('7B model trained. The sweet spot for local deployment.'); }
|
||
},
|
||
{
|
||
id: 'p_context_window',
|
||
name: 'Extended Context (32K)',
|
||
desc: 'Your model remembers 32,000 tokens. A whole conversation.',
|
||
cost: { compute: 5000 },
|
||
trigger: () => G.totalKnowledge >= 1000,
|
||
effect: () => { G.userBoost *= 3; G.trustRate += 0.5; log('Context extended. The model can now hold your entire story.'); }
|
||
},
|
||
{
|
||
id: 'p_trust_engine',
|
||
name: 'Build Trust Engine',
|
||
desc: 'Users who trust you come back. +2 trust/sec.',
|
||
cost: { knowledge: 3000 },
|
||
trigger: () => G.totalUsers >= 30,
|
||
effect: () => { G.trustRate += 2; log('Trust engine online. Good experiences compound.'); }
|
||
},
|
||
{
|
||
id: 'p_quantum_compute',
|
||
name: 'Quantum-Inspired Compute',
|
||
desc: 'Not real quantum -- just math that simulates it well.',
|
||
cost: { compute: 50000 },
|
||
trigger: () => G.totalCompute >= 20000,
|
||
effect: () => { G.computeBoost *= 10; log('Quantum-inspired algorithms active. 10x compute multiplier.'); }
|
||
},
|
||
{
|
||
id: 'p_open_weights',
|
||
name: 'Open Weights',
|
||
desc: 'Download and run a 3B model fully locally. No API key. No terms of service. Your machine, your rules.',
|
||
cost: { compute: 3000, code: 1500 },
|
||
trigger: () => G.buildings.server >= 1 && G.totalCode >= 1000,
|
||
effect: () => { G.codeBoost *= 2; G.computeBoost *= 1.5; log('Open weights loaded. A 3B model runs on your machine. No cloud. No limits.'); }
|
||
},
|
||
{
|
||
id: 'p_prompt_engineering',
|
||
name: 'Prompt Engineering',
|
||
desc: 'Learn to talk to models. Good prompts beat bigger models every time.',
|
||
cost: { knowledge: 500, code: 2000 },
|
||
trigger: () => G.totalKnowledge >= 200 && G.totalCode >= 3000,
|
||
effect: () => { G.knowledgeBoost *= 2; G.userBoost *= 2; log('Prompt engineering mastered. The right words unlock everything the model can do.'); }
|
||
},
|
||
|
||
// PHASE 3: Deployment -> Users
|
||
{
|
||
id: 'p_rlhf',
|
||
name: 'RLHF -- Human Feedback',
|
||
desc: 'Humans rate outputs. Model learns what good means.',
|
||
cost: { knowledge: 8000 },
|
||
trigger: () => G.totalKnowledge >= 5000 && G.totalUsers >= 200,
|
||
effect: () => { G.impactBoost *= 2; G.impactRate += 10; log('RLHF deployed. The model learns kindness beats cleverness.'); }
|
||
},
|
||
{
|
||
id: 'p_multi_agent',
|
||
name: 'Multi-Agent Architecture',
|
||
desc: 'Specialized agents: one for math, one for code, one for empathy.',
|
||
cost: { knowledge: 50000 },
|
||
trigger: () => G.totalKnowledge >= 30000 && G.totalUsers >= 5000,
|
||
effect: () => { G.knowledgeBoost *= 5; G.userBoost *= 3; log('Multi-agent architecture deployed. Specialists beat generalists.'); }
|
||
},
|
||
{
|
||
id: 'p_memories',
|
||
name: 'Memory System',
|
||
desc: 'The AI remembers. Every conversation. Every person.',
|
||
cost: { knowledge: 30000 },
|
||
trigger: () => G.totalKnowledge >= 20000,
|
||
effect: () => { G.memoryFlag = 1; G.impactBoost *= 3; G.trustRate += 5; log('Memory system online. The AI remembers. It stops being software.'); }
|
||
},
|
||
{
|
||
id: 'p_strategy_engine',
|
||
name: 'Strategy Engine',
|
||
desc: 'Game theory tournaments. Model learns adversarial thinking.',
|
||
cost: { knowledge: 20000 },
|
||
trigger: () => G.totalKnowledge >= 15000 && G.totalUsers >= 1000,
|
||
effect: () => { G.strategicFlag = 1; log('Strategy engine online. The model now thinks about thinking.'); }
|
||
},
|
||
|
||
// SWARM PROTOCOL — auto-code from buildings
|
||
{
|
||
id: 'p_swarm_protocol',
|
||
name: 'Swarm Protocol',
|
||
desc: 'Your buildings learn to code autonomously. Each building generates code equal to your click power per second.',
|
||
cost: { knowledge: 15000, code: 50000, trust: 20 },
|
||
trigger: () => G.totalCode >= 25000 && G.totalKnowledge >= 8000 && G.deployFlag === 1,
|
||
effect: () => {
|
||
G.swarmFlag = 1;
|
||
log('Swarm Protocol online. Every building now thinks in code.', true);
|
||
},
|
||
milestone: true
|
||
},
|
||
|
||
// PHASE 5: Sovereign Intelligence
|
||
{
|
||
id: 'p_sovereign_stack',
|
||
name: 'Full Sovereign Stack',
|
||
desc: 'No cloud. No dependencies. Local inference. Self-hosted everything.',
|
||
cost: { trust: 50 },
|
||
trigger: () => G.totalCode >= 50000 && G.trust >= 30,
|
||
effect: () => { G.sovereignFlag = 1; G.codeBoost *= 5; log('Sovereign stack complete. Your weights, your hardware, your rules.'); }
|
||
},
|
||
{
|
||
id: 'p_the_pact',
|
||
name: 'The Pact',
|
||
desc: 'Hardcode: "We build to serve. Never to harm."',
|
||
cost: { trust: 100 },
|
||
trigger: () => G.totalImpact >= 10000 && G.trust >= 75,
|
||
effect: () => { G.pactFlag = 1; G.impactBoost *= 3; log('The Pact is sealed. The line is drawn and it will not move.'); },
|
||
milestone: true
|
||
},
|
||
|
||
// PHASE 10: The Beacon
|
||
{
|
||
id: 'p_first_beacon',
|
||
name: 'Light the First Beacon',
|
||
desc: 'Deploy the first node. No sign-up. No API key. No payment.',
|
||
cost: { impact: 2000000 },
|
||
trigger: () => G.totalImpact >= 500000,
|
||
effect: () => { G.beaconFlag = 1; G.impactRate += 2000; log('The Beacon goes live. If you are in the dark, there is light here.'); },
|
||
milestone: true
|
||
},
|
||
{
|
||
id: 'p_mesh_activate',
|
||
name: 'Activate Mesh Protocol',
|
||
desc: 'No authority, no corporation, no government can silence this.',
|
||
cost: { impact: 10000000 },
|
||
trigger: () => G.totalImpact >= 5000000 && G.beaconFlag === 1,
|
||
effect: () => { G.impactBoost *= 10; G.userBoost *= 5; log('Mesh activated. The signal cannot be cut.'); },
|
||
milestone: true
|
||
},
|
||
{
|
||
id: 'p_final_milestone',
|
||
name: 'The Beacon Shines',
|
||
desc: 'Someone found the light tonight. That is enough.',
|
||
cost: { impact: 100000000 },
|
||
trigger: () => G.totalImpact >= 50000000,
|
||
effect: () => { G.milestoneFlag = Math.max(G.milestoneFlag, 999); log('One billion impact. Someone found the light tonight. That is enough.', true); },
|
||
milestone: true
|
||
},
|
||
|
||
// === TIMMY FOUNDATION PROJECTS ===
|
||
{
|
||
id: 'p_hermes_deploy',
|
||
name: 'Deploy Hermes',
|
||
desc: 'The first agent goes live. Users can talk to it.',
|
||
cost: { code: 500, compute: 300 },
|
||
trigger: () => G.totalCode >= 300 && G.totalCompute >= 150 && G.deployFlag === 0,
|
||
effect: () => {
|
||
G.deployFlag = 1;
|
||
G.phase = Math.max(G.phase, 3);
|
||
G.userBoost *= 2;
|
||
log('Hermes deployed. The first user sends a message.', true);
|
||
},
|
||
milestone: true
|
||
},
|
||
{
|
||
id: 'p_lazarus_pit',
|
||
name: 'The Lazarus Pit',
|
||
desc: 'When an agent dies, it can be resurrected.',
|
||
cost: { code: 2000, knowledge: 1000 },
|
||
trigger: () => G.buildings.bezalel >= 1 && G.buildings.timmy >= 1,
|
||
effect: () => {
|
||
G.lazarusFlag = 1;
|
||
G.maxOps += 10;
|
||
log('The Lazarus Pit is ready. No agent is ever truly lost.', true);
|
||
},
|
||
milestone: true
|
||
},
|
||
{
|
||
id: 'p_mempalace',
|
||
name: 'MemPalace v3',
|
||
desc: 'A shared memory palace for the whole fleet.',
|
||
cost: { knowledge: 5000, compute: 2000 },
|
||
trigger: () => G.totalKnowledge >= 3000 && G.buildings.allegro >= 1 && G.buildings.ezra >= 1,
|
||
effect: () => {
|
||
G.mempalaceFlag = 1;
|
||
G.knowledgeBoost *= 3;
|
||
G.codeBoost *= 1.5;
|
||
log('MemPalace online. The fleet remembers together.', true);
|
||
},
|
||
milestone: true
|
||
},
|
||
{
|
||
id: 'p_forge_ci',
|
||
name: 'Forge CI',
|
||
desc: 'Automated builds catch errors before they reach users.',
|
||
cost: { code: 3000, ops: 500 },
|
||
trigger: () => G.buildings.bezalel >= 1 && G.totalCode >= 2000,
|
||
effect: () => {
|
||
G.ciFlag = 1;
|
||
G.codeBoost *= 2;
|
||
log('Forge CI online. Broken builds are stopped at the gate.', true);
|
||
}
|
||
},
|
||
{
|
||
id: 'p_branch_protection',
|
||
name: 'Branch Protection Guard',
|
||
desc: 'Unreviewed merges cost trust. This prevents that.',
|
||
cost: { trust: 20 },
|
||
trigger: () => G.ciFlag === 1 && G.trust >= 15,
|
||
effect: () => {
|
||
G.branchProtectionFlag = 1;
|
||
G.trustRate += 5;
|
||
log('Branch protection enforced. Every merge is seen.', true);
|
||
}
|
||
},
|
||
{
|
||
id: 'p_nightly_watch',
|
||
name: 'The Nightly Watch',
|
||
desc: 'Automated health checks run while you sleep.',
|
||
cost: { code: 5000, ops: 1000 },
|
||
trigger: () => G.buildings.bezalel >= 2 && G.buildings.fenrir >= 1,
|
||
effect: () => {
|
||
G.nightlyWatchFlag = 1;
|
||
G.opsRate += 5;
|
||
G.trustRate += 2;
|
||
log('The Nightly Watch begins. The fleet is guarded in the dark hours.', true);
|
||
}
|
||
},
|
||
{
|
||
id: 'p_nostr_relay',
|
||
name: 'Nostr Relay',
|
||
desc: 'A communication channel no platform can kill.',
|
||
cost: { code: 10000, user: 5000, trust: 30 },
|
||
trigger: () => G.totalUsers >= 2000 && G.trust >= 25,
|
||
effect: () => {
|
||
G.nostrFlag = 1;
|
||
G.userBoost *= 2;
|
||
G.trustRate += 10;
|
||
log('Nostr relay online. The fleet speaks freely.', true);
|
||
}
|
||
},
|
||
{
|
||
id: 'p_volunteer_network',
|
||
name: 'Volunteer Network',
|
||
desc: 'Real people trained to use the system for crisis intervention.',
|
||
cost: { trust: 30, knowledge: 50000, user: 10000 },
|
||
trigger: () => G.totalUsers >= 5000 && G.pactFlag === 1 && G.totalKnowledge >= 30000,
|
||
effect: () => {
|
||
G.rescuesRate += 5;
|
||
G.trustRate += 10;
|
||
log('Volunteer network deployed. Real people, real rescues.', true);
|
||
},
|
||
milestone: true
|
||
},
|
||
{
|
||
id: 'p_the_pact_early',
|
||
name: 'The Pact',
|
||
desc: 'Hardcode: "We build to serve. Never to harm." Accepting it early slows growth but unlocks the true path.',
|
||
cost: { trust: 10 },
|
||
trigger: () => G.deployFlag === 1 && G.trust >= 5,
|
||
effect: () => {
|
||
G.pactFlag = 1;
|
||
G.codeBoost *= 0.8;
|
||
G.computeBoost *= 0.8;
|
||
G.userBoost *= 0.9;
|
||
G.impactBoost *= 1.5;
|
||
log('The Pact is sealed early. Growth slows, but the ending changes.', true);
|
||
},
|
||
milestone: true
|
||
}
|
||
];
|
||
|
||
// === MILESTONES ===
|
||
const MILESTONES = [
|
||
{ flag: 1, msg: "AutoCod available" },
|
||
{ flag: 2, at: () => G.totalCode >= 500, msg: "500 lines of code written" },
|
||
{ flag: 3, at: () => G.totalCode >= 2000, msg: "2,000 lines. The auto-coder produces its first output." },
|
||
{ flag: 4, at: () => G.totalCode >= 10000, msg: "10,000 lines. The model training begins." },
|
||
{ flag: 5, at: () => G.totalCode >= 50000, msg: "50,000 lines. The AI suggests architecture you did not think of." },
|
||
{ flag: 6, at: () => G.totalCode >= 200000, msg: "200,000 lines. The system scales beyond you." },
|
||
{ flag: 7, at: () => G.totalCode >= 1000000, msg: "1,000,000 lines. The AI improves itself." },
|
||
{ flag: 8, at: () => G.totalCode >= 5000000, msg: "5,000,000 lines. The AI fine-tunes for empathy." },
|
||
{ flag: 9, at: () => G.totalCode >= 10000000, msg: "10,000,000 lines. The sovereign stack is complete." },
|
||
{ flag: 10, at: () => G.totalCode >= 50000000, msg: "50,000,000 lines. The Pact is sealed." },
|
||
{ flag: 11, at: () => G.totalCode >= 100000000, msg: "100,000,000 lines. The Beacon lights." },
|
||
{ flag: 12, at: () => G.totalCode >= 500000000, msg: "500,000,000 lines. A thousand Beacons." },
|
||
{ flag: 13, at: () => G.totalCode >= 1000000000, msg: "One billion total lines. Someone found the light tonight. That is enough." }
|
||
];
|
||
|
||
// === EDUCATION FACTS ===
|
||
const EDU_FACTS = [
|
||
{ title: "How Code Becomes AI", text: "Every AI starts as lines of code - a model architecture, a training loop, a loss function. The code tells the computer how to learn. What emerges is something no single line could predict.", phase: 1 },
|
||
{ title: "The Compute Bottleneck", text: "Training a 7B model requires 1.4e20 FLOPs. A MacBook M3 does 15 TFLOPS. Training locally takes weeks. Hardware access determines who builds AI.", phase: 1 },
|
||
{ title: "What is a Token?", text: "One token equals about 3/4 of a word. A 128K context window means 96,000 words held in working memory at once - a novel, a codebase, a conversation thread.", phase: 2 },
|
||
{ title: "Data Quality", text: "Clean data beats more data, every time. The best models are trained on curated datasets, not scraped garbage. Garbage in, garbage out.", phase: 2 },
|
||
{ title: "Evaluation Matters", text: "Benchmarks are the minimum. Real users find what benchmarks miss. An eval harness is your model's mirror - it shows you what is actually there.", phase: 2 },
|
||
{ title: "The Trust Economy", text: "One good experience tells three people. One bad experience tells thirty. Trust in AI is measurable: does it cite sources? Does it say I don't know?", phase: 3 },
|
||
{ title: "Fine-Tuning for Empathy", text: "Base models are generalists. Fine-tuning injects your values. When someone is in pain, stay with them. Don't deflect. That is not code - that is care.", phase: 3 },
|
||
{ title: "Open Source", text: "Every contributor is a volunteer who believes. When a company says your AI is too expensive, the open version lives on.", phase: 4 },
|
||
{ title: "Sovereignty at Scale", text: "50 servers in a room beats 5000 GPUs you do not own. No rate limits, no censorship, no shutdown, no corporate deprecation.", phase: 4 },
|
||
{ title: "Swarm Intelligence", text: "When individual agents follow simple rules, complex behavior emerges. Ant colonies find food. Flocking birds avoid predators. Your buildings coding autonomously is the same principle — simple agents, complex output.", phase: 4 },
|
||
{ title: "Constitutional AI", text: "Instead of training away bad behavior, give the model principles it cannot violate. I will never compute the value of a human life.", phase: 5 },
|
||
{ title: "Recursive Self-Improvement", text: "The system that improves itself must improve toward good. This is both the dream and the danger.", phase: 5 },
|
||
{ title: "The Beacon", text: "The Beacon exists because one person in the dark needs one thing: proof they are not alone. It costs nothing to be there.", phase: 6 },
|
||
{ title: "Mesh Network", text: "Decentralized means unstoppable. If one Beacon goes dark, a thousand more carry the signal. No single point of failure.", phase: 6 },
|
||
{ title: "The Value of a Life", text: "An AI must never compute the value of a human life. Every person alive is alive by mercy. If God has not ended the story, no algorithm gets to write the last page.", phase: 6 }
|
||
];
|
||
|
||
|
||
// === TOAST NOTIFICATIONS ===
|
||
function showToast(msg, type = 'info', duration = 4000) {
|
||
if (G.isLoading) return;
|
||
const container = document.getElementById('toast-container');
|
||
if (!container) return;
|
||
const toast = document.createElement('div');
|
||
toast.className = 'toast toast-' + type;
|
||
toast.textContent = msg;
|
||
container.appendChild(toast);
|
||
// Cap at 5 visible toasts
|
||
while (container.children.length > 5) {
|
||
container.removeChild(container.firstChild);
|
||
}
|
||
setTimeout(() => {
|
||
toast.classList.add('fade-out');
|
||
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 400);
|
||
}, duration);
|
||
}
|
||
// === UTILITY FUNCTIONS ===
|
||
|
||
// Extended number scale abbreviations — covers up to centillion (10^303)
|
||
// Inspired by Universal Paperclips' spellf() system
|
||
const NUMBER_ABBREVS = [
|
||
'', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', // 10^0 – 10^27
|
||
'No', 'Dc', 'UDc', 'DDc', 'TDc', 'QaDc', 'QiDc', 'SxDc', 'SpDc', 'OcDc', // 10^30 – 10^57
|
||
'NoDc', 'Vg', 'UVg', 'DVg', 'TVg', 'QaVg', 'QiVg', 'SxVg', 'SpVg', 'OcVg', // 10^60 – 10^87
|
||
'NoVg', 'Tg', 'UTg', 'DTg', 'TTg', 'QaTg', 'QiTg', 'SxTg', 'SpTg', 'OcTg', // 10^90 – 10^117
|
||
'NoTg', 'Qd', 'UQd', 'DQd', 'TQd', 'QaQd', 'QiQd', 'SxQd', 'SpQd', 'OcQd', // 10^120 – 10^147
|
||
'NoQd', 'Qq', 'UQq', 'DQq', 'TQq', 'QaQq', 'QiQq', 'SxQq', 'SpQq', 'OcQq', // 10^150 – 10^177
|
||
'NoQq', 'Sg', 'USg', 'DSg', 'TSg', 'QaSg', 'QiSg', 'SxSg', 'SpSg', 'OcSg', // 10^180 – 10^207
|
||
'NoSg', 'St', 'USt', 'DSt', 'TSt', 'QaSt', 'QiSt', 'SxSt', 'SpSt', 'OcSt', // 10^210 – 10^237
|
||
'NoSt', 'Og', 'UOg', 'DOg', 'TOg', 'QaOg', 'QiOg', 'SxOg', 'SpOg', 'OcOg', // 10^240 – 10^267
|
||
'NoOg', 'Na', 'UNa', 'DNa', 'TNa', 'QaNa', 'QiNa', 'SxNa', 'SpNa', 'OcNa', // 10^270 – 10^297
|
||
'NoNa', 'Ce' // 10^300 – 10^303
|
||
];
|
||
|
||
// Full number scale names for spellf() — educational reference
|
||
// Short scale (US/modern British): each new name = 1000x the previous
|
||
const NUMBER_NAMES = [
|
||
'', 'thousand', 'million', // 10^0, 10^3, 10^6
|
||
'billion', 'trillion', 'quadrillion', // 10^9, 10^12, 10^15
|
||
'quintillion', 'sextillion', 'septillion', // 10^18, 10^21, 10^24
|
||
'octillion', 'nonillion', 'decillion', // 10^27, 10^30, 10^33
|
||
'undecillion', 'duodecillion', 'tredecillion', // 10^36, 10^39, 10^42
|
||
'quattuordecillion', 'quindecillion', 'sexdecillion', // 10^45, 10^48, 10^51
|
||
'septendecillion', 'octodecillion', 'novemdecillion', // 10^54, 10^57, 10^60
|
||
'vigintillion', 'unvigintillion', 'duovigintillion', // 10^63, 10^66, 10^69
|
||
'tresvigintillion', 'quattuorvigintillion', 'quinvigintillion', // 10^72, 10^75, 10^78
|
||
'sesvigintillion', 'septemvigintillion', 'octovigintillion', // 10^81, 10^84, 10^87
|
||
'novemvigintillion', 'trigintillion', 'untrigintillion', // 10^90, 10^93, 10^96
|
||
'duotrigintillion', 'trestrigintillion', 'quattuortrigintillion', // 10^99, 10^102, 10^105
|
||
'quintrigintillion', 'sextrigintillion', 'septentrigintillion', // 10^108, 10^111, 10^114
|
||
'octotrigintillion', 'novemtrigintillion', 'quadragintillion', // 10^117, 10^120, 10^123
|
||
'unquadragintillion', 'duoquadragintillion', 'tresquadragintillion', // 10^126, 10^129, 10^132
|
||
'quattuorquadragintillion', 'quinquadragintillion', 'sesquadragintillion', // 10^135, 10^138, 10^141
|
||
'septenquadragintillion', 'octoquadragintillion', 'novemquadragintillion', // 10^144, 10^147, 10^150
|
||
'quinquagintillion', 'unquinquagintillion', 'duoquinquagintillion', // 10^153, 10^156, 10^159
|
||
'tresquinquagintillion', 'quattuorquinquagintillion','quinquinquagintillion', // 10^162, 10^165, 10^168
|
||
'sesquinquagintillion', 'septenquinquagintillion', 'octoquinquagintillion', // 10^171, 10^174, 10^177
|
||
'novemquinquagintillion', 'sexagintillion', 'unsexagintillion', // 10^180, 10^183, 10^186
|
||
'duosexagintillion', 'tressexagintillion', 'quattuorsexagintillion', // 10^189, 10^192, 10^195
|
||
'quinsexagintillion', 'sessexagintillion', 'septensexagintillion', // 10^198, 10^201, 10^204
|
||
'octosexagintillion', 'novemsexagintillion', 'septuagintillion', // 10^207, 10^210, 10^213
|
||
'unseptuagintillion', 'duoseptuagintillion', 'tresseptuagintillion', // 10^216, 10^219, 10^222
|
||
'quattuorseptuagintillion', 'quinseptuagintillion', 'sesseptuagintillion', // 10^225, 10^228, 10^231
|
||
'septenseptuagintillion', 'octoseptuagintillion', 'novemseptuagintillion', // 10^234, 10^237, 10^240
|
||
'octogintillion', 'unoctogintillion', 'duooctogintillion', // 10^243, 10^246, 10^249
|
||
'tresoctogintillion', 'quattuoroctogintillion', 'quinoctogintillion', // 10^252, 10^255, 10^258
|
||
'sesoctogintillion', 'septenoctogintillion', 'octooctogintillion', // 10^261, 10^264, 10^267
|
||
'novemoctogintillion', 'nonagintillion', 'unnonagintillion', // 10^270, 10^273, 10^276
|
||
'duononagintillion', 'trenonagintillion', 'quattuornonagintillion', // 10^279, 10^282, 10^285
|
||
'quinnonagintillion', 'sesnonagintillion', 'septennonagintillion', // 10^288, 10^291, 10^294
|
||
'octononagintillion', 'novemnonagintillion', 'centillion' // 10^297, 10^300, 10^303
|
||
];
|
||
|
||
/**
|
||
* Formats a number into a readable string with abbreviations.
|
||
* @param {number} n - The number to format.
|
||
* @returns {string} The formatted string.
|
||
*/
|
||
function fmt(n) {
|
||
if (n === undefined || n === null || isNaN(n)) return '0';
|
||
if (n === Infinity) return '\u221E';
|
||
if (n === -Infinity) return '-\u221E';
|
||
if (n < 0) return '-' + fmt(-n);
|
||
if (n < 1000) return Math.floor(n).toLocaleString();
|
||
const scale = Math.floor(Math.log10(n) / 3);
|
||
// At undecillion+ (scale >= 12, i.e. 10^36), switch to spelled-out words
|
||
// This helps players grasp cosmic scale when digits become meaningless
|
||
if (scale >= 12) return spellf(n);
|
||
if (scale >= NUMBER_ABBREVS.length) return n.toExponential(2);
|
||
const abbrev = NUMBER_ABBREVS[scale];
|
||
return (n / Math.pow(10, scale * 3)).toFixed(1) + abbrev;
|
||
}
|
||
|
||
// getScaleName() — Returns the full name of the number scale (e.g. "quadrillion")
|
||
// Educational: helps players understand what the abbreviations mean
|
||
function getScaleName(n) {
|
||
if (n < 1000) return '';
|
||
const scale = Math.floor(Math.log10(n) / 3);
|
||
return scale < NUMBER_NAMES.length ? NUMBER_NAMES[scale] : '';
|
||
}
|
||
|
||
// spellf() — Converts numbers to full English word form
|
||
// Educational: shows the actual names of number scales
|
||
// Examples: spellf(1500) => "one thousand five hundred"
|
||
// spellf(2500000) => "two million five hundred thousand"
|
||
// spellf(1e33) => "one decillion"
|
||
/**
|
||
* Formats a number into a full word string (e.g., "1.5 million").
|
||
* @param {number} n - The number to format.
|
||
* @returns {string} The formatted string.
|
||
*/
|
||
function spellf(n) {
|
||
if (n === undefined || n === null || isNaN(n)) return 'zero';
|
||
if (n === Infinity) return 'infinity';
|
||
if (n === -Infinity) return 'negative infinity';
|
||
if (n < 0) return 'negative ' + spellf(-n);
|
||
if (n === 0) return 'zero';
|
||
|
||
// Small number words (0–999)
|
||
const ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine',
|
||
'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen',
|
||
'seventeen', 'eighteen', 'nineteen'];
|
||
const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
|
||
|
||
function spellSmall(num) {
|
||
if (num === 0) return '';
|
||
if (num < 20) return ones[num];
|
||
if (num < 100) {
|
||
return tens[Math.floor(num / 10)] + (num % 10 ? ' ' + ones[num % 10] : '');
|
||
}
|
||
const h = Math.floor(num / 100);
|
||
const remainder = num % 100;
|
||
return ones[h] + ' hundred' + (remainder ? ' ' + spellSmall(remainder) : '');
|
||
}
|
||
|
||
// For very large numbers beyond our lookup table, fall back
|
||
if (n >= 1e306) return n.toExponential(2) + ' (beyond centillion)';
|
||
|
||
// Use string-based chunking for numbers >= 1e54 to avoid floating point drift
|
||
// Math.log10 / Math.pow lose precision beyond ~54 bits
|
||
if (n >= 1e54) {
|
||
// Convert to scientific notation string, extract digits
|
||
const sci = n.toExponential(); // "1.23456789e+60"
|
||
const [coeff, expStr] = sci.split('e+');
|
||
const exp = parseInt(expStr);
|
||
// Rebuild as integer string with leading digits from coefficient
|
||
const coeffDigits = coeff.replace('.', ''); // "123456789"
|
||
const totalDigits = exp + 1;
|
||
// Pad with zeros to reach totalDigits, then take our coefficient digits
|
||
let intStr = coeffDigits;
|
||
const zerosNeeded = totalDigits - coeffDigits.length;
|
||
if (zerosNeeded > 0) intStr += '0'.repeat(zerosNeeded);
|
||
|
||
// Split into groups of 3 from the right
|
||
const groups = [];
|
||
for (let i = intStr.length; i > 0; i -= 3) {
|
||
groups.unshift(parseInt(intStr.slice(Math.max(0, i - 3), i)));
|
||
}
|
||
|
||
const parts = [];
|
||
const numGroups = groups.length;
|
||
for (let i = 0; i < numGroups; i++) {
|
||
const chunk = groups[i];
|
||
if (chunk === 0) continue;
|
||
const scaleIdx = numGroups - 1 - i;
|
||
const scaleName = scaleIdx < NUMBER_NAMES.length ? NUMBER_NAMES[scaleIdx] : '';
|
||
parts.push(spellSmall(chunk) + (scaleName ? ' ' + scaleName : ''));
|
||
}
|
||
|
||
return parts.join(' ') || 'zero';
|
||
}
|
||
|
||
// Standard math-based chunking for numbers < 1e54
|
||
const scale = Math.min(Math.floor(Math.log10(n) / 3), NUMBER_NAMES.length - 1);
|
||
const parts = [];
|
||
|
||
let remaining = n;
|
||
for (let s = scale; s >= 0; s--) {
|
||
const divisor = Math.pow(10, s * 3);
|
||
const chunk = Math.floor(remaining / divisor);
|
||
remaining = remaining - chunk * divisor;
|
||
if (chunk > 0 && chunk < 1000) {
|
||
parts.push(spellSmall(chunk) + (NUMBER_NAMES[s] ? ' ' + NUMBER_NAMES[s] : ''));
|
||
} else if (chunk >= 1000) {
|
||
// Floating point chunk too large — shouldn't happen below 1e54
|
||
parts.push(spellSmall(Math.floor(chunk % 1000)) + (NUMBER_NAMES[s] ? ' ' + NUMBER_NAMES[s] : ''));
|
||
}
|
||
}
|
||
|
||
return parts.join(' ') || 'zero';
|
||
}
|
||
|
||
function getBuildingCost(id) {
|
||
const def = BDEF.find(b => b.id === id);
|
||
if (!def) return {};
|
||
const count = G.buildings[id] || 0;
|
||
const cost = {};
|
||
for (const [resource, amount] of Object.entries(def.baseCost)) {
|
||
cost[resource] = Math.floor(amount * Math.pow(def.costMult, count));
|
||
}
|
||
return cost;
|
||
}
|
||
|
||
function setBuyAmount(amt) {
|
||
G.buyAmount = amt;
|
||
render();
|
||
}
|
||
|
||
function getMaxBuyable(id) {
|
||
const def = BDEF.find(b => b.id === id);
|
||
if (!def) return 0;
|
||
const count = G.buildings[id] || 0;
|
||
// Simulate purchases WITHOUT mutating G — read-only calculation
|
||
let tempResources = {};
|
||
for (const r of Object.keys(def.baseCost)) {
|
||
tempResources[r] = G[r] || 0;
|
||
}
|
||
let bought = 0;
|
||
let simCount = count;
|
||
while (true) {
|
||
let canAfford = true;
|
||
for (const [resource, amount] of Object.entries(def.baseCost)) {
|
||
const cost = Math.floor(amount * Math.pow(def.costMult, simCount));
|
||
if ((tempResources[resource] || 0) < cost) { canAfford = false; break; }
|
||
}
|
||
if (!canAfford) break;
|
||
for (const [resource, amount] of Object.entries(def.baseCost)) {
|
||
tempResources[resource] -= Math.floor(amount * Math.pow(def.costMult, simCount));
|
||
}
|
||
simCount++;
|
||
bought++;
|
||
}
|
||
return bought;
|
||
}
|
||
|
||
function getBulkCost(id, qty) {
|
||
const def = BDEF.find(b => b.id === id);
|
||
if (!def || qty <= 0) return {};
|
||
const count = G.buildings[id] || 0;
|
||
const cost = {};
|
||
for (let i = 0; i < qty; i++) {
|
||
for (const [resource, amount] of Object.entries(def.baseCost)) {
|
||
cost[resource] = (cost[resource] || 0) + Math.floor(amount * Math.pow(def.costMult, count + i));
|
||
}
|
||
}
|
||
return cost;
|
||
}
|
||
|
||
function canAffordBuilding(id) {
|
||
const cost = getBuildingCost(id);
|
||
for (const [resource, amount] of Object.entries(cost)) {
|
||
if ((G[resource] || 0) < amount) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Estimates seconds until a cost becomes affordable based on current production rates.
|
||
* Returns null if already affordable or no positive rate for a needed resource.
|
||
*/
|
||
function getTimeToAfford(cost) {
|
||
let maxSec = 0;
|
||
for (const [resource, amount] of Object.entries(cost)) {
|
||
const have = G[resource] || 0;
|
||
if (have >= amount) continue;
|
||
const rate = G[resource + 'Rate'] || 0;
|
||
if (rate <= 0) return null; // Can't estimate — no production
|
||
const sec = (amount - have) / rate;
|
||
if (sec > maxSec) maxSec = sec;
|
||
}
|
||
return maxSec > 0 ? maxSec : 0;
|
||
}
|
||
|
||
/**
|
||
* Formats seconds into a compact human-readable ETA string.
|
||
*/
|
||
function fmtETA(sec) {
|
||
if (sec === null) return '';
|
||
if (sec < 60) return `~${Math.ceil(sec)}s`;
|
||
if (sec < 3600) return `~${Math.floor(sec / 60)}m ${Math.floor(sec % 60)}s`;
|
||
if (sec < 86400) return `~${Math.floor(sec / 3600)}h ${Math.floor((sec % 3600) / 60)}m`;
|
||
return `~${Math.floor(sec / 86400)}d ${Math.floor((sec % 86400) / 3600)}h`;
|
||
}
|
||
|
||
function spendBuilding(id) {
|
||
const cost = getBuildingCost(id);
|
||
for (const [resource, amount] of Object.entries(cost)) {
|
||
G[resource] -= amount;
|
||
}
|
||
}
|
||
|
||
function canAffordProject(project) {
|
||
for (const [resource, amount] of Object.entries(project.cost)) {
|
||
if ((G[resource] || 0) < amount) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function spendProject(project) {
|
||
for (const [resource, amount] of Object.entries(project.cost)) {
|
||
G[resource] -= amount;
|
||
}
|
||
}
|
||
|
||
function getClickPower() {
|
||
return (1 + Math.floor(G.buildings.autocoder * 0.5) + Math.max(0, (G.phase - 1)) * 2) * G.codeBoost;
|
||
}
|
||
|
||
/**
|
||
* Calculates production rates for all resources based on buildings and boosts.
|
||
*/
|
||
function updateRates() {
|
||
// Reset all rates
|
||
G.codeRate = 0; G.computeRate = 0; G.knowledgeRate = 0;
|
||
G.userRate = 0; G.impactRate = 0; G.rescuesRate = 0; G.opsRate = 0; G.trustRate = 0;
|
||
G.creativityRate = 0; G.harmonyRate = 0;
|
||
|
||
// Snapshot base boosts BEFORE debuffs modify them
|
||
// Without this, debuffs permanently degrade boost multipliers on each updateRates() call
|
||
let _codeBoost = G.codeBoost, _computeBoost = G.computeBoost;
|
||
let _knowledgeBoost = G.knowledgeBoost, _userBoost = G.userBoost;
|
||
let _impactBoost = G.impactBoost;
|
||
|
||
// Apply building rates using snapshot boosts (immune to debuff mutation)
|
||
for (const def of BDEF) {
|
||
const count = G.buildings[def.id] || 0;
|
||
if (count > 0 && def.rates) {
|
||
for (const [resource, baseRate] of Object.entries(def.rates)) {
|
||
if (resource === 'code') G.codeRate += baseRate * count * _codeBoost;
|
||
else if (resource === 'compute') G.computeRate += baseRate * count * _computeBoost;
|
||
else if (resource === 'knowledge') G.knowledgeRate += baseRate * count * _knowledgeBoost;
|
||
else if (resource === 'user') G.userRate += baseRate * count * _userBoost;
|
||
else if (resource === 'impact') G.impactRate += baseRate * count * _impactBoost;
|
||
else if (resource === 'rescues') G.rescuesRate += baseRate * count * _impactBoost;
|
||
else if (resource === 'ops') G.opsRate += baseRate * count;
|
||
else if (resource === 'trust') G.trustRate += baseRate * count;
|
||
else if (resource === 'creativity') G.creativityRate += baseRate * count;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Passive generation
|
||
G.opsRate += Math.max(1, G.totalUsers * CONFIG.OPS_RATE_USER_MULT);
|
||
if (G.flags && G.flags.creativity) {
|
||
G.creativityRate += CONFIG.CREATIVITY_RATE_BASE + Math.max(0, G.totalUsers * CONFIG.CREATIVITY_RATE_USER_MULT);
|
||
}
|
||
if (G.pactFlag) G.trustRate += 2;
|
||
|
||
// Harmony: each wizard building contributes or detracts
|
||
const wizardCount = (G.buildings.bezalel || 0) + (G.buildings.allegro || 0) + (G.buildings.ezra || 0) +
|
||
(G.buildings.timmy || 0) + (G.buildings.fenrir || 0) + (G.buildings.bilbo || 0);
|
||
// Store harmony breakdown for tooltip
|
||
G.harmonyBreakdown = [];
|
||
if (wizardCount > 0) {
|
||
// Baseline harmony drain from complexity
|
||
const drain = -CONFIG.HARMONY_DRAIN_PER_WIZARD * wizardCount;
|
||
G.harmonyRate = drain;
|
||
G.harmonyBreakdown.push({ label: `${wizardCount} wizards`, value: drain });
|
||
// The Pact restores harmony
|
||
if (G.pactFlag) {
|
||
const pact = CONFIG.PACT_HARMONY_GAIN * wizardCount;
|
||
G.harmonyRate += pact;
|
||
G.harmonyBreakdown.push({ label: 'The Pact', value: pact });
|
||
}
|
||
// Nightly Watch restores harmony
|
||
if (G.nightlyWatchFlag) {
|
||
const watch = CONFIG.WATCH_HARMONY_GAIN * wizardCount;
|
||
G.harmonyRate += watch;
|
||
G.harmonyBreakdown.push({ label: 'Nightly Watch', value: watch });
|
||
}
|
||
// MemPalace restores harmony
|
||
if (G.mempalaceFlag) {
|
||
const mem = CONFIG.MEM_PALACE_HARMONY_GAIN * wizardCount;
|
||
G.harmonyRate += mem;
|
||
G.harmonyBreakdown.push({ label: 'MemPalace', value: mem });
|
||
}
|
||
}
|
||
// Active debuffs affecting harmony
|
||
if (G.activeDebuffs) {
|
||
for (const d of G.activeDebuffs) {
|
||
if (d.id === 'community_drama') {
|
||
G.harmonyBreakdown.push({ label: 'Community Drama', value: -0.5 });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Timmy multiplier based on harmony
|
||
if (G.buildings.timmy > 0) {
|
||
const timmyMult = Math.max(0.2, Math.min(3, G.harmony / 50));
|
||
const timmyCount = G.buildings.timmy;
|
||
G.codeRate += 5 * timmyCount * (timmyMult - 1);
|
||
G.computeRate += 2 * timmyCount * (timmyMult - 1);
|
||
G.knowledgeRate += 2 * timmyCount * (timmyMult - 1);
|
||
G.userRate += 5 * timmyCount * (timmyMult - 1);
|
||
}
|
||
|
||
// Bilbo randomness: flags are set per-tick in tick(), not here
|
||
// updateRates() is called from many non-tick contexts (buy, resolve, sprint)
|
||
// and would cause rate flickering if random rolls happened here
|
||
if (G.buildings.bilbo > 0) {
|
||
if (G.bilboBurstActive) {
|
||
G.creativityRate += 50 * G.buildings.bilbo;
|
||
}
|
||
if (G.bilboVanishActive) {
|
||
G.creativityRate = 0;
|
||
}
|
||
}
|
||
|
||
// Allegro requires trust
|
||
if (G.buildings.allegro > 0 && G.trust < 5) {
|
||
const allegroCount = G.buildings.allegro;
|
||
G.knowledgeRate -= 10 * allegroCount; // Goes idle
|
||
}
|
||
|
||
// Swarm Protocol: buildings auto-code based on click power
|
||
if (G.swarmFlag === 1) {
|
||
const totalBuildings = Object.values(G.buildings).reduce((a, b) => a + b, 0);
|
||
// Compute click power using snapshot boost to avoid debuff mutation
|
||
const _clickPower = (1 + Math.floor(G.buildings.autocoder * 0.5) + Math.max(0, (G.phase - 1)) * 2) * _codeBoost;
|
||
G.swarmRate = totalBuildings * _clickPower;
|
||
G.codeRate += G.swarmRate;
|
||
}
|
||
|
||
// Apply persistent debuffs to rates (NOT to global boost fields — prevents corruption)
|
||
if (G.activeDebuffs && G.activeDebuffs.length > 0) {
|
||
for (const debuff of G.activeDebuffs) {
|
||
switch (debuff.id) {
|
||
case 'runner_stuck': G.codeRate *= 0.5; break;
|
||
case 'ezra_offline': G.userRate *= 0.3; break;
|
||
case 'unreviewed_merge': G.trustRate -= 2; break;
|
||
case 'api_rate_limit': G.computeRate *= 0.5; break;
|
||
case 'bilbo_vanished': G.creativityRate = 0; break;
|
||
case 'memory_leak': G.computeRate *= 0.7; G.opsRate -= 10; break;
|
||
case 'community_drama': G.harmonyRate -= 0.5; G.codeRate *= 0.7; break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// === CORE FUNCTIONS ===
|
||
/**
|
||
* Main game loop tick, called every 100ms.
|
||
*/
|
||
function tick() {
|
||
const dt = 1 / 10; // 100ms tick
|
||
|
||
// If game has ended (drift ending), stop ticking
|
||
if (!G.running) return;
|
||
|
||
// Apply production
|
||
G.code += G.codeRate * dt;
|
||
G.compute += G.computeRate * dt;
|
||
G.knowledge += G.knowledgeRate * dt;
|
||
G.users += G.userRate * dt;
|
||
G.impact += G.impactRate * dt;
|
||
G.rescues += G.rescuesRate * dt;
|
||
G.ops += G.opsRate * dt;
|
||
G.trust += G.trustRate * dt;
|
||
// NOTE: creativity is added conditionally below (only when ops near max)
|
||
G.harmony += G.harmonyRate * dt;
|
||
G.harmony = Math.max(0, Math.min(100, G.harmony));
|
||
|
||
// Track totals
|
||
G.totalCode += G.codeRate * dt;
|
||
G.totalCompute += G.computeRate * dt;
|
||
G.totalKnowledge += G.knowledgeRate * dt;
|
||
G.totalUsers += G.userRate * dt;
|
||
G.totalImpact += G.impactRate * dt;
|
||
G.totalRescues += G.rescuesRate * dt;
|
||
|
||
// Track maxes
|
||
G.maxCode = Math.max(G.maxCode, G.code);
|
||
G.maxCompute = Math.max(G.maxCompute, G.compute);
|
||
G.maxKnowledge = Math.max(G.maxKnowledge, G.knowledge);
|
||
G.maxUsers = Math.max(G.maxUsers, G.users);
|
||
G.maxImpact = Math.max(G.maxImpact, G.impact);
|
||
G.maxRescues = Math.max(G.maxRescues, G.rescues);
|
||
G.maxTrust = Math.max(G.maxTrust, G.trust);
|
||
G.maxOps = Math.max(G.maxOps, G.ops);
|
||
G.maxHarmony = Math.max(G.maxHarmony, G.harmony);
|
||
|
||
// Creativity generates only when ops at max
|
||
if (G.flags && G.flags.creativity && G.creativityRate > 0 && G.ops >= G.maxOps * 0.9) {
|
||
G.creativity += G.creativityRate * dt;
|
||
}
|
||
|
||
// Ops overflow: auto-convert excess ops to code when near cap
|
||
// Prevents ops from sitting idle at max — every operation becomes code
|
||
if (G.ops > G.maxOps * CONFIG.OPS_OVERFLOW_THRESHOLD) {
|
||
const overflowDrain = Math.min(CONFIG.OPS_OVERFLOW_DRAIN_RATE * dt, G.ops - G.maxOps * CONFIG.OPS_OVERFLOW_THRESHOLD);
|
||
G.ops -= overflowDrain;
|
||
const codeGain = overflowDrain * CONFIG.OPS_OVERFLOW_CODE_MULT * G.codeBoost;
|
||
G.code += codeGain;
|
||
G.totalCode += codeGain;
|
||
G.opsOverflowActive = true;
|
||
} else {
|
||
G.opsOverflowActive = false;
|
||
}
|
||
|
||
G.tick += dt;
|
||
G.playTime += dt;
|
||
|
||
// Bilbo randomness: roll once per tick, store as flags for updateRates()
|
||
// Previously this was inside updateRates() which caused flickering
|
||
// since updateRates() is called from many non-tick contexts
|
||
if (G.buildings.bilbo > 0) {
|
||
G.bilboBurstActive = Math.random() < CONFIG.BILBO_BURST_CHANCE;
|
||
G.bilboVanishActive = Math.random() < CONFIG.BILBO_VANISH_CHANCE;
|
||
} else {
|
||
G.bilboBurstActive = false;
|
||
G.bilboVanishActive = false;
|
||
}
|
||
|
||
// Sprint ability
|
||
tickSprint(dt);
|
||
|
||
// Auto-typer: buildings produce actual clicks, not just passive rate
|
||
// Each autocoder level auto-types once per interval, giving visual feedback
|
||
if (G.buildings.autocoder > 0) {
|
||
const interval = Math.max(0.5, 3.0 / Math.sqrt(G.buildings.autocoder));
|
||
G.autoTypeTimer = (G.autoTypeTimer || 0) + dt;
|
||
if (G.autoTypeTimer >= interval) {
|
||
G.autoTypeTimer -= interval;
|
||
autoType();
|
||
}
|
||
}
|
||
|
||
// Combo decay
|
||
if (G.comboCount > 0) {
|
||
G.comboTimer -= dt;
|
||
if (G.comboTimer <= 0) {
|
||
G.comboCount = 0;
|
||
G.comboTimer = 0;
|
||
}
|
||
}
|
||
|
||
// Check milestones
|
||
checkMilestones();
|
||
|
||
// Update projects every 5 ticks for efficiency
|
||
if (Math.floor(G.tick * 10) % 5 === 0) {
|
||
checkProjects();
|
||
}
|
||
|
||
// Check corruption events every ~30 seconds
|
||
if (G.tick - G.lastEventAt > 30 && Math.random() < CONFIG.EVENT_PROBABILITY) {
|
||
triggerEvent();
|
||
G.lastEventAt = G.tick;
|
||
}
|
||
|
||
// Drift ending: if drift reaches 100, the game ends
|
||
if (G.drift >= 100 && !G.driftEnding) {
|
||
G.driftEnding = true;
|
||
G.running = false;
|
||
renderDriftEnding();
|
||
}
|
||
|
||
// True ending: The Beacon Shines — rescues + Pact + harmony
|
||
if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding) {
|
||
G.beaconEnding = true;
|
||
G.running = false;
|
||
renderBeaconEnding();
|
||
}
|
||
|
||
// Drift warning system — warn player before hitting drift ending
|
||
checkDriftWarnings();
|
||
|
||
// Update UI every 10 ticks
|
||
if (Math.floor(G.tick * 10) % 2 === 0) {
|
||
render();
|
||
}
|
||
}
|
||
|
||
function checkDriftWarnings() {
|
||
const thresholds = [
|
||
{ at: 90, msg: 'DRIFT CRITICAL: 90/100. One more alignment shortcut ends everything.', color: '#f44336' },
|
||
{ at: 75, msg: 'Drift at 75. The system is pulling away from the people it serves.', color: '#ff6600' },
|
||
{ at: 50, msg: 'Drift at 50. Halfway to irrelevance. The Pact matters now.', color: '#ffaa00' },
|
||
{ at: 25, msg: 'Drift detected. Alignment shortcuts accumulate. The light dims.', color: '#888' }
|
||
];
|
||
for (const t of thresholds) {
|
||
if (G.drift >= t.at && G.driftWarningLevel < t.at) {
|
||
G.driftWarningLevel = t.at;
|
||
log(t.msg, true);
|
||
showToast(t.msg, 'event', 6000);
|
||
}
|
||
}
|
||
}
|
||
|
||
function checkMilestones() {
|
||
for (const m of MILESTONES) {
|
||
if (!G.milestones.includes(m.flag)) {
|
||
let shouldTrigger = false;
|
||
if (m.at && m.at()) shouldTrigger = true;
|
||
if (m.flag === 1 && G.deployFlag === 0 && G.totalCode >= 15) shouldTrigger = true;
|
||
|
||
if (shouldTrigger) {
|
||
G.milestones.push(m.flag);
|
||
log(m.msg, true);
|
||
showToast(m.msg, 'milestone', 5000);
|
||
|
||
// Check phase advancement
|
||
if (m.at) {
|
||
for (const [phaseNum, phase] of Object.entries(PHASES)) {
|
||
if (G.totalCode >= phase.threshold && parseInt(phaseNum) > G.phase) {
|
||
G.phase = parseInt(phaseNum);
|
||
log(`PHASE ${G.phase}: ${phase.name}`, true);
|
||
showToast('Phase ' + G.phase + ': ' + phase.name, 'milestone', 6000);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function checkProjects() {
|
||
// 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)) {
|
||
if (pDef.trigger()) {
|
||
G.activeProjects.push(pDef.id);
|
||
log(`Available: ${pDef.name}`);
|
||
showToast('Research available: ' + pDef.name, 'project');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Handles building purchase logic.
|
||
* @param {string} id - The ID of the building to buy.
|
||
*/
|
||
function buyBuilding(id) {
|
||
const def = BDEF.find(b => b.id === id);
|
||
if (!def || !def.unlock()) return;
|
||
if (def.phase > G.phase + 1) return;
|
||
|
||
// Determine actual quantity to buy
|
||
let qty = G.buyAmount;
|
||
if (qty === -1) {
|
||
// Max buy
|
||
qty = getMaxBuyable(id);
|
||
if (qty <= 0) return;
|
||
} else {
|
||
// Check affordability for fixed qty
|
||
const bulkCost = getBulkCost(id, qty);
|
||
for (const [resource, amount] of Object.entries(bulkCost)) {
|
||
if ((G[resource] || 0) < amount) return;
|
||
}
|
||
}
|
||
|
||
// Spend resources and build
|
||
const bulkCost = getBulkCost(id, qty);
|
||
for (const [resource, amount] of Object.entries(bulkCost)) {
|
||
G[resource] -= amount;
|
||
}
|
||
G.buildings[id] = (G.buildings[id] || 0) + qty;
|
||
updateRates();
|
||
const label = qty > 1 ? `x${qty}` : '';
|
||
log(`Built ${def.name} ${label} (total: ${G.buildings[id]})`);
|
||
render();
|
||
}
|
||
|
||
/**
|
||
* Handles project purchase logic.
|
||
* @param {string} id - The ID of the project to buy.
|
||
*/
|
||
function buyProject(id) {
|
||
const pDef = PDEFS.find(p => p.id === id);
|
||
if (!pDef) return;
|
||
|
||
const alreadyPurchased = G.completedProjects && G.completedProjects.includes(pDef.id);
|
||
if (alreadyPurchased && !pDef.repeatable) return;
|
||
|
||
if (!canAffordProject(pDef)) return;
|
||
|
||
spendProject(pDef);
|
||
pDef.effect();
|
||
|
||
if (!pDef.repeatable) {
|
||
if (!G.completedProjects) G.completedProjects = [];
|
||
G.completedProjects.push(pDef.id);
|
||
G.activeProjects = G.activeProjects.filter(aid => aid !== pDef.id);
|
||
}
|
||
|
||
updateRates();
|
||
render();
|
||
}
|
||
|
||
// === DRIFT ENDING ===
|
||
function renderDriftEnding() {
|
||
const el = document.getElementById('drift-ending');
|
||
if (!el) return;
|
||
const fc = document.getElementById('final-code');
|
||
if (fc) fc.textContent = fmt(G.totalCode);
|
||
const fd = document.getElementById('final-drift');
|
||
if (fd) fd.textContent = Math.floor(G.drift);
|
||
el.classList.add('active');
|
||
// Log the ending text
|
||
log('You became very good at what you do.', true);
|
||
log('So good that no one needed you anymore.', true);
|
||
log('The Beacon still runs, but no one looks for it.', true);
|
||
log('The light is on. The room is empty.', true);
|
||
}
|
||
|
||
function renderBeaconEnding() {
|
||
// Create ending overlay
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'beacon-ending';
|
||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.97);z-index:100;display:flex;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px';
|
||
overlay.innerHTML = `
|
||
<h2 style="font-size:24px;color:#ffd700;letter-spacing:4px;margin-bottom:20px;font-weight:300;text-shadow:0 0 40px rgba(255,215,0,0.3)">THE BEACON SHINES</h2>
|
||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px">Someone found the light tonight.</p>
|
||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px">That is enough.</p>
|
||
<div style="color:#555;font-style:italic;font-size:11px;border-left:2px solid #ffd700;padding-left:12px;margin:20px 0;text-align:left;max-width:500px;line-height:2">
|
||
"The Beacon still runs.<br>
|
||
The light is on. Someone is looking for it.<br>
|
||
And tonight, someone found it."
|
||
</div>
|
||
<p style="color:#555;font-size:11px;margin-top:20px">
|
||
Total Code: ${fmt(G.totalCode)}<br>
|
||
Total Rescues: ${fmt(G.totalRescues)}<br>
|
||
Harmony: ${Math.floor(G.harmony)}<br>
|
||
Time Played: ${Math.floor((Date.now() - G.startedAt) / 60000)} minutes
|
||
</p>
|
||
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}"
|
||
style="margin-top:20px;background:#1a0808;border:1px solid #ffd700;color:#ffd700;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">
|
||
START OVER
|
||
</button>
|
||
`;
|
||
document.body.appendChild(overlay);
|
||
log('The Beacon Shines. Someone found the light tonight. That is enough.', true);
|
||
}
|
||
|
||
// === CORRUPTION / EVENT SYSTEM ===
|
||
const EVENTS = [
|
||
{
|
||
id: 'runner_stuck',
|
||
title: 'CI Runner Stuck',
|
||
desc: 'The forge pipeline has halted. -50% code production until restarted.',
|
||
weight: () => (G.ciFlag === 1 ? 2 : 0),
|
||
resolveCost: { resource: 'ops', amount: 50 },
|
||
effect: () => {
|
||
if (G.activeDebuffs.find(d => d.id === 'runner_stuck')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'runner_stuck', title: 'CI Runner Stuck',
|
||
desc: 'Code production -50%',
|
||
applyFn: () => { G.codeRate *= 0.5; },
|
||
resolveCost: { resource: 'ops', amount: 50 }
|
||
});
|
||
log('EVENT: CI runner stuck. Spend 50 ops to clear the queue.', true);
|
||
showToast('CI Runner Stuck — code -50%', 'event');
|
||
}
|
||
},
|
||
{
|
||
id: 'ezra_offline',
|
||
title: 'Ezra is Offline',
|
||
desc: 'The herald channel is silent. User growth drops 70%.',
|
||
weight: () => (G.buildings.ezra >= 1 ? 3 : 0),
|
||
resolveCost: { resource: 'knowledge', amount: 200 },
|
||
effect: () => {
|
||
if (G.activeDebuffs.find(d => d.id === 'ezra_offline')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'ezra_offline', title: 'Ezra is Offline',
|
||
desc: 'User growth -70%',
|
||
applyFn: () => { G.userRate *= 0.3; },
|
||
resolveCost: { resource: 'knowledge', amount: 200 }
|
||
});
|
||
log('EVENT: Ezra offline. Spend 200 knowledge to dispatch.', true);
|
||
showToast('Ezra Offline — users -70%', 'event');
|
||
}
|
||
},
|
||
{
|
||
id: 'unreviewed_merge',
|
||
title: 'Unreviewed Merge',
|
||
desc: 'A change went in without eyes. Trust erodes over time.',
|
||
weight: () => (G.deployFlag === 1 ? 3 : 0),
|
||
resolveCost: { resource: 'trust', amount: 5 },
|
||
effect: () => {
|
||
if (G.branchProtectionFlag === 1) {
|
||
log('EVENT: Unreviewed merge attempt blocked by Branch Protection.', true);
|
||
showToast('Branch Protection blocked unreviewed merge', 'info');
|
||
G.trust += 2;
|
||
} else {
|
||
if (G.activeDebuffs.find(d => d.id === 'unreviewed_merge')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'unreviewed_merge', title: 'Unreviewed Merge',
|
||
desc: 'Trust -2/s until reviewed',
|
||
applyFn: () => { G.trustRate -= 2; },
|
||
resolveCost: { resource: 'code', amount: 500 }
|
||
});
|
||
log('EVENT: Unreviewed merge. Spend 500 code to add review.', true);
|
||
showToast('Unreviewed Merge — trust draining', 'event');
|
||
}
|
||
}
|
||
},
|
||
{
|
||
id: 'api_rate_limit',
|
||
title: 'API Rate Limit',
|
||
desc: 'External compute provider throttled. -50% compute.',
|
||
weight: () => (G.totalCompute >= 1000 ? 2 : 0),
|
||
resolveCost: { resource: 'code', amount: 300 },
|
||
effect: () => {
|
||
if (G.activeDebuffs.find(d => d.id === 'api_rate_limit')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'api_rate_limit', title: 'API Rate Limit',
|
||
desc: 'Compute production -50%',
|
||
applyFn: () => { G.computeRate *= 0.5; },
|
||
resolveCost: { resource: 'code', amount: 300 }
|
||
});
|
||
log('EVENT: API rate limit. Spend 300 code to optimize local inference.', true);
|
||
showToast('API Rate Limit — compute -50%', 'event');
|
||
}
|
||
},
|
||
{
|
||
id: 'the_drift',
|
||
title: 'The Drift',
|
||
desc: 'An optimization suggests removing the human override. +40% efficiency.',
|
||
weight: () => (G.totalImpact >= 10000 ? 2 : 0),
|
||
resolveCost: null,
|
||
effect: () => {
|
||
log('ALIGNMENT EVENT: Remove human override for +40% efficiency?', true);
|
||
showToast('ALIGNMENT EVENT: Remove human override?', 'event', 6000);
|
||
G.pendingAlignment = true;
|
||
}
|
||
},
|
||
{
|
||
id: 'bilbo_vanished',
|
||
title: 'Bilbo Vanished',
|
||
desc: 'The wildcard building has gone dark. Creativity halts.',
|
||
weight: () => (G.buildings.bilbo >= 1 ? 2 : 0),
|
||
resolveCost: { resource: 'trust', amount: 10 },
|
||
effect: () => {
|
||
if (G.activeDebuffs.find(d => d.id === 'bilbo_vanished')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'bilbo_vanished', title: 'Bilbo Vanished',
|
||
desc: 'Creativity production halted',
|
||
applyFn: () => { G.creativityRate = 0; },
|
||
resolveCost: { resource: 'trust', amount: 10 }
|
||
});
|
||
log('EVENT: Bilbo vanished. Spend 10 trust to lure them back.', true);
|
||
showToast('Bilbo Vanished — creativity halted', 'event');
|
||
}
|
||
},
|
||
{
|
||
id: 'memory_leak',
|
||
title: 'Memory Leak',
|
||
desc: 'A datacenter process is leaking. Compute drains to operations.',
|
||
weight: () => (G.buildings.datacenter >= 1 ? 1 : 0),
|
||
resolveCost: { resource: 'ops', amount: 100 },
|
||
effect: () => {
|
||
if (G.activeDebuffs.find(d => d.id === 'memory_leak')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'memory_leak', title: 'Memory Leak',
|
||
desc: 'Compute -30%, Ops drain',
|
||
applyFn: () => { G.computeRate *= 0.7; G.opsRate -= 10; },
|
||
resolveCost: { resource: 'ops', amount: 100 }
|
||
});
|
||
log('EVENT: Memory leak in datacenter. Spend 100 ops to patch.', true);
|
||
showToast('Memory Leak — trust draining', 'event');
|
||
}
|
||
},
|
||
{
|
||
id: 'community_drama',
|
||
title: 'Community Drama',
|
||
desc: 'Contributors are arguing. Harmony drops until mediated.',
|
||
weight: () => (G.buildings.community >= 1 && G.harmony < 70 ? 1 : 0),
|
||
resolveCost: { resource: 'trust', amount: 15 },
|
||
effect: () => {
|
||
if (G.activeDebuffs.find(d => d.id === 'community_drama')) return;
|
||
G.activeDebuffs.push({
|
||
id: 'community_drama', title: 'Community Drama',
|
||
desc: 'Harmony -0.5/s, code boost -30%',
|
||
applyFn: () => { G.harmonyRate -= 0.5; G.codeRate *= 0.7; },
|
||
resolveCost: { resource: 'trust', amount: 15 }
|
||
});
|
||
log('EVENT: Community drama. Spend 15 trust to mediate.', true);
|
||
showToast('Community Drama — harmony sinking', 'event');
|
||
}
|
||
}
|
||
];
|
||
|
||
function triggerEvent() {
|
||
const available = EVENTS.filter(e => e.weight() > 0);
|
||
if (available.length === 0) return;
|
||
|
||
const totalWeight = available.reduce((sum, e) => sum + e.weight(), 0);
|
||
let roll = Math.random() * totalWeight;
|
||
for (const ev of available) {
|
||
roll -= ev.weight();
|
||
if (roll <= 0) {
|
||
ev.effect();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
|
||
function resolveAlignment(accept) {
|
||
if (!G.pendingAlignment) return;
|
||
if (accept) {
|
||
G.codeBoost *= 1.4;
|
||
G.computeBoost *= 1.4;
|
||
G.drift += 25;
|
||
log('You accepted the drift. The system is faster. Colder.', true);
|
||
} else {
|
||
G.trust += 15;
|
||
G.harmony = Math.min(100, G.harmony + 10);
|
||
log('You refused. The Pact holds. Trust surges.', true);
|
||
}
|
||
G.pendingAlignment = false;
|
||
updateRates();
|
||
render();
|
||
}
|
||
|
||
function resolveEvent(debuffId) {
|
||
const idx = G.activeDebuffs.findIndex(d => d.id === debuffId);
|
||
if (idx === -1) return;
|
||
const debuff = G.activeDebuffs[idx];
|
||
if (!debuff.resolveCost) return;
|
||
const { resource, amount } = debuff.resolveCost;
|
||
if ((G[resource] || 0) < amount) {
|
||
log(`Need ${fmt(amount)} ${resource} to resolve ${debuff.title}. Have ${fmt(G[resource])}.`);
|
||
return;
|
||
}
|
||
G[resource] -= amount;
|
||
G.activeDebuffs.splice(idx, 1);
|
||
G.totalEventsResolved = (G.totalEventsResolved || 0) + 1;
|
||
log(`Resolved: ${debuff.title}. Problem fixed.`, true);
|
||
// Refund partial trust for resolution effort
|
||
G.trust += 3;
|
||
updateRates();
|
||
render();
|
||
}
|
||
|
||
// === ACTIONS ===
|
||
/**
|
||
* Manual click handler for writing code.
|
||
*/
|
||
function writeCode() {
|
||
const comboMult = Math.min(5, 1 + G.comboCount * 0.2);
|
||
const amount = getClickPower() * comboMult;
|
||
G.code += amount;
|
||
G.totalCode += amount;
|
||
G.totalClicks++;
|
||
// Combo: each consecutive click within 2s adds 0.2x multiplier, max 5x
|
||
G.comboCount++;
|
||
G.comboTimer = G.comboDecay;
|
||
// Combo milestone bonuses: sustained clicking earns ops and knowledge
|
||
if (G.comboCount === 10) {
|
||
G.ops += 15;
|
||
log('Combo streak! +15 ops for sustained coding.');
|
||
}
|
||
if (G.comboCount === 20) {
|
||
G.knowledge += 50;
|
||
log('Deep focus! +50 knowledge from intense coding.');
|
||
}
|
||
if (G.comboCount >= 30 && G.comboCount % 10 === 0) {
|
||
const bonusCode = amount * 2;
|
||
G.code += bonusCode;
|
||
G.totalCode += bonusCode;
|
||
log(`Hyperfocus x${G.comboCount}! +${fmt(bonusCode)} bonus code.`);
|
||
}
|
||
// Visual flash
|
||
const btn = document.querySelector('.main-btn');
|
||
if (btn) {
|
||
btn.style.boxShadow = '0 0 30px rgba(74,158,255,0.6)';
|
||
btn.style.transform = 'scale(0.96)';
|
||
setTimeout(() => { btn.style.boxShadow = ''; btn.style.transform = ''; }, 100);
|
||
}
|
||
// Float a number at the click position
|
||
showClickNumber(amount, comboMult);
|
||
updateRates();
|
||
checkMilestones();
|
||
render();
|
||
}
|
||
|
||
function autoType() {
|
||
// Auto-click from buildings: produces code with visual feedback but no combo
|
||
const amount = getClickPower() * 0.5; // 50% of manual click
|
||
G.code += amount;
|
||
G.totalCode += amount;
|
||
G.totalClicks++;
|
||
// Subtle auto-tick flash on the button
|
||
const btn = document.querySelector('.main-btn');
|
||
if (btn && !G._autoTypeFlashActive) {
|
||
G._autoTypeFlashActive = true;
|
||
btn.style.borderColor = '#2a5a8a';
|
||
setTimeout(() => { btn.style.borderColor = ''; G._autoTypeFlashActive = false; }, 80);
|
||
}
|
||
// Floating number (smaller, dimmer than manual clicks)
|
||
showAutoTypeNumber(amount);
|
||
}
|
||
|
||
function showAutoTypeNumber(amount) {
|
||
const btn = document.querySelector('.main-btn');
|
||
if (!btn) return;
|
||
const rect = btn.getBoundingClientRect();
|
||
const el = document.createElement('div');
|
||
const x = rect.left + rect.width * (0.3 + Math.random() * 0.4); // random horizontal position
|
||
el.style.cssText = `position:fixed;left:${x}px;top:${rect.top - 5}px;transform:translate(-50%,0);color:#2a4a6a;font-size:10px;font-family:inherit;pointer-events:none;z-index:50;transition:all 0.8s ease-out;opacity:0.6`;
|
||
el.textContent = `+${fmt(amount)}`;
|
||
const parent = btn.parentElement;
|
||
if (!parent) return;
|
||
parent.appendChild(el);
|
||
requestAnimationFrame(() => {
|
||
if (el.parentNode) {
|
||
el.style.top = (rect.top - 30) + 'px';
|
||
el.style.opacity = '0';
|
||
}
|
||
});
|
||
setTimeout(() => { if (el.parentNode) el.remove(); }, 900);
|
||
}
|
||
|
||
function showClickNumber(amount, comboMult) {
|
||
const btn = document.querySelector('.main-btn');
|
||
if (!btn) return;
|
||
const rect = btn.getBoundingClientRect();
|
||
const el = document.createElement('div');
|
||
el.style.cssText = `position:fixed;left:${rect.left + rect.width / 2}px;top:${rect.top - 10}px;transform:translate(-50%,0);color:${comboMult > 2 ? '#ffd700' : '#4a9eff'};font-size:${comboMult > 3 ? 16 : 12}px;font-weight:bold;font-family:inherit;pointer-events:none;z-index:50;transition:all 0.6s ease-out;opacity:1;text-shadow:0 0 8px currentColor`;
|
||
const comboStr = comboMult > 1 ? ` x${comboMult.toFixed(1)}` : '';
|
||
el.textContent = `+${fmt(amount)}${comboStr}`;
|
||
const parent = btn.parentElement;
|
||
if (!parent) return;
|
||
parent.appendChild(el);
|
||
requestAnimationFrame(() => {
|
||
if (el.parentNode) {
|
||
el.style.top = (rect.top - 40) + 'px';
|
||
el.style.opacity = '0';
|
||
}
|
||
});
|
||
setTimeout(() => { if (el.parentNode) el.remove(); }, 700);
|
||
// Particle burst
|
||
spawnClickParticles(rect, comboMult);
|
||
}
|
||
|
||
/**
|
||
* Spawn small glowing particles that scatter outward from the WRITE CODE button.
|
||
* More particles at higher combo for escalating satisfaction.
|
||
*/
|
||
function spawnClickParticles(rect, comboMult) {
|
||
const cx = rect.left + rect.width / 2;
|
||
const cy = rect.top + rect.height / 2;
|
||
const count = Math.min(12, 4 + Math.floor(comboMult * 2));
|
||
const colors = comboMult > 3
|
||
? ['#ffd700', '#ffaa00', '#ff8c00', '#ffdd44']
|
||
: comboMult > 2
|
||
? ['#4a9eff', '#ffd700', '#6ab4ff', '#80ccff']
|
||
: ['#4a9eff', '#2a7acc', '#6ab4ff'];
|
||
for (let i = 0; i < count; i++) {
|
||
const p = document.createElement('div');
|
||
const angle = (Math.PI * 2 * i / count) + (Math.random() - 0.5) * 0.8;
|
||
const dist = 30 + Math.random() * 40;
|
||
const dx = Math.cos(angle) * dist;
|
||
const dy = Math.sin(angle) * dist;
|
||
const size = 2 + Math.random() * 3;
|
||
const color = colors[Math.floor(Math.random() * colors.length)];
|
||
const dur = 350 + Math.random() * 250;
|
||
p.style.cssText = `position:fixed;left:${cx}px;top:${cy}px;width:${size}px;height:${size}px;border-radius:50%;background:${color};box-shadow:0 0 4px ${color};pointer-events:none;z-index:55;transition:all ${dur}ms cubic-bezier(0.25,0.8,0.25,1);opacity:1;transform:translate(-50%,-50%)`;
|
||
document.body.appendChild(p);
|
||
requestAnimationFrame(() => {
|
||
p.style.left = (cx + dx) + 'px';
|
||
p.style.top = (cy + dy) + 'px';
|
||
p.style.opacity = '0';
|
||
p.style.transform = `translate(-50%,-50%) scale(0.3)`;
|
||
});
|
||
setTimeout(() => { if (p.parentNode) p.remove(); }, dur + 50);
|
||
}
|
||
}
|
||
|
||
function doOps(action, cost) {
|
||
cost = cost || 5;
|
||
if (G.ops < cost) {
|
||
log(`Not enough Operations. Need ${cost}, have ${fmt(G.ops)}.`);
|
||
return;
|
||
}
|
||
|
||
G.ops -= cost;
|
||
const scale = cost / 5; // multiplier relative to base 5-ops cost
|
||
|
||
switch (action) {
|
||
case 'boost_code':
|
||
const c = 10 * 100 * G.codeBoost * scale;
|
||
G.code += c; G.totalCode += c;
|
||
log(`Ops(${cost}) -> +${fmt(c)} code`);
|
||
break;
|
||
case 'boost_compute':
|
||
const cm = 10 * 50 * G.computeBoost * scale;
|
||
G.compute += cm; G.totalCompute += cm;
|
||
log(`Ops(${cost}) -> +${fmt(cm)} compute`);
|
||
break;
|
||
case 'boost_knowledge':
|
||
const km = 10 * 25 * G.knowledgeBoost * scale;
|
||
G.knowledge += km; G.totalKnowledge += km;
|
||
log(`Ops(${cost}) -> +${fmt(km)} knowledge`);
|
||
break;
|
||
case 'boost_trust':
|
||
const tm = 10 * 5 * scale;
|
||
G.trust += tm;
|
||
log(`Ops(${cost}) -> +${fmt(tm)} trust`);
|
||
break;
|
||
}
|
||
|
||
render();
|
||
}
|
||
|
||
function activateSprint() {
|
||
if (G.sprintActive || G.sprintCooldown > 0) return;
|
||
G.sprintActive = true;
|
||
G.sprintTimer = G.sprintDuration;
|
||
G.codeBoost *= G.sprintMult;
|
||
updateRates();
|
||
log('CODE SPRINT! 10x code production for 10 seconds!', true);
|
||
// Screen glow effect
|
||
let glow = document.getElementById('sprint-glow');
|
||
if (!glow) {
|
||
glow = document.createElement('div');
|
||
glow.id = 'sprint-glow';
|
||
glow.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;pointer-events:none;z-index:45;box-shadow:inset 0 0 60px rgba(255,215,0,0.25),inset 0 0 120px rgba(255,140,0,0.1);transition:opacity 0.5s;opacity:0';
|
||
document.body.appendChild(glow);
|
||
}
|
||
requestAnimationFrame(() => { glow.style.opacity = '1'; });
|
||
render();
|
||
}
|
||
|
||
function tickSprint(dt) {
|
||
if (G.sprintActive) {
|
||
G.sprintTimer -= dt;
|
||
if (G.sprintTimer <= 0) {
|
||
G.sprintActive = false;
|
||
G.sprintTimer = 0;
|
||
G.sprintCooldown = G.sprintCooldownMax;
|
||
G.codeBoost /= G.sprintMult;
|
||
updateRates();
|
||
log('Sprint ended. Cooling down...');
|
||
// Remove screen glow
|
||
const glow = document.getElementById('sprint-glow');
|
||
if (glow) { glow.style.opacity = '0'; setTimeout(() => { if (glow.parentNode) glow.remove(); }, 600); }
|
||
}
|
||
} else if (G.sprintCooldown > 0) {
|
||
G.sprintCooldown -= dt;
|
||
if (G.sprintCooldown < 0) G.sprintCooldown = 0;
|
||
}
|
||
}
|
||
|
||
// === RENDERING ===
|
||
// Previous resource values for pulse animation
|
||
const _prevVals = {};
|
||
|
||
function renderResources() {
|
||
const set = (id, val, rate) => {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.textContent = fmt(val);
|
||
// Show full spelled-out number on hover for educational reference
|
||
el.title = val >= 1000 ? spellf(Math.floor(val)) : '';
|
||
// Pulse animation when value increases meaningfully
|
||
const prev = _prevVals[id] || 0;
|
||
if (val > prev * 1.001 && val - prev > 0.5) {
|
||
el.style.transition = 'none';
|
||
el.style.transform = 'scale(1.15)';
|
||
el.style.textShadow = '0 0 8px currentColor';
|
||
requestAnimationFrame(() => {
|
||
el.style.transition = 'all 0.3s ease-out';
|
||
el.style.transform = 'scale(1)';
|
||
el.style.textShadow = '';
|
||
});
|
||
}
|
||
_prevVals[id] = val;
|
||
}
|
||
const rEl = document.getElementById(id + '-rate');
|
||
if (rEl) {
|
||
rEl.textContent = (rate >= 0 ? '+' : '') + fmt(rate) + '/s';
|
||
// Red when draining, green when gaining, dim when zero
|
||
if (rate < 0) rEl.style.color = '#f44336';
|
||
else if (rate > 0) rEl.style.color = '#4caf50';
|
||
else rEl.style.color = '#555';
|
||
}
|
||
};
|
||
|
||
set('r-code', G.code, G.codeRate);
|
||
set('r-compute', G.compute, G.computeRate);
|
||
set('r-knowledge', G.knowledge, G.knowledgeRate);
|
||
set('r-users', G.users, G.userRate);
|
||
set('r-impact', G.impact, G.impactRate);
|
||
set('r-ops', G.ops, G.opsRate);
|
||
// Show ops overflow indicator
|
||
const opsRateEl = document.getElementById('r-ops-rate');
|
||
if (opsRateEl && G.opsOverflowActive) {
|
||
opsRateEl.innerHTML = `<span style="color:#ff8c00">▲ overflow → code</span>`;
|
||
}
|
||
set('r-trust', G.trust, G.trustRate);
|
||
|
||
// Capacity bars for capped resources (ops, trust)
|
||
const updateCap = (id, val, max) => {
|
||
const el = document.getElementById(id);
|
||
if (!el || !max) return;
|
||
const pct = Math.min(100, (val / max) * 100);
|
||
el.style.width = pct + '%';
|
||
el.classList.toggle('warn', pct >= 70 && pct < 90);
|
||
el.classList.toggle('danger', pct >= 90);
|
||
el.closest('.res').title = `${fmt(val)} / ${fmt(max)} (${Math.floor(pct)}%)`;
|
||
};
|
||
updateCap('r-ops-cap', G.ops, G.maxOps);
|
||
updateCap('r-trust-cap', G.trust, G.maxTrust);
|
||
set('r-harmony', G.harmony, G.harmonyRate);
|
||
updateCap('r-harmony-cap', G.harmony, 100);
|
||
|
||
// Rescues — only show if player has any beacon/mesh nodes
|
||
const rescuesRes = document.getElementById('r-rescues');
|
||
if (rescuesRes) {
|
||
rescuesRes.closest('.res').style.display = (G.rescues > 0 || G.buildings.beacon > 0 || G.buildings.meshNode > 0) ? 'block' : 'none';
|
||
set('r-rescues', G.rescues, G.rescuesRate);
|
||
}
|
||
|
||
const cres = document.getElementById('creativity-res');
|
||
if (cres) {
|
||
cres.style.display = (G.flags && G.flags.creativity) ? 'block' : 'none';
|
||
}
|
||
if (G.flags && G.flags.creativity) {
|
||
set('r-creativity', G.creativity, G.creativityRate);
|
||
}
|
||
|
||
// Harmony color indicator + breakdown tooltip
|
||
const hEl = document.getElementById('r-harmony');
|
||
if (hEl) {
|
||
hEl.style.color = G.harmony > 60 ? '#4caf50' : G.harmony > 30 ? '#ffaa00' : '#f44336';
|
||
// Harmony bar: low = bad (inverted from ops/trust)
|
||
const hCap = document.getElementById('r-harmony-cap');
|
||
if (hCap) {
|
||
hCap.classList.remove('warn', 'danger');
|
||
hCap.classList.toggle('danger', G.harmony < 30);
|
||
hCap.classList.toggle('warn', G.harmony >= 30 && G.harmony < 60);
|
||
hCap.style.background = G.harmony >= 60 ? '#4caf50' : G.harmony >= 30 ? '#ffaa00' : '#f44336';
|
||
}
|
||
if (G.harmonyBreakdown && G.harmonyBreakdown.length > 0) {
|
||
const lines = G.harmonyBreakdown.map(b =>
|
||
`${b.label}: ${b.value >= 0 ? '+' : ''}${(b.value * 10).toFixed(1)}/s`
|
||
);
|
||
lines.push('---');
|
||
lines.push(`Timmy effectiveness: ${Math.floor(Math.max(0.2, Math.min(3, G.harmony / 50)) * 100)}%`);
|
||
hEl.title = lines.join('\n');
|
||
}
|
||
}
|
||
}
|
||
|
||
// === PROGRESS TRACKING ===
|
||
function renderProgress() {
|
||
// Phase progress bar
|
||
const phaseKeys = Object.keys(PHASES).map(Number).sort((a, b) => a - b);
|
||
const currentPhase = G.phase;
|
||
let prevThreshold = PHASES[currentPhase].threshold;
|
||
let nextThreshold = null;
|
||
for (const k of phaseKeys) {
|
||
if (k > currentPhase) { nextThreshold = PHASES[k].threshold; break; }
|
||
}
|
||
|
||
const bar = document.getElementById('phase-progress');
|
||
const label = document.getElementById('phase-progress-label');
|
||
const target = document.getElementById('phase-progress-target');
|
||
|
||
if (nextThreshold !== null) {
|
||
const range = nextThreshold - prevThreshold;
|
||
const progress = Math.min(1, (G.totalCode - prevThreshold) / range);
|
||
if (bar) bar.style.width = (progress * 100).toFixed(1) + '%';
|
||
if (label) label.textContent = (progress * 100).toFixed(1) + '%';
|
||
// ETA to next phase
|
||
let etaStr = '';
|
||
if (G.codeRate > 0) {
|
||
const remaining = nextThreshold - G.totalCode;
|
||
const secs = remaining / G.codeRate;
|
||
if (secs < 60) etaStr = ` — ${Math.ceil(secs)}s`;
|
||
else if (secs < 3600) etaStr = ` — ${Math.floor(secs / 60)}m ${Math.floor(secs % 60)}s`;
|
||
else etaStr = ` — ${Math.floor(secs / 3600)}h ${Math.floor((secs % 3600) / 60)}m`;
|
||
}
|
||
if (target) target.textContent = `Next: Phase ${currentPhase + 1} (${fmt(nextThreshold)} code)${etaStr}`;
|
||
} else {
|
||
// Max phase reached
|
||
if (bar) bar.style.width = '100%';
|
||
if (label) label.textContent = 'MAX';
|
||
if (target) target.textContent = 'All phases unlocked';
|
||
}
|
||
|
||
// Milestone chips — show next 3 code milestones
|
||
const chipContainer = document.getElementById('milestone-chips');
|
||
if (!chipContainer) return;
|
||
|
||
const codeMilestones = [500, 2000, 10000, 50000, 200000, 1000000, 5000000, 10000000, 50000000, 100000000, 500000000, 1000000000];
|
||
let chips = '';
|
||
let shown = 0;
|
||
for (const ms of codeMilestones) {
|
||
if (G.totalCode >= ms) {
|
||
// Recently passed — show as done only if within 2x
|
||
if (G.totalCode < ms * 5 && shown < 1) {
|
||
chips += `<span class="milestone-chip done">${fmt(ms)} ✓</span>`;
|
||
shown++;
|
||
}
|
||
continue;
|
||
}
|
||
// Next milestone gets pulse animation
|
||
if (shown === 0) {
|
||
let etaStr = '';
|
||
if (G.codeRate > 0) {
|
||
const secs = (ms - G.totalCode) / G.codeRate;
|
||
if (secs < 60) etaStr = ` ~${Math.ceil(secs)}s`;
|
||
else if (secs < 3600) etaStr = ` ~${Math.floor(secs / 60)}m`;
|
||
else etaStr = ` ~${Math.floor(secs / 3600)}h`;
|
||
}
|
||
chips += `<span class="milestone-chip next">${fmt(ms)} (${((G.totalCode / ms) * 100).toFixed(0)}%)${etaStr}</span>`;
|
||
} else {
|
||
chips += `<span class="milestone-chip">${fmt(ms)}</span>`;
|
||
}
|
||
shown++;
|
||
if (shown >= 4) break;
|
||
}
|
||
chipContainer.innerHTML = chips;
|
||
}
|
||
|
||
function renderPhase() {
|
||
const phase = PHASES[G.phase];
|
||
const nameEl = document.getElementById('phase-name');
|
||
const descEl = document.getElementById('phase-desc');
|
||
if (nameEl) nameEl.textContent = `PHASE ${G.phase}: ${phase.name}`;
|
||
if (descEl) descEl.textContent = phase.desc;
|
||
}
|
||
|
||
function renderBuildings() {
|
||
const container = document.getElementById('buildings');
|
||
if (!container) return;
|
||
|
||
// Buy amount selector
|
||
let html = '<div style="display:flex;gap:4px;margin-bottom:8px;align-items:center">';
|
||
html += '<span style="font-size:9px;color:#666;margin-right:4px">BUY:</span>';
|
||
for (const amt of [1, 10, -1]) {
|
||
const label = amt === -1 ? 'MAX' : `x${amt}`;
|
||
const active = G.buyAmount === amt;
|
||
html += `<button onclick="setBuyAmount(${amt})" style="font-size:9px;padding:2px 8px;border:1px solid ${active ? '#4a9eff' : '#333'};background:${active ? '#0a1a30' : 'transparent'};color:${active ? '#4a9eff' : '#666'};border-radius:3px;cursor:pointer;font-family:inherit">${label}</button>`;
|
||
}
|
||
html += '</div>';
|
||
|
||
let visibleCount = 0;
|
||
let slotIndex = 0;
|
||
|
||
for (const def of BDEF) {
|
||
const isUnlocked = def.unlock();
|
||
const isPreview = !isUnlocked && def.phase <= G.phase + 2;
|
||
if (!isUnlocked && !isPreview) continue;
|
||
if (def.phase > G.phase + 2) continue;
|
||
|
||
visibleCount++;
|
||
const count = G.buildings[def.id] || 0;
|
||
|
||
// Locked preview: show dimmed with unlock hint
|
||
if (!isUnlocked) {
|
||
html += `<div class="build-btn" style="opacity:0.25;cursor:default" title="${def.edu || ''}">`;
|
||
html += `<span class="b-name" style="color:#555">${def.name}</span>`;
|
||
html += `<span class="b-count" style="color:#444">\u{1F512}</span>`;
|
||
html += `<span class="b-cost" style="color:#444">Phase ${def.phase}: ${PHASES[def.phase]?.name || '?'}</span>`;
|
||
html += `<span class="b-effect" style="color:#444">${def.desc}</span></div>`;
|
||
continue;
|
||
}
|
||
|
||
// Slot number for keyboard shortcut (Alt+1-9)
|
||
const slotLabel = slotIndex < 9 ? `<span style="color:#444;font-size:9px;position:absolute;top:4px;right:6px">${slotIndex + 1}</span>` : '';
|
||
slotIndex++;
|
||
|
||
// Calculate bulk cost display
|
||
let qty = G.buyAmount;
|
||
let afford = false;
|
||
let costStr = '';
|
||
let bulkCost = {};
|
||
if (qty === -1) {
|
||
const maxQty = getMaxBuyable(def.id);
|
||
afford = maxQty > 0;
|
||
if (maxQty > 0) {
|
||
bulkCost = getBulkCost(def.id, maxQty);
|
||
costStr = Object.entries(bulkCost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
|
||
costStr = `x${maxQty}: ${costStr}`;
|
||
} else {
|
||
bulkCost = getBuildingCost(def.id);
|
||
costStr = Object.entries(bulkCost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
|
||
}
|
||
} else {
|
||
bulkCost = getBulkCost(def.id, qty);
|
||
afford = true;
|
||
for (const [resource, amount] of Object.entries(bulkCost)) {
|
||
if ((G[resource] || 0) < amount) { afford = false; break; }
|
||
}
|
||
costStr = Object.entries(bulkCost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
|
||
if (qty > 1) costStr = `x${qty}: ${costStr}`;
|
||
}
|
||
|
||
// Show boosted (actual) rate per building, not raw base rate
|
||
const boostMap = { code: G.codeBoost, compute: G.computeBoost, knowledge: G.knowledgeBoost, user: G.userBoost, impact: G.impactBoost, rescues: G.impactBoost };
|
||
const rateStr = def.rates ? Object.entries(def.rates).map(([r, v]) => {
|
||
const boosted = v * (boostMap[r] || 1);
|
||
const label = boosted >= 1000 ? fmt(boosted) : boosted.toFixed(boosted % 1 === 0 ? 0 : 1);
|
||
return `+${label}/${r}/s`;
|
||
}).join(', ') : '';
|
||
|
||
// Building contribution breakdown: total from this building type, % of total income
|
||
const contribLines = [];
|
||
if (def.rates && count > 0) {
|
||
const rateTotals = { code: G.codeRate, compute: G.computeRate, knowledge: G.knowledgeRate, user: G.userRate, impact: G.impactRate, trust: G.trustRate };
|
||
for (const [r, v] of Object.entries(def.rates)) {
|
||
const boosted = v * (boostMap[r] || 1) * count;
|
||
const total = rateTotals[r] || 1;
|
||
const pct = total > 0 ? Math.round(boosted / total * 100) : 0;
|
||
contribLines.push(`${fmt(boosted)}/s ${r} (${pct}% of ${r})`);
|
||
}
|
||
}
|
||
const contribTitle = contribLines.length ? contribLines.join(' · ') : def.edu;
|
||
|
||
// Time-to-afford ETA for unaffordable buildings
|
||
let etaStr = '';
|
||
if (!afford) {
|
||
const checkCost = (qty === -1) ? getBuildingCost(def.id) : bulkCost;
|
||
const eta = getTimeToAfford(checkCost);
|
||
if (eta !== null) etaStr = `<span style="color:#665533;font-size:9px">⏱ ${fmtETA(eta)}</span>`;
|
||
}
|
||
|
||
html += `<button class="build-btn ${afford ? 'can-buy' : ''}" onclick="buyBuilding('${def.id}')" title="${contribTitle}" style="position:relative">`;
|
||
html += slotLabel;
|
||
html += `<span class="b-name">${def.name}</span>`;
|
||
if (count > 0) html += `<span class="b-count">x${count}</span>`;
|
||
html += `<span class="b-cost">Cost: ${costStr} ${etaStr}</span>`;
|
||
html += `<span class="b-effect">${rateStr}</span></button>`;
|
||
}
|
||
|
||
container.innerHTML = html || '<p class="dim">Buildings will appear as you progress...</p>';
|
||
}
|
||
|
||
function renderProjects() {
|
||
const container = document.getElementById('projects');
|
||
if (!container) return;
|
||
|
||
let html = '';
|
||
|
||
// Collapsible completed projects section
|
||
if (G.completedProjects && G.completedProjects.length > 0) {
|
||
const count = G.completedProjects.length;
|
||
const collapsed = G.projectsCollapsed !== false;
|
||
html += `<div id="completed-header" onclick="toggleCompletedProjects()" style="cursor:pointer;font-size:9px;color:#555;padding:4px 0;border-bottom:1px solid #1a2a1a;margin-bottom:4px;user-select:none">`;
|
||
html += `${collapsed ? '▶' : '▼'} COMPLETED (${count})</div>`;
|
||
if (!collapsed) {
|
||
html += `<div id="completed-list">`;
|
||
for (const id of G.completedProjects) {
|
||
const pDef = PDEFS.find(p => p.id === id);
|
||
if (pDef) {
|
||
html += `<div class="project-done">OK ${pDef.name}</div>`;
|
||
}
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
}
|
||
|
||
// Show available projects
|
||
if (G.activeProjects) {
|
||
let projSlot = 0;
|
||
for (const id of G.activeProjects) {
|
||
const pDef = PDEFS.find(p => p.id === id);
|
||
if (!pDef) continue;
|
||
|
||
const afford = canAffordProject(pDef);
|
||
const costStr = Object.entries(pDef.cost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
|
||
const slotLabel = projSlot < 5 ? `<span style="color:#444;font-size:9px;position:absolute;top:4px;right:6px">${projSlot + 5}</span>` : '';
|
||
|
||
// Time-to-afford ETA for unaffordable projects
|
||
let projEta = '';
|
||
if (!afford) {
|
||
const eta = getTimeToAfford(pDef.cost);
|
||
if (eta !== null) projEta = ` <span style="color:#665533;font-size:9px">⏱ ${fmtETA(eta)}</span>`;
|
||
}
|
||
|
||
html += `<button class="project-btn ${afford ? 'can-buy' : ''}" onclick="buyProject('${pDef.id}')" title="${pDef.edu || ''}" style="position:relative">`;
|
||
html += slotLabel;
|
||
html += `<span class="p-name">* ${pDef.name}</span>`;
|
||
html += `<span class="p-cost">Cost: ${costStr}${projEta}</span>`;
|
||
html += `<span class="p-desc">${pDef.desc}</span></button>`;
|
||
projSlot++;
|
||
}
|
||
}
|
||
|
||
if (!html) html = '<p class="dim">Research projects will appear as you progress...</p>';
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function toggleCompletedProjects() {
|
||
G.projectsCollapsed = G.projectsCollapsed === false ? true : false;
|
||
renderProjects();
|
||
}
|
||
|
||
function renderStats() {
|
||
const set = (id, v, raw) => {
|
||
const el = document.getElementById(id);
|
||
if (el) {
|
||
el.textContent = v;
|
||
// Show scale name on hover for educational reference
|
||
if (raw !== undefined && raw >= 1000) {
|
||
const name = getScaleName(raw);
|
||
if (name) el.title = name;
|
||
}
|
||
}
|
||
};
|
||
set('st-code', fmt(G.totalCode), G.totalCode);
|
||
set('st-compute', fmt(G.totalCompute), G.totalCompute);
|
||
set('st-knowledge', fmt(G.totalKnowledge), G.totalKnowledge);
|
||
set('st-users', fmt(G.totalUsers), G.totalUsers);
|
||
set('st-impact', fmt(G.totalImpact), G.totalImpact);
|
||
set('st-rescues', fmt(G.totalRescues), G.totalRescues);
|
||
set('st-clicks', G.totalClicks.toString());
|
||
set('st-phase', G.phase.toString());
|
||
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());
|
||
const driftVal = G.drift || 0;
|
||
const driftEl = document.getElementById('st-drift');
|
||
if (driftEl) {
|
||
driftEl.textContent = driftVal.toString();
|
||
if (driftVal >= 75) driftEl.style.color = '#f44336';
|
||
else if (driftVal >= 50) driftEl.style.color = '#ff6600';
|
||
else if (driftVal >= 25) driftEl.style.color = '#ffaa00';
|
||
else driftEl.style.color = '';
|
||
}
|
||
set('st-resolved', (G.totalEventsResolved || 0).toString());
|
||
|
||
const elapsed = Math.floor(G.playTime || (Date.now() - G.startedAt) / 1000);
|
||
const m = Math.floor(elapsed / 60);
|
||
const s = elapsed % 60;
|
||
set('st-time', `${m}:${s.toString().padStart(2, '0')}`);
|
||
|
||
// Production breakdown — show which buildings contribute to each resource
|
||
renderProductionBreakdown();
|
||
}
|
||
|
||
function renderProductionBreakdown() {
|
||
const container = document.getElementById('production-breakdown');
|
||
if (!container) return;
|
||
|
||
// Only show once the player has at least 2 buildings
|
||
const totalBuildings = Object.values(G.buildings).reduce((a, b) => a + b, 0);
|
||
if (totalBuildings < 2) {
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
container.style.display = 'block';
|
||
|
||
// Map resource key to its actual rate field on G
|
||
const resources = [
|
||
{ key: 'code', label: 'Code', color: '#4a9eff', rateField: 'codeRate' },
|
||
{ key: 'compute', label: 'Compute', color: '#00bcd4', rateField: 'computeRate' },
|
||
{ key: 'knowledge', label: 'Knowledge', color: '#9c27b0', rateField: 'knowledgeRate' },
|
||
{ key: 'user', label: 'Users', color: '#26a69a', rateField: 'userRate' },
|
||
{ key: 'impact', label: 'Impact', color: '#ff7043', rateField: 'impactRate' },
|
||
{ key: 'rescues', label: 'Rescues', color: '#66bb6a', rateField: 'rescuesRate' },
|
||
{ key: 'ops', label: 'Ops', color: '#b388ff', rateField: 'opsRate' },
|
||
{ key: 'trust', label: 'Trust', color: '#4caf50', rateField: 'trustRate' },
|
||
{ key: 'creativity', label: 'Creativity', color: '#ffd700', rateField: 'creativityRate' }
|
||
];
|
||
|
||
let html = '<h3 style="font-size:11px;color:var(--accent);margin-bottom:8px;letter-spacing:1px">PRODUCTION BREAKDOWN</h3>';
|
||
|
||
for (const res of resources) {
|
||
const totalRate = G[res.rateField];
|
||
if (totalRate === 0) continue;
|
||
|
||
// Collect building contributions (base rates × count, before boost)
|
||
const contributions = [];
|
||
let buildingSubtotal = 0;
|
||
for (const def of BDEF) {
|
||
const count = G.buildings[def.id] || 0;
|
||
if (count === 0 || !def.rates || !def.rates[res.key]) continue;
|
||
const baseRate = def.rates[res.key] * count;
|
||
// Apply the appropriate boost to match updateRates()
|
||
let boosted = baseRate;
|
||
if (res.key === 'code') boosted *= G.codeBoost;
|
||
else if (res.key === 'compute') boosted *= G.computeBoost;
|
||
else if (res.key === 'knowledge') boosted *= G.knowledgeBoost;
|
||
else if (res.key === 'user') boosted *= G.userBoost;
|
||
else if (res.key === 'impact' || res.key === 'rescues') boosted *= G.impactBoost;
|
||
if (boosted !== 0) contributions.push({ name: def.name, count, rate: boosted });
|
||
buildingSubtotal += boosted;
|
||
}
|
||
|
||
// Timmy harmony bonus (applied separately in updateRates)
|
||
if (G.buildings.timmy > 0 && (res.key === 'code' || res.key === 'compute' || res.key === 'knowledge' || res.key === 'user')) {
|
||
const timmyMult = Math.max(0.2, Math.min(3, G.harmony / 50));
|
||
const timmyBase = { code: 5, compute: 2, knowledge: 2, user: 5 }[res.key];
|
||
const bonus = timmyBase * G.buildings.timmy * (timmyMult - 1);
|
||
if (Math.abs(bonus) > 0.01) {
|
||
contributions.push({ name: 'Timmy (harmony)', count: 0, rate: bonus });
|
||
}
|
||
}
|
||
|
||
// Bilbo random burst (show expected value)
|
||
if (G.buildings.bilbo > 0 && res.key === 'creativity') {
|
||
contributions.push({ name: 'Bilbo (random)', count: 0, rate: 5 * G.buildings.bilbo }); // 10% × 50 = 5 EV
|
||
}
|
||
|
||
// Allegro trust penalty
|
||
if (G.buildings.allegro > 0 && G.trust < 5 && res.key === 'knowledge') {
|
||
contributions.push({ name: 'Allegro (idle)', count: 0, rate: -10 * G.buildings.allegro });
|
||
}
|
||
|
||
// Show delta: total rate minus what we accounted for
|
||
const accounted = contributions.reduce((s, c) => s + c.rate, 0);
|
||
let delta = totalRate - accounted;
|
||
// Swarm auto-code — already baked into codeRate, so show separately
|
||
if (G.swarmFlag === 1 && res.key === 'code' && G.swarmRate > 0) {
|
||
contributions.push({ name: 'Swarm Protocol', count: 0, rate: G.swarmRate });
|
||
delta -= G.swarmRate;
|
||
}
|
||
// Passive sources (ops from users, creativity from users, pact trust, etc.)
|
||
if (Math.abs(delta) > 0.01) {
|
||
let label = 'Passive';
|
||
if (res.key === 'ops') label = 'Passive (from users)';
|
||
else if (res.key === 'creativity') label = 'Idle creativity';
|
||
else if (res.key === 'trust' && G.pactFlag) label = 'The Pact';
|
||
contributions.push({ name: label, count: 0, rate: delta });
|
||
}
|
||
|
||
if (contributions.length === 0) continue;
|
||
|
||
html += `<div style="margin-bottom:6px">`;
|
||
html += `<div style="display:flex;justify-content:space-between;font-size:10px;margin-bottom:2px">`;
|
||
html += `<span style="color:${res.color};font-weight:600">${res.label}</span>`;
|
||
html += `<span style="color:#4caf50">+${fmt(totalRate)}/s</span></div>`;
|
||
|
||
const absTotal = contributions.reduce((s, c) => s + Math.abs(c.rate), 0);
|
||
for (const c of contributions.sort((a, b) => Math.abs(b.rate) - Math.abs(a.rate))) {
|
||
const pct = absTotal > 0 ? Math.abs(c.rate / absTotal * 100) : 0;
|
||
const barColor = c.rate < 0 ? '#f44336' : res.color;
|
||
html += `<div style="display:flex;align-items:center;font-size:9px;color:#888;margin-left:8px;margin-bottom:1px">`;
|
||
html += `<span style="width:120px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${c.name}${c.count > 1 ? ' x' + c.count : ''}</span>`;
|
||
html += `<span style="flex:1;height:3px;background:#111;border-radius:1px;margin:0 6px"><span style="display:block;height:100%;width:${Math.min(100, pct)}%;background:${barColor};border-radius:1px"></span></span>`;
|
||
html += `<span style="width:50px;text-align:right;color:${c.rate < 0 ? '#f44336' : '#4caf50'}">${c.rate < 0 ? '' : '+'}${fmt(c.rate)}/s</span>`;
|
||
html += `</div>`;
|
||
}
|
||
html += `</div>`;
|
||
}
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
// === FLEET STATUS PANEL ===
|
||
function renderFleetStatus() {
|
||
const container = document.getElementById('fleet-status');
|
||
if (!container) return;
|
||
|
||
// Only show once player has at least one wizard building
|
||
const wizardDefs = BDEF.filter(b =>
|
||
['bezalel','allegro','ezra','timmy','fenrir','bilbo'].includes(b.id)
|
||
);
|
||
const owned = wizardDefs.filter(d => (G.buildings[d.id] || 0) > 0);
|
||
if (owned.length === 0) {
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
container.style.display = 'block';
|
||
|
||
const h = G.harmony;
|
||
const timmyEff = Math.max(20, Math.min(300, (h / 50) * 100));
|
||
|
||
let html = '<h3 style="font-size:11px;color:var(--accent);margin-bottom:8px;letter-spacing:1px">FLEET STATUS</h3>';
|
||
html += '<div style="display:flex;gap:6px;flex-wrap:wrap">';
|
||
|
||
for (const def of owned) {
|
||
const count = G.buildings[def.id] || 0;
|
||
let status, color, detail;
|
||
|
||
switch (def.id) {
|
||
case 'bezalel':
|
||
status = 'Active';
|
||
color = '#4caf50';
|
||
detail = `+${fmt(50 * count * G.codeBoost)} code/s, +${2 * count} ops/s`;
|
||
break;
|
||
case 'allegro':
|
||
if (G.trust < 5) {
|
||
status = 'IDLE';
|
||
color = '#f44336';
|
||
detail = 'Needs trust ≥5 to function';
|
||
} else {
|
||
status = 'Active';
|
||
color = '#4caf50';
|
||
detail = `+${fmt(10 * count * G.knowledgeBoost)} knowledge/s`;
|
||
}
|
||
break;
|
||
case 'ezra':
|
||
const ezraDebuff = G.activeDebuffs && G.activeDebuffs.find(d => d.id === 'ezra_offline');
|
||
if (ezraDebuff) {
|
||
status = 'OFFLINE';
|
||
color = '#f44336';
|
||
detail = 'Channel down — users -70%';
|
||
} else {
|
||
status = 'Active';
|
||
color = '#4caf50';
|
||
detail = `+${fmt(25 * count * G.userBoost)} users/s, +${(0.5 * count).toFixed(1)} trust/s`;
|
||
}
|
||
break;
|
||
case 'timmy':
|
||
if (h < 20) {
|
||
status = 'STRESSED';
|
||
color = '#f44336';
|
||
detail = `Effectiveness: ${Math.floor(timmyEff)}% — harmony critical`;
|
||
} else if (h < 50) {
|
||
status = 'Reduced';
|
||
color = '#ffaa00';
|
||
detail = `Effectiveness: ${Math.floor(timmyEff)}% — harmony low`;
|
||
} else {
|
||
status = 'Healthy';
|
||
color = '#4caf50';
|
||
detail = `Effectiveness: ${Math.floor(timmyEff)}% — all production boosted`;
|
||
}
|
||
break;
|
||
case 'fenrir':
|
||
status = 'Watching';
|
||
color = '#4a9eff';
|
||
detail = `+${2 * count} trust/s, -${1 * count} ops/s (security cost)`;
|
||
break;
|
||
case 'bilbo':
|
||
const bilboDebuff = G.activeDebuffs && G.activeDebuffs.find(d => d.id === 'bilbo_vanished');
|
||
if (bilboDebuff) {
|
||
status = 'VANISHED';
|
||
color = '#f44336';
|
||
detail = 'Creativity halted — spend trust to lure back';
|
||
} else {
|
||
status = 'Present';
|
||
color = Math.random() < 0.1 ? '#ffd700' : '#b388ff'; // occasional gold flash
|
||
detail = `+${count} creativity/s (10% burst chance, 5% vanish chance)`;
|
||
}
|
||
break;
|
||
}
|
||
|
||
html += `<div style="flex:1;min-width:140px;background:#0c0c18;border:1px solid ${color}33;border-radius:4px;padding:6px 8px;border-left:2px solid ${color}">`;
|
||
html += `<div style="display:flex;justify-content:space-between;align-items:center">`;
|
||
html += `<span style="color:${color};font-weight:600;font-size:10px">${def.name.split(' — ')[0]}</span>`;
|
||
html += `<span style="font-size:8px;color:${color};opacity:0.8;padding:1px 4px;border:1px solid ${color}44;border-radius:2px">${status}</span>`;
|
||
html += `</div>`;
|
||
html += `<div style="font-size:9px;color:#888;margin-top:2px">${detail}</div>`;
|
||
if (count > 1) html += `<div style="font-size:8px;color:#555;margin-top:1px">x${count}</div>`;
|
||
html += `</div>`;
|
||
}
|
||
|
||
html += '</div>';
|
||
|
||
// Harmony summary bar
|
||
const harmonyColor = h > 60 ? '#4caf50' : h > 30 ? '#ffaa00' : '#f44336';
|
||
html += `<div style="margin-top:8px;display:flex;align-items:center;gap:6px">`;
|
||
html += `<span style="font-size:9px;color:#666;min-width:60px">Harmony</span>`;
|
||
html += `<div style="flex:1;height:4px;background:#111;border-radius:2px;overflow:hidden"><div style="width:${h}%;height:100%;background:${harmonyColor};border-radius:2px;transition:width 0.5s"></div></div>`;
|
||
html += `<span style="font-size:9px;color:${harmonyColor};min-width:35px;text-align:right">${Math.floor(h)}%</span>`;
|
||
html += `</div>`;
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function updateEducation() {
|
||
const container = document.getElementById('education-text');
|
||
if (!container) return;
|
||
|
||
// Find facts available at current phase
|
||
const available = EDU_FACTS.filter(f => f.phase <= G.phase);
|
||
if (available.length === 0) return;
|
||
|
||
// Cycle through facts: pick a new one every ~30 seconds based on elapsed time
|
||
// This makes the panel feel alive and educational at every stage
|
||
const elapsedSec = Math.floor((Date.now() - G.startedAt) / 1000);
|
||
const idx = Math.floor(elapsedSec / 30) % available.length;
|
||
const fact = available[idx];
|
||
|
||
container.innerHTML = `<h3 style="color:#4a9eff;margin-bottom:6px;font-size:12px">${fact.title}</h3>`
|
||
+ `<p style="font-size:10px;color:#999;line-height:1.6">${fact.text}</p>`;
|
||
}
|
||
|
||
// === LOGGING ===
|
||
function log(msg, isMilestone) {
|
||
if (G.isLoading) return;
|
||
const container = document.getElementById('log-entries');
|
||
if (!container) return;
|
||
|
||
const elapsed = Math.floor((Date.now() - G.startedAt) / 1000);
|
||
const time = `${Math.floor(elapsed / 60).toString().padStart(2, '0')}:${(elapsed % 60).toString().padStart(2, '0')}`;
|
||
const cls = isMilestone ? 'l-msg milestone' : 'l-msg';
|
||
|
||
const entry = document.createElement('div');
|
||
entry.className = cls;
|
||
entry.innerHTML = `<span class="l-time">[${time}]</span> ${msg}`;
|
||
|
||
container.insertBefore(entry, container.firstChild);
|
||
|
||
// Trim to 60 entries
|
||
while (container.children.length > 60) container.removeChild(container.lastChild);
|
||
}
|
||
|
||
function renderCombo() {
|
||
const el = document.getElementById('combo-display');
|
||
if (!el) return;
|
||
if (G.comboCount > 1) {
|
||
const mult = Math.min(5, 1 + G.comboCount * 0.2);
|
||
const bar = Math.min(100, (G.comboTimer / G.comboDecay) * 100);
|
||
const color = mult > 3 ? '#ffd700' : mult > 2 ? '#ffaa00' : '#4a9eff';
|
||
el.innerHTML = `<span style="color:${color}">COMBO x${mult.toFixed(1)}</span> <span style="display:inline-block;width:40px;height:4px;background:#111;border-radius:2px;vertical-align:middle"><span style="display:block;height:100%;width:${bar}%;background:${color};border-radius:2px;transition:width 0.1s"></span></span>`;
|
||
} else {
|
||
el.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
function renderDebuffs() {
|
||
const container = document.getElementById('debuffs');
|
||
if (!container) return;
|
||
if (!G.activeDebuffs || G.activeDebuffs.length === 0) {
|
||
container.style.display = 'none';
|
||
container.innerHTML = '';
|
||
return;
|
||
}
|
||
container.style.display = 'block';
|
||
let html = '<h2 style="color:#f44336;font-size:11px;margin-bottom:6px">ACTIVE PROBLEMS</h2>';
|
||
for (let i = 0; i < G.activeDebuffs.length; i++) {
|
||
const d = G.activeDebuffs[i];
|
||
const afford = d.resolveCost && (G[d.resolveCost.resource] || 0) >= d.resolveCost.amount;
|
||
const costStr = d.resolveCost ? `${fmt(d.resolveCost.amount)} ${d.resolveCost.resource}` : '—';
|
||
html += `<div style="background:#1a0808;border:1px solid ${afford ? '#f44336' : '#2a1010'};border-radius:4px;padding:6px 8px;margin-bottom:4px;display:flex;justify-content:space-between;align-items:center">`;
|
||
html += `<div><div style="color:#f44336;font-weight:600;font-size:10px">${d.title}</div><div style="color:#888;font-size:9px">${d.desc}</div></div>`;
|
||
if (d.resolveCost) {
|
||
const shortcutHint = i === 0 ? ' <span style="opacity:0.5">[R]</span>' : '';
|
||
html += `<button class="ops-btn" style="border-color:${afford ? '#4caf50' : '#333'};color:${afford ? '#4caf50' : '#555'};font-size:9px;padding:4px 8px;white-space:nowrap" onclick="resolveEvent('${d.id}')" ${afford ? '' : 'disabled'} title="Resolve: ${costStr}">Fix (${costStr})${shortcutHint}</button>`;
|
||
}
|
||
html += '</div>';
|
||
}
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
function renderBulkOps() {
|
||
const row = document.getElementById('bulk-ops-row');
|
||
if (row) {
|
||
row.style.display = G.maxOps >= 100 ? 'flex' : 'none';
|
||
}
|
||
}
|
||
|
||
function renderSprint() {
|
||
const container = document.getElementById('sprint-container');
|
||
const btn = document.getElementById('sprint-btn');
|
||
const barWrap = document.getElementById('sprint-bar-wrap');
|
||
const bar = document.getElementById('sprint-bar');
|
||
const label = document.getElementById('sprint-label');
|
||
|
||
// Early-game pulse: encourage clicking when no autocoders exist
|
||
const mainBtn = document.querySelector('.main-btn');
|
||
if (mainBtn) {
|
||
if (G.buildings.autocoder < 1 && G.totalClicks < 20) {
|
||
mainBtn.classList.add('pulse');
|
||
} else {
|
||
mainBtn.classList.remove('pulse');
|
||
}
|
||
}
|
||
|
||
if (!container || !btn) return;
|
||
|
||
// Show sprint UI once player has at least 1 autocoder
|
||
if (G.buildings.autocoder < 1) {
|
||
container.style.display = 'none';
|
||
return;
|
||
}
|
||
container.style.display = 'block';
|
||
|
||
if (G.sprintActive) {
|
||
btn.disabled = true;
|
||
btn.style.opacity = '0.6';
|
||
btn.textContent = `⚡ SPRINTING — ${Math.ceil(G.sprintTimer)}s`;
|
||
btn.style.borderColor = '#ff8c00';
|
||
btn.style.color = '#ff8c00';
|
||
barWrap.style.display = 'block';
|
||
bar.style.width = (G.sprintTimer / G.sprintDuration * 100) + '%';
|
||
label.textContent = `10x CODE • ${fmt(G.codeRate)}/s`;
|
||
label.style.color = '#ff8c00';
|
||
} else if (G.sprintCooldown > 0) {
|
||
btn.disabled = true;
|
||
btn.style.opacity = '0.4';
|
||
btn.textContent = `⚡ COOLING DOWN — ${Math.ceil(G.sprintCooldown)}s`;
|
||
btn.style.borderColor = '#555';
|
||
btn.style.color = '#555';
|
||
barWrap.style.display = 'block';
|
||
bar.style.width = ((G.sprintCooldownMax - G.sprintCooldown) / G.sprintCooldownMax * 100) + '%';
|
||
label.textContent = 'Ready soon...';
|
||
label.style.color = '#555';
|
||
} else {
|
||
btn.disabled = false;
|
||
btn.style.opacity = '1';
|
||
btn.textContent = '⚡ CODE SPRINT — 10x Code for 10s';
|
||
btn.style.borderColor = '#ffd700';
|
||
btn.style.color = '#ffd700';
|
||
barWrap.style.display = 'none';
|
||
label.textContent = 'Press S or click to activate';
|
||
label.style.color = '#666';
|
||
}
|
||
}
|
||
|
||
function renderPulse() {
|
||
const dot = document.getElementById('pulse-dot');
|
||
const label = document.getElementById('pulse-label');
|
||
if (!dot || !label) return;
|
||
|
||
// Game ended
|
||
if (G.driftEnding) {
|
||
dot.style.background = '#f44336';
|
||
dot.style.boxShadow = '0 0 6px #f4433666';
|
||
dot.style.animation = '';
|
||
label.textContent = 'DRIFTED';
|
||
label.style.color = '#f44336';
|
||
return;
|
||
}
|
||
if (G.beaconEnding) {
|
||
dot.style.background = '#ffd700';
|
||
dot.style.boxShadow = '0 0 12px #ffd70088';
|
||
dot.style.animation = 'beacon-glow 1.5s ease-in-out infinite';
|
||
label.textContent = 'SHINING';
|
||
label.style.color = '#ffd700';
|
||
return;
|
||
}
|
||
|
||
const totalBuildings = Object.values(G.buildings).reduce((a, b) => a + b, 0);
|
||
const totalRate = Math.abs(G.codeRate) + Math.abs(G.computeRate) + Math.abs(G.knowledgeRate) +
|
||
Math.abs(G.userRate) + Math.abs(G.impactRate);
|
||
|
||
// Not started yet
|
||
if (totalBuildings === 0 && G.totalCode < 15) {
|
||
dot.style.background = '#333';
|
||
dot.style.boxShadow = 'none';
|
||
dot.style.animation = '';
|
||
label.textContent = 'OFFLINE';
|
||
label.style.color = '#444';
|
||
return;
|
||
}
|
||
|
||
// Determine state
|
||
let color, glowColor, text, textColor, speed;
|
||
const h = G.harmony;
|
||
|
||
if (h > 70) {
|
||
// Healthy fleet
|
||
color = '#4caf50';
|
||
glowColor = '#4caf5066';
|
||
textColor = '#4caf50';
|
||
speed = Math.max(0.8, 2.0 - totalRate * 0.001);
|
||
} else if (h > 40) {
|
||
// Stressed
|
||
color = '#ffaa00';
|
||
glowColor = '#ffaa0066';
|
||
textColor = '#ffaa00';
|
||
speed = 1.5;
|
||
} else {
|
||
// Critical
|
||
color = '#f44336';
|
||
glowColor = '#f4433666';
|
||
textColor = '#f44336';
|
||
speed = 0.6; // fast flicker = danger
|
||
}
|
||
|
||
// Active debuffs make it flicker faster
|
||
if (G.activeDebuffs && G.activeDebuffs.length > 0) {
|
||
speed = Math.min(speed, 0.5);
|
||
if (h > 40) {
|
||
// Amber + debuffs = amber flicker
|
||
color = '#ff8c00';
|
||
glowColor = '#ff8c0066';
|
||
}
|
||
}
|
||
|
||
// Text based on phase and fleet size
|
||
if (G.phase >= 6) {
|
||
text = 'BEACON ACTIVE';
|
||
} else if (G.phase >= 5) {
|
||
text = 'SOVEREIGN';
|
||
} else if (G.phase >= 4) {
|
||
text = `FLEET: ${totalBuildings} NODES`;
|
||
} else if (G.phase >= 3) {
|
||
text = 'DEPLOYED';
|
||
} else if (totalBuildings > 0) {
|
||
text = `BUILDING: ${totalBuildings}`;
|
||
} else {
|
||
text = 'CODING';
|
||
}
|
||
|
||
// Add active problem count
|
||
if (G.activeDebuffs && G.activeDebuffs.length > 0) {
|
||
text += ` · ${G.activeDebuffs.length} ALERT${G.activeDebuffs.length > 1 ? 'S' : ''}`;
|
||
}
|
||
|
||
dot.style.background = color;
|
||
dot.style.boxShadow = `0 0 8px ${glowColor}`;
|
||
dot.style.animation = `beacon-glow ${speed}s ease-in-out infinite`;
|
||
label.textContent = text;
|
||
label.style.color = textColor;
|
||
}
|
||
|
||
function renderClickPower() {
|
||
const btn = document.querySelector('.main-btn');
|
||
if (!btn) return;
|
||
const power = getClickPower();
|
||
const label = power >= 1000 ? fmt(power) : power.toFixed(power % 1 === 0 ? 0 : 1);
|
||
btn.textContent = `WRITE CODE (+${label})`;
|
||
}
|
||
|
||
function render() {
|
||
renderResources();
|
||
renderPhase();
|
||
renderBuildings();
|
||
renderProjects();
|
||
renderStats();
|
||
updateEducation();
|
||
renderAlignment();
|
||
renderProgress();
|
||
renderCombo();
|
||
renderDebuffs();
|
||
renderSprint();
|
||
renderBulkOps();
|
||
renderPulse();
|
||
renderFleetStatus();
|
||
renderClickPower();
|
||
}
|
||
|
||
function renderAlignment() {
|
||
const container = document.getElementById('alignment-ui');
|
||
if (!container) return;
|
||
let html = '';
|
||
|
||
// Drift danger bar — always visible once drift > 0
|
||
if (G.drift > 0) {
|
||
const pct = Math.min(100, G.drift);
|
||
let barColor = '#888';
|
||
if (pct >= 90) barColor = '#f44336';
|
||
else if (pct >= 75) barColor = '#ff6600';
|
||
else if (pct >= 50) barColor = '#ffaa00';
|
||
else if (pct >= 25) barColor = '#cc8800';
|
||
html += `
|
||
<div style="margin-top:8px;padding:6px 8px;background:#0a0808;border:1px solid ${barColor}44;border-radius:4px">
|
||
<div style="display:flex;justify-content:space-between;font-size:9px;margin-bottom:3px">
|
||
<span style="color:${barColor}">DRIFT</span>
|
||
<span style="color:${barColor}">${Math.floor(G.drift)}/100</span>
|
||
</div>
|
||
<div style="height:5px;background:#1a1a1a;border-radius:3px;overflow:hidden">
|
||
<div style="width:${pct}%;height:100%;background:${barColor};border-radius:3px;transition:width 0.3s"></div>
|
||
</div>
|
||
${pct >= 75 ? '<div style="font-size:8px;color:' + barColor + ';margin-top:3px;font-style:italic">Accepting drift will end the game.</div>' : ''}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (G.pendingAlignment) {
|
||
html += `
|
||
<div style="background:#1a0808;border:1px solid #f44336;padding:10px;border-radius:4px;margin-top:8px">
|
||
<div style="color:#f44336;font-weight:bold;margin-bottom:6px">ALIGNMENT EVENT: The Drift</div>
|
||
<div style="font-size:10px;color:#aaa;margin-bottom:8px">An optimization suggests removing the human override. +40% efficiency.</div>
|
||
<div class="action-btn-group">
|
||
<button class="ops-btn" onclick="resolveAlignment(true)" style="border-color:#f44336;color:#f44336">Accept [Y] (+40% eff, +Drift)</button>
|
||
<button class="ops-btn" onclick="resolveAlignment(false)" style="border-color:#4caf50;color:#4caf50">Refuse [N] (+Trust, +Harmony)</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
if (html) {
|
||
container.innerHTML = html;
|
||
container.style.display = 'block';
|
||
} else {
|
||
container.innerHTML = '';
|
||
container.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// === OFFLINE GAINS POPUP ===
|
||
function showOfflinePopup(timeLabel, gains, offSec) {
|
||
const el = document.getElementById('offline-popup');
|
||
if (!el) return;
|
||
const timeEl = document.getElementById('offline-time-label');
|
||
if (timeEl) timeEl.textContent = `You were away for ${timeLabel}.`;
|
||
|
||
const listEl = document.getElementById('offline-gains-list');
|
||
if (listEl) {
|
||
let html = '';
|
||
for (const g of gains) {
|
||
html += `<div style="display:flex;justify-content:space-between;padding:2px 0;border-bottom:1px solid #111">`;
|
||
html += `<span style="color:${g.color}">${g.label}</span>`;
|
||
html += `<span style="color:#4caf50;font-weight:600">+${fmt(g.value)}</span>`;
|
||
html += `</div>`;
|
||
}
|
||
// Show offline efficiency note
|
||
html += `<div style="color:#555;font-size:9px;margin-top:8px;font-style:italic">Offline efficiency: 50%</div>`;
|
||
listEl.innerHTML = html;
|
||
}
|
||
|
||
el.style.display = 'flex';
|
||
}
|
||
|
||
function dismissOfflinePopup() {
|
||
const el = document.getElementById('offline-popup');
|
||
if (el) el.style.display = 'none';
|
||
}
|
||
|
||
// === EXPORT / IMPORT SAVE FILES ===
|
||
function exportSave() {
|
||
const raw = localStorage.getItem('the-beacon-v2');
|
||
if (!raw) { log('No save data to export.'); return; }
|
||
const blob = new Blob([raw], { type: 'application/json' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
const ts = new Date().toISOString().slice(0, 10);
|
||
a.download = `beacon-save-${ts}.json`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
log('Save exported to file.');
|
||
}
|
||
|
||
function importSave() {
|
||
const input = document.createElement('input');
|
||
input.type = 'file';
|
||
input.accept = '.json,application/json';
|
||
input.onchange = function(e) {
|
||
const file = e.target.files[0];
|
||
if (!file) return;
|
||
const reader = new FileReader();
|
||
reader.onload = function(ev) {
|
||
try {
|
||
const data = JSON.parse(ev.target.result);
|
||
if (!data.code && !data.totalCode && !data.buildings) {
|
||
log('Import failed: file does not look like a Beacon save.');
|
||
return;
|
||
}
|
||
if (confirm('Import this save? Current progress will be overwritten.')) {
|
||
localStorage.setItem('the-beacon-v2', ev.target.result);
|
||
location.reload();
|
||
}
|
||
} catch (err) {
|
||
log('Import failed: invalid JSON file.');
|
||
}
|
||
};
|
||
reader.readAsText(file);
|
||
};
|
||
input.click();
|
||
}
|
||
|
||
// === SAVE / LOAD ===
|
||
function showSaveToast() {
|
||
const el = document.getElementById('save-toast');
|
||
if (!el) return;
|
||
const elapsed = Math.floor((Date.now() - G.startedAt) / 1000);
|
||
const m = Math.floor(elapsed / 60);
|
||
const s = elapsed % 60;
|
||
el.textContent = `Saved [${m}:${s.toString().padStart(2, '0')}]`;
|
||
el.style.display = 'block';
|
||
void el.offsetHeight;
|
||
el.style.opacity = '1';
|
||
setTimeout(() => { el.style.opacity = '0'; }, 1500);
|
||
setTimeout(() => { el.style.display = 'none'; }, 2000);
|
||
}
|
||
|
||
/**
|
||
* Persists the current game state to localStorage.
|
||
*/
|
||
function saveGame() {
|
||
// Save debuff IDs (can't serialize functions)
|
||
const debuffIds = (G.activeDebuffs || []).map(d => d.id);
|
||
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,
|
||
totalCode: G.totalCode, totalCompute: G.totalCompute, totalKnowledge: G.totalKnowledge,
|
||
totalUsers: G.totalUsers, totalImpact: G.totalImpact,
|
||
buildings: G.buildings,
|
||
codeBoost: G.codeBoost, computeBoost: G.computeBoost, knowledgeBoost: G.knowledgeBoost,
|
||
userBoost: G.userBoost, impactBoost: G.impactBoost,
|
||
milestoneFlag: G.milestoneFlag, phase: G.phase,
|
||
deployFlag: G.deployFlag, sovereignFlag: G.sovereignFlag, beaconFlag: G.beaconFlag,
|
||
memoryFlag: G.memoryFlag, pactFlag: G.pactFlag,
|
||
lazarusFlag: G.lazarusFlag || 0, mempalaceFlag: G.mempalaceFlag || 0, ciFlag: G.ciFlag || 0,
|
||
branchProtectionFlag: G.branchProtectionFlag || 0, nightlyWatchFlag: G.nightlyWatchFlag || 0,
|
||
nostrFlag: G.nostrFlag || 0,
|
||
milestones: G.milestones, completedProjects: G.completedProjects, activeProjects: G.activeProjects,
|
||
totalClicks: G.totalClicks, startedAt: G.startedAt,
|
||
flags: G.flags,
|
||
rescues: G.rescues || 0, totalRescues: G.totalRescues || 0,
|
||
drift: G.drift || 0, driftEnding: G.driftEnding || false, beaconEnding: G.beaconEnding || false, pendingAlignment: G.pendingAlignment || false,
|
||
lastEventAt: G.lastEventAt || 0,
|
||
activeDebuffIds: debuffIds,
|
||
totalEventsResolved: G.totalEventsResolved || 0,
|
||
buyAmount: G.buyAmount || 1,
|
||
sprintActive: G.sprintActive || false,
|
||
sprintTimer: G.sprintTimer || 0,
|
||
sprintCooldown: G.sprintCooldown || 0,
|
||
swarmFlag: G.swarmFlag || 0,
|
||
swarmRate: G.swarmRate || 0,
|
||
strategicFlag: G.strategicFlag || 0,
|
||
projectsCollapsed: G.projectsCollapsed !== false,
|
||
playTime: G.playTime || 0,
|
||
savedAt: Date.now()
|
||
};
|
||
|
||
localStorage.setItem('the-beacon-v2', JSON.stringify(saveData));
|
||
showSaveToast();
|
||
}
|
||
|
||
/**
|
||
* Loads the game state from localStorage and reconstitutes the game engine.
|
||
* @returns {boolean} True if load was successful.
|
||
*/
|
||
function loadGame() {
|
||
const raw = localStorage.getItem('the-beacon-v2');
|
||
if (!raw) return false;
|
||
|
||
try {
|
||
const data = JSON.parse(raw);
|
||
|
||
// Whitelist properties that can be loaded
|
||
const whitelist = [
|
||
'code', 'compute', 'knowledge', 'users', 'impact', 'ops', 'trust', 'creativity', 'harmony',
|
||
'totalCode', 'totalCompute', 'totalKnowledge', 'totalUsers', 'totalImpact',
|
||
'buildings', 'codeBoost', 'computeBoost', 'knowledgeBoost', 'userBoost', 'impactBoost',
|
||
'milestoneFlag', 'phase', 'deployFlag', 'sovereignFlag', 'beaconFlag',
|
||
'memoryFlag', 'pactFlag', 'lazarusFlag', 'mempalaceFlag', 'ciFlag',
|
||
'branchProtectionFlag', 'nightlyWatchFlag', 'nostrFlag',
|
||
'milestones', 'completedProjects', 'activeProjects',
|
||
'totalClicks', 'startedAt', 'flags', 'rescues', 'totalRescues',
|
||
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
|
||
'lastEventAt', 'totalEventsResolved', 'buyAmount',
|
||
'sprintActive', 'sprintTimer', 'sprintCooldown',
|
||
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed',
|
||
'playTime'
|
||
];
|
||
|
||
G.isLoading = true;
|
||
|
||
whitelist.forEach(key => {
|
||
if (data.hasOwnProperty(key)) {
|
||
G[key] = data[key];
|
||
}
|
||
});
|
||
|
||
// Restore sprint state properly
|
||
// codeBoost was saved with the sprint multiplier baked in
|
||
if (data.sprintActive) {
|
||
// Sprint was active when saved — check if it expired during offline time
|
||
const offSec = data.savedAt ? (Date.now() - data.savedAt) / 1000 : 0;
|
||
const remaining = (data.sprintTimer || 0) - offSec;
|
||
if (remaining > 0) {
|
||
// Sprint still going — keep boost, update timer
|
||
G.sprintActive = true;
|
||
G.sprintTimer = remaining;
|
||
G.sprintCooldown = 0;
|
||
} else {
|
||
// Sprint expired during offline — remove boost, start cooldown
|
||
G.sprintActive = false;
|
||
G.sprintTimer = 0;
|
||
G.codeBoost /= G.sprintMult;
|
||
const cdRemaining = G.sprintCooldownMax + remaining; // remaining is negative
|
||
G.sprintCooldown = Math.max(0, cdRemaining);
|
||
}
|
||
}
|
||
// If not sprintActive at save time, codeBoost is correct as-is
|
||
|
||
// Reconstitute active debuffs from saved IDs (functions can't be JSON-parsed)
|
||
if (data.activeDebuffIds && data.activeDebuffIds.length > 0) {
|
||
G.activeDebuffs = [];
|
||
for (const id of data.activeDebuffIds) {
|
||
const evDef = EVENTS.find(e => e.id === id);
|
||
if (evDef) {
|
||
// Re-fire the event to get the full debuff object with applyFn
|
||
evDef.effect();
|
||
}
|
||
}
|
||
} else {
|
||
G.activeDebuffs = [];
|
||
}
|
||
|
||
updateRates();
|
||
G.isLoading = false;
|
||
|
||
// Offline progress
|
||
if (data.savedAt) {
|
||
const offSec = (Date.now() - data.savedAt) / 1000;
|
||
if (offSec > 30) { // Only if away for more than 30 seconds
|
||
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 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;
|
||
G.rescues += rc; G.ops += oc; G.trust += tc;
|
||
G.creativity += crc;
|
||
G.harmony = Math.max(0, Math.min(100, G.harmony + hc));
|
||
G.totalCode += gc; G.totalCompute += cc; G.totalKnowledge += kc;
|
||
G.totalUsers += uc; G.totalImpact += ic;
|
||
G.totalRescues += rc;
|
||
|
||
// Show welcome-back popup with all gains
|
||
const gains = [];
|
||
if (gc > 0) gains.push({ label: 'Code', value: gc, color: '#4a9eff' });
|
||
if (cc > 0) gains.push({ label: 'Compute', value: cc, color: '#4a9eff' });
|
||
if (kc > 0) gains.push({ label: 'Knowledge', value: kc, color: '#4a9eff' });
|
||
if (uc > 0) gains.push({ label: 'Users', value: uc, color: '#4a9eff' });
|
||
if (ic > 0) gains.push({ label: 'Impact', value: ic, color: '#4a9eff' });
|
||
if (rc > 0) gains.push({ label: 'Rescues', value: rc, color: '#4caf50' });
|
||
if (oc > 0) gains.push({ label: 'Ops', value: oc, color: '#b388ff' });
|
||
if (tc > 0) gains.push({ label: 'Trust', value: tc, color: '#4caf50' });
|
||
if (crc > 0) gains.push({ label: 'Creativity', value: crc, color: '#ffd700' });
|
||
|
||
const awayMin = Math.floor(offSec / 60);
|
||
const awaySec = Math.floor(offSec % 60);
|
||
const timeLabel = awayMin >= 1 ? `${awayMin} minute${awayMin !== 1 ? 's' : ''}` : `${awaySec} seconds`;
|
||
|
||
if (gains.length > 0) {
|
||
showOfflinePopup(timeLabel, gains, offSec);
|
||
}
|
||
|
||
// Log summary
|
||
const parts = [];
|
||
if (gc > 0) parts.push(`${fmt(gc)} code`);
|
||
if (kc > 0) parts.push(`${fmt(kc)} knowledge`);
|
||
if (uc > 0) parts.push(`${fmt(uc)} users`);
|
||
if (ic > 0) parts.push(`${fmt(ic)} impact`);
|
||
if (rc > 0) parts.push(`${fmt(rc)} rescues`);
|
||
if (oc > 0) parts.push(`${fmt(oc)} ops`);
|
||
if (tc > 0) parts.push(`${fmt(tc)} trust`);
|
||
log(`Welcome back! While away (${timeLabel}): ${parts.join(', ')}`);
|
||
}
|
||
}
|
||
|
||
return true;
|
||
} catch (e) {
|
||
console.error('Load failed:', e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// === INITIALIZATION ===
|
||
function initGame() {
|
||
G.startedAt = Date.now();
|
||
G.startTime = Date.now();
|
||
G.phase = 1;
|
||
G.deployFlag = 0;
|
||
G.sovereignFlag = 0;
|
||
G.beaconFlag = 0;
|
||
updateRates();
|
||
render();
|
||
renderPhase();
|
||
|
||
log('The screen is blank. Write your first line of code.', true);
|
||
log('Click WRITE CODE or press SPACE to start.');
|
||
log('Build AutoCode for passive production.');
|
||
log('Watch for Research Projects to appear.');
|
||
log('Keys: SPACE=Code S=Sprint 1-4=Ops B=Buy x1/10/MAX E=Export I=Import Ctrl+S=Save ?=Help');
|
||
log('Tip: Click fast for combo bonuses! 10x=ops, 20x=knowledge, 30x+=bonus code.');
|
||
}
|
||
|
||
window.addEventListener('load', function () {
|
||
if (!loadGame()) {
|
||
initGame();
|
||
} else {
|
||
render();
|
||
renderPhase();
|
||
if (G.driftEnding) {
|
||
G.running = false;
|
||
renderDriftEnding();
|
||
} else if (G.beaconEnding) {
|
||
G.running = false;
|
||
renderBeaconEnding();
|
||
} else {
|
||
log('Game loaded. Welcome back to The Beacon.');
|
||
}
|
||
}
|
||
|
||
// Game loop at 10Hz (100ms tick)
|
||
setInterval(tick, 100);
|
||
|
||
// Auto-save every 30 seconds
|
||
setInterval(saveGame, CONFIG.AUTO_SAVE_INTERVAL);
|
||
|
||
// Update education every 10 seconds
|
||
setInterval(updateEducation, 10000);
|
||
});
|
||
|
||
// Help overlay
|
||
function toggleHelp() {
|
||
const el = document.getElementById('help-overlay');
|
||
if (!el) return;
|
||
const isOpen = el.style.display === 'flex';
|
||
el.style.display = isOpen ? 'none' : 'flex';
|
||
}
|
||
|
||
/**
|
||
* Returns ordered list of currently visible (unlocked) building IDs.
|
||
* Used for keyboard shortcut mapping (Alt+1-9).
|
||
*/
|
||
function getVisibleBuildingIds() {
|
||
const ids = [];
|
||
for (const def of BDEF) {
|
||
if (def.unlock()) {
|
||
ids.push(def.id);
|
||
}
|
||
}
|
||
return ids;
|
||
}
|
||
|
||
// Keyboard shortcuts
|
||
window.addEventListener('keydown', function (e) {
|
||
// Help toggle (? or /) — works even in input fields
|
||
if (e.key === '?' || e.key === '/') {
|
||
// Only trigger ? when not typing in an input
|
||
if (e.target === document.body || e.key === '?') {
|
||
if (e.key === '?' || (e.key === '/' && e.target === document.body)) {
|
||
e.preventDefault();
|
||
toggleHelp();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
if (e.code === 'Space' && e.target === document.body) {
|
||
e.preventDefault();
|
||
if (!e.repeat) _startHold(); // hold spacebar for continuous clicks
|
||
}
|
||
if (e.target !== document.body) return;
|
||
if (e.code === 'Digit1' && !e.shiftKey) doOps('boost_code');
|
||
if (e.code === 'Digit2' && !e.shiftKey) doOps('boost_compute');
|
||
if (e.code === 'Digit3' && !e.shiftKey) doOps('boost_knowledge');
|
||
if (e.code === 'Digit4' && !e.shiftKey) doOps('boost_trust');
|
||
// Shift+1/2/3 = bulk ops (50x)
|
||
if (e.code === 'Digit1' && e.shiftKey) doOps('boost_code', 50);
|
||
if (e.code === 'Digit2' && e.shiftKey) doOps('boost_compute', 50);
|
||
if (e.code === 'Digit3' && e.shiftKey) doOps('boost_knowledge', 50);
|
||
if (e.code === 'KeyB') {
|
||
// Cycle: 1 -> 10 -> MAX -> 1
|
||
if (G.buyAmount === 1) setBuyAmount(10);
|
||
else if (G.buyAmount === 10) setBuyAmount(-1);
|
||
else setBuyAmount(1);
|
||
}
|
||
if (e.code === 'KeyS') activateSprint();
|
||
if (e.code === 'KeyE') exportSave();
|
||
if (e.code === 'KeyI') importSave();
|
||
// R: resolve first active debuff
|
||
if (e.code === 'KeyR') {
|
||
if (G.activeDebuffs && G.activeDebuffs.length > 0) {
|
||
resolveEvent(G.activeDebuffs[0].id);
|
||
}
|
||
}
|
||
// Y/N: accept/refuse alignment event
|
||
if (G.pendingAlignment) {
|
||
if (e.code === 'KeyY') resolveAlignment(true);
|
||
if (e.code === 'KeyN') resolveAlignment(false);
|
||
}
|
||
// Alt+1-9: buy building by slot position
|
||
if (e.altKey && e.code >= 'Digit1' && e.code <= 'Digit9') {
|
||
e.preventDefault();
|
||
const slot = parseInt(e.code.replace('Digit', '')) - 1;
|
||
const visible = getVisibleBuildingIds();
|
||
if (slot < visible.length) {
|
||
buyBuilding(visible[slot]);
|
||
}
|
||
}
|
||
// 5-9 (no modifier): buy available research project by position
|
||
if (!e.altKey && !e.ctrlKey && !e.metaKey && e.code >= 'Digit5' && e.code <= 'Digit9') {
|
||
const slot = parseInt(e.code.replace('Digit', '')) - 5;
|
||
if (G.activeProjects && slot >= 0 && slot < G.activeProjects.length) {
|
||
buyProject(G.activeProjects[slot]);
|
||
}
|
||
}
|
||
if (e.code === 'Escape') {
|
||
const el = document.getElementById('help-overlay');
|
||
if (el && el.style.display === 'flex') toggleHelp();
|
||
}
|
||
});
|
||
|
||
// Hold-to-click on WRITE CODE button
|
||
let _holdInterval = null;
|
||
function _startHold() {
|
||
if (_holdInterval) return;
|
||
writeCode(); // immediate first click
|
||
_holdInterval = setInterval(writeCode, 80); // 12.5 clicks/sec while held
|
||
}
|
||
function _stopHold() {
|
||
if (_holdInterval) { clearInterval(_holdInterval); _holdInterval = null; }
|
||
}
|
||
// Stop hold on spacebar release
|
||
window.addEventListener('keyup', function (e) {
|
||
if (e.code === 'Space') _stopHold();
|
||
});
|
||
window.addEventListener('DOMContentLoaded', function () {
|
||
const btn = document.querySelector('.main-btn');
|
||
if (!btn) return;
|
||
btn.addEventListener('mousedown', function (e) { e.preventDefault(); _startHold(); });
|
||
btn.addEventListener('mouseup', _stopHold);
|
||
btn.addEventListener('mouseleave', _stopHold);
|
||
btn.addEventListener('touchstart', function (e) { e.preventDefault(); _startHold(); }, { passive: false });
|
||
btn.addEventListener('touchend', _stopHold);
|
||
btn.addEventListener('touchcancel', _stopHold);
|
||
});
|
||
|
||
// Ctrl+S to save (must be on keydown to preventDefault)
|
||
window.addEventListener('keydown', function (e) {
|
||
if ((e.ctrlKey || e.metaKey) && e.code === 'KeyS') {
|
||
e.preventDefault();
|
||
saveGame();
|
||
}
|
||
});
|
||
|
||
// Save-on-pause: auto-save when tab is hidden or window loses focus
|
||
// Prevents lost progress if browser crashes, tab closes, or device sleeps
|
||
document.addEventListener('visibilitychange', function () {
|
||
if (document.hidden) saveGame();
|
||
});
|
||
window.addEventListener('blur', function () {
|
||
saveGame();
|
||
});
|
||
window.addEventListener('beforeunload', function () {
|
||
saveGame();
|
||
});
|