Compare commits

..

2 Commits

Author SHA1 Message Date
VENTUS
c32e7cfe51 feat: Nexus portal documentation and entry for The Beacon
Some checks failed
Accessibility Checks / a11y-audit (pull_request) Successful in 21s
Smoke Test / smoke (pull_request) Failing after 27s
Closes #167

Adds documentation for integrating The Beacon as a portal in the Nexus world:
- docs/NEXUS_PORTAL.md: Complete integration guide
- portal-entry.json: Sample portal entry for portals.json

Covers three implementation options:
1. Simple iframe embed (recommended starting point)
2. State synchronization via postMessage
3. URL parameters for state passing

Includes security considerations, testing guide, and related issues.
2026-04-14 23:40:28 -04:00
729343e503 Fix #137: Unbuilding defer cooldown persists across save/load (#143)
Some checks failed
Smoke Test / smoke (push) Failing after 9s
Merge PR #143 (squash)
2026-04-14 22:10:06 +00:00
7 changed files with 404 additions and 10 deletions

165
docs/NEXUS_PORTAL.md Normal file
View File

@@ -0,0 +1,165 @@
# The Beacon — Nexus Portal Integration
## Overview
The Beacon can be embedded as a portal in the Nexus world, allowing users to play the game directly within the Nexus interface.
## Portal Configuration
Add the following entry to `portals.json` in the Nexus repository:
```json
{
"id": "the-beacon",
"name": "The Beacon",
"description": "An idle game about building a sovereign AI. Click to code, build, and rescue.",
"url": "https://the-beacon.alexanderwhitestone.com",
"icon": "🌟",
"category": "games",
"tags": ["idle", "game", "sovereign", "ai"],
"iframe": true,
"width": 1200,
"height": 800,
"sandbox": "allow-scripts allow-same-origin allow-forms",
"persistence": "localStorage",
"stateKey": "the-beacon-v2"
}
```
## Requirements
### 1. URL Hosting
The Beacon must be hosted at a publicly accessible URL. Current deployment:
- **Production:** https://the-beacon.alexanderwhitestone.com
- **Local:** http://localhost:8080 (for development)
### 2. Iframe Compatibility
The Beacon is a standalone HTML file with no server-side dependencies. It can be embedded in an iframe without issues:
- No `X-Frame-Options` restrictions
- No `Content-Security-Policy` frame-ancestors restrictions
- All assets are inline (CSS, JS) or from CDN
### 3. Game State Persistence
Game state is stored in `localStorage` with key `the-beacon-v2`:
```javascript
// Save
localStorage.setItem('the-beacon-v2', JSON.stringify(gameState));
// Load
const saved = localStorage.getItem('the-beacon-v2');
if (saved) {
const gameState = JSON.parse(saved);
// Restore game state
}
```
### 4. Cross-Origin Considerations
Since The Beacon is hosted on a different domain than Nexus:
- `localStorage` is origin-specific
- Game state won't persist across different domains
- Solution: Use `postMessage` API for state synchronization
## Implementation Options
### Option 1: Simple iframe Embed
The simplest approach — just embed The Beacon in an iframe:
```html
<iframe
src="https://the-beacon.alexanderwhitestone.com"
width="1200"
height="800"
sandbox="allow-scripts allow-same-origin allow-forms"
title="The Beacon - Idle Game">
</iframe>
```
**Pros:**
- Simple, no code changes needed
- Game runs in isolated context
**Cons:**
- State doesn't persist across Nexus sessions
- No integration with Nexus UI
### Option 2: State Synchronization
Add postMessage communication between The Beacon and Nexus:
1. **Beacon → Nexus:** Send state updates on save
```javascript
// In The Beacon
window.parent.postMessage({
type: 'beacon-state',
state: gameState
}, '*');
```
2. **Nexus → Beacon:** Send state on portal load
```javascript
// In Nexus portal
beaconFrame.contentWindow.postMessage({
type: 'nexus-load-state',
state: savedState
}, '*');
```
**Pros:**
- State persists across sessions
- Better integration
**Cons:**
- Requires code changes in both repos
- More complex
### Option 3: URL Parameters
Pass initial state via URL parameters:
```
https://the-beacon.alexanderwhitestone.com?state=<base64-encoded-state>
```
**Pros:**
- Simple, no cross-origin issues
- Works with any iframe
**Cons:**
- URL length limits
- State visible in URL
- No automatic state saving
## Recommended Approach
**Start with Option 1** (simple iframe embed) to validate the portal works. Then implement Option 2 for state synchronization if needed.
## Testing
### Manual Test
1. Add portal entry to Nexus portals.json
2. Open Nexus world
3. Click The Beacon portal
4. Verify game loads in iframe
5. Play game, save progress
6. Close portal, reopen
7. Verify state persistence (Option 2 required)
### Automated Test
```javascript
// Test iframe loads
const iframe = document.querySelector('iframe[src*="the-beacon"]');
assert(iframe !== null, 'Portal iframe exists');
// Test game loads
iframe.onload = () => {
assert(iframe.contentDocument !== null, 'Game loaded');
};
```
## Security Considerations
1. **Sandbox Attribute:** Use `sandbox` to restrict iframe capabilities
2. **Content Security:** The Beacon has no external dependencies except CDN fonts
3. **Data Privacy:** Game state is stored locally, no server communication
4. **XSS Protection:** The Beacon doesn't accept user input beyond game clicks
## Related Issues
- **#167:** Nexus portal for The Beacon — playable in-world
- **#12:** Prestige New Game+ System (affects state persistence)

View File

@@ -165,6 +165,9 @@ const G = {
dismantleTriggered: false,
dismantleActive: false,
dismantleStage: 0,
dismantleResourceIndex: 0,
dismantleResourceTimer: 0,
dismantleDeferUntilAt: 0,
dismantleComplete: false
};

View File

@@ -14,10 +14,10 @@ const Dismantle = {
tickTimer: 0,
active: false,
triggered: false,
deferUntilTick: 0,
deferUntilAt: 0,
// Timing: seconds between each dismantle stage
STAGE_INTERVALS: [0, 3.0, 2.5, 2.5, 2.0, 3.5, 2.0, 2.0, 2.5],
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)
@@ -39,7 +39,8 @@ const Dismantle = {
*/
checkTrigger() {
if (this.triggered || G.dismantleTriggered || this.active || G.dismantleActive || G.dismantleComplete) return;
if ((G.tick || 0) < (this.deferUntilTick || 0)) return;
const deferUntilAt = G.dismantleDeferUntilAt || this.deferUntilAt || 0;
if (Date.now() < deferUntilAt) return;
if (!this.isEligible()) return;
this.offerChoice();
},
@@ -53,6 +54,9 @@ const Dismantle = {
G.dismantleActive = false;
G.dismantleComplete = false;
G.dismantleStage = 0;
G.dismantleResourceIndex = 0;
G.dismantleResourceTimer = 0;
G.dismantleDeferUntilAt = 0;
G.beaconEnding = false;
G.running = true;
@@ -105,7 +109,8 @@ const Dismantle = {
this.clearChoice();
this.triggered = false;
G.dismantleTriggered = false;
this.deferUntilTick = (G.tick || 0) + 50;
this.deferUntilAt = Date.now() + 5000;
G.dismantleDeferUntilAt = this.deferUntilAt;
log('The Beacon waits. It will ask again.');
},
@@ -115,12 +120,14 @@ const Dismantle = {
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
@@ -135,6 +142,7 @@ const Dismantle = {
this.resourceSequence = this.getResourceList();
this.resourceIndex = 0;
this.resourceTimer = 0;
this.syncProgress();
log('', false);
log('=== THE UNBUILDING ===', true);
@@ -180,6 +188,7 @@ const Dismantle = {
this.dismantleNextResource();
this.resourceIndex++;
}
this.syncProgress();
}
// Advance to next stage
@@ -195,6 +204,7 @@ const Dismantle = {
*/
advanceStage() {
this.stage++;
this.syncProgress();
if (this.stage <= 8) {
this.renderStage();
@@ -210,6 +220,12 @@ const Dismantle = {
}
},
syncProgress() {
G.dismantleStage = this.stage;
G.dismantleResourceIndex = this.resourceIndex;
G.dismantleResourceTimer = this.resourceTimer;
},
/**
* Disappear the next resource in the sequence.
*/
@@ -445,7 +461,11 @@ const Dismantle = {
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();
@@ -461,6 +481,11 @@ const Dismantle = {
this.triggered = true;
this.renderChoice();
}
// Restore defer cooldown even if not triggered
if (G.dismantleDeferUntilAt > 0) {
this.deferUntilAt = G.dismantleDeferUntilAt;
}
},
/**
@@ -501,6 +526,10 @@ const Dismantle = {
case 8: this.instantHide('log'); break;
}
}
if (this.stage === 5 && this.resourceIndex > 0) {
this.instantHideFirstResources(this.resourceIndex);
}
},
instantHide(id) {
@@ -508,6 +537,16 @@ const Dismantle = {
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;

View File

@@ -35,7 +35,7 @@ window.addEventListener('load', function () {
if (G.driftEnding) {
G.running = false;
renderDriftEnding();
} else if (typeof Dismantle !== 'undefined' && (G.dismantleTriggered || G.dismantleActive || G.dismantleComplete)) {
} else if (typeof Dismantle !== 'undefined' && (G.dismantleTriggered || G.dismantleActive || G.dismantleComplete || G.dismantleDeferUntilAt > 0)) {
Dismantle.restore();
} else if (G.beaconEnding) {
G.running = false;

View File

@@ -37,6 +37,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">
@@ -218,6 +230,9 @@ function saveGame() {
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()
};
@@ -251,7 +266,8 @@ function loadGame() {
'lastEventAt', 'totalEventsResolved', 'buyAmount',
'sprintActive', 'sprintTimer', 'sprintCooldown',
'swarmFlag', 'swarmRate', 'strategicFlag', 'projectsCollapsed',
'dismantleTriggered', 'dismantleActive', 'dismantleStage', 'dismantleComplete'
'dismantleTriggered', 'dismantleActive', 'dismantleStage',
'dismantleResourceIndex', 'dismantleResourceTimer', 'dismantleDeferUntilAt', 'dismantleComplete'
];
G.isLoading = true;

20
portal-entry.json Normal file
View File

@@ -0,0 +1,20 @@
{
"id": "the-beacon",
"name": "The Beacon",
"description": "An idle game about building a sovereign AI. Click to code, build, and rescue.",
"url": "https://the-beacon.alexanderwhitestone.com",
"icon": "\ud83c\udf1f",
"category": "games",
"tags": [
"idle",
"game",
"sovereign",
"ai"
],
"iframe": true,
"width": 1200,
"height": 800,
"sandbox": "allow-scripts allow-same-origin allow-forms",
"persistence": "localStorage",
"stateKey": "the-beacon-v2"
}

View File

@@ -209,6 +209,7 @@ this.__exports = {
G,
Dismantle,
tick,
renderAlignment: typeof renderAlignment === 'function' ? renderAlignment : null,
saveGame: typeof saveGame === 'function' ? saveGame : null,
loadGame: typeof loadGame === 'function' ? loadGame : null
};`, context);
@@ -242,13 +243,92 @@ test('tick offers the Unbuilding instead of ending the game immediately', () =>
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;
G.tick = 0;
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, true);
@@ -257,15 +337,44 @@ test('deferring the Unbuilding clears the prompt and allows it to return later',
assert.equal(G.dismantleTriggered, false);
assert.equal(document.getElementById('alignment-ui').innerHTML, '');
G.tick = (Dismantle.deferUntilTick || 0) - 0.1;
Dismantle.deferUntilAt = Date.now() + 1000;
G.dismantleDeferUntilAt = Dismantle.deferUntilAt;
Dismantle.checkTrigger();
assert.equal(G.dismantleTriggered, false);
G.tick = (Dismantle.deferUntilTick || 0) + 1;
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 });
@@ -300,4 +409,46 @@ test('restore re-renders an offered but not-yet-started Unbuilding prompt', () =
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');
});