Compare commits

..

1 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
4 changed files with 185 additions and 285 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)

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

@@ -1,235 +0,0 @@
#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
function extractProjects(source) {
const start = source.indexOf('const PDEFS = [');
if (start === -1) return [];
const arrayStart = source.indexOf('[', start);
const arrayEnd = source.indexOf('];', arrayStart);
const body = source.slice(arrayStart + 1, arrayEnd);
const objects = [];
let depth = 0;
let objStart = -1;
let inSingle = false;
let inDouble = false;
let escaped = false;
for (let i = 0; i < body.length; i += 1) {
const ch = body[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === '\\') {
escaped = true;
continue;
}
if (!inDouble && ch === "'") {
inSingle = !inSingle;
continue;
}
if (!inSingle && ch === '"') {
inDouble = !inDouble;
continue;
}
if (inSingle || inDouble) continue;
if (ch === '{') {
if (depth === 0) objStart = i;
depth += 1;
} else if (ch === '}') {
depth -= 1;
if (depth === 0 && objStart !== -1) {
objects.push(body.slice(objStart, i + 1));
objStart = -1;
}
}
}
return objects.map((obj) => {
const id = /id:\s*'([^']+)'/.exec(obj);
const name = /name:\s*'([^']+)'/.exec(obj);
const trigger = new RegExp(String.raw`trigger:\s*\(\)\s*=>\s*([\s\S]*?)(?:,\s*effect:|,\s*repeatable:|,\s*milestone:|\n\s*\})`).exec(obj);
const effect = /effect:\s*\(\)\s*=>\s*\{([\s\S]*?)\}/.exec(obj);
return {
id: id ? id[1] : null,
name: name ? name[1] : '',
trigger: trigger ? trigger[1].trim() : '',
effect: effect ? effect[1].trim() : '',
};
}).filter((project) => project.id);
}
function buildDependencyGraph(projects) {
const ids = new Set(projects.map((project) => project.id));
const nodes = projects.map((project) => ({ id: project.id, name: project.name }));
const edges = [];
for (const project of projects) {
for (const dep of ids) {
const token = `includes('${dep}')`;
if (project.trigger.includes(token)) {
edges.push({ from: dep, to: project.id, reason: 'completedProjects.includes' });
}
}
}
return { nodes, edges };
}
function isTerminalProject(project) {
return [
'G.beaconEnding = true',
'G.dismantleTriggered = true',
'G.dismantleActive = true',
'renderBeaconEnding',
'renderDriftEnding',
].some((token) => project.effect.includes(token));
}
function analyzeReckoningChain(projects) {
const graph = buildDependencyGraph(projects);
const reckoningProjects = projects
.filter((project) => project.id.startsWith('p_reckoning_'))
.sort((a, b) => a.id.localeCompare(b.id));
const deadEnds = [];
const fixProposals = [];
if (reckoningProjects.length === 0) {
deadEnds.push({
type: 'missing_root_project',
message: 'No ReCKoning projects exist in js/data.js.',
});
fixProposals.push({
type: 'create_chain',
proposals: [
'p_reckoning_140',
'p_reckoning_141',
'p_reckoning_142',
'p_reckoning_143',
'p_reckoning_144',
],
rationale: 'Create a narrative chain with at least one terminal project so endgame guardrails have a live project path.',
});
return {
totalProjects: projects.length,
totalEdges: graph.edges.length,
reckoningProjects: [],
hasReckoningProjects: false,
deadEnds,
fixProposals,
};
}
const outgoing = new Map();
for (const project of reckoningProjects) {
outgoing.set(project.id, graph.edges.filter((edge) => edge.from === project.id).map((edge) => edge.to));
}
const ids = reckoningProjects.map((project) => project.id);
const root = 'p_reckoning_140';
if (!ids.includes(root)) {
deadEnds.push({
type: 'missing_expected_root',
message: `Expected root project ${root} is missing.`,
});
fixProposals.push({
type: 'add_root',
project: root,
rationale: 'Root project anchors the ReCKoning chain and prevents the endgame panel from opening empty.',
});
}
for (const project of reckoningProjects) {
const next = outgoing.get(project.id) || [];
if (next.length === 0 && !isTerminalProject(project)) {
deadEnds.push({
type: 'leaf_without_terminal_effect',
project: project.id,
message: `${project.id} unlocks nothing downstream and does not end the sequence.`,
});
const num = Number(project.id.split('_').pop());
if (Number.isFinite(num)) {
fixProposals.push({
type: 'add_missing_link',
after: project.id,
project: `p_reckoning_${num + 1}`,
rationale: 'Add the next narrative project or mark this node as a true terminal endgame effect.',
});
}
}
}
return {
totalProjects: projects.length,
totalEdges: graph.edges.length,
reckoningProjects: reckoningProjects.map((project) => ({
id: project.id,
name: project.name,
outgoing: outgoing.get(project.id) || [],
terminal: isTerminalProject(project),
})),
hasReckoningProjects: true,
deadEnds,
fixProposals,
};
}
function loadSource(filePath) {
return fs.readFileSync(filePath, 'utf8');
}
function generateReport(filePath) {
const source = loadSource(filePath);
const projects = extractProjects(source);
return analyzeReckoningChain(projects);
}
function parseArgs(argv) {
const args = {
file: path.resolve(process.cwd(), 'js', 'data.js'),
json: false,
strict: false,
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--file') {
args.file = path.resolve(process.cwd(), argv[i + 1]);
i += 1;
} else if (arg === '--json') {
args.json = true;
} else if (arg === '--strict') {
args.strict = true;
}
}
return args;
}
function main(argv = process.argv.slice(2)) {
const args = parseArgs(argv);
const report = generateReport(args.file);
if (args.json) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
} else {
process.stdout.write(`ReCKoning projects: ${report.reckoningProjects.length}\n`);
process.stdout.write(`Dead ends: ${report.deadEnds.length}\n`);
}
if (args.strict && report.deadEnds.length > 0) {
return 1;
}
return 0;
}
module.exports = {
extractProjects,
buildDependencyGraph,
analyzeReckoningChain,
generateReport,
main,
};
if (require.main === module) {
process.exitCode = main();
}

View File

@@ -1,50 +0,0 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const {
extractProjects,
buildDependencyGraph,
analyzeReckoningChain,
generateReport,
} = require('../scripts/reckoning_chain_validator.cjs');
const DATA_PATH = path.resolve(__dirname, '..', 'js', 'data.js');
test('extractProjects finds the beacon project definitions', () => {
const fs = require('node:fs');
const source = fs.readFileSync(DATA_PATH, 'utf8');
const projects = extractProjects(source);
assert.ok(projects.length >= 30);
assert.ok(projects.some((project) => project.id === 'p_hermes_deploy'));
});
test('buildDependencyGraph links completed-project triggers', () => {
const projects = [
{ id: 'p_reckoning_140', name: 'Start', trigger: 'G.phase >= 6', effect: '' },
{ id: 'p_reckoning_141', name: 'Next', trigger: "G.completedProjects.includes('p_reckoning_140')", effect: '' },
];
const graph = buildDependencyGraph(projects);
assert.deepEqual(graph.edges, [
{ from: 'p_reckoning_140', to: 'p_reckoning_141', reason: 'completedProjects.includes' },
]);
});
test('analyzeReckoningChain flags missing chain on current beacon data', () => {
const report = generateReport(DATA_PATH);
assert.equal(report.hasReckoningProjects, false);
assert.ok(report.deadEnds.some((item) => item.type === 'missing_root_project'));
assert.ok(report.fixProposals.some((item) => item.type === 'create_chain'));
});
test('analyzeReckoningChain accepts a healthy synthetic ReCKoning chain', () => {
const projects = [
{ id: 'p_reckoning_140', name: 'ReCKoning I', trigger: 'G.phase >= 6', effect: '' },
{ id: 'p_reckoning_141', name: 'ReCKoning II', trigger: "G.completedProjects.includes('p_reckoning_140')", effect: '' },
{ id: 'p_reckoning_142', name: 'ReCKoning III', trigger: "G.completedProjects.includes('p_reckoning_141')", effect: 'G.beaconEnding = true;' },
];
const report = analyzeReckoningChain(projects);
assert.equal(report.hasReckoningProjects, true);
assert.equal(report.deadEnds.length, 0);
assert.equal(report.reckoningProjects.length, 3);
assert.equal(report.reckoningProjects[2].terminal, true);
});