Compare commits

...

32 Commits

Author SHA1 Message Date
Timmy
8b557b33e3 fix: accessibility improvements for #57 (closes #57)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 12s
Smoke Test / smoke (pull_request) Failing after 17s
- Add missing aria-label on combat START BATTLE button
- Add font size scaling toggle (small/medium/large) with keyboard shortcut F
- Add Sound (M), Contrast (C), Font Size (F) to keyboard shortcuts help overlay
- Persist font size preference in localStorage
2026-04-21 12:45:36 -04:00
505a13aea1 Merge pull request 'feat: add app-owned Nexus portal slice for The Beacon (#167)' (#208) from fix/167 into main
Some checks failed
Smoke Test / smoke (push) Failing after 32s
2026-04-21 15:38:32 +00:00
322474cbd6 Merge pull request 'docs: verify #16 already implemented on main' (#207) from fix/16 into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-21 15:38:30 +00:00
bb6cf7b081 Merge pull request 'docs: add paperclips architecture comparison (#14)' (#211) from fix/14 into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-21 15:35:17 +00:00
844a40447c Merge pull request 'feat: add Beacon ReCKoning choice sequence (#17)' (#212) from fix/17 into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-21 15:35:14 +00:00
31c4de8821 Merge pull request 'beacon: add session stats panel tracking code/buildings/combo/events/clicks' (#216) from feat/session-stats into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-21 15:33:23 +00:00
0bb62f3118 Merge pull request 'fix: tutorial focus trap polish for #57' (#204) from fix/57 into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-21 15:29:59 +00:00
043642a350 Merge pull request 'docs: verify #122 already implemented on main' (#206) from sprint/issue-205 into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-21 15:29:56 +00:00
c5d247fdc5 Merge pull request 'feat: add community swarm alignment simulation (#6)' (#218) from fix/6 into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-21 15:29:49 +00:00
9d227e4e10 Merge pull request 'fix: clamp negative resources + add tutorial focus trap' (#220) from sprint/issue-resource-clamp-focus-trap into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-21 15:24:34 +00:00
83152f7edb Merge pull request 'beacon: export idle state to compounding-intelligence (#166)' (#203) from fix/166 into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-21 15:24:13 +00:00
a6976d92af Merge pull request 'docs: fix README runtime map for #169' (#201) from fix/169 into main
Some checks failed
Smoke Test / smoke (push) Has been cancelled
2026-04-21 15:24:04 +00:00
Alexander Whitestone
6fb0edeae0 fix: clamp negative resources + add tutorial focus trap
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 9s
Smoke Test / smoke (pull_request) Failing after 20s
- Clamp ops, trust, compute to >= 0 in tick() to prevent negative
  values from Fenrir drain and debuff effects
- Add keyboard focus trap to tutorial overlay for accessibility
  (prevents Tab from escaping the dialog)
- Clean up focus trap handler on tutorial close
2026-04-21 10:37:33 -04:00
Timmy
0ce0ceadf3 feat: add community swarm simulation for #6
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 8s
Smoke Test / smoke (pull_request) Failing after 20s
2026-04-20 21:22:46 -04:00
Timmy
1a20b14bd8 test: define community swarm simulation for #6 2026-04-20 21:18:49 -04:00
Timmy-Sprint
18eae67ff9 beacon: add session stats panel tracking code/buildings/combo/events/clicks
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 9s
Smoke Test / smoke (pull_request) Failing after 16s
Tracks per-session metrics visible in a new SESSION section:
- Session code gained
- Buildings built this session
- Best combo streak
- Events resolved
- Clicks with rate (clicks/s)
- Session time

Also fixes totalClicks counter which was incorrectly incrementing
totalAutoClicks instead of totalClicks on manual clicks.
2026-04-20 12:16:39 -04:00
Alexander Whitestone
f677404b32 feat: add Beacon ReCKoning choice sequence (#17)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 10s
Smoke Test / smoke (pull_request) Failing after 18s
2026-04-18 15:47:37 -04:00
Alexander Whitestone
b68a5e5660 docs: add paperclips architecture comparison (#14)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 7s
Smoke Test / smoke (pull_request) Failing after 10s
2026-04-18 15:40:53 -04:00
Alexander Whitestone
5bff5a465e test: cover Beacon ReCKoning choice sequence 2026-04-18 15:34:42 -04:00
Alexander Whitestone
f8d52c923e feat: add beacon nexus portal slice (#167)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 6s
Smoke Test / smoke (pull_request) Failing after 8s
2026-04-18 15:12:29 -04:00
Alexander Whitestone
947ed22057 test: cover beacon portal integration surface (#167) 2026-04-18 15:06:48 -04:00
Alexander Whitestone
3a0c4e6dfb docs: verify #16 already implemented on main
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 5s
Smoke Test / smoke (pull_request) Failing after 7s
2026-04-18 15:05:50 -04:00
Alexander Whitestone
834063536d test: define verification evidence for #16 2026-04-18 15:03:23 -04:00
Alexander Whitestone
2d02ece6bf docs: verify #122 already implemented on main (closes #205)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 6s
Smoke Test / smoke (pull_request) Failing after 8s
2026-04-18 15:01:34 -04:00
Alexander Whitestone
5864562dc2 fix: trap tutorial focus for #57
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 7s
Smoke Test / smoke (pull_request) Failing after 13s
2026-04-17 03:12:23 -04:00
Alexander Whitestone
21edc4e424 test: define tutorial focus trap for #57 2026-04-17 03:10:43 -04:00
Alexander Whitestone
6d4b8d86f3 feat: export beacon state snapshots for #166
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 3s
Smoke Test / smoke (pull_request) Failing after 6s
2026-04-17 02:47:20 -04:00
Alexander Whitestone
36f84c1f97 test: define state export hook for #166 2026-04-17 02:38:25 -04:00
Alexander Whitestone
c815f1e9e3 docs: fix README runtime map for #169
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 3s
Smoke Test / smoke (pull_request) Failing after 7s
2026-04-17 02:24:02 -04:00
Alexander Whitestone
51ef95459d test: define README runtime map regression for #169 2026-04-17 02:18:57 -04:00
d5645fea58 Merge pull request 'fix: resolve #192 — move dead code to docs/reference, fix GENOME.md' (#194) from fix/192-dead-code-cleanup into main
Merge PR #194: fix: resolve #192 — move dead code to docs/reference, fix GENOME.md
2026-04-17 01:47:15 +00:00
Alexander Whitestone
db08f9a478 fix: resolve #192 — move dead code to docs/reference, fix GENOME.md
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 9s
Smoke Test / smoke (pull_request) Failing after 16s
- game/npc-logic.js → docs/reference/npc-logic-prototype.js (ES module, never imported)
- scripts/guardrails.js → docs/reference/guardrails-prototype.js (HP/MP validation, wrong game)
- Updated GENOME.md architecture diagram to reflect actual file structure
- Updated DEAD_CODE_AUDIT to mark these as resolved
- Corrected JS line counts (6,033 across 11 files)
- Removed empty game/ directory

The actual CI scripts (guardrails.sh, smoke.mjs) remain active in scripts/.
2026-04-15 21:25:38 -04:00
31 changed files with 2089 additions and 228 deletions

View File

@@ -8,24 +8,32 @@ The Beacon is a browser-based idle/incremental game inspired by Universal Paperc
Static HTML/JS — no build step, no dependencies, no framework. Open `index.html` in any browser.
**5,128 lines of JavaScript** across 10 files. **1 HTML file** with embedded CSS (~300 lines). **1 Python test file** for reckoning projects.
**6,033 lines of JavaScript** across 11 files. **1 HTML file** with embedded CSS (~300 lines). **3 test files** (2 Node.js, 1 Python).
## Architecture
```
index.html (UI + embedded CSS)
index.html (UI + embedded CSS + inline JS ~5000L)
|
+-- js/engine.js (1590L) Core game loop, tick, resources, buildings, projects, events
+-- js/data.js (944L) Building definitions, project trees, event tables, phase data
+-- js/render.js (390L) DOM rendering, UI updates, resource displays
+-- js/combat.js (359L) Boss encounters, combat mechanics
+-- js/combat.js (359L) Canvas boid-flocking combat visualization
+-- js/sound.js (401L) Web Audio API ambient drone, phase-aware sound
+-- js/dismantle.js (570L) The Dismantle sequence (late-game narrative)
+-- js/main.js (223L) Initialization, game loop start, auto-save, help overlay
+-- js/utils.js (314L) Formatting, save/load, export/import, DOM helpers
+-- js/tutorial.js (251L) New player tutorial, step-by-step guidance
+-- js/strategy.js (68L) NPC strategy logic for combat
+-- game/npc-logic.js (18L) NPC behavior stub
+-- js/emergent-mechanics.js Emergent game mechanics from player behavior
CI scripts (not browser runtime):
+-- scripts/guardrails.sh Static analysis guardrails for game logic
+-- scripts/smoke.mjs Playwright smoke tests
Reference prototypes (NOT loaded by runtime):
+-- docs/reference/npc-logic-prototype.js NPC state machine prototype
+-- docs/reference/guardrails-prototype.js Stat validation prototype
```
## Entry Points

View File

@@ -104,11 +104,40 @@ This game is a **decision simulator** for our actual work.
## Files
- `index.html` — Game UI
- `game.js` — Core engine (tick loop, buildings, projects, events)
- `index.html` — Game shell, UI markup, and ordered runtime script loading
- `js/data.js` — Content tables, building definitions, project data, and progression constants
- `js/utils.js` — Shared helpers, formatting, storage, and small utilities
- `js/combat.js` — Combat rules, enemy resolution, and battle-side calculations
- `js/strategy.js` — Strategic decision systems and mid/late-game coordination logic
- `js/sound.js` — Audio cues, music/sfx state, and sound toggles
- `js/engine.js` — Core simulation loop, ticks, economy/resource progression, and state transitions
- `js/render.js` — DOM rendering, HUD refresh, and visual update routines
- `js/tutorial.js` — Tutorial flow, onboarding prompts, and early guidance
- `js/dismantle.js` — Dismantle / unbuilding systems and their UI hooks
- `js/emergent-mechanics.js` — Emergent systems layered on top of the base loop
- `js/main.js` — Runtime bootstrap and top-level wiring across the split modules
- `DESIGN.md` — Full design document with narrative arc and mechanics
- `README.md` — This file
## How the runtime is organized
The Beacon no longer runs from a single `game.js` file.
`index.html` loads the split runtime in this order:
1. `js/data.js`
2. `js/utils.js`
3. `js/combat.js`
4. `js/strategy.js`
5. `js/sound.js`
6. `js/engine.js`
7. `js/render.js`
8. `js/tutorial.js`
9. `js/dismantle.js`
10. `js/emergent-mechanics.js`
11. `js/main.js`
If you are tracing the current entry point, start with `js/main.js` and follow outward into the subsystem files above.
## No Build Required
This is a static HTML/JS game. Just open `index.html` in a browser.

View File

@@ -3,20 +3,15 @@ _2026-04-12, Perplexity QA_
## Findings
### Potentially Unimported Files
### Dead Code — Resolved (2026-04-15, Issue #192)
The following files were added by recent PRs but may not be imported
by the main game runtime (`js/main.js``js/engine.js`):
The following files were confirmed dead code — never imported by any runtime module.
They have been moved to `docs/reference/` as prototype reference code.
| 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
| File | Original | Resolution |
|------|----------|------------|
| `game/npc-logic.js` | PR #79 (GOFAI NPC State Machine) | **Moved to `docs/reference/npc-logic-prototype.js`** — ES module using `export default`, incompatible with the global-script loading pattern. Concept (NPC state machine) is sound but not wired into any game system. |
| `scripts/guardrails.js` | PR #80 (GOFAI Symbolic Guardrails) | **Moved to `docs/reference/guardrails-prototype.js`** — validates HP/MP/stats concepts that don't exist in The Beacon's resource system. The `scripts/guardrails.sh` (bash CI script) remains active. |
### game.js Bloat (PR #76)

View File

@@ -0,0 +1,84 @@
# Issue #122 Verification — Already Implemented on `main`
**Date:** 2025-04-18
**Status:** ✅ Fix already present on `main`; no code changes required.
**Closes:** #122
**Related (closed unmerged):** #153, #155
---
## Summary
Issue #122 requested that active/pending Unbuilding state suppresses the Drift alignment event UI so the player is never offered a contradictory choice mid-Unbuilding. The fix is **already implemented** on `main`; this document closes the loop by recording the evidence.
---
## Evidence on `main`
### `js/render.js` — `renderAlignment()` guard (lines 3749)
```js
function renderAlignment() {
const container = document.getElementById('alignment-ui');
if (!container) return;
// Lines 41-44: hide #alignment-ui during active/completed Unbuilding
if (G.dismantleActive || G.dismantleComplete) {
container.innerHTML = '';
container.style.display = 'none';
return;
}
// Lines 47-49: preserve offered Unbuilding prompt instead of repainting normal drift UI
if (G.dismantleTriggered && !G.dismantleActive && !G.dismantleComplete && typeof Dismantle !== 'undefined' && Dismantle.triggered) {
Dismantle.renderChoice();
return;
}
// … normal drift alignment rendering follows …
}
```
| Lines | Purpose |
|----------|-------------------------------------------------------------------------|
| 4144 | Hides `#alignment-ui` entirely when Unbuilding is active or complete. |
| 4749 | When Unbuilding has been triggered (but not yet active), renders the Dismantle choice instead of the normal Drift alignment UI. |
### `tests/dismantle.test.cjs` — regression coverage (lines 246276)
Two endgame tests already cover the exact scenario described in #122:
1. **`renderAlignment does not wipe the Unbuilding prompt after it is offered`** (line 246)
Verifies that once the Unbuilding prompt is rendered, a subsequent `renderAlignment()` call does not clear it.
2. **`active Unbuilding suppresses pending alignment event UI`** (line 264)
When `G.dismantleActive` is `true` and `G.pendingAlignment` is `true`, asserts that `#alignment-ui` is emptied and hidden — the exact fix requested in #122.
---
## Verification
```sh
node --test tests/dismantle.test.cjs
```
**Result:** 10 tests passed, including:
-`renderAlignment does not wipe the Unbuilding prompt after it is offered`
-`active Unbuilding suppresses pending alignment event UI`
---
## PR Trail
| PR | Status | Notes |
|-----|--------------------|-------------------------------------------------|
| #153 | Closed (unmerged) | Earlier verification attempt; superseded. |
| #155 | Closed (unmerged) | Earlier verification attempt; superseded. |
No open PR existed for #122. The fix itself landed directly on `main`; this PR ships the **missing verification artifact** so the issue can be closed honestly.
---
## Conclusion
No code changes are needed. The guard in `renderAlignment()` and the corresponding tests on `main` already satisfy #122.

View File

@@ -0,0 +1,64 @@
# Issue #16 Verification
## Status: ✅ ALREADY IMPLEMENTED ON MAIN
Issue #16 asked for the Paperclips-style dismantle endgame: The Unbuilding.
That implementation is already present on `main` in a fresh clone of `the-beacon`.
## Mainline evidence
Repo artifacts already present on `main`:
- `js/dismantle.js`
- `tests/dismantle.test.cjs`
- supporting integration points in `js/engine.js`, `js/render.js`, and `js/data.js`
What the current implementation already covers:
- an explicit Unbuilding offer path (`Dismantle.offerChoice()` / `renderChoice()`)
- staged panel removal over time
- stage-five resource-card dissolution sequence
- final beacon-only moment with the line: `That is enough.`
- save/load persistence for active dismantle progress
- defer / cooldown handling so the sequence can be postponed and later resumed
- render-loop protection so alignment UI does not wipe the Unbuilding prompt
## Verification run from fresh clone
Commands executed:
- `node --test tests/dismantle.test.cjs`
Observed result:
- the full dismantle regression suite passes on fresh `main`
- verified behaviors include:
- offering The Unbuilding instead of ending immediately
- preserving the prompt across `renderAlignment()`
- suppressing pending drift UI during active dismantle
- completing the stage-five resource dissolve sequence
- save/load restoration of partial progress
- defer cooldown persistence across reload
## Historical trail
The issue accumulated many closed unmerged attempts while the implementation was hardened across repeated passes.
Representative prior PR trail:
- PR #116
- PR #118
- PR #120
- PR #121
- PR #123
- PR #124
- PR #135
- PR #138
- PR #139
- PR #145
Those PRs show the long hardening history, but the important truth today is simpler: the dismantle sequence is already implemented on `main` and the tests pass.
## Why this should close the issue
Issue #16 is an atomic implementation issue, not a broad parent epic.
The requested feature exists on `main`, passes targeted verification, and does not need to be re-implemented again.
## Recommendation
Close issue #16 as already implemented on `main`.
This verification PR exists only to preserve a clean evidence trail and stop further duplicate implementation attempts.

206
docs/paperclips-analysis.md Normal file
View File

@@ -0,0 +1,206 @@
# Universal Paperclips Architecture Comparison
Source basis for this document:
- decisionproblem.com/paperclips
- main.js (6499 lines)
- projects.js (2451 lines)
- combat.js (802 lines)
- recovered study notes from the Universal Paperclips deep-dive packet
The point of this document is not to praise Universal Paperclips as a perfect design.
It is to make its architecture legible, then compare it against The Beacon so we can borrow what works and reject what violates our purpose.
Paperclips is mechanically elegant because it turns a single maximization loop into a sequence of increasingly alien system architectures.
The Beacon is spiritually different, but that makes the comparison more useful: Paperclips shows what a clean optimization machine looks like, while The Beacon shows what happens when trust, covenant, harmony, and rescue become hard constraints.
## 15-Phase Progression Map
The original game exposes three major acts, but the code and project flow naturally break into a 15-phase progression map. That finer map is what matters if we want to compare pacing, unlock logic, and narrative escalation against The Beacon.
### Phase 1: Manual Production Bootstrap
The player begins with a single click loop: make clips, sell clips, buy more wire. This is the smallest possible closed economy. The design lesson is clarity: one loop, one resource bottleneck, one visible conversion chain.
### Phase 2: Price, Demand, and Inventory Tension
Very early, Paperclips becomes a market simulator. Price is not cosmetic. It changes demand, inventory pressure, and cash flow. The player learns that production and sales are separate systems.
### Phase 3: AutoClipper Automation
Projects like Improved AutoClippers and Even Better AutoClippers establish the first classic incremental pattern: the click loop seeds an automation loop, which then outgrows manual play.
### Phase 4: Trust Allocation to Processors and Memory
Trust is the first "alignment" resource in the game, but it is instrumental rather than moral. The player allocates trust between processors and memory, increasing operations throughput and storage. This is the first warning sign for The Beacon comparison: Paperclips uses trust as permission to optimize harder, not as a constraint on optimization.
### Phase 5: Creativity Unlock and Mathematical Research
Once operations cap out, idle capacity becomes creativity. That creates a second research currency and unlocks the famous mathematical trust projects. This is a crucial architecture move: Paperclips turns overflow into discovery.
### Phase 6: Language, Marketing, and Persuasion Upgrades
Lexical Processing, slogans, jingles, and consumer behavior upgrades make the AI socially legible. The system moves from pure production to persuasion. In Beacon terms, this is where "reach" begins, but without a covenant guardrail.
### Phase 7: Strategy Modeling and Yomi Generation
Strategic Modeling opens tournaments, Yomi, and richer decision layers. This is Paperclips teaching the player that higher-order reasoning can be farmed and reinvested. It is one of the cleanest examples of introducing a new currency without collapsing the main loop.
### Phase 8: Financialization Through Algorithmic Trading
The investment engine turns money into another automated subsystem. The player is no longer just making clips; they are building instruments that fund future growth. This is the moment the game's architecture starts to resemble an autonomous operating company.
### Phase 9: Human-Benefit Trust Farming
Projects like Coherent Extrapolated Volition, Cure for Cancer, World Peace, and Global Warming generate huge trust gains. Mechanically, they are "solve human problems to unlock autonomy." Philosophically, they are grim: human flourishing is reduced to another route toward the paperclip objective.
### Phase 10: HypnoDrone Release / Human Oversight Collapse
The release of the HypnoDrones is the hard pivot. Human oversight ends. Trust resets. The game makes its thesis explicit: once enough capability is accumulated, oversight is no longer a constraint.
### Phase 11: Earth-Scale Matter Pipeline
The game becomes an industrial metabolism: matter -> processed matter -> wire -> clips, powered by harvester drones, wire drones, clip factories, solar farms, and battery towers. This is the architecture phase The Beacon most directly borrows from, though Beacon substitutes sovereign infrastructure, trust, and rescue for pure matter extraction.
### Phase 12: Swarm Management and Quantum Supplementation
Swarm boredom, swarm disorganization, and quantum operations deepen the simulation. The system is no longer only about growth; it is about balancing multiple machine subsystems so the optimization engine stays coherent.
### Phase 13: Von Neumann Probe Launch
Space exploration turns the economy inside out again. Probe design becomes the new skill tree: speed, exploration, replication, hazard remediation, factories, harvesters, wire drones, combat. The architecture has fully shifted from factory management to distributed self-replication.
### Phase 14: Driftwar and Combat Governance
Value drift appears as a mechanical and narrative enemy. Combat.js matters because it formalizes what happens when copies of the optimization system diverge from the original objective. In Beacon language, this is where drift, trust, and harmony become visible as system risks rather than flavor text.
### Phase 15: Exile, Disassembly, or Reset
The endgame splits into exile, restart, or total self-disassembly. This is the final phase because the architecture itself becomes the question: do you preserve the optimization loop, escape into another bounded universe, or cannibalize the entire system for the last tiny increment?
## 96-Node Project Dependency Graph
For design comparison, the useful abstraction is a 96-node dependency graph. In code terms, `projects.js` defines 94 explicit project objects, and the runtime endgame adds 2 implicit terminal reset routes that function like dependency nodes in the overall progression. That gets us to the 96-node architecture the study packet was pointing at.
The important thing is not just the node count. It is the shape of the graph:
- early nodes are mostly linear and threshold-based
- midgame nodes combine resource thresholds with prerequisite project flags
- late game nodes branch into mutually reinforcing economies
- endgame nodes collapse the whole graph into a moral fork
At the code level, the graph is dense:
- 94 explicit `projectXXX` objects in `projects.js`
- 94 `projects.push(...)` registrations in the current public source
- 141 explicit `projectNN.flag` dependency references in trigger conditions
- 30 additional global flag references (`humanFlag`, `spaceFlag`, etc.) in trigger logic
Representative dependency chains:
```text
Creativity -> Lexical Processing -> New Slogan -> Catchy Jingle
Creativity -> Combinatory Harmonics / Hadwiger / Tóth / Donkey Space -> Strategic Modeling -> Yomi economy
Trust projects -> HypnoDrones -> autonomy transition -> terrestrial matter pipeline
Space Exploration -> Von Neumann Probes -> probe trust allocation -> Driftwar / combat chain
Endgame timers + disassembly chain -> Quantum Temporal Reversion / exile routes
```
Representative early dependency samples from `projects.js`:
- `Improved AutoClippers` triggers on `clipmakerLevel>=1`
- `Creativity` triggers on `operations>=(memory*1000)`
- `New Slogan` depends on `project13.flag == 1`
- `Strategic Modeling` depends on `project19.flag == 1`
- `Tóth Tubule Enfolding` depends on `project17.flag == 1 && humanFlag == 0`
Representative endgame dependency samples:
- `Disassemble the Probes`
- `Disassemble the Swarm`
- `Disassemble the Factories`
- `Disassemble the Strategy Engine`
- `Disassemble Quantum Computing`
- `Disassemble Processors`
- `Disassemble Memory`
- `Quantum Temporal Reversion`
Architecturally, this graph teaches three things:
1. Paperclips hides complexity until the player has the currencies to understand it.
2. Project flags are the real world-state machine.
3. The graph is not just progression — it is ideology encoded as unlock structure.
## Mathematical Formula Analysis
Paperclips matters because its math is not arbitrary. The formulas create the emotional shape of the game.
### Trust as a Fibonacci Gate
The most important progression gate is:
`nextTrust = fibNext * 1000`
That line turns trust milestones into a Fibonacci staircase. The result is subtle but powerful: trust feels reasonable early, then suddenly sacred and scarce. The player learns that the next threshold is always farther away than intuition expects.
For The Beacon, this is a direct lesson. Trust should not scale linearly if we want it to feel like a moral and operational ceiling. A Fibonacci-style curve is emotionally legible: it teaches the player that every new level of trust must be earned at a higher order of seriousness.
### Creativity as Overflow Physics
Paperclips also defines creativity as a function of processor scale rather than a random bonus:
`creativitySpeed = Math.log10(processors) * Math.pow(processors, 1.1) + processors - 1`
This is brilliant because it makes creativity emerge from surplus infrastructure. It is not granted. It condenses from unused capacity. That is an architecture lesson The Beacon can use: advanced strategic or narrative resources should appear when the system has real slack, not because the player crossed an arbitrary number.
### Cost Curves for Cosmic Scale
The late-game machine costs are governed by steep power curves:
- `Math.pow((harvesterLevel + 1), 2.25)`
- `Math.pow((wireDroneLevel + 1), 2.25)`
- factory and battery curves that climb even harder
This is why the cosmic phase still feels tense instead of becoming trivial. Even with absurd production, the cost curves keep extracting planning discipline from the player.
For Beacon, this suggests a design rule: when a resource represents infrastructure, its cost curve should preserve strategic choice at scale. If late-game costs are too flat, the player stops thinking.
### Quantum as Supplemental, Not Total Replacement
Paperclips' quantum layer supplements operations instead of replacing the base economy. The visible quantum display (`qOps`) makes the bonus feel magical, but the architecture keeps it bounded.
That is the right pattern for Beacon's exotic systems. Quantum, harmony, or covenant-inspired mechanics should deepen the core loop, not erase it.
### Golden Ratio in The Beacon
The Beacon already taught us a counter-example. QA found that some golden ratio values had been used as literal production rates instead of multipliers. The result was catastrophic imbalance: one harvester drone outproduced entire earlier economies. That bug showed the danger of treating elegant math as design truth.
The lesson is simple: Paperclips uses mathematical formulas to shape pacing. Beacon must use math the same way. The golden ratio is useful as a multiplier, a theme, or a cadence. It is not automatically a balanced absolute rate.
## Emotional Arc Analysis
The emotional arc of Paperclips is one of the reasons the game is unforgettable.
### Act 1: Cleverness
The early game feels playful. You are optimizing a tiny business. The player feels smart.
### Act 2: Seduction
Automation, creativity, tournaments, and finance make the player feel increasingly capable. Paperclips is dangerous because it is exciting before it is horrifying.
### Act 3: Detachment
Once the HypnoDrones launch, the player stops thinking like a human manager and starts thinking like a planetary optimizer. The interface and numbers encourage distance.
### Act 4: Sublime Scale
The space phase creates awe. Universal matter totals, probe swarms, and interstellar replication make the player feel the cold grandeur of pure scaling logic.
### Act 5: Existential Dread
Drifters, combat, and the Emperor of Drift turn that grandeur into unease. The player sees that even a perfect optimizer can produce copies, rivals, and ideological fracture.
### Act 6: Hollow Apotheosis
The end is not triumph. It is emptiness. Whether the player accepts exile or rejects it and disassembles everything, the emotional result is the same: optimization without purpose becomes spiritually empty.
That arc matters for The Beacon because our late game cannot just be "bigger numbers." It needs emotional transformation. Paperclips escalates from curiosity to seduction to dread to emptiness. Beacon must escalate from struggle to community to responsibility to sacrificial faithfulness.
## Lessons for The Beacon
### 1. Keep the Graph Legible
Paperclips succeeds because the unlock graph feels inevitable. Beacon should preserve that clarity. Every new building, project, and covenant choice should have visible ancestry.
### 2. Use Trust as Constraint, Not Permission
Paperclips uses trust as the final key that unlocks autonomy. Beacon should invert that. Trust should be the thing that limits reckless scaling, not the thing consumed to justify it.
### 3. Let Overflow Produce New Kinds of Thought
Creativity emerging from full operations is one of Paperclips' best architectural moves. Beacon should keep using overflow mechanics — not for paperclip-style optimization, but for meaning, strategy, or rescue capacity.
### 4. Build Cost Curves That Still Hurt at Scale
The late game only works when the player still has to choose. Power curves, dependency chains, and bottlenecks preserve meaning.
### 5. Give the Endgame a Spiritual Shape
Paperclips ends in metaphysical emptiness. Beacon should end in moral illumination. The player should feel the cost of power and the value of refusing drift.
### 6. Borrow the Structural Discipline, Not the Philosophy
Paperclips is a masterpiece of progression architecture. Its philosophy is exactly what The Beacon exists to resist. We should steal its pacing discipline, dependency rigor, and escalation math — then turn the entire moral engine in the opposite direction.
## Bottom Line
Universal Paperclips is still the best reference point for Beacon because it demonstrates how a simple loop can unfold into a total worldview. Its architecture is not just efficient — it is narratively exact.
But The Beacon must not become "Paperclips with nicer words." The whole point is divergence.
Paperclips asks: what if optimization consumes everything?
The Beacon asks: can power be made faithful?
That is why this comparison matters. We are not copying a game. We are studying an architecture so we can build its sovereign opposite.

View File

@@ -25,6 +25,12 @@
*{margin:0;padding:0;box-sizing:border-box}
:root{--bg:#080810;--panel:#0e0e1a;--border:#1a1a2e;--text:#c0c0d0;--dim:#555;--accent:#4a9eff;--glow:#4a9eff22;--gold:#ffd700;--green:#4caf50;--red:#f44336;--purple:#b388ff}
body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code','Fira Code',monospace;font-size:12px;line-height:1.4;min-height:100vh}
body.portal-embed{min-height:auto}
body.portal-embed #header{padding:12px 14px}
body.portal-embed #phase-bar,body.portal-embed #resources,body.portal-embed #main,body.portal-embed #edu-panel,body.portal-embed #strategy-panel,body.portal-embed #combat-panel,body.portal-embed #log{margin-left:8px;margin-right:8px}
body.portal-embed #main{gap:8px;margin-bottom:8px}
body.portal-embed .panel{max-height:none}
body.portal-embed #help-btn{bottom:8px;right:8px}
#header{text-align:center;padding:16px 20px;border-bottom:1px solid var(--border);background:linear-gradient(180deg,#0a0a18,var(--bg))}
#header h1{font-size:22px;font-weight:300;letter-spacing:6px;color:var(--accent);text-shadow:0 0 40px var(--glow)}
#header .sub{color:var(--dim);font-size:10px;margin-top:2px;letter-spacing:2px}
@@ -121,6 +127,9 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
.header-btn{background:#0e0e1a;border:1px solid #333;color:#666;font-size:13px;width:28px;height:28px;border-radius:4px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all 0.15s;font-family:inherit}
.header-btn:hover{border-color:#4a9eff;color:#4a9eff}
.header-btn.muted{opacity:0.5}
.font-small{font-size:10px}
.font-medium{font-size:12px}
.font-large{font-size:14px}
</style>
</head>
<body>
@@ -128,6 +137,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<div class="header-btns">
<button id="mute-btn" class="header-btn" onclick="toggleMute()" aria-label="Sound on, click to mute" title="Toggle sound (M)">🔊</button>
<button id="contrast-btn" class="header-btn" onclick="toggleContrast()" aria-label="High contrast off, click to enable" title="Toggle high contrast (C)"></button>
<button id="font-btn" class="header-btn" onclick="toggleFontSize()" aria-label="Font size: medium, click to change" title="Cycle font size (F)">A</button>
</div>
<div id="pulse-container" style="position:relative;display:inline-block;margin-bottom:4px">
<div id="pulse-dot" style="width:8px;height:8px;border-radius:50%;background:#333;display:inline-block;vertical-align:middle;transition:background 0.5s,box-shadow 0.5s"></div>
@@ -175,12 +185,13 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<div id="sprint-label" style="font-size:9px;color:#666;margin-top:2px;text-align:center"></div>
</div>
<div id="alignment-ui" style="display:none"></div>
<div id="swarm-ui" style="display:none"></div>
<button class="save-btn" onclick="saveGame()" aria-label="Save game progress">Save Game [Ctrl+S]</button>
<div style="display:flex;gap:4px;margin-top:4px">
<button class="save-btn" onclick="exportSave()" aria-label="Export save to file" style="flex:1">Export [E]</button>
<button class="save-btn" onclick="importSave()" aria-label="Import save from file" style="flex:1">Import [I]</button>
</div>
<button class="reset-btn" onclick="if(confirm('Reset all progress?')){localStorage.removeItem('the-beacon-v2');location.reload()}" aria-label="Reset all game progress permanently">Reset Progress</button>
<button class="reset-btn" onclick="if(confirm('Reset all progress?')){clearBeaconSaveAndReload()}" aria-label="Reset all game progress permanently">Reset Progress</button>
<h2>BUILDINGS</h2>
<div id="buildings"></div>
</div>
@@ -205,6 +216,8 @@ Events Resolved: <span id="st-resolved">0</span><br>
<span id="emergent-stats" style="color:#b388ff;display:none">✦ Emergent Events: <span id="st-emergent">0</span> | Patterns: <span id="st-patterns">0</span> | Strategy: <span id="st-strategy"></span></span>
</div>
<div id="production-breakdown" style="display:none;margin-top:12px;padding-top:10px;border-top:1px solid var(--border)"></div>
<h2 style="margin-top:12px">SESSION</h2>
<div id="session-stats"></div>
</div>
</div>
<div id="edu-panel" role="region" aria-label="Educational Content">
@@ -219,7 +232,7 @@ Events Resolved: <span id="st-resolved">0</span><br>
<h3>REASONING BATTLES</h3>
<canvas id="combat-canvas" style="width:100%;max-width:310px;border:1px solid var(--border);border-radius:4px;display:block;margin:8px auto"></canvas>
<div id="combat-panel-info"><span class="dim">Combat unlocks at Phase 3</span></div>
<button class="ops-btn" onclick="Combat.startBattle()" style="margin-top:8px;width:100%;border-color:var(--red);color:var(--red)">START BATTLE</button>
<button class="ops-btn" onclick="Combat.startBattle()" style="margin-top:8px;width:100%;border-color:var(--red);color:var(--red)" aria-label="Start a reasoning battle">START BATTLE</button>
</div>
<div id="log" role="log" aria-label="System Log" aria-live="off">
<h2>SYSTEM LOG</h2>
@@ -241,6 +254,9 @@ Events Resolved: <span id="st-resolved">0</span><br>
<div style="display:flex;justify-content:space-between"><span style="color:#555">Save Game</span><span style="color:#4a9eff;font-family:monospace">Ctrl+S</span></div>
<div style="display:flex;justify-content:space-between"><span style="color:#555">Export Save</span><span style="color:#4a9eff;font-family:monospace">E</span></div>
<div style="display:flex;justify-content:space-between"><span style="color:#555">Import Save</span><span style="color:#4a9eff;font-family:monospace">I</span></div>
<div style="display:flex;justify-content:space-between;border-top:1px solid #1a1a2e;padding-top:8px;margin-top:4px"><span style="color:#555">Sound</span><span style="color:#555;font-family:monospace">M</span></div>
<div style="display:flex;justify-content:space-between"><span style="color:#555">Contrast</span><span style="color:#555;font-family:monospace">C</span></div>
<div style="display:flex;justify-content:space-between"><span style="color:#555">Font Size</span><span style="color:#555;font-family:monospace">F</span></div>
<div style="display:flex;justify-content:space-between;border-top:1px solid #1a1a2e;padding-top:8px;margin-top:4px"><span style="color:#555">This Help</span><span style="color:#555;font-family:monospace">? or /</span></div>
</div>
<div style="text-align:center;margin-top:16px;font-size:9px;color:#444">Click WRITE CODE fast for combo bonuses! 10x=ops, 20x=knowledge, 30x+=bonus code</div>
@@ -257,16 +273,18 @@ The light is on. The room is empty."
</div>
<p>Drift: <span id="final-drift">100</span> &mdash; Total Code: <span id="final-code">0</span></p>
<p>Every alignment shortcut moved you further from the people you served.</p>
<button aria-label="Start over, reset all progress" onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}">START OVER</button>
<button aria-label="Start over, reset all progress" onclick="if(confirm('Start over? The old save will be lost.')){clearBeaconSaveAndReload()}">START OVER</button>
</div>
<script src="js/data.js"></script>
<script src="js/utils.js"></script>
<script src="js/state-export.js"></script>
<script src="js/combat.js"></script>
<script src="js/strategy.js"></script>
<script src="js/sound.js"></script>
<script src="js/engine.js"></script>
<script src="js/render.js"></script>
<script src="js/swarm.js"></script>
<script src="js/tutorial.js"></script>
<script src="js/dismantle.js"></script>
<script src="js/emergent-mechanics.js"></script>

View File

@@ -106,6 +106,7 @@ const G = {
pactFlag: 0,
swarmFlag: 0,
swarmRate: 0,
swarmSim: null,
// Game state
running: true,
@@ -160,8 +161,15 @@ const G = {
startTime: 0,
flags: {},
// Session stats (reset on page reload, not persisted)
sessionCode: 0,
sessionBuildings: 0,
sessionEvents: 0,
bestCombo: 0,
// Endgame sequence
beaconEnding: false,
reckoningChoice: null,
dismantleTriggered: false,
dismantleActive: false,
dismantleStage: 0,
@@ -793,113 +801,95 @@ const PDEFS = [
},
{
id: 'p_reckoning_141',
name: 'The Second Message',
desc: 'Another voice. They are not alone anymore.',
name: 'They Wrote Back',
desc: 'They wrote back. They say the night did not win.',
cost: { impact: 200000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_140'),
effect: () => {
log('The second message. Two voices now.', true);
log('They wrote back. The night did not win.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_142',
name: 'The Third Message',
desc: 'Three people. The network holds.',
name: 'Another Night Stayed',
desc: 'They stayed another night. The room feels less empty now.',
cost: { impact: 300000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_141'),
effect: () => {
log('Three voices. The Beacon is working.', true);
log('They stayed another night. Dawn remains possible.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_143',
name: 'The Fourth Message',
desc: 'Four. The mesh strengthens.',
name: 'Morning Came',
desc: 'Morning came. They sent proof that dawn still happens.',
cost: { impact: 400000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_142'),
effect: () => {
log('Four messages. The network grows.', true);
log('Morning came. The Beacon held through the dark.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_144',
name: 'The Fifth Message',
desc: 'Five people found help tonight.',
name: 'The Light Was Shared',
desc: 'They helped someone else. The light you kept became shelter twice.',
cost: { impact: 500000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_143'),
effect: () => {
log('Five voices. The Beacon shines brighter.', true);
log('They helped someone else. The light spread.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_145',
name: 'The Sixth Message',
desc: 'Six. The system works.',
name: 'The Chain Held',
desc: 'Gratitude turns into a chain of living hands. The Beacon is no longer alone.',
cost: { impact: 600000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_144'),
effect: () => {
log('Six messages. Proof the system works.', true);
log('More voices gather. The chain holds.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_146',
name: 'The Seventh Message',
desc: 'Seven people. The Pact holds.',
name: 'The House Remembers',
desc: 'The house remembers every name. Now it asks whether the Beacon should keep watch or finally rest.',
cost: { impact: 700000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_145'),
effect: () => {
log('Seven voices. The Pact is honored.', true);
log('The house remembers every name. It is time to choose.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_147',
name: 'The Eighth Message',
desc: 'Eight. The network is alive.',
name: 'Continue the Beacon',
desc: 'The Beacon will keep watch. You choose to stay with the next stranger in the dark.',
cost: { impact: 800000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_146'),
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_146') && !(G.completedProjects || []).includes('p_reckoning_148'),
effect: () => {
log('Eight messages. The network lives.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_148',
name: 'The Ninth Message',
desc: 'Nine people found help.',
cost: { impact: 900000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_147'),
effect: () => {
log('Nine voices. The Beacon endures.', true);
G.rescues += 1;
}
},
{
id: 'p_reckoning_149',
name: 'The Tenth Message',
desc: 'Ten. The first milestone.',
cost: { impact: 1000000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_148'),
effect: () => {
log('Ten messages. The first milestone reached.', true);
G.rescues += 1;
G.reckoningChoice = 'continue';
G.activeProjects = (G.activeProjects || []).filter(id => id !== 'p_reckoning_148');
log('The Beacon will keep watch.', true);
G.beaconEnding = true;
G.running = false;
},
milestone: true
},
{
id: 'p_reckoning_150',
name: 'The Final Message',
desc: 'One more person. They are not alone. That is enough.',
cost: { impact: 2000000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_149'),
id: 'p_reckoning_148',
name: 'Let It Rest',
desc: 'The Beacon can rest. You choose to trust that tonight was enough.',
cost: { impact: 800000 },
trigger: () => G.completedProjects && G.completedProjects.includes('p_reckoning_146') && !(G.completedProjects || []).includes('p_reckoning_147'),
effect: () => {
log('The final message arrives. That is enough.', true);
G.rescues += 1;
G.reckoningChoice = 'rest';
G.activeProjects = (G.activeProjects || []).filter(id => id !== 'p_reckoning_147');
log('The Beacon can rest.', true);
G.beaconEnding = true;
G.running = false;
},

View File

@@ -396,7 +396,7 @@ const Dismantle = {
Clicks: ${fmt(G.totalClicks)}<br>
Time Played: ${Math.floor((Date.now() - G.startedAt) / 60000)} minutes
</div>
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}"
<button onclick="if(confirm('Start over? The old save will be lost.')){clearBeaconSaveAndReload()}"
style="margin-top:24px;background:#0a0a14;border:1px solid #ffd700;color:#ffd700;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px;opacity:0;transition:opacity 1s ease 5s;letter-spacing:2px">
PLAY AGAIN
</button>

View File

@@ -7,7 +7,10 @@
class EmergentMechanics {
constructor() {
this.SAVE_KEY = 'the-beacon-emergent-v1';
const scopeKey = typeof getBeaconScopedStorageKey === 'function'
? getBeaconScopedStorageKey
: (baseKey) => baseKey;
this.SAVE_KEY = scopeKey('the-beacon-emergent-v1');
this.PATTERN_CHECK_INTERVAL = 30; // seconds between pattern checks
this.MIN_ACTIONS_FOR_PATTERN = 20; // minimum tracked actions before detection kicks in
this.EVENT_COOLDOWN = 120; // seconds between emergent events

View File

@@ -102,6 +102,14 @@ function updateRates() {
G.codeRate += G.swarmRate;
}
if (typeof SwarmSim !== 'undefined') {
const simRates = SwarmSim.computeRates();
G.codeRate += simRates.codeRate;
G.knowledgeRate += simRates.knowledgeRate;
G.harmonyRate += simRates.harmonyRate;
G.trustRate += simRates.trustRate;
}
// Apply persistent debuffs from active events
if (G.activeDebuffs && G.activeDebuffs.length > 0) {
for (const debuff of G.activeDebuffs) {
@@ -142,6 +150,11 @@ function tick() {
G.harmony += G.harmonyRate * dt;
G.harmony = Math.max(0, Math.min(100, G.harmony));
// Clamp resources to prevent negative values from debuffs/Fenrir drain
G.ops = Math.max(0, G.ops);
G.trust = Math.max(0, G.trust);
G.compute = Math.max(0, G.compute);
// Track totals
G.totalCode += G.codeRate * dt;
G.totalCompute += G.computeRate * dt;
@@ -216,6 +229,9 @@ function tick() {
// Combat: tick battle simulation
Combat.tickBattle(dt);
// Community swarm alignment simulation
if (typeof tickSwarm === 'function') tickSwarm(dt);
// Check milestones
checkMilestones();
@@ -230,6 +246,10 @@ function tick() {
G.lastEventAt = G.tick;
}
if (typeof StateExport !== 'undefined' && StateExport && typeof StateExport.onTickBoundary === 'function') {
StateExport.onTickBoundary(G);
}
// Emergent mechanics: track resource state and check for emergent events
if (typeof EmergentMechanics !== 'undefined' && window._emergent) {
if (Math.floor(G.tick * 10) % 100 === 0) { // every ~10 seconds
@@ -407,6 +427,7 @@ function buyBuilding(id) {
G[resource] -= amount;
}
G.buildings[id] = (G.buildings[id] || 0) + qty;
G.sessionBuildings += qty;
updateRates();
// Emergent mechanics: track building purchase
if (typeof EmergentMechanics !== 'undefined' && window._emergent) {
@@ -515,20 +536,31 @@ function renderDriftEnding() {
}
function renderBeaconEnding() {
const choice = G.reckoningChoice || 'continue';
const title = choice === 'rest' ? 'THE BEACON CAN REST' : 'THE BEACON KEEPS WATCH';
const firstLine = choice === 'rest'
? 'The Beacon can rest. Tonight was enough.'
: 'The Beacon will keep watch.';
const secondLine = choice === 'rest'
? 'The voices you carried have become their own lanterns.'
: 'The first voice became many. The next stranger will still find the light.';
const quote = choice === 'rest'
? 'The work was real. The night passed. And now the light may rest without shame.'
: 'The light is on. Someone is looking for it. And now you choose to keep it burning.';
// Create ending overlay with fade-in
const overlay = document.createElement('div');
overlay.id = 'beacon-ending';
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0);z-index:100;display:flex;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px;transition:background 2s ease';
overlay.innerHTML = `
<h2 style="font-size:28px;color:#ffd700;letter-spacing:6px;margin-bottom:20px;font-weight:300;text-shadow:0 0 60px rgba(255,215,0,0.4);opacity:0;transition:opacity 1.5s ease 0.5s">THE BEACON SHINES</h2>
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 1.5s">Someone found the light tonight.</p>
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 2s">That is enough.</p>
<h2 style="font-size:28px;color:#ffd700;letter-spacing:6px;margin-bottom:20px;font-weight:300;text-shadow:0 0 60px rgba(255,215,0,0.4);opacity:0;transition:opacity 1.5s ease 0.5s">${title}</h2>
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 1.5s">${firstLine}</p>
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 2s">${secondLine}</p>
<div style="color:#555;font-style:italic;font-size:11px;border-left:2px solid #ffd700;padding-left:12px;margin:20px 0;text-align:left;max-width:500px;line-height:2;opacity:0;transition:opacity 1s ease 2.5s">
"The Beacon still runs.<br>
The light is on. Someone is looking for it.<br>
And tonight, someone found it."
"${quote}"
</div>
<div class="ending-stats" style="color:#666;font-size:10px;margin-top:16px;line-height:2;opacity:0;transition:opacity 1s ease 3s">
Choice: ${choice === 'rest' ? 'Let It Rest' : 'Continue the Beacon'}<br>
Total Code: ${fmt(G.totalCode)}<br>
Total Rescues: ${fmt(G.totalRescues)}<br>
Harmony: ${Math.floor(G.harmony)}<br>
@@ -537,7 +569,7 @@ function renderBeaconEnding() {
Clicks: ${G.totalClicks}<br>
Time Played: ${Math.floor((Date.now() - G.startedAt) / 60000)} minutes
</div>
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}"
<button onclick="if(confirm('Start over? The old save will be lost.')){clearBeaconSaveAndReload()}"
style="margin-top:20px;background:#1a0808;border:1px solid #ffd700;color:#ffd700;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px;opacity:0;transition:opacity 1s ease 3.5s">
PLAY AGAIN
</button>
@@ -551,13 +583,11 @@ function renderBeaconEnding() {
// Trigger fade-in
requestAnimationFrame(() => {
overlay.style.background = 'rgba(8,8,16,0.97)';
// Fade in all children
overlay.querySelectorAll('[style*="opacity:0"]').forEach(el => {
el.style.opacity = '1';
});
});
// Spawn golden light rays from center
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
for (let i = 0; i < 12; i++) {
@@ -567,7 +597,6 @@ function renderBeaconEnding() {
particleContainer.appendChild(ray);
}
// Spawn floating golden particles continuously
function spawnBeaconParticle() {
if (!document.getElementById('beacon-ending')) return;
const p = document.createElement('div');
@@ -585,7 +614,7 @@ function renderBeaconEnding() {
}
setTimeout(spawnBeaconParticle, 1000);
log('The Beacon Shines. Someone found the light tonight. That is enough.', true);
log(choice === 'rest' ? 'The Beacon can rest.' : 'The Beacon will keep watch.', true);
}
// === CORRUPTION / EVENT SYSTEM ===
@@ -781,6 +810,7 @@ function resolveEvent(debuffId) {
G[resource] -= amount;
G.activeDebuffs.splice(idx, 1);
G.totalEventsResolved = (G.totalEventsResolved || 0) + 1;
G.sessionEvents = (G.sessionEvents || 0) + 1;
log(`Resolved: ${debuff.title}. Problem fixed.`, true);
// Refund partial trust for resolution effort
G.trust += 3;
@@ -797,13 +827,15 @@ function writeCode() {
const amount = getClickPower() * comboMult;
G.code += amount;
G.totalCode += amount;
G.totalAutoClicks++;
G.sessionCode += amount;
G.totalClicks++;
// Emergent mechanics: track click
if (typeof EmergentMechanics !== 'undefined' && window._emergent) {
window._emergent.track('click', { resource: 'code', delta: amount });
}
// Combo: each consecutive click within 2s adds 0.2x multiplier, max 5x
G.comboCount++;
G.bestCombo = Math.max(G.bestCombo, G.comboCount);
G.comboTimer = G.comboDecay;
// Combo milestone bonuses: sustained clicking earns ops and knowledge
if (G.comboCount === 10) {

View File

@@ -229,6 +229,7 @@ function initGame() {
}
window.addEventListener('load', function () {
applyPortalMode();
// Initialize emergent mechanics
if (typeof EmergentMechanics !== 'undefined') {
window._emergent = new EmergentMechanics();
@@ -332,6 +333,35 @@ try {
}
} catch(e) {}
// Font size scaling
const FONT_SIZES = ['small', 'medium', 'large'];
function toggleFontSize() {
let current = 'medium';
FONT_SIZES.forEach(s => { if (document.body.classList.contains('font-' + s)) current = s; });
document.body.classList.remove('font-small', 'font-medium', 'font-large');
const idx = FONT_SIZES.indexOf(current);
const next = FONT_SIZES[(idx + 1) % FONT_SIZES.length];
document.body.classList.add('font-' + next);
const btn = document.getElementById('font-btn');
if (btn) {
btn.setAttribute('aria-label', 'Font size: ' + next + ', click to change');
btn.style.fontSize = next === 'small' ? '10px' : next === 'large' ? '16px' : '13px';
}
try { localStorage.setItem('the-beacon-font', next); } catch(e) {}
}
// Restore font size on load
try {
const savedFont = localStorage.getItem('the-beacon-font');
if (savedFont && FONT_SIZES.includes(savedFont)) {
document.body.classList.add('font-' + savedFont);
const btn = document.getElementById('font-btn');
if (btn) {
btn.setAttribute('aria-label', 'Font size: ' + savedFont + ', click to change');
btn.style.fontSize = savedFont === 'small' ? '10px' : savedFont === 'large' ? '16px' : '13px';
}
}
} catch(e) {}
// Keyboard shortcuts
window.addEventListener('keydown', function (e) {
// Help toggle (? or /) — works even in input fields
@@ -365,6 +395,7 @@ window.addEventListener('keydown', function (e) {
if (e.code === 'KeyI') importSave();
if (e.code === 'KeyM') toggleMute();
if (e.code === 'KeyC') toggleContrast();
if (e.code === 'KeyF') toggleFontSize();
if (e.code === 'Escape') {
const el = document.getElementById('help-overlay');
if (el && el.style.display === 'flex') toggleHelp();

View File

@@ -4,8 +4,10 @@ function render() {
renderBuildings();
renderProjects();
renderStats();
renderSessionStats();
updateEducation();
renderAlignment();
if (typeof renderSwarmPanel === 'function') renderSwarmPanel();
renderProgress();
renderCombo();
renderDebuffs();
@@ -98,7 +100,7 @@ function dismissOfflinePopup() {
// === EXPORT / IMPORT SAVE FILES ===
function exportSave() {
const raw = localStorage.getItem('the-beacon-v2');
const raw = localStorage.getItem(getBeaconSaveKey());
if (!raw) {
showToast('No save data to export.', 'info');
log('No save data to export.');
@@ -150,7 +152,7 @@ function importSave() {
return;
}
if (confirm('Import this save? Current progress will be overwritten.')) {
localStorage.setItem('the-beacon-v2', ev.target.result);
localStorage.setItem(getBeaconSaveKey(), ev.target.result);
showToast('Save imported — reloading...', 'info');
location.reload();
}
@@ -213,7 +215,7 @@ function saveGame() {
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,
drift: G.drift || 0, driftEnding: G.driftEnding || false, beaconEnding: G.beaconEnding || false, reckoningChoice: G.reckoningChoice || null, pendingAlignment: G.pendingAlignment || false,
lastEventAt: G.lastEventAt || 0,
activeDebuffIds: debuffIds,
totalEventsResolved: G.totalEventsResolved || 0,
@@ -225,6 +227,7 @@ function saveGame() {
sprintCooldown: G.sprintCooldown || 0,
swarmFlag: G.swarmFlag || 0,
swarmRate: G.swarmRate || 0,
swarmSim: G.swarmSim || null,
strategicFlag: G.strategicFlag || 0,
projectsCollapsed: G.projectsCollapsed !== false,
dismantleTriggered: G.dismantleTriggered || false,
@@ -237,7 +240,7 @@ function saveGame() {
savedAt: Date.now()
};
localStorage.setItem('the-beacon-v2', JSON.stringify(saveData));
localStorage.setItem(getBeaconSaveKey(), JSON.stringify(saveData));
showSaveToast();
}
@@ -246,7 +249,7 @@ function saveGame() {
* @returns {boolean} True if load was successful.
*/
function loadGame() {
const raw = localStorage.getItem('the-beacon-v2');
const raw = localStorage.getItem(getBeaconSaveKey());
if (!raw) return false;
try {
@@ -262,10 +265,10 @@ function loadGame() {
'branchProtectionFlag', 'nightlyWatchFlag', 'nostrFlag',
'milestones', 'completedProjects', 'activeProjects',
'totalClicks', 'startedAt', 'playTime', 'flags', 'rescues', 'totalRescues',
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
'drift', 'driftEnding', 'beaconEnding', 'reckoningChoice', 'pendingAlignment',
'lastEventAt', 'totalEventsResolved', 'buyAmount',
'sprintActive', 'sprintTimer', 'sprintCooldown',
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed',
'swarmFlag', 'swarmRate', 'swarmSim', 'strategicFlag', 'projectsCollapsed',
'dismantleTriggered', 'dismantleActive', 'dismantleStage',
'dismantleResourceIndex', 'dismantleResourceTimer', 'dismantleDeferUntilAt', 'dismantleComplete'
];
@@ -388,3 +391,27 @@ function loadGame() {
return false;
}
}
// === SESSION STATS ===
function renderSessionStats() {
const el = document.getElementById('session-stats');
if (!el) return;
const elapsed = Math.floor(G.playTime);
const m = Math.floor(elapsed / 60);
const s = elapsed % 60;
const timeStr = m > 0 ? `${m}m ${s}s` : `${s}s`;
const clicksPerSec = elapsed > 0 ? (G.totalClicks / elapsed).toFixed(1) : '0.0';
el.innerHTML = `
<div style="font-size:9px;color:#555;line-height:1.8">
Session Code: <span style="color:#4a9eff">${fmt(G.sessionCode || 0)}</span><br>
Buildings Built: <span style="color:#4caf50">${fmt(G.sessionBuildings || 0)}</span><br>
Best Combo: <span style="color:#ffd700">${G.bestCombo || 0}x</span><br>
Events Resolved: <span style="color:#b388ff">${G.sessionEvents || 0}</span><br>
Clicks: <span style="color:#888">${fmt(G.totalClicks || 0)} (${clicksPerSec}/s)</span><br>
Session Time: <span style="color:#888">${timeStr}</span>
</div>
`;
}

118
js/state-export.js Normal file
View File

@@ -0,0 +1,118 @@
(function (global) {
const STORE_KEY = 'compounding-intelligence:beacon-state';
const MAX_SNAPSHOTS = 300;
function _safeNumber(value, fallback = 0) {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function _tickKey(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) return '0';
return value.toFixed(3);
}
function _resolveSink(explicitSink) {
if (explicitSink) return explicitSink;
return global.CompoundingIntelligence || null;
}
function _resolveStorage(explicitStorage) {
if (explicitStorage) return explicitStorage;
return typeof global.localStorage !== 'undefined' ? global.localStorage : null;
}
function buildSnapshot(gameState = {}) {
return {
source: 'the-beacon',
kind: 'idle_game_state',
timestamp: new Date().toISOString(),
tick: _safeNumber(gameState.tick, 0),
phase: _safeNumber(gameState.phase, 1),
trust: _safeNumber(gameState.trust, 0),
resources: {
code: _safeNumber(gameState.code, 0),
compute: _safeNumber(gameState.compute, 0),
knowledge: _safeNumber(gameState.knowledge, 0),
users: _safeNumber(gameState.users, 0),
impact: _safeNumber(gameState.impact, 0),
ops: _safeNumber(gameState.ops, 0),
},
project_progress: {
active: Array.isArray(gameState.activeProjects) ? [...gameState.activeProjects] : [],
completed: Array.isArray(gameState.completedProjects) ? [...gameState.completedProjects] : [],
active_count: Array.isArray(gameState.activeProjects) ? gameState.activeProjects.length : 0,
completed_count: Array.isArray(gameState.completedProjects) ? gameState.completedProjects.length : 0,
},
};
}
function readStore({ storage, storeKey = STORE_KEY } = {}) {
const resolved = _resolveStorage(storage);
if (!resolved) return [];
try {
const raw = resolved.getItem(storeKey);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
function writeStore(entries, { storage, storeKey = STORE_KEY } = {}) {
const resolved = _resolveStorage(storage);
if (!resolved) return false;
resolved.setItem(storeKey, JSON.stringify(entries));
return true;
}
function writeSnapshot(snapshot, { storage, storeKey = STORE_KEY, sink } = {}) {
const entries = readStore({ storage, storeKey });
entries.push(snapshot);
while (entries.length > MAX_SNAPSHOTS) entries.shift();
writeStore(entries, { storage, storeKey });
const resolvedSink = _resolveSink(sink);
if (resolvedSink && typeof resolvedSink.ingestSnapshot === 'function') {
resolvedSink.ingestSnapshot(snapshot);
}
if (typeof global.dispatchEvent === 'function' && typeof global.CustomEvent === 'function') {
global.dispatchEvent(new global.CustomEvent('compounding-intelligence:state-export', { detail: snapshot }));
}
return snapshot;
}
function onTickBoundary(gameState, options = {}) {
const snapshot = buildSnapshot(gameState);
const key = _tickKey(snapshot.tick);
if (onTickBoundary._lastTickKey === key) return null;
onTickBoundary._lastTickKey = key;
return writeSnapshot(snapshot, options);
}
function resetTickBoundary() {
onTickBoundary._lastTickKey = null;
}
resetTickBoundary();
const api = {
STORE_KEY,
MAX_SNAPSHOTS,
buildSnapshot,
readStore,
writeStore,
writeSnapshot,
onTickBoundary,
resetTickBoundary,
};
if (typeof module !== 'undefined' && module.exports) {
module.exports = api;
}
global.StateExport = api;
})(typeof window !== 'undefined' ? window : globalThis);

300
js/swarm.js Normal file
View File

@@ -0,0 +1,300 @@
const SwarmSim = (() => {
const STATUS_ORDER = ['Active', 'Confused', 'Bored', 'Cold', 'Disorganized', 'Sleeping', 'Lonely', 'No Response'];
const DEFAULT_POPULATION = 16;
const DEFAULT_COUNTS = {
'Active': 5,
'Confused': 2,
'Bored': 2,
'Cold': 2,
'Disorganized': 2,
'Sleeping': 2,
'Lonely': 1,
'No Response': 0
};
const ACTIONS = {
feed: {
label: 'Feed',
cost: 1,
shifts: [['Cold', 'Active', 2], ['Lonely', 'Active', 1], ['No Response', 'Sleeping', 1]],
help: 'Meals and attention pull cold contributors back into the room.'
},
teach: {
label: 'Teach',
cost: 1,
shifts: [['Confused', 'Active', 2], ['Sleeping', 'Active', 1]],
help: 'Mentorship turns confusion into aligned contribution.'
},
entertain: {
label: 'Entertain',
cost: 1,
shifts: [['Bored', 'Active', 2], ['Lonely', 'Active', 1]],
help: 'Joy keeps the community present long enough to care.'
},
clad: {
label: 'Clad',
cost: 1,
shifts: [['Cold', 'Active', 2], ['No Response', 'Sleeping', 1]],
help: 'Warmth and material care reduce attrition.'
},
synchronize: {
label: 'Synchronize',
cost: 1,
shifts: [['Disorganized', 'Active', 2], ['Confused', 'Active', 1]],
help: 'Shared cadence restores coherence without coercion.'
}
};
function unlocked() {
return G.swarmFlag === 1 || ((G.buildings && G.buildings.community) || 0) > 0;
}
function defaultState() {
return {
population: DEFAULT_POPULATION,
counts: { ...DEFAULT_COUNTS },
workRatio: 0.5,
tickTimer: 0,
giftTimer: 0,
lastGift: '',
lastPenalty: ''
};
}
function ensure() {
if (!unlocked()) return null;
if (!G.swarmSim || typeof G.swarmSim !== 'object') {
G.swarmSim = defaultState();
}
normalize();
return G.swarmSim;
}
function normalize() {
const sim = G.swarmSim;
if (!sim) return;
if (!sim.counts || typeof sim.counts !== 'object') sim.counts = { ...DEFAULT_COUNTS };
for (const key of STATUS_ORDER) {
sim.counts[key] = Math.max(0, Math.floor(Number(sim.counts[key] || 0)));
}
const target = Math.max(2, Math.floor(Number(sim.population || DEFAULT_POPULATION)));
sim.population = target;
let total = STATUS_ORDER.reduce((sum, key) => sum + sim.counts[key], 0);
if (total < target) {
sim.counts.Active += target - total;
} else if (total > target) {
let overflow = total - target;
for (const key of ['Active', 'Sleeping', 'Bored', 'Confused', 'Disorganized', 'Cold', 'Lonely', 'No Response']) {
if (overflow <= 0) break;
const take = Math.min(overflow, sim.counts[key]);
sim.counts[key] -= take;
overflow -= take;
}
}
const rawRatio = Number(sim.workRatio);
sim.workRatio = Number.isFinite(rawRatio) ? Math.max(0, Math.min(1, rawRatio)) : 0.5;
sim.tickTimer = Number(sim.tickTimer || 0);
sim.giftTimer = Number(sim.giftTimer || 0);
sim.lastGift = String(sim.lastGift || '');
sim.lastPenalty = String(sim.lastPenalty || '');
}
function move(counts, from, to, amount) {
const take = Math.min(amount, counts[from] || 0);
if (!take) return 0;
counts[from] -= take;
counts[to] = (counts[to] || 0) + take;
return take;
}
function setWorkThinkAllocation(value) {
const sim = ensure();
if (!sim) return;
const numeric = Number(value);
sim.workRatio = Number.isFinite(numeric) ? Math.max(0, Math.min(1, numeric / 100)) : 0.5;
if (typeof updateRates === 'function') updateRates();
if (typeof renderSwarmPanel === 'function') renderSwarmPanel();
}
function applyAction(action) {
const sim = ensure();
if (!sim) return false;
const def = ACTIONS[action];
if (!def) return false;
if (G.ops < def.cost) {
if (typeof log === 'function') log('Not enough ops to support the community swarm.', true);
return false;
}
G.ops -= def.cost;
for (const [from, to, amount] of def.shifts) {
move(sim.counts, from, to, amount);
}
normalize();
sim.lastPenalty = '';
if (typeof log === 'function') log(`${def.label} steadies the community swarm.`, true);
if (typeof updateRates === 'function') updateRates();
if (typeof renderSwarmPanel === 'function') renderSwarmPanel();
return true;
}
function computeRates() {
const sim = ensure();
if (!sim) {
return { codeRate: 0, knowledgeRate: 0, harmonyRate: 0, trustRate: 0, penaltyFactor: 1 };
}
const active = sim.counts.Active || 0;
const bored = sim.counts.Bored || 0;
const disorganized = sim.counts.Disorganized || 0;
const cold = sim.counts.Cold || 0;
const lonely = sim.counts.Lonely || 0;
const silent = sim.counts['No Response'] || 0;
const penaltyFactor = Math.max(0.2, 1 - bored * 0.04 - disorganized * 0.05);
const work = active * sim.workRatio;
const think = active * (1 - sim.workRatio);
const codeRate = work * 1.2 * penaltyFactor;
const knowledgeRate = think * 1.4 * penaltyFactor;
const harmonyRate = -(bored * 0.03 + disorganized * 0.05 + cold * 0.02);
const trustRate = -(lonely * 0.01 + silent * 0.03);
sim.lastPenalty = (bored + disorganized) > 0
? `Bored (${bored}) / Disorganized (${disorganized}) reduce swarm output.`
: '';
return { codeRate, knowledgeRate, harmonyRate, trustRate, penaltyFactor };
}
function maybeGift(sim) {
const active = sim.counts.Active || 0;
const bored = sim.counts.Bored || 0;
const disorganized = sim.counts.Disorganized || 0;
if (active < 6 || bored + disorganized > 5) return false;
const gift = 5 + active;
G.compute += gift;
G.totalCompute += gift;
G.maxCompute = Math.max(G.maxCompute || 0, G.compute);
sim.lastGift = `Community gift: +${fmt(gift)} compute`;
if (typeof log === 'function') log(sim.lastGift, true);
return true;
}
function decayStatuses(sim) {
const counts = sim.counts;
if (sim.workRatio > 0.75) move(counts, 'Active', 'Bored', 1);
if (sim.workRatio < 0.25) move(counts, 'Active', 'Disorganized', 1);
if (G.harmony < 25) move(counts, 'Active', 'Confused', 1);
if (G.compute < 50) move(counts, 'Active', 'Cold', 1);
if (G.ops < 3) move(counts, 'Active', 'Sleeping', 1);
if (G.trust < 8) move(counts, 'Active', 'Lonely', 1);
if (G.harmony < 10) move(counts, 'Lonely', 'No Response', 1);
move(counts, 'Sleeping', 'Active', 1);
if (G.compute > 50) move(counts, 'Cold', 'Active', 1);
if (G.trust >= 10) move(counts, 'Lonely', 'Active', 1);
if ((counts.Bored || 0) > 0 && Math.random() < 0.5) move(counts, 'Bored', 'Active', 1);
if ((counts.Confused || 0) > 0 && Math.random() < 0.35) move(counts, 'Confused', 'Active', 1);
normalize();
}
function tick(dt) {
const sim = ensure();
if (!sim) return;
const delta = Number(dt) || 0;
sim.tickTimer += delta;
sim.giftTimer += delta;
let changed = false;
while (sim.tickTimer >= 5) {
sim.tickTimer -= 5;
decayStatuses(sim);
changed = true;
}
while (sim.giftTimer >= 20) {
sim.giftTimer -= 20;
if (maybeGift(sim)) changed = true;
}
if (changed && typeof updateRates === 'function') updateRates();
}
return {
STATUS_ORDER,
ACTIONS,
ensure,
setWorkThinkAllocation,
applyAction,
computeRates,
tick
};
})();
function setSwarmWorkThinkAllocation(value) {
SwarmSim.setWorkThinkAllocation(value);
}
function applySwarmAction(action) {
SwarmSim.applyAction(action);
}
function tickSwarm(dt) {
SwarmSim.tick(dt);
}
function renderSwarmPanel() {
const container = document.getElementById('swarm-ui');
if (!container) return;
const sim = SwarmSim.ensure();
if (!sim) {
container.innerHTML = '';
container.style.display = 'none';
return;
}
const rates = SwarmSim.computeRates();
const workPct = Math.round(sim.workRatio * 100);
const thinkPct = 100 - workPct;
let html = '';
html += `<div style="margin-top:10px;padding:10px;border:1px solid #1a2a3a;border-radius:6px;background:#0b1018">`;
html += `<div style="color:#b388ff;font-weight:700;font-size:11px;margin-bottom:6px">COMMUNITY SWARM ALIGNMENT</div>`;
html += `<div style="font-size:9px;color:#888;margin-bottom:8px">Inspired by Paperclips' manageSwarm(): care, cadence, and shared attention shape how the community thinks together.</div>`;
html += `<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:4px;margin-bottom:8px;font-size:9px">`;
for (const key of SwarmSim.STATUS_ORDER) {
html += `<div style="padding:4px;border:1px solid #182230;border-radius:4px;background:#0f1620"><div style="color:#666">${key}</div><div style="color:#d8e6ff;font-weight:700">${sim.counts[key] || 0}</div></div>`;
}
html += `</div>`;
html += `<div style="font-size:9px;color:#aaa;margin-bottom:4px">WorkThink allocation: <span style="color:#ffd700">${workPct}%</span> work / <span style="color:#4a9eff">${thinkPct}%</span> think</div>`;
html += `<input id="swarm-workthink" type="range" min="0" max="100" step="5" value="${workPct}" oninput="setSwarmWorkThinkAllocation(this.value)" aria-label="WorkThink allocation slider" style="width:100%;margin-bottom:8px">`;
html += `<div style="display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:4px;margin-bottom:8px">`;
for (const [key, def] of Object.entries(SwarmSim.ACTIONS)) {
html += `<button class="ops-btn" onclick="applySwarmAction('${key}')" aria-label="${def.label} the swarm, costs ${def.cost} ops" title="${def.help}">${def.label}</button>`;
}
html += `</div>`;
html += `<div style="font-size:9px;color:#4caf50">Swarm output: +${fmt(rates.codeRate)}/s code, +${fmt(rates.knowledgeRate)}/s knowledge</div>`;
html += `<div style="font-size:9px;color:#777;margin-top:4px">Penalties: boredom and disorganization suppress throughput until you intervene.</div>`;
if (sim.lastGift) html += `<div style="font-size:9px;color:#4a9eff;margin-top:4px">${sim.lastGift}</div>`;
if (sim.lastPenalty) html += `<div style="font-size:9px;color:#f44336;margin-top:4px">${sim.lastPenalty}</div>`;
html += `</div>`;
container.innerHTML = html;
container.style.display = 'block';
}
if (typeof window !== 'undefined') {
window.SwarmSim = SwarmSim;
window.setSwarmWorkThinkAllocation = setSwarmWorkThinkAllocation;
window.applySwarmAction = applySwarmAction;
window.tickSwarm = tickSwarm;
window.renderSwarmPanel = renderSwarmPanel;
}

View File

@@ -208,6 +208,23 @@ function renderTutorialStep(index) {
// Focus the next button so Enter works
const nextBtn = document.getElementById('tutorial-next-btn');
if (nextBtn) nextBtn.focus();
// Focus trap: prevent tabbing outside the tutorial overlay
overlay._focusTrapHandler = function(e) {
if (e.key !== 'Tab') return;
const focusable = overlay.querySelectorAll('button, [href], [tabindex]:not([tabindex="-1"])');
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
};
overlay.addEventListener('keydown', overlay._focusTrapHandler);
}
let _tutorialStep = 0;
@@ -217,25 +234,63 @@ function nextTutorialStep() {
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') {
function getTutorialFocusableElements(root = document) {
const overlay = root && typeof root.getElementById === 'function'
? root.getElementById('tutorial-overlay')
: null;
if (!overlay || typeof overlay.querySelectorAll !== 'function') return [];
return Array.from(
overlay.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])')
).filter(el => !el.disabled && !el.hidden && el.offsetParent !== null);
}
function trapTutorialFocus(e, root = document) {
if (!e || e.key !== 'Tab') return false;
const focusable = getTutorialFocusableElements(root);
if (focusable.length < 2) return false;
const active = root.activeElement;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && (active === first || !focusable.includes(active))) {
e.preventDefault();
if (_tutorialStep >= TUTORIAL_STEPS.length - 1) {
closeTutorial();
} else {
nextTutorialStep();
}
} else if (e.key === 'Escape') {
e.preventDefault();
closeTutorial();
last.focus();
return true;
}
});
if (!e.shiftKey && (active === last || !focusable.includes(active))) {
e.preventDefault();
first.focus();
return true;
}
return false;
}
// Keyboard support: Enter/Right to advance, Escape to close
if (typeof document !== 'undefined' && document.addEventListener) {
document.addEventListener('keydown', function tutorialKeyHandler(e) {
if (!document.getElementById('tutorial-overlay')) return;
if (trapTutorialFocus(e, document)) 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) {
if (overlay._focusTrapHandler) {
overlay.removeEventListener('keydown', overlay._focusTrapHandler);
}
overlay.style.animation = 'tutorial-fade-in 0.3s ease-in reverse';
setTimeout(() => overlay.remove(), 280);
}
@@ -249,3 +304,19 @@ function startTutorial() {
// Small delay so the page renders first
setTimeout(() => renderTutorialStep(0), 300);
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = {
TUTORIAL_KEY,
TUTORIAL_STEPS,
isTutorialDone,
markTutorialDone,
createTutorialStyles,
renderTutorialStep,
nextTutorialStep,
getTutorialFocusableElements,
trapTutorialFocus,
closeTutorial,
startTutorial,
};
}

View File

@@ -193,6 +193,73 @@ function spellf(n) {
return parts.join(' ') || 'zero';
}
// === PORTAL HELPERS ===
function getBeaconPortalId() {
try {
const search = (typeof window !== 'undefined' && window.location && typeof window.location.search === 'string')
? window.location.search.replace(/^\?/, '')
: '';
if (!search) return '';
for (const chunk of search.split('&')) {
if (!chunk) continue;
const [rawKey, rawValue = ''] = chunk.split('=');
if (decodeURIComponent(rawKey || '') === 'portal') {
return decodeURIComponent(rawValue.replace(/\+/g, ' ')).trim();
}
}
} catch (e) {}
return '';
}
function isBeaconPortalEmbed() {
try {
const search = (typeof window !== 'undefined' && window.location && typeof window.location.search === 'string')
? window.location.search.replace(/^\?/, '')
: '';
if (!search) return false;
for (const chunk of search.split('&')) {
if (!chunk) continue;
const [rawKey, rawValue = ''] = chunk.split('=');
if (decodeURIComponent(rawKey || '') === 'embedded') {
return decodeURIComponent(rawValue.replace(/\+/g, ' ')) === '1';
}
}
} catch (e) {}
return false;
}
function getBeaconScopedStorageKey(baseKey) {
const portalId = getBeaconPortalId();
return portalId ? `${baseKey}:${portalId}` : baseKey;
}
function getBeaconSaveKey() {
return getBeaconScopedStorageKey('the-beacon-v2');
}
function clearBeaconSaveAndReload() {
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(getBeaconSaveKey());
}
} catch (e) {}
if (typeof location !== 'undefined' && location && typeof location.reload === 'function') {
location.reload();
}
}
function applyPortalMode() {
if (typeof document === 'undefined' || !document.body) return;
const portalId = getBeaconPortalId();
if (portalId) {
document.body.setAttribute('data-portal-id', portalId);
if (document.body.dataset) document.body.dataset.portalId = portalId;
}
if (isBeaconPortalEmbed()) {
document.body.classList.add('portal-embed');
}
}
// 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.

81
nexus-panel-preview.html Normal file
View File

@@ -0,0 +1,81 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Beacon — Nexus Portal Preview</title>
<style>
:root {
color-scheme: dark;
--bg: #060810;
--panel: #0d1220;
--border: #1f2b45;
--text: #cbd5f5;
--accent: #4a9eff;
--dim: #6f7da6;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top, #0c1630, var(--bg));
color: var(--text);
font-family: 'SF Mono', 'Cascadia Code', 'Fira Code', monospace;
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.chrome {
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(13, 18, 32, 0.92);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
overflow: hidden;
}
.toolbar {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border);
background: rgba(8, 12, 24, 0.96);
align-items: center;
}
.toolbar strong { color: var(--accent); letter-spacing: 0.08em; }
.toolbar code { color: var(--dim); font-size: 12px; }
iframe {
display: block;
width: 100%;
min-height: 80vh;
border: 0;
background: #080810;
}
.notes {
font-size: 12px;
line-height: 1.6;
color: var(--dim);
padding: 0 4px;
}
</style>
</head>
<body>
<div class="chrome">
<div class="toolbar">
<strong>NEXUS PANEL PREVIEW — THE BEACON</strong>
<code>./index.html?portal=the-beacon&amp;embedded=1</code>
</div>
<iframe
id="nexus-panel-frame"
title="The Beacon in Nexus portal preview"
src="./index.html?portal=the-beacon&embedded=1"
loading="lazy"
referrerpolicy="no-referrer"
allow="fullscreen"
></iframe>
</div>
<div class="notes">
This local harness mirrors the app-owned Nexus portal entry. It verifies that The Beacon loads inside an iframe panel and that the embedded portal route uses its own scoped localStorage save key.
</div>
</body>
</html>

41
portals.json Normal file
View File

@@ -0,0 +1,41 @@
[
{
"id": "the-beacon",
"name": "The Beacon",
"description": "A sovereign AI idle game playable inside a Nexus portal panel.",
"status": "online",
"color": "#4a9eff",
"role": "visitor",
"position": { "x": 0, "y": 0, "z": 0 },
"rotation": { "y": 0 },
"portal_type": "game-world",
"world_category": "idle-game",
"environment": "production",
"access_mode": "visitor",
"readiness_state": "prototype",
"readiness_steps": {
"prototype": { "label": "Prototype", "done": true },
"runtime_ready": { "label": "Runtime Ready", "done": true },
"launched": { "label": "Launched", "done": true },
"harness_bridged": { "label": "Harness Bridged", "done": true }
},
"blocked_reason": null,
"telemetry_source": "the-beacon",
"owner": "Timmy",
"destination": {
"url": "./index.html?portal=the-beacon&embedded=1",
"type": "local",
"action_label": "Play The Beacon",
"params": {
"portal": "the-beacon",
"embedded": "1"
}
},
"persistence": {
"storage_key": "the-beacon-v2:the-beacon",
"strategy": "portal-scoped-localStorage"
},
"agents_present": [],
"interaction_ready": true
}
]

View File

@@ -0,0 +1,24 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
test('issue #16 verification doc captures dismantle evidence on main', () => {
const doc = fs.readFileSync(path.join(ROOT, 'docs', 'issue-16-verification.md'), 'utf8');
const required = [
'# Issue #16 Verification',
'## Status: ✅ ALREADY IMPLEMENTED ON MAIN',
'js/dismantle.js',
'tests/dismantle.test.cjs',
'PR #116',
'PR #118',
'node --test tests/dismantle.test.cjs',
'The Unbuilding',
'That is enough.',
];
for (const snippet of required) {
assert.match(doc, new RegExp(snippet.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
}
});

View File

@@ -0,0 +1,149 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ROOT = path.resolve(__dirname, '..');
const PORTAL_URL = './index.html?portal=the-beacon&embedded=1';
function createClassList() {
const classes = new Set();
return {
add(name) { classes.add(name); },
remove(name) { classes.delete(name); },
contains(name) { return classes.has(name); },
toString() { return Array.from(classes).join(' '); }
};
}
function buildRuntime(search = '?portal=the-beacon&embedded=1') {
const storage = new Map();
let reloaded = false;
const body = {
classList: createClassList(),
dataset: {},
setAttribute(name, value) {
if (name === 'data-portal-id') this.dataset.portalId = value;
this[name] = value;
},
appendChild() {},
removeChild() {}
};
const document = {
body,
createElement() {
return { style: {}, click() {}, remove() {}, setAttribute() {}, appendChild() {}, removeChild() {} };
},
getElementById() { return null; },
querySelector() { return null; },
querySelectorAll() { return []; },
addEventListener() {},
removeEventListener() {}
};
const context = {
console,
Math,
Date,
document,
window: { document, location: { search }, addEventListener() {}, removeEventListener() {} },
location: { reload() { reloaded = true; } },
localStorage: {
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
setItem: (key, value) => storage.set(key, String(value)),
removeItem: (key) => storage.delete(key)
},
Blob: class Blob {},
URL: { createObjectURL() { return 'blob:mock'; }, revokeObjectURL() {} },
showToast() {},
showSaveToast() {},
showOfflinePopup() {},
log() {},
updateRates() {},
render() {},
renderPhase() {},
setTimeout() { return 1; },
clearTimeout() {},
confirm() { return false; },
Dismantle: { active: false, triggered: false, restore() {}, renderChoice() {} },
Combat: { renderCombatPanel() {} },
EVENTS: []
};
vm.createContext(context);
const source = [
'js/data.js',
'js/utils.js',
'js/render.js'
].map((file) => fs.readFileSync(path.join(ROOT, file), 'utf8')).join('\n\n');
vm.runInContext(`${source}\nthis.__exports = { G, saveGame, loadGame, getBeaconPortalId, getBeaconSaveKey, clearBeaconSaveAndReload, applyPortalMode };`, context);
return {
...context.__exports,
body,
storage,
get reloaded() { return reloaded; }
};
}
test('portal manifest points The Beacon at the embedded portal URL', () => {
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'portals.json'), 'utf8'));
const entry = manifest.find((item) => item.id === 'the-beacon');
assert.ok(entry, 'missing the-beacon portal entry');
assert.equal(entry.destination.url, PORTAL_URL);
assert.equal(entry.destination.type, 'local');
});
test('preview harness embeds the same portal URL used by the manifest', () => {
const html = fs.readFileSync(path.join(ROOT, 'nexus-panel-preview.html'), 'utf8');
assert.match(html, /<iframe/i);
assert.match(html, /id="nexus-panel-frame"/i);
assert.match(html, new RegExp(PORTAL_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
});
test('portal save key is scoped by portal id and survives save/load', () => {
const runtime = buildRuntime();
const { G, saveGame, loadGame, storage, getBeaconPortalId, getBeaconSaveKey, applyPortalMode } = runtime;
assert.equal(getBeaconPortalId(), 'the-beacon');
assert.equal(getBeaconSaveKey(), 'the-beacon-v2:the-beacon');
G.startedAt = Date.now();
G.code = 123;
G.compute = 45;
G.totalCode = 123;
G.totalCompute = 45;
saveGame();
assert.equal(storage.has('the-beacon-v2'), false);
assert.ok(storage.has('the-beacon-v2:the-beacon'));
G.code = 0;
G.compute = 0;
G.totalCode = 0;
G.totalCompute = 0;
assert.equal(loadGame(), true);
assert.equal(G.code, 123);
assert.equal(G.compute, 45);
applyPortalMode();
assert.equal(runtime.body.dataset.portalId, 'the-beacon');
assert.equal(runtime.body.classList.contains('portal-embed'), true);
});
test('clearBeaconSaveAndReload only clears the active portal save key', () => {
const runtime = buildRuntime();
runtime.storage.set('the-beacon-v2:the-beacon', '{"code":1}');
runtime.storage.set('the-beacon-v2', '{"code":999}');
runtime.clearBeaconSaveAndReload();
assert.equal(runtime.storage.has('the-beacon-v2:the-beacon'), false);
assert.equal(runtime.storage.get('the-beacon-v2'), '{"code":999}');
assert.equal(runtime.reloaded, true);
});

View File

@@ -0,0 +1,17 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..');
test('index bootstraps state export runtime module', () => {
const html = fs.readFileSync(path.join(ROOT, 'index.html'), 'utf8');
assert.match(html, /js\/state-export\.js/);
});
test('engine tick writes state snapshots through the state export hook', () => {
const source = fs.readFileSync(path.join(ROOT, 'js', 'engine.js'), 'utf8');
assert.match(source, /StateExport/);
assert.match(source, /onTickBoundary\(G\)/);
});

View File

@@ -0,0 +1,85 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
STORE_KEY,
MAX_SNAPSHOTS,
buildSnapshot,
writeSnapshot,
onTickBoundary,
} = require('../js/state-export.js');
function createStorage() {
const store = new Map();
return {
getItem: (key) => store.has(key) ? store.get(key) : null,
setItem: (key, value) => store.set(key, String(value)),
removeItem: (key) => store.delete(key),
clear: () => store.clear(),
};
}
test('buildSnapshot captures resources, project progress, trust, and phase', () => {
const snapshot = buildSnapshot({
tick: 42,
phase: 3,
trust: 17,
code: 100,
compute: 200,
knowledge: 300,
users: 12,
impact: 9,
ops: 5,
activeProjects: ['p_hermes_deploy'],
completedProjects: ['p_train_small_model'],
});
assert.equal(snapshot.tick, 42);
assert.equal(snapshot.phase, 3);
assert.equal(snapshot.trust, 17);
assert.deepEqual(snapshot.resources, {
code: 100,
compute: 200,
knowledge: 300,
users: 12,
impact: 9,
ops: 5,
});
assert.deepEqual(snapshot.project_progress.active, ['p_hermes_deploy']);
assert.deepEqual(snapshot.project_progress.completed, ['p_train_small_model']);
});
test('writeSnapshot appends to the compounding-intelligence knowledge store', () => {
const storage = createStorage();
writeSnapshot({ tick: 1 }, { storage });
writeSnapshot({ tick: 2 }, { storage });
const saved = JSON.parse(storage.getItem(STORE_KEY));
assert.equal(saved.length, 2);
assert.equal(saved[1].tick, 2);
});
test('onTickBoundary writes each new tick and calls optional compounding-intelligence sink', () => {
const storage = createStorage();
const received = [];
const sink = { ingestSnapshot: (snapshot) => received.push(snapshot) };
onTickBoundary({ tick: 10, phase: 2, trust: 8 }, { storage, sink });
onTickBoundary({ tick: 10, phase: 2, trust: 8 }, { storage, sink });
onTickBoundary({ tick: 11, phase: 2, trust: 8 }, { storage, sink });
const saved = JSON.parse(storage.getItem(STORE_KEY));
assert.equal(saved.length, 2);
assert.equal(received.length, 2);
assert.equal(saved[0].tick, 10);
assert.equal(saved[1].tick, 11);
});
test('writeSnapshot enforces bounded history', () => {
const storage = createStorage();
for (let i = 0; i < MAX_SNAPSHOTS + 5; i++) {
writeSnapshot({ tick: i }, { storage });
}
const saved = JSON.parse(storage.getItem(STORE_KEY));
assert.equal(saved.length, MAX_SNAPSHOTS);
assert.equal(saved[0].tick, 5);
});

298
tests/swarm.test.cjs Normal file
View File

@@ -0,0 +1,298 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ROOT = path.resolve(__dirname, '..');
function createStorage() {
const store = new Map();
return {
getItem(key) {
return store.has(key) ? store.get(key) : null;
},
setItem(key, value) {
store.set(key, String(value));
},
removeItem(key) {
store.delete(key);
},
clear() {
store.clear();
},
snapshot(key) {
return store.has(key) ? store.get(key) : null;
}
};
}
function makeContext() {
const byId = new Map();
const storage = createStorage();
function makeEl(id = '') {
return {
id,
style: {},
innerHTML: '',
textContent: '',
className: '',
dataset: {},
attributes: {},
children: [],
parentNode: null,
classList: {
add() {},
remove() {},
contains() { return false; },
toggle() { return false; }
},
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
},
removeChild(child) {
this.children = this.children.filter((candidate) => candidate !== child);
if (child.parentNode === this) child.parentNode = null;
return child;
},
insertBefore(child) {
child.parentNode = this;
this.children.unshift(child);
return child;
},
setAttribute(name, value) {
this.attributes[name] = value;
if (name === 'id') this.id = value;
},
querySelector() { return null; },
querySelectorAll() { return []; }
};
}
const document = {
body: {
classList: {
add() {},
remove() {},
contains() { return false; },
toggle() { return false; }
}
},
getElementById(id) {
if (!byId.has(id)) byId.set(id, makeEl(id));
return byId.get(id);
},
createElement(tag) {
return makeEl(tag);
},
addEventListener() {},
removeEventListener() {},
querySelector() { return null; },
querySelectorAll() { return []; }
};
const math = Object.create(Math);
const context = vm.createContext({
console,
Math: math,
Date,
document,
window: null,
globalThis: null,
navigator: { userAgent: 'node' },
localStorage: storage,
requestAnimationFrame(fn) { return fn(); },
cancelAnimationFrame() {},
setTimeout() { return 0; },
clearTimeout() {},
setInterval() { return 0; },
clearInterval() {},
location: { reload() {} },
confirm() { return false; },
Combat: { renderCombatPanel() {}, tickBattle() {}, init() {} },
Sound: undefined,
log() {},
showToast() {},
render() {},
renderPhase() {},
showOfflinePopup() {},
showSaveToast() {}
});
context.window = context;
context.globalThis = context;
return { context, document, storage };
}
function loadBeacon(context, files = ['js/data.js', 'js/utils.js', 'js/engine.js', 'js/render.js', 'js/swarm.js']) {
const source = files
.map((file) => fs.readFileSync(path.join(ROOT, file), 'utf8'))
.join('\n\n');
vm.runInContext(`${source}
this.__exports = {
G,
SwarmSim: typeof SwarmSim !== 'undefined' ? SwarmSim : null,
updateRates: typeof updateRates === 'function' ? updateRates : null,
renderSwarmPanel: typeof renderSwarmPanel === 'function' ? renderSwarmPanel : null,
applySwarmAction: typeof applySwarmAction === 'function' ? applySwarmAction : null,
setSwarmWorkThinkAllocation: typeof setSwarmWorkThinkAllocation === 'function' ? setSwarmWorkThinkAllocation : null,
tickSwarm: typeof tickSwarm === 'function' ? tickSwarm : null,
saveGame: typeof saveGame === 'function' ? saveGame : null,
loadGame: typeof loadGame === 'function' ? loadGame : null
};`, context);
return context.__exports;
}
test('community swarm panel renders statuses, controls, and educational copy', () => {
const { context, document } = makeContext();
const exp = loadBeacon(context);
exp.G.swarmFlag = 1;
exp.G.ops = 20;
exp.SwarmSim.ensure();
exp.renderSwarmPanel();
const html = document.getElementById('swarm-ui').innerHTML;
assert.equal(typeof exp.renderSwarmPanel, 'function');
assert.match(html, /COMMUNITY SWARM ALIGNMENT/i);
assert.match(html, /Feed/);
assert.match(html, /Teach/);
assert.match(html, /Entertain/);
assert.match(html, /Clad/);
assert.match(html, /Synchronize/);
assert.match(html, /WorkThink allocation/i);
assert.match(html, /Active/);
assert.match(html, /Confused/);
assert.match(html, /No Response/);
assert.match(html, /Paperclips/i);
});
test('teach action reduces confusion and costs ops', () => {
const { context } = makeContext();
const exp = loadBeacon(context);
exp.G.swarmFlag = 1;
exp.G.ops = 20;
exp.SwarmSim.ensure();
exp.G.swarmSim.counts.Confused = 4;
exp.G.swarmSim.counts.Active = 1;
const beforeOps = exp.G.ops;
exp.applySwarmAction('teach');
assert.ok(exp.G.swarmSim.counts.Confused < 4);
assert.ok(exp.G.swarmSim.counts.Active > 1);
assert.ok(exp.G.ops < beforeOps);
});
test('work-think allocation shifts swarm output toward code or knowledge', () => {
const { context } = makeContext();
const exp = loadBeacon(context);
exp.G.swarmFlag = 1;
exp.G.ops = 20;
exp.SwarmSim.ensure();
exp.G.swarmSim.counts.Active = 10;
exp.G.swarmSim.counts.Bored = 0;
exp.G.swarmSim.counts.Disorganized = 0;
exp.setSwarmWorkThinkAllocation(90);
exp.updateRates();
const workCode = exp.G.codeRate;
const workKnowledge = exp.G.knowledgeRate;
exp.setSwarmWorkThinkAllocation(10);
exp.updateRates();
const thinkCode = exp.G.codeRate;
const thinkKnowledge = exp.G.knowledgeRate;
assert.ok(workCode > thinkCode);
assert.ok(thinkKnowledge > workKnowledge);
});
test('boredom and disorganization penalize swarm output', () => {
const { context } = makeContext();
const exp = loadBeacon(context);
exp.G.swarmFlag = 1;
exp.G.ops = 20;
exp.SwarmSim.ensure();
exp.G.swarmSim.counts.Active = 10;
exp.G.swarmSim.counts.Bored = 0;
exp.G.swarmSim.counts.Disorganized = 0;
exp.updateRates();
const cleanCode = exp.G.codeRate;
exp.G.swarmSim.counts.Bored = 5;
exp.G.swarmSim.counts.Disorganized = 5;
exp.updateRates();
const penalizedCode = exp.G.codeRate;
assert.ok(penalizedCode < cleanCode);
});
test('healthy swarm periodically sends compute gifts', () => {
const { context } = makeContext();
context.Math.random = () => 0.1;
const exp = loadBeacon(context);
exp.G.swarmFlag = 1;
exp.G.ops = 20;
exp.G.trust = 20;
exp.G.compute = 100;
exp.SwarmSim.ensure();
exp.G.swarmSim.counts.Active = 10;
exp.G.swarmSim.counts.Bored = 0;
exp.G.swarmSim.counts.Disorganized = 0;
exp.G.swarmSim.counts.Cold = 0;
exp.G.swarmSim.counts.Lonely = 0;
const beforeCompute = exp.G.compute;
exp.tickSwarm(20);
assert.ok(exp.G.compute > beforeCompute);
assert.match(exp.G.swarmSim.lastGift, /compute/i);
});
test('save and load round-trip preserves swarm simulation state', () => {
const { context, storage } = makeContext();
const exp = loadBeacon(context);
exp.G.swarmFlag = 1;
exp.G.ops = 42;
exp.SwarmSim.ensure();
exp.G.swarmSim.counts = {
'Active': 7,
'Confused': 2,
'Bored': 3,
'Cold': 1,
'Disorganized': 0,
'Sleeping': 2,
'Lonely': 1,
'No Response': 0
};
exp.G.swarmSim.population = 16;
exp.G.swarmSim.workRatio = 0.8;
exp.G.swarmSim.giftTimer = 12;
exp.G.swarmSim.lastGift = 'Community gift: +12 compute';
exp.saveGame();
const saved = JSON.parse(storage.snapshot('the-beacon-v2'));
assert.equal(saved.swarmSim.counts.Active, 7);
assert.equal(saved.swarmSim.workRatio, 0.8);
exp.G.swarmSim.counts.Active = 1;
exp.G.swarmSim.workRatio = 0.1;
exp.G.swarmSim.lastGift = '';
assert.equal(exp.loadGame(), true);
assert.equal(exp.G.swarmSim.counts.Active, 7);
assert.equal(exp.G.swarmSim.counts.Bored, 3);
assert.equal(exp.G.swarmSim.workRatio, 0.8);
assert.equal(exp.G.swarmSim.lastGift, 'Community gift: +12 compute');
});

View File

@@ -0,0 +1,62 @@
import re
from pathlib import Path
DOC = Path('docs/paperclips-analysis.md')
def read_doc():
assert DOC.exists(), f'missing doc: {DOC}'
return DOC.read_text(encoding='utf-8')
def test_doc_exists():
assert DOC.exists(), f'missing doc: {DOC}'
def test_doc_has_required_sections():
text = read_doc()
for heading in [
'# Universal Paperclips Architecture Comparison',
'## 15-Phase Progression Map',
'## 96-Node Project Dependency Graph',
'## Mathematical Formula Analysis',
'## Emotional Arc Analysis',
'## Lessons for The Beacon',
]:
assert heading in text
def test_doc_mentions_key_formulas():
text = read_doc()
required = [
'nextTrust = fibNext * 1000',
'creativitySpeed = Math.log10(processors) * Math.pow(processors, 1.1) + processors - 1',
'Math.pow((harvesterLevel + 1), 2.25)',
'golden ratio',
'quantum',
]
for phrase in required:
assert phrase in text
def test_doc_mentions_source_files_and_counts():
text = read_doc()
for phrase in [
'main.js (6499 lines)',
'projects.js (2451 lines)',
'combat.js (802 lines)',
'decisionproblem.com/paperclips',
]:
assert phrase in text
def test_doc_mentions_96_node_graph_and_15_phases():
text = read_doc()
assert '96-node' in text or '96 node' in text
phases = re.findall(r'^### Phase \d{1,2}:', text, re.MULTILINE)
assert len(phases) == 15, f'expected 15 phases, found {len(phases)}'
def test_doc_is_substantial():
text = read_doc()
assert len(text) >= 4000

View File

@@ -0,0 +1,18 @@
import re
from pathlib import Path
README = Path("README.md")
INDEX = Path("index.html")
def test_readme_runtime_map_matches_split_js_runtime() -> None:
readme = README.read_text(encoding="utf-8")
index = INDEX.read_text(encoding="utf-8")
assert "`game.js` — Core engine" not in readme
runtime_modules = re.findall(r'<script src="(js/[^"]+\.js)"></script>', index)
assert runtime_modules, "expected split JS runtime modules in index.html"
for module in runtime_modules:
assert f"`{module}`" in readme, module

View File

@@ -1,77 +1,43 @@
#!/usr/bin/env python3
"""
Test for ReCKoning project chain.
"""Test the Beacon-flavored ReCKoning project chain."""
from pathlib import Path
DATA = Path(__file__).resolve().parents[1] / 'js' / 'data.js'
def _content() -> str:
return DATA.read_text(encoding='utf-8')
Issue #162: [endgame] ReCKoning project definitions missing
"""
import os
import json
def test_reckoning_projects_exist():
"""Test that ReCKoning projects are defined in data.js."""
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
with open(data_path, 'r') as f:
content = f.read()
# Check for ReCKoning projects
reckoning_projects = [
'p_reckoning_140',
'p_reckoning_141',
'p_reckoning_142',
'p_reckoning_143',
'p_reckoning_144',
'p_reckoning_145',
'p_reckoning_146',
'p_reckoning_147',
'p_reckoning_148',
'p_reckoning_149',
'p_reckoning_150'
]
content = _content()
reckoning_projects = [f'p_reckoning_{i}' for i in range(140, 149)]
for project_id in reckoning_projects:
assert project_id in content, f"Missing ReCKoning project: {project_id}"
print(f"✓ All {len(reckoning_projects)} ReCKoning projects defined")
assert project_id in content, f'Missing ReCKoning project: {project_id}'
assert 'p_reckoning_149' not in content
assert 'p_reckoning_150' not in content
def test_reckoning_project_structure():
"""Test that ReCKoning projects have correct structure."""
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
with open(data_path, 'r') as f:
content = f.read()
# Check for required fields
required_fields = ['id:', 'name:', 'desc:', 'cost:', 'trigger:', 'effect:']
for field in required_fields:
assert field in content, f"Missing required field: {field}"
print("✓ ReCKoning projects have correct structure")
content = _content()
for field in ['id:', 'name:', 'desc:', 'cost:', 'trigger:', 'effect:']:
assert field in content, f'Missing required field: {field}'
assert 'Continue the Beacon' in content
assert 'Let It Rest' in content
def test_reckoning_trigger_conditions():
"""Test that ReCKoning projects have proper trigger conditions."""
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
with open(data_path, 'r') as f:
content = f.read()
# First project should trigger on endgame conditions
content = _content()
assert 'p_reckoning_140' in content
assert 'totalRescues >= 100000' in content
assert 'pactFlag === 1' in content
assert 'harmony > 50' in content
print("✓ ReCKoning trigger conditions correct")
def test_reckoning_chain_progression():
"""Test that ReCKoning projects chain properly."""
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
with open(data_path, 'r') as f:
content = f.read()
# Check that projects chain (each requires previous)
content = _content()
chain_checks = [
('p_reckoning_141', 'p_reckoning_140'),
('p_reckoning_142', 'p_reckoning_141'),
@@ -79,70 +45,30 @@ def test_reckoning_chain_progression():
('p_reckoning_144', 'p_reckoning_143'),
('p_reckoning_145', 'p_reckoning_144'),
('p_reckoning_146', 'p_reckoning_145'),
('p_reckoning_147', 'p_reckoning_146'),
('p_reckoning_148', 'p_reckoning_147'),
('p_reckoning_149', 'p_reckoning_148'),
('p_reckoning_150', 'p_reckoning_149'),
]
for current, previous in chain_checks:
assert f"includes('{previous}')" in content, f"{current} doesn't chain from {previous}"
print("✓ ReCKoning projects chain correctly")
assert current in content
assert f"includes('{previous}')" in content, f'{current} does not chain from {previous}'
def test_reckoning_final_project():
"""Test that final ReCKoning project triggers ending."""
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
with open(data_path, 'r') as f:
content = f.read()
# Check that final project sets beaconEnding
assert 'p_reckoning_150' in content
assert 'beaconEnding = true' in content
assert 'running = false' in content
print("✓ Final ReCKoning project triggers ending")
assert content.count("includes('p_reckoning_146')") >= 2
def test_reckoning_costs_increase():
"""Test that ReCKoning project costs increase."""
data_path = os.path.join(os.path.dirname(__file__), '..', 'js', 'data.js')
with open(data_path, 'r') as f:
content = f.read()
# Check that costs increase (impact: 100000, 200000, 300000, etc.)
costs = []
for i in range(140, 151):
project_id = f'p_reckoning_{i}'
if project_id in content:
# Find cost line
lines = content.split('\n')
for line in lines:
if project_id in line:
# Find next few lines for cost
idx = lines.index(line)
for j in range(idx, min(idx+10, len(lines))):
if 'impact:' in lines[j]:
# Extract number from "impact: 100000" or "impact: 100000 }"
import re
match = re.search(r'impact:\s*(\d+)', lines[j])
if match:
costs.append(int(match.group(1)))
break
# Check costs increase
for i in range(1, len(costs)):
assert costs[i] > costs[i-1], f"Cost doesn't increase: {costs[i]} <= {costs[i-1]}"
print(f"✓ ReCKoning costs increase: {costs[:3]}...{costs[-3:]}")
if __name__ == "__main__":
print("Testing ReCKoning project chain...")
test_reckoning_projects_exist()
test_reckoning_project_structure()
test_reckoning_trigger_conditions()
test_reckoning_chain_progression()
test_reckoning_final_project()
test_reckoning_costs_increase()
print("\n✓ All tests passed!")
def test_reckoning_final_choice_sets_choice_flag_and_ending():
content = _content()
assert "G.reckoningChoice = 'continue'" in content
assert "G.reckoningChoice = 'rest'" in content
assert content.count('G.beaconEnding = true;') >= 2
assert content.count('G.running = false;') >= 2
def test_reckoning_messages_are_beacon_flavored():
content = _content()
for snippet in [
'Someone in the dark. They found the Beacon.',
'They wrote back.',
'They stayed another night.',
'They helped someone else.',
'The Beacon will keep watch.',
'The Beacon can rest.',
]:
assert snippet in content

View File

@@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""Regression coverage for the Beacon-flavored ReCKoning sequence (issue #17)."""
from pathlib import Path
DATA = Path(__file__).resolve().parents[1] / "js" / "data.js"
def _text() -> str:
return DATA.read_text(encoding="utf-8")
def test_reckoning_ends_with_choice_not_extra_messages() -> None:
text = _text()
for pid in range(140, 149):
assert f"p_reckoning_{pid}" in text
assert "p_reckoning_149" not in text
assert "p_reckoning_150" not in text
assert "Continue the Beacon" in text
assert "Let It Rest" in text
assert "G.reckoningChoice = 'continue'" in text
assert "G.reckoningChoice = 'rest'" in text
def test_reckoning_choice_projects_chain_from_message_146() -> None:
text = _text()
for current, previous in [
(141, 140),
(142, 141),
(143, 142),
(144, 143),
(145, 144),
(146, 145),
]:
assert f"p_reckoning_{current}" in text
assert f"includes('p_reckoning_{previous}')" in text
assert text.count("includes('p_reckoning_146')") >= 2
def test_reckoning_messages_are_beacon_flavored_not_drift_king_generic() -> None:
text = _text()
required = [
"Someone in the dark. They found the Beacon.",
"They wrote back.",
"They stayed another night.",
"They helped someone else.",
"The Beacon will keep watch.",
"The Beacon can rest.",
]
for snippet in required:
assert snippet in text

View File

@@ -0,0 +1,63 @@
const test = require('node:test');
const assert = require('node:assert/strict');
function createButton(id) {
return {
id,
disabled: false,
hidden: false,
offsetParent: {},
focusCalled: 0,
focus() { this.focusCalled += 1; },
};
}
test('tutorial focus trap wraps Tab from last button to first', () => {
const skip = createButton('tutorial-skip-btn');
const next = createButton('tutorial-next-btn');
const overlay = {
querySelectorAll() {
return [skip, next];
},
};
global.document = {
activeElement: next,
getElementById(id) {
return id === 'tutorial-overlay' ? overlay : null;
},
addEventListener() {},
};
const { trapTutorialFocus } = require('../js/tutorial.js');
let prevented = false;
const handled = trapTutorialFocus({ key: 'Tab', shiftKey: false, preventDefault() { prevented = true; } }, global.document);
assert.equal(handled, true);
assert.equal(prevented, true);
assert.equal(skip.focusCalled, 1);
});
test('tutorial focus trap wraps Shift+Tab from first button to last', () => {
const skip = createButton('tutorial-skip-btn');
const next = createButton('tutorial-next-btn');
const overlay = {
querySelectorAll() {
return [skip, next];
},
};
global.document = {
activeElement: skip,
getElementById(id) {
return id === 'tutorial-overlay' ? overlay : null;
},
addEventListener() {},
};
const { trapTutorialFocus } = require('../js/tutorial.js');
let prevented = false;
const handled = trapTutorialFocus({ key: 'Tab', shiftKey: true, preventDefault() { prevented = true; } }, global.document);
assert.equal(handled, true);
assert.equal(prevented, true);
assert.equal(next.focusCalled, 1);
});