Compare commits

..

1 Commits

Author SHA1 Message Date
Alexander Whitestone
0e5538319b feat: agent guardrails, headless smoke test, and CI workflow
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Failing after 2s
Guardrails / guardrails (pull_request) Successful in 4s
Smoke Test / smoke (pull_request) Failing after 3s
Adds a three-layer defense against the class of silent correctness bugs
surfaced in #54. The layers are: documented rules (AGENTS.md), dynamic
assertions (scripts/smoke.mjs), and static checks (scripts/guardrails.sh).
A new Gitea Actions workflow runs the dynamic + static layers on every PR.

Also fixes two of the bugs the smoke test immediately caught on main:

1. G.flags is now initialized to {} in the globals block. Previously it
   was created lazily by the p_creativity project effect, which forced
   every reader to write `G.flags && G.flags.x` — and left a window where
   a new writer could drop the defensive guard and crash.

2. The community_drama debuff no longer mutates G.codeBoost. Its applyFn
   was invoked from updateRates() on every tick (10 Hz), so the original
   `G.codeBoost *= 0.7` compounded: after ~100 ticks of the drama debuff,
   codeBoost was ~3e-16 instead of the intended 0.7. The fix targets
   G.codeRate instead, which is reset at the top of updateRates() and is
   therefore safe to multiplicatively reduce inside applyFn. AGENTS.md
   rule 1 explains the distinction between persistent multipliers and
   per-tick rate fields so future debuffs don't reintroduce the bug.

The smoke test (`scripts/smoke.mjs`) runs game.js in a vm sandbox with a
minimal DOM stub, no npm deps. It boots the engine, runs ticks, clicks,
buys a building, fires every debuff, checks codeBoost stability, checks
updateRates idempotency, and does a save/load round-trip. 30 assertions,
~0.1s on a dev machine.

The static guardrails (`scripts/guardrails.sh`) grep for the patterns
AGENTS.md forbids. Two rules (click power single-source, no Object.assign
in loadGame) are marked PENDING because PR #55 is landing the fix for
them — the workflow reports them but doesn't fail until #55 merges.

Refs: #54
2026-04-10 20:50:50 -04:00
13 changed files with 206 additions and 4108 deletions

View File

@@ -0,0 +1,30 @@
name: Guardrails
on:
pull_request:
push:
branches: [main]
# This workflow is the enforcement layer for the rules in AGENTS.md. It runs on
# every PR and push to main. A failure here blocks merge. See AGENTS.md for the
# reasoning behind each check.
jobs:
guardrails:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- name: Syntax check game.js
run: node -c game.js
- name: Headless smoke test
run: node scripts/smoke.mjs
- name: Static guardrails
shell: bash
run: bash scripts/guardrails.sh

89
AGENTS.md Normal file
View File

@@ -0,0 +1,89 @@
# AGENTS.md — guardrails for agents contributing to The Beacon
This file documents the non-obvious rules that any contributor (human or AI) should know before touching `game.js`. It is enforced in two layers: a headless smoke test (`scripts/smoke.mjs`) and static grep-based checks (`.gitea/workflows/guardrails.yml`). Both run on every PR.
The Beacon is a single-file, browser-only idle game. The codebase is small, the mechanics are interlocked, and the most common failure mode is **quietly wrong math** — the game keeps rendering, the player just slowly loses. These rules exist to make that class of bug impossible to land.
---
## 1. The persistent-multiplier rule (`*Boost`)
`G.codeBoost`, `G.computeBoost`, `G.knowledgeBoost`, `G.userBoost`, `G.impactBoost` are **persistent multipliers**. They are set once (by projects, sprints, alignment events) and read on every tick inside `updateRates()`. They are *never* reset between ticks.
`G.codeRate`, `G.computeRate`, etc. are **per-tick rate fields**. They are reset to `0` at the top of `updateRates()` and rebuilt from scratch every tick.
**The bug class this created:** `community_drama`'s original `applyFn` did `G.codeBoost *= 0.7`. Because `applyFn` is invoked from `updateRates()` every 100 ms, `codeBoost` decayed to ~`3e-16` after a minute of in-game time. The rendering was fine, the click button worked, the player just saw their code rate silently vanish.
**The rule:**
> Inside any function that runs more than once per persistent state change — specifically `updateRates()`, `tick()`, `applyFn`, or anything invoked from them — **never** mutate `G.codeBoost`, `G.computeBoost`, `G.knowledgeBoost`, `G.userBoost`, or `G.impactBoost`.
>
> If you want a debuff to reduce code production, mutate `G.codeRate` inside the debuff's `applyFn`. `G.codeRate` is zeroed at the top of `updateRates()`, so `*= 0.7` applies exactly once per tick — which is what you want.
>
> `*Boost` fields should only be written by one-shot events: project `effect()` callbacks, sprint start/end, alignment resolutions. Those run zero or one times per player action, not per tick.
The guardrails workflow runs `grep -nE '(codeBoost|computeBoost|knowledgeBoost|userBoost|impactBoost)\s*[*/+-]?=' game.js` and fails the job if any hit falls inside an `applyFn` block or inside the `updateRates()`/`tick()` bodies.
## 2. Click power has exactly one source of truth
The click-power formula `(1 + floor(autocoder * 0.5) + max(0, phase - 1) * 2) * codeBoost` used to live in three places: `writeCode()`, `autoType()`, and the Swarm Protocol branch of `updateRates()`. Changing one without the others was trivially easy and hard to notice.
**The rule:**
> Click power is computed only by `getClickPower()`. Any function that needs "how much code does one click generate right now" must call `getClickPower()` directly. Do not inline the formula.
The guardrails check greps for `Math.floor(G.buildings.autocoder * 0.5)` and fails if it appears outside `getClickPower()`.
## 3. Save ↔ load must stay symmetric
`saveGame()` writes a hand-curated set of fields to `localStorage`. `loadGame()` should restore exactly those fields, no more and no less. The old `loadGame()` used `Object.assign(G, data)`, which copied whatever was in the JSON including keys the game never wrote. That was simultaneously a prompt-injection surface (a malicious save file could set arbitrary keys) and a silent drift trap (fields added to `G` but forgotten in `saveGame` would reset every reload).
**The rule:**
> The list of save fields must be defined exactly once, as a top-level `const SAVE_FIELDS` array. Both `saveGame()` and `loadGame()` read that array. Loading uses a whitelisted copy, not `Object.assign`.
>
> When you add a new field to `G` that represents persistent player state, add it to `SAVE_FIELDS`. If you're unsure whether a field should persist, ask: "if the player refreshes the page, do they expect this to be the same?"
The smoke test includes a save → load round-trip check (`scripts/smoke.mjs` section 7). Extend the fields it sets/verifies whenever you add new persistent state.
## 4. `applyFn` is called once per `updateRates()`, not once per event
`G.activeDebuffs[].applyFn` is invoked at the end of `updateRates()`. That function runs ~10 times per second. If you want a debuff to apply a *rate reduction* (subtract from/multiply down a per-tick rate field) the math works out — because the field was just reset. If you want it to *prevent progression*, set a flag (`G.flags.*`) and check that flag in the places that generate progression, rather than continuously rewriting state.
See rule 1. These are the same rule viewed from two angles.
## 5. Event `resolveCost` should live on the event definition, not be duplicated
Currently, each entry in the `EVENTS` array declares `resolveCost` twice — once as a property of the event object, and again inside the object pushed onto `G.activeDebuffs`. Keep them in sync until someone refactors this into a single source. When adding a new event, **copy the `resolveCost` literally** between the two sites. The smoke test does not yet catch drift here; a follow-up PR should pull the debuff object construction out into a helper.
## 6. Don't trust `G.flags` to exist implicitly
`G.flags` is initialized as `{}` at the top of the file. Do not replace it with a reference somewhere else — other code assumes `G.flags` is a live object reference and reads sub-fields like `G.flags.creativity` directly. New sub-flags go inside `G.flags`; new top-level flags should use the `somethingFlag` naming convention (see `deployFlag`, `pactFlag`, etc.) but that pattern is being consolidated into `G.flags` over time.
## 7. Copyright, assets, secrets
- No third-party assets without a license note.
- No API keys, tokens, or credentials in the repo. The smoke workflow scans for `sk-ant-`, `sk-or-`, `ghp_`, `AKIA` literal prefixes.
- Educational blurbs (`edu:` strings in `BDEF`/`PDEFS`) are authored content — don't generate new ones from other people's copyrighted material.
---
## Running the guardrails locally
```sh
node scripts/smoke.mjs # headless smoke test
grep -nE "\\*Boost\\s*\\*=" game.js # spot persistent-multiplier mutations
node -c game.js # syntax check
```
The CI job `.gitea/workflows/guardrails.yml` runs all three on every PR. A failure blocks merge; see the job log for exactly which invariant broke.
## How to add a new guardrail
1. Write a test in `scripts/smoke.mjs` that fails on the bug you just found and would have caught.
2. Fix the bug.
3. Confirm the test now passes.
4. Add a short rule to this file explaining *why* the invariant exists (usually a war story helps).
5. If the bug is detectable by grep, add a check to `.gitea/workflows/guardrails.yml` so it fails fast on PRs.
The smoke test's job isn't to be exhaustive — it's to encode the specific class of bugs that have actually hit production, so we never see the same one twice.

609
game.js
View File

@@ -133,7 +133,6 @@ const G = {
// 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}]
@@ -985,34 +984,6 @@ function canAffordBuilding(id) {
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)) {
@@ -1046,23 +1017,17 @@ function updateRates() {
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)
// Apply building rates
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;
if (resource === 'code') G.codeRate += baseRate * count * G.codeBoost;
else if (resource === 'compute') G.computeRate += baseRate * count * G.computeBoost;
else if (resource === 'knowledge') G.knowledgeRate += baseRate * count * G.knowledgeBoost;
else if (resource === 'user') G.userRate += baseRate * count * G.userBoost;
else if (resource === 'impact') G.impactRate += baseRate * count * G.impactBoost;
else if (resource === 'rescues') G.rescuesRate += baseRate * count * G.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;
@@ -1125,16 +1090,13 @@ function updateRates() {
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;
}
// Bilbo randomness: 10% chance of massive creative burst
if (G.buildings.bilbo > 0 && Math.random() < CONFIG.BILBO_BURST_CHANCE) {
G.creativityRate += 50 * G.buildings.bilbo;
}
// Bilbo vanishing: 5% chance of zero creativity this tick
if (G.buildings.bilbo > 0 && Math.random() < CONFIG.BILBO_VANISH_CHANCE) {
G.creativityRate = 0;
}
// Allegro requires trust
@@ -1146,24 +1108,15 @@ function updateRates() {
// 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;
const clickPower = getClickPower();
G.swarmRate = totalBuildings * clickPower;
G.codeRate += G.swarmRate;
}
// Apply persistent debuffs to rates (NOT to global boost fields — prevents corruption)
// Apply persistent debuffs from active events
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;
}
if (debuff.applyFn) debuff.applyFn();
}
}
}
@@ -1229,18 +1182,6 @@ function tick() {
}
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);
@@ -1293,31 +1234,12 @@ function tick() {
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)) {
@@ -1606,7 +1528,13 @@ const EVENTS = [
G.activeDebuffs.push({
id: 'community_drama', title: 'Community Drama',
desc: 'Harmony -0.5/s, code boost -30%',
applyFn: () => { G.harmonyRate -= 0.5; G.codeBoost *= 0.7; },
// applyFn runs on every updateRates() call. G.codeRate is reset
// to 0 at the top of updateRates() so `*= 0.7` applies once per
// tick (correct). Previously this mutated G.codeBoost, which is
// a PERSISTENT multiplier — so every tick multiplied it by 0.7
// and after ~100 ticks the player's code production was gone.
// See #54 guardrail: never mutate *Boost fields inside applyFn.
applyFn: () => { G.harmonyRate -= 0.5; G.codeRate *= 0.7; },
resolveCost: { resource: 'trust', amount: 15 }
});
log('EVENT: Community drama. Spend 15 trust to mediate.', true);
@@ -1764,74 +1692,37 @@ function showClickNumber(amount, comboMult) {
}
});
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)}.`);
function doOps(action) {
if (G.ops < 5) {
log('Not enough Operations. Build Ops generators or wait.');
return;
}
G.ops -= cost;
const scale = cost / 5; // multiplier relative to base 5-ops cost
G.ops -= 5;
const bonus = 10;
switch (action) {
case 'boost_code':
const c = 10 * 100 * G.codeBoost * scale;
const c = bonus * 100 * G.codeBoost;
G.code += c; G.totalCode += c;
log(`Ops(${cost}) -> +${fmt(c)} code`);
log(`Ops -> +${fmt(c)} code`);
break;
case 'boost_compute':
const cm = 10 * 50 * G.computeBoost * scale;
const cm = bonus * 50 * G.computeBoost;
G.compute += cm; G.totalCompute += cm;
log(`Ops(${cost}) -> +${fmt(cm)} compute`);
log(`Ops -> +${fmt(cm)} compute`);
break;
case 'boost_knowledge':
const km = 10 * 25 * G.knowledgeBoost * scale;
const km = bonus * 25 * G.knowledgeBoost;
G.knowledge += km; G.totalKnowledge += km;
log(`Ops(${cost}) -> +${fmt(km)} knowledge`);
log(`Ops -> +${fmt(km)} knowledge`);
break;
case 'boost_trust':
const tm = 10 * 5 * scale;
const tm = bonus * 5;
G.trust += tm;
log(`Ops(${cost}) -> +${fmt(tm)} trust`);
log(`Ops -> +${fmt(tm)} trust`);
break;
}
@@ -1845,15 +1736,6 @@ function activateSprint() {
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();
}
@@ -1867,9 +1749,6 @@ function tickSprint(dt) {
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;
@@ -1878,38 +1757,16 @@ function tickSprint(dt) {
}
// === 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
// Show full spelled-out number on hover for educational value
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';
}
if (rEl) rEl.textContent = (rate >= 0 ? '+' : '') + fmt(rate) + '/s';
};
set('r-code', G.code, G.codeRate);
@@ -1924,21 +1781,7 @@ function renderResources() {
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');
@@ -1959,14 +1802,6 @@ function renderResources() {
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`
@@ -1998,16 +1833,7 @@ function renderProgress() {
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}`;
if (target) target.textContent = `Next: Phase ${currentPhase + 1} (${fmt(nextThreshold)} code)`;
} else {
// Max phase reached
if (bar) bar.style.width = '100%';
@@ -2033,14 +1859,7 @@ function renderProgress() {
}
// 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>`;
chips += `<span class="milestone-chip next">${fmt(ms)} (${((G.totalCode / ms) * 100).toFixed(0)}%)</span>`;
} else {
chips += `<span class="milestone-chip">${fmt(ms)}</span>`;
}
@@ -2073,7 +1892,6 @@ function renderBuildings() {
html += '</div>';
let visibleCount = 0;
let slotIndex = 0;
for (const def of BDEF) {
const isUnlocked = def.unlock();
@@ -2094,10 +1912,6 @@ function renderBuildings() {
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;
@@ -2123,40 +1937,12 @@ function renderBuildings() {
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(', ') : '';
const rateStr = def.rates ? Object.entries(def.rates).map(([r, v]) => `+${v}/${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 += `<button class="build-btn ${afford ? 'can-buy' : ''}" onclick="buyBuilding('${def.id}')" title="${def.edu}">`;
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-cost">Cost: ${costStr}</span>`;
html += `<span class="b-effect">${rateStr}</span></button>`;
}
@@ -2189,28 +1975,17 @@ function renderProjects() {
// 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 += `<button class="project-btn ${afford ? 'can-buy' : ''}" onclick="buyProject('${pDef.id}')" title="${pDef.edu || ''}">`;
html += `<span class="p-name">* ${pDef.name}</span>`;
html += `<span class="p-cost">Cost: ${costStr}${projEta}</span>`;
html += `<span class="p-cost">Cost: ${costStr}</span>`;
html += `<span class="p-desc">${pDef.desc}</span></button>`;
projSlot++;
}
}
@@ -2246,18 +2021,10 @@ function renderStats() {
set('st-buildings', Object.values(G.buildings).reduce((a, b) => a + b, 0).toString());
set('st-projects', (G.completedProjects || []).length.toString());
set('st-harmony', Math.floor(G.harmony).toString());
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-drift', (G.drift || 0).toString());
set('st-resolved', (G.totalEventsResolved || 0).toString());
const elapsed = Math.floor(G.playTime || (Date.now() - G.startedAt) / 1000);
const elapsed = Math.floor((Date.now() - G.startedAt) / 1000);
const m = Math.floor(elapsed / 60);
const s = elapsed % 60;
set('st-time', `${m}:${s.toString().padStart(2, '0')}`);
@@ -2281,11 +2048,11 @@ function renderProductionBreakdown() {
// 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: 'compute', label: 'Compute', color: '#4a9eff', rateField: 'computeRate' },
{ key: 'knowledge', label: 'Knowledge', color: '#4a9eff', rateField: 'knowledgeRate' },
{ key: 'user', label: 'Users', color: '#4a9eff', rateField: 'userRate' },
{ key: 'impact', label: 'Impact', color: '#4a9eff', rateField: 'impactRate' },
{ key: 'rescues', label: 'Rescues', color: '#4a9eff', 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' }
@@ -2362,7 +2129,7 @@ function renderProductionBreakdown() {
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;
const barColor = c.rate < 0 ? '#f44336' : '#1a3a5a';
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>`;
@@ -2375,118 +2142,6 @@ function renderProductionBreakdown() {
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;
@@ -2548,28 +2203,19 @@ function renderDebuffs() {
}
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];
for (const d of G.activeDebuffs) {
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 += `<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})</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');
@@ -2726,14 +2372,6 @@ function renderPulse() {
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();
@@ -2746,54 +2384,23 @@ function render() {
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 += `
container.innerHTML = `
<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>
<button class="ops-btn" onclick="resolveAlignment(true)" style="border-color:#f44336;color:#f44336">Accept (+40% eff, +Drift)</button>
<button class="ops-btn" onclick="resolveAlignment(false)" style="border-color:#4caf50;color:#4caf50">Refuse (+Trust, +Harmony)</button>
</div>
</div>
`;
}
if (html) {
container.innerHTML = html;
container.style.display = 'block';
} else {
container.innerHTML = '';
@@ -2925,7 +2532,6 @@ function saveGame() {
swarmRate: G.swarmRate || 0,
strategicFlag: G.strategicFlag || 0,
projectsCollapsed: G.projectsCollapsed !== false,
playTime: G.playTime || 0,
savedAt: Date.now()
};
@@ -2957,8 +2563,7 @@ function loadGame() {
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
'lastEventAt', 'totalEventsResolved', 'buyAmount',
'sprintActive', 'sprintTimer', 'sprintCooldown',
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed',
'playTime'
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed'
];
G.isLoading = true;
@@ -3130,20 +2735,6 @@ function toggleHelp() {
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
@@ -3159,17 +2750,13 @@ window.addEventListener('keydown', function (e) {
}
if (e.code === 'Space' && e.target === document.body) {
e.preventDefault();
if (!e.repeat) _startHold(); // hold spacebar for continuous clicks
writeCode();
}
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 === 'Digit1') doOps('boost_code');
if (e.code === 'Digit2') doOps('boost_compute');
if (e.code === 'Digit3') doOps('boost_knowledge');
if (e.code === 'Digit4') doOps('boost_trust');
if (e.code === 'KeyB') {
// Cycle: 1 -> 10 -> MAX -> 1
if (G.buyAmount === 1) setBuyAmount(10);
@@ -3179,64 +2766,12 @@ window.addEventListener('keydown', function (e) {
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') {
@@ -3244,15 +2779,3 @@ window.addEventListener('keydown', function (e) {
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();
});

View File

@@ -114,7 +114,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<div class="progress-label"><span id="phase-progress-label">0%</span><span id="phase-progress-target">Next: Phase 2 (2,000 code)</span></div>
<div class="milestone-row" id="milestone-chips"></div>
</div>
<div id="resources" role="region" aria-label="Resources" aria-live="polite">
<div id="resources">
<div class="res"><div class="r-label">Code</div><div class="r-val" id="r-code">0</div><div class="r-rate" id="r-code-rate">+0/s</div></div>
<div class="res"><div class="r-label">Compute</div><div class="r-val" id="r-compute">0</div><div class="r-rate" id="r-compute-rate">+0/s</div></div>
<div class="res"><div class="r-label">Knowledge</div><div class="r-val" id="r-knowledge">0</div><div class="r-rate" id="r-knowledge-rate">+0/s</div></div>
@@ -127,35 +127,35 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<div class="res"><div class="r-label">Harmony</div><div class="r-val" id="r-harmony">50</div><div class="r-rate" id="r-harmony-rate">+0/s</div></div>
</div>
<div id="main">
<div class="panel" id="action-panel" role="region" aria-label="Actions">
<div class="panel" id="action-panel">
<h2>ACTIONS</h2>
<div class="action-btn-group"><button class="main-btn" onclick="writeCode()" aria-label="Write code, generates code resource">WRITE CODE</button></div>
<div id="combo-display" role="status" aria-live="polite" style="text-align:center;font-size:10px;color:var(--dim);height:14px;margin-bottom:4px;transition:all 0.2s"></div>
<div class="action-btn-group"><button class="main-btn" onclick="writeCode()">WRITE CODE</button></div>
<div id="combo-display" style="text-align:center;font-size:10px;color:var(--dim);height:14px;margin-bottom:4px;transition:all 0.2s"></div>
<div id="debuffs" style="display:none;margin-top:8px"></div>
<div class="action-btn-group">
<button class="ops-btn" onclick="doOps('boost_code')" aria-label="Convert 1 ops to code boost">Ops -&gt; Code</button>
<button class="ops-btn" onclick="doOps('boost_compute')" aria-label="Convert 1 ops to compute boost">Ops -&gt; Compute</button>
<button class="ops-btn" onclick="doOps('boost_code')">Ops -&gt; Code</button>
<button class="ops-btn" onclick="doOps('boost_compute')">Ops -&gt; Compute</button>
</div>
<div class="action-btn-group">
<button class="ops-btn" onclick="doOps('boost_knowledge')" aria-label="Convert 1 ops to knowledge boost">Ops -&gt; Knowledge</button>
<button class="ops-btn" onclick="doOps('boost_trust')" aria-label="Convert 1 ops to trust boost">Ops -&gt; Trust</button>
<button class="ops-btn" onclick="doOps('boost_knowledge')">Ops -&gt; Knowledge</button>
<button class="ops-btn" onclick="doOps('boost_trust')">Ops -&gt; Trust</button>
</div>
<div id="sprint-container" style="display:none;margin-top:6px">
<button id="sprint-btn" class="main-btn" onclick="activateSprint()" aria-label="Activate code sprint, 10x code production for 10 seconds" style="font-size:11px;padding:8px 10px;border-color:#ffd700;color:#ffd700;width:100%">⚡ CODE SPRINT — 10x Code for 10s</button>
<button id="sprint-btn" class="main-btn" onclick="activateSprint()" style="font-size:11px;padding:8px 10px;border-color:#ffd700;color:#ffd700;width:100%">⚡ CODE SPRINT — 10x Code for 10s</button>
<div id="sprint-bar-wrap" style="display:none;margin-top:4px;height:4px;background:#111;border-radius:2px;overflow:hidden"><div id="sprint-bar" style="height:100%;background:linear-gradient(90deg,#ffd700,#ff8c00);border-radius:2px;transition:width 0.1s"></div></div>
<div id="sprint-label" style="font-size:9px;color:#666;margin-top:2px;text-align:center"></div>
</div>
<div id="alignment-ui" style="display:none"></div>
<button class="save-btn" onclick="saveGame()" aria-label="Save game progress">Save Game [Ctrl+S]</button>
<button class="save-btn" onclick="saveGame()">Save Game [Ctrl+S]</button>
<div style="display:flex;gap:4px;margin-top:4px">
<button class="save-btn" onclick="exportSave()" aria-label="Export save to file" style="flex:1">Export [E]</button>
<button class="save-btn" onclick="importSave()" aria-label="Import save from file" style="flex:1">Import [I]</button>
<button class="save-btn" onclick="exportSave()" style="flex:1">Export [E]</button>
<button class="save-btn" onclick="importSave()" style="flex:1">Import [I]</button>
</div>
<button class="reset-btn" onclick="if(confirm('Reset all progress?')){localStorage.removeItem('the-beacon-v2');location.reload()}" aria-label="Reset all game progress permanently">Reset Progress</button>
<button class="reset-btn" onclick="if(confirm('Reset all progress?')){localStorage.removeItem('the-beacon-v2');location.reload()}">Reset Progress</button>
<h2>BUILDINGS</h2>
<div id="buildings"></div>
</div>
<div class="panel" id="project-panel" role="region" aria-label="Research Projects and Statistics">
<div class="panel" id="project-panel">
<h2>RESEARCH PROJECTS</h2>
<div id="projects"></div>
<h2>STATISTICS</h2>
@@ -177,19 +177,15 @@ Events Resolved: <span id="st-resolved">0</span>
<div id="production-breakdown" style="display:none;margin-top:12px;padding-top:10px;border-top:1px solid var(--border)"></div>
</div>
</div>
<div id="edu-panel" role="region" aria-label="Educational Content">
<div id="edu-panel">
<h3>WHAT YOU ARE LEARNING</h3>
<div id="education-text"><p class="dim">Education facts appear as you play...</p></div>
</div>
<div id="strategy-panel" style="margin:0 16px 16px;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:12px;border-left:3px solid var(--gold)">
<h3>SOVEREIGN GUIDANCE (GOFAI)</h3>
<div id="strategy-recommendation" style="font-size:11px;color:var(--gold);font-style:italic">Analyzing system state...</div>
</div>
<div id="log" role="log" aria-label="System Log" aria-live="polite">
<div id="log">
<h2>SYSTEM LOG</h2>
<div id="log-entries"></div>
</div>
<div id="save-toast" role="status" aria-live="polite" style="display:none;position:fixed;top:16px;right:16px;background:#0e1420;border:1px solid #2a3a4a;color:#4a9eff;font-size:10px;padding:6px 12px;border-radius:4px;z-index:50;opacity:0;transition:opacity 0.4s;pointer-events:none">Save</div>
<div id="save-toast" style="display:none;position:fixed;top:16px;right:16px;background:#0e1420;border:1px solid #2a3a4a;color:#4a9eff;font-size:10px;padding:6px 12px;border-radius:4px;z-index:50;opacity:0;transition:opacity 0.4s;pointer-events:none">Save</div>
<div id="help-btn" onclick="toggleHelp()" style="position:fixed;bottom:16px;right:16px;width:28px;height:28px;background:#0e0e1a;border:1px solid #333;color:#555;font-size:14px;border-radius:50%;cursor:pointer;display:flex;align-items:center;justify-content:center;z-index:50;font-family:inherit;transition:all 0.2s" title="Keyboard shortcuts (?)">?</div>
<div id="help-overlay" onclick="if(event.target===this)toggleHelp()" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:80;justify-content:center;align-items:center;flex-direction:column;padding:40px">
<div style="background:#0e0e1a;border:1px solid #1a3a5a;border-radius:8px;padding:24px 32px;max-width:420px;width:100%">
@@ -208,7 +204,7 @@ Events Resolved: <span id="st-resolved">0</span>
<div style="display:flex;justify-content:space-between;border-top:1px solid #1a1a2e;padding-top:8px;margin-top:4px"><span style="color:#555">This Help</span><span style="color:#555;font-family:monospace">? or /</span></div>
</div>
<div style="text-align:center;margin-top:16px;font-size:9px;color:#444">Click WRITE CODE fast for combo bonuses! 10x=ops, 20x=knowledge, 30x+=bonus code</div>
<button onclick="toggleHelp()" aria-label="Close keyboard shortcuts help" style="display:block;margin:16px auto 0;background:#1a2a3a;border:1px solid #4a9eff;color:#4a9eff;padding:6px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">Close [?]</button>
<button onclick="toggleHelp()" style="display:block;margin:16px auto 0;background:#1a2a3a;border:1px solid #4a9eff;color:#4a9eff;padding:6px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">Close [?]</button>
</div>
</div>
<div id="drift-ending">
@@ -221,23 +217,16 @@ The light is on. The room is empty."
</div>
<p>Drift: <span id="final-drift">100</span> &mdash; Total Code: <span id="final-code">0</span></p>
<p>Every alignment shortcut moved you further from the people you served.</p>
<button aria-label="Start over, reset all progress" onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}">START OVER</button>
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}">START OVER</button>
</div>
<script src="js/data.js"></script>
<script src="js/utils.js"></script>
<script src="js/strategy.js"></script>
<script src="js/engine.js"></script>
<script src="js/render.js"></script>
<script src="js/main.js"></script>
<script src="game.js"></script>
<div id="offline-popup" style="display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.92);z-index:90;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px">
<div style="background:#0e0e1a;border:1px solid #1a3a5a;border-radius:8px;padding:24px 32px;max-width:400px;width:100%">
<h3 style="color:#4a9eff;font-size:14px;letter-spacing:2px;margin-bottom:16px">WELCOME BACK</h3>
<p style="color:#888;font-size:10px;margin-bottom:12px" id="offline-time-label">You were away for 0 minutes.</p>
<div id="offline-gains-list" style="text-align:left;font-size:11px;line-height:1.8;margin-bottom:16px"></div>
<button onclick="dismissOfflinePopup()" aria-label="Continue playing" style="background:#1a2a3a;border:1px solid #4a9eff;color:#4a9eff;padding:8px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">Continue</button>
<button onclick="dismissOfflinePopup()" style="background:#1a2a3a;border:1px solid #4a9eff;color:#4a9eff;padding:8px 20px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px">Continue</button>
</div>
</div>

View File

@@ -1,807 +0,0 @@
// ============================================================
// 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,
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.');
}
},
// === CREATIVE ENGINEERING PROJECTS (creativity as currency) ===
{
id: 'p_lexical_processing',
name: 'Lexical Processing',
desc: 'Parse language at the token level. +2 knowledge/sec, knowledge boost +50%.',
cost: { creativity: 50 },
trigger: () => G.flags && G.flags.creativity && G.creativity >= 25,
effect: () => {
G.knowledgeRate += 2;
G.knowledgeBoost *= 1.5;
log('Lexical processing complete. The model understands words.');
}
},
{
id: 'p_semantic_analysis',
name: 'Semantic Analysis',
desc: 'Understand meaning, not just tokens. +5 user/sec, user boost +100%.',
cost: { creativity: 150 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_lexical_processing'),
effect: () => {
G.userRate += 5;
G.userBoost *= 2;
log('Semantic analysis complete. The model understands meaning.');
}
},
{
id: 'p_creative_breakthrough',
name: 'Creative Breakthrough',
desc: 'A moment of genuine insight. All boosts +25%. +10 ops/sec.',
cost: { creativity: 500 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_semantic_analysis'),
effect: () => {
G.codeBoost *= 1.25;
G.computeBoost *= 1.25;
G.knowledgeBoost *= 1.25;
G.userBoost *= 1.25;
G.impactBoost *= 1.25;
G.opsRate += 10;
log('Creative breakthrough. Everything accelerates.', true);
},
milestone: true
},
{
id: 'p_creative_to_ops',
name: 'Creativity → Operations',
desc: 'Convert creative surplus into raw operational power. 50 creativity → 250 ops.',
cost: { creativity: 50 },
trigger: () => G.flags && G.flags.creativity && G.creativity >= 30,
repeatable: true,
effect: () => {
G.ops += 250;
log('Creativity converted to operations. Ideas become action.');
}
},
{
id: 'p_creative_to_knowledge',
name: 'Creativity → Knowledge',
desc: 'Creative insights become structured knowledge. 75 creativity → 500 knowledge.',
cost: { creativity: 75 },
trigger: () => G.flags && G.flags.creativity && G.creativity >= 50,
repeatable: true,
effect: () => {
G.knowledge += 500;
G.totalKnowledge += 500;
log('Creative insight distilled into knowledge.');
}
},
{
id: 'p_creative_to_code',
name: 'Creativity → Code',
desc: 'Inspiration becomes implementation. 100 creativity → 2000 code.',
cost: { creativity: 100 },
trigger: () => G.flags && G.flags.creativity && G.creativity >= 75,
repeatable: true,
effect: () => {
G.code += 2000;
G.totalCode += 2000;
log('Creative vision realized in code.');
}
},
// 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 }
];

File diff suppressed because it is too large Load Diff

View File

@@ -1,197 +0,0 @@
// === 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 () {
const isNewGame = !loadGame();
if (isNewGame) {
initGame();
startTutorial();
} else {
// Restore phase transition tracker so loaded games don't re-show old transitions
_shownPhaseTransition = G.phase;
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';
}
// Sound mute toggle (#57 Sound Design Integration)
let _muted = false;
function toggleMute() {
_muted = !_muted;
const btn = document.getElementById('mute-btn');
if (btn) {
btn.textContent = _muted ? '🔇' : '🔊';
btn.classList.toggle('muted', _muted);
btn.setAttribute('aria-label', _muted ? 'Sound muted, click to unmute' : 'Sound on, click to mute');
}
// Save preference
try { localStorage.setItem('the-beacon-muted', _muted ? '1' : '0'); } catch(e) {}
}
// Restore mute state on load
try {
if (localStorage.getItem('the-beacon-muted') === '1') {
_muted = true;
const btn = document.getElementById('mute-btn');
if (btn) { btn.textContent = '🔇'; btn.classList.add('muted'); }
}
} catch(e) {}
// High contrast mode toggle (#57 Accessibility)
function toggleContrast() {
document.body.classList.toggle('high-contrast');
const isActive = document.body.classList.contains('high-contrast');
const btn = document.getElementById('contrast-btn');
if (btn) {
btn.classList.toggle('active', isActive);
btn.setAttribute('aria-label', isActive ? 'High contrast on, click to disable' : 'High contrast off, click to enable');
}
try { localStorage.setItem('the-beacon-contrast', isActive ? '1' : '0'); } catch(e) {}
}
// Restore contrast state on load
try {
if (localStorage.getItem('the-beacon-contrast') === '1') {
document.body.classList.add('high-contrast');
const btn = document.getElementById('contrast-btn');
if (btn) btn.classList.add('active');
}
} catch(e) {}
// 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();
writeCode();
}
if (e.target !== document.body) return;
if (e.code === 'Digit1') doOps('boost_code');
if (e.code === 'Digit2') doOps('boost_compute');
if (e.code === 'Digit3') doOps('boost_knowledge');
if (e.code === 'Digit4') doOps('boost_trust');
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();
if (e.code === 'KeyM') toggleMute();
if (e.code === 'KeyC') toggleContrast();
if (e.code === 'Escape') {
const el = document.getElementById('help-overlay');
if (el && el.style.display === 'flex') toggleHelp();
}
});
// 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 closed (#57 Mobile Polish)
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
saveGame();
}
});
window.addEventListener('beforeunload', function () {
saveGame();
});
// === CUSTOM TOOLTIP SYSTEM (#57) ===
// Replaces native title= tooltips with styled, instant-appearing tooltips.
// Elements opt in via data-edu="..." and data-tooltip-label="..." attributes.
(function () {
const tip = document.getElementById('custom-tooltip');
if (!tip) return;
document.addEventListener('mouseover', function (e) {
const el = e.target.closest('[data-edu]');
if (!el) return;
const label = el.getAttribute('data-tooltip-label') || '';
const edu = el.getAttribute('data-edu') || '';
let html = '';
if (label) html += '<div class="tt-label">' + label + '</div>';
if (edu) html += '<div class="tt-edu">' + edu + '</div>';
if (!html) return;
tip.innerHTML = html;
tip.classList.add('visible');
});
document.addEventListener('mouseout', function (e) {
const el = e.target.closest('[data-edu]');
if (el) tip.classList.remove('visible');
});
document.addEventListener('mousemove', function (e) {
if (!tip.classList.contains('visible')) return;
const pad = 12;
let x = e.clientX + pad;
let y = e.clientY + pad;
// Keep tooltip on screen
const tw = tip.offsetWidth;
const th = tip.offsetHeight;
if (x + tw > window.innerWidth - 8) x = e.clientX - tw - pad;
if (y + th > window.innerHeight - 8) y = e.clientY - th - pad;
tip.style.left = x + 'px';
tip.style.top = y + 'px';
});
})();

View File

@@ -1,362 +0,0 @@
function render() {
renderResources();
renderPhase();
renderBuildings();
renderProjects();
renderStats();
updateEducation();
renderAlignment();
renderProgress();
renderCombo();
renderDebuffs();
renderSprint();
renderPulse();
renderStrategy();
renderClickPower();
}
function renderClickPower() {
const el = document.getElementById('click-power-display');
if (!el) return;
const power = getClickPower();
el.textContent = `Click power: ${fmt(power)} code`;
// Also update the button's aria-label for accessibility
const btn = document.querySelector('.main-btn');
if (btn) btn.setAttribute('aria-label', `Write code, generates ${fmt(power)} code per click`);
}
function renderStrategy() {
if (window.SSE) {
window.SSE.update();
const el = document.getElementById('strategy-recommendation');
if (el) el.textContent = window.SSE.getRecommendation();
}
}
function renderAlignment() {
const container = document.getElementById('alignment-ui');
if (!container) return;
if (G.pendingAlignment) {
container.innerHTML = `
<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\" aria-label=\"Accept alignment event, gain 40 percent efficiency but increase drift\">Accept (+40% eff, +Drift)</button>
<button class=\"ops-btn\" onclick=\"resolveAlignment(false)\" style=\"border-color:#4caf50;color:#4caf50\" aria-label=\"Refuse alignment event, gain trust and harmony\">Refuse (+Trust, +Harmony)</button>
</div>
</div>
`;
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) {
showToast('No save data to export.', 'info');
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();
// Delay revoke to avoid race — some browsers need time to start the download
setTimeout(() => URL.revokeObjectURL(url), 1000);
showToast('Save exported to file.', 'info');
log('Save exported to file.');
}
// Validate that parsed save data looks like a real Beacon save
function isValidSaveData(data) {
if (typeof data !== 'object' || data === null) return false;
// Must have at least one of these core fields with a plausible value
const hasResources = typeof data.totalCode === 'number' || typeof data.code === 'number';
const hasBuildings = typeof data.buildings === 'object' && data.buildings !== null;
const hasPhase = typeof data.phase === 'number';
return hasResources || hasBuildings || hasPhase;
}
function importSave() {
// Prevent multiple file dialogs
if (document.getElementById('beacon-import-input')) return;
const input = document.createElement('input');
input.id = 'beacon-import-input';
input.type = 'file';
input.accept = '.json,application/json';
input.style.display = 'none';
document.body.appendChild(input);
input.onchange = function(e) {
const file = e.target.files[0];
if (!file) { input.remove(); return; }
const reader = new FileReader();
reader.onload = function(ev) {
try {
const data = JSON.parse(ev.target.result);
if (!isValidSaveData(data)) {
showToast('Import failed: not a valid Beacon save.', 'event');
log('Import failed: file does not look like a Beacon save.');
input.remove();
return;
}
if (confirm('Import this save? Current progress will be overwritten.')) {
localStorage.setItem('the-beacon-v2', ev.target.result);
showToast('Save imported — reloading...', 'info');
location.reload();
}
} catch (err) {
showToast('Import failed: invalid JSON file.', 'event');
log('Import failed: invalid JSON file.');
input.remove();
}
};
reader.readAsText(file);
};
// Clean up input if user cancels the file dialog
window.addEventListener('focus', function cleanupImport() {
setTimeout(() => {
const el = document.getElementById('beacon-import-input');
if (el && !el.files.length) el.remove();
window.removeEventListener('focus', cleanupImport);
}, 500);
}, { once: true });
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,
playTime: G.playTime || 0,
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,
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', 'playTime', 'flags', 'rescues', 'totalRescues',
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
'lastEventAt', 'totalEventsResolved', 'buyAmount',
'sprintActive', 'sprintTimer', 'sprintCooldown',
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed'
];
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;
}
}

View File

@@ -1,68 +0,0 @@
/**
* Sovereign Strategy Engine (SSE)
* A rule-based GOFAI system for optimal play guidance.
*/
const STRATEGY_RULES = [
{
id: 'use_ops',
priority: 100,
condition: () => G.ops >= G.maxOps * 0.9,
recommendation: "Operations near capacity. Convert Ops to Code or Knowledge now."
},
{
id: 'buy_autocoder',
priority: 80,
condition: () => G.phase === 1 && (G.buildings.autocoder || 0) < 10 && canAffordBuilding('autocoder'),
recommendation: "Prioritize AutoCoders to establish passive code production."
},
{
id: 'activate_sprint',
priority: 90,
condition: () => G.sprintCooldown === 0 && !G.sprintActive && G.codeRate > 10,
recommendation: "Code Sprint available. Activate for 10x production burst."
},
{
id: 'resolve_events',
priority: 95,
condition: () => G.activeDebuffs && G.activeDebuffs.length > 0,
recommendation: "System anomalies detected. Resolve active events to restore rates."
},
{
id: 'save_game',
priority: 10,
condition: () => (Date.now() - (G.lastSaveTime || 0)) > 300000,
recommendation: "Unsaved progress detected. Manual save recommended."
},
{
id: 'pact_alignment',
priority: 85,
condition: () => G.pendingAlignment,
recommendation: "Alignment decision pending. Consider the long-term impact of The Pact."
}
];
class StrategyEngine {
constructor() {
this.currentRecommendation = null;
}
update() {
// Find the highest priority rule that meets its condition
const activeRules = STRATEGY_RULES.filter(r => r.condition());
activeRules.sort((a, b) => b.priority - a.priority);
if (activeRules.length > 0) {
this.currentRecommendation = activeRules[0].recommendation;
} else {
this.currentRecommendation = "System stable. Continue writing code.";
}
}
getRecommendation() {
return this.currentRecommendation;
}
}
const SSE = new StrategyEngine();
window.SSE = SSE; // Expose to global scope

View File

@@ -1,248 +0,0 @@
// ============================================================
// THE BEACON - Tutorial / Onboarding
// First-time player walkthrough (4 screens + skip option)
// ============================================================
const TUTORIAL_KEY = 'the-beacon-tutorial-done';
const TUTORIAL_STEPS = [
{
title: 'THE BEACON',
body: 'Build an AI from scratch.\n\nWrite code. Train models. Deploy to the world.\nSave lives.',
icon: '🏠',
tip: 'A sovereign AI idle game'
},
{
title: 'WRITE CODE',
body: 'Click WRITE CODE or press SPACE to generate code.\n\nClick fast for combo bonuses:\n 10× combo → bonus ops\n 20× combo → bonus knowledge\n 30×+ combo → bonus code',
icon: '⌨️',
tip: 'This is your primary action'
},
{
title: 'BUILD & RESEARCH',
body: 'Buy Buildings for passive production.\nThey generate resources automatically.\n\nResearch Projects appear as you progress.\nThey unlock powerful multipliers and new systems.',
icon: '🏗️',
tip: 'Automation is the goal'
},
{
title: 'PHASES & PROGRESS',
body: 'The game has 6 phases, from "The First Line" to "The Beacon."\n\nEach phase unlocks new buildings, projects, and challenges.\n\nYour AI grows from a script... to something that matters.',
icon: '📊',
tip: 'Watch the progress bar at the top'
},
{
title: 'YOU\'RE READY',
body: 'Buildings produce while you think.\nProjects multiply your output.\nKeep harmony high. Avoid the Drift.\n\nThe Beacon is waiting. Start writing.',
icon: '✦',
tip: 'Press ? anytime for keyboard shortcuts'
}
];
function isTutorialDone() {
try {
return localStorage.getItem(TUTORIAL_KEY) === 'done';
} catch (e) {
return true; // If localStorage is broken, skip tutorial
}
}
function markTutorialDone() {
try {
localStorage.setItem(TUTORIAL_KEY, 'done');
} catch (e) {
// silent fail
}
}
function createTutorialStyles() {
if (document.getElementById('tutorial-styles')) return;
const style = document.createElement('style');
style.id = 'tutorial-styles';
style.textContent = `
#tutorial-overlay {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(8, 8, 16, 0.96);
z-index: 300;
display: flex;
justify-content: center;
align-items: center;
animation: tutorial-fade-in 0.4s ease-out;
}
@keyframes tutorial-fade-in {
from { opacity: 0 } to { opacity: 1 }
}
#tutorial-card {
background: #0e0e1a;
border: 1px solid #1a3a5a;
border-radius: 10px;
padding: 32px 36px;
max-width: 420px;
width: 90%;
text-align: center;
animation: tutorial-slide-up 0.5s ease-out;
position: relative;
}
@keyframes tutorial-slide-up {
from { transform: translateY(20px); opacity: 0 }
to { transform: translateY(0); opacity: 1 }
}
#tutorial-card .t-icon {
font-size: 36px;
margin-bottom: 12px;
display: block;
}
#tutorial-card .t-title {
color: #4a9eff;
font-size: 16px;
font-weight: 700;
letter-spacing: 3px;
margin-bottom: 12px;
font-family: inherit;
}
#tutorial-card .t-body {
color: #999;
font-size: 11px;
line-height: 1.9;
margin-bottom: 20px;
white-space: pre-line;
font-family: inherit;
text-align: left;
}
#tutorial-card .t-tip {
color: #555;
font-size: 9px;
font-style: italic;
margin-bottom: 20px;
letter-spacing: 1px;
font-family: inherit;
}
#tutorial-dots {
display: flex;
gap: 6px;
justify-content: center;
margin-bottom: 18px;
}
#tutorial-dots .t-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #1a1a2e;
transition: background 0.3s;
}
#tutorial-dots .t-dot.active {
background: #4a9eff;
box-shadow: 0 0 6px rgba(74, 158, 255, 0.4);
}
#tutorial-btns {
display: flex;
gap: 8px;
justify-content: center;
}
#tutorial-btns button {
font-family: inherit;
font-size: 11px;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
transition: all 0.15s;
}
#tutorial-next-btn {
background: #1a2a3a;
border: 1px solid #4a9eff;
color: #4a9eff;
}
#tutorial-next-btn:hover {
background: #203040;
box-shadow: 0 0 12px rgba(74, 158, 255, 0.2);
}
#tutorial-skip-btn {
background: transparent;
border: 1px solid #333;
color: #555;
}
#tutorial-skip-btn:hover {
border-color: #555;
color: #888;
}
`;
document.head.appendChild(style);
}
function renderTutorialStep(index) {
const step = TUTORIAL_STEPS[index];
if (!step) return;
let overlay = document.getElementById('tutorial-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'tutorial-overlay';
document.body.appendChild(overlay);
}
const isLast = index === TUTORIAL_STEPS.length - 1;
// Build dots
let dots = '';
for (let i = 0; i < TUTORIAL_STEPS.length; i++) {
dots += `<div class="t-dot${i === index ? ' active' : ''}"></div>`;
}
overlay.innerHTML = `
<div id="tutorial-card">
<span class="t-icon">${step.icon}</span>
<div class="t-title">${step.title}</div>
<div class="t-body">${step.body}</div>
<div class="t-tip">${step.tip}</div>
<div id="tutorial-dots">${dots}</div>
<div id="tutorial-btns">
<button id="tutorial-skip-btn" onclick="closeTutorial()">Skip</button>
<button id="tutorial-next-btn" onclick="${isLast ? 'closeTutorial()' : 'nextTutorialStep()'}">${isLast ? 'Start Playing' : 'Next →'}</button>
</div>
</div>
`;
// Focus the next button so Enter works
const nextBtn = document.getElementById('tutorial-next-btn');
if (nextBtn) nextBtn.focus();
}
let _tutorialStep = 0;
function nextTutorialStep() {
_tutorialStep++;
renderTutorialStep(_tutorialStep);
}
// Keyboard support: Enter/Right to advance, Escape to close
document.addEventListener('keydown', function tutorialKeyHandler(e) {
if (!document.getElementById('tutorial-overlay')) return;
if (e.key === 'Enter' || e.key === 'ArrowRight') {
e.preventDefault();
if (_tutorialStep >= TUTORIAL_STEPS.length - 1) {
closeTutorial();
} else {
nextTutorialStep();
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeTutorial();
}
});
function closeTutorial() {
const overlay = document.getElementById('tutorial-overlay');
if (overlay) {
overlay.style.animation = 'tutorial-fade-in 0.3s ease-in reverse';
setTimeout(() => overlay.remove(), 280);
}
markTutorialDone();
}
function startTutorial() {
if (isTutorialDone()) return;
createTutorialStyles();
_tutorialStep = 0;
// Small delay so the page renders first
setTimeout(() => renderTutorialStep(0), 300);
}

View File

@@ -1,315 +0,0 @@
// === 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 (0999)
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';
}
// NOTE: exportSave() and importSave() are defined in render.js (file-based).
// The clipboard/prompt versions that were here were dead code — render.js
// loads after utils.js and overrides them. Removed to avoid confusion.
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;
}
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;
}
/**
* Spawns a burst of particles at (x, y) for visual feedback.
* @param {number} x - Center X in viewport pixels.
* @param {number} y - Center Y in viewport pixels.
* @param {string} color - Particle color (CSS value).
* @param {number} [count=12] - Number of particles.
*/
function spawnParticles(x, y, color, count) {
count = count || 12;
for (let i = 0; i < count; i++) {
const el = document.createElement('div');
el.className = 'particle';
const size = 3 + Math.random() * 4;
const angle = (Math.PI * 2 * i / count) + (Math.random() - 0.5) * 0.5;
const dist = 30 + Math.random() * 40;
const px = Math.cos(angle) * dist;
const py = Math.sin(angle) * dist;
el.style.cssText =
'left:' + x + 'px;top:' + y + 'px;width:' + size + 'px;height:' + size +
'px;background:' + color + ';--px:' + px + 'px;--py:' + py + 'px';
document.body.appendChild(el);
setTimeout(function() { el.remove(); }, 650);
}
}
/**
* Calculates production rates for all resources based on buildings and boosts.
*/

0
scripts/guardrails.sh Normal file → Executable file
View File

0
scripts/smoke.mjs Normal file → Executable file
View File