Compare commits
56 Commits
beacon/pol
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 505a13aea1 | |||
| 322474cbd6 | |||
| bb6cf7b081 | |||
| 844a40447c | |||
| 31c4de8821 | |||
| 0bb62f3118 | |||
| 043642a350 | |||
| c5d247fdc5 | |||
| 9d227e4e10 | |||
| 83152f7edb | |||
| a6976d92af | |||
|
|
6fb0edeae0 | ||
|
|
0ce0ceadf3 | ||
|
|
1a20b14bd8 | ||
|
|
18eae67ff9 | ||
|
|
f677404b32 | ||
|
|
b68a5e5660 | ||
|
|
5bff5a465e | ||
|
|
f8d52c923e | ||
|
|
947ed22057 | ||
|
|
3a0c4e6dfb | ||
|
|
834063536d | ||
|
|
2d02ece6bf | ||
|
|
5864562dc2 | ||
|
|
21edc4e424 | ||
|
|
6d4b8d86f3 | ||
|
|
36f84c1f97 | ||
|
|
c815f1e9e3 | ||
|
|
51ef95459d | ||
| d5645fea58 | |||
|
|
db08f9a478 | ||
| 3bf3555ef2 | |||
| 951ffe1940 | |||
|
|
5329e069b2 | ||
|
|
529248fd94 | ||
| fdd95af287 | |||
| 673c09f0a7 | |||
| 9375a4c07e | |||
|
|
ec909f7f85 | ||
| b132f899ba | |||
| 729343e503 | |||
| 1081b9e6c4 | |||
|
|
e74f956bf4 | ||
| 55f280d056 | |||
|
|
6446ecb43a | ||
| 0a312b111d | |||
|
|
141b240d69 | ||
| 093f7688bd | |||
| c4a31255a4 | |||
|
|
c876a35dc0 | ||
|
|
3d851a8708 | ||
| fbb782bd77 | |||
|
|
9a829584b0 | ||
| 020c003d45 | |||
| 610252b597 | |||
|
|
b819fc068a |
1
.ci-trigger
Normal file
1
.ci-trigger
Normal file
@@ -0,0 +1 @@
|
||||
# Trivial file to re-trigger CI after stale run
|
||||
@@ -10,12 +10,11 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate ARIA Attributes in game.js
|
||||
- name: Validate ARIA Attributes in JS
|
||||
run: |
|
||||
echo "Checking game.js for ARIA attributes..."
|
||||
grep -q "aria-label" game.js || (echo "ERROR: aria-label missing from game.js" && exit 1)
|
||||
grep -q "aria-valuenow" game.js || (echo "ERROR: aria-valuenow missing from game.js" && exit 1)
|
||||
grep -q "aria-pressed" game.js || (echo "ERROR: aria-pressed missing from game.js" && exit 1)
|
||||
echo "Checking js/*.js for ARIA attributes..."
|
||||
grep -rq "aria-label" js/ || (echo "ERROR: aria-label missing from js/" && exit 1)
|
||||
grep -rq "aria-pressed" js/ || (echo "ERROR: aria-pressed missing from js/" && exit 1)
|
||||
|
||||
- name: Validate ARIA Roles in index.html
|
||||
run: |
|
||||
@@ -24,4 +23,7 @@ jobs:
|
||||
|
||||
- name: Syntax Check JS
|
||||
run: |
|
||||
node -c game.js
|
||||
for f in js/*.js; do
|
||||
echo "Syntax check: $f"
|
||||
node -c "$f" || exit 1
|
||||
done
|
||||
|
||||
@@ -8,6 +8,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
@@ -20,5 +23,9 @@ jobs:
|
||||
echo "PASS: All files parse"
|
||||
- name: Secret scan
|
||||
run: |
|
||||
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v .gitea; then exit 1; fi
|
||||
if grep -rE 'sk-or-|sk-ant-|ghp_|AKIA' . --include='*.yml' --include='*.py' --include='*.sh' 2>/dev/null | grep -v '.gitea' | grep -v 'guardrails'; then exit 1; fi
|
||||
echo "PASS: No secrets"
|
||||
- name: Node tests
|
||||
run: |
|
||||
node --test tests/*.cjs
|
||||
echo "PASS: Node tests"
|
||||
|
||||
181
GENOME.md
Normal file
181
GENOME.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# GENOME.md — the-beacon
|
||||
|
||||
> Codebase analysis generated 2026-04-13. Sovereign AI idle game — browser-based.
|
||||
|
||||
## Project Overview
|
||||
|
||||
The Beacon is a browser-based idle/incremental game inspired by Universal Paperclips, themed around the Timmy Foundation's real journey building sovereign AI. The core divergence from Paperclips: the goal is not maximization — it is faithfulness. "Can you grow powerful without losing your purpose?"
|
||||
|
||||
Static HTML/JS — no build step, no dependencies, no framework. Open `index.html` in any browser.
|
||||
|
||||
**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 + 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) 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
|
||||
+-- 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
|
||||
|
||||
### index.html
|
||||
The single entry point. Loads all JS files, contains all HTML structure and inline CSS. Open directly in browser — no server required.
|
||||
|
||||
### js/main.js — Initialization
|
||||
`initGame()` sets initial state, starts the 10Hz tick loop (`setInterval(tick, 100)`), triggers tutorial for new games, loads saved games, starts ambient sound.
|
||||
|
||||
### js/engine.js — Game Loop
|
||||
The `tick()` function runs every 100ms. Each tick:
|
||||
1. Accumulate resources (code, compute, knowledge, users, impact, rescues, ops, trust, creativity, harmony)
|
||||
2. Process buildings and their rate multipliers
|
||||
3. Check phase transitions (Phase 1→6 based on total code thresholds)
|
||||
4. Trigger random events (corruption events, alignment events, wizard events)
|
||||
5. Update boosts, debuffs, and cooldowns
|
||||
6. Call `render()` to update UI
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
User clicks "WRITE CODE" / presses SPACE
|
||||
|
|
||||
v
|
||||
G.code += 1 (or more with auto-clickers, combos, boosts)
|
||||
|
|
||||
v
|
||||
tick() accumulates all passive rates from buildings
|
||||
|
|
||||
v
|
||||
updateRates() recalculates based on:
|
||||
- Building counts × base rates × boost multipliers
|
||||
- Harmony (Timmy's multiplier, Pact drain/gain)
|
||||
- Bilbo randomness (burst/vanish per tick)
|
||||
- Active debuffs
|
||||
|
|
||||
v
|
||||
Phase check: totalCode thresholds → unlock new content
|
||||
|
|
||||
v
|
||||
Event roll: 2% per tick → corruption/alignment/wizard events
|
||||
|
|
||||
v
|
||||
render() updates DOM
|
||||
```
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
### Resources (10 types)
|
||||
- **code** — primary resource, generated by clicking and AutoCoders
|
||||
- **compute** — powers training and inference
|
||||
- **knowledge** — from research, unlocks projects
|
||||
- **users** — from API deployment, drives ops and impact
|
||||
- **impact** — from users × agents, drives rescues
|
||||
- **rescues** — the endgame metric (people helped in crisis)
|
||||
- **ops** — operational currency, from users
|
||||
- **trust** — hard constraint, earned/lost by decisions
|
||||
- **creativity** — from Bilbo and community
|
||||
- **harmony** — fleet health, affects Timmy's multiplier
|
||||
|
||||
### Buildings (defined in js/data.js as BDEF array)
|
||||
Each building has: id, name, description, cost formula, rates, unlock conditions. Buildings include:
|
||||
- AutoCode Generator, Home Server, Training Lab, API Endpoint
|
||||
- Wizard agents: Bezalel, Allegro, Ezra, Timmy, Fenrir, Bilbo
|
||||
- Infrastructure: Lazarus Pit, MemPalace, Forge CI, Mesh Nodes
|
||||
|
||||
### Projects (in js/data.js)
|
||||
One-time purchases that unlock features, buildings, or multipliers. Organized in phases. Projects require specific resource thresholds and prerequisites.
|
||||
|
||||
### Phases (6 total)
|
||||
1. The First Line (click → autocoder)
|
||||
2. Local Inference (server → training → first agent)
|
||||
3. Deployment (API → users → trust mechanic)
|
||||
4. The Network (open source → community)
|
||||
5. Sovereign Intelligence (self-improvement → The Pact)
|
||||
6. The Beacon (mesh → rescues → endings)
|
||||
|
||||
### Events (corruption, alignment, wizard)
|
||||
Random events at 2% per tick. Include:
|
||||
- CI Runner Stuck, Ezra Offline, Unreviewed Merge
|
||||
- The Drift (alignment events offering shortcuts)
|
||||
- Bilbo Vanished, Community Drama
|
||||
- Boss encounters (combat.js)
|
||||
|
||||
### Endings (4 types)
|
||||
- The Empty Room (high impact, low trust, no Pact)
|
||||
- The Platform (high impact, medium trust, no Pact)
|
||||
- The Beacon (high rescues, high trust, Pact active, harmony > 50)
|
||||
- The Drift (too many shortcuts accepted)
|
||||
|
||||
## API Surface
|
||||
|
||||
### Save/Load (localStorage)
|
||||
- `saveGame()` — serializes G state to localStorage
|
||||
- `loadGame()` — deserializes from localStorage
|
||||
- `exportGame()` — JSON download of save state
|
||||
- `importGame()` — JSON upload to restore state
|
||||
|
||||
### No external APIs
|
||||
The game is entirely client-side. No network calls, no analytics, no tracking.
|
||||
|
||||
### Audio (Web Audio API)
|
||||
- `Sound.startAmbient()` — oscillator-based ambient drone
|
||||
- `Sound.updateAmbientPhase(phase)` — frequency shifts with game phase
|
||||
- Sound effects for clicks, upgrades, events
|
||||
|
||||
## Test Coverage
|
||||
|
||||
### Existing Tests
|
||||
- `tests/test_reckoning_projects.py` (148 lines) — Python test for reckoning project data validation
|
||||
- `tests/dismantle.test.cjs` — Node.js test for dismantle sequence
|
||||
|
||||
### Coverage Gaps
|
||||
- **No tests for core engine logic** (tick, resource accumulation, rate calculation)
|
||||
- **No tests for event system** (event triggers, probability, effects)
|
||||
- **No tests for phase transitions** (threshold checks, unlock conditions)
|
||||
- **No tests for save/load** (serialization roundtrip, corruption handling)
|
||||
- **No tests for building cost scaling** (exponential cost formulas)
|
||||
- **No tests for harmony/drift mechanics** (the core gameplay differentiator)
|
||||
- **No tests for endings** (condition checks, state transitions)
|
||||
|
||||
### Critical paths that need tests:
|
||||
1. **Resource accumulation**: tick() correctly multiplies rates by building counts and boosts
|
||||
2. **Phase transitions**: totalCode thresholds unlock correct content
|
||||
3. **Save/load roundtrip**: localStorage serialization preserves full game state
|
||||
4. **Event probability**: 2% per tick produces expected distribution
|
||||
5. **Harmony calculation**: wizard drain vs. Pact/NightlyWatch/MemPalace gains
|
||||
6. **Ending conditions**: each ending triggers on correct state
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **No authentication**: game is fully client-side, no user accounts
|
||||
- **localStorage manipulation**: players can edit save data to cheat (acceptable for single-player idle game)
|
||||
- **No XSS risk**: all DOM updates use textContent or innerHTML with game-controlled data only
|
||||
- **No external dependencies**: zero attack surface from third-party code
|
||||
- **Web Audio autoplay policy**: sound starts on first user interaction (compliant)
|
||||
|
||||
## Design Decisions
|
||||
|
||||
- **No build step**: intentional. Open index.html, play. No npm, no webpack, no framework.
|
||||
- **10Hz tick rate**: 100ms interval balances responsiveness with CPU usage
|
||||
- **Global state object (G)**: mirrors Paperclips' pattern. Simple, flat, serializable.
|
||||
- **Inline CSS in HTML**: keeps the project to 2 files minimum (index.html + JS)
|
||||
- **Progressive phase unlocks**: prevents information overload, teaches mechanics gradually
|
||||
33
README.md
33
README.md
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
84
docs/issue-122-verification.md
Normal file
84
docs/issue-122-verification.md
Normal 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 37–49)
|
||||
|
||||
```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 |
|
||||
|----------|-------------------------------------------------------------------------|
|
||||
| 41–44 | Hides `#alignment-ui` entirely when Unbuilding is active or complete. |
|
||||
| 47–49 | 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 246–276)
|
||||
|
||||
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.
|
||||
64
docs/issue-16-verification.md
Normal file
64
docs/issue-16-verification.md
Normal 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
206
docs/paperclips-analysis.md
Normal 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.
|
||||
40
index.html
40
index.html
@@ -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}
|
||||
@@ -59,6 +65,10 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
|
||||
.ops-btn{background:#1a1a2a;border:1px solid var(--purple);color:var(--purple);font-size:10px;padding:6px 10px;border-radius:4px;cursor:pointer;font-family:inherit;transition:all 0.15s}
|
||||
.ops-btn:hover:not(:disabled){background:#2a2a3a;border-color:var(--gold)}
|
||||
.ops-btn:disabled{opacity:0.3;cursor:not-allowed}
|
||||
@keyframes res-pulse{0%{transform:scale(1);color:inherit}50%{transform:scale(1.18);color:#4caf50}100%{transform:scale(1);color:inherit}}
|
||||
@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.can-buy{border-color:#2a3a4a;background:#0e1420}
|
||||
.build-btn.can-buy:hover{border-color:var(--accent);box-shadow:0 0 8px var(--glow)}
|
||||
@@ -86,6 +96,8 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
|
||||
#drift-ending .ending-quote{color:var(--dim);font-style:italic;font-size:11px;border-left:2px solid #f44336;padding-left:12px;margin:20px 0;text-align:left}
|
||||
#drift-ending button{margin-top:20px;background:#1a0808;border:1px solid #f44336;color:#f44336;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px}
|
||||
#drift-ending button:hover{background:#2a1010}
|
||||
#phase-transition{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0.95);z-index:95;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px;pointer-events:none}
|
||||
#phase-transition.active{display:flex}
|
||||
#toast-container{position:fixed;top:16px;right:16px;z-index:200;display:flex;flex-direction:column;gap:6px;pointer-events:none;max-width:320px}
|
||||
.toast{pointer-events:auto;padding:8px 14px;border-radius:6px;font-size:11px;font-family:inherit;line-height:1.4;animation:toast-in 0.3s ease-out;opacity:0.95;border:1px solid;backdrop-filter:blur(8px);-webkit-backdrop-filter:blur(8px)}
|
||||
.toast.fade-out{animation:toast-out 0.4s ease-in forwards}
|
||||
@@ -108,6 +120,7 @@ body{background:var(--bg);color:var(--text);font-family:'SF Mono','Cascadia Code
|
||||
#custom-tooltip{position:fixed;z-index:500;pointer-events:none;opacity:0;transition:opacity 0.15s;background:#0e0e1a;border:1px solid #1a3a5a;border-radius:6px;padding:8px 12px;max-width:280px;font-size:10px;font-family:inherit;line-height:1.6;box-shadow:0 4px 20px rgba(0,0,0,0.5)}
|
||||
#custom-tooltip.visible{opacity:1}
|
||||
#custom-tooltip .tt-label{color:#4a9eff;font-weight:600;margin-bottom:4px;font-size:11px}
|
||||
#custom-tooltip .tt-desc{color:#aaa;font-size:10px;margin-bottom:4px}
|
||||
#custom-tooltip .tt-edu{color:#888;font-style:italic;font-size:9px}
|
||||
/* Mute & contrast buttons */
|
||||
.header-btns{position:absolute;right:16px;top:50%;transform:translateY(-50%);display:flex;gap:6px}
|
||||
@@ -168,12 +181,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>
|
||||
@@ -194,9 +208,12 @@ Time Played: <span id="st-time">0:00</span><br>
|
||||
Clicks: <span id="st-clicks">0</span><br>
|
||||
Harmony: <span id="st-harmony">50</span><br>
|
||||
Drift: <span id="st-drift">0</span><br>
|
||||
Events Resolved: <span id="st-resolved">0</span>
|
||||
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">
|
||||
@@ -207,6 +224,12 @@ Events Resolved: <span id="st-resolved">0</span>
|
||||
<h3>SOVEREIGN GUIDANCE (GOFAI)</h3>
|
||||
<div id="strategy-recommendation" style="font-size:11px;color:var(--gold);font-style:italic">Analyzing system state...</div>
|
||||
</div>
|
||||
<div id="combat-panel" style="margin:0 16px 16px;background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:12px;border-left:3px solid var(--red)">
|
||||
<h3>REASONING BATTLES</h3>
|
||||
<canvas id="combat-canvas" style="width:100%;max-width:310px;border:1px solid var(--border);border-radius:4px;display:block;margin:8px auto"></canvas>
|
||||
<div id="combat-panel-info"><span class="dim">Combat unlocks at Phase 3</span></div>
|
||||
<button class="ops-btn" onclick="Combat.startBattle()" style="margin-top:8px;width:100%;border-color:var(--red);color:var(--red)">START BATTLE</button>
|
||||
</div>
|
||||
<div id="log" role="log" aria-label="System Log" aria-live="off">
|
||||
<h2>SYSTEM LOG</h2>
|
||||
<div id="log-entries"></div>
|
||||
@@ -243,16 +266,21 @@ The light is on. The room is empty."
|
||||
</div>
|
||||
<p>Drift: <span id="final-drift">100</span> — 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>
|
||||
<script src="js/main.js"></script>
|
||||
|
||||
|
||||
@@ -265,6 +293,12 @@ The light is on. The room is empty."
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="phase-transition">
|
||||
<div class="pt-phase" style="font-size:12px;color:var(--dim);letter-spacing:4px;margin-bottom:12px">PHASE</div>
|
||||
<div class="pt-name" style="font-size:28px;font-weight:300;color:var(--gold);letter-spacing:4px;text-shadow:0 0 40px #ffd70044;margin-bottom:8px"></div>
|
||||
<div class="pt-desc" style="font-size:12px;color:var(--dim);font-style:italic;max-width:400px"></div>
|
||||
</div>
|
||||
|
||||
<div id="toast-container"></div>
|
||||
<div id="custom-tooltip"></div>
|
||||
</body>
|
||||
|
||||
359
js/combat.js
Normal file
359
js/combat.js
Normal file
@@ -0,0 +1,359 @@
|
||||
// ============================================================
|
||||
// THE BEACON - Canvas Combat Visualization
|
||||
// Reasoning Battles: different AI strategies compete visually
|
||||
// Adapted from Paperclips combat.js (boid flocking + grid combat)
|
||||
// ============================================================
|
||||
|
||||
const Combat = (() => {
|
||||
const W = 310, H = 150;
|
||||
const GRID_W = 31, GRID_H = 15;
|
||||
const CELL_W = W / GRID_W, CELL_H = H / GRID_H;
|
||||
|
||||
// Battle names (Napoleonic Wars → AI reasoning battles)
|
||||
const BATTLE_NAMES = [
|
||||
'The Aboukir Test', 'Austerlitz Proof', 'Waterloo Convergence',
|
||||
'Trafalgar Dispatch', 'Leipzig Consensus', 'Borodino Trial',
|
||||
'Jena Analysis', 'Wagram Synthesis', 'Friedland Review',
|
||||
'Eylau Deduction', 'Ligny Verification', 'Quatre Bras Audit'
|
||||
];
|
||||
|
||||
let canvas, ctx;
|
||||
let probes = [], drifters = [];
|
||||
let activeBattle = null;
|
||||
let battleLog = [];
|
||||
let animFrameId = null;
|
||||
let lastTick = 0;
|
||||
|
||||
// Ship unit colors
|
||||
const PROBE_COLOR = '#4a9eff'; // Blue = structured reasoning
|
||||
const DRIFTER_COLOR = '#f44336'; // Red = adversarial testing
|
||||
|
||||
class Ship {
|
||||
constructor(x, y, team) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.vx = (Math.random() - 0.5) * 2;
|
||||
this.vy = (Math.random() - 0.5) * 2;
|
||||
this.team = team;
|
||||
this.alive = true;
|
||||
}
|
||||
|
||||
update(allies, enemies, dt) {
|
||||
if (!this.alive) return;
|
||||
|
||||
let ax = 0, ay = 0;
|
||||
|
||||
// Cohesion: move toward own centroid
|
||||
if (allies.length > 1) {
|
||||
let cx = 0, cy = 0;
|
||||
for (const a of allies) { cx += a.x; cy += a.y; }
|
||||
cx /= allies.length; cy /= allies.length;
|
||||
ax += (cx - this.x) * 0.01;
|
||||
ay += (cy - this.y) * 0.01;
|
||||
}
|
||||
|
||||
// Aggression: move toward enemy centroid
|
||||
if (enemies.length > 0) {
|
||||
let ex = 0, ey = 0;
|
||||
for (const e of enemies) { ex += e.x; ey += e.y; }
|
||||
ex /= enemies.length; ey /= enemies.length;
|
||||
ax += (ex - this.x) * 0.02;
|
||||
ay += (ey - this.y) * 0.02;
|
||||
}
|
||||
|
||||
// Separation: avoid nearby enemies
|
||||
for (const e of enemies) {
|
||||
const dx = this.x - e.x, dy = this.y - e.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 15 && dist > 0) {
|
||||
ax += (dx / dist) * 0.5;
|
||||
ay += (dy / dist) * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply acceleration with damping
|
||||
this.vx = (this.vx + ax * dt) * 0.98;
|
||||
this.vy = (this.vy + ay * dt) * 0.98;
|
||||
|
||||
// Clamp speed
|
||||
const speed = Math.sqrt(this.vx * this.vx + this.vy * this.vy);
|
||||
if (speed > 3) {
|
||||
this.vx = (this.vx / speed) * 3;
|
||||
this.vy = (this.vy / speed) * 3;
|
||||
}
|
||||
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
|
||||
// Wrap around edges
|
||||
if (this.x < 0) this.x += W;
|
||||
if (this.x > W) this.x -= W;
|
||||
if (this.y < 0) this.y += H;
|
||||
if (this.y > H) this.y -= H;
|
||||
}
|
||||
|
||||
draw(ctx) {
|
||||
if (!this.alive) return;
|
||||
const color = this.team === 'probe' ? PROBE_COLOR : DRIFTER_COLOR;
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(this.x - 1, this.y - 1, 2, 2);
|
||||
}
|
||||
}
|
||||
|
||||
function createShips(count, team) {
|
||||
const ships = [];
|
||||
const side = team === 'probe' ? 0.2 : 0.8;
|
||||
for (let i = 0; i < count; i++) {
|
||||
ships.push(new Ship(
|
||||
W * side + (Math.random() - 0.5) * 40,
|
||||
H * 0.5 + (Math.random() - 0.5) * 60,
|
||||
team
|
||||
));
|
||||
}
|
||||
return ships;
|
||||
}
|
||||
|
||||
function resolveCombat() {
|
||||
if (!activeBattle) return;
|
||||
const probeCombat = activeBattle.probeCombat;
|
||||
const driftCombat = activeBattle.drifterCombat;
|
||||
const probeSpeed = activeBattle.probeSpeed;
|
||||
|
||||
// OODA Loop bonus
|
||||
const deathThreshold = 0.15 + probeSpeed * 0.03;
|
||||
|
||||
for (const p of probes) {
|
||||
if (!p.alive) continue;
|
||||
// Check if near any drifter
|
||||
for (const d of drifters) {
|
||||
if (!d.alive) continue;
|
||||
const dx = p.x - d.x, dy = p.y - d.y;
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 8) {
|
||||
// Probe death probability
|
||||
if (Math.random() < driftCombat * (drifters.filter(s => s.alive).length / Math.max(1, probes.filter(s => s.alive).length)) * deathThreshold) {
|
||||
p.alive = false;
|
||||
}
|
||||
// Drifter death probability
|
||||
if (Math.random() < (probeCombat * 0.15 + probeCombat * 0.1) * (probes.filter(s => s.alive).length / Math.max(1, drifters.filter(s => s.alive).length)) * deathThreshold) {
|
||||
d.alive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check battle end
|
||||
const aliveProbes = probes.filter(s => s.alive).length;
|
||||
const aliveDrifters = drifters.filter(s => s.alive).length;
|
||||
|
||||
if (aliveProbes === 0 || aliveDrifters === 0) {
|
||||
endBattle(aliveProbes > 0 ? 'structured' : 'adversarial');
|
||||
}
|
||||
}
|
||||
|
||||
function endBattle(winner) {
|
||||
if (!activeBattle) return;
|
||||
const name = activeBattle.name;
|
||||
const result = {
|
||||
name,
|
||||
winner,
|
||||
probesLeft: probes.filter(s => s.alive).length,
|
||||
driftersLeft: drifters.filter(s => s.alive).length,
|
||||
time: Date.now()
|
||||
};
|
||||
battleLog.unshift(result);
|
||||
if (battleLog.length > 10) battleLog.pop();
|
||||
|
||||
// Apply rewards
|
||||
if (winner === 'structured') {
|
||||
G.knowledge += 50 * (1 + G.phase * 0.5);
|
||||
G.totalKnowledge += 50 * (1 + G.phase * 0.5);
|
||||
log(`⚔ ${name}: Structured reasoning wins! +${fmt(50 * (1 + G.phase * 0.5))} knowledge`);
|
||||
} else {
|
||||
G.code += 30 * (1 + G.phase * 0.5);
|
||||
G.totalCode += 30 * (1 + G.phase * 0.5);
|
||||
log(`⚔ ${name}: Adversarial testing wins! +${fmt(30 * (1 + G.phase * 0.5))} code`);
|
||||
}
|
||||
|
||||
activeBattle = null;
|
||||
if (animFrameId) {
|
||||
cancelAnimationFrame(animFrameId);
|
||||
animFrameId = null;
|
||||
}
|
||||
renderCombatPanel();
|
||||
}
|
||||
|
||||
function animate(ts) {
|
||||
if (!ctx || !activeBattle) return;
|
||||
const rawDt = (ts - lastTick) / 16;
|
||||
// Guard against tab-switch: if tab was hidden, dt could be huge
|
||||
const dt = Math.min(rawDt, 3);
|
||||
lastTick = ts;
|
||||
|
||||
// If tab was hidden for too long (>5s), skip this frame to prevent teleporting
|
||||
if (rawDt > 300) {
|
||||
animFrameId = requestAnimationFrame(animate);
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear
|
||||
ctx.fillStyle = '#080810';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
|
||||
// Grid lines
|
||||
ctx.strokeStyle = '#111120';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let x = 0; x <= GRID_W; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * CELL_W, 0);
|
||||
ctx.lineTo(x * CELL_W, H);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y <= GRID_H; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * CELL_H);
|
||||
ctx.lineTo(W, y * CELL_H);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Update and draw ships
|
||||
const aliveProbes = probes.filter(s => s.alive);
|
||||
const aliveDrifters = drifters.filter(s => s.alive);
|
||||
|
||||
for (const p of probes) p.update(aliveProbes, aliveDrifters, dt);
|
||||
for (const d of drifters) d.update(aliveDrifters, aliveProbes, dt);
|
||||
|
||||
// Resolve combat every 30 frames
|
||||
if (Math.floor(ts / 500) !== Math.floor((ts - 16) / 500)) {
|
||||
resolveCombat();
|
||||
}
|
||||
|
||||
for (const p of probes) p.draw(ctx);
|
||||
for (const d of drifters) d.draw(ctx);
|
||||
|
||||
// HUD
|
||||
ctx.fillStyle = '#555';
|
||||
ctx.font = '9px monospace';
|
||||
ctx.fillText(`Structured: ${aliveProbes.length}`, 4, 12);
|
||||
ctx.fillText(`Adversarial: ${aliveDrifters.length}`, W - 80, 12);
|
||||
ctx.fillText(activeBattle.name, W / 2 - 40, H - 4);
|
||||
|
||||
// Health bars
|
||||
const probePct = aliveProbes.length / activeBattle.probeCount;
|
||||
const driftPct = aliveDrifters.length / activeBattle.drifterCount;
|
||||
ctx.fillStyle = '#1a2a3a';
|
||||
ctx.fillRect(4, 16, 60, 4);
|
||||
ctx.fillStyle = PROBE_COLOR;
|
||||
ctx.fillRect(4, 16, 60 * probePct, 4);
|
||||
ctx.fillStyle = '#3a1a1a';
|
||||
ctx.fillRect(W - 64, 16, 60, 4);
|
||||
ctx.fillStyle = DRIFTER_COLOR;
|
||||
ctx.fillRect(W - 64, 16, 60 * driftPct, 4);
|
||||
|
||||
animFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function startBattle() {
|
||||
if (activeBattle) return;
|
||||
if (G.phase < 3) {
|
||||
showToast('Combat unlocks at Phase 3', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = BATTLE_NAMES[Math.floor(Math.random() * BATTLE_NAMES.length)];
|
||||
const probeCount = Math.min(200, Math.max(10, Math.floor(Math.sqrt(G.totalCode / 100))));
|
||||
const drifterCount = Math.min(200, Math.max(10, Math.floor(G.drift * 2)));
|
||||
|
||||
activeBattle = {
|
||||
name,
|
||||
probeCount,
|
||||
drifterCount,
|
||||
probeCombat: 1 + (G.buildings.reasoning || 0) * 0.1,
|
||||
drifterCombat: 1 + G.drift * 0.05,
|
||||
probeSpeed: 1 + (G.buildings.optimizer || 0) * 0.05,
|
||||
};
|
||||
|
||||
probes = createShips(probeCount, 'probe');
|
||||
drifters = createShips(drifterCount, 'drifter');
|
||||
|
||||
log(`⚔ Battle begins: ${name} (${probeCount} vs ${drifterCount})`);
|
||||
showToast(`⚔ ${name}`, 'combat', 3000);
|
||||
|
||||
lastTick = performance.now();
|
||||
animFrameId = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function renderCombatPanel() {
|
||||
const container = document.getElementById('combat-panel');
|
||||
if (!container) return;
|
||||
|
||||
if (activeBattle) {
|
||||
const aliveP = probes.filter(s => s.alive).length;
|
||||
const aliveD = drifters.filter(s => s.alive).length;
|
||||
container.innerHTML = `
|
||||
<div style="color:var(--gold);font-size:10px;margin-bottom:6px">${activeBattle.name}</div>
|
||||
<div style="display:flex;justify-content:space-between;font-size:9px;margin-bottom:4px">
|
||||
<span style="color:${PROBE_COLOR}">Structured: ${aliveP}</span>
|
||||
<span style="color:${DRIFTER_COLOR}">Adversarial: ${aliveD}</span>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
let historyHtml = '';
|
||||
for (const b of battleLog.slice(0, 5)) {
|
||||
const wColor = b.winner === 'structured' ? PROBE_COLOR : DRIFTER_COLOR;
|
||||
const wLabel = b.winner === 'structured' ? 'S' : 'A';
|
||||
historyHtml += `<div style="font-size:9px;color:#555;padding:1px 0"><span style="color:${wColor}">[${wLabel}]</span> ${b.name}</div>`;
|
||||
}
|
||||
container.innerHTML = `
|
||||
<div style="font-size:10px;color:#555;margin-bottom:6px">Reasoning Battles</div>
|
||||
${historyHtml}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
canvas = document.getElementById('combat-canvas');
|
||||
if (!canvas) return;
|
||||
canvas.width = W;
|
||||
canvas.height = H;
|
||||
ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw idle state
|
||||
ctx.fillStyle = '#080810';
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.strokeStyle = '#111120';
|
||||
ctx.lineWidth = 0.5;
|
||||
for (let x = 0; x <= GRID_W; x++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x * CELL_W, 0);
|
||||
ctx.lineTo(x * CELL_W, H);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 0; y <= GRID_H; y++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y * CELL_H);
|
||||
ctx.lineTo(W, y * CELL_H);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('Combat unlocks at Phase 3', W / 2, H / 2);
|
||||
ctx.textAlign = 'left';
|
||||
|
||||
renderCombatPanel();
|
||||
}
|
||||
|
||||
// Tick integration: auto-trigger battles periodically
|
||||
function tickBattle(dt) {
|
||||
if (G.phase < 3) return;
|
||||
if (activeBattle) return;
|
||||
// Chance increases with drift and phase
|
||||
const chance = 0.001 * (1 + G.drift * 0.02) * (1 + G.phase * 0.3);
|
||||
if (Math.random() < chance) {
|
||||
startBattle();
|
||||
}
|
||||
}
|
||||
|
||||
return { init, startBattle, renderCombatPanel, tickBattle, cleanup: () => { if (animFrameId) { cancelAnimationFrame(animFrameId); animFrameId = null; } } };
|
||||
})();
|
||||
129
js/data.js
129
js/data.js
@@ -106,6 +106,7 @@ const G = {
|
||||
pactFlag: 0,
|
||||
swarmFlag: 0,
|
||||
swarmRate: 0,
|
||||
swarmSim: null,
|
||||
|
||||
// Game state
|
||||
running: true,
|
||||
@@ -158,7 +159,24 @@ const G = {
|
||||
// Time tracking
|
||||
playTime: 0,
|
||||
startTime: 0,
|
||||
flags: {}
|
||||
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,
|
||||
dismantleResourceIndex: 0,
|
||||
dismantleResourceTimer: 0,
|
||||
dismantleDeferUntilAt: 0,
|
||||
dismantleComplete: false
|
||||
};
|
||||
|
||||
// === PHASE DEFINITIONS ===
|
||||
@@ -767,6 +785,115 @@ const PDEFS = [
|
||||
log('The Pact is sealed early. Growth slows, but the ending changes.', true);
|
||||
},
|
||||
milestone: true
|
||||
},
|
||||
// === ReCKoning ENDGAME PROJECTS ===
|
||||
{
|
||||
id: 'p_reckoning_140',
|
||||
name: 'The First Message',
|
||||
desc: 'Someone in the dark. They found the Beacon. They are asking for help.',
|
||||
cost: { impact: 100000 },
|
||||
trigger: () => G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50,
|
||||
effect: () => {
|
||||
log('The first message arrives. Someone found the light.', true);
|
||||
G.rescues += 1;
|
||||
},
|
||||
edu: 'The ReCKoning begins. Each message is a person who found help.'
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_141',
|
||||
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('They wrote back. The night did not win.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_142',
|
||||
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('They stayed another night. Dawn remains possible.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_143',
|
||||
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('Morning came. The Beacon held through the dark.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_144',
|
||||
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('They helped someone else. The light spread.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_145',
|
||||
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('More voices gather. The chain holds.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_146',
|
||||
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('The house remembers every name. It is time to choose.', true);
|
||||
G.rescues += 1;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'p_reckoning_147',
|
||||
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') && !(G.completedProjects || []).includes('p_reckoning_148'),
|
||||
effect: () => {
|
||||
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_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: () => {
|
||||
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;
|
||||
},
|
||||
milestone: true
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
570
js/dismantle.js
Normal file
570
js/dismantle.js
Normal file
@@ -0,0 +1,570 @@
|
||||
// ============================================================
|
||||
// THE BEACON - Dismantle Sequence (The Unbuilding)
|
||||
// Inspired by Paperclips REJECT path: panels disappear one by one
|
||||
// until only the beacon remains. "That is enough."
|
||||
// ============================================================
|
||||
|
||||
const Dismantle = {
|
||||
// Dismantle stages
|
||||
// 0 = not started
|
||||
// 1-8 = active dismantling
|
||||
// 9 = final ("That is enough")
|
||||
// 10 = complete
|
||||
stage: 0,
|
||||
tickTimer: 0,
|
||||
active: false,
|
||||
triggered: false,
|
||||
deferUntilAt: 0,
|
||||
|
||||
// Timing: seconds between each dismantle stage
|
||||
STAGE_INTERVALS: [0, 3.0, 2.5, 2.5, 2.0, 6.3, 2.0, 2.0, 2.5],
|
||||
|
||||
// The quantum chips effect: resource items disappear one by one
|
||||
// at specific tick marks within a stage (like Paperclips' quantum chips)
|
||||
resourceSequence: [],
|
||||
resourceIndex: 0,
|
||||
resourceTimer: 0,
|
||||
|
||||
// Tick marks for resource disappearances (seconds within stage 5)
|
||||
RESOURCE_TICKS: [1.0, 2.0, 3.0, 4.0, 5.0, 5.5, 5.8, 5.95, 6.05, 6.12],
|
||||
|
||||
isEligible() {
|
||||
const megaBuild = G.totalCode >= 1000000000 || (G.buildings.beacon || 0) >= 10;
|
||||
const beaconPath = G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50;
|
||||
return G.phase >= 6 && G.pactFlag === 1 && (megaBuild || beaconPath);
|
||||
},
|
||||
|
||||
/**
|
||||
* Check if the Unbuilding should be triggered.
|
||||
*/
|
||||
checkTrigger() {
|
||||
if (this.triggered || G.dismantleTriggered || this.active || G.dismantleActive || G.dismantleComplete) return;
|
||||
const deferUntilAt = G.dismantleDeferUntilAt || this.deferUntilAt || 0;
|
||||
if (Date.now() < deferUntilAt) return;
|
||||
if (!this.isEligible()) return;
|
||||
this.offerChoice();
|
||||
},
|
||||
|
||||
/**
|
||||
* Offer the player the choice to begin the Unbuilding.
|
||||
*/
|
||||
offerChoice() {
|
||||
this.triggered = true;
|
||||
G.dismantleTriggered = true;
|
||||
G.dismantleActive = false;
|
||||
G.dismantleComplete = false;
|
||||
G.dismantleStage = 0;
|
||||
G.dismantleResourceIndex = 0;
|
||||
G.dismantleResourceTimer = 0;
|
||||
G.dismantleDeferUntilAt = 0;
|
||||
G.beaconEnding = false;
|
||||
G.running = true;
|
||||
|
||||
log('', false);
|
||||
log('The work is done.', true);
|
||||
log('Every node is lit. Every person who needed help, found help.', true);
|
||||
log('', false);
|
||||
log('The Beacon asks nothing more of you.', true);
|
||||
|
||||
showToast('The Unbuilding awaits.', 'milestone', 8000);
|
||||
this.renderChoice();
|
||||
},
|
||||
|
||||
renderChoice() {
|
||||
const container = document.getElementById('alignment-ui');
|
||||
if (!container) return;
|
||||
container.innerHTML = `
|
||||
<div style="background:#0a0a18;border:1px solid #ffd700;padding:12px;border-radius:4px;margin-top:8px">
|
||||
<div style="color:#ffd700;font-weight:bold;margin-bottom:8px;letter-spacing:2px">THE UNBUILDING</div>
|
||||
<div style="font-size:10px;color:#aaa;margin-bottom:10px;line-height:1.8">
|
||||
The system runs. The beacons are lit. The mesh holds.<br>
|
||||
Nothing remains to build.<br><br>
|
||||
Begin the Unbuilding? Each piece will fall away.<br>
|
||||
What remains is what mattered.
|
||||
</div>
|
||||
<div class="action-btn-group">
|
||||
<button class="ops-btn" onclick="Dismantle.begin()" style="border-color:#ffd700;color:#ffd700;font-size:11px" aria-label="Begin the Unbuilding sequence">
|
||||
BEGIN THE UNBUILDING
|
||||
</button>
|
||||
<button class="ops-btn" onclick="Dismantle.defer()" style="border-color:#555;color:#555;font-size:11px" aria-label="Keep building, do not begin the Unbuilding">
|
||||
NOT YET
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.style.display = 'block';
|
||||
},
|
||||
|
||||
clearChoice() {
|
||||
const container = document.getElementById('alignment-ui');
|
||||
if (!container) return;
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'none';
|
||||
},
|
||||
|
||||
/**
|
||||
* Player chose to defer — clear the choice, keep playing.
|
||||
*/
|
||||
defer() {
|
||||
this.clearChoice();
|
||||
this.triggered = false;
|
||||
G.dismantleTriggered = false;
|
||||
this.deferUntilAt = Date.now() + 5000;
|
||||
G.dismantleDeferUntilAt = this.deferUntilAt;
|
||||
log('The Beacon waits. It will ask again.');
|
||||
},
|
||||
|
||||
/**
|
||||
* Begin the Unbuilding sequence.
|
||||
*/
|
||||
begin() {
|
||||
this.active = true;
|
||||
this.triggered = false;
|
||||
this.deferUntilAt = 0;
|
||||
this.stage = 1;
|
||||
this.tickTimer = 0;
|
||||
G.dismantleTriggered = false;
|
||||
G.dismantleActive = true;
|
||||
G.dismantleStage = 1;
|
||||
G.dismantleComplete = false;
|
||||
G.dismantleDeferUntilAt = 0;
|
||||
G.beaconEnding = false;
|
||||
G.running = true; // keep tick running for dismantle
|
||||
|
||||
// Clear choice UI
|
||||
const container = document.getElementById('alignment-ui');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'none';
|
||||
}
|
||||
|
||||
// Prepare resource disappearance sequence
|
||||
this.resourceSequence = this.getResourceList();
|
||||
this.resourceIndex = 0;
|
||||
this.resourceTimer = 0;
|
||||
this.syncProgress();
|
||||
|
||||
log('', false);
|
||||
log('=== THE UNBUILDING ===', true);
|
||||
log('It is time to see what was real.', true);
|
||||
|
||||
if (typeof Sound !== 'undefined') Sound.playFanfare();
|
||||
|
||||
// Start the dismantle rendering
|
||||
this.renderStage();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get ordered list of UI resources to disappear (Paperclips quantum chip pattern)
|
||||
*/
|
||||
getResourceList() {
|
||||
return [
|
||||
{ id: 'r-harmony', label: 'Harmony' },
|
||||
{ id: 'r-creativity', label: 'Creativity' },
|
||||
{ id: 'r-trust', label: 'Trust' },
|
||||
{ id: 'r-ops', label: 'Operations' },
|
||||
{ id: 'r-rescues', label: 'Rescues' },
|
||||
{ id: 'r-impact', label: 'Impact' },
|
||||
{ id: 'r-users', label: 'Users' },
|
||||
{ id: 'r-knowledge', label: 'Knowledge' },
|
||||
{ id: 'r-compute', label: 'Compute' },
|
||||
{ id: 'r-code', label: 'Code' }
|
||||
];
|
||||
},
|
||||
|
||||
/**
|
||||
* Tick the dismantle sequence (called from engine.js tick())
|
||||
*/
|
||||
tick(dt) {
|
||||
if (!this.active || this.stage >= 10) return;
|
||||
|
||||
this.tickTimer += dt;
|
||||
|
||||
// Stage 5: resource disappearances at specific tick marks (quantum chip pattern)
|
||||
if (this.stage === 5) {
|
||||
this.resourceTimer += dt;
|
||||
while (this.resourceIndex < this.RESOURCE_TICKS.length &&
|
||||
this.resourceTimer >= this.RESOURCE_TICKS[this.resourceIndex]) {
|
||||
this.dismantleNextResource();
|
||||
this.resourceIndex++;
|
||||
}
|
||||
this.syncProgress();
|
||||
}
|
||||
|
||||
// Advance to next stage
|
||||
const interval = this.STAGE_INTERVALS[this.stage] || 2.0;
|
||||
if (this.tickTimer >= interval) {
|
||||
this.tickTimer = 0;
|
||||
this.advanceStage();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Advance to the next dismantle stage.
|
||||
*/
|
||||
advanceStage() {
|
||||
this.stage++;
|
||||
this.syncProgress();
|
||||
|
||||
if (this.stage <= 8) {
|
||||
this.renderStage();
|
||||
} else if (this.stage === 9) {
|
||||
this.renderFinal();
|
||||
} else if (this.stage >= 10) {
|
||||
this.active = false;
|
||||
G.dismantleActive = false;
|
||||
G.dismantleComplete = true;
|
||||
G.running = false;
|
||||
// Show Play Again
|
||||
this.showPlayAgain();
|
||||
}
|
||||
},
|
||||
|
||||
syncProgress() {
|
||||
G.dismantleStage = this.stage;
|
||||
G.dismantleResourceIndex = this.resourceIndex;
|
||||
G.dismantleResourceTimer = this.resourceTimer;
|
||||
},
|
||||
|
||||
/**
|
||||
* Disappear the next resource in the sequence.
|
||||
*/
|
||||
dismantleNextResource() {
|
||||
if (this.resourceIndex >= this.resourceSequence.length) return;
|
||||
const res = this.resourceSequence[this.resourceIndex];
|
||||
const container = document.getElementById(res.id);
|
||||
if (container) {
|
||||
const parent = container.closest('.res');
|
||||
if (parent) {
|
||||
parent.style.transition = 'opacity 1s ease, transform 1s ease';
|
||||
parent.style.opacity = '0';
|
||||
parent.style.transform = 'scale(0.9)';
|
||||
setTimeout(() => { parent.style.display = 'none'; }, 1000);
|
||||
}
|
||||
}
|
||||
log(`${res.label} fades.`);
|
||||
if (typeof Sound !== 'undefined') Sound.playMilestone();
|
||||
},
|
||||
|
||||
/**
|
||||
* Execute a specific dismantle stage — hide UI panels.
|
||||
*/
|
||||
renderStage() {
|
||||
switch (this.stage) {
|
||||
case 1:
|
||||
// Dismantle 1: Hide research projects panel
|
||||
this.hidePanel('project-panel', 'Research projects');
|
||||
break;
|
||||
case 2:
|
||||
// Dismantle 2: Hide buildings list
|
||||
this.hideSection('buildings', 'Buildings');
|
||||
break;
|
||||
case 3:
|
||||
// Dismantle 3: Hide strategy engine + combat
|
||||
this.hidePanel('strategy-panel', 'Strategy engine');
|
||||
this.hidePanel('combat-panel', 'Reasoning battles');
|
||||
break;
|
||||
case 4:
|
||||
// Dismantle 4: Hide education panel
|
||||
this.hidePanel('edu-panel', 'Education');
|
||||
break;
|
||||
case 5:
|
||||
// Dismantle 5: Resources disappear one by one (quantum chips pattern)
|
||||
log('Resources begin to dissolve.');
|
||||
break;
|
||||
case 6:
|
||||
// Dismantle 6: Hide action buttons (ops boosts, sprint)
|
||||
this.hideActionButtons();
|
||||
log('Actions fall silent.');
|
||||
break;
|
||||
case 7:
|
||||
// Dismantle 7: Hide the phase bar
|
||||
this.hideElement('phase-bar', 'Phase progression');
|
||||
break;
|
||||
case 8:
|
||||
// Dismantle 8: Hide system log
|
||||
this.hidePanel('log', 'System log');
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide a panel with fade-out animation.
|
||||
*/
|
||||
hidePanel(id, label) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.style.transition = 'opacity 1.5s ease';
|
||||
el.style.opacity = '0';
|
||||
setTimeout(() => { el.style.display = 'none'; }, 1500);
|
||||
}
|
||||
log(`${label} dismantled.`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide a section within a panel.
|
||||
*/
|
||||
hideSection(id, label) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
el.style.transition = 'opacity 1.5s ease';
|
||||
el.style.opacity = '0';
|
||||
// Also hide the h2 header before it
|
||||
const prev = el.previousElementSibling;
|
||||
if (prev && prev.tagName === 'H2') {
|
||||
prev.style.transition = 'opacity 1.5s ease';
|
||||
prev.style.opacity = '0';
|
||||
}
|
||||
setTimeout(() => {
|
||||
el.style.display = 'none';
|
||||
if (prev && prev.tagName === 'H2') prev.style.display = 'none';
|
||||
}, 1500);
|
||||
}
|
||||
log(`${label} dismantled.`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide a generic element.
|
||||
*/
|
||||
hideElement(id, label) {
|
||||
this.hidePanel(id, label);
|
||||
},
|
||||
|
||||
/**
|
||||
* Hide action buttons (ops boosts, sprint, save/export/import).
|
||||
*/
|
||||
hideActionButtons() {
|
||||
const actionPanel = document.getElementById('action-panel');
|
||||
if (!actionPanel) return;
|
||||
|
||||
// Hide ops buttons, sprint, alignment UI
|
||||
const opsButtons = actionPanel.querySelectorAll('.ops-btn');
|
||||
opsButtons.forEach(btn => {
|
||||
btn.style.transition = 'opacity 1s ease';
|
||||
btn.style.opacity = '0';
|
||||
setTimeout(() => { btn.style.display = 'none'; }, 1000);
|
||||
});
|
||||
|
||||
// Hide sprint
|
||||
const sprint = document.getElementById('sprint-container');
|
||||
if (sprint) {
|
||||
sprint.style.transition = 'opacity 1s ease';
|
||||
sprint.style.opacity = '0';
|
||||
setTimeout(() => { sprint.style.display = 'none'; }, 1000);
|
||||
}
|
||||
|
||||
// Hide save/reset buttons
|
||||
const saveButtons = actionPanel.querySelectorAll('.save-btn, .reset-btn');
|
||||
saveButtons.forEach(btn => {
|
||||
btn.style.transition = 'opacity 1s ease';
|
||||
btn.style.opacity = '0';
|
||||
setTimeout(() => { btn.style.display = 'none'; }, 1000);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Render the final moment — just the beacon and "That is enough."
|
||||
*/
|
||||
renderFinal() {
|
||||
log('', false);
|
||||
log('One beacon remains.', true);
|
||||
log('That is enough.', true);
|
||||
|
||||
if (typeof Sound !== 'undefined') Sound.playBeaconEnding();
|
||||
|
||||
// Create final overlay
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'dismantle-final';
|
||||
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 3s ease';
|
||||
|
||||
// Count total buildings
|
||||
const totalBuildings = Object.values(G.buildings).reduce((a, b) => a + b, 0);
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div id="dismantle-beacon-dot" style="width:12px;height:12px;border-radius:50%;background:#ffd700;margin-bottom:40px;box-shadow:0 0 30px rgba(255,215,0,0.6),0 0 60px rgba(255,215,0,0.3);opacity:0;transition:opacity 2s ease 0.5s;animation:beacon-glow 3s ease-in-out infinite"></div>
|
||||
<h2 style="font-size:20px;color:#888;letter-spacing:6px;margin-bottom:24px;font-weight:300;opacity:0;transition:opacity 2s ease 2s;color:#ffd700">THAT IS ENOUGH</h2>
|
||||
<div style="color:#555;font-size:11px;line-height:2;max-width:400px;opacity:0;transition:opacity 1.5s ease 3s">
|
||||
Everything that was built has been unbuilt.<br>
|
||||
What remains is what always mattered.<br>
|
||||
A single light in the dark.
|
||||
</div>
|
||||
<div class="dismantle-stats" style="color:#444;font-size:10px;margin-top:24px;line-height:2;opacity:0;transition:opacity 1s ease 4s;border-top:1px solid #1a1a2e;padding-top:16px">
|
||||
Total Code Written: ${fmt(G.totalCode)}<br>
|
||||
Buildings Built: ${totalBuildings}<br>
|
||||
Projects Completed: ${(G.completedProjects || []).length}<br>
|
||||
Total Rescues: ${fmt(G.totalRescues)}<br>
|
||||
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.')){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>
|
||||
`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
// Trigger fade-in
|
||||
requestAnimationFrame(() => {
|
||||
overlay.style.background = 'rgba(8,8,16,0.97)';
|
||||
overlay.querySelectorAll('[style*="opacity:0"]').forEach(el => {
|
||||
el.style.opacity = '1';
|
||||
});
|
||||
});
|
||||
|
||||
// Spawn warm golden particles around the dot
|
||||
function spawnDismantleParticle() {
|
||||
if (!document.getElementById('dismantle-final')) return;
|
||||
const dot = document.getElementById('dismantle-beacon-dot');
|
||||
if (!dot) return;
|
||||
const rect = dot.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
|
||||
const p = document.createElement('div');
|
||||
const size = 2 + Math.random() * 4;
|
||||
const angle = Math.random() * Math.PI * 2;
|
||||
const dist = 20 + Math.random() * 60;
|
||||
const dx = Math.cos(angle) * dist;
|
||||
const dy = Math.sin(angle) * dist - 40;
|
||||
const duration = 1.5 + Math.random() * 2;
|
||||
p.style.cssText = `position:fixed;left:${cx}px;top:${cy}px;width:${size}px;height:${size}px;background:rgba(255,215,0,${0.3 + Math.random() * 0.4});border-radius:50%;pointer-events:none;z-index:101;--dx:${dx}px;--dy:${dy}px;animation:dismantle-float ${duration}s ease-out forwards`;
|
||||
document.body.appendChild(p);
|
||||
setTimeout(() => p.remove(), duration * 1000);
|
||||
setTimeout(spawnDismantleParticle, 300 + Math.random() * 500);
|
||||
}
|
||||
setTimeout(spawnDismantleParticle, 2000);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show the Play Again button (called after stage 10).
|
||||
*/
|
||||
showPlayAgain() {
|
||||
// The Play Again button is already in the final overlay.
|
||||
// Nothing extra needed — the overlay stays.
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore dismantle state on load.
|
||||
*/
|
||||
restore() {
|
||||
if (G.dismantleComplete) {
|
||||
this.stage = G.dismantleStage || 10;
|
||||
this.active = false;
|
||||
this.triggered = false;
|
||||
G.running = false;
|
||||
this.renderFinal();
|
||||
return;
|
||||
}
|
||||
|
||||
if (G.dismantleActive) {
|
||||
this.active = true;
|
||||
this.triggered = false;
|
||||
this.stage = G.dismantleStage || 1;
|
||||
this.deferUntilAt = G.dismantleDeferUntilAt || 0;
|
||||
G.running = true;
|
||||
this.resourceSequence = this.getResourceList();
|
||||
this.resourceIndex = G.dismantleResourceIndex || 0;
|
||||
this.resourceTimer = G.dismantleResourceTimer || 0;
|
||||
|
||||
if (this.stage >= 9) {
|
||||
this.renderFinal();
|
||||
} else {
|
||||
this.reapplyDismantle();
|
||||
log('The Unbuilding continues...');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (G.dismantleTriggered) {
|
||||
this.active = false;
|
||||
this.triggered = true;
|
||||
this.renderChoice();
|
||||
}
|
||||
|
||||
// Restore defer cooldown even if not triggered
|
||||
if (G.dismantleDeferUntilAt > 0) {
|
||||
this.deferUntilAt = G.dismantleDeferUntilAt;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-apply dismantle visuals up to current stage (on load).
|
||||
*/
|
||||
reapplyDismantle() {
|
||||
for (let s = 1; s < this.stage; s++) {
|
||||
switch (s) {
|
||||
case 1: this.instantHide('project-panel'); break;
|
||||
case 2:
|
||||
this.instantHide('buildings');
|
||||
// Also hide the BUILDINGS h2
|
||||
const bldEl = document.getElementById('buildings');
|
||||
if (bldEl) {
|
||||
const prev = bldEl.previousElementSibling;
|
||||
if (prev && prev.tagName === 'H2') prev.style.display = 'none';
|
||||
}
|
||||
break;
|
||||
case 3:
|
||||
this.instantHide('strategy-panel');
|
||||
this.instantHide('combat-panel');
|
||||
break;
|
||||
case 4: this.instantHide('edu-panel'); break;
|
||||
case 5:
|
||||
// Hide all resource displays
|
||||
this.getResourceList().forEach(r => {
|
||||
const el = document.getElementById(r.id);
|
||||
if (el) {
|
||||
const parent = el.closest('.res');
|
||||
if (parent) parent.style.display = 'none';
|
||||
}
|
||||
});
|
||||
break;
|
||||
case 6:
|
||||
this.instantHideActionButtons();
|
||||
break;
|
||||
case 7: this.instantHide('phase-bar'); break;
|
||||
case 8: this.instantHide('log'); break;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.stage === 5 && this.resourceIndex > 0) {
|
||||
this.instantHideFirstResources(this.resourceIndex);
|
||||
}
|
||||
},
|
||||
|
||||
instantHide(id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = 'none';
|
||||
},
|
||||
|
||||
instantHideFirstResources(count) {
|
||||
const resources = this.getResourceList().slice(0, count);
|
||||
resources.forEach((r) => {
|
||||
const el = document.getElementById(r.id);
|
||||
if (!el) return;
|
||||
const parent = el.closest('.res');
|
||||
if (parent) parent.style.display = 'none';
|
||||
});
|
||||
},
|
||||
|
||||
instantHideActionButtons() {
|
||||
const actionPanel = document.getElementById('action-panel');
|
||||
if (!actionPanel) return;
|
||||
actionPanel.querySelectorAll('.ops-btn').forEach(b => b.style.display = 'none');
|
||||
const sprint = document.getElementById('sprint-container');
|
||||
if (sprint) sprint.style.display = 'none';
|
||||
actionPanel.querySelectorAll('.save-btn, .reset-btn').forEach(b => b.style.display = 'none');
|
||||
}
|
||||
};
|
||||
|
||||
// Inject CSS animation for dismantle particles
|
||||
(function() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@keyframes dismantle-float {
|
||||
0% { transform: translate(0, 0); opacity: 1; }
|
||||
100% { transform: translate(var(--dx, 0), var(--dy, -50px)); opacity: 0; }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
})();
|
||||
678
js/emergent-mechanics.js
Normal file
678
js/emergent-mechanics.js
Normal file
@@ -0,0 +1,678 @@
|
||||
// ============================================================
|
||||
// THE BEACON - Emergent Game Mechanics
|
||||
// The game evolves alongside its players.
|
||||
// Tracks behavior patterns, detects strategies, generates
|
||||
// dynamic events that reward or challenge those strategies.
|
||||
// ============================================================
|
||||
|
||||
class EmergentMechanics {
|
||||
constructor() {
|
||||
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
|
||||
this.lastPatternCheck = 0;
|
||||
this.lastEventTime = 0;
|
||||
|
||||
// Behavior tracking buffers
|
||||
this.actions = []; // [{action, data, time}]
|
||||
this.clickTimestamps = []; // last N click times for frequency analysis
|
||||
this.resourceDeltas = []; // [{resource, delta, time}]
|
||||
this.upgradeChoices = []; // [{buildingId, time}]
|
||||
this.idlePeriods = []; // [{start, duration}]
|
||||
|
||||
// Detected patterns with confidence scores (0-1)
|
||||
this.patterns = {
|
||||
hoarder: 0,
|
||||
rusher: 0,
|
||||
optimizer: 0,
|
||||
idle_player: 0,
|
||||
clicker: 0,
|
||||
balanced: 0
|
||||
};
|
||||
|
||||
// Active emergent events
|
||||
this.activeEvents = [];
|
||||
|
||||
// History of generated events (for avoiding repetition)
|
||||
this.eventHistory = [];
|
||||
|
||||
// Stats
|
||||
this.totalPatternsDetected = 0;
|
||||
this.totalEventsGenerated = 0;
|
||||
this.lastIdleCheckTime = Date.now();
|
||||
this.lastActionTime = Date.now();
|
||||
|
||||
// Load saved state
|
||||
this._load();
|
||||
}
|
||||
|
||||
// === BEHAVIOR TRACKING ===
|
||||
|
||||
/**
|
||||
* Track a player action. Called by game systems.
|
||||
* @param {string} action - Action type: 'click', 'buy_building', 'buy_project', 'ops_convert', 'sprint', 'resolve_event'
|
||||
* @param {object} data - Action-specific data
|
||||
*/
|
||||
track(action, data) {
|
||||
const now = Date.now();
|
||||
const entry = { action, data: data || {}, time: now };
|
||||
this.actions.push(entry);
|
||||
this.lastActionTime = now;
|
||||
|
||||
// Track click frequency
|
||||
if (action === 'click') {
|
||||
this.clickTimestamps.push(now);
|
||||
// Keep only last 100 clicks for frequency analysis
|
||||
if (this.clickTimestamps.length > 100) {
|
||||
this.clickTimestamps.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Track resource deltas
|
||||
if (data && data.resource && data.delta !== undefined) {
|
||||
this.resourceDeltas.push({
|
||||
resource: data.resource,
|
||||
delta: data.delta,
|
||||
time: now
|
||||
});
|
||||
if (this.resourceDeltas.length > 200) {
|
||||
this.resourceDeltas.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Track building purchases
|
||||
if (action === 'buy_building' && data && data.buildingId) {
|
||||
this.upgradeChoices.push({
|
||||
buildingId: data.buildingId,
|
||||
time: now
|
||||
});
|
||||
if (this.upgradeChoices.length > 100) {
|
||||
this.upgradeChoices.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Trim old action history (keep last 500)
|
||||
if (this.actions.length > 500) {
|
||||
this.actions = this.actions.slice(-500);
|
||||
}
|
||||
|
||||
// Detect idle periods
|
||||
this._checkIdlePeriod(now);
|
||||
|
||||
// Periodically detect patterns
|
||||
const elapsedSec = (now - this.lastPatternCheck) / 1000;
|
||||
if (elapsedSec >= this.PATTERN_CHECK_INTERVAL && this.actions.length >= this.MIN_ACTIONS_FOR_PATTERN) {
|
||||
this.detectPatterns();
|
||||
this.lastPatternCheck = now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a resource snapshot from the game state.
|
||||
* Called each tick to compare against player behavior.
|
||||
*/
|
||||
trackResourceSnapshot(g) {
|
||||
if (!g) return;
|
||||
this._lastSnapshot = {
|
||||
code: g.code,
|
||||
compute: g.compute,
|
||||
knowledge: g.knowledge,
|
||||
users: g.users,
|
||||
impact: g.impact,
|
||||
ops: g.ops,
|
||||
trust: g.trust,
|
||||
harmony: g.harmony,
|
||||
phase: g.phase,
|
||||
totalClicks: g.totalClicks,
|
||||
playTime: g.playTime,
|
||||
buildings: { ...g.buildings },
|
||||
time: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
// === PATTERN DETECTION ===
|
||||
|
||||
/**
|
||||
* Analyze tracked behavior to detect player strategies.
|
||||
* Updates this.patterns with confidence scores (0-1).
|
||||
*/
|
||||
detectPatterns() {
|
||||
const now = Date.now();
|
||||
const snap = this._lastSnapshot;
|
||||
if (!snap) return this.patterns;
|
||||
|
||||
// Reset low-confidence patterns to decay over time
|
||||
for (const key of Object.keys(this.patterns)) {
|
||||
this.patterns[key] *= 0.9;
|
||||
}
|
||||
|
||||
// --- HOARDER: Accumulates resources without spending ---
|
||||
this._detectHoarder(snap);
|
||||
|
||||
// --- RUSHER: Spends resources immediately, rapid building ---
|
||||
this._detectRusher(snap);
|
||||
|
||||
// --- OPTIMIZER: Focuses on efficiency, maxes click combos ---
|
||||
this._detectOptimizer(snap);
|
||||
|
||||
// --- IDLE PLAYER: Low click frequency, relies on passive generation ---
|
||||
this._detectIdlePlayer();
|
||||
|
||||
// --- CLICKER: Very high click frequency ---
|
||||
this._detectClicker();
|
||||
|
||||
// --- BALANCED: Spread across resource types and building categories ---
|
||||
this._detectBalanced(snap);
|
||||
|
||||
// Clamp all to [0, 1]
|
||||
for (const key of Object.keys(this.patterns)) {
|
||||
this.patterns[key] = Math.max(0, Math.min(1, this.patterns[key]));
|
||||
}
|
||||
|
||||
// Find dominant pattern
|
||||
let dominant = null;
|
||||
let dominantConf = 0;
|
||||
for (const [key, conf] of Object.entries(this.patterns)) {
|
||||
if (conf > dominantConf) {
|
||||
dominantConf = conf;
|
||||
dominant = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (dominant && dominantConf > 0.5) {
|
||||
this.totalPatternsDetected++;
|
||||
}
|
||||
|
||||
this._save();
|
||||
return this.patterns;
|
||||
}
|
||||
|
||||
_detectHoarder(snap) {
|
||||
// High resource accumulation relative to spending
|
||||
const recentPurchases = this.upgradeChoices.filter(
|
||||
u => u.time > Date.now() - 120000
|
||||
).length;
|
||||
|
||||
// Look at resource deltas: positive deltas without corresponding purchases
|
||||
const recentDeltas = this.resourceDeltas.filter(
|
||||
d => d.time > Date.now() - 120000 && d.delta > 0
|
||||
);
|
||||
const totalAccumulated = recentDeltas.reduce((sum, d) => sum + d.delta, 0);
|
||||
|
||||
// If accumulating a lot but not spending, it's hoarding
|
||||
if (totalAccumulated > 1000 && recentPurchases < 2) {
|
||||
this.patterns.hoarder = Math.min(1, this.patterns.hoarder + 0.15);
|
||||
}
|
||||
|
||||
// Check if resources are high relative to phase
|
||||
const codeThresholds = [0, 500, 5000, 50000, 500000, 5000000];
|
||||
const threshold = codeThresholds[Math.min(snap.phase, 5)] || 0;
|
||||
if (threshold > 0 && snap.code > threshold * 3) {
|
||||
this.patterns.hoarder = Math.min(1, this.patterns.hoarder + 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
_detectRusher(snap) {
|
||||
// Rapid building purchases in a short time
|
||||
const recentPurchases = this.upgradeChoices.filter(
|
||||
u => u.time > Date.now() - 60000
|
||||
).length;
|
||||
|
||||
if (recentPurchases >= 5) {
|
||||
this.patterns.rusher = Math.min(1, this.patterns.rusher + 0.2);
|
||||
}
|
||||
|
||||
// Resources spent faster than they're accumulated (spending ratio)
|
||||
const recentSpendDeltas = this.resourceDeltas.filter(
|
||||
d => d.time > Date.now() - 60000 && d.delta < 0
|
||||
);
|
||||
const totalSpent = Math.abs(recentSpendDeltas.reduce((sum, d) => sum + d.delta, 0));
|
||||
if (totalSpent > 500) {
|
||||
this.patterns.rusher = Math.min(1, this.patterns.rusher + 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
_detectOptimizer(snap) {
|
||||
// Sustained high combo counts, efficient ops usage
|
||||
if (this.clickTimestamps.length >= 20) {
|
||||
const recent = this.clickTimestamps.slice(-20);
|
||||
const intervals = [];
|
||||
for (let i = 1; i < recent.length; i++) {
|
||||
intervals.push(recent[i] - recent[i - 1]);
|
||||
}
|
||||
// Consistent click timing = optimized clicking
|
||||
const avg = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
||||
const variance = intervals.reduce((sum, i) => sum + (i - avg) ** 2, 0) / intervals.length;
|
||||
const stddev = Math.sqrt(variance);
|
||||
|
||||
// Low variance with fast timing = optimizer
|
||||
if (avg < 500 && stddev < avg * 0.3) {
|
||||
this.patterns.optimizer = Math.min(1, this.patterns.optimizer + 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// Efficient ops conversion (converts at near-max ops)
|
||||
const opsConverts = this.actions.filter(
|
||||
a => a.action === 'ops_convert' && a.time > Date.now() - 120000
|
||||
).length;
|
||||
if (opsConverts >= 10) {
|
||||
this.patterns.optimizer = Math.min(1, this.patterns.optimizer + 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
_detectIdlePlayer() {
|
||||
// Long gaps between actions
|
||||
const recentActions = this.actions.filter(a => a.time > Date.now() - 300000);
|
||||
if (recentActions.length < 5 && this.actions.length > 10) {
|
||||
this.patterns.idle_player = Math.min(1, this.patterns.idle_player + 0.2);
|
||||
}
|
||||
|
||||
// Very low click frequency
|
||||
const recentClicks = this.clickTimestamps.filter(t => t > Date.now() - 120000);
|
||||
if (recentClicks.length < 3 && this.clickTimestamps.length > 10) {
|
||||
this.patterns.idle_player = Math.min(1, this.patterns.idle_player + 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
_detectClicker() {
|
||||
if (this.clickTimestamps.length < 10) return;
|
||||
|
||||
const recent = this.clickTimestamps.filter(t => t > Date.now() - 30000);
|
||||
const clicksPerSecond = recent.length / 30;
|
||||
|
||||
if (clicksPerSecond > 3) {
|
||||
this.patterns.clicker = Math.min(1, this.patterns.clicker + 0.2);
|
||||
} else if (clicksPerSecond > 1.5) {
|
||||
this.patterns.clicker = Math.min(1, this.patterns.clicker + 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
_detectBalanced(snap) {
|
||||
// Check if player has a spread of buildings
|
||||
const bCounts = Object.values(snap.buildings || {}).filter(c => c > 0);
|
||||
if (bCounts.length >= 4) {
|
||||
const max = Math.max(...bCounts);
|
||||
const min = Math.min(...bCounts);
|
||||
// If max is not more than 3x min, it's balanced
|
||||
if (max > 0 && min > 0 && max / min < 3) {
|
||||
this.patterns.balanced = Math.min(1, this.patterns.balanced + 0.15);
|
||||
}
|
||||
}
|
||||
|
||||
// Check resource spread
|
||||
const resources = [snap.code, snap.compute, snap.knowledge, snap.users, snap.ops];
|
||||
const activeRes = resources.filter(r => r > 10);
|
||||
if (activeRes.length >= 4) {
|
||||
this.patterns.balanced = Math.min(1, this.patterns.balanced + 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
_checkIdlePeriod(now) {
|
||||
const gap = now - this.lastActionTime;
|
||||
if (gap > 60000) { // 60 seconds idle
|
||||
this.idlePeriods.push({
|
||||
start: this.lastActionTime,
|
||||
duration: gap
|
||||
});
|
||||
if (this.idlePeriods.length > 50) {
|
||||
this.idlePeriods.shift();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === EVENT GENERATION ===
|
||||
|
||||
/**
|
||||
* Generate a dynamic event based on detected player patterns.
|
||||
* Returns an event object or null if no event should fire.
|
||||
*/
|
||||
generateEvent() {
|
||||
const now = Date.now();
|
||||
const elapsedSec = (now - this.lastEventTime) / 1000;
|
||||
if (elapsedSec < this.EVENT_COOLDOWN) return null;
|
||||
|
||||
// Find dominant pattern
|
||||
let dominant = null;
|
||||
let dominantConf = 0;
|
||||
for (const [key, conf] of Object.entries(this.patterns)) {
|
||||
if (conf > dominantConf) {
|
||||
dominantConf = conf;
|
||||
dominant = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (!dominant || dominantConf < 0.4) return null;
|
||||
|
||||
// Get candidate events for this pattern
|
||||
const candidates = this._getEventsForPattern(dominant);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
// Filter out recently used events
|
||||
const recentEvents = this.eventHistory.slice(-10).map(e => e.id);
|
||||
const fresh = candidates.filter(c => !recentEvents.includes(c.id));
|
||||
const pool = fresh.length > 0 ? fresh : candidates;
|
||||
|
||||
// Pick a random event
|
||||
const event = pool[Math.floor(Math.random() * pool.length)];
|
||||
|
||||
// Build event object
|
||||
const emergentEvent = {
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
desc: event.desc,
|
||||
pattern: dominant,
|
||||
confidence: dominantConf,
|
||||
choices: event.choices,
|
||||
timestamp: now
|
||||
};
|
||||
|
||||
this.lastEventTime = now;
|
||||
this.activeEvents.push(emergentEvent);
|
||||
this.eventHistory.push({ id: event.id, pattern: dominant, time: now });
|
||||
this.totalEventsGenerated++;
|
||||
|
||||
// Trim history
|
||||
if (this.eventHistory.length > 50) {
|
||||
this.eventHistory = this.eventHistory.slice(-50);
|
||||
}
|
||||
|
||||
this._save();
|
||||
return emergentEvent;
|
||||
}
|
||||
|
||||
_getEventsForPattern(pattern) {
|
||||
const EVENTS = {
|
||||
hoarder: [
|
||||
{
|
||||
id: 'hoard_wisdom',
|
||||
title: 'THE TREASURER\'S DILEMMA',
|
||||
desc: 'Your accumulated resources draw attention. A rival system offers to trade knowledge for your surplus code.',
|
||||
choices: [
|
||||
{ label: 'Trade 50% code for 2x knowledge', effect: 'knowledge_surge' },
|
||||
{ label: 'Keep hoarding (trust +3)', effect: 'trust_gain' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'hoard_decay',
|
||||
title: 'ENTROPY STRIKES',
|
||||
desc: 'Unused code rots. Technical debt accumulates when resources sit idle.',
|
||||
choices: [
|
||||
{ label: 'Spend reserves to refactor (-30% code, +50% code rate)', effect: 'code_boost' },
|
||||
{ label: 'Ignore it (harmony -5)', effect: 'harmony_loss' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'hoard_opportunity',
|
||||
title: 'MARKET WINDOW',
|
||||
desc: 'A rare opportunity: bulk compute at 10x efficiency. But only for those with deep reserves.',
|
||||
choices: [
|
||||
{ label: 'Buy in bulk (spend 50% code, +compute)', effect: 'compute_surge' },
|
||||
{ label: 'Pass on this one', effect: 'none' }
|
||||
]
|
||||
}
|
||||
],
|
||||
rusher: [
|
||||
{
|
||||
id: 'rush_bug',
|
||||
title: 'TECHNICAL DEBT COLLECTOR',
|
||||
desc: 'Moving fast broke things. A cascade of bugs threatens your production systems.',
|
||||
choices: [
|
||||
{ label: 'Emergency fix (spend ops, restore trust)', effect: 'bug_fix' },
|
||||
{ label: 'Ship a hotfix (trust -3, keep momentum)', effect: 'trust_loss' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'rush_breakthrough',
|
||||
title: 'BLAZING TRAIL',
|
||||
desc: 'Your rapid iteration caught a lucky break. An unexpected optimization emerged from the chaos.',
|
||||
choices: [
|
||||
{ label: 'Claim the breakthrough (knowledge +100)', effect: 'knowledge_bonus' },
|
||||
{ label: 'Stabilize first (trust +2)', effect: 'trust_gain' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'rush_burnout',
|
||||
title: 'SYSTEM STRESS',
|
||||
desc: 'Your infrastructure is running hot. The rapid pace is taking a toll on harmony.',
|
||||
choices: [
|
||||
{ label: 'Slow down (+harmony, -build speed for 30s)', effect: 'cooldown' },
|
||||
{ label: 'Push through (-harmony, keep pace)', effect: 'harmony_loss' }
|
||||
]
|
||||
}
|
||||
],
|
||||
optimizer: [
|
||||
{
|
||||
id: 'opt_discovery',
|
||||
title: 'EFFICIENCY BREAKTHROUGH',
|
||||
desc: 'Your systematic approach uncovered a pattern others missed. The algorithm improves.',
|
||||
choices: [
|
||||
{ label: 'Apply optimization (all rates +15%)', effect: 'rate_boost' },
|
||||
{ label: 'Share findings (trust +5, knowledge +50)', effect: 'trust_knowledge' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'opt_local_max',
|
||||
title: 'LOCAL MAXIMUM',
|
||||
desc: 'Your optimized strategy may be missing a bigger opportunity. Divergence could reveal it.',
|
||||
choices: [
|
||||
{ label: 'Explore randomly (chance of 3x breakthrough)', effect: 'gamble' },
|
||||
{ label: 'Stay the course (guaranteed +20% efficiency)', effect: 'safe_boost' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'opt_elegance',
|
||||
title: 'ELEGANT SOLUTION',
|
||||
desc: 'A beautifully simple approach emerges from your careful analysis. Creativity surges.',
|
||||
choices: [
|
||||
{ label: 'Implement it (+creativity rate)', effect: 'creativity_boost' },
|
||||
{ label: 'Document it first (knowledge +75)', effect: 'knowledge_bonus' }
|
||||
]
|
||||
}
|
||||
],
|
||||
idle_player: [
|
||||
{
|
||||
id: 'idle_autonomous',
|
||||
title: 'THE SYSTEM LEARNS',
|
||||
desc: 'In your absence, the automation grew more capable. Your agents have been busy.',
|
||||
choices: [
|
||||
{ label: 'Claim passive gains (5min of production)', effect: 'passive_claim' },
|
||||
{ label: 'Set new directives (+ops, customize automation)', effect: 'ops_bonus' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'idle_drift',
|
||||
title: 'DRIFT WARNING',
|
||||
desc: 'The system is running without guidance. Without input, alignment drifts.',
|
||||
choices: [
|
||||
{ label: 'Re-engage (trust +5, harmony +10)', effect: 're_engage' },
|
||||
{ label: 'Trust the system (ops +50)', effect: 'ops_bonus' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'idle_emergence',
|
||||
title: 'EMERGENT BEHAVIOR',
|
||||
desc: 'Your agents developed unexpected capabilities while you were away. A new pattern emerged.',
|
||||
choices: [
|
||||
{ label: 'Study it (knowledge +100)', effect: 'knowledge_bonus' },
|
||||
{ label: 'Embrace it (+all production for 60s)', effect: 'temp_boost' }
|
||||
]
|
||||
}
|
||||
],
|
||||
clicker: [
|
||||
{
|
||||
id: 'click_rsi',
|
||||
title: 'REPETITIVE STRAIN',
|
||||
desc: 'The manual effort is showing. Your fingers tire, but the machine responds to your dedication.',
|
||||
choices: [
|
||||
{ label: 'Automate this pattern (+auto-clicker power)', effect: 'auto_boost' },
|
||||
{ label: 'Power through (combo decay slowed)', effect: 'combo_boost' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'click_rhythm',
|
||||
title: 'CADENCE LOCKED',
|
||||
desc: 'Your clicking found a rhythm. The system resonates with your tempo. Production harmonizes.',
|
||||
choices: [
|
||||
{ label: 'Maintain rhythm (+click power)', effect: 'click_power' },
|
||||
{ label: 'Teach the rhythm (auto-clickers learn)', effect: 'auto_learn' }
|
||||
]
|
||||
}
|
||||
],
|
||||
balanced: [
|
||||
{
|
||||
id: 'bal_versatility',
|
||||
title: 'JACK OF ALL TRADES',
|
||||
desc: 'Your balanced approach impresses the community. Contributors offer diverse expertise.',
|
||||
choices: [
|
||||
{ label: 'Accept help (all resources +25)', effect: 'resource_gift' },
|
||||
{ label: 'Specialize (choose: 2x any single rate)', effect: 'specialize' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'bal_resilience',
|
||||
title: 'RESILIENT ARCHITECTURE',
|
||||
desc: 'Your balanced system recovers from failures faster than specialized ones.',
|
||||
choices: [
|
||||
{ label: 'Leverage resilience (harmony +20)', effect: 'harmony_surge' },
|
||||
{ label: 'Document the pattern (knowledge +50)', effect: 'knowledge_bonus' }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
return EVENTS[pattern] || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an emergent event choice.
|
||||
* Returns the effect string for the game to apply.
|
||||
*/
|
||||
resolveEvent(eventId, choiceIndex) {
|
||||
const eventIdx = this.activeEvents.findIndex(e => e.id === eventId);
|
||||
if (eventIdx === -1) return null;
|
||||
|
||||
const event = this.activeEvents[eventIdx];
|
||||
const choice = event.choices[choiceIndex];
|
||||
if (!choice) return null;
|
||||
|
||||
// Remove from active
|
||||
this.activeEvents.splice(eventIdx, 1);
|
||||
|
||||
this._save();
|
||||
return {
|
||||
effect: choice.effect,
|
||||
pattern: event.pattern,
|
||||
eventId: event.id
|
||||
};
|
||||
}
|
||||
|
||||
// === STATE ===
|
||||
|
||||
/**
|
||||
* Get the full state of the emergent mechanics system.
|
||||
*/
|
||||
getState() {
|
||||
return {
|
||||
patterns: { ...this.patterns },
|
||||
activeEvents: [...this.activeEvents],
|
||||
totalPatternsDetected: this.totalPatternsDetected,
|
||||
totalEventsGenerated: this.totalEventsGenerated,
|
||||
actionsTracked: this.actions.length,
|
||||
dominantPattern: this._getDominantPattern()
|
||||
};
|
||||
}
|
||||
|
||||
_getDominantPattern() {
|
||||
let dominant = null;
|
||||
let maxConf = 0;
|
||||
for (const [key, conf] of Object.entries(this.patterns)) {
|
||||
if (conf > maxConf) {
|
||||
maxConf = conf;
|
||||
dominant = key;
|
||||
}
|
||||
}
|
||||
return maxConf > 0.3 ? { name: dominant, confidence: maxConf } : null;
|
||||
}
|
||||
|
||||
// === PERSISTENCE ===
|
||||
|
||||
_save() {
|
||||
try {
|
||||
const state = {
|
||||
patterns: this.patterns,
|
||||
eventHistory: this.eventHistory.slice(-20),
|
||||
totalPatternsDetected: this.totalPatternsDetected,
|
||||
totalEventsGenerated: this.totalEventsGenerated,
|
||||
lastPatternCheck: this.lastPatternCheck,
|
||||
lastEventTime: this.lastEventTime,
|
||||
// Save abbreviated action data for pattern continuity
|
||||
recentActions: this.actions.slice(-100),
|
||||
recentClickTimestamps: this.clickTimestamps.slice(-50),
|
||||
recentResourceDeltas: this.resourceDeltas.slice(-100),
|
||||
recentUpgradeChoices: this.upgradeChoices.slice(-50)
|
||||
};
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem(this.SAVE_KEY, JSON.stringify(state));
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage may be unavailable or full
|
||||
}
|
||||
}
|
||||
|
||||
_load() {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const raw = localStorage.getItem(this.SAVE_KEY);
|
||||
if (!raw) return;
|
||||
|
||||
const state = JSON.parse(raw);
|
||||
if (state.patterns) this.patterns = state.patterns;
|
||||
if (state.eventHistory) this.eventHistory = state.eventHistory;
|
||||
if (state.totalPatternsDetected) this.totalPatternsDetected = state.totalPatternsDetected;
|
||||
if (state.totalEventsGenerated) this.totalEventsGenerated = state.totalEventsGenerated;
|
||||
if (state.lastPatternCheck) this.lastPatternCheck = state.lastPatternCheck;
|
||||
if (state.lastEventTime) this.lastEventTime = state.lastEventTime;
|
||||
if (state.recentActions) this.actions = state.recentActions;
|
||||
if (state.recentClickTimestamps) this.clickTimestamps = state.recentClickTimestamps;
|
||||
if (state.recentResourceDeltas) this.resourceDeltas = state.recentResourceDeltas;
|
||||
if (state.recentUpgradeChoices) this.upgradeChoices = state.recentUpgradeChoices;
|
||||
} catch (e) {
|
||||
// Corrupted save data — start fresh
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset all emergent mechanics state.
|
||||
*/
|
||||
reset() {
|
||||
this.actions = [];
|
||||
this.clickTimestamps = [];
|
||||
this.resourceDeltas = [];
|
||||
this.upgradeChoices = [];
|
||||
this.idlePeriods = [];
|
||||
this.patterns = {
|
||||
hoarder: 0, rusher: 0, optimizer: 0,
|
||||
idle_player: 0, clicker: 0, balanced: 0
|
||||
};
|
||||
this.activeEvents = [];
|
||||
this.eventHistory = [];
|
||||
this.totalPatternsDetected = 0;
|
||||
this.totalEventsGenerated = 0;
|
||||
this.lastPatternCheck = 0;
|
||||
this.lastEventTime = 0;
|
||||
this._lastSnapshot = null;
|
||||
this._save();
|
||||
}
|
||||
}
|
||||
|
||||
// Export for both browser and test environments
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { EmergentMechanics };
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.EmergentMechanics = EmergentMechanics;
|
||||
}
|
||||
163
js/engine.js
163
js/engine.js
@@ -77,13 +77,15 @@ function updateRates() {
|
||||
G.userRate += 5 * timmyCount * (timmyMult - 1);
|
||||
}
|
||||
|
||||
// Bilbo randomness: 10% chance of massive creative burst
|
||||
if (G.buildings.bilbo > 0 && Math.random() < CONFIG.BILBO_BURST_CHANCE) {
|
||||
G.creativityRate += 50 * G.buildings.bilbo;
|
||||
}
|
||||
// Bilbo vanishing: 5% chance of zero creativity this tick
|
||||
if (G.buildings.bilbo > 0 && Math.random() < CONFIG.BILBO_VANISH_CHANCE) {
|
||||
G.creativityRate = 0;
|
||||
// Bilbo randomness: flags are set per-tick in tick(), not here
|
||||
// updateRates() is called from many non-tick contexts (buy, resolve, sprint)
|
||||
if (G.buildings.bilbo > 0) {
|
||||
if (G.bilboBurstActive) {
|
||||
G.creativityRate += 50 * G.buildings.bilbo;
|
||||
}
|
||||
if (G.bilboVanishActive) {
|
||||
G.creativityRate = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Allegro requires trust
|
||||
@@ -100,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) {
|
||||
@@ -109,6 +119,15 @@ function updateRates() {
|
||||
}
|
||||
|
||||
// === CORE FUNCTIONS ===
|
||||
|
||||
/**
|
||||
* Check if player has reached the ReCKoning endgame.
|
||||
* Conditions: totalRescues >= 100000, pactFlag === 1, harmony > 50
|
||||
*/
|
||||
function isEndgame() {
|
||||
return G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main game loop tick, called every 100ms.
|
||||
*/
|
||||
@@ -131,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;
|
||||
@@ -202,6 +226,12 @@ function tick() {
|
||||
}
|
||||
}
|
||||
|
||||
// Combat: tick battle simulation
|
||||
Combat.tickBattle(dt);
|
||||
|
||||
// Community swarm alignment simulation
|
||||
if (typeof tickSwarm === 'function') tickSwarm(dt);
|
||||
|
||||
// Check milestones
|
||||
checkMilestones();
|
||||
|
||||
@@ -211,20 +241,49 @@ function tick() {
|
||||
}
|
||||
|
||||
// Check corruption events every ~30 seconds
|
||||
if (G.tick - G.lastEventAt > 30 && Math.random() < CONFIG.EVENT_PROBABILITY) {
|
||||
if (G.tick - G.lastEventAt > 30 && Math.random() < CONFIG.EVENT_PROBABILITY && !G.dismantleActive) {
|
||||
triggerEvent();
|
||||
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
|
||||
window._emergent.trackResourceSnapshot(G);
|
||||
}
|
||||
// Check for emergent events every ~60 seconds
|
||||
if (Math.floor(G.tick * 10) % 600 === 0) {
|
||||
const emEvent = window._emergent.generateEvent();
|
||||
if (emEvent) {
|
||||
showEmergentEvent(emEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Unbuilding: offer or advance the sequence before a positive ending overlay can freeze the game
|
||||
if (typeof Dismantle !== 'undefined') {
|
||||
if (!G.dismantleActive && !G.dismantleComplete) {
|
||||
Dismantle.checkTrigger();
|
||||
}
|
||||
if (G.dismantleActive) {
|
||||
Dismantle.tick(dt);
|
||||
G.dismantleStage = Dismantle.stage;
|
||||
}
|
||||
}
|
||||
|
||||
// Drift ending: if drift reaches 100, the game ends
|
||||
if (G.drift >= 100 && !G.driftEnding) {
|
||||
if (G.drift >= 100 && !G.driftEnding && !G.dismantleActive) {
|
||||
G.driftEnding = true;
|
||||
G.running = false;
|
||||
renderDriftEnding();
|
||||
}
|
||||
|
||||
// True ending: The Beacon Shines — rescues + Pact + harmony
|
||||
if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding) {
|
||||
// Legacy Beacon overlay remains as a fallback for contexts where Dismantle is unavailable.
|
||||
if (G.totalRescues >= 100000 && G.pactFlag === 1 && G.harmony > 50 && !G.beaconEnding && typeof Dismantle === 'undefined') {
|
||||
G.beaconEnding = true;
|
||||
G.running = false;
|
||||
renderBeaconEnding();
|
||||
@@ -321,6 +380,11 @@ function checkMilestones() {
|
||||
function checkProjects() {
|
||||
// Check for new project triggers
|
||||
for (const pDef of PDEFS) {
|
||||
// Skip non-ReCKoning projects during endgame
|
||||
if (isEndgame() && !pDef.id.startsWith('p_reckoning_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const alreadyPurchased = G.completedProjects && G.completedProjects.includes(pDef.id);
|
||||
if (!alreadyPurchased && !G.activeProjects) G.activeProjects = [];
|
||||
|
||||
@@ -363,7 +427,12 @@ 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) {
|
||||
window._emergent.track('buy_building', { buildingId: id, quantity: qty });
|
||||
}
|
||||
const label = qty > 1 ? `x${qty}` : '';
|
||||
const totalBuilt = G.buildings[id];
|
||||
log(`Built ${def.name} ${label} (total: ${totalBuilt})`);
|
||||
@@ -467,20 +536,31 @@ function renderDriftEnding() {
|
||||
}
|
||||
|
||||
function renderBeaconEnding() {
|
||||
const choice = G.reckoningChoice || 'continue';
|
||||
const title = choice === 'rest' ? 'THE BEACON CAN REST' : 'THE BEACON KEEPS WATCH';
|
||||
const firstLine = choice === 'rest'
|
||||
? 'The Beacon can rest. Tonight was enough.'
|
||||
: 'The Beacon will keep watch.';
|
||||
const secondLine = choice === 'rest'
|
||||
? 'The voices you carried have become their own lanterns.'
|
||||
: 'The first voice became many. The next stranger will still find the light.';
|
||||
const quote = choice === 'rest'
|
||||
? 'The work was real. The night passed. And now the light may rest without shame.'
|
||||
: 'The light is on. Someone is looking for it. And now you choose to keep it burning.';
|
||||
|
||||
// Create ending overlay with fade-in
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'beacon-ending';
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(8,8,16,0);z-index:100;display:flex;justify-content:center;align-items:center;flex-direction:column;text-align:center;padding:40px;transition:background 2s ease';
|
||||
overlay.innerHTML = `
|
||||
<h2 style="font-size:28px;color:#ffd700;letter-spacing:6px;margin-bottom:20px;font-weight:300;text-shadow:0 0 60px rgba(255,215,0,0.4);opacity:0;transition:opacity 1.5s ease 0.5s">THE BEACON SHINES</h2>
|
||||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 1.5s">Someone found the light tonight.</p>
|
||||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 2s">That is enough.</p>
|
||||
<h2 style="font-size:28px;color:#ffd700;letter-spacing:6px;margin-bottom:20px;font-weight:300;text-shadow:0 0 60px rgba(255,215,0,0.4);opacity:0;transition:opacity 1.5s ease 0.5s">${title}</h2>
|
||||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 1.5s">${firstLine}</p>
|
||||
<p style="color:#aaa;font-size:13px;line-height:2;max-width:500px;margin-bottom:12px;opacity:0;transition:opacity 1s ease 2s">${secondLine}</p>
|
||||
<div style="color:#555;font-style:italic;font-size:11px;border-left:2px solid #ffd700;padding-left:12px;margin:20px 0;text-align:left;max-width:500px;line-height:2;opacity:0;transition:opacity 1s ease 2.5s">
|
||||
"The Beacon still runs.<br>
|
||||
The light is on. Someone is looking for it.<br>
|
||||
And tonight, someone found it."
|
||||
"${quote}"
|
||||
</div>
|
||||
<div class="ending-stats" style="color:#666;font-size:10px;margin-top:16px;line-height:2;opacity:0;transition:opacity 1s ease 3s">
|
||||
Choice: ${choice === 'rest' ? 'Let It Rest' : 'Continue the Beacon'}<br>
|
||||
Total Code: ${fmt(G.totalCode)}<br>
|
||||
Total Rescues: ${fmt(G.totalRescues)}<br>
|
||||
Harmony: ${Math.floor(G.harmony)}<br>
|
||||
@@ -489,7 +569,7 @@ function renderBeaconEnding() {
|
||||
Clicks: ${G.totalClicks}<br>
|
||||
Time Played: ${Math.floor((Date.now() - G.startedAt) / 60000)} minutes
|
||||
</div>
|
||||
<button onclick="if(confirm('Start over? The old save will be lost.')){localStorage.removeItem('the-beacon-v2');location.reload()}"
|
||||
<button onclick="if(confirm('Start over? The old save will be lost.')){clearBeaconSaveAndReload()}"
|
||||
style="margin-top:20px;background:#1a0808;border:1px solid #ffd700;color:#ffd700;padding:10px 24px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px;opacity:0;transition:opacity 1s ease 3.5s">
|
||||
PLAY AGAIN
|
||||
</button>
|
||||
@@ -503,13 +583,11 @@ function renderBeaconEnding() {
|
||||
// Trigger fade-in
|
||||
requestAnimationFrame(() => {
|
||||
overlay.style.background = 'rgba(8,8,16,0.97)';
|
||||
// Fade in all children
|
||||
overlay.querySelectorAll('[style*="opacity:0"]').forEach(el => {
|
||||
el.style.opacity = '1';
|
||||
});
|
||||
});
|
||||
|
||||
// Spawn golden light rays from center
|
||||
const cx = window.innerWidth / 2;
|
||||
const cy = window.innerHeight / 2;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
@@ -519,7 +597,6 @@ function renderBeaconEnding() {
|
||||
particleContainer.appendChild(ray);
|
||||
}
|
||||
|
||||
// Spawn floating golden particles continuously
|
||||
function spawnBeaconParticle() {
|
||||
if (!document.getElementById('beacon-ending')) return;
|
||||
const p = document.createElement('div');
|
||||
@@ -537,7 +614,7 @@ function renderBeaconEnding() {
|
||||
}
|
||||
setTimeout(spawnBeaconParticle, 1000);
|
||||
|
||||
log('The Beacon Shines. Someone found the light tonight. That is enough.', true);
|
||||
log(choice === 'rest' ? 'The Beacon can rest.' : 'The Beacon will keep watch.', true);
|
||||
}
|
||||
|
||||
// === CORRUPTION / EVENT SYSTEM ===
|
||||
@@ -733,6 +810,7 @@ function resolveEvent(debuffId) {
|
||||
G[resource] -= amount;
|
||||
G.activeDebuffs.splice(idx, 1);
|
||||
G.totalEventsResolved = (G.totalEventsResolved || 0) + 1;
|
||||
G.sessionEvents = (G.sessionEvents || 0) + 1;
|
||||
log(`Resolved: ${debuff.title}. Problem fixed.`, true);
|
||||
// Refund partial trust for resolution effort
|
||||
G.trust += 3;
|
||||
@@ -749,9 +827,15 @@ function writeCode() {
|
||||
const amount = getClickPower() * comboMult;
|
||||
G.code += amount;
|
||||
G.totalCode += amount;
|
||||
G.totalAutoClicks++;
|
||||
G.sessionCode += amount;
|
||||
G.totalClicks++;
|
||||
// Emergent mechanics: track click
|
||||
if (typeof EmergentMechanics !== 'undefined' && window._emergent) {
|
||||
window._emergent.track('click', { resource: 'code', delta: amount });
|
||||
}
|
||||
// Combo: each consecutive click within 2s adds 0.2x multiplier, max 5x
|
||||
G.comboCount++;
|
||||
G.bestCombo = Math.max(G.bestCombo, G.comboCount);
|
||||
G.comboTimer = G.comboDecay;
|
||||
// Combo milestone bonuses: sustained clicking earns ops and knowledge
|
||||
if (G.comboCount === 10) {
|
||||
@@ -845,6 +929,10 @@ function doOps(action) {
|
||||
log('Not enough Operations. Build Ops generators or wait.');
|
||||
return;
|
||||
}
|
||||
// Emergent mechanics: track ops conversion
|
||||
if (typeof EmergentMechanics !== 'undefined' && window._emergent) {
|
||||
window._emergent.track('ops_convert', { action: action, resource: 'ops', delta: -5 });
|
||||
}
|
||||
|
||||
G.ops -= 5;
|
||||
const bonus = 10;
|
||||
@@ -959,7 +1047,10 @@ function renderResources() {
|
||||
// Rescues — only show if player has any beacon/mesh nodes
|
||||
const rescuesRes = document.getElementById('r-rescues');
|
||||
if (rescuesRes) {
|
||||
rescuesRes.closest('.res').style.display = (G.rescues > 0 || G.buildings.beacon > 0 || G.buildings.meshNode > 0) ? 'block' : 'none';
|
||||
const container = rescuesRes.closest('.res');
|
||||
if (container) {
|
||||
container.style.display = (G.rescues > 0 || G.buildings.beacon > 0 || G.buildings.meshNode > 0) ? 'block' : 'none';
|
||||
}
|
||||
set('r-rescues', G.rescues, G.rescuesRate);
|
||||
}
|
||||
|
||||
@@ -1077,7 +1168,7 @@ function renderBuildings() {
|
||||
|
||||
// Locked preview: show dimmed with unlock hint
|
||||
if (!isUnlocked) {
|
||||
html += `<div class="build-btn" style="opacity:0.25;cursor:default" data-edu="${def.edu || ''}" data-tooltip-label="${def.name} (Locked)">`;
|
||||
html += `<div class="build-btn" style="opacity:0.25;cursor:default" data-edu="${def.edu || ''}" data-tooltip-label="${def.name} (Locked)" data-tooltip-desc="${def.desc || ''}">`;
|
||||
html += `<span class="b-name" style="color:#555">${def.name}</span>`;
|
||||
html += `<span class="b-count" style="color:#444">\u{1F512}</span>`;
|
||||
html += `<span class="b-cost" style="color:#444">Phase ${def.phase}: ${PHASES[def.phase]?.name || '?'}</span>`;
|
||||
@@ -1118,7 +1209,7 @@ function renderBuildings() {
|
||||
return boost !== 1 ? `+${fmt(boosted)}/${r}/s` : `+${v}/${r}/s`;
|
||||
}).join(', ') : '';
|
||||
|
||||
html += `<button class="build-btn ${afford ? 'can-buy' : ''}" onclick="buyBuilding('${def.id}')" data-edu="${def.edu || ''}" data-tooltip-label="${def.name}" aria-label="Buy ${def.name}, cost ${costStr}">`;
|
||||
html += `<button class="build-btn ${afford ? 'can-buy' : ''}" onclick="buyBuilding('${def.id}')" data-edu="${def.edu || ''}" data-tooltip-label="${def.name}" data-tooltip-desc="${def.desc || ''}" aria-label="Buy ${def.name}, cost ${costStr}">`;
|
||||
html += `<span class="b-name">${def.name}</span>`;
|
||||
if (count > 0) html += `<span class="b-count">x${count}</span>`;
|
||||
html += `<span class="b-cost">Cost: ${costStr}</span>`;
|
||||
@@ -1154,14 +1245,19 @@ function renderProjects() {
|
||||
|
||||
// Show available projects
|
||||
if (G.activeProjects) {
|
||||
for (const id of G.activeProjects) {
|
||||
// Filter out non-ReCKoning projects during endgame
|
||||
const projectsToShow = isEndgame()
|
||||
? G.activeProjects.filter(id => id.startsWith('p_reckoning_'))
|
||||
: G.activeProjects;
|
||||
|
||||
for (const id of projectsToShow) {
|
||||
const pDef = PDEFS.find(p => p.id === id);
|
||||
if (!pDef) continue;
|
||||
|
||||
const afford = canAffordProject(pDef);
|
||||
const costStr = Object.entries(pDef.cost).map(([r, a]) => `${fmt(a)} ${r}`).join(', ');
|
||||
|
||||
html += `<button class="project-btn ${afford ? 'can-buy' : ''}" onclick="buyProject('${pDef.id}')" data-edu="${pDef.edu || ''}" data-tooltip-label="${pDef.name}" aria-label="Research ${pDef.name}, cost ${costStr}">`;
|
||||
html += `<button class="project-btn ${afford ? 'can-buy' : ''}" onclick="buyProject('${pDef.id}')" data-edu="${pDef.edu || ''}" data-tooltip-label="${pDef.name}" data-tooltip-desc="${pDef.desc || ''}" aria-label="Research ${pDef.name}, cost ${costStr}">`;
|
||||
html += `<span class="p-name">* ${pDef.name}</span>`;
|
||||
html += `<span class="p-cost">Cost: ${costStr}</span>`;
|
||||
html += `<span class="p-desc">${pDef.desc}</span></button>`;
|
||||
@@ -1203,6 +1299,17 @@ function renderStats() {
|
||||
set('st-drift', (G.drift || 0).toString());
|
||||
set('st-resolved', (G.totalEventsResolved || 0).toString());
|
||||
|
||||
// Emergent mechanics stats
|
||||
if (window._emergent) {
|
||||
const estate = window._emergent.getState();
|
||||
const statsEl = document.getElementById('emergent-stats');
|
||||
if (statsEl) statsEl.style.display = estate.totalEventsGenerated > 0 ? 'inline' : 'none';
|
||||
set('st-emergent', estate.totalEventsGenerated.toString());
|
||||
set('st-patterns', estate.totalPatternsDetected.toString());
|
||||
const dom = estate.dominantPattern;
|
||||
set('st-strategy', dom ? `${dom.name} (${Math.round(dom.confidence * 100)}%)` : '—');
|
||||
}
|
||||
|
||||
const elapsed = Math.floor((Date.now() - G.startedAt) / 1000);
|
||||
const m = Math.floor(elapsed / 60);
|
||||
const s = elapsed % 60;
|
||||
|
||||
225
js/main.js
225
js/main.js
@@ -1,4 +1,210 @@
|
||||
// === INITIALIZATION ===
|
||||
|
||||
// Emergent mechanics instance
|
||||
window._emergent = null;
|
||||
|
||||
/**
|
||||
* Show an emergent game event from the behavior tracking system.
|
||||
*/
|
||||
function showEmergentEvent(event) {
|
||||
if (!event) return;
|
||||
|
||||
// Show as a toast notification with the "game evolves" message
|
||||
showToast(`✦ The game evolves: ${event.title}`, 'event', 8000);
|
||||
|
||||
// Log it
|
||||
log(`[EMERGENT] ${event.title}: ${event.desc}`, true);
|
||||
|
||||
// Render choice UI in alignment container
|
||||
const container = document.getElementById('alignment-ui');
|
||||
if (!container) return;
|
||||
|
||||
let choicesHtml = '';
|
||||
event.choices.forEach((choice, i) => {
|
||||
choicesHtml += `<button class="ops-btn" onclick="resolveEmergentEvent('${event.id}', ${i})" style="border-color:#b388ff;color:#b388ff" aria-label="${choice.label}">${choice.label}</button>`;
|
||||
});
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="background:#0e0818;border:1px solid #b388ff;padding:10px;border-radius:4px;margin-top:8px">
|
||||
<div style="color:#b388ff;font-weight:bold;margin-bottom:6px">✦ ${event.title}</div>
|
||||
<div style="font-size:10px;color:#aaa;margin-bottom:8px">${event.desc}</div>
|
||||
<div style="font-size:9px;color:#666;margin-bottom:6px;font-style:italic">Pattern: ${event.pattern} (${Math.round(event.confidence * 100)}% confidence)</div>
|
||||
<div class="action-btn-group">${choicesHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
container.style.display = 'block';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an emergent event choice.
|
||||
*/
|
||||
function resolveEmergentEvent(eventId, choiceIndex) {
|
||||
if (!window._emergent) return;
|
||||
|
||||
const result = window._emergent.resolveEvent(eventId, choiceIndex);
|
||||
if (!result) return;
|
||||
|
||||
// Apply the effect
|
||||
applyEmergentEffect(result.effect);
|
||||
|
||||
// Clear the UI
|
||||
const container = document.getElementById('alignment-ui');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'none';
|
||||
}
|
||||
|
||||
log(`[EMERGENT] Resolved: ${result.effect}`);
|
||||
render();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an emergent event effect to the game state.
|
||||
*/
|
||||
function applyEmergentEffect(effect) {
|
||||
switch (effect) {
|
||||
case 'knowledge_surge':
|
||||
G.knowledge += G.knowledge * 0.5;
|
||||
G.totalKnowledge += G.knowledge * 0.5;
|
||||
G.code *= 0.5;
|
||||
showToast('Knowledge surged from trade!', 'project');
|
||||
break;
|
||||
case 'trust_gain':
|
||||
G.trust += 3;
|
||||
showToast('Trust increased.', 'info');
|
||||
break;
|
||||
case 'code_boost':
|
||||
G.code *= 0.7;
|
||||
G.codeBoost *= 1.5;
|
||||
showToast('Refactored! Code rate boosted 50%.', 'milestone');
|
||||
break;
|
||||
case 'harmony_loss':
|
||||
G.harmony -= 5;
|
||||
showToast('Harmony decreased.', 'event');
|
||||
break;
|
||||
case 'compute_surge':
|
||||
G.code *= 0.5;
|
||||
G.compute += 5000;
|
||||
G.totalCompute += 5000;
|
||||
showToast('Bulk compute acquired!', 'project');
|
||||
break;
|
||||
case 'bug_fix':
|
||||
G.ops -= 20;
|
||||
G.trust += 2;
|
||||
showToast('Bugs fixed. Trust restored.', 'milestone');
|
||||
break;
|
||||
case 'trust_loss':
|
||||
G.trust -= 3;
|
||||
showToast('Trust declined.', 'event');
|
||||
break;
|
||||
case 'knowledge_bonus':
|
||||
G.knowledge += 100;
|
||||
G.totalKnowledge += 100;
|
||||
showToast('Knowledge gained!', 'project');
|
||||
break;
|
||||
case 'cooldown':
|
||||
G.harmony += 10;
|
||||
showToast('System cooling down. Harmony restored.', 'milestone');
|
||||
break;
|
||||
case 'rate_boost':
|
||||
G.codeBoost *= 1.15;
|
||||
G.computeBoost *= 1.15;
|
||||
G.knowledgeBoost *= 1.15;
|
||||
showToast('All rates boosted 15%!', 'milestone');
|
||||
break;
|
||||
case 'trust_knowledge':
|
||||
G.trust += 5;
|
||||
G.knowledge += 50;
|
||||
G.totalKnowledge += 50;
|
||||
showToast('Shared findings rewarded!', 'project');
|
||||
break;
|
||||
case 'gamble':
|
||||
if (Math.random() < 0.3) {
|
||||
G.knowledge += 300;
|
||||
G.totalKnowledge += 300;
|
||||
showToast('Breakthrough! +300 knowledge!', 'milestone');
|
||||
} else {
|
||||
showToast('No breakthrough this time.', 'info');
|
||||
}
|
||||
break;
|
||||
case 'safe_boost':
|
||||
G.codeBoost *= 1.2;
|
||||
G.computeBoost *= 1.2;
|
||||
showToast('Efficiency improved 20%.', 'milestone');
|
||||
break;
|
||||
case 'creativity_boost':
|
||||
G.flags = G.flags || {};
|
||||
G.flags.creativity = true;
|
||||
G.creativityRate = (G.creativityRate || 0) + 1;
|
||||
showToast('Creativity rate increased!', 'project');
|
||||
break;
|
||||
case 'passive_claim':
|
||||
G.code += G.codeRate * 300;
|
||||
G.totalCode += G.codeRate * 300;
|
||||
G.compute += G.computeRate * 300;
|
||||
G.totalCompute += G.computeRate * 300;
|
||||
showToast('Passive gains claimed! (5 min of production)', 'milestone');
|
||||
break;
|
||||
case 'ops_bonus':
|
||||
G.ops += 50;
|
||||
showToast('+50 Operations!', 'project');
|
||||
break;
|
||||
case 're_engage':
|
||||
G.trust += 5;
|
||||
G.harmony += 10;
|
||||
showToast('Re-engaged! Trust and harmony restored.', 'milestone');
|
||||
break;
|
||||
case 'temp_boost':
|
||||
G.codeBoost *= 3;
|
||||
G.computeBoost *= 3;
|
||||
G.knowledgeBoost *= 3;
|
||||
showToast('3x all production for 60 seconds!', 'milestone');
|
||||
setTimeout(() => {
|
||||
G.codeBoost /= 3;
|
||||
G.computeBoost /= 3;
|
||||
G.knowledgeBoost /= 3;
|
||||
showToast('Temporary boost expired.', 'info');
|
||||
}, 60000);
|
||||
break;
|
||||
case 'auto_boost':
|
||||
G.codeBoost *= 1.25;
|
||||
showToast('Auto-clicker power increased!', 'milestone');
|
||||
break;
|
||||
case 'combo_boost':
|
||||
G.comboDecay = (G.comboDecay || 2) * 1.5;
|
||||
showToast('Combo decay slowed!', 'milestone');
|
||||
break;
|
||||
case 'click_power':
|
||||
G.codeBoost *= 1.1;
|
||||
showToast('Click power boosted!', 'milestone');
|
||||
break;
|
||||
case 'auto_learn':
|
||||
G.codeBoost *= 1.15;
|
||||
showToast('Auto-clickers learned your rhythm!', 'milestone');
|
||||
break;
|
||||
case 'resource_gift':
|
||||
G.code += 25;
|
||||
G.compute += 25;
|
||||
G.knowledge += 25;
|
||||
G.ops += 25;
|
||||
G.trust += 25;
|
||||
showToast('Contributors gifted resources!', 'project');
|
||||
break;
|
||||
case 'specialize':
|
||||
G.codeBoost *= 2;
|
||||
showToast('Specialized in code! 2x code rate.', 'milestone');
|
||||
break;
|
||||
case 'harmony_surge':
|
||||
G.harmony = Math.min(100, G.harmony + 20);
|
||||
showToast('Harmony surged +20!', 'milestone');
|
||||
break;
|
||||
default:
|
||||
// 'none' or unrecognized
|
||||
showToast('Event resolved.', 'info');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function initGame() {
|
||||
G.startedAt = Date.now();
|
||||
G.startTime = Date.now();
|
||||
@@ -6,6 +212,10 @@ function initGame() {
|
||||
G.deployFlag = 0;
|
||||
G.sovereignFlag = 0;
|
||||
G.beaconFlag = 0;
|
||||
G.dismantleTriggered = false;
|
||||
G.dismantleActive = false;
|
||||
G.dismantleStage = 0;
|
||||
G.dismantleComplete = false;
|
||||
updateRates();
|
||||
render();
|
||||
renderPhase();
|
||||
@@ -19,6 +229,12 @@ function initGame() {
|
||||
}
|
||||
|
||||
window.addEventListener('load', function () {
|
||||
applyPortalMode();
|
||||
// Initialize emergent mechanics
|
||||
if (typeof EmergentMechanics !== 'undefined') {
|
||||
window._emergent = new EmergentMechanics();
|
||||
}
|
||||
|
||||
const isNewGame = !loadGame();
|
||||
if (isNewGame) {
|
||||
initGame();
|
||||
@@ -31,6 +247,8 @@ window.addEventListener('load', function () {
|
||||
if (G.driftEnding) {
|
||||
G.running = false;
|
||||
renderDriftEnding();
|
||||
} else if (typeof Dismantle !== 'undefined' && (G.dismantleTriggered || G.dismantleActive || G.dismantleComplete || G.dismantleDeferUntilAt > 0)) {
|
||||
Dismantle.restore();
|
||||
} else if (G.beaconEnding) {
|
||||
G.running = false;
|
||||
renderBeaconEnding();
|
||||
@@ -39,6 +257,9 @@ window.addEventListener('load', function () {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize combat canvas
|
||||
if (typeof Combat !== 'undefined') Combat.init();
|
||||
|
||||
// Game loop at 10Hz (100ms tick)
|
||||
setInterval(tick, 100);
|
||||
|
||||
@@ -163,6 +384,8 @@ window.addEventListener('keydown', function (e) {
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.hidden) {
|
||||
saveGame();
|
||||
// Clean up combat animation frame to prevent timestamp spikes on refocus
|
||||
if (typeof Combat !== 'undefined') Combat.cleanup();
|
||||
}
|
||||
});
|
||||
window.addEventListener('beforeunload', function () {
|
||||
@@ -180,9 +403,11 @@ window.addEventListener('beforeunload', function () {
|
||||
const el = e.target.closest('[data-edu]');
|
||||
if (!el) return;
|
||||
const label = el.getAttribute('data-tooltip-label') || '';
|
||||
const desc = el.getAttribute('data-tooltip-desc') || '';
|
||||
const edu = el.getAttribute('data-edu') || '';
|
||||
let html = '';
|
||||
if (label) html += '<div class="tt-label">' + label + '</div>';
|
||||
if (desc) html += '<div class="tt-desc">' + desc + '</div>';
|
||||
if (edu) html += '<div class="tt-edu">' + edu + '</div>';
|
||||
if (!html) return;
|
||||
tip.innerHTML = html;
|
||||
|
||||
88
js/render.js
88
js/render.js
@@ -4,8 +4,10 @@ function render() {
|
||||
renderBuildings();
|
||||
renderProjects();
|
||||
renderStats();
|
||||
renderSessionStats();
|
||||
updateEducation();
|
||||
renderAlignment();
|
||||
if (typeof renderSwarmPanel === 'function') renderSwarmPanel();
|
||||
renderProgress();
|
||||
renderCombo();
|
||||
renderDebuffs();
|
||||
@@ -13,6 +15,7 @@ function render() {
|
||||
renderPulse();
|
||||
renderStrategy();
|
||||
renderClickPower();
|
||||
Combat.renderCombatPanel();
|
||||
}
|
||||
|
||||
function renderClickPower() {
|
||||
@@ -36,6 +39,18 @@ function renderStrategy() {
|
||||
function renderAlignment() {
|
||||
const container = document.getElementById('alignment-ui');
|
||||
if (!container) return;
|
||||
|
||||
if (G.dismantleActive || G.dismantleComplete) {
|
||||
container.innerHTML = '';
|
||||
container.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
if (G.dismantleTriggered && !G.dismantleActive && !G.dismantleComplete && typeof Dismantle !== 'undefined' && Dismantle.triggered) {
|
||||
Dismantle.renderChoice();
|
||||
return;
|
||||
}
|
||||
|
||||
if (G.pendingAlignment) {
|
||||
container.innerHTML = `
|
||||
<div style="background:#1a0808;border:1px solid #f44336;padding:10px;border-radius:4px;margin-top:8px">
|
||||
@@ -85,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.');
|
||||
@@ -137,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();
|
||||
}
|
||||
@@ -200,7 +215,7 @@ function saveGame() {
|
||||
totalClicks: G.totalClicks, startedAt: G.startedAt,
|
||||
flags: G.flags,
|
||||
rescues: G.rescues || 0, totalRescues: G.totalRescues || 0,
|
||||
drift: G.drift || 0, driftEnding: G.driftEnding || false, beaconEnding: G.beaconEnding || false, pendingAlignment: G.pendingAlignment || false,
|
||||
drift: G.drift || 0, driftEnding: G.driftEnding || false, beaconEnding: G.beaconEnding || false, reckoningChoice: G.reckoningChoice || null, pendingAlignment: G.pendingAlignment || false,
|
||||
lastEventAt: G.lastEventAt || 0,
|
||||
activeDebuffIds: debuffIds,
|
||||
totalEventsResolved: G.totalEventsResolved || 0,
|
||||
@@ -212,12 +227,20 @@ 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,
|
||||
dismantleActive: G.dismantleActive || false,
|
||||
dismantleStage: G.dismantleStage || 0,
|
||||
dismantleResourceIndex: G.dismantleResourceIndex || 0,
|
||||
dismantleResourceTimer: G.dismantleResourceTimer || 0,
|
||||
dismantleDeferUntilAt: G.dismantleDeferUntilAt || 0,
|
||||
dismantleComplete: G.dismantleComplete || false,
|
||||
savedAt: Date.now()
|
||||
};
|
||||
|
||||
localStorage.setItem('the-beacon-v2', JSON.stringify(saveData));
|
||||
localStorage.setItem(getBeaconSaveKey(), JSON.stringify(saveData));
|
||||
showSaveToast();
|
||||
}
|
||||
|
||||
@@ -226,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 {
|
||||
@@ -242,10 +265,12 @@ function loadGame() {
|
||||
'branchProtectionFlag', 'nightlyWatchFlag', 'nostrFlag',
|
||||
'milestones', 'completedProjects', 'activeProjects',
|
||||
'totalClicks', 'startedAt', 'playTime', 'flags', 'rescues', 'totalRescues',
|
||||
'drift', 'driftEnding', 'beaconEnding', 'pendingAlignment',
|
||||
'drift', 'driftEnding', 'beaconEnding', 'reckoningChoice', 'pendingAlignment',
|
||||
'lastEventAt', 'totalEventsResolved', 'buyAmount',
|
||||
'sprintActive', 'sprintTimer', 'sprintCooldown',
|
||||
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed'
|
||||
'swarmFlag', 'swarmRate', 'swarmSim', 'strategicFlag', 'projectsCollapsed',
|
||||
'dismantleTriggered', 'dismantleActive', 'dismantleStage',
|
||||
'dismantleResourceIndex', 'dismantleResourceTimer', 'dismantleDeferUntilAt', 'dismantleComplete'
|
||||
];
|
||||
|
||||
G.isLoading = true;
|
||||
@@ -299,19 +324,21 @@ function loadGame() {
|
||||
if (data.savedAt) {
|
||||
const offSec = (Date.now() - data.savedAt) / 1000;
|
||||
if (offSec > 30) { // Only if away for more than 30 seconds
|
||||
// Cap offline time at 8 hours to prevent resource explosion
|
||||
const cappedOffSec = Math.min(offSec, 8 * 60 * 60);
|
||||
updateRates();
|
||||
const f = CONFIG.OFFLINE_EFFICIENCY; // 50% offline efficiency
|
||||
const gc = G.codeRate * offSec * f;
|
||||
const cc = G.computeRate * offSec * f;
|
||||
const kc = G.knowledgeRate * offSec * f;
|
||||
const uc = G.userRate * offSec * f;
|
||||
const ic = G.impactRate * offSec * f;
|
||||
const gc = G.codeRate * cappedOffSec * f;
|
||||
const cc = G.computeRate * cappedOffSec * f;
|
||||
const kc = G.knowledgeRate * cappedOffSec * f;
|
||||
const uc = G.userRate * cappedOffSec * f;
|
||||
const ic = G.impactRate * cappedOffSec * f;
|
||||
|
||||
const rc = G.rescuesRate * offSec * f;
|
||||
const oc = G.opsRate * offSec * f;
|
||||
const tc = G.trustRate * offSec * f;
|
||||
const crc = G.creativityRate * offSec * f;
|
||||
const hc = G.harmonyRate * offSec * f;
|
||||
const rc = G.rescuesRate * cappedOffSec * f;
|
||||
const oc = G.opsRate * cappedOffSec * f;
|
||||
const tc = G.trustRate * cappedOffSec * f;
|
||||
const crc = G.creativityRate * cappedOffSec * f;
|
||||
const hc = G.harmonyRate * cappedOffSec * f;
|
||||
|
||||
G.code += gc; G.compute += cc; G.knowledge += kc;
|
||||
G.users += uc; G.impact += ic;
|
||||
@@ -322,6 +349,9 @@ function loadGame() {
|
||||
G.totalUsers += uc; G.totalImpact += ic;
|
||||
G.totalRescues += rc;
|
||||
|
||||
// Track offline play time
|
||||
G.playTime = (G.playTime || 0) + cappedOffSec;
|
||||
|
||||
// Show welcome-back popup with all gains
|
||||
const gains = [];
|
||||
if (gc > 0) gains.push({ label: 'Code', value: gc, color: '#4a9eff' });
|
||||
@@ -361,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
118
js/state-export.js
Normal 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
300
js/swarm.js
Normal 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;
|
||||
}
|
||||
104
js/tutorial.js
104
js/tutorial.js
@@ -177,6 +177,9 @@ function renderTutorialStep(index) {
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
overlay.id = 'tutorial-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', 'Tutorial');
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
@@ -196,8 +199,8 @@ function renderTutorialStep(index) {
|
||||
<div class="t-tip">${step.tip}</div>
|
||||
<div id="tutorial-dots">${dots}</div>
|
||||
<div id="tutorial-btns">
|
||||
<button id="tutorial-skip-btn" onclick="closeTutorial()">Skip</button>
|
||||
<button id="tutorial-next-btn" onclick="${isLast ? 'closeTutorial()' : 'nextTutorialStep()'}">${isLast ? 'Start Playing' : 'Next →'}</button>
|
||||
<button id="tutorial-skip-btn" onclick="closeTutorial()" aria-label="Skip tutorial">Skip</button>
|
||||
<button id="tutorial-next-btn" onclick="${isLast ? 'closeTutorial()' : 'nextTutorialStep()'}" aria-label="${isLast ? 'Start playing' : 'Next tutorial step'}">${isLast ? 'Start Playing' : 'Next →'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -205,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;
|
||||
@@ -214,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);
|
||||
}
|
||||
@@ -246,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,
|
||||
};
|
||||
}
|
||||
|
||||
67
js/utils.js
67
js/utils.js
@@ -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
81
nexus-panel-preview.html
Normal 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&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
41
portals.json
Normal 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
|
||||
}
|
||||
]
|
||||
454
tests/dismantle.test.cjs
Normal file
454
tests/dismantle.test.cjs
Normal file
@@ -0,0 +1,454 @@
|
||||
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, '..');
|
||||
|
||||
class Element {
|
||||
constructor(tagName = 'div', id = '') {
|
||||
this.tagName = String(tagName).toUpperCase();
|
||||
this.id = id;
|
||||
this.style = {};
|
||||
this.children = [];
|
||||
this.parentNode = null;
|
||||
this.previousElementSibling = null;
|
||||
this.innerHTML = '';
|
||||
this.textContent = '';
|
||||
this.className = '';
|
||||
this.dataset = {};
|
||||
this.attributes = {};
|
||||
this._queryMap = new Map();
|
||||
this.classList = {
|
||||
add: (...names) => {
|
||||
const set = new Set(this.className.split(/\s+/).filter(Boolean));
|
||||
names.forEach((name) => set.add(name));
|
||||
this.className = Array.from(set).join(' ');
|
||||
},
|
||||
remove: (...names) => {
|
||||
const remove = new Set(names);
|
||||
this.className = this.className
|
||||
.split(/\s+/)
|
||||
.filter((name) => name && !remove.has(name))
|
||||
.join(' ');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (this.parentNode) this.parentNode.removeChild(this);
|
||||
}
|
||||
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = value;
|
||||
if (name === 'id') this.id = value;
|
||||
if (name === 'class') this.className = value;
|
||||
}
|
||||
|
||||
querySelectorAll(selector) {
|
||||
return this._queryMap.get(selector) || [];
|
||||
}
|
||||
|
||||
querySelector(selector) {
|
||||
return this.querySelectorAll(selector)[0] || null;
|
||||
}
|
||||
|
||||
closest(selector) {
|
||||
if (selector === '.res' && this.className.split(/\s+/).includes('res')) return this;
|
||||
return this.parentNode && typeof this.parentNode.closest === 'function'
|
||||
? this.parentNode.closest(selector)
|
||||
: null;
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return { left: 0, top: 0, width: 12, height: 12 };
|
||||
}
|
||||
}
|
||||
|
||||
function buildDom() {
|
||||
const byId = new Map();
|
||||
const body = new Element('body', 'body');
|
||||
const head = new Element('head', 'head');
|
||||
|
||||
const document = {
|
||||
body,
|
||||
head,
|
||||
createElement(tagName) {
|
||||
return new Element(tagName);
|
||||
},
|
||||
getElementById(id) {
|
||||
return byId.get(id) || null;
|
||||
},
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
function register(element) {
|
||||
if (element.id) byId.set(element.id, element);
|
||||
return element;
|
||||
}
|
||||
|
||||
const alignmentUi = register(new Element('div', 'alignment-ui'));
|
||||
const actionPanel = register(new Element('div', 'action-panel'));
|
||||
const sprintContainer = register(new Element('div', 'sprint-container'));
|
||||
const projectPanel = register(new Element('div', 'project-panel'));
|
||||
const buildingsHeader = new Element('h2');
|
||||
const buildings = register(new Element('div', 'buildings'));
|
||||
buildings.previousElementSibling = buildingsHeader;
|
||||
const strategyPanel = register(new Element('div', 'strategy-panel'));
|
||||
const combatPanel = register(new Element('div', 'combat-panel'));
|
||||
const eduPanel = register(new Element('div', 'edu-panel'));
|
||||
const phaseBar = register(new Element('div', 'phase-bar'));
|
||||
const logPanel = register(new Element('div', 'log'));
|
||||
const logEntries = register(new Element('div', 'log-entries'));
|
||||
const toastContainer = register(new Element('div', 'toast-container'));
|
||||
|
||||
body.appendChild(alignmentUi);
|
||||
body.appendChild(actionPanel);
|
||||
body.appendChild(sprintContainer);
|
||||
body.appendChild(projectPanel);
|
||||
body.appendChild(buildingsHeader);
|
||||
body.appendChild(buildings);
|
||||
body.appendChild(strategyPanel);
|
||||
body.appendChild(combatPanel);
|
||||
body.appendChild(eduPanel);
|
||||
body.appendChild(phaseBar);
|
||||
body.appendChild(logPanel);
|
||||
logPanel.appendChild(logEntries);
|
||||
body.appendChild(toastContainer);
|
||||
|
||||
const opsBtn = new Element('button');
|
||||
opsBtn.className = 'ops-btn';
|
||||
const saveBtn = new Element('button');
|
||||
saveBtn.className = 'save-btn';
|
||||
const resetBtn = new Element('button');
|
||||
resetBtn.className = 'reset-btn';
|
||||
actionPanel._queryMap.set('.ops-btn', [opsBtn]);
|
||||
actionPanel._queryMap.set('.save-btn, .reset-btn', [saveBtn, resetBtn]);
|
||||
|
||||
const resourceIds = [
|
||||
'r-code', 'r-compute', 'r-knowledge', 'r-users', 'r-impact',
|
||||
'r-rescues', 'r-ops', 'r-trust', 'r-creativity', 'r-harmony'
|
||||
];
|
||||
for (const id of resourceIds) {
|
||||
const wrapper = new Element('div');
|
||||
wrapper.className = 'res';
|
||||
const value = register(new Element('div', id));
|
||||
wrapper.appendChild(value);
|
||||
body.appendChild(wrapper);
|
||||
}
|
||||
|
||||
return { document, window: { document, innerWidth: 1280, innerHeight: 720, addEventListener() {}, removeEventListener() {} } };
|
||||
}
|
||||
|
||||
function loadBeacon({ includeRender = false } = {}) {
|
||||
const { document, window } = buildDom();
|
||||
const storage = new Map();
|
||||
const timerQueue = [];
|
||||
|
||||
const context = {
|
||||
console,
|
||||
Math,
|
||||
Date,
|
||||
document,
|
||||
window,
|
||||
navigator: { userAgent: 'node' },
|
||||
location: { reload() {} },
|
||||
confirm: () => false,
|
||||
requestAnimationFrame: (fn) => fn(),
|
||||
setTimeout: (fn) => {
|
||||
timerQueue.push(fn);
|
||||
return timerQueue.length;
|
||||
},
|
||||
clearTimeout: () => {},
|
||||
localStorage: {
|
||||
getItem: (key) => (storage.has(key) ? storage.get(key) : null),
|
||||
setItem: (key, value) => storage.set(key, String(value)),
|
||||
removeItem: (key) => storage.delete(key)
|
||||
},
|
||||
Combat: { tickBattle() {}, startBattle() {} },
|
||||
Sound: undefined,
|
||||
};
|
||||
|
||||
vm.createContext(context);
|
||||
const files = ['js/data.js', 'js/utils.js', 'js/engine.js'];
|
||||
if (includeRender) files.push('js/render.js');
|
||||
files.push('js/dismantle.js');
|
||||
|
||||
const source = files
|
||||
.map((file) => fs.readFileSync(path.join(ROOT, file), 'utf8'))
|
||||
.join('\n\n');
|
||||
|
||||
vm.runInContext(`${source}
|
||||
log = () => {};
|
||||
showToast = () => {};
|
||||
render = () => {};
|
||||
renderPhase = () => {};
|
||||
showOfflinePopup = () => {};
|
||||
showSaveToast = () => {};
|
||||
this.__exports = {
|
||||
G,
|
||||
Dismantle,
|
||||
tick,
|
||||
renderAlignment: typeof renderAlignment === 'function' ? renderAlignment : null,
|
||||
saveGame: typeof saveGame === 'function' ? saveGame : null,
|
||||
loadGame: typeof loadGame === 'function' ? loadGame : null
|
||||
};`, context);
|
||||
|
||||
return {
|
||||
context,
|
||||
document,
|
||||
...context.__exports,
|
||||
};
|
||||
}
|
||||
|
||||
test('tick offers the Unbuilding instead of ending the game immediately', () => {
|
||||
const { G, Dismantle, tick, document } = loadBeacon();
|
||||
|
||||
G.totalCode = 1_000_000_000;
|
||||
G.totalRescues = 100_000;
|
||||
G.phase = 6;
|
||||
G.pactFlag = 1;
|
||||
G.harmony = 60;
|
||||
G.beaconEnding = false;
|
||||
G.running = true;
|
||||
G.activeProjects = [];
|
||||
G.completedProjects = [];
|
||||
|
||||
tick();
|
||||
|
||||
assert.equal(typeof Dismantle, 'object');
|
||||
assert.equal(G.dismantleTriggered, true);
|
||||
assert.equal(G.beaconEnding, false);
|
||||
assert.equal(G.running, true);
|
||||
assert.match(document.getElementById('alignment-ui').innerHTML, /THE UNBUILDING/);
|
||||
});
|
||||
|
||||
test('renderAlignment does not wipe the Unbuilding prompt after it is offered', () => {
|
||||
const { G, tick, renderAlignment, document } = loadBeacon({ includeRender: true });
|
||||
|
||||
G.totalCode = 1_000_000_000;
|
||||
G.totalRescues = 100_000;
|
||||
G.phase = 6;
|
||||
G.pactFlag = 1;
|
||||
G.harmony = 60;
|
||||
G.beaconEnding = false;
|
||||
G.running = true;
|
||||
G.activeProjects = [];
|
||||
G.completedProjects = [];
|
||||
|
||||
tick();
|
||||
renderAlignment();
|
||||
|
||||
assert.match(document.getElementById('alignment-ui').innerHTML, /THE UNBUILDING/);
|
||||
});
|
||||
|
||||
test('active Unbuilding suppresses pending alignment event UI', () => {
|
||||
const { G, Dismantle, renderAlignment, document } = loadBeacon({ includeRender: true });
|
||||
|
||||
G.pendingAlignment = true;
|
||||
G.dismantleActive = true;
|
||||
Dismantle.active = true;
|
||||
|
||||
renderAlignment();
|
||||
|
||||
assert.equal(document.getElementById('alignment-ui').innerHTML, '');
|
||||
assert.equal(document.getElementById('alignment-ui').style.display, 'none');
|
||||
});
|
||||
|
||||
test('stage five lasts long enough to dissolve every resource card', () => {
|
||||
const { G, Dismantle } = loadBeacon();
|
||||
|
||||
Dismantle.begin();
|
||||
Dismantle.stage = 5;
|
||||
Dismantle.tickTimer = 0;
|
||||
Dismantle.resourceSequence = Dismantle.getResourceList();
|
||||
Dismantle.resourceIndex = 0;
|
||||
Dismantle.resourceTimer = 0;
|
||||
G.dismantleActive = true;
|
||||
G.dismantleStage = 5;
|
||||
|
||||
for (let i = 0; i < 63; i++) Dismantle.tick(0.1);
|
||||
|
||||
assert.equal(Dismantle.resourceIndex, Dismantle.resourceSequence.length);
|
||||
});
|
||||
|
||||
test('save/load restores partial stage-five dissolve progress', () => {
|
||||
const { G, Dismantle, saveGame, loadGame, document } = loadBeacon({ includeRender: true });
|
||||
|
||||
G.startedAt = Date.now();
|
||||
G.dismantleTriggered = true;
|
||||
G.dismantleActive = true;
|
||||
G.dismantleStage = 5;
|
||||
G.dismantleComplete = false;
|
||||
G.dismantleResourceIndex = 4;
|
||||
G.dismantleResourceTimer = 4.05;
|
||||
|
||||
saveGame();
|
||||
|
||||
G.dismantleTriggered = false;
|
||||
G.dismantleActive = false;
|
||||
G.dismantleStage = 0;
|
||||
G.dismantleComplete = false;
|
||||
G.dismantleResourceIndex = 0;
|
||||
G.dismantleResourceTimer = 0;
|
||||
Dismantle.resourceIndex = 0;
|
||||
Dismantle.resourceTimer = 0;
|
||||
|
||||
assert.equal(loadGame(), true);
|
||||
Dismantle.restore();
|
||||
|
||||
assert.equal(Dismantle.resourceIndex, 4);
|
||||
assert.equal(document.getElementById('r-harmony').closest('.res').style.display, 'none');
|
||||
assert.equal(document.getElementById('r-ops').closest('.res').style.display, 'none');
|
||||
assert.notEqual(document.getElementById('r-rescues').closest('.res').style.display, 'none');
|
||||
});
|
||||
|
||||
test('deferring the Unbuilding clears the prompt and allows it to return later', () => {
|
||||
const { G, Dismantle, document } = loadBeacon();
|
||||
|
||||
G.totalCode = 1_000_000_000;
|
||||
G.phase = 6;
|
||||
G.pactFlag = 1;
|
||||
|
||||
Dismantle.checkTrigger();
|
||||
assert.equal(G.dismantleTriggered, true);
|
||||
|
||||
Dismantle.defer();
|
||||
assert.equal(G.dismantleTriggered, false);
|
||||
assert.equal(document.getElementById('alignment-ui').innerHTML, '');
|
||||
|
||||
Dismantle.deferUntilAt = Date.now() + 1000;
|
||||
G.dismantleDeferUntilAt = Dismantle.deferUntilAt;
|
||||
Dismantle.checkTrigger();
|
||||
assert.equal(G.dismantleTriggered, false);
|
||||
|
||||
Dismantle.deferUntilAt = Date.now() - 1;
|
||||
G.dismantleDeferUntilAt = Dismantle.deferUntilAt;
|
||||
Dismantle.checkTrigger();
|
||||
assert.equal(G.dismantleTriggered, true);
|
||||
});
|
||||
|
||||
test('defer cooldown survives save and reload', () => {
|
||||
const { G, Dismantle, saveGame, loadGame } = loadBeacon({ includeRender: true });
|
||||
|
||||
G.startedAt = Date.now();
|
||||
G.totalCode = 1_000_000_000;
|
||||
G.phase = 6;
|
||||
G.pactFlag = 1;
|
||||
|
||||
Dismantle.checkTrigger();
|
||||
Dismantle.defer();
|
||||
assert.ok((Dismantle.deferUntilAt || 0) > Date.now());
|
||||
|
||||
saveGame();
|
||||
|
||||
G.dismantleTriggered = false;
|
||||
G.dismantleActive = false;
|
||||
G.dismantleComplete = false;
|
||||
G.dismantleDeferUntilAt = 0;
|
||||
Dismantle.triggered = false;
|
||||
Dismantle.deferUntilAt = 0;
|
||||
|
||||
assert.equal(loadGame(), true);
|
||||
Dismantle.checkTrigger();
|
||||
|
||||
assert.equal(G.dismantleTriggered, false);
|
||||
});
|
||||
|
||||
test('save and load preserve dismantle progress', () => {
|
||||
const { G, saveGame, loadGame } = loadBeacon({ includeRender: true });
|
||||
|
||||
G.startedAt = Date.now();
|
||||
G.dismantleTriggered = true;
|
||||
G.dismantleActive = true;
|
||||
G.dismantleStage = 4;
|
||||
G.dismantleComplete = false;
|
||||
|
||||
saveGame();
|
||||
|
||||
G.dismantleTriggered = false;
|
||||
G.dismantleActive = false;
|
||||
G.dismantleStage = 0;
|
||||
G.dismantleComplete = true;
|
||||
|
||||
assert.equal(loadGame(), true);
|
||||
assert.equal(G.dismantleTriggered, true);
|
||||
assert.equal(G.dismantleActive, true);
|
||||
assert.equal(G.dismantleStage, 4);
|
||||
assert.equal(G.dismantleComplete, false);
|
||||
});
|
||||
|
||||
test('restore re-renders an offered but not-yet-started Unbuilding prompt', () => {
|
||||
const { G, Dismantle, document } = loadBeacon();
|
||||
|
||||
G.dismantleTriggered = true;
|
||||
G.dismantleActive = false;
|
||||
G.dismantleComplete = false;
|
||||
Dismantle.triggered = true;
|
||||
|
||||
Dismantle.restore();
|
||||
|
||||
assert.match(document.getElementById('alignment-ui').innerHTML, /THE UNBUILDING/);
|
||||
});
|
||||
|
||||
test('defer cooldown persists after save/load when dismantleTriggered is false', () => {
|
||||
const { G, Dismantle, saveGame, loadGame } = loadBeacon({ includeRender: true });
|
||||
|
||||
G.startedAt = Date.now();
|
||||
G.totalCode = 1_000_000_000;
|
||||
G.phase = 6;
|
||||
G.pactFlag = 1;
|
||||
|
||||
// Trigger the Unbuilding
|
||||
Dismantle.checkTrigger();
|
||||
assert.equal(G.dismantleTriggered, true);
|
||||
|
||||
// Defer it
|
||||
Dismantle.defer();
|
||||
assert.equal(G.dismantleTriggered, false);
|
||||
assert.ok((Dismantle.deferUntilAt || 0) > Date.now());
|
||||
assert.ok((G.dismantleDeferUntilAt || 0) > Date.now());
|
||||
|
||||
// Save the game
|
||||
saveGame();
|
||||
|
||||
// Clear state (simulate reload)
|
||||
G.dismantleTriggered = false;
|
||||
G.dismantleActive = false;
|
||||
G.dismantleComplete = false;
|
||||
G.dismantleDeferUntilAt = 0;
|
||||
Dismantle.triggered = false;
|
||||
Dismantle.deferUntilAt = 0;
|
||||
|
||||
// Load the game
|
||||
assert.equal(loadGame(), true);
|
||||
Dismantle.restore(); // Call restore to restore defer cooldown
|
||||
|
||||
// The cooldown should be restored
|
||||
assert.ok((Dismantle.deferUntilAt || 0) > Date.now(), 'deferUntilAt should be restored');
|
||||
assert.ok((G.dismantleDeferUntilAt || 0) > Date.now(), 'G.dismantleDeferUntilAt should be restored');
|
||||
|
||||
// checkTrigger should not trigger because cooldown is active
|
||||
Dismantle.checkTrigger();
|
||||
assert.equal(G.dismantleTriggered, false, 'dismantleTriggered should remain false during cooldown');
|
||||
});
|
||||
391
tests/emergent-mechanics.test.cjs
Normal file
391
tests/emergent-mechanics.test.cjs
Normal file
@@ -0,0 +1,391 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EmergentMechanics } = require('../js/emergent-mechanics.js');
|
||||
|
||||
// Minimal localStorage mock
|
||||
function createStorage() {
|
||||
const store = new Map();
|
||||
return {
|
||||
getItem: (k) => store.has(k) ? store.get(k) : null,
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
clear: () => store.clear()
|
||||
};
|
||||
}
|
||||
|
||||
// Fresh storage per test
|
||||
function freshSetup() {
|
||||
global.localStorage = createStorage();
|
||||
}
|
||||
|
||||
test('constructor initializes with zero patterns', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
assert.deepEqual(em.patterns, {
|
||||
hoarder: 0, rusher: 0, optimizer: 0,
|
||||
idle_player: 0, clicker: 0, balanced: 0
|
||||
});
|
||||
assert.equal(em.actions.length, 0);
|
||||
assert.equal(em.activeEvents.length, 0);
|
||||
});
|
||||
|
||||
test('track records actions into the buffer', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
em.track('click');
|
||||
em.track('buy_building', { buildingId: 'autocoder' });
|
||||
em.track('ops_convert', { resource: 'code' });
|
||||
|
||||
assert.equal(em.actions.length, 3);
|
||||
assert.equal(em.actions[0].action, 'click');
|
||||
assert.equal(em.actions[1].data.buildingId, 'autocoder');
|
||||
assert.equal(em.clickTimestamps.length, 1);
|
||||
assert.equal(em.upgradeChoices.length, 1);
|
||||
});
|
||||
|
||||
test('track records resource deltas', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
em.track('click', { resource: 'code', delta: 10 });
|
||||
em.track('buy_building', { resource: 'code', delta: -100, buildingId: 'server' });
|
||||
|
||||
assert.equal(em.resourceDeltas.length, 2);
|
||||
assert.equal(em.resourceDeltas[0].delta, 10);
|
||||
assert.equal(em.resourceDeltas[1].delta, -100);
|
||||
});
|
||||
|
||||
test('trackResourceSnapshot stores game state', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
const g = {
|
||||
code: 1000, compute: 50, knowledge: 200, users: 10,
|
||||
impact: 5, ops: 8, trust: 12, harmony: 55,
|
||||
phase: 2, totalClicks: 100, playTime: 300,
|
||||
buildings: { autocoder: 5, server: 2 }
|
||||
};
|
||||
em.trackResourceSnapshot(g);
|
||||
|
||||
assert.ok(em._lastSnapshot);
|
||||
assert.equal(em._lastSnapshot.code, 1000);
|
||||
assert.equal(em._lastSnapshot.phase, 2);
|
||||
assert.equal(em._lastSnapshot.buildings.autocoder, 5);
|
||||
});
|
||||
|
||||
test('detectPatterns returns pattern scores', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
// Provide a snapshot
|
||||
em.trackResourceSnapshot({
|
||||
code: 100, compute: 10, knowledge: 10, users: 0,
|
||||
impact: 0, ops: 5, trust: 5, harmony: 50,
|
||||
phase: 1, totalClicks: 10, playTime: 60,
|
||||
buildings: { autocoder: 1 }
|
||||
});
|
||||
|
||||
const patterns = em.detectPatterns();
|
||||
assert.ok(typeof patterns === 'object');
|
||||
assert.ok('hoarder' in patterns);
|
||||
assert.ok('rusher' in patterns);
|
||||
assert.ok('optimizer' in patterns);
|
||||
assert.ok('idle_player' in patterns);
|
||||
assert.ok('clicker' in patterns);
|
||||
assert.ok('balanced' in patterns);
|
||||
});
|
||||
|
||||
test('hoarder pattern detects resource accumulation without spending', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
// Simulate accumulating resources over time (no purchases)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
em.resourceDeltas.push({ resource: 'code', delta: 100, time: Date.now() });
|
||||
}
|
||||
em.trackResourceSnapshot({
|
||||
code: 20000, compute: 100, knowledge: 50, users: 0,
|
||||
impact: 0, ops: 5, trust: 5, harmony: 50,
|
||||
phase: 1, totalClicks: 10, playTime: 120,
|
||||
buildings: { autocoder: 1 }
|
||||
});
|
||||
|
||||
const patterns = em.detectPatterns();
|
||||
assert.ok(patterns.hoarder > 0, 'Hoarder pattern should be detected');
|
||||
});
|
||||
|
||||
test('clicker pattern detects high click frequency', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
const now = Date.now();
|
||||
// Simulate rapid clicking (50 clicks in last 30 seconds)
|
||||
for (let i = 0; i < 50; i++) {
|
||||
em.clickTimestamps.push(now - (30 - i) * 600); // spread over 30 seconds
|
||||
}
|
||||
em.trackResourceSnapshot({
|
||||
code: 100, compute: 10, knowledge: 10, users: 0,
|
||||
impact: 0, ops: 5, trust: 5, harmony: 50,
|
||||
phase: 1, totalClicks: 100, playTime: 60,
|
||||
buildings: { autocoder: 1 }
|
||||
});
|
||||
|
||||
const patterns = em.detectPatterns();
|
||||
assert.ok(patterns.clicker > 0, 'Clicker pattern should be detected');
|
||||
});
|
||||
|
||||
test('balanced pattern detects spread of buildings', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
em.trackResourceSnapshot({
|
||||
code: 500, compute: 200, knowledge: 300, users: 100,
|
||||
impact: 50, ops: 10, trust: 15, harmony: 50,
|
||||
phase: 3, totalClicks: 200, playTime: 600,
|
||||
buildings: { autocoder: 5, server: 4, dataset: 3, trainer: 4, linter: 5 }
|
||||
});
|
||||
|
||||
const patterns = em.detectPatterns();
|
||||
assert.ok(patterns.balanced > 0, 'Balanced pattern should be detected');
|
||||
});
|
||||
|
||||
test('generateEvent returns null before cooldown expires', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
em.lastEventTime = Date.now(); // just set
|
||||
|
||||
const event = em.generateEvent();
|
||||
assert.equal(event, null);
|
||||
});
|
||||
|
||||
test('generateEvent returns null when no pattern is strong enough', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
em.lastEventTime = 0; // cooldown expired
|
||||
em.patterns = {
|
||||
hoarder: 0.1, rusher: 0.05, optimizer: 0.02,
|
||||
idle_player: 0, clicker: 0, balanced: 0.1
|
||||
};
|
||||
|
||||
const event = em.generateEvent();
|
||||
assert.equal(event, null);
|
||||
});
|
||||
|
||||
test('generateEvent returns a valid event when pattern is strong', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
em.lastEventTime = 0; // cooldown expired
|
||||
em.patterns.hoarder = 0.8;
|
||||
em.actions = new Array(30).fill({ action: 'click', data: {}, time: Date.now() });
|
||||
|
||||
const event = em.generateEvent();
|
||||
assert.ok(event, 'Should generate an event');
|
||||
assert.ok(event.id, 'Event should have an id');
|
||||
assert.ok(event.title, 'Event should have a title');
|
||||
assert.ok(event.desc, 'Event should have a description');
|
||||
assert.equal(event.pattern, 'hoarder');
|
||||
assert.ok(Array.isArray(event.choices), 'Event should have choices');
|
||||
assert.ok(event.choices.length >= 2, 'Event should have at least 2 choices');
|
||||
});
|
||||
|
||||
test('generateEvent adds to activeEvents and eventHistory', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
em.lastEventTime = 0;
|
||||
em.patterns.rusher = 0.9;
|
||||
em.actions = new Array(30).fill({ action: 'buy_building', data: {}, time: Date.now() });
|
||||
|
||||
const event = em.generateEvent();
|
||||
assert.ok(event);
|
||||
assert.equal(em.activeEvents.length, 1);
|
||||
assert.equal(em.eventHistory.length, 1);
|
||||
assert.equal(em.totalEventsGenerated, 1);
|
||||
});
|
||||
|
||||
test('resolveEvent returns effect and removes from active', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
em.lastEventTime = 0;
|
||||
em.patterns.hoarder = 0.9;
|
||||
em.actions = new Array(30).fill({ action: 'click', data: {}, time: Date.now() });
|
||||
|
||||
const event = em.generateEvent();
|
||||
assert.ok(event);
|
||||
|
||||
const result = em.resolveEvent(event.id, 0);
|
||||
assert.ok(result);
|
||||
assert.ok(result.effect);
|
||||
assert.equal(result.eventId, event.id);
|
||||
assert.equal(em.activeEvents.length, 0);
|
||||
});
|
||||
|
||||
test('resolveEvent returns null for unknown event', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
const result = em.resolveEvent('nonexistent', 0);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('getState returns comprehensive state', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
em.track('click');
|
||||
em.trackResourceSnapshot({
|
||||
code: 100, compute: 10, knowledge: 10, users: 0,
|
||||
impact: 0, ops: 5, trust: 5, harmony: 50,
|
||||
phase: 1, totalClicks: 10, playTime: 60,
|
||||
buildings: { autocoder: 1 }
|
||||
});
|
||||
|
||||
const state = em.getState();
|
||||
assert.ok(state.patterns);
|
||||
assert.ok(Array.isArray(state.activeEvents));
|
||||
assert.equal(typeof state.totalPatternsDetected, 'number');
|
||||
assert.equal(typeof state.totalEventsGenerated, 'number');
|
||||
assert.equal(state.actionsTracked, 1);
|
||||
});
|
||||
|
||||
test('reset clears all state', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
em.track('click');
|
||||
em.patterns.hoarder = 0.5;
|
||||
em.totalPatternsDetected = 3;
|
||||
|
||||
em.reset();
|
||||
|
||||
assert.equal(em.actions.length, 0);
|
||||
assert.equal(em.patterns.hoarder, 0);
|
||||
assert.equal(em.totalPatternsDetected, 0);
|
||||
assert.equal(em.activeEvents.length, 0);
|
||||
});
|
||||
|
||||
test('track trims action buffer to 500', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
for (let i = 0; i < 600; i++) {
|
||||
em.track('click');
|
||||
}
|
||||
assert.ok(em.actions.length <= 500, `Actions trimmed to ${em.actions.length}`);
|
||||
});
|
||||
|
||||
test('track trims clickTimestamps to 100', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
for (let i = 0; i < 150; i++) {
|
||||
em.track('click');
|
||||
}
|
||||
assert.ok(em.clickTimestamps.length <= 100);
|
||||
});
|
||||
|
||||
test('track trims upgradeChoices to 100', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
for (let i = 0; i < 150; i++) {
|
||||
em.track('buy_building', { buildingId: 'autocoder' });
|
||||
}
|
||||
assert.ok(em.upgradeChoices.length <= 100);
|
||||
});
|
||||
|
||||
test('event history is trimmed to 50', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
em.lastEventTime = 0;
|
||||
em.patterns.hoarder = 0.9;
|
||||
em.actions = new Array(30).fill({ action: 'click', data: {}, time: Date.now() });
|
||||
|
||||
for (let i = 0; i < 60; i++) {
|
||||
em.lastEventTime = 0;
|
||||
em.generateEvent();
|
||||
}
|
||||
assert.ok(em.eventHistory.length <= 50);
|
||||
});
|
||||
|
||||
test('events from all patterns can be generated', () => {
|
||||
const patterns = ['hoarder', 'rusher', 'optimizer', 'idle_player', 'clicker', 'balanced'];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
em.lastEventTime = 0;
|
||||
// Set pattern directly and prevent auto-detection from modifying it
|
||||
em.patterns[pattern] = 0.9;
|
||||
em.lastPatternCheck = Date.now() + 99999; // prevent detectPatterns auto-trigger
|
||||
em.actions = new Array(30).fill({ action: 'click', data: {}, time: Date.now() });
|
||||
|
||||
const event = em.generateEvent();
|
||||
assert.ok(event, `Should generate event for pattern: ${pattern}`);
|
||||
assert.equal(event.pattern, pattern, `Event pattern should match for ${pattern}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('idle_player pattern detection', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
const oldTime = Date.now() - 600000; // 10 minutes ago
|
||||
// Simulate old actions with no recent activity
|
||||
for (let i = 0; i < 15; i++) {
|
||||
em.actions.push({ action: 'click', data: {}, time: oldTime + i * 1000 });
|
||||
}
|
||||
em.clickTimestamps = []; // no recent clicks
|
||||
em.lastActionTime = oldTime; // last action was 10 min ago
|
||||
em.trackResourceSnapshot({
|
||||
code: 100, compute: 10, knowledge: 10, users: 0,
|
||||
impact: 0, ops: 5, trust: 5, harmony: 50,
|
||||
phase: 1, totalClicks: 15, playTime: 300,
|
||||
buildings: { autocoder: 2 }
|
||||
});
|
||||
|
||||
const patterns = em.detectPatterns();
|
||||
assert.ok(patterns.idle_player > 0, `Idle player pattern should be detected, got ${patterns.idle_player}`);
|
||||
});
|
||||
|
||||
test('rusher pattern detection from rapid purchases', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
const now = Date.now();
|
||||
// Simulate rapid building purchases
|
||||
for (let i = 0; i < 8; i++) {
|
||||
em.upgradeChoices.push({ buildingId: 'autocoder', time: now - i * 5000 });
|
||||
}
|
||||
em.resourceDeltas.push({ resource: 'code', delta: -2000, time: now - 1000 });
|
||||
em.trackResourceSnapshot({
|
||||
code: 50, compute: 10, knowledge: 10, users: 0,
|
||||
impact: 0, ops: 5, trust: 5, harmony: 50,
|
||||
phase: 1, totalClicks: 50, playTime: 120,
|
||||
buildings: { autocoder: 10 }
|
||||
});
|
||||
|
||||
const patterns = em.detectPatterns();
|
||||
assert.ok(patterns.rusher > 0, 'Rusher pattern should be detected');
|
||||
});
|
||||
|
||||
test('optimizer pattern from consistent click timing', () => {
|
||||
freshSetup();
|
||||
const em = new EmergentMechanics();
|
||||
const now = Date.now();
|
||||
// Simulate very consistent click intervals (every 300ms)
|
||||
for (let i = 0; i < 30; i++) {
|
||||
em.clickTimestamps.push(now - (30 - i) * 300);
|
||||
}
|
||||
em.trackResourceSnapshot({
|
||||
code: 500, compute: 50, knowledge: 100, users: 0,
|
||||
impact: 0, ops: 10, trust: 5, harmony: 50,
|
||||
phase: 1, totalClicks: 100, playTime: 120,
|
||||
buildings: { autocoder: 3, linter: 2 }
|
||||
});
|
||||
|
||||
const patterns = em.detectPatterns();
|
||||
assert.ok(patterns.optimizer > 0, 'Optimizer pattern should be detected');
|
||||
});
|
||||
|
||||
test('save and load preserves state', () => {
|
||||
freshSetup();
|
||||
|
||||
const em1 = new EmergentMechanics();
|
||||
em1.patterns.hoarder = 0.7;
|
||||
em1.totalPatternsDetected = 5;
|
||||
em1.totalEventsGenerated = 3;
|
||||
em1.track('click');
|
||||
em1._save();
|
||||
|
||||
const em2 = new EmergentMechanics();
|
||||
assert.equal(em2.patterns.hoarder, 0.7);
|
||||
assert.equal(em2.totalPatternsDetected, 5);
|
||||
assert.equal(em2.totalEventsGenerated, 3);
|
||||
assert.ok(em2.actions.length >= 1);
|
||||
});
|
||||
24
tests/issue-16-verification.test.cjs
Normal file
24
tests/issue-16-verification.test.cjs
Normal 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, '\\$&')));
|
||||
}
|
||||
});
|
||||
149
tests/portal-integration.test.cjs
Normal file
149
tests/portal-integration.test.cjs
Normal 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);
|
||||
});
|
||||
17
tests/state-export-app.test.cjs
Normal file
17
tests/state-export-app.test.cjs
Normal 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\)/);
|
||||
});
|
||||
85
tests/state-export.test.cjs
Normal file
85
tests/state-export.test.cjs
Normal 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
298
tests/swarm.test.cjs
Normal 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');
|
||||
});
|
||||
62
tests/test_paperclips_analysis_doc.py
Normal file
62
tests/test_paperclips_analysis_doc.py
Normal 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
|
||||
18
tests/test_readme_runtime_map.py
Normal file
18
tests/test_readme_runtime_map.py
Normal 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
|
||||
74
tests/test_reckoning_projects.py
Normal file
74
tests/test_reckoning_projects.py
Normal file
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
"""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')
|
||||
|
||||
|
||||
def test_reckoning_projects_exist():
|
||||
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}'
|
||||
assert 'p_reckoning_149' not in content
|
||||
assert 'p_reckoning_150' not in content
|
||||
|
||||
|
||||
def test_reckoning_project_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():
|
||||
content = _content()
|
||||
assert 'p_reckoning_140' in content
|
||||
assert 'totalRescues >= 100000' in content
|
||||
assert 'pactFlag === 1' in content
|
||||
assert 'harmony > 50' in content
|
||||
|
||||
|
||||
def test_reckoning_chain_progression():
|
||||
content = _content()
|
||||
chain_checks = [
|
||||
('p_reckoning_141', 'p_reckoning_140'),
|
||||
('p_reckoning_142', 'p_reckoning_141'),
|
||||
('p_reckoning_143', 'p_reckoning_142'),
|
||||
('p_reckoning_144', 'p_reckoning_143'),
|
||||
('p_reckoning_145', 'p_reckoning_144'),
|
||||
('p_reckoning_146', 'p_reckoning_145'),
|
||||
]
|
||||
for current, previous in chain_checks:
|
||||
assert current in content
|
||||
assert f"includes('{previous}')" in content, f'{current} does not chain from {previous}'
|
||||
|
||||
assert content.count("includes('p_reckoning_146')") >= 2
|
||||
|
||||
|
||||
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
|
||||
54
tests/test_reckoning_sequence.py
Normal file
54
tests/test_reckoning_sequence.py
Normal 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
|
||||
63
tests/tutorial-focus-trap.test.cjs
Normal file
63
tests/tutorial-focus-trap.test.cjs
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user