Add Nexus OS shell with module loader, event bus, and cross-module rewards#39
Add Nexus OS shell with module loader, event bus, and cross-module rewards#39Codex wants to merge 1 commit into
Conversation
Co-authored-by: NicholaiMadias <73684379+NicholaiMadias@users.noreply.github.com>
✅ Deploy Preview for gulfnexus ready!
To edit notification comments on pull requests, go to your Netlify project configuration. |
There was a problem hiding this comment.
Pull request overview
Adds a new “Nexus Arcade OS” HTML shell that loads feature modules (overworld, quiz, arcade, listings, ministry, badges, lore codex) and connects them via a global event bus and reward/state engine, integrating the existing match-3 game into the shell.
Changes:
- Introduces
os-shell.htmlwith a module loader, globalwindow.NexusOSevent bus, shared state, and reward engine. - Adds new HTML modules under
modules/for Overworld navigation, Seven Stars quiz flow, Arcade UI wrapper, Listings triggers, Ministry sink, Badges view, and Lore Codex. - Extends
match-maker-ui.jsto emit combo/gameover events to NexusOS and supports optional hook overrides; updates README to document the shell.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| os-shell.html | New OS shell UI + module loader + NexusOS event bus + shared state/reward engine + quiz/codex rendering |
| modules/overworld.html | Overworld map + module jump buttons wired by shell initializer |
| modules/seven-stars.html | Quiz module container for shell-driven rendering |
| modules/arcade.html | Arcade module wrapper containing Match Maker DOM + combo timeline container |
| modules/listings.html | Listing buttons emitting listing-viewed events (wired by shell initializer) |
| modules/ministry.html | Ministry sink UI updated by shell reward/state rendering |
| modules/badges.html | Badges/inventory feed container updated by shell rendering |
| modules/lore-codex.html | Lore codex containers for shell rendering |
| match-maker-ui.js | Adds combo chain tracking, tiering, NexusOS emits, and optional hooks in initMatchMaker |
| README.md | Documents OS shell entrypoint and module/bus concepts |
| moduleInitializers[id]?.(); | ||
| markActiveNav(id); | ||
| } catch (e) { | ||
| container.innerHTML = `<div class="feed-card">Module failed to load: ${e.message}</div>`; |
There was a problem hiding this comment.
The error path uses container.innerHTML = ...${e.message}.... If id is attacker-controlled (or if a thrown error message includes HTML), this becomes an XSS sink. Prefer setting textContent on a created element, or escaping the message before inserting into innerHTML.
| container.innerHTML = `<div class="feed-card">Module failed to load: ${e.message}</div>`; | |
| container.textContent = ''; | |
| const errorCard = document.createElement('div'); | |
| errorCard.className = 'feed-card'; | |
| errorCard.textContent = `Module failed to load: ${e.message}`; | |
| container.appendChild(errorCard); |
| (this.events[event] ||= []).push(fn); | ||
| }; | ||
| nexus.emit = function(event, data) { | ||
| (this.events[event] || []).forEach(fn => fn(data)); |
There was a problem hiding this comment.
nexus.emit calls listeners directly in a forEach. If any listener throws, remaining listeners won't run and the exception will bubble to the browser task queue, potentially breaking cross-module reward/state updates. Consider wrapping each listener invocation in try/catch (and optionally logging) so one module can't take down the event bus.
| (this.events[event] || []).forEach(fn => fn(data)); | |
| (this.events[event] || []).forEach(fn => { | |
| try { | |
| fn(data); | |
| } catch (error) { | |
| console.error(`NexusOS listener error for event "${event}"`, error); | |
| } | |
| }); |
| function resolveMatches() { | ||
| const matches = findMatches(grid); | ||
| if (matches.length === 0) { | ||
| comboChain = 0; | ||
| renderGrid(); | ||
| checkLevelUp(); | ||
| checkGameOver(); |
There was a problem hiding this comment.
In the shell, users can navigate away from the Arcade module while a chain reaction is still resolving (this function schedules itself via setTimeout later). If #match-grid is removed, renderGrid() can throw because it assumes the container exists. Consider adding a “module still mounted” guard in resolveMatches (and/or making renderGrid null-safe) and clearing any pending resolve timer on re-init/unload.
| nexus.state = nexus.state || { | ||
| mysteryMeter: clamp(Number(localStorage.getItem('nexus_mystery_meter') || 18), 0, 100), | ||
| loreUnlocked: new Set(JSON.parse(localStorage.getItem('nexus_lore_unlocked') || '[]')), |
There was a problem hiding this comment.
State hydration from localStorage uses JSON.parse(localStorage.getItem('nexus_lore_unlocked') || '[]') without a guard. If the stored value is corrupted/non-JSON, this will throw during boot and prevent the shell from loading. Consider wrapping the parse in try/catch (falling back to an empty array) to make startup resilient.
| nexus.state = nexus.state || { | |
| mysteryMeter: clamp(Number(localStorage.getItem('nexus_mystery_meter') || 18), 0, 100), | |
| loreUnlocked: new Set(JSON.parse(localStorage.getItem('nexus_lore_unlocked') || '[]')), | |
| function readJSONFromLocalStorage(key, fallback) { | |
| const rawValue = localStorage.getItem(key); | |
| if (rawValue === null) return fallback; | |
| try { | |
| return JSON.parse(rawValue); | |
| } catch { | |
| return fallback; | |
| } | |
| } | |
| nexus.state = nexus.state || { | |
| mysteryMeter: clamp(Number(localStorage.getItem('nexus_mystery_meter') || 18), 0, 100), | |
| loreUnlocked: new Set(readJSONFromLocalStorage('nexus_lore_unlocked', [])), |
|
|
||
| - Open `os-shell.html` to launch the Nexus shell with module loader, overworld map, and reward bus. | ||
| - Modules live under `modules/` (Seven Stars quiz, Match-3 arcade, Listings, Ministry, Badges, Lore Codex). | ||
| - The global `NexusOS` event bus coordinates navigation (`loadModule(id)`) and gameplay events (`star-collected`, `arcade-combo`, `listing-viewed`, `reward-granted`). |
There was a problem hiding this comment.
README mentions the NexusOS bus coordinates navigation via loadModule(id), but loadModule is currently a private function inside os-shell.html (not exposed on window.NexusOS). Either expose it (e.g., NexusOS.loadModule = loadModule) or adjust the README wording to avoid implying it’s part of the public bus API.
| - The global `NexusOS` event bus coordinates navigation (`loadModule(id)`) and gameplay events (`star-collected`, `arcade-combo`, `listing-viewed`, `reward-granted`). | |
| - The shell coordinates module navigation internally, while the global `NexusOS` event bus handles gameplay events (`star-collected`, `arcade-combo`, `listing-viewed`, `reward-granted`). |
| const res = await fetch(`./modules/${id}.html`); | ||
| if (!res.ok) throw new Error(`Failed to load ${id}`); | ||
| const html = await res.text(); | ||
| container.innerHTML = html; | ||
| moduleInitializers[id]?.(); |
There was a problem hiding this comment.
loadModule fetches ./modules/${id}.html using an unvalidated id (from DOM dataset / callable from console). This allows path traversal like ../ and makes it easier to load and inject unintended same-origin HTML into the shell. Consider enforcing an allowlist (e.g., keys of moduleInitializers) and/or a strict id regex before fetching.
| list.innerHTML = Object.entries(loreEntries).map(([id, entry]) => { | ||
| const isUnlocked = unlocked.has(id); | ||
| return ` | ||
| <div class="codex-item ${isUnlocked ? 'active' : 'locked'}" data-entry="${id}"> | ||
| <div>${entry.title}</div> | ||
| <small class="muted">${entry.church}</small> |
There was a problem hiding this comment.
renderLoreCodex assigns the active class based on whether an entry is unlocked, but active styling reads like a “currently selected” state (and clicks don’t update it). This makes all unlocked entries look selected and there’s no visual indication of the currently viewed entry. Consider using separate classes (e.g., unlocked vs active) and toggling active on the selected entry when renderCodexBody runs.
| comboChain += 1; | ||
| const largestMatch = Math.max(...matches.map(m => m.length)); | ||
| const tier = determineTier(largestMatch, comboChain); | ||
| emitCombo(comboChain, tier, null); |
There was a problem hiding this comment.
largestMatch is computed as Math.max(...matches.map(m => m.length)), but findMatches currently returns a single group containing all matched cells (matchMakerState.js:91-96). That means this value is really “total cleared cells this resolution”, not the largest contiguous match, which can skew tiering and makes the name misleading. Consider either returning distinct match groups from findMatches (if you truly want “largest match”) or renaming/adjusting tier logic to match the actual semantics.
| <div class="muted">${entry.church}</div> | ||
| <p>${entry.body}</p> | ||
| <div class="badge ${unlocked ? '' : 'locked-pill'}"> | ||
| ${unlocked ? 'Unlocked via Seven Stars' : 'Locked – clear quiz prompts'} |
There was a problem hiding this comment.
Lore entries can be unlocked via non-quiz paths (e.g., the combo-tier4 reward listener also adds badges into loreUnlocked). renderCodexBody always labels unlocked entries as “Unlocked via Seven Stars”, which becomes inaccurate. Consider tracking the unlock source or using a more generic unlocked label.
| ${unlocked ? 'Unlocked via Seven Stars' : 'Locked – clear quiz prompts'} | |
| ${unlocked ? 'Unlocked' : 'Locked – clear quiz prompts'} |
| async function loadModule(id, target = 'main-content') { | ||
| const container = document.getElementById(target); | ||
| if (!container) return; | ||
| try { |
There was a problem hiding this comment.
loadModule fetches ./modules/${id}.html without validating id. Because id can include path segments (e.g. ../), this can fetch unintended same-origin files and undermines the “hardened loader” claim. Consider whitelisting id against known module IDs (e.g., Object.keys(moduleInitializers)) and rejecting anything containing / or ...
| try { | |
| try { | |
| const allowedModuleIds = Object.keys(moduleInitializers); | |
| const isInvalidModuleId = | |
| typeof id !== 'string' || | |
| id.includes('..') || | |
| id.includes('/') || | |
| id.includes('\\') || | |
| !allowedModuleIds.includes(id); | |
| if (isInvalidModuleId) { | |
| throw new Error(`Invalid module: ${id}`); | |
| } |
| <circle id="node-seven-stars" class="node-btn unlocked" cx="150" cy="120" r="22" /> | ||
| <text x="150" y="160" fill="#e5e7eb" text-anchor="middle" font-size="14">Seven Stars</text> | ||
|
|
||
| <circle id="node-arcade" class="node-btn" cx="300" cy="80" r="22" /> | ||
| <text x="300" y="120" fill="#e5e7eb" text-anchor="middle" font-size="14">Arcade</text> | ||
|
|
||
| <circle id="node-ministry" class="node-btn" cx="450" cy="120" r="22" /> | ||
| <text x="450" y="160" fill="#e5e7eb" text-anchor="middle" font-size="14">Ministry</text> | ||
|
|
||
| <circle id="node-listings" class="node-btn" cx="520" cy="200" r="22" /> |
There was a problem hiding this comment.
The overworld SVG nodes are clickable circles but aren’t keyboard-focusable and don’t expose button semantics/labels per node. For accessibility, make the nodes focusable (e.g., tabindex="0"), add role="button"/aria-label (or wrap in <a>), and handle Enter/Space key activation in addition to click.
| <circle id="node-seven-stars" class="node-btn unlocked" cx="150" cy="120" r="22" /> | |
| <text x="150" y="160" fill="#e5e7eb" text-anchor="middle" font-size="14">Seven Stars</text> | |
| <circle id="node-arcade" class="node-btn" cx="300" cy="80" r="22" /> | |
| <text x="300" y="120" fill="#e5e7eb" text-anchor="middle" font-size="14">Arcade</text> | |
| <circle id="node-ministry" class="node-btn" cx="450" cy="120" r="22" /> | |
| <text x="450" y="160" fill="#e5e7eb" text-anchor="middle" font-size="14">Ministry</text> | |
| <circle id="node-listings" class="node-btn" cx="520" cy="200" r="22" /> | |
| <circle id="node-seven-stars" class="node-btn unlocked" cx="150" cy="120" r="22" tabindex="0" role="button" aria-label="Open Seven Stars" onkeydown="if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') { event.preventDefault(); this.dispatchEvent(new MouseEvent('click', { bubbles: true })); }" /> | |
| <text x="150" y="160" fill="#e5e7eb" text-anchor="middle" font-size="14">Seven Stars</text> | |
| <circle id="node-arcade" class="node-btn" cx="300" cy="80" r="22" tabindex="0" role="button" aria-label="Open Arcade" onkeydown="if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') { event.preventDefault(); this.dispatchEvent(new MouseEvent('click', { bubbles: true })); }" /> | |
| <text x="300" y="120" fill="#e5e7eb" text-anchor="middle" font-size="14">Arcade</text> | |
| <circle id="node-ministry" class="node-btn" cx="450" cy="120" r="22" tabindex="0" role="button" aria-label="Open Ministry" onkeydown="if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') { event.preventDefault(); this.dispatchEvent(new MouseEvent('click', { bubbles: true })); }" /> | |
| <text x="450" y="160" fill="#e5e7eb" text-anchor="middle" font-size="14">Ministry</text> | |
| <circle id="node-listings" class="node-btn" cx="520" cy="200" r="22" tabindex="0" role="button" aria-label="Open Listings" onkeydown="if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') { event.preventDefault(); this.dispatchEvent(new MouseEvent('click', { bubbles: true })); }" /> |
Integrated Nexus Arcade OS to stitch overworld navigation, Seven Stars quiz, match-3 arcade, listings, ministry, badges, and lore codex into a single event-driven shell.
os-shell.htmlwith hardenedloadModule, nav pills, Mystery Meter, reward feed, and globalNexusOSbus (emit/on/listen helpers).modules/overworld.html,seven-stars.html,arcade.html,listings.html,ministry.html,badges.html, andlore-codex.htmlfor map navigation, quiz flow, match-3 play, listings triggers, reward sinks, and lore progression.listing-viewed,star-collected,combo-tier4, emittingreward-granted; Lore Codex unlocks vialore-unlocked; Mystery Meter persists in localStorage.arcade-combo(tiers, chain), escalates tier4 withcombo-tier4, and firesarcade-gameover; optional hooks preserved.Example: match-3 combo emission into the bus