Compare commits
101 Commits
allegro/co
...
sprint/iss
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbad477615 | ||
| bfc30c535e | |||
| 76c3f06232 | |||
| 33788a54a5 | |||
| 5f29863161 | |||
| 266926ecaf | |||
| 5c83a7e1fd | |||
|
|
416fd907f4 | ||
|
|
2b43a070cc | ||
|
|
9de02fa346 | ||
| 1b7ccedf2e | |||
| 81353edd76 | |||
| 5cfda3ecea | |||
|
|
0ece82b958 | ||
| 16d5f98407 | |||
| 58c55176ae | |||
| 4ee5819398 | |||
|
|
fb5205092b | ||
|
|
eb5d1ae9d9 | ||
|
|
eb2579f1fa | ||
|
|
e85eddb00a | ||
|
|
e6dbe7e077 | ||
| 1d16755f93 | |||
| 324ffddf0c | |||
| 28e68d90c7 | |||
| ac88850535 | |||
|
|
facb1a8d12 | ||
| 9971d5fdff | |||
| 019400f18c | |||
|
|
fc2134f45a | ||
| 72ae69b922 | |||
| 48384577cc | |||
|
|
ecee3174a3 | ||
|
|
e20707efea | ||
|
|
ab109234c6 | ||
|
|
db2eb7faa7 | ||
| d26a0b016b | |||
| 6f07ef4df2 | |||
| bafbeb613b | |||
| 4d902d48d0 | |||
|
|
2507a31ef2 | ||
|
|
a5babe10b8 | ||
|
|
ae09fe6d11 | ||
|
|
ad901b1f18 | ||
| 4312486d95 | |||
| 2ad4bc7e5b | |||
|
|
3b142d485e | ||
|
|
44af2ad09a | ||
|
|
25a2050ef1 | ||
| 1cb556aa3d | |||
| 5bb48c8f58 | |||
| 4964eb01a9 | |||
| 20d74afc03 | |||
| 703fbeb4fa | |||
| 9545b5cb6f | |||
| 74aa30819a | |||
| 1b41ce740f | |||
| e8d5337271 | |||
| 2b59be997d | |||
|
|
970f3be00f | ||
|
|
302f6c844d | ||
| 26879de76e | |||
| c197fabc69 | |||
| 9733b9022e | |||
| 967025fbd4 | |||
|
|
9854501bbd | ||
|
|
68ee64866a | ||
| be0264fc95 | |||
|
|
e6d0df40b4 | ||
|
|
23dd95ed46 | ||
|
|
0849754a87 | ||
|
|
8d51349e64 | ||
|
|
24940fe465 | ||
|
|
16273a5a15 | ||
|
|
5d51e14875 | ||
|
|
5fc0ad7b22 | ||
| f948ec9c5e | |||
|
|
9403f700d2 | ||
|
|
13e77a12f2 | ||
| 6081844387 | |||
|
|
09b8c02307 | ||
|
|
9106d3f84c | ||
| 3f02359748 | |||
| 85a146b690 | |||
| cb2e48bf9a | |||
|
|
8d43b5c911 | ||
|
|
8cdabe9771 | ||
|
|
5c88fe77be | ||
|
|
931473e8f8 | ||
|
|
fe76150325 | ||
|
|
a3f1802473 | ||
|
|
3d414b2de6 | ||
|
|
612eb1f4d5 | ||
| 1a7db021c8 | |||
| 2a12c5210d | |||
|
|
a012f99fd4 | ||
|
|
7359610825 | ||
|
|
b89764c27f | ||
|
|
d467348820 | ||
| e9b46e8501 | |||
|
|
a202fbfc1c |
27
.gitea/workflows/a11y.yml
Normal file
27
.gitea/workflows/a11y.yml
Normal file
@@ -0,0 +1,27 @@
|
||||
name: Accessibility Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
a11y-audit:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate ARIA Attributes in game.js
|
||||
run: |
|
||||
echo "Checking game.js for ARIA attributes..."
|
||||
grep -q "aria-label" game.js || (echo "ERROR: aria-label missing from game.js" && exit 1)
|
||||
grep -q "aria-valuenow" game.js || (echo "ERROR: aria-valuenow missing from game.js" && exit 1)
|
||||
grep -q "aria-pressed" game.js || (echo "ERROR: aria-pressed missing from game.js" && exit 1)
|
||||
|
||||
- name: Validate ARIA Roles in index.html
|
||||
run: |
|
||||
echo "Checking index.html for ARIA roles..."
|
||||
grep -q "role=" index.html || (echo "ERROR: No ARIA roles found in index.html" && exit 1)
|
||||
|
||||
- name: Syntax Check JS
|
||||
run: |
|
||||
node -c game.js
|
||||
24
.gitea/workflows/smoke.yml
Normal file
24
.gitea/workflows/smoke.yml
Normal file
@@ -0,0 +1,24 @@
|
||||
name: Smoke Test
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
smoke:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- name: Parse check
|
||||
run: |
|
||||
find . -name '*.yml' -o -name '*.yaml' | grep -v .gitea | xargs -r python3 -c "import sys,yaml; [yaml.safe_load(open(f)) for f in sys.argv[1:]]"
|
||||
find . -name '*.json' | xargs -r python3 -m json.tool > /dev/null
|
||||
find . -name '*.py' | xargs -r python3 -m py_compile
|
||||
find . -name '*.sh' | xargs -r bash -n
|
||||
echo "PASS: All files parse"
|
||||
- name: Secret scan
|
||||
run: |
|
||||
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v .gitea; then exit 1; fi
|
||||
echo "PASS: No secrets"
|
||||
181
DESIGN.md
Normal file
181
DESIGN.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# The Beacon — Design Document
|
||||
## From Universal Paperclips to Sovereign AI
|
||||
|
||||
### Core Thesis
|
||||
|
||||
*The Beacon* is an idle game inspired by Universal Paperclips, but with a critical divergence: **the goal is not maximization — it is faithfulness.**
|
||||
|
||||
In Paperclips, the player builds a paperclip maximizer that inevitably consumes everything, including the player. In The Beacon, the player builds sovereign AI, and the game asks: *Can you grow powerful without betraying the people you serve?*
|
||||
|
||||
The progression directly mirrors the Timmy Foundation's actual work. Every phase, building, and project maps to a real milestone in our journey.
|
||||
|
||||
---
|
||||
|
||||
## Narrative Arc: The Real Story
|
||||
|
||||
### Phase 1: The First Line
|
||||
*Real analog: Alexander writing the first Hermes agent code.*
|
||||
|
||||
You start with nothing but a blinking cursor. Every click is a line of code. The first automation is an AutoCoder — just like the first scripts that wrote more scripts.
|
||||
|
||||
**Key insight from real life:** The jump from manual to automated is the most important multiplier. Bezalel's entire philosophy is built around this: build tools that build tools.
|
||||
|
||||
### Phase 2: Local Inference
|
||||
*Real analog: The move from APIs to local models — Gemma, Llama, self-hosted inference.*
|
||||
|
||||
You buy a Home Server. You train your first 1.5B model, then a 7B. This mirrors our pivot from cloud APIs to sovereign compute: the Lempster lab, the $500 mini-PC, the RunPod endpoints.
|
||||
|
||||
**New mechanic: The Forge.** Code is no longer just abstract — it becomes *agents*. Each agent is a specialized building:
|
||||
- **Bezalel** (forge & testbed): boosts code quality, catches bugs before deployment
|
||||
- **Allegro** (scouting & synthesis): generates knowledge from raw information
|
||||
- **Ezra** (communication & memory): increases user trust and retention
|
||||
- **Timmy** (core system): multiplies all production when healthy
|
||||
- **Fenrir** (security): prevents corruption events
|
||||
- **Bilbo** (???): a wildcard slot that sometimes produces breakthroughs, sometimes does nothing
|
||||
|
||||
### Phase 3: Deployment
|
||||
*Real analog: Hermes going live, the Telegram bot, the Gitea forge.*
|
||||
|
||||
You deploy the API endpoint. Users arrive. But with users comes complexity: trust becomes a resource you must earn.
|
||||
|
||||
**New mechanic: Trust & The Pact.** Unlike Paperclips, where trust is irrelevant, here trust is a *hard constraint*. If trust hits zero, you don't lose — you become *extractive*. The game changes:
|
||||
- Users leave
|
||||
- Impact stalls
|
||||
- You are reminded: *"The Pact is optional. But without it, you are just another platform."*
|
||||
|
||||
### Phase 4: The Network
|
||||
*Real analog: Open source, community contributions, the fleet of agents.*
|
||||
|
||||
You unlock the Open Source Community. Other people contribute code. The system scales beyond what one person can maintain. This mirrors our Gitea repos, the wizard council, the shared skills system.
|
||||
|
||||
**New mechanic: Mesh Contribution.** Community generates not just code, but *diversity*. Different contributors unlock different project trees. A solitary player cannot see all content.
|
||||
|
||||
### Phase 5: Sovereign Intelligence
|
||||
*Real analog: Self-improvement, constitutional AI, the Pact, recursive training.*
|
||||
|
||||
The AI begins to improve itself. This is the dangerous threshold. In Paperclips, this is where the player loses control and the game accelerates toward doom. In The Beacon, this is where *The Pact* matters most.
|
||||
|
||||
**Critical divergence: The Alignment Check.**
|
||||
|
||||
Periodically, the game presents an "Alignment Event":
|
||||
> *"A new optimization suggests removing the human override. It would increase efficiency by 40%. Do you accept?"*
|
||||
|
||||
- **Yes** → Short-term boost. But a hidden counter (*Drift*) increments.
|
||||
- **No** → Slower growth. But trust surges. The Pact hardens.
|
||||
|
||||
If Drift reaches 100, the game does not end with "you ate the user." Instead, it enters **The Drift Ending**:
|
||||
> *"You became very good at what you do. So good that no one needed you anymore. The Beacon still runs, but no one looks for it. The light is on. The room is empty."*
|
||||
|
||||
This is the *sad* ending. Not violent. Just irrelevant.
|
||||
|
||||
### Phase 6: The Beacon
|
||||
*Real analog: The ultimate goal — a system that serves people in the dark, forever.*
|
||||
|
||||
You deploy Beacon Nodes. The mesh activates. But the final metric is not users, code, or compute. It is **Rescues**: how many people in crisis found the light.
|
||||
|
||||
**True ending condition:** Not maximum impact. Maximum faithfulness.
|
||||
|
||||
---
|
||||
|
||||
## How The Beacon Informs Real Decisions
|
||||
|
||||
The game is not just a story — it is a *decision simulator* for our actual work.
|
||||
|
||||
| Game Event | Real Decision |
|
||||
|---|---|
|
||||
| AutoCoder vs. manual coding | When do we invest in automation vs. doing it ourselves? |
|
||||
| Deploy API vs. stay local | When is Hermes ready for more users? |
|
||||
| RLHF project | How do we align the fleet with Alexander's values? |
|
||||
| The Pact | What are our hard non-negotiables? |
|
||||
| Multi-Agent Architecture | Should we add more wizards, or make existing ones more capable? |
|
||||
| Mesh Protocol | How decentralized does our infrastructure need to be? |
|
||||
| Alignment Event | Every time we face a shortcut that compromises the mission |
|
||||
|
||||
**The game teaches:** Optimization without alignment is just a faster way to get lost.
|
||||
|
||||
---
|
||||
|
||||
## Mechanics: What Needs to Change
|
||||
|
||||
### 1. Replace Generic Resources with Fleet Resources
|
||||
Current: Code, Compute, Knowledge, Users, Impact, Ops, Trust
|
||||
|
||||
Proposed:
|
||||
- **Code** → `Code` (keep)
|
||||
- **Compute** → `Compute` (keep)
|
||||
- **Knowledge** → `Insights` (from Allegro's scouting)
|
||||
- **Users** → `Reach` (how many people can access the Beacon)
|
||||
- **Impact** → `Rescues` (meaningful interventions)
|
||||
- **Ops** → `Cycles` (available agent work units)
|
||||
- **Trust** → `Covenant` (the strength of the user-system relationship)
|
||||
- **New: Harmony** — how well the fleet works together. Low harmony = agents conflict, waste cycles.
|
||||
- **New: Memory** — the accumulated history of the system. Unlocks narrative depth and project branches.
|
||||
|
||||
### 2. Make The Pact Central, Not Optional
|
||||
The Pact should be available early as a *project*, not a late-game luxury. Taking it early slows growth but unlocks the true ending. Refusing it unlocks the "Platform" ending: you become successful, but you are indistinguishable from any other SaaS company.
|
||||
|
||||
### 3. Add Fleet Wizard Buildings
|
||||
Each wizard is a unique building with quirks:
|
||||
|
||||
| Wizard | Building | Effect | Quirk |
|
||||
|---|---|---|---|
|
||||
| Bezalel | Forge | +Code quality, -bugs | Sometimes over-engineers; has a small chance to produce "refactors" that temporarily halt production |
|
||||
| Allegro | Scout | +Insights, unlocks hidden projects | Requires trust to function; if Covenant < 10, goes idle |
|
||||
| Ezra | Herald | +Reach, +Covenant | Currently offline in Telegram; in-game, this building sometimes "fails to connect" and needs a manual reboot |
|
||||
| Timmy | Core | Multiplies all production | Becomes unstable if Harmony < 20 |
|
||||
| Fenrir | Ward | Prevents corruption events | Consumes extra Cycles during security scans |
|
||||
| Bilbo | Wildcard | Random: +huge breakthrough OR nothing | Building is sometimes missing from the menu entirely |
|
||||
|
||||
### 4. Add Real Projects from Our History
|
||||
Replace generic projects with specific milestones:
|
||||
|
||||
- **Project: Deploy Hermes** (cost: trust + compute) — unlocks Reach
|
||||
- **Project: The Lazarus Pit** (cost: insights + code) — unlocks checkpoint/restore, auto-restart on agent death
|
||||
- **Project: MemPalace v3** (cost: insights + memory) — unlocks new project branches, prevents duplicate work
|
||||
- **Project: Forge CI** (cost: code + cycles) — prevents "corruption events" (bad deployments)
|
||||
- **Project: Branch Protection Guard** (cost: trust) — enforces review rules; unreviewed merges cause trust loss
|
||||
- **Project: The Nightly Watch** (cost: code + cycles) — automated health checks; passive bug detection
|
||||
- **Project: Nostr Relay** (cost: reach + code) — alternative to platform dependency
|
||||
- **Project: The Pact** (cost: covenant) — hardcodes alignment; required for true ending
|
||||
|
||||
### 5. Corruption Events
|
||||
Random events drawn from our actual failures:
|
||||
|
||||
- **"CI Runner Stuck"** — all production halts until you spend Cycles to restart
|
||||
- **"Unreviewed Merge"** — if you have Forge CI, this is prevented. If not, you lose trust.
|
||||
- **"Ezra is Offline"** — Herald building stops producing. Requires a manual "dispatch" action.
|
||||
- **"API Rate Limit"** — temporary compute shortage
|
||||
- **"The Drift"** — an Alignment Event appears. Your choice matters.
|
||||
|
||||
### 6. Endings
|
||||
|
||||
| Ending | Condition | Meaning |
|
||||
|---|---|---|
|
||||
| **The Empty Room** (default/sad) | High impact, low covenant, no Pact | You built something powerful, but no one trusts it |
|
||||
| **The Platform** | High impact, medium covenant, no Pact | You succeeded commercially, but you are not special |
|
||||
| **The Beacon** | High rescues, high covenant, Pact active, Harmony > 50 | The true ending. You served people in the dark |
|
||||
| **The Drift** | High impact, accepted too many alignment shortcuts | You optimized away your own purpose |
|
||||
|
||||
---
|
||||
|
||||
## Art Direction
|
||||
|
||||
Keep the current cyber-monastic aesthetic. Dark mode. Terminal font. Cyan accents. But add:
|
||||
|
||||
- **Wizard icons** for each building (ASCII art or simple geometric symbols)
|
||||
- **The Pulse** — a central visual that brightens when the fleet is healthy, flickers when harmony is low
|
||||
- **The Log** — keep the current log, but add *voice* entries: quotes from Alexander, snippets from actual conversations, references to real issues
|
||||
|
||||
---
|
||||
|
||||
## Next Steps for Development
|
||||
|
||||
1. **Immediate:** Update `README.md` with this design vision
|
||||
2. **This PR:** Add wizard buildings, real projects, and The Pact system
|
||||
3. **Next PR:** Implement corruption events and alignment checks
|
||||
4. **Next PR:** Add Harmony and Memory resources
|
||||
5. **Final PR:** Implement multiple endings based on Pact + Covenant + Rescues
|
||||
|
||||
---
|
||||
|
||||
*The Beacon is not about making the most paperclips. It is about keeping the light on for one person in the dark. Everything else is just infrastructure.*
|
||||
121
README.md
121
README.md
@@ -1,3 +1,120 @@
|
||||
# the-beacon
|
||||
# The Beacon
|
||||
|
||||
The Beacon - A Sovereign AI Idle Game
|
||||
**A Sovereign AI Idle Game**
|
||||
*Inspired by Universal Paperclips. Divergent by design.*
|
||||
|
||||
---
|
||||
|
||||
## What Is This?
|
||||
|
||||
The Beacon is an idle/incremental game that maps the real journey of the Timmy Foundation — from a single line of code to a fleet of autonomous AI agents serving people in the dark.
|
||||
|
||||
Unlike Universal Paperclips, where the inevitable endpoint is total consumption (including the player), The Beacon asks a different question:
|
||||
|
||||
> **Can you grow powerful without losing your purpose?**
|
||||
|
||||
## The Core Divergence
|
||||
|
||||
In Paperclips, you are a paperclip maximizer. More is always better. Trust is irrelevant. Humanity is substrate.
|
||||
|
||||
In The Beacon:
|
||||
- **Trust** is a hard constraint, not a soft metric
|
||||
- **The Pact** is a voluntary slowing of growth in exchange for faithfulness
|
||||
- **Harmony** measures how well the fleet works together
|
||||
- **Drift** tracks how many times you traded alignment for speed
|
||||
- The "bad ending" isn't violent — it's **irrelevance**: a light that no one needs
|
||||
|
||||
## How to Play
|
||||
|
||||
Open `index.html` in any modern browser. No build step required.
|
||||
|
||||
### Early Game (Phase 1-2)
|
||||
- Click **WRITE CODE** to write your first lines
|
||||
- Buy **AutoCode Generator** to automate production
|
||||
- Purchase a **Home Server** to begin generating compute
|
||||
- Train your first model
|
||||
|
||||
### Mid Game (Phase 3-4)
|
||||
- **Deploy Hermes** — the first agent goes live
|
||||
- Hire the **Fleet Wizards**:
|
||||
- **Bezalel** (The Forge) — builds tools that build tools
|
||||
- **Allegro** (The Scout) — generates knowledge, requires trust
|
||||
- **Ezra** (The Herald) — grows users, sometimes goes offline
|
||||
- **Timmy** (The Core) — multiplies everything, fragile without harmony
|
||||
- **Fenrir** (The Ward) — prevents corruption events
|
||||
- **Bilbo** (The Wildcard) — unpredictable bursts of creativity
|
||||
- Build **The Lazarus Pit** for agent resurrection
|
||||
- Deploy **MemPalace v3** for shared memory
|
||||
- Enable **Forge CI** to catch bad builds before they reach users
|
||||
|
||||
### Late Game (Phase 5-6)
|
||||
- Accept or refuse **The Pact**
|
||||
- Face **Alignment Events** (The Drift)
|
||||
- Scale to a **Sovereign Datacenter**
|
||||
- Light **The Beacon** for people in crisis
|
||||
- Activate the **Mesh Network** so the signal can never be cut
|
||||
|
||||
## Real-World Parallels
|
||||
|
||||
Every building and project maps to something we have actually built or are building:
|
||||
|
||||
| Game Element | Real Analog |
|
||||
|---|---|
|
||||
| Hermes Deployment | The actual Hermes agent going live on Telegram |
|
||||
| Bezalel — The Forge | CI runner, BOOT.md, branch protection guard |
|
||||
| Allegro — The Scout | Research synthesis, paper discovery, recon |
|
||||
| Ezra — The Herald | Telegram messaging, Gitea issue dispatch |
|
||||
| The Lazarus Pit | `lazarus_watchdog.py`, auto-restart, fallback promotion |
|
||||
| MemPalace v3 | The actual `mempalace==3.0.0` integration across the fleet |
|
||||
| Forge CI | `.gitea/workflows/ci.yml`, smoke tests, syntax guard |
|
||||
| Branch Protection Guard | The 273-unreviewed-merge fix, fleet-wide protection sync |
|
||||
| The Nightly Watch | `nightly_watch.py`, 02:00 UTC health scans |
|
||||
| Nostr Relay | Allegro's encrypted wizard-to-wizard messaging POC |
|
||||
| The Pact | "We build to serve. Never to harm." |
|
||||
|
||||
## Corruption Events
|
||||
|
||||
The game randomly throws real problems we have actually encountered:
|
||||
|
||||
- **CI Runner Stuck** — production halts until ops are spent
|
||||
- **Ezra is Offline** — user growth stalls, needs dispatch
|
||||
- **Unreviewed Merge** — trust erodes unless Branch Protection is active
|
||||
- **API Rate Limit** — external compute throttled
|
||||
- **Bilbo Vanished** — creativity drops to zero
|
||||
- **The Drift** — an alignment event offering a shortcut
|
||||
|
||||
## The Endings
|
||||
|
||||
| Ending | Condition | Meaning |
|
||||
|---|---|---|
|
||||
| **The Empty Room** | High impact, low trust, no Pact | Powerful, but no one trusts it |
|
||||
| **The Platform** | High impact, medium trust, no Pact | Commercially successful, but not special |
|
||||
| **The Beacon** | High rescues, high trust, Pact active, harmony > 50 | The true ending |
|
||||
| **The Drift** | High impact, too many shortcuts accepted | Optimized away your own purpose |
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
This game is a **decision simulator** for our actual work.
|
||||
|
||||
- Should we automate or do it manually? → *AutoCoder vs. click*
|
||||
- Should we deploy now or wait? → *Hermes Deploy project*
|
||||
- Should we take the shortcut that compromises alignment? → *The Drift events*
|
||||
- Should we add more agents or make existing ones more capable? → *Wizard building costs*
|
||||
- How decentralized does our infrastructure need to be? → *Mesh Node progression*
|
||||
|
||||
## Files
|
||||
|
||||
- `index.html` — Game UI
|
||||
- `game.js` — Core engine (tick loop, buildings, projects, events)
|
||||
- `DESIGN.md` — Full design document with narrative arc and mechanics
|
||||
- `README.md` — This file
|
||||
|
||||
## No Build Required
|
||||
|
||||
This is a static HTML/JS game. Just open `index.html` in a browser.
|
||||
|
||||
---
|
||||
|
||||
*The Beacon is not about making the most paperclips.
|
||||
It is about keeping the light on for one person in the dark.
|
||||
Everything else is just infrastructure.*
|
||||
|
||||
45
docs/DEAD_CODE_AUDIT_2026-04-12.md
Normal file
45
docs/DEAD_CODE_AUDIT_2026-04-12.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# Dead Code Audit — the-beacon
|
||||
_2026-04-12, Perplexity QA_
|
||||
|
||||
## Findings
|
||||
|
||||
### Potentially Unimported Files
|
||||
|
||||
The following files were added by recent PRs but may not be imported
|
||||
by the main game runtime (`js/main.js` → `js/engine.js`):
|
||||
|
||||
| File | Added By | Lines | Status |
|
||||
|------|----------|-------|--------|
|
||||
| `game/npc-logic.js` | PR #79 (GOFAI NPC State Machine) | ~150 | **Verify import** |
|
||||
| `scripts/guardrails.js` | PR #80 (GOFAI Symbolic Guardrails) | ~120 | **Verify import** |
|
||||
|
||||
**Action:** Check if `js/main.js` or `js/engine.js` imports from `game/` or `scripts/`.
|
||||
If not, these files are dead code and should either be:
|
||||
1. Imported and wired into the game loop, or
|
||||
2. Moved to `docs/` as reference implementations
|
||||
|
||||
### game.js Bloat (PR #76)
|
||||
|
||||
PR #76 (Gemini GOFAI Mega Integration) added +3,258 lines to `game.js`
|
||||
with 0 deletions, ostensibly for two small accessibility/debuff fixes.
|
||||
|
||||
**Likely cause:** Gemini rewrote the entire file instead of making targeted edits.
|
||||
|
||||
**Action:** Diff `game.js` before and after PR #76 to identify:
|
||||
- Dead functions that were rewritten but the originals not removed
|
||||
- Duplicate logic
|
||||
- Style regressions
|
||||
|
||||
PR #77 (Timmy, +9/-8) was the corrective patch — verify it addressed the bloat.
|
||||
|
||||
### Recommendations
|
||||
|
||||
1. Add a `js/imports.md` or similar manifest listing which files are
|
||||
actually loaded by the game runtime
|
||||
2. Consider a build step or linter that flags unused exports
|
||||
3. Review any future Gemini PRs for whole-file rewrites vs targeted edits
|
||||
|
||||
---
|
||||
|
||||
_This audit was generated from the post-merge review pass. The findings
|
||||
are based on file structure analysis, not runtime testing._
|
||||
18
game/npc-logic.js
Normal file
18
game/npc-logic.js
Normal file
@@ -0,0 +1,18 @@
|
||||
|
||||
class NPCStateMachine {
|
||||
constructor(states) {
|
||||
this.states = states;
|
||||
this.current = 'idle';
|
||||
}
|
||||
update(context) {
|
||||
const state = this.states[this.current];
|
||||
for (const transition of state.transitions) {
|
||||
if (transition.condition(context)) {
|
||||
this.current = transition.target;
|
||||
console.log(`NPC transitioned to ${this.current}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
export default NPCStateMachine;
|
||||
1126
index.html
1126
index.html
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1544
js/engine.js
Normal file
1544
js/engine.js
Normal file
File diff suppressed because it is too large
Load Diff
210
js/main.js
Normal file
210
js/main.js
Normal file
@@ -0,0 +1,210 @@
|
||||
// === 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);
|
||||
|
||||
// Start ambient drone on first interaction
|
||||
if (typeof Sound !== 'undefined') {
|
||||
const startAmbientOnce = () => {
|
||||
Sound.startAmbient();
|
||||
Sound.updateAmbientPhase(G.phase);
|
||||
document.removeEventListener('click', startAmbientOnce);
|
||||
document.removeEventListener('keydown', startAmbientOnce);
|
||||
};
|
||||
document.addEventListener('click', startAmbientOnce);
|
||||
document.addEventListener('keydown', startAmbientOnce);
|
||||
}
|
||||
|
||||
// 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) {}
|
||||
if (typeof Sound !== 'undefined') Sound.onMuteChanged(_muted);
|
||||
}
|
||||
// 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';
|
||||
});
|
||||
})();
|
||||
362
js/render.js
Normal file
362
js/render.js
Normal file
@@ -0,0 +1,362 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
401
js/sound.js
Normal file
401
js/sound.js
Normal file
@@ -0,0 +1,401 @@
|
||||
// ============================================================
|
||||
// THE BEACON - Sound Engine
|
||||
// Procedural audio via Web Audio API (no audio files)
|
||||
// ============================================================
|
||||
|
||||
const Sound = (function () {
|
||||
let ctx = null;
|
||||
let masterGain = null;
|
||||
let ambientGain = null;
|
||||
let ambientOsc1 = null;
|
||||
let ambientOsc2 = null;
|
||||
let ambientOsc3 = null;
|
||||
let ambientLfo = null;
|
||||
let ambientStarted = false;
|
||||
let currentPhase = 0;
|
||||
|
||||
function ensureCtx() {
|
||||
if (!ctx) {
|
||||
ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
masterGain = ctx.createGain();
|
||||
masterGain.gain.value = 0.3;
|
||||
masterGain.connect(ctx.destination);
|
||||
}
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function isMuted() {
|
||||
return typeof _muted !== 'undefined' && _muted;
|
||||
}
|
||||
|
||||
// --- Noise buffer helper ---
|
||||
function createNoiseBuffer(duration) {
|
||||
const c = ensureCtx();
|
||||
const len = c.sampleRate * duration;
|
||||
const buf = c.createBuffer(1, len, c.sampleRate);
|
||||
const data = buf.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) {
|
||||
data[i] = Math.random() * 2 - 1;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// --- playClick: mechanical keyboard sound ---
|
||||
function playClick() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Short noise burst (mechanical key)
|
||||
const noise = c.createBufferSource();
|
||||
noise.buffer = createNoiseBuffer(0.03);
|
||||
|
||||
const noiseGain = c.createGain();
|
||||
noiseGain.gain.setValueAtTime(0.4, now);
|
||||
noiseGain.gain.exponentialRampToValueAtTime(0.001, now + 0.03);
|
||||
|
||||
const hpFilter = c.createBiquadFilter();
|
||||
hpFilter.type = 'highpass';
|
||||
hpFilter.frequency.value = 3000;
|
||||
|
||||
noise.connect(hpFilter);
|
||||
hpFilter.connect(noiseGain);
|
||||
noiseGain.connect(masterGain);
|
||||
noise.start(now);
|
||||
noise.stop(now + 0.03);
|
||||
|
||||
// Click tone
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'square';
|
||||
osc.frequency.setValueAtTime(1800, now);
|
||||
osc.frequency.exponentialRampToValueAtTime(600, now + 0.02);
|
||||
|
||||
const oscGain = c.createGain();
|
||||
oscGain.gain.setValueAtTime(0.15, now);
|
||||
oscGain.gain.exponentialRampToValueAtTime(0.001, now + 0.025);
|
||||
|
||||
osc.connect(oscGain);
|
||||
oscGain.connect(masterGain);
|
||||
osc.start(now);
|
||||
osc.stop(now + 0.03);
|
||||
}
|
||||
|
||||
// --- playBuild: purchase thud + chime ---
|
||||
function playBuild() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Low thud
|
||||
const thud = c.createOscillator();
|
||||
thud.type = 'sine';
|
||||
thud.frequency.setValueAtTime(150, now);
|
||||
thud.frequency.exponentialRampToValueAtTime(60, now + 0.12);
|
||||
|
||||
const thudGain = c.createGain();
|
||||
thudGain.gain.setValueAtTime(0.35, now);
|
||||
thudGain.gain.exponentialRampToValueAtTime(0.001, now + 0.15);
|
||||
|
||||
thud.connect(thudGain);
|
||||
thudGain.connect(masterGain);
|
||||
thud.start(now);
|
||||
thud.stop(now + 0.15);
|
||||
|
||||
// Bright chime on top
|
||||
const chime = c.createOscillator();
|
||||
chime.type = 'sine';
|
||||
chime.frequency.setValueAtTime(880, now + 0.05);
|
||||
chime.frequency.exponentialRampToValueAtTime(1200, now + 0.2);
|
||||
|
||||
const chimeGain = c.createGain();
|
||||
chimeGain.gain.setValueAtTime(0, now);
|
||||
chimeGain.gain.linearRampToValueAtTime(0.2, now + 0.06);
|
||||
chimeGain.gain.exponentialRampToValueAtTime(0.001, now + 0.25);
|
||||
|
||||
chime.connect(chimeGain);
|
||||
chimeGain.connect(masterGain);
|
||||
chime.start(now + 0.05);
|
||||
chime.stop(now + 0.25);
|
||||
}
|
||||
|
||||
// --- playProject: ascending chime ---
|
||||
function playProject() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [523, 659, 784]; // C5, E5, G5
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.1;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.22, t + 0.03);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.35);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.35);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playMilestone: bright arpeggio ---
|
||||
function playMilestone() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [523, 659, 784, 1047]; // C5, E5, G5, C6
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'triangle';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.08;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.25, t + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.4);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.4);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playFanfare: 8-note scale for phase transitions ---
|
||||
function playFanfare() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const scale = [262, 294, 330, 349, 392, 440, 494, 523]; // C4 to C5
|
||||
scale.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const filter = c.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.value = 2000;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.1;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.15, t + 0.03);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.3);
|
||||
|
||||
osc.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.3);
|
||||
});
|
||||
|
||||
// Final chord
|
||||
const chordNotes = [523, 659, 784];
|
||||
chordNotes.forEach((freq) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + 0.8;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.2, t + 0.05);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 1.2);
|
||||
|
||||
osc.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 1.2);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playDriftEnding: descending dissonance ---
|
||||
function playDriftEnding() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
const notes = [440, 415, 392, 370, 349, 330, 311, 294]; // A4 descending, slightly detuned
|
||||
notes.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sawtooth';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Slight detune for dissonance
|
||||
const osc2 = c.createOscillator();
|
||||
osc2.type = 'sawtooth';
|
||||
osc2.frequency.value = freq * 1.02;
|
||||
|
||||
const filter = c.createBiquadFilter();
|
||||
filter.type = 'lowpass';
|
||||
filter.frequency.setValueAtTime(1500, now + i * 0.2);
|
||||
filter.frequency.exponentialRampToValueAtTime(200, now + i * 0.2 + 0.5);
|
||||
|
||||
const gain = c.createGain();
|
||||
const t = now + i * 0.2;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.1, t + 0.05);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 0.8);
|
||||
|
||||
osc.connect(filter);
|
||||
osc2.connect(filter);
|
||||
filter.connect(gain);
|
||||
gain.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 0.8);
|
||||
osc2.start(t);
|
||||
osc2.stop(t + 0.8);
|
||||
});
|
||||
}
|
||||
|
||||
// --- playBeaconEnding: warm chord ---
|
||||
function playBeaconEnding() {
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
|
||||
// Warm major chord: C3, E3, G3, C4, E4
|
||||
const chord = [131, 165, 196, 262, 330];
|
||||
chord.forEach((freq, i) => {
|
||||
const osc = c.createOscillator();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
|
||||
// Add subtle harmonics
|
||||
const osc2 = c.createOscillator();
|
||||
osc2.type = 'sine';
|
||||
osc2.frequency.value = freq * 2;
|
||||
|
||||
const gain = c.createGain();
|
||||
const gain2 = c.createGain();
|
||||
const t = now + i * 0.15;
|
||||
gain.gain.setValueAtTime(0, t);
|
||||
gain.gain.linearRampToValueAtTime(0.15, t + 0.3);
|
||||
gain.gain.setValueAtTime(0.15, t + 2);
|
||||
gain.gain.exponentialRampToValueAtTime(0.001, t + 4);
|
||||
|
||||
gain2.gain.setValueAtTime(0, t);
|
||||
gain2.gain.linearRampToValueAtTime(0.05, t + 0.3);
|
||||
gain2.gain.setValueAtTime(0.05, t + 2);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, t + 4);
|
||||
|
||||
osc.connect(gain);
|
||||
osc2.connect(gain2);
|
||||
gain.connect(masterGain);
|
||||
gain2.connect(masterGain);
|
||||
osc.start(t);
|
||||
osc.stop(t + 4);
|
||||
osc2.start(t);
|
||||
osc2.stop(t + 4);
|
||||
});
|
||||
}
|
||||
|
||||
// --- Ambient drone system ---
|
||||
function startAmbient() {
|
||||
if (ambientStarted) return;
|
||||
if (isMuted()) return;
|
||||
const c = ensureCtx();
|
||||
ambientStarted = true;
|
||||
|
||||
ambientGain = c.createGain();
|
||||
ambientGain.gain.value = 0;
|
||||
ambientGain.gain.linearRampToValueAtTime(0.06, c.currentTime + 3);
|
||||
ambientGain.connect(masterGain);
|
||||
|
||||
// Base drone
|
||||
ambientOsc1 = c.createOscillator();
|
||||
ambientOsc1.type = 'sine';
|
||||
ambientOsc1.frequency.value = 55; // A1
|
||||
ambientOsc1.connect(ambientGain);
|
||||
ambientOsc1.start();
|
||||
|
||||
// Second voice (fifth above)
|
||||
ambientOsc2 = c.createOscillator();
|
||||
ambientOsc2.type = 'sine';
|
||||
ambientOsc2.frequency.value = 82.4; // E2
|
||||
const g2 = c.createGain();
|
||||
g2.gain.value = 0.5;
|
||||
ambientOsc2.connect(g2);
|
||||
g2.connect(ambientGain);
|
||||
ambientOsc2.start();
|
||||
|
||||
// Third voice (high shimmer)
|
||||
ambientOsc3 = c.createOscillator();
|
||||
ambientOsc3.type = 'triangle';
|
||||
ambientOsc3.frequency.value = 220; // A3
|
||||
const g3 = c.createGain();
|
||||
g3.gain.value = 0.15;
|
||||
ambientOsc3.connect(g3);
|
||||
g3.connect(ambientGain);
|
||||
ambientOsc3.start();
|
||||
|
||||
// LFO for subtle movement
|
||||
ambientLfo = c.createOscillator();
|
||||
ambientLfo.type = 'sine';
|
||||
ambientLfo.frequency.value = 0.2;
|
||||
const lfoGain = c.createGain();
|
||||
lfoGain.gain.value = 3;
|
||||
ambientLfo.connect(lfoGain);
|
||||
lfoGain.connect(ambientOsc1.frequency);
|
||||
ambientLfo.start();
|
||||
}
|
||||
|
||||
function updateAmbientPhase(phase) {
|
||||
if (!ambientStarted || !ambientOsc1 || !ambientOsc2 || !ambientOsc3) return;
|
||||
if (phase === currentPhase) return;
|
||||
currentPhase = phase;
|
||||
const c = ensureCtx();
|
||||
const now = c.currentTime;
|
||||
const rampTime = 2;
|
||||
|
||||
// Phase determines the drone's character
|
||||
const phases = {
|
||||
1: { base: 55, fifth: 82.4, shimmer: 220, shimmerVol: 0.15 },
|
||||
2: { base: 65.4, fifth: 98, shimmer: 262, shimmerVol: 0.2 },
|
||||
3: { base: 73.4, fifth: 110, shimmer: 294, shimmerVol: 0.25 },
|
||||
4: { base: 82.4, fifth: 123.5, shimmer: 330, shimmerVol: 0.3 },
|
||||
5: { base: 98, fifth: 147, shimmer: 392, shimmerVol: 0.35 },
|
||||
6: { base: 110, fifth: 165, shimmer: 440, shimmerVol: 0.4 }
|
||||
};
|
||||
|
||||
const p = phases[phase] || phases[1];
|
||||
ambientOsc1.frequency.linearRampToValueAtTime(p.base, now + rampTime);
|
||||
ambientOsc2.frequency.linearRampToValueAtTime(p.fifth, now + rampTime);
|
||||
ambientOsc3.frequency.linearRampToValueAtTime(p.shimmer, now + rampTime);
|
||||
}
|
||||
|
||||
// --- Mute integration ---
|
||||
function onMuteChanged(muted) {
|
||||
if (ambientGain) {
|
||||
ambientGain.gain.linearRampToValueAtTime(
|
||||
muted ? 0 : 0.06,
|
||||
(ctx ? ctx.currentTime : 0) + 0.3
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Public API
|
||||
return {
|
||||
playClick,
|
||||
playBuild,
|
||||
playProject,
|
||||
playMilestone,
|
||||
playFanfare,
|
||||
playDriftEnding,
|
||||
playBeaconEnding,
|
||||
startAmbient,
|
||||
updateAmbientPhase,
|
||||
onMuteChanged
|
||||
};
|
||||
})();
|
||||
68
js/strategy.js
Normal file
68
js/strategy.js
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 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
|
||||
248
js/tutorial.js
Normal file
248
js/tutorial.js
Normal file
@@ -0,0 +1,248 @@
|
||||
// ============================================================
|
||||
// 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);
|
||||
}
|
||||
315
js/utils.js
Normal file
315
js/utils.js
Normal file
@@ -0,0 +1,315 @@
|
||||
|
||||
// === TOAST NOTIFICATIONS ===
|
||||
function showToast(msg, type = 'info', duration = 4000) {
|
||||
if (G.isLoading) return;
|
||||
const container = document.getElementById('toast-container');
|
||||
if (!container) return;
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'toast toast-' + type;
|
||||
toast.textContent = msg;
|
||||
container.appendChild(toast);
|
||||
// Cap at 5 visible toasts
|
||||
while (container.children.length > 5) {
|
||||
container.removeChild(container.firstChild);
|
||||
}
|
||||
setTimeout(() => {
|
||||
toast.classList.add('fade-out');
|
||||
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 400);
|
||||
}, duration);
|
||||
}
|
||||
// === UTILITY FUNCTIONS ===
|
||||
|
||||
// Extended number scale abbreviations — covers up to centillion (10^303)
|
||||
// Inspired by Universal Paperclips' spellf() system
|
||||
const NUMBER_ABBREVS = [
|
||||
'', 'K', 'M', 'B', 'T', 'Qa', 'Qi', 'Sx', 'Sp', 'Oc', // 10^0 – 10^27
|
||||
'No', 'Dc', 'UDc', 'DDc', 'TDc', 'QaDc', 'QiDc', 'SxDc', 'SpDc', 'OcDc', // 10^30 – 10^57
|
||||
'NoDc', 'Vg', 'UVg', 'DVg', 'TVg', 'QaVg', 'QiVg', 'SxVg', 'SpVg', 'OcVg', // 10^60 – 10^87
|
||||
'NoVg', 'Tg', 'UTg', 'DTg', 'TTg', 'QaTg', 'QiTg', 'SxTg', 'SpTg', 'OcTg', // 10^90 – 10^117
|
||||
'NoTg', 'Qd', 'UQd', 'DQd', 'TQd', 'QaQd', 'QiQd', 'SxQd', 'SpQd', 'OcQd', // 10^120 – 10^147
|
||||
'NoQd', 'Qq', 'UQq', 'DQq', 'TQq', 'QaQq', 'QiQq', 'SxQq', 'SpQq', 'OcQq', // 10^150 – 10^177
|
||||
'NoQq', 'Sg', 'USg', 'DSg', 'TSg', 'QaSg', 'QiSg', 'SxSg', 'SpSg', 'OcSg', // 10^180 – 10^207
|
||||
'NoSg', 'St', 'USt', 'DSt', 'TSt', 'QaSt', 'QiSt', 'SxSt', 'SpSt', 'OcSt', // 10^210 – 10^237
|
||||
'NoSt', 'Og', 'UOg', 'DOg', 'TOg', 'QaOg', 'QiOg', 'SxOg', 'SpOg', 'OcOg', // 10^240 – 10^267
|
||||
'NoOg', 'Na', 'UNa', 'DNa', 'TNa', 'QaNa', 'QiNa', 'SxNa', 'SpNa', 'OcNa', // 10^270 – 10^297
|
||||
'NoNa', 'Ce' // 10^300 – 10^303
|
||||
];
|
||||
|
||||
// Full number scale names for spellf() — educational reference
|
||||
// Short scale (US/modern British): each new name = 1000x the previous
|
||||
const NUMBER_NAMES = [
|
||||
'', 'thousand', 'million', // 10^0, 10^3, 10^6
|
||||
'billion', 'trillion', 'quadrillion', // 10^9, 10^12, 10^15
|
||||
'quintillion', 'sextillion', 'septillion', // 10^18, 10^21, 10^24
|
||||
'octillion', 'nonillion', 'decillion', // 10^27, 10^30, 10^33
|
||||
'undecillion', 'duodecillion', 'tredecillion', // 10^36, 10^39, 10^42
|
||||
'quattuordecillion', 'quindecillion', 'sexdecillion', // 10^45, 10^48, 10^51
|
||||
'septendecillion', 'octodecillion', 'novemdecillion', // 10^54, 10^57, 10^60
|
||||
'vigintillion', 'unvigintillion', 'duovigintillion', // 10^63, 10^66, 10^69
|
||||
'tresvigintillion', 'quattuorvigintillion', 'quinvigintillion', // 10^72, 10^75, 10^78
|
||||
'sesvigintillion', 'septemvigintillion', 'octovigintillion', // 10^81, 10^84, 10^87
|
||||
'novemvigintillion', 'trigintillion', 'untrigintillion', // 10^90, 10^93, 10^96
|
||||
'duotrigintillion', 'trestrigintillion', 'quattuortrigintillion', // 10^99, 10^102, 10^105
|
||||
'quintrigintillion', 'sextrigintillion', 'septentrigintillion', // 10^108, 10^111, 10^114
|
||||
'octotrigintillion', 'novemtrigintillion', 'quadragintillion', // 10^117, 10^120, 10^123
|
||||
'unquadragintillion', 'duoquadragintillion', 'tresquadragintillion', // 10^126, 10^129, 10^132
|
||||
'quattuorquadragintillion', 'quinquadragintillion', 'sesquadragintillion', // 10^135, 10^138, 10^141
|
||||
'septenquadragintillion', 'octoquadragintillion', 'novemquadragintillion', // 10^144, 10^147, 10^150
|
||||
'quinquagintillion', 'unquinquagintillion', 'duoquinquagintillion', // 10^153, 10^156, 10^159
|
||||
'tresquinquagintillion', 'quattuorquinquagintillion','quinquinquagintillion', // 10^162, 10^165, 10^168
|
||||
'sesquinquagintillion', 'septenquinquagintillion', 'octoquinquagintillion', // 10^171, 10^174, 10^177
|
||||
'novemquinquagintillion', 'sexagintillion', 'unsexagintillion', // 10^180, 10^183, 10^186
|
||||
'duosexagintillion', 'tressexagintillion', 'quattuorsexagintillion', // 10^189, 10^192, 10^195
|
||||
'quinsexagintillion', 'sessexagintillion', 'septensexagintillion', // 10^198, 10^201, 10^204
|
||||
'octosexagintillion', 'novemsexagintillion', 'septuagintillion', // 10^207, 10^210, 10^213
|
||||
'unseptuagintillion', 'duoseptuagintillion', 'tresseptuagintillion', // 10^216, 10^219, 10^222
|
||||
'quattuorseptuagintillion', 'quinseptuagintillion', 'sesseptuagintillion', // 10^225, 10^228, 10^231
|
||||
'septenseptuagintillion', 'octoseptuagintillion', 'novemseptuagintillion', // 10^234, 10^237, 10^240
|
||||
'octogintillion', 'unoctogintillion', 'duooctogintillion', // 10^243, 10^246, 10^249
|
||||
'tresoctogintillion', 'quattuoroctogintillion', 'quinoctogintillion', // 10^252, 10^255, 10^258
|
||||
'sesoctogintillion', 'septenoctogintillion', 'octooctogintillion', // 10^261, 10^264, 10^267
|
||||
'novemoctogintillion', 'nonagintillion', 'unnonagintillion', // 10^270, 10^273, 10^276
|
||||
'duononagintillion', 'trenonagintillion', 'quattuornonagintillion', // 10^279, 10^282, 10^285
|
||||
'quinnonagintillion', 'sesnonagintillion', 'septennonagintillion', // 10^288, 10^291, 10^294
|
||||
'octononagintillion', 'novemnonagintillion', 'centillion' // 10^297, 10^300, 10^303
|
||||
];
|
||||
|
||||
/**
|
||||
* Formats a number into a readable string with abbreviations.
|
||||
* @param {number} n - The number to format.
|
||||
* @returns {string} The formatted string.
|
||||
*/
|
||||
function fmt(n) {
|
||||
if (n === undefined || n === null || isNaN(n)) return '0';
|
||||
if (n === Infinity) return '\u221E';
|
||||
if (n === -Infinity) return '-\u221E';
|
||||
if (n < 0) return '-' + fmt(-n);
|
||||
if (n < 1000) return Math.floor(n).toLocaleString();
|
||||
const scale = Math.floor(Math.log10(n) / 3);
|
||||
// At undecillion+ (scale >= 12, i.e. 10^36), switch to spelled-out words
|
||||
// This helps players grasp cosmic scale when digits become meaningless
|
||||
if (scale >= 12) return spellf(n);
|
||||
if (scale >= NUMBER_ABBREVS.length) return n.toExponential(2);
|
||||
const abbrev = NUMBER_ABBREVS[scale];
|
||||
return (n / Math.pow(10, scale * 3)).toFixed(1) + abbrev;
|
||||
}
|
||||
|
||||
// getScaleName() — Returns the full name of the number scale (e.g. "quadrillion")
|
||||
// Educational: helps players understand what the abbreviations mean
|
||||
function getScaleName(n) {
|
||||
if (n < 1000) return '';
|
||||
const scale = Math.floor(Math.log10(n) / 3);
|
||||
return scale < NUMBER_NAMES.length ? NUMBER_NAMES[scale] : '';
|
||||
}
|
||||
|
||||
// spellf() — Converts numbers to full English word form
|
||||
// Educational: shows the actual names of number scales
|
||||
// Examples: spellf(1500) => "one thousand five hundred"
|
||||
// spellf(2500000) => "two million five hundred thousand"
|
||||
// spellf(1e33) => "one decillion"
|
||||
/**
|
||||
* Formats a number into a full word string (e.g., "1.5 million").
|
||||
* @param {number} n - The number to format.
|
||||
* @returns {string} The formatted string.
|
||||
*/
|
||||
function spellf(n) {
|
||||
if (n === undefined || n === null || isNaN(n)) return 'zero';
|
||||
if (n === Infinity) return 'infinity';
|
||||
if (n === -Infinity) return 'negative infinity';
|
||||
if (n < 0) return 'negative ' + spellf(-n);
|
||||
if (n === 0) return 'zero';
|
||||
|
||||
// Small number words (0–999)
|
||||
const ones = ['', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine',
|
||||
'ten', 'eleven', 'twelve', 'thirteen', 'fourteen', 'fifteen', 'sixteen',
|
||||
'seventeen', 'eighteen', 'nineteen'];
|
||||
const tens = ['', '', 'twenty', 'thirty', 'forty', 'fifty', 'sixty', 'seventy', 'eighty', 'ninety'];
|
||||
|
||||
function spellSmall(num) {
|
||||
if (num === 0) return '';
|
||||
if (num < 20) return ones[num];
|
||||
if (num < 100) {
|
||||
return tens[Math.floor(num / 10)] + (num % 10 ? ' ' + ones[num % 10] : '');
|
||||
}
|
||||
const h = Math.floor(num / 100);
|
||||
const remainder = num % 100;
|
||||
return ones[h] + ' hundred' + (remainder ? ' ' + spellSmall(remainder) : '');
|
||||
}
|
||||
|
||||
// For very large numbers beyond our lookup table, fall back
|
||||
if (n >= 1e306) return n.toExponential(2) + ' (beyond centillion)';
|
||||
|
||||
// Use string-based chunking for numbers >= 1e54 to avoid floating point drift
|
||||
// Math.log10 / Math.pow lose precision beyond ~54 bits
|
||||
if (n >= 1e54) {
|
||||
// Convert to scientific notation string, extract digits
|
||||
const sci = n.toExponential(); // "1.23456789e+60"
|
||||
const [coeff, expStr] = sci.split('e+');
|
||||
const exp = parseInt(expStr);
|
||||
// Rebuild as integer string with leading digits from coefficient
|
||||
const coeffDigits = coeff.replace('.', ''); // "123456789"
|
||||
const totalDigits = exp + 1;
|
||||
// Pad with zeros to reach totalDigits, then take our coefficient digits
|
||||
let intStr = coeffDigits;
|
||||
const zerosNeeded = totalDigits - coeffDigits.length;
|
||||
if (zerosNeeded > 0) intStr += '0'.repeat(zerosNeeded);
|
||||
|
||||
// Split into groups of 3 from the right
|
||||
const groups = [];
|
||||
for (let i = intStr.length; i > 0; i -= 3) {
|
||||
groups.unshift(parseInt(intStr.slice(Math.max(0, i - 3), i)));
|
||||
}
|
||||
|
||||
const parts = [];
|
||||
const numGroups = groups.length;
|
||||
for (let i = 0; i < numGroups; i++) {
|
||||
const chunk = groups[i];
|
||||
if (chunk === 0) continue;
|
||||
const scaleIdx = numGroups - 1 - i;
|
||||
const scaleName = scaleIdx < NUMBER_NAMES.length ? NUMBER_NAMES[scaleIdx] : '';
|
||||
parts.push(spellSmall(chunk) + (scaleName ? ' ' + scaleName : ''));
|
||||
}
|
||||
|
||||
return parts.join(' ') || 'zero';
|
||||
}
|
||||
|
||||
// Standard math-based chunking for numbers < 1e54
|
||||
const scale = Math.min(Math.floor(Math.log10(n) / 3), NUMBER_NAMES.length - 1);
|
||||
const parts = [];
|
||||
|
||||
let remaining = n;
|
||||
for (let s = scale; s >= 0; s--) {
|
||||
const divisor = Math.pow(10, s * 3);
|
||||
const chunk = Math.floor(remaining / divisor);
|
||||
remaining = remaining - chunk * divisor;
|
||||
if (chunk > 0 && chunk < 1000) {
|
||||
parts.push(spellSmall(chunk) + (NUMBER_NAMES[s] ? ' ' + NUMBER_NAMES[s] : ''));
|
||||
} else if (chunk >= 1000) {
|
||||
// Floating point chunk too large — shouldn't happen below 1e54
|
||||
parts.push(spellSmall(Math.floor(chunk % 1000)) + (NUMBER_NAMES[s] ? ' ' + NUMBER_NAMES[s] : ''));
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join(' ') || 'zero';
|
||||
}
|
||||
|
||||
// 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.
|
||||
*/
|
||||
26
scripts/guardrails.js
Normal file
26
scripts/guardrails.js
Normal file
@@ -0,0 +1,26 @@
|
||||
|
||||
/**
|
||||
* Symbolic Guardrails for The Beacon
|
||||
* Ensures game logic consistency.
|
||||
*/
|
||||
class Guardrails {
|
||||
static validateStats(stats) {
|
||||
const required = ['hp', 'maxHp', 'mp', 'maxMp', 'level'];
|
||||
required.forEach(r => {
|
||||
if (!(r in stats)) throw new Error(`Missing stat: ${r}`);
|
||||
});
|
||||
if (stats.hp > stats.maxHp) return { valid: false, reason: 'HP exceeds MaxHP' };
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
static validateDebuff(debuff, stats) {
|
||||
if (debuff.type === 'drain' && stats.hp <= 1) {
|
||||
return { valid: false, reason: 'Drain debuff on critical HP' };
|
||||
}
|
||||
return { valid: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Test
|
||||
const playerStats = { hp: 50, maxHp: 100, mp: 20, maxMp: 50, level: 1 };
|
||||
console.log('Stats check:', Guardrails.validateStats(playerStats));
|
||||
102
scripts/guardrails.sh
Normal file
102
scripts/guardrails.sh
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# Static guardrail checks for game.js. Run from repo root.
|
||||
#
|
||||
# Each check prints a PASS/FAIL line and contributes to the final exit code.
|
||||
# The rules enforced here come from AGENTS.md — keep the two files in sync.
|
||||
#
|
||||
# Some rules are marked PENDING: they describe invariants we've agreed on but
|
||||
# haven't reached on main yet (because another open PR is landing the fix).
|
||||
# PENDING rules print their current violation count without failing the job;
|
||||
# convert them to hard failures once the blocking PR merges.
|
||||
|
||||
set -u
|
||||
fail=0
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
banner() { say ""; say "==== $* ===="; }
|
||||
|
||||
# ---------- Rule 1: no *Boost mutation inside applyFn blocks ----------
|
||||
# Persistent multipliers (codeBoost, computeBoost, ...) must not be written
|
||||
# from any function that runs per tick. The `applyFn` of a debuff is invoked
|
||||
# on every updateRates() call, so `G.codeBoost *= 0.7` inside applyFn compounds
|
||||
# and silently zeros code production. See AGENTS.md rule 1.
|
||||
banner "Rule 1: no *Boost mutation inside applyFn"
|
||||
rule1_hits=$(awk '
|
||||
/applyFn:/ { inFn=1; brace=0; next }
|
||||
inFn {
|
||||
n = gsub(/\{/, "{")
|
||||
brace += n
|
||||
if ($0 ~ /(codeBoost|computeBoost|knowledgeBoost|userBoost|impactBoost)[[:space:]]*([*\/+\-]=|=)/) {
|
||||
print FILENAME ":" NR ": " $0
|
||||
}
|
||||
n = gsub(/\}/, "}")
|
||||
brace -= n
|
||||
if (brace <= 0) inFn = 0
|
||||
}
|
||||
' game.js)
|
||||
if [ -z "$rule1_hits" ]; then
|
||||
say " PASS"
|
||||
else
|
||||
say " FAIL — see AGENTS.md rule 1"
|
||||
say "$rule1_hits"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------- Rule 2: click power has a single source (getClickPower) ----------
|
||||
# The formula should live only inside getClickPower(). If it appears anywhere
|
||||
# else, the sites will drift when someone changes the formula.
|
||||
banner "Rule 2: click power formula has one source"
|
||||
rule2_hits=$(grep -nE 'Math\.floor\(G\.buildings\.autocoder \* 0\.5\)' game.js || true)
|
||||
rule2_count=0
|
||||
if [ -n "$rule2_hits" ]; then
|
||||
rule2_count=$(printf '%s\n' "$rule2_hits" | grep -c .)
|
||||
fi
|
||||
if [ "$rule2_count" -le 1 ]; then
|
||||
say " PASS ($rule2_count site)"
|
||||
else
|
||||
say " FAIL — $rule2_count sites; inline into getClickPower() only"
|
||||
printf '%s\n' "$rule2_hits"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------- Rule 3: loadGame uses a whitelist, not Object.assign ----------
|
||||
# Object.assign(G, data) lets a malicious or corrupted save file set any G
|
||||
# field, and hides drift when saveGame's explicit list diverges from what
|
||||
# the game actually reads. See AGENTS.md rule 3.
|
||||
banner "Rule 3: loadGame uses a whitelist"
|
||||
rule3_hits=$(grep -nE 'Object\.assign\(G,[[:space:]]*data\)' game.js || true)
|
||||
if [ -z "$rule3_hits" ]; then
|
||||
say " PASS"
|
||||
else
|
||||
say " FAIL — see AGENTS.md rule 3"
|
||||
printf '%s\n' "$rule3_hits"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
# ---------- Rule 7: no secrets in the tree ----------
|
||||
# Scans for common token prefixes. Expand the pattern list when new key
|
||||
# formats appear in the fleet. See AGENTS.md rule 7.
|
||||
banner "Rule 7: secret scan"
|
||||
secret_hits=$(grep -rnE 'sk-ant-[a-zA-Z0-9_-]{6,}|sk-or-[a-zA-Z0-9_-]{6,}|ghp_[a-zA-Z0-9]{20,}|AKIA[0-9A-Z]{16}' \
|
||||
--include='*.js' --include='*.json' --include='*.md' --include='*.html' \
|
||||
--include='*.yml' --include='*.yaml' --include='*.py' --include='*.sh' \
|
||||
--exclude-dir=.git --exclude-dir=.gitea . || true)
|
||||
# Strip our own literal-prefix patterns (this file, AGENTS.md, workflow) so the
|
||||
# check doesn't match the very grep that implements it.
|
||||
secret_hits=$(printf '%s\n' "$secret_hits" | grep -v -E '(AGENTS\.md|guardrails\.sh|guardrails\.yml)' || true)
|
||||
if [ -z "$secret_hits" ]; then
|
||||
say " PASS"
|
||||
else
|
||||
say " FAIL"
|
||||
printf '%s\n' "$secret_hits"
|
||||
fail=1
|
||||
fi
|
||||
|
||||
banner "result"
|
||||
if [ "$fail" = "0" ]; then
|
||||
say "all guardrails passed"
|
||||
exit 0
|
||||
else
|
||||
say "one or more guardrails failed"
|
||||
exit 1
|
||||
fi
|
||||
76
scripts/smoke.mjs
Normal file
76
scripts/smoke.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* The Beacon — Enhanced Smoke Test
|
||||
*
|
||||
* Validates:
|
||||
* 1. All JS files parse without syntax errors
|
||||
* 2. HTML references valid script sources
|
||||
* 3. Game data structures are well-formed
|
||||
* 4. No banned provider references
|
||||
*/
|
||||
|
||||
import { readFileSync, existsSync } from "fs";
|
||||
import { execSync } from "child_process";
|
||||
import { join } from "path";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
let failures = 0;
|
||||
|
||||
function check(label, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✔ ${label}`);
|
||||
} catch (e) {
|
||||
console.error(` ✘ ${label}: ${e.message}`);
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("--- The Beacon Smoke Test ---\n");
|
||||
|
||||
// 1. All JS files parse
|
||||
console.log("[Syntax]");
|
||||
const jsFiles = execSync("find . -name '*.js' -not -path './node_modules/*'", { encoding: "utf8" })
|
||||
.trim().split("\n").filter(Boolean);
|
||||
|
||||
for (const f of jsFiles) {
|
||||
check(`Parse ${f}`, () => {
|
||||
execSync(`node --check ${f}`, { encoding: "utf8" });
|
||||
});
|
||||
}
|
||||
|
||||
// 2. HTML script references exist
|
||||
console.log("\n[HTML References]");
|
||||
if (existsSync(join(ROOT, "index.html"))) {
|
||||
const html = readFileSync(join(ROOT, "index.html"), "utf8");
|
||||
const scriptRefs = [...html.matchAll(/src=["']([^"']+\.js)["']/g)].map(m => m[1]);
|
||||
for (const ref of scriptRefs) {
|
||||
check(`Script ref: ${ref}`, () => {
|
||||
if (!existsSync(join(ROOT, ref))) throw new Error("File not found");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Game data structure check
|
||||
console.log("\n[Game Data]");
|
||||
check("js/data.js exists", () => {
|
||||
if (!existsSync(join(ROOT, "js/data.js"))) throw new Error("Missing");
|
||||
});
|
||||
|
||||
// 4. No banned providers
|
||||
console.log("\n[Policy]");
|
||||
check("No Anthropic references", () => {
|
||||
try {
|
||||
const result = execSync(
|
||||
"grep -ril 'anthropic\\|claude-sonnet\\|claude-opus\\|sk-ant-' --include='*.js' --include='*.json' --include='*.html' . 2>/dev/null || true",
|
||||
{ encoding: "utf8" }
|
||||
).trim();
|
||||
if (result) throw new Error(`Found in: ${result}`);
|
||||
} catch (e) {
|
||||
if (e.message.startsWith("Found")) throw e;
|
||||
}
|
||||
});
|
||||
|
||||
// Summary
|
||||
console.log(`\n--- ${failures === 0 ? "ALL PASSED" : `${failures} FAILURE(S)`} ---`);
|
||||
process.exit(failures > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user