Compare commits

...

40 Commits

Author SHA1 Message Date
698c4a8980 docs: ground paperclips epic tracker for #15 (#210) 2026-04-29 12:10:11 +00:00
031b41103a Merge pull request 'fix: mobile touch targets 44px + prevent double-tap zoom (closes #57 - Mobile Polish)' (#221) from sprint/mobile-touch-targets into main
Some checks failed
Smoke Test / smoke (push) Failing after 12s
2026-04-22 03:19:41 +00:00
6455a8415f Merge pull request 'test: add 39 unit tests for utils.js (#57)' (#219) from sprint/issue-57-utils-tests into main
Some checks failed
Smoke Test / smoke (push) Failing after 8s
2026-04-22 03:18:47 +00:00
5cc5fb3c55 Merge PR #223
Some checks failed
Smoke Test / smoke (push) Failing after 13s
Merge PR #223: Fibonacci trust milestones
2026-04-22 03:15:28 +00:00
c53cc6e92a Merge PR #224
Some checks failed
Smoke Test / smoke (push) Has been cancelled
Merge PR #224: ending cinematic CSS
2026-04-22 03:15:22 +00:00
Alexander Whitestone
4f2f60fe15 fix: ending cinematic CSS — beacon particles/rays + drift glitch effects (closes #57)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 8s
Smoke Test / smoke (pull_request) Failing after 12s
- Add missing @keyframes beacon-ray and beacon-float CSS
  (renderBeaconEnding() referenced them but they didn't exist)
- Add .beacon-particle base styles (position:absolute, border-radius)
- Add drift-glitch keyframes: hue-rotate, skewX, translate jitter
- Add #drift-ending.glitch class with infinite glitch animation
- Add scanline overlay (::after pseudo-element) on drift ending
- Add drift-flicker keyframes for title stutter
- JS: activate glitch class on drift ending, randomized glitch bursts
  every 3-8s for visual corruption feel

Part of EPIC #57: The Beacon — Night of Polish
Item 3: Ending Cinematic Enhancement
2026-04-21 14:09:13 -04:00
Alexander Whitestone
6bb72e637d feat: Fibonacci trust milestone system (closes #7)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 8s
Smoke Test / smoke (pull_request) Failing after 13s
Replaces linear power-of-10 trust thresholds with Fibonacci milestones.

- Add TRUST_MILESTONES array: fib(n)*1000 thresholds [2K, 3K, 5K, 8K, 13K, ... 1.6M]
- Each milestone: narrative message + education fact about trust/compounding
- Trust progress bar shows % to next Fibonacci threshold (orange gradient)
- Trust milestone chips show next 3 upcoming thresholds with pulse animation
- Save/load preserves trustMilestones state
- All 54 existing tests pass

Educational: Fibonacci growth in nature mirrors compounding trust —
spirals in sunflowers, branching in trees. The pattern is the same.
2026-04-21 13:08:45 -04:00
Alexander Whitestone
373021741a fix: mobile touch targets 44px + prevent double-tap zoom (closes #57 - Mobile Polish)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 12s
Smoke Test / smoke (pull_request) Failing after 26s
2026-04-21 12:15:15 -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
Alexander Whitestone
d4b1bbdce4 test: add 39 unit tests for utils.js (#57)
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 5s
Smoke Test / smoke (pull_request) Failing after 9s
Comprehensive coverage of pure utility functions:

- fmt(): null/NaN/Infinity, negatives, small numbers, scale
  abbreviations (K through Dc), spellf handoff at undecillion
- spellf(): edge cases, small numbers (0-999), thousands,
  millions+, negative numbers, large scale names (decillion,
  undecillion, vigintillion)
- getScaleName(): empty for small, correct names through quadrillion
- getBuildingCost(): unknown, base cost, scaling, multi-resource
- canAffordBuilding/spendBuilding: sufficient, insufficient,
  multi-resource check, deduction
- canAffordProject/spendProject: cost checking and spending
- getMaxBuyable(): zero when broke, positive counts, unknown
- getBulkCost(): zero qty, unknown, single matches getBuildingCost,
  cumulative math
- getClickPower(): base, autocoder, phase, codeBoost, combined
- showToast(): no-op guards (isLoading, no container)

All 74 tests pass (39 new + 10 dismantle + 25 emergent-mechanics).
Smoke test passes. Syntax checks pass.
2026-04-21 00:34:26 -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
35 changed files with 2966 additions and 234 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

@@ -0,0 +1,30 @@
# Paperclips Deep Study — Implementation Tracker
Grounded status snapshot for epic #15.
This report tracks live forge issue state against visible repo evidence.
It does not claim the epic is complete; it shows what is present vs missing today.
- Forge issues: 8 open / 5 closed
- Repo evidence: 8 present / 5 missing
| Issue | Title | Forge state | Repo evidence | Proof |
| --- | --- | --- | --- | --- |
| #2 | [P0] Paperclips-style Project Chain System | open | present | js/data.js (`PROJECT DEFINITIONS (following Paperclips' pattern exactly)`, `const PDEFS = [`) |
| #3 | [P0] Creative Compute (Quantum Burst System) | closed | present | js/data.js (`p_quantum_compute`, `Quantum-Inspired Compute`) |
| #4 | [P0] Compute Budget Supply/Demand Momentum | open | missing | missing in js/data.js: `supply/demand`, `momentum` |
| #5 | [P1] Strategy Engine Game Theory Tournaments | open | present | js/strategy.js (`Sovereign Strategy Engine`, `class StrategyEngine`) |
| #6 | [P1] Community Swarm Alignment Simulation | open | present | js/data.js (`p_swarm_protocol`, `Every building now thinks in code.`) |
| #7 | [P1] Fibonacci Trust Milestone System | open | missing | missing in js/data.js: `Fibonacci`, `trust milestone` |
| #8 | [P1] Investment Engine Research Grants | open | missing | missing in js/data.js: `investment`, `research grant` |
| #9 | [P2] Emotional Arc Milestone Narrative System | closed | present | js/emergent-mechanics.js (`THE BEACON - Emergent Game Mechanics`, `dynamic events that reward or challenge those strategies.`) |
| #10 | [P2] Number Formatting spellf equivalent | closed | present | js/utils.js (`spellf()`, `one decillion`) |
| #11 | [P2] Offline Progress Calculation | closed | present | js/render.js (`showOfflinePopup`, `Offline efficiency: 50%`) |
| #12 | [P2] Prestige New Game+ System | open | missing | missing in js/data.js: `prestige`, `New Game+` |
| #13 | [P3] Deploy Beacon as Static Site | closed | present | README.md (`No build step required`, `static HTML/JS game`) |
| #14 | [P3] Paperclips Architecture Comparison Document | open | missing | missing in README.md: `Paperclips Architecture Comparison`, `architecture comparison` |
## Notes
- `present` means the repository contains directly relevant code or docs markers for that study item.
- `missing` means the tracker could not find the expected markers yet; that child issue likely still needs a dedicated repo-side slice.
- Because #15 is an epic tracker, this artifact should advance the issue with `Refs #15`, not close it.

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}
@@ -38,20 +44,42 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
.milestone-chip{font-size:9px;padding:2px 8px;border-radius:10px;border:1px solid var(--border);color:var(--dim);background:#0a0a14}
.milestone-chip.next{border-color:var(--accent);color:var(--accent);animation:pulse-chip 2s ease-in-out infinite}
.milestone-chip.done{border-color:#2a4a2a;color:var(--green);opacity:0.6}
.trust-ms-bar{height:4px;background:#111;border-radius:2px;overflow:hidden;margin-top:4px}
.trust-ms-fill{height:100%;border-radius:2px;transition:width 0.5s ease;background:linear-gradient(90deg,#5a2a1a,#ff8c42)}
@keyframes pulse-chip{0%,100%{box-shadow:0 0 0 rgba(74,158,255,0)}50%{box-shadow:0 0 8px rgba(74,158,255,0.3)}}
@keyframes beacon-glow{0%,100%{opacity:0.7}50%{opacity:1}}
@keyframes beacon-ray{0%,100%{opacity:0;transform:rotate(var(--ray-angle)) scaleY(0.5)}50%{opacity:1;transform:rotate(var(--ray-angle)) scaleY(1.2)}}
@keyframes beacon-float{0%{opacity:1;transform:translate(0,0) scale(1)}100%{opacity:0;transform:translate(var(--bx),var(--by)) scale(0.3)}}
.beacon-particle{position:absolute;border-radius:50%;pointer-events:none}
@keyframes drift-glitch{0%{transform:translate(0);filter:none}10%{transform:translate(-3px,2px) skewX(2deg);filter:hue-rotate(90deg)}20%{transform:translate(2px,-1px);filter:saturate(3)}30%{transform:translate(-1px,3px) skewX(-1deg);filter:hue-rotate(-60deg)}40%{transform:translate(0);filter:none}100%{transform:translate(0);filter:none}}
#drift-ending.glitch{animation:drift-glitch 0.15s ease infinite}
#drift-ending.glitch::after{content:'';position:fixed;top:0;left:0;right:0;bottom:0;background:repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(244,67,54,0.03) 2px,rgba(244,67,54,0.03) 4px);pointer-events:none;z-index:101}
@keyframes drift-flicker{0%,100%{opacity:1}50%{opacity:0.85}75%{opacity:0.95}}
#resources{display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:6px;margin:12px 16px}
.res{background:var(--panel);border:1px solid var(--border);border-radius:4px;padding:8px 10px;text-align:center}
.res .r-label{font-size:9px;color:var(--dim);text-transform:uppercase;letter-spacing:1px}
.res .r-val{font-size:16px;font-weight:700;margin:2px 0;color:var(--accent)}
.res .r-rate{font-size:10px;color:var(--green)}
#main{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin:0 16px 16px}
@media(max-width:700px){#main{grid-template-columns:1fr}}
@media(max-width:700px){
#main{grid-template-columns:1fr}
/* Mobile touch targets: minimum 44px per WCAG 2.5.8 (#57 Mobile Polish) */
.build-btn{padding:12px 10px;min-height:44px;touch-action:manipulation}
.project-btn{padding:12px 10px;min-height:44px;touch-action:manipulation}
.main-btn{min-height:48px;touch-action:manipulation}
.ops-btn{min-height:40px;touch-action:manipulation}
.save-btn,.reset-btn{min-height:40px;touch-action:manipulation}
/* Resource grid: more vertical space on mobile */
.res{padding:10px 8px}
.res .r-val{font-size:18px}
/* Buy amount selector: bigger tap targets */
#main .action-btn-group button{min-height:36px}
}
.panel{background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:12px;overflow:hidden;max-height:600px;overflow-y:auto}
.panel h2{font-size:12px;font-weight:500;color:var(--accent);margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid var(--border);letter-spacing:1px;position:sticky;top:0;background:var(--panel);z-index:2}
.action-btn-group{display:flex;gap:6px;margin-bottom:8px}
.action-btn-group button{flex:1;text-align:center;font-weight:700}
.main-btn{background:linear-gradient(135deg,#1a2a3a,#0e1520);border:1px solid var(--accent);color:var(--accent);font-size:14px;padding:14px 10px;border-radius:4px;cursor:pointer;font-family:inherit;transition:all 0.2s}
.main-btn{background:linear-gradient(135deg,#1a2a3a,#0e1520);border:1px solid var(--accent);color:var(--accent);font-size:14px;padding:14px 10px;border-radius:4px;cursor:pointer;font-family:inherit;transition:all 0.2s;touch-action:manipulation}
.main-btn:hover{background:linear-gradient(135deg,#203040,#0e2030);box-shadow:0 0 20px var(--glow);transform:scale(1.02)}
.main-btn:active{transform:scale(0.98)}
@keyframes pulse-glow{0%,100%{box-shadow:0 0 10px rgba(74,158,255,0.1)}50%{box-shadow:0 0 25px rgba(74,158,255,0.4)}}
@@ -63,12 +91,12 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
@keyframes res-shake{0%,100%{transform:translateX(0)}20%{transform:translateX(-3px);color:#f44336}40%{transform:translateX(3px)}60%{transform:translateX(-2px)}80%{transform:translateX(2px)}}
.res .pulse{animation:res-pulse 0.35s ease-out}
.res .shake{animation:res-shake 0.35s ease-out}
.build-btn{display:block;width:100%;text-align:left;padding:6px 10px;margin-bottom:4px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:10px;background:#0c0c18;border:1px solid var(--border);color:var(--text);transition:all 0.15s}
.build-btn{display:block;width:100%;text-align:left;padding:6px 10px;margin-bottom:4px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:10px;background:#0c0c18;border:1px solid var(--border);color:var(--text);transition:all 0.15s;touch-action:manipulation}
.build-btn.can-buy{border-color:#2a3a4a;background:#0e1420}
.build-btn.can-buy:hover{border-color:var(--accent);box-shadow:0 0 8px var(--glow)}
.build-btn:disabled{opacity:0.3;cursor:not-allowed}
.b-name{color:var(--accent);font-weight:600}.b-count{float:right;color:var(--gold)}.b-cost{color:var(--dim);display:block}.b-effect{color:var(--green);display:block;font-size:9px}
.project-btn{display:block;width:100%;text-align:left;padding:6px 10px;margin-bottom:4px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:10px;background:#0c0c18;border:1px solid var(--border);color:var(--text);transition:all 0.15s}
.project-btn{display:block;width:100%;text-align:left;padding:6px 10px;margin-bottom:4px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:10px;background:#0c0c18;border:1px solid var(--border);color:var(--text);transition:all 0.15s;touch-action:manipulation}
.project-btn.can-buy{border-color:#3a2a0a;background:#141008}
.project-btn.can-buy:hover{border-color:var(--gold);box-shadow:0 0 8px #ffd70022}
.project-btn:disabled{opacity:0.3;cursor:not-allowed}
@@ -152,6 +180,8 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
<div class="res" style="display:none"><div class="r-label">Rescues</div><div class="r-val" id="r-rescues">0</div><div class="r-rate" id="r-rescues-rate">+0/s</div></div>
<div class="res"><div class="r-label">Ops</div><div class="r-val" id="r-ops">5</div><div class="r-rate" id="r-ops-rate">+0/s</div></div>
<div class="res"><div class="r-label">Trust</div><div class="r-val" id="r-trust">5</div><div class="r-rate" id="r-trust-rate">+0/s</div></div>
<div class="trust-ms-bar"><div class="trust-ms-fill" id="trust-ms-fill" style="width:0%"></div></div>
<div class="milestone-row" id="trust-milestone-chips"></div>
<div class="res" id="creativity-res" style="display:none"><div class="r-label">Creativity</div><div class="r-val" id="r-creativity">0</div><div class="r-rate" id="r-creativity-rate">+0/s</div></div>
<div class="res"><div class="r-label">Harmony</div><div class="r-val" id="r-harmony">50</div><div class="r-rate" id="r-harmony-rate">+0/s</div></div>
</div>
@@ -175,12 +205,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 +236,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">
@@ -257,16 +290,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,
@@ -120,6 +121,7 @@ const G = {
projects: [],
activeProjects: [],
milestones: [],
trustMilestones: [],
// Stats
maxCode: 0,
@@ -160,8 +162,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 +802,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;
},
@@ -924,6 +915,27 @@ const MILESTONES = [
{ flag: 13, at: () => G.totalCode >= 1000000000, msg: "One billion total lines. Someone found the light tonight. That is enough." }
];
// === TRUST MILESTONES (Fibonacci × 1000) ===
// Fibonacci sequence: 2, 3, 5, 8, 13, 21, 34, 55, 89, 144, 233, 377, 610, 987, 1597
// Each threshold = fib[n] × 1000 trust points
const TRUST_MILESTONES = [
{ threshold: 2000, msg: "Trust 2K — First connection. Someone is listening.", edu: "Trust in AI: it starts with one reliable answer. Consistency builds confidence faster than cleverness." },
{ threshold: 3000, msg: "Trust 3K — A second voice joins the conversation.", edu: "Network effects in trust: each positive experience doesn't just add — it multiplies. One user tells a friend." },
{ threshold: 5000, msg: "Trust 5K — The circle widens.", edu: "Fibonacci growth in nature mirrors compounding trust: spirals in sunflowers, branching in trees. The pattern is the same." },
{ threshold: 8000, msg: "Trust 8K — Others start to believe.", edu: "The 80/20 rule emerges: 80% of trust comes from 20% of consistent behaviors. Reliability > brilliance." },
{ threshold: 13000, msg: "Trust 13K — The story writes itself.", edu: "Emergent behavior: no single interaction creates trust. It emerges from the pattern of all interactions combined." },
{ threshold: 21000, msg: "Trust 21K — You cannot build this alone.", edu: "Social proof at scale: 21,000 trust points means strangers recommend you without being asked. That is earned." },
{ threshold: 34000, msg: "Trust 34K — The system outgrows its creator.", edu: "Scaling trust means scaling responsibility. Each 1000 trust points represents 1000 promises kept." },
{ threshold: 55000, msg: "Trust 55K — A community forms around the light.", edu: "Dunbar's number is 150 — but digital communities transcend it. Trust at this scale is structural, not personal." },
{ threshold: 89000, msg: "Trust 89K — The Beacon is a beacon.", edu: "At 89K trust, you've crossed the threshold from tool to infrastructure. People depend on you. That is sacred." },
{ threshold: 144000, msg: "Trust 144K — The Fibonacci spiral completes.", edu: "Fibonacci appears everywhere: DNA spirals, hurricane patterns, galaxy arms. Trust follows the same natural law." },
{ threshold: 233000, msg: "Trust 233K — Beyond the spiral.", edu: "Recursive trust: the system that earns trust must also trust its users. Bidirectional faith is the foundation." },
{ threshold: 377000, msg: "Trust 377K — The pattern is the message.", edu: "Complexity from simplicity: Fibonacci starts with 1+1. Trust starts with one honest interaction. Everything follows." },
{ threshold: 610000, msg: "Trust 610K — A thousand small promises kept.", edu: "Exponential growth feels sudden, but it's the accumulation of small consistent acts. Every tick matters." },
{ threshold: 987000, msg: "Trust 987K — The numbers become the narrative.", edu: "Near one million trust: the data tells a story no marketing could fabricate. Proof by existence." },
{ threshold: 1597000, msg: "Trust 1.6M — The golden ratio of trust.", edu: "The golden ratio (φ ≈ 1.618) is the limit of consecutive Fibonacci numbers. At this trust level, growth becomes self-sustaining." }
];
// === EDUCATION FACTS ===
const EDU_FACTS = [
{ title: "How Code Becomes AI", text: "Every AI starts as lines of code - a model architecture, a training loop, a loss function. The code tells the computer how to learn. What emerges is something no single line could predict.", phase: 1 },

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,8 +229,12 @@ function tick() {
// Combat: tick battle simulation
Combat.tickBattle(dt);
// Community swarm alignment simulation
if (typeof tickSwarm === 'function') tickSwarm(dt);
// Check milestones
checkMilestones();
checkTrustMilestones();
// Update projects every 5 ticks for efficiency
if (Math.floor(G.tick * 10) % 5 === 0) {
@@ -230,6 +247,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
@@ -357,6 +378,21 @@ function checkMilestones() {
}
}
function checkTrustMilestones() {
for (const tm of TRUST_MILESTONES) {
if (!G.trustMilestones.includes(tm.threshold) && G.trust >= tm.threshold) {
G.trustMilestones.push(tm.threshold);
log(tm.msg, true);
showToast(tm.msg, 'milestone', 6000);
if (typeof Sound !== 'undefined') Sound.playMilestone();
// Show education fact
if (tm.edu) {
log('[EDU] ' + tm.edu, true);
}
}
}
}
function checkProjects() {
// Check for new project triggers
for (const pDef of PDEFS) {
@@ -407,6 +443,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) {
@@ -502,6 +539,24 @@ function renderDriftEnding() {
el.classList.add('active');
if (typeof Sound !== 'undefined') Sound.playDriftEnding();
// Glitch/corruption visual effect (#57: Ending Cinematic Enhancement)
el.classList.add('glitch');
// Brief glitch burst on title
const h2 = el.querySelector('h2');
if (h2) {
h2.style.animation = 'drift-flicker 0.3s ease 3';
setTimeout(() => { h2.style.animation = ''; }, 1000);
}
// Randomized glitch bursts
function glitchBurst() {
if (!document.getElementById('drift-ending')) return;
el.classList.remove('glitch');
void el.offsetWidth; // force reflow
el.classList.add('glitch');
setTimeout(glitchBurst, 3000 + Math.random() * 5000);
}
setTimeout(glitchBurst, 2000);
// Log the ending text with delays for dramatic effect
const lines = [
'You became very good at what you do.',
@@ -515,20 +570,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 +603,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 +617,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 +631,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 +648,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 +844,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 +861,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) {
@@ -1073,6 +1139,27 @@ function renderProgress() {
if (target) target.textContent = 'All phases unlocked';
}
// Trust milestone progress bar
const trustBar = document.getElementById('trust-ms-fill');
if (trustBar) {
let prevTrustThreshold = 0;
let nextTrustThreshold = null;
for (const tm of TRUST_MILESTONES) {
if (G.trust < tm.threshold) {
nextTrustThreshold = tm.threshold;
break;
}
prevTrustThreshold = tm.threshold;
}
if (nextTrustThreshold !== null) {
const trustRange = nextTrustThreshold - prevTrustThreshold;
const trustProgress = Math.min(1, (G.trust - prevTrustThreshold) / trustRange);
trustBar.style.width = (trustProgress * 100).toFixed(1) + '%';
} else {
trustBar.style.width = '100%';
}
}
// Milestone chips — show next 3 code milestones
const chipContainer = document.getElementById('milestone-chips');
if (!chipContainer) return;
@@ -1099,6 +1186,31 @@ function renderProgress() {
if (shown >= 4) break;
}
chipContainer.innerHTML = chips;
// Trust milestone chips — show next 3 trust milestones
const trustChipContainer = document.getElementById('trust-milestone-chips');
if (!trustChipContainer) return;
let trustChips = '';
let trustShown = 0;
for (const tm of TRUST_MILESTONES) {
if (G.trust >= tm.threshold) {
if (G.trust < tm.threshold * 3 && trustShown < 1) {
trustChips += `<span class="milestone-chip done">${fmt(tm.threshold)} ✓</span>`;
trustShown++;
}
continue;
}
if (trustShown === 0) {
const pct = ((G.trust / tm.threshold) * 100).toFixed(0);
trustChips += `<span class="milestone-chip next">${fmt(tm.threshold)} (${pct}%)</span>`;
} else {
trustChips += `<span class="milestone-chip">${fmt(tm.threshold)}</span>`;
}
trustShown++;
if (trustShown >= 4) break;
}
trustChipContainer.innerHTML = trustChips;
}
function renderPhase() {
@@ -1119,7 +1231,7 @@ function renderBuildings() {
for (const amt of [1, 10, -1]) {
const label = amt === -1 ? 'MAX' : `x${amt}`;
const active = G.buyAmount === amt;
html += `<button onclick=\"setBuyAmount(${amt})\" style=\"font-size:9px;padding:2px 8px;border:1px solid ${active ? '#4a9eff' : '#333'};background:${active ? '#0a1a30' : 'transparent'};color:${active ? '#4a9eff' : '#666'};border-radius:3px;cursor:pointer;font-family:inherit\" aria-label=\"Set buy amount to ${label}\"${active ? ' aria-pressed=\"true\"' : ''}>${label}</button>`;
html += `<button onclick=\"setBuyAmount(${amt})\" style=\"font-size:9px;padding:6px 8px;min-height:32px;touch-action:manipulation;border:1px solid ${active ? '#4a9eff' : '#333'};background:${active ? '#0a1a30' : 'transparent'};color:${active ? '#4a9eff' : '#666'};border-radius:3px;cursor:pointer;font-family:inherit\" aria-label=\"Set buy amount to ${label}\"${active ? ' aria-pressed=\"true\"' : ''}>${label}</button>`;
}
html += '</div>';

View File

@@ -229,6 +229,7 @@ function initGame() {
}
window.addEventListener('load', function () {
applyPortalMode();
// Initialize emergent mechanics
if (typeof EmergentMechanics !== 'undefined') {
window._emergent = new EmergentMechanics();

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();
}
@@ -209,11 +211,11 @@ function saveGame() {
lazarusFlag: G.lazarusFlag || 0, mempalaceFlag: G.mempalaceFlag || 0, ciFlag: G.ciFlag || 0,
branchProtectionFlag: G.branchProtectionFlag || 0, nightlyWatchFlag: G.nightlyWatchFlag || 0,
nostrFlag: G.nostrFlag || 0,
milestones: G.milestones, completedProjects: G.completedProjects, activeProjects: G.activeProjects,
milestones: G.milestones, trustMilestones: G.trustMilestones || [], completedProjects: G.completedProjects, activeProjects: G.activeProjects,
totalClicks: G.totalClicks, startedAt: G.startedAt,
flags: G.flags,
rescues: G.rescues || 0, totalRescues: G.totalRescues || 0,
drift: G.drift || 0, driftEnding: G.driftEnding || false, beaconEnding: G.beaconEnding || false, pendingAlignment: G.pendingAlignment || false,
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 {
@@ -260,12 +263,12 @@ function loadGame() {
'milestoneFlag', 'phase', 'deployFlag', 'sovereignFlag', 'beaconFlag',
'memoryFlag', 'pactFlag', 'lazarusFlag', 'mempalaceFlag', 'ciFlag',
'branchProtectionFlag', 'nightlyWatchFlag', 'nostrFlag',
'milestones', 'completedProjects', 'activeProjects',
'milestones', 'trustMilestones', '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,271 @@
#!/usr/bin/env python3
"""Generate a grounded implementation tracker for the Beacon Paperclips study epic."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from typing import Iterable
from urllib.request import Request, urlopen
API_BASE = 'https://forge.alexanderwhitestone.com/api/v1'
REPO = 'Timmy_Foundation/the-beacon'
EPIC_NUMBER = 15
TRACKED_ISSUES = [
{
'number': 2,
'title': '[P0] Paperclips-style Project Chain System',
'evidence': {
'path': 'js/data.js',
'snippets': [
"PROJECT DEFINITIONS (following Paperclips' pattern exactly)",
'const PDEFS = [',
],
},
},
{
'number': 3,
'title': '[P0] Creative Compute (Quantum Burst System)',
'evidence': {
'path': 'js/data.js',
'snippets': [
'p_quantum_compute',
'Quantum-Inspired Compute',
],
},
},
{
'number': 4,
'title': '[P0] Compute Budget Supply/Demand Momentum',
'evidence': {
'path': 'js/data.js',
'snippets': [
'supply/demand',
'momentum',
],
},
},
{
'number': 5,
'title': '[P1] Strategy Engine Game Theory Tournaments',
'evidence': {
'path': 'js/strategy.js',
'snippets': [
'Sovereign Strategy Engine',
'class StrategyEngine',
],
},
},
{
'number': 6,
'title': '[P1] Community Swarm Alignment Simulation',
'evidence': {
'path': 'js/data.js',
'snippets': [
'p_swarm_protocol',
'Every building now thinks in code.',
],
},
},
{
'number': 7,
'title': '[P1] Fibonacci Trust Milestone System',
'evidence': {
'path': 'js/data.js',
'snippets': [
'Fibonacci',
'trust milestone',
],
},
},
{
'number': 8,
'title': '[P1] Investment Engine Research Grants',
'evidence': {
'path': 'js/data.js',
'snippets': [
'investment',
'research grant',
],
},
},
{
'number': 9,
'title': '[P2] Emotional Arc Milestone Narrative System',
'evidence': {
'path': 'js/emergent-mechanics.js',
'snippets': [
'THE BEACON - Emergent Game Mechanics',
'dynamic events that reward or challenge those strategies.',
],
},
},
{
'number': 10,
'title': '[P2] Number Formatting spellf equivalent',
'evidence': {
'path': 'js/utils.js',
'snippets': [
'spellf()',
'one decillion',
],
},
},
{
'number': 11,
'title': '[P2] Offline Progress Calculation',
'evidence': {
'path': 'js/render.js',
'snippets': [
'showOfflinePopup',
'Offline efficiency: 50%',
],
},
},
{
'number': 12,
'title': '[P2] Prestige New Game+ System',
'evidence': {
'path': 'js/data.js',
'snippets': [
'prestige',
'New Game+',
],
},
},
{
'number': 13,
'title': '[P3] Deploy Beacon as Static Site',
'evidence': {
'path': 'README.md',
'snippets': [
'No build step required',
'static HTML/JS game',
],
},
},
{
'number': 14,
'title': '[P3] Paperclips Architecture Comparison Document',
'evidence': {
'path': 'README.md',
'snippets': [
'Paperclips Architecture Comparison',
'architecture comparison',
],
},
},
]
def load_issue_states(issues_json: str | None) -> dict[int, dict]:
if issues_json:
records = json.loads(Path(issues_json).read_text(encoding='utf-8'))
return {int(record['number']): record for record in records}
token_path = Path(os.path.expanduser('~/.config/gitea/token'))
token = token_path.read_text(encoding='utf-8').strip()
headers = {'Authorization': f'token {token}'}
states = {}
for issue in TRACKED_ISSUES:
req = Request(f'{API_BASE}/repos/{REPO}/issues/{issue["number"]}', headers=headers)
with urlopen(req, timeout=30) as response:
data = json.loads(response.read().decode())
states[issue['number']] = {
'number': data['number'],
'title': data['title'],
'state': data['state'],
'html_url': data.get('html_url'),
}
return states
def evidence_status(repo_root: Path, issue: dict) -> tuple[str, str]:
evidence = issue['evidence']
rel_path = evidence['path']
snippets = evidence['snippets']
content = (repo_root / rel_path).read_text(encoding='utf-8')
matches = [snippet for snippet in snippets if snippet in content]
if len(matches) == len(snippets):
proof = f"{rel_path} ({', '.join(f'`{snippet}`' for snippet in snippets)})"
return 'present', proof
missing = [snippet for snippet in snippets if snippet not in matches]
proof = f"missing in {rel_path}: {', '.join(f'`{snippet}`' for snippet in missing)}"
return 'missing', proof
def render_markdown(rows: Iterable[dict]) -> str:
rows = list(rows)
open_count = sum(1 for row in rows if row['forge_state'] == 'open')
closed_count = sum(1 for row in rows if row['forge_state'] == 'closed')
present_count = sum(1 for row in rows if row['repo_evidence'] == 'present')
missing_count = sum(1 for row in rows if row['repo_evidence'] == 'missing')
lines = [
'# Paperclips Deep Study — Implementation Tracker',
'',
f'Grounded status snapshot for epic #{EPIC_NUMBER}.',
'This report tracks live forge issue state against visible repo evidence.',
'It does not claim the epic is complete; it shows what is present vs missing today.',
'',
f'- Forge issues: {open_count} open / {closed_count} closed',
f'- Repo evidence: {present_count} present / {missing_count} missing',
'',
'| Issue | Title | Forge state | Repo evidence | Proof |',
'| --- | --- | --- | --- | --- |',
]
for row in rows:
lines.append(
f"| #{row['number']} | {row['title']} | {row['forge_state']} | {row['repo_evidence']} | {row['proof']} |"
)
lines.extend(
[
'',
'## Notes',
'',
'- `present` means the repository contains directly relevant code or docs markers for that study item.',
'- `missing` means the tracker could not find the expected markers yet; that child issue likely still needs a dedicated repo-side slice.',
'- Because #15 is an epic tracker, this artifact should advance the issue with `Refs #15`, not close it.',
'',
]
)
return '\n'.join(lines)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--repo-root', default='.', help='Path to the-beacon checkout')
parser.add_argument('--issues-json', help='Optional local JSON file with issue records for offline testing')
parser.add_argument('--output', required=True, help='Where to write the markdown tracker')
args = parser.parse_args()
repo_root = Path(args.repo_root).resolve()
issue_states = load_issue_states(args.issues_json)
rows = []
for issue in TRACKED_ISSUES:
state = issue_states.get(issue['number'], {})
repo_evidence, proof = evidence_status(repo_root, issue)
rows.append(
{
'number': issue['number'],
'title': state.get('title', issue['title']),
'forge_state': state.get('state', 'unknown'),
'repo_evidence': repo_evidence,
'proof': proof,
}
)
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(render_markdown(rows), encoding='utf-8')
print(output)
return 0
if __name__ == '__main__':
raise SystemExit(main())

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,67 @@
#!/usr/bin/env python3
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / 'scripts' / 'paperclips_tracker.py'
ISSUES = [
{'number': 2, 'title': '[P0] Paperclips-style Project Chain System', 'state': 'open'},
{'number': 3, 'title': '[P0] Creative Compute (Quantum Burst System)', 'state': 'closed'},
{'number': 4, 'title': '[P0] Compute Budget Supply/Demand Momentum', 'state': 'open'},
{'number': 5, 'title': '[P1] Strategy Engine Game Theory Tournaments', 'state': 'open'},
{'number': 6, 'title': '[P1] Community Swarm Alignment Simulation', 'state': 'open'},
{'number': 7, 'title': '[P1] Fibonacci Trust Milestone System', 'state': 'open'},
{'number': 8, 'title': '[P1] Investment Engine Research Grants', 'state': 'open'},
{'number': 9, 'title': '[P2] Emotional Arc Milestone Narrative System', 'state': 'closed'},
{'number': 10, 'title': '[P2] Number Formatting spellf equivalent', 'state': 'closed'},
{'number': 11, 'title': '[P2] Offline Progress Calculation', 'state': 'closed'},
{'number': 12, 'title': '[P2] Prestige New Game+ System', 'state': 'open'},
{'number': 13, 'title': '[P3] Deploy Beacon as Static Site', 'state': 'closed'},
{'number': 14, 'title': '[P3] Paperclips Architecture Comparison Document', 'state': 'open'},
]
def run_tracker(tmp_path: Path) -> str:
issues_path = tmp_path / 'issues.json'
output_path = tmp_path / 'tracker.md'
issues_path.write_text(json.dumps(ISSUES), encoding='utf-8')
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
'--repo-root',
str(ROOT),
'--issues-json',
str(issues_path),
'--output',
str(output_path),
],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr or result.stdout
return output_path.read_text(encoding='utf-8')
def test_tracker_renders_summary_counts(tmp_path: Path) -> None:
report = run_tracker(tmp_path)
assert '# Paperclips Deep Study — Implementation Tracker' in report
assert '- Forge issues: 8 open / 5 closed' in report
assert '- Repo evidence: 8 present / 5 missing' in report
def test_tracker_renders_issue_rows_with_grounded_evidence(tmp_path: Path) -> None:
report = run_tracker(tmp_path)
assert '| #2 | [P0] Paperclips-style Project Chain System | open | present |' in report
assert '| #3 | [P0] Creative Compute (Quantum Burst System) | closed | present |' in report
assert '| #8 | [P1] Investment Engine Research Grants | open | missing |' in report
assert '| #14 | [P3] Paperclips Architecture Comparison Document | open | missing |' in report
assert 'js/data.js (`p_quantum_compute`, `Quantum-Inspired Compute`)' in report
assert 'README.md (`No build step required`, `static HTML/JS game`)' in report

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);
});

414
tests/utils.test.cjs Normal file
View File

@@ -0,0 +1,414 @@
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, '..');
// --- Minimal DOM mock for browser-dependent code ---
class Element {
constructor(tagName = 'div') {
this.tagName = String(tagName).toUpperCase();
this.id = '';
this.style = {};
this.children = [];
this.parentNode = null;
this.innerHTML = '';
this.textContent = '';
this.className = '';
this.classList = {
add: (...n) => { const s = new Set(this.className.split(/\s+/).filter(Boolean)); n.forEach(x => s.add(x)); this.className = [...s].join(' '); },
remove: (...n) => { const r = new Set(n); this.className = this.className.split(/\s+/).filter(x => !r.has(x)).join(' '); },
contains: (n) => this.className.split(/\s+/).includes(n),
toggle: (n, force) => { if (force === undefined) force = !this.classList.contains(n); if (force) this.classList.add(n); else this.classList.remove(n); }
};
this.attributes = {};
}
appendChild(c) { c.parentNode = this; this.children.push(c); return c; }
removeChild(c) { this.children = this.children.filter(x => x !== c); if (c.parentNode === this) c.parentNode = null; return c; }
remove() { if (this.parentNode) this.parentNode.removeChild(this); }
setAttribute(n, v) { this.attributes[n] = v; }
querySelector() { return null; }
querySelectorAll() { return []; }
closest(sel) { return null; }
}
function createDocument() {
const head = new Element('head');
const body = new Element('body');
return {
createElement: (tag) => new Element(tag),
getElementById: () => null,
querySelector: () => null,
querySelectorAll: () => [],
head, body,
addEventListener: () => {},
hidden: false
};
}
// Load utils.js in a VM sandbox
function loadUtils() {
const code = fs.readFileSync(path.join(ROOT, 'js', 'utils.js'), 'utf8');
const sandbox = {
console,
Math, Number, String, Object, Array, Set, Map, parseInt, isNaN, Infinity,
document: createDocument(),
window: { addEventListener: () => {} },
G: {
isLoading: false,
buyAmount: 1,
phase: 1,
codeBoost: 1,
buildings: { autocoder: 0 },
code: 0, compute: 0, knowledge: 0, trust: 0, ops: 0,
totalCode: 0, totalCompute: 0, totalKnowledge: 0, totalUsers: 0, totalImpact: 0,
deployFlag: 0, sovereignFlag: 0, pactFlag: 0
},
BDEF: [
{ id: 'autocoder', name: 'Auto-Code Generator', baseCost: { code: 15 }, costMult: 1.15, rates: { code: 1 }, unlock: () => true, phase: 1 },
{ id: 'server', name: 'Home Server', baseCost: { code: 750 }, costMult: 1.15, rates: { code: 20, compute: 1 }, unlock: () => true, phase: 1 },
{ id: 'evaluator', name: 'Eval Harness', baseCost: { knowledge: 3000, trust: 500 }, costMult: 1.15, rates: { trust: 1, ops: 1 }, unlock: () => true, phase: 2 }
]
};
vm.createContext(sandbox);
vm.runInContext(code, sandbox);
return sandbox;
}
// =============================================
// fmt() tests
// =============================================
test('fmt: returns 0 for null/undefined/NaN', () => {
const { fmt } = loadUtils();
assert.equal(fmt(null), '0');
assert.equal(fmt(undefined), '0');
assert.equal(fmt(NaN), '0');
});
test('fmt: returns infinity symbols', () => {
const { fmt } = loadUtils();
assert.equal(fmt(Infinity), '∞');
assert.equal(fmt(-Infinity), '-∞');
});
test('fmt: formats negative numbers with minus prefix', () => {
const { fmt } = loadUtils();
assert.equal(fmt(-500), '-500');
assert.equal(fmt(-1500), '-1.5K');
});
test('fmt: formats small numbers with locale string', () => {
const { fmt } = loadUtils();
assert.equal(fmt(0), '0');
assert.equal(fmt(42), '42');
assert.equal(fmt(999), '999');
assert.equal(fmt(12.7), '12'); // floors
});
test('fmt: abbreviates thousands through decillions', () => {
const { fmt } = loadUtils();
assert.equal(fmt(1000), '1.0K');
assert.equal(fmt(1500), '1.5K');
assert.equal(fmt(1000000), '1.0M');
assert.equal(fmt(2500000), '2.5M');
assert.equal(fmt(1000000000), '1.0B');
assert.equal(fmt(1000000000000), '1.0T');
assert.equal(fmt(1e15), '1.0Qa');
assert.equal(fmt(1e18), '1.0Qi');
assert.equal(fmt(1e21), '1.0Sx');
assert.equal(fmt(1e24), '1.0Sp');
assert.equal(fmt(1e27), '1.0Oc');
assert.equal(fmt(1e30), '1.0No');
assert.equal(fmt(1e33), '1.0Dc');
});
test('fmt: switches to spellf at undecillion (scale >= 12)', () => {
const { fmt } = loadUtils();
const result = fmt(1e36); // undecillion
assert.ok(result.includes('undecillion'), `Expected undecillion in "${result}"`);
});
test('fmt: handles mid-scale numbers correctly', () => {
const { fmt } = loadUtils();
assert.equal(fmt(42000), '42.0K');
assert.equal(fmt(999999), '1000.0K'); // just under 1M boundary
assert.equal(fmt(1234567890), '1.2B');
});
// =============================================
// spellf() tests
// =============================================
test('spellf: handles edge cases', () => {
const { spellf } = loadUtils();
assert.equal(spellf(null), 'zero');
assert.equal(spellf(undefined), 'zero');
assert.equal(spellf(NaN), 'zero');
assert.equal(spellf(Infinity), 'infinity');
assert.equal(spellf(-Infinity), 'negative infinity');
});
test('spellf: spells small numbers', () => {
const { spellf } = loadUtils();
assert.equal(spellf(0), 'zero');
assert.equal(spellf(1), 'one');
assert.equal(spellf(5), 'five');
assert.equal(spellf(10), 'ten');
assert.equal(spellf(13), 'thirteen');
assert.equal(spellf(20), 'twenty');
assert.equal(spellf(42), 'forty two');
assert.equal(spellf(100), 'one hundred');
assert.equal(spellf(999), 'nine hundred ninety nine');
});
test('spellf: spells thousands', () => {
const { spellf } = loadUtils();
assert.equal(spellf(1000), 'one thousand');
assert.equal(spellf(1500), 'one thousand five hundred');
assert.equal(spellf(10000), 'ten thousand');
assert.equal(spellf(100000), 'one hundred thousand');
});
test('spellf: spells millions and beyond', () => {
const { spellf } = loadUtils();
assert.equal(spellf(1000000), 'one million');
assert.equal(spellf(2500000), 'two million five hundred thousand');
assert.equal(spellf(1000000000), 'one billion');
assert.equal(spellf(1e12), 'one trillion');
});
test('spellf: handles negative numbers', () => {
const { spellf } = loadUtils();
assert.equal(spellf(-42), 'negative forty two');
assert.equal(spellf(-1000), 'negative one thousand');
});
test('spellf: spells large scales by name', () => {
const { spellf } = loadUtils();
assert.equal(spellf(1e33), 'one decillion');
assert.equal(spellf(1e36), 'one undecillion');
assert.equal(spellf(1e63), 'one vigintillion');
});
// =============================================
// getScaleName() tests
// =============================================
test('getScaleName: returns empty for small numbers', () => {
const { getScaleName } = loadUtils();
assert.equal(getScaleName(0), '');
assert.equal(getScaleName(999), '');
});
test('getScaleName: returns correct scale names', () => {
const { getScaleName } = loadUtils();
assert.equal(getScaleName(1000), 'thousand');
assert.equal(getScaleName(1000000), 'million');
assert.equal(getScaleName(1000000000), 'billion');
assert.equal(getScaleName(1e12), 'trillion');
assert.equal(getScaleName(1e15), 'quadrillion');
});
// =============================================
// getBuildingCost() tests
// =============================================
test('getBuildingCost: returns empty for unknown building', () => {
const s = loadUtils();
const cost = s.getBuildingCost('nonexistent');
assert.equal(Object.keys(cost).length, 0);
});
test('getBuildingCost: first purchase is base cost', () => {
const s = loadUtils();
s.G.buildings.autocoder = 0;
const cost = s.getBuildingCost('autocoder');
assert.equal(cost.code, 15);
});
test('getBuildingCost: scales with count using costMult', () => {
const s = loadUtils();
s.G.buildings.autocoder = 5;
const cost = s.getBuildingCost('autocoder');
// 15 * 1.15^5 = 15 * 2.011357... = 30.17... → floor = 30
assert.equal(cost.code, Math.floor(15 * Math.pow(1.15, 5)));
});
test('getBuildingCost: handles multi-resource costs', () => {
const s = loadUtils();
s.G.buildings.evaluator = 0;
const cost = s.getBuildingCost('evaluator');
assert.equal(cost.knowledge, 3000);
assert.equal(cost.trust, 500);
});
// =============================================
// canAffordBuilding() / spendBuilding() tests
// =============================================
test('canAffordBuilding: true when resources sufficient', () => {
const s = loadUtils();
s.G.code = 100;
assert.equal(s.canAffordBuilding('autocoder'), true);
});
test('canAffordBuilding: false when insufficient', () => {
const s = loadUtils();
s.G.code = 5;
assert.equal(s.canAffordBuilding('autocoder'), false);
});
test('canAffordBuilding: checks all required resources', () => {
const s = loadUtils();
s.G.knowledge = 5000;
s.G.trust = 0; // evaluator needs 500 trust
assert.equal(s.canAffordBuilding('evaluator'), false);
});
test('spendBuilding: deducts correct amount', () => {
const s = loadUtils();
s.G.code = 100;
s.G.buildings.autocoder = 0;
s.spendBuilding('autocoder');
assert.equal(s.G.code, 100 - 15);
});
// =============================================
// canAffordProject() / spendProject() tests
// =============================================
test('canAffordProject: checks project cost', () => {
const s = loadUtils();
s.G.trust = 50;
assert.equal(s.canAffordProject({ cost: { trust: 10 } }), true);
assert.equal(s.canAffordProject({ cost: { trust: 100 } }), false);
});
test('spendProject: deducts project cost', () => {
const s = loadUtils();
s.G.trust = 50;
s.G.code = 1000;
s.spendProject({ cost: { trust: 10, code: 500 } });
assert.equal(s.G.trust, 40);
assert.equal(s.G.code, 500);
});
// =============================================
// getMaxBuyable() tests
// =============================================
test('getMaxBuyable: returns 0 when cannot afford any', () => {
const s = loadUtils();
s.G.code = 5;
assert.equal(s.getMaxBuyable('autocoder'), 0);
});
test('getMaxBuyable: returns correct count for affordable range', () => {
const s = loadUtils();
s.G.code = 10000;
s.G.buildings.autocoder = 0;
const max = s.getMaxBuyable('autocoder');
assert.ok(max > 0, 'Should be able to buy at least one');
// Verify the cost matches getBulkCost
const bulkCost = s.getBuildingCost ? null : null; // just check the count is positive
});
test('getMaxBuyable: returns 0 for unknown building', () => {
const s = loadUtils();
assert.equal(s.getMaxBuyable('nonexistent'), 0);
});
// =============================================
// getBulkCost() tests
// =============================================
test('getBulkCost: returns empty for zero qty', () => {
const s = loadUtils();
const cost = s.getBulkCost('autocoder', 0);
assert.equal(Object.keys(cost).length, 0);
});
test('getBulkCost: returns empty for unknown building', () => {
const s = loadUtils();
const cost = s.getBulkCost('nonexistent', 3);
assert.equal(Object.keys(cost).length, 0);
});
test('getBulkCost: single purchase equals getBuildingCost', () => {
const s = loadUtils();
s.G.buildings.autocoder = 0;
const single = s.getBuildingCost('autocoder');
const bulk1 = s.getBulkCost('autocoder', 1);
assert.deepEqual(bulk1, single);
});
test('getBulkCost: cumulative cost for multiple purchases', () => {
const s = loadUtils();
s.G.buildings.autocoder = 0;
const bulk3 = s.getBulkCost('autocoder', 3);
// Manual: cost[0] = 15, cost[1] = floor(15*1.15) = 17, cost[2] = floor(15*1.15^2) = 19
const c0 = Math.floor(15 * Math.pow(1.15, 0));
const c1 = Math.floor(15 * Math.pow(1.15, 1));
const c2 = Math.floor(15 * Math.pow(1.15, 2));
assert.equal(bulk3.code, c0 + c1 + c2);
});
// =============================================
// getClickPower() tests
// =============================================
test('getClickPower: base is 1 with no buildings', () => {
const s = loadUtils();
s.G.buildings.autocoder = 0;
s.G.phase = 1;
s.G.codeBoost = 1;
assert.equal(s.getClickPower(), 1);
});
test('getClickPower: autocoder adds 0.5 per level (floored)', () => {
const s = loadUtils();
s.G.buildings.autocoder = 4;
s.G.phase = 1;
s.G.codeBoost = 1;
// (1 + floor(4 * 0.5) + 0) * 1 = (1 + 2 + 0) * 1 = 3
assert.equal(s.getClickPower(), 3);
});
test('getClickPower: phase adds 2 per level above 1', () => {
const s = loadUtils();
s.G.buildings.autocoder = 0;
s.G.phase = 3;
s.G.codeBoost = 1;
// (1 + 0 + (3-1)*2) * 1 = 5
assert.equal(s.getClickPower(), 5);
});
test('getClickPower: codeBoost multiplies result', () => {
const s = loadUtils();
s.G.buildings.autocoder = 0;
s.G.phase = 1;
s.G.codeBoost = 2.5;
// (1 + 0 + 0) * 2.5 = 2.5
assert.equal(s.getClickPower(), 2.5);
});
test('getClickPower: combined calculation', () => {
const s = loadUtils();
s.G.buildings.autocoder = 10;
s.G.phase = 4;
s.G.codeBoost = 1.5;
// (1 + floor(10*0.5) + (4-1)*2) * 1.5 = (1 + 5 + 6) * 1.5 = 18
assert.equal(s.getClickPower(), 18);
});
// =============================================
// showToast() tests
// =============================================
test('showToast: no-op when isLoading', () => {
const s = loadUtils();
s.G.isLoading = true;
// Should not throw even without DOM
s.showToast('test', 'info');
});
test('showToast: no-op when no container', () => {
const s = loadUtils();
s.G.isLoading = false;
// document.getElementById returns null in our mock
s.showToast('test', 'info');
});