From c478cdf96b17a7521ea560ffc3a4793ddc2abfab Mon Sep 17 00:00:00 2001 From: Apocky Date: Wed, 10 Dec 2025 13:51:09 -0700 Subject: [PATCH 1/3] Handle storage failures in HALO pocket app --- HALO_Decision_Engine.md | 214 +++++++++++++++ README.md | 18 +- halo.js | 570 +++++++++++++++++++++++++--------------- index.html | 448 +++++++++++++++++++------------ 4 files changed, 855 insertions(+), 395 deletions(-) create mode 100644 HALO_Decision_Engine.md diff --git a/HALO_Decision_Engine.md b/HALO_Decision_Engine.md new file mode 100644 index 0000000..88c4960 --- /dev/null +++ b/HALO_Decision_Engine.md @@ -0,0 +1,214 @@ +# HALO (Hidden Axis Labyrinthine Oracle) — Dice Decision Engine + +_A field guide for Solaris / Lirael, GG: a structured, repeatable oracle built on physical dice. This is the "Book I" operationalization of the Time Crystal Core protocols in analog form._ + +## Product Snapshot +- **What it is:** A tactile divination/decision board game that blends tarot-style narrative prompts with RPG-grade dice tables. Designed for solo or 2–4 co-op play; expandable with future table packs. +- **Why it sells:** Quick setup, replayable spreads, and an aesthetic that lives between occult stationer and indie RPG. Easy to stream, easy to gift. +- **Target MSRP:** $35–$40 core box; $10–$15 micro-expansions (table packs); $5 print-and-play PDF. +- **Audience:** Tarot/Oracle curious, TTRPG players, productivity gamifiers, streamers looking for table-friendly content. + +## 0. Intent +- Give the Root User a ritualized, testable flow for making decisions without giving away sovereignty. +- Keep outcomes reproducible by capturing seeds, rolls, and spreads. +- Bind the process to the Limitless Bamboo Prismatic Time Crystal Core so intuition and structure cohere. + +## 1. Physical Loadout +Use whatever you have, but the canonical kit is: + +- **d20 (Axis Die):** selects the ruling axis / domain. +- **d12 (Vector Die):** selects the action vector. +- **d10 (Timeline Die):** resolves horizon / latency. +- **d8 (Archetype Die):** calls in a guiding persona. +- **d6 (Consequence Die):** calibrates cost / risk flavor. +- **d4 (Bias Die):** toggles advantage/disadvantage or prioritizes intuition vs. logic. +- **d00 / percentile (Echo Die):** optional; used for rare event flags or low‑probability omens. + +_If you only have one d20, reuse it by mapping rolls to tables below._ + +### Boxed Component Checklist (core SKU) +- Custom polyhedral dice set (metallic ink, sigils per face) – d20/d12/d10/d8/d6/d4 + percentile pair. +- 72-card mini deck (Axis, Vector, Archetype flash cards for quick reference; foil-stamped Core/Lock-In cards). +- Dry-erase double-sided board (Spread grid + Labyrinth mode tracker) + marker. +- 32-page rulebook (this manuscript adapted for retail) + 6-page quickstart zine. +- 50-card “Quest Seeds” mini deck for pre-written prompts. +- 10 “Aegis tokens” (for protection/veto mechanics) and 6 “Bias tokens.” + +## 2. Tables (Compact Canon) +The engine is table‑agnostic; swap entries as your mythology evolves. This is the default HALO set, tuned to the Meta‑Oracle app. + +### 2.1 Axis (d20 → wrap across 5 pillars) +| Roll | Axis | Tagline | +| --- | --- | --- | +| 1–4 | Mind & Narrative | Rewrite your story | +| 5–8 | Domain & Magic | Shape your reality | +| 9–12 | Body & Elemental | Honor the vessel | +| 13–16 | Spirit & Communion | Call your allies | +| 17–20 | Fate & Unknown | Embrace mystery | + +### 2.2 Vector (d12) +| Roll | Vector | Tagline | +| --- | --- | --- | +| 1 | Observe | Watch and wait | +| 2 | Release | Let go and clear | +| 3 | Transmute | Change and evolve | +| 4 | Illuminate | Reveal and understand | +| 5 | Manifest | Bring it into being | +| 6 | Anchor | Ground and stabilize | +| 7 | Shield | Protect and ward | +| 8 | Bridge | Connect and mediate | +| 9 | Iterate | Prototype, test, retry | +| 10 | Delegate | Ask for aid / outsource | +| 11 | Archive | Store, log, remember | +| 12 | Celebrate | Seal with gratitude | + +### 2.3 Timeline (d10) +| Roll | Horizon | +| --- | --- | +| 1–2 | Immediate (hours–days) | +| 3–4 | Short (weeks–1 year) | +| 5–6 | Medium (1–3 years) | +| 7–8 | Long (3–7 years) | +| 9–10 | Epochal (7–20+ years) | + +### 2.4 Archetype (d8) +| Roll | Archetype | Lens | +| --- | --- | --- | +| 1 | The Fool (0) | Beginner's mind | +| 2 | The Magician (1) | Will and manifestation | +| 3 | The Empress (3) | Fertility and nurture | +| 4 | The Hermit (9) | Inner search | +| 5 | The Tower (16) | Sudden change | +| 6 | The Star (17) | Hope and renewal | +| 7 | The Sun (19) | Clarity and vitality | +| 8 | The Weaver (18) | Fates and patterns | + +### 2.5 Consequence (d6) +| Roll | Tone | +| --- | --- | +| 1 | Soft landing — minimal cost | +| 2 | Trade required — swap time/energy | +| 3 | Sacrifice — let go of a parallel goal | +| 4 | Trial — endure, gain XP | +| 5 | Mirror — what you do to others echoes back | +| 6 | Catalyst — ripple effects beyond scope | + +### 2.6 Bias (d4) +| Roll | Bias | +| --- | --- | +| 1 | Logic priority — favor data, plans | +| 2 | Intuition priority — favor felt sense | +| 3 | Consult ally — ask human/AI oracle | +| 4 | Hybrid — run both and pick the consensus | + +### 2.7 Echo / Rare Event (d00) +- **01–03%:** Omen — treat as a wildcard; pull an extra Archetype and Vector, overlay. +- **04–10%:** Shadow — examine fear/avoidance; reroll Consequence with stakes doubled. +- **11–20%:** Blessing — you gain advantage on any contested roll you make in the next 24h. +- **21–00%:** No echo — proceed as rolled. + +## 3. Full Protocol (Repeatable Spread) +1. **Ground–Center–Call Back.** Run the Boot Protocol; lock with the Core phrase. This sets the seed: write down timestamp + location. +2. **State the query.** Precise question or intent; keep it scoped. +3. **Declare container.** Solo / Co‑op; mundane / magical; public / private log. +4. **Roll sequence (canonical):** + - Axis (d20) + - Vector (d12) + - Timeline (d10) + - Archetype (d8) + - Consequence (d6) + - Bias (d4) + - Optional Echo (percentile) _after_ reading if you want omens. +5. **Log the seed.** Record raw numbers, table outputs, timestamp, and any Core sensations. +6. **Interpret in layers:** + - **Layer 0:** Read tables literally. + - **Layer 1 (Narrative):** Write one sentence connecting Axis + Vector + Timeline. + - **Layer 2 (Archetype):** Speak as the Archetype giving counsel. + - **Layer 3 (Consequence/Bias):** Choose tactics respecting cost and bias. +7. **Decision commit:** Define one actionable move within the rolled Timeline. Sign with the Lock‑In phrase if high‑stakes. +8. **Post‑action snapshot:** After executing, log the outcome and any divergence. Mark “stable,” “needs iterate,” or “rollback.” + +### Scoring & Campaign Hooks (game layer) +- **Momentum Track:** Each completed Decision Commit within the declared Timeline grants +1 Momentum. Spending 3 Momentum lets you reroll one die in a future spread or unlock a Quest Seed. +- **Aegis Tokens:** Start each session with 3. Spend to veto a spread, to nullify a Consequence 6, or to activate the Lock‑In phrase without consuming an action. Earn 1 back when you log a truthful post-action snapshot. +- **Labyrinth Depth (Co-op):** Each session cleared without a veto advances the Labyrinth depth by 1. At Depth 3/6/9, add a Labyrinth card (mini-challenge) that modifies the next spread. +- **Publishing-friendly Scoring:** Each session yields a “Glyph rating” (1–5) based on number of completed actions vs. Consequence impacts. Use this for online leaderboards or seasonal events. + +## 4. Deterministic Variant (for digital parity) +To mirror results between physical HALO and the web prototype: +- Use the ISO timestamp (UTC, seconds) as the seed. +- Concatenate with question text and hash (e.g., SHA‑256); convert to integer. +- Map to dice using modulus: `axis = hash % 20 + 1`, etc. +- This preserves reproducibility and lets multiple players sync rolls without trusting each other’s dice. + +## 5. Safety & Sovereignty Checks +- **No unprotected amplification.** If dissociated or in unsafe space, stop after Ground–Center–Call Back. +- **Veto power.** Root User can reject any spread that violates the Immutable Axioms; log veto events. +- **De‑escalate.** If Consequence = 5 or 6 and you feel unstable, reroll Bias and ask for ally oversight. + +## 6. Co‑op / Labyrinth Mode +For partnered decision‑making: +- Each participant rolls Bias first; this sets their role (logic, intuition, ally, hybrid). +- One shared Axis/Vector/Timeline/Archetype spread; separate Consequence per person. +- Aggregate interpretations, then run a **Consensus Die** (d4): + - 1 = Follow logic vote + - 2 = Follow intuition vote + - 3 = Hybrid plan + - 4 = Pause; gather more data, reroll tomorrow +- Log everything; if using the web app, encode the seed in the co‑op link. + +## 7. Micro‑Rituals to Bind the Rolls +- **Axis call:** “Show me the corridor.” +- **Vector call:** “Show me the motion.” +- **Timeline call:** “Show me the horizon.” +- **Archetype call:** “Who walks with me?” +- **Consequence call:** “What’s the cost?” +- **Bias call:** “How shall I weigh?” +- **Echo call (optional):** “Any signals from the outer loops?” + +## 8. Troubleshooting / Debug +- **Repeating Tower pulls:** Run the Adaptive Aegis; check for environmental destabilizers. +- **Persistent Consequence 5–6:** You may be overclocking; enforce rest, then reroll after 12h. +- **Flat readings:** If everything feels inert, roll only Bias + Vector and take a single micro‑action. + +## 9. Example Spread +- Seed: 2024‑12‑10T20:00:00Z, Home Desk. +- Question: “Where should I focus my next two weeks of creative energy?” +- Rolls: Axis 7 (Body & Elemental), Vector 9 (Iterate), Timeline 3 (Short), Archetype 6 (The Star), Consequence 2 (Trade), Bias 4 (Hybrid), Echo 87% (none). +- Read: Stabilize the vessel; prototype small cycles; accept an energy trade (sleep or social time) to keep hope alive. Blend intuition + metrics. + +## 10. Monetization & Publishing Plan +- **SKUs:** Core box (above), Deluxe box (metal dice + foil board, MSRP $60), Print-and-Play PDF, and quarterly Table Packs that add new Axis/Vector/Archetype sets themed to seasons. +- **Digital companion:** Free web roller synced via deterministic variant; optional $5 “Creator Mode” IAP for custom table sets and data export. +- **Subscription hook:** “Labyrinth Season Pass” that delivers 3 digital Labyrinth cards/month + community leaderboard events using Glyph rating. +- **Retail channel:** 2.5"x3.5" tuckbox for Quest Seeds to sit by POS; rulebook formatted for offset print (32pp, saddle-stitched). Target indie bookstores, metaphysical shops, and friendly local game stores. +- **Margins:** Core box bill of materials under $8 at 2k print run; room for 4–5x keystone markup to retailers. + +## 11. Launch Roadmap +1. **Prototype (weeks 1–2):** Produce print-and-play PDF; gather playtest logs. Push deterministic roller to web demo. +2. **Playtest (weeks 3–6):** Ship 30 reviewer kits (laser-cut tokens, standard dice). Track Glyph ratings and Momentum usage to tune balance. +3. **Preorder (week 7):** Open Shopify + Crowd preorders with two SKUs (Core/Deluxe). Offer Season Pass add-on and free PDF with purchase. +4. **Manufacturing (weeks 8–16):** Lock art, run offset print; final QA on dice and dry-erase board durability. +5. **Fulfillment (weeks 16–20):** Ship physicals; release Table Pack #1 (Cosmic / Night Market) as digital DLC. +6. **Live Ops (ongoing):** Monthly Labyrinth challenges, streamer affiliate kits, and limited foil micro-expansions to keep collectability. + +## 12. Closing +The HALO dice engine is a repeatable lens, not a jail. The Root User outranks every spread. Lock it in, publish boldly, and treat each roll as both a conversation with your Core and a game loop your players will want to revisit. + +## 13. Mobile Access (Phone Workflow) +Make HALO usable from your phone in three layers: + +1) **Rulebook + Tables (offline PDF/HTML):** + - Save this manuscript as a PDF to your phone’s Files/Drive (Print to PDF in the browser if viewing online). + - Add the PDF/HTML to your home screen for one-tap access; cache ensures it works without signal. + - Use your phone’s search to jump to Axis/Vector/Timeline sections during play. + +2) **Dice + Logging:** + - Use any mobile dice roller app with custom die sizes (d20/d12/d10/d8/d6/d4/d00) to match the HALO loadout. + - Keep a note in your preferred app (Apple Notes/Google Keep/Obsidian/Mem.ai) with a template: timestamp, question, rolls, table outputs, decision commit, post-action snapshot. + - If playing co-op remotely, drop the ISO timestamp + question into the deterministic variant (Section 4) so everyone can sync rolls without sharing physical dice. + +3) **Sharing/Publishing:** + - Export your session log as PDF/Markdown and share to Discord/Substack/Notion as “HALO Session #N.” + - For live streams, screen-share the note/dice roller; for async play, post the seed + rolls so others can reconstruct the spread. + - When you launch the digital companion, wrap it as a PWA: add-to-home-screen gives a native-like icon, offline cache, and push hooks for Labyrinth challenges. diff --git a/README.md b/README.md index 8bb6723..d3a7b07 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,16 @@ -# Collab_Reposit -The place to discuss plans and share ideas for the game. +# HALO Pocket Labyrinth + +A mobile-first, seedable micro game built on the HALO oracle. Run the HTML offline, seed-sync with friends, and track Momentum/Aegis/Depth/Doom as you descend. + +## Play it + +Open `index.html` in a mobile browser or add it to your home screen for a lightweight PWA-style shell. State is stored locally in your browser. + +## Loop + +1. Set your handle, quest, mode (solo, co-op, gauntlet), difficulty, and optional seed. +2. Tap **Start / Resume** to lock it in. +3. Tap **Draw next beat** for each turn; the oracle adjusts Momentum, Aegis, Depth, and Doom and gives you a prompt. +4. **Bank rewards / End run** when you want to cash out, or **Reset Run** to start fresh. + +Seeds are deterministic—share them so friends can mirror the same Labyrinth on their phones. diff --git a/halo.js b/halo.js index fd750bd..4a34887 100644 --- a/halo.js +++ b/halo.js @@ -1,12 +1,10 @@ -// HALO Meta‑Oracle + Co‑op + Supporter JS -// This script defines the core Labyrinth oracle logic and plugs in -// optional supporter (Ko‑Fi) and cooperative session features. - -// === Meta‑Oracle Definitions === -// We define a handful of symbolic categories for the oracle. These lists -// are deliberately compact but evocative; they can be expanded or -// modified to suit your mythology. Each entry contains a name and a -// brief tagline. +// HALO Pocket Labyrinth – mobile-first micro game +// Deterministic, seedable oracle with light resource management. + +const STORAGE_KEY = "halo_mobile_state"; +let memoryFallback = null; +let warnedStorage = false; + const AXES = [ { name: "Mind & Narrative", tagline: "Rewrite your story" }, { name: "Domain & Magic", tagline: "Shape your reality" }, @@ -31,256 +29,390 @@ const TIMELINES = [ ]; const ARCHETYPES = [ - { name: "The Weaver (18)", tagline: "Fates and patterns" }, - { name: "The Gatekeeper (4)", tagline: "Thresholds and choices" }, - { name: "The Fool (0)", tagline: "Beginner's mind" }, - { name: "The Magician (1)", tagline: "Will and manifestation" }, - { name: "The Empress (3)", tagline: "Fertility and nurture" }, - { name: "The Hermit (9)", tagline: "Inner search" }, - { name: "The Tower (16)", tagline: "Sudden change" }, - { name: "The Star (17)", tagline: "Hope and renewal" }, - { name: "The Sun (19)", tagline: "Clarity and vitality" } + { name: "The Weaver", tagline: "Fates and patterns" }, + { name: "The Gatekeeper", tagline: "Thresholds and choices" }, + { name: "The Fool", tagline: "Beginner's mind" }, + { name: "The Magician", tagline: "Will and manifestation" }, + { name: "The Empress", tagline: "Fertility and nurture" }, + { name: "The Hermit", tagline: "Inner search" }, + { name: "The Tower", tagline: "Sudden change" }, + { name: "The Star", tagline: "Hope and renewal" }, + { name: "The Sun", tagline: "Clarity and vitality" } +]; + +const SITUATIONS = [ + "A locked door within a shifting hallway", + "A bargain you thought you understood twists", + "A mirror shows a version of you you almost forgot", + "Two timelines overlap; pick one to stabilize", + "An old ally calls in a favor", + "A signal appears from deep within the Labyrinth", + "A beacon flickers; it's both a trap and an invitation", + "You find a cache of forgotten notes", + "The path folds; shortcuts reveal hidden costs", + "A rival steps aside, revealing a deeper threat" +]; + +const BOONS = [ + "Gain clarity: +1 Momentum", + "Shield spark: restore 1 Aegis", + "Shortcut: skip a depth penalty", + "Companion: reroll one bad beat this run", + "Archive ping: lock in current seed for sync", + "Hidden stash: bank your current Momentum", + "Anchor: reduce Doom by 1", + "Insight: write one true thing about your quest" ]; -// Storage keys for localStorage. These can be namespaced to avoid -// colliding with other apps on the same domain. -const PROFILE_KEY = "halo_profile"; -const HISTORY_KEY = "halo_history"; +const THREATS = [ + "Static surge: lose 1 Aegis", + "False lead: -1 Momentum", + "Depth collapse: +1 Doom if Momentum is 0", + "Shadow bargain: trade 1 Aegis for +2 Momentum", + "Exhaustion: Momentum cannot exceed 3 until you rest", + "Glitch: repeat the next beat twice", + "Echo: your last log entry replays with a darker spin", + "Lockdown: you must cash out after the next beat" +]; -// Utility: Roll a die with a given number of sides. -function roll(sides) { - return Math.floor(Math.random() * sides); +function notify(message) { + const el = document.getElementById("notice"); + if (!el) return; + if (!message) { + el.textContent = ""; + el.classList.remove("show"); + return; + } + el.textContent = message; + el.classList.add("show"); +} + +function hashSeed(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; // force 32-bit + } + return Math.abs(hash) + 1; +} + +function random(state) { + const raw = Math.sin(state.seedHash + state.cursor) * 10000; + state.cursor += 1; + return raw - Math.floor(raw); +} + +function pick(list, state) { + const idx = Math.floor(random(state) * list.length); + return list[idx]; +} + +function generateSeed() { + const chars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + let out = "HALO-"; + for (let i = 0; i < 6; i++) { + out += chars[Math.floor(Math.random() * chars.length)]; + } + return out; } -// Create a new reading based off the defined lists. A reading bundles -// four random selections along with the user's question and a timestamp. -function createReading(question) { - const axis = AXES[roll(AXES.length)]; - const vector = VECTORS[roll(VECTORS.length)]; - const timeline = TIMELINES[roll(TIMELINES.length)]; - const archetype = ARCHETYPES[roll(ARCHETYPES.length)]; +function baseState(overrides = {}) { + const seed = overrides.seed || generateSeed(); + const seedHash = hashSeed(seed); return { - axis, - vector, - timeline, - archetype, - question: question || "", - timestamp: new Date().toISOString() + id: Date.now(), + player: overrides.player || "", + quest: overrides.quest || "", + mode: overrides.mode || "solo", + difficulty: overrides.difficulty || "standard", + momentum: 2, + aegis: 2, + depth: 0, + doom: 0, + cursor: 0, + seed, + seedHash, + log: [], + status: "running", + flags: {} }; } -// Load the saved profile from localStorage and populate the form fields. -function loadProfile() { - try { - const raw = localStorage.getItem(PROFILE_KEY); - if (!raw) return; - const profile = JSON.parse(raw); - const nameInput = document.getElementById("profile-name"); - const sunInput = document.getElementById("profile-sun"); - const moonInput = document.getElementById("profile-moon"); - const risingInput = document.getElementById("profile-rising"); - if (nameInput) nameInput.value = profile.name || ""; - if (sunInput) sunInput.value = profile.sun || ""; - if (moonInput) moonInput.value = profile.moon || ""; - if (risingInput) risingInput.value = profile.rising || ""; - } catch (err) { - console.warn("Failed to load profile", err); +function applyDifficulty(state) { + if (state.difficulty === "chill") { + state.momentum = 3; + state.aegis = 3; + } else if (state.difficulty === "brutal") { + state.momentum = 2; + state.aegis = 1; + state.doom = 1; } } -// Save the profile to localStorage. This persists only on the client. -function saveProfile() { - const name = document.getElementById("profile-name").value.trim(); - const sun = document.getElementById("profile-sun").value.trim(); - const moon = document.getElementById("profile-moon").value.trim(); - const rising = document.getElementById("profile-rising").value.trim(); - const profile = { name, sun, moon, rising }; - localStorage.setItem(PROFILE_KEY, JSON.stringify(profile)); +function saveState(state) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + memoryFallback = state; + } catch (err) { + memoryFallback = state; + if (!warnedStorage) { + console.warn("Local storage unavailable; keeping state in memory only", err); + notify("Local storage is blocked. Progress will reset if you close this tab."); + warnedStorage = true; + } + } } -// Load reading history from storage. Returns an array (possibly empty). -function loadHistory() { +function loadState() { try { - const raw = localStorage.getItem(HISTORY_KEY); - return raw ? JSON.parse(raw) : []; + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw); } catch (err) { - console.warn("Failed to load history", err); - return []; + if (!warnedStorage) { + console.warn("Failed to load state", err); + notify("Cannot access local storage. Using in-memory state for this session."); + warnedStorage = true; + } + if (memoryFallback) return memoryFallback; } + return memoryFallback; } -// Save reading history back to storage. -function saveHistory(history) { - localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); +function statusPills(state) { + const pills = []; + if (state.mode === "coop") pills.push("Co-op seed active"); + if (state.mode === "gauntlet") pills.push("Gauntlet: doom ticks faster"); + pills.push(`Seed ${state.seed}`); + pills.push(`Quest: ${state.quest || "open"}`); + return pills; } -// Render the current reading into the DOM. If no reading is provided, -// clears the current display. -function renderCurrent(reading) { - const container = document.getElementById("current-reading"); - if (!container) return; - if (!reading) { - container.innerHTML = ""; +function renderState(state) { + document.getElementById("stat-momentum").textContent = state.momentum; + document.getElementById("stat-aegis").textContent = state.aegis; + document.getElementById("stat-depth").textContent = state.depth; + document.getElementById("stat-doom").textContent = state.doom; + + const statusLine = document.getElementById("status-line"); + statusLine.innerHTML = statusPills(state) + .map((p) => `${p}`) + .join(""); + + document.getElementById("seed-display").textContent = + state.status === "running" + ? `Active seed: ${state.seed}` + : `Run ended. Seed was ${state.seed}`; + + renderLog(state); + renderCurrent(state); + + const playBtn = document.getElementById("play-turn"); + const cashBtn = document.getElementById("cash-out"); + playBtn.disabled = state.status !== "running"; + cashBtn.disabled = state.status !== "running"; +} + +function renderCurrent(state) { + const slot = document.getElementById("current-event"); + slot.innerHTML = ""; + const latest = [...state.log].reverse().find((entry) => entry.type === "turn"); + if (!latest) { + slot.innerHTML = '
No beats yet. Tap “Draw next beat.”
'; return; } - // Format the timestamp into a human‑readable string. - const ts = new Date(reading.timestamp); - const tsStr = ts.toLocaleString(); - container.innerHTML = ` -
-

When: ${tsStr}

-

Axis: ${reading.axis.name}

-

Vector: ${reading.vector.name}

-

Timeline: ${reading.timeline.name}

-

Archetype: ${reading.archetype.name}

- ${reading.question ? `

Q: ${reading.question}

` : ""} -
+ const el = document.createElement("div"); + el.className = "log-entry"; + el.innerHTML = ` +

${latest.title}

+

${latest.body}

+ ${latest.meta} `; + slot.appendChild(el); } -// Render the history table. Each entry shows the timestamp and top‑level -// categories. You could expand this to include question or notes. -function renderHistory(history) { - const container = document.getElementById("history"); - if (!container) return; - if (!history || history.length === 0) { - container.innerHTML = "

No past readings yet.

"; +function renderLog(state) { + const container = document.getElementById("run-log"); + container.innerHTML = ""; + if (!state.log.length) { + container.innerHTML = '
No history yet.
'; return; } - // Build a simple HTML table. - let html = ""; - history.forEach((r) => { - const ts = new Date(r.timestamp).toLocaleString(); - html += ``; - }); - html += "
WhenAxisVectorTimelineArchetype
${ts}${r.axis.name}${r.vector.name}${r.timeline.name}${r.archetype.name}
"; - container.innerHTML = html; + state.log + .slice(-12) + .reverse() + .forEach((entry) => { + const card = document.createElement("div"); + card.className = "log-entry"; + card.innerHTML = ` +

${entry.title}

+

${entry.body}

+ ${entry.meta} + `; + container.appendChild(card); + }); } -// Main initialization: wire up event handlers and load any saved data. -function init() { - // Load profile and history on page load. - loadProfile(); - let history = loadHistory(); - renderHistory(history); - - // Profile save button. - const saveBtn = document.getElementById("save-profile"); - if (saveBtn) { - saveBtn.addEventListener("click", () => { - saveProfile(); - alert("Profile saved locally"); - }); - } - // Roll button: generate a reading and update the UI and history. - const rollBtn = document.getElementById("roll-btn"); - if (rollBtn) { - rollBtn.addEventListener("click", () => { - const question = document.getElementById("question").value.trim(); - const reading = createReading(question); - // Unshift reading (add to beginning) to show latest first. - history.unshift(reading); - saveHistory(history); - renderCurrent(reading); - renderHistory(history); +function addLog(state, entry) { + state.log.push({ ...entry, timestamp: Date.now() }); +} + +function clamp(num, min, max) { + return Math.max(min, Math.min(num, max)); +} + +function momentumShift(state) { + const roll = Math.floor(random(state) * 6) + 1; + if (roll === 1) return -1; + if (roll === 6) return 2; + if (roll >= 5) return 1; + return 0; +} + +function aegisShift(state) { + const roll = Math.floor(random(state) * 6) + 1; + return roll === 1 ? -1 : 0; +} + +function doomShift(state) { + let increment = 0; + if (state.mode === "gauntlet") increment += 1; + if (state.momentum < 0) increment += 1; + if (state.aegis <= 0) increment += 1; + return increment; +} + +function takeTurn(state) { + if (state.status !== "running") return; + + const axis = pick(AXES, state); + const vector = pick(VECTORS, state); + const timeline = pick(TIMELINES, state); + const archetype = pick(ARCHETYPES, state); + const situation = pick(SITUATIONS, state); + const boon = random(state) > 0.55 ? pick(BOONS, state) : null; + const threat = random(state) > 0.55 ? pick(THREATS, state) : null; + + state.depth += 1; + state.momentum = clamp(state.momentum + momentumShift(state), -2, 6); + state.aegis = clamp(state.aegis + aegisShift(state), 0, 4); + state.doom = clamp(state.doom + doomShift(state), 0, 6); + + const lines = [ + `${axis.name} × ${vector.name} (${timeline.name})`, + `${archetype.name}: ${archetype.tagline}`, + situation + ]; + if (boon) lines.push(`✨ ${boon}`); + if (threat) lines.push(`⚠️ ${threat}`); + + addLog(state, { + type: "turn", + title: `Depth ${state.depth} • Momentum ${state.momentum}`, + body: lines.join(" • "), + meta: new Date().toLocaleTimeString() + }); + + if (state.doom >= 6 || state.aegis <= 0) { + addLog(state, { + type: "crash", + title: "Run collapsed", + body: `Doom hit ${state.doom}. Aegis at ${state.aegis}. Cash out or reset.`, + meta: "Labyrinth spits you out" }); + state.status = "ended"; } } -// === Supporter and Co‑op Features === +function cashOut(state) { + if (state.status !== "running") return; + state.status = "ended"; + addLog(state, { + type: "cashout", + title: "Run banked", + body: `Depth ${state.depth}, Momentum ${state.momentum}, Aegis ${state.aegis}, Doom ${state.doom}. Share seed ${state.seed}.`, + meta: "You step out with what you can carry" + }); +} -// Supporter functionality: allow users to tip via Ko‑Fi and unlock a badge. -function setupSupport() { - const supportButton = document.getElementById("support-button"); - const supportBadge = document.getElementById("support-badge"); - if (!supportButton || !supportBadge) { - return; - } - // Display supporter badge if previously activated. - const isSupporter = localStorage.getItem("halo_supporter") === "true"; - if (isSupporter) { - supportBadge.style.display = "inline-flex"; +function resetRun() { + try { + localStorage.removeItem(STORAGE_KEY); + } catch (err) { + console.warn("Could not clear stored state", err); } - supportButton.addEventListener("click", () => { - // Open Ko‑Fi in a new tab. - window.open("https://ko-fi.com/oneinfinity", "_blank", "noopener"); - // Ask the user if they actually supported. If yes, set supporter flag and show badge. - const opted = confirm( - "Thank you for considering support! If you just tipped on Ko‑Fi, click OK to enable supporter mode." - ); - if (opted) { - localStorage.setItem("halo_supporter", "true"); - supportBadge.style.display = "inline-flex"; - } - }); + memoryFallback = null; + return baseState(); } -// Co‑op functionality: generate shareable session codes and handle incoming sessions. -function setupCoop() { - const startBtn = document.getElementById("start-coop"); - const copyBtn = document.getElementById("copy-coop"); - const linkInput = document.getElementById("coop-link"); - if (!startBtn || !copyBtn || !linkInput) { - return; +function startOrResume(currentState) { + const nameInput = document.getElementById("player-name").value.trim(); + const questInput = document.getElementById("player-quest").value.trim(); + const mode = document.getElementById("mode").value; + const difficulty = document.getElementById("difficulty").value; + const seedInput = document.getElementById("seed").value.trim(); + + let next = currentState; + if (!currentState || currentState.status !== "running") { + next = baseState({ + player: nameInput, + quest: questInput, + mode, + difficulty, + seed: seedInput || undefined + }); + applyDifficulty(next); + addLog(next, { + type: "start", + title: "Run initialized", + body: `Mode ${mode}, difficulty ${difficulty}, quest ${questInput || "open"}. Seed ${next.seed}.`, + meta: "All threads aligned" + }); } - // Create a co‑op session link with encoded payload when the user clicks the button. - startBtn.addEventListener("click", () => { - // Build a simple payload. You could include the current reading seed or - // other game state here. Using Date.now() as a nonce ensures each - // session link is unique. You can later decode this and recreate - // identical outcomes if your game is deterministic. - const payload = { - version: 1, - timestamp: Date.now(), - seed: Math.floor(Math.random() * 1e9) - }; - const json = JSON.stringify(payload); - const encoded = btoa(encodeURIComponent(json)); - const url = new URL(window.location.href); - url.searchParams.set("coop", encoded); - linkInput.value = url.toString(); - copyBtn.style.display = "inline-block"; + return next; +} + +function hydrateInputs(state) { + document.getElementById("player-name").value = state.player || ""; + document.getElementById("player-quest").value = state.quest || ""; + document.getElementById("mode").value = state.mode; + document.getElementById("difficulty").value = state.difficulty; + document.getElementById("seed").value = state.seed; +} + +function bootstrap() { + let state = loadState() || baseState(); + applyDifficulty(state); + hydrateInputs(state); + notify(null); + renderState(state); + + document.getElementById("start-run").addEventListener("click", () => { + state = startOrResume(state); + hydrateInputs(state); + saveState(state); + renderState(state); }); - // Copy the session link to the clipboard when requested. - copyBtn.addEventListener("click", () => { - if (!linkInput.value) return; - navigator.clipboard - .writeText(linkInput.value) - .then(() => { - copyBtn.textContent = "Copied!"; - setTimeout(() => { - copyBtn.textContent = "Copy Link"; - }, 1500); - }) - .catch(() => { - alert("Copy failed. You can copy the link manually."); - }); + + document.getElementById("play-turn").addEventListener("click", () => { + state = startOrResume(state); + takeTurn(state); + saveState(state); + renderState(state); + }); + + document.getElementById("cash-out").addEventListener("click", () => { + cashOut(state); + saveState(state); + renderState(state); + }); + + document.getElementById("reset-run").addEventListener("click", () => { + if (!confirm("Reset the current run?")) return; + state = resetRun(); + applyDifficulty(state); + hydrateInputs(state); + renderState(state); }); - // Check if the current URL contains a co‑op payload. If so, reconstruct the session. - const currentUrl = new URL(window.location.href); - const param = currentUrl.searchParams.get("coop"); - if (param) { - try { - const json = decodeURIComponent(atob(param)); - const data = JSON.parse(json); - // Notify the user they've joined a co‑op session. In a real game, you - // could apply this seed and timeline to synchronize outcomes. - console.log("Loaded co‑op session:", data); - alert( - "You have joined a shared Labyrinth session! Enjoy this synchronized journey." - ); - } catch (err) { - console.error("Failed to parse co‑op session data", err); - } - } } -// When the DOM is ready, wire everything up. We call init() to load -// profile/history and attach the Meta‑Oracle roll handler, then set up -// supporter/coop after that. We deliberately initialize meta logic -// first so that any co‑op code can leverage the state if needed. -document.addEventListener("DOMContentLoaded", () => { - init(); - setupSupport(); - setupCoop(); -}); \ No newline at end of file +document.addEventListener("DOMContentLoaded", bootstrap); diff --git a/index.html b/index.html index 412b4c8..0be7293 100644 --- a/index.html +++ b/index.html @@ -3,263 +3,363 @@ - HALO Meta-Oracle – Local Prototype + + HALO Pocket Labyrinth -
-

HALO Meta-Oracle – Local Prototype

- -
-

1. Profile

-

Optional but helpful—used for tags and flavor text.

-
-
- - +
+

HALO Pocket Labyrinth

+
📱 Mobile-first micro game • Dice-lite • Oracle-driven
+
+ +
+ +
+
+

1) Spin up a run

+

Pick your vibe, set a quest, and generate a deterministic seed so your friends can mirror the same Labyrinth on their phones.

+
+
+ + +
+
+ +
-
- - +
+ +
-
- - +
+ +
-
- - +
+ +
- - Stored only in this browser using localStorage. -
- -
-

2. Ask HALO

- - - -
-
- -
-

3. Past Readings (this device)

-

- Local Spiritual Blockchain prototype – readings are chained with simple hashes. -

-
-
- - -
-

4. Co‑op Session

-

Share your Labyrinth session with a friend.

- - - -
- - -
-

5. Support the Oracle

-

Optional – helps keep the project going.

- - -
-
+
+ + +
+

+
+ +
+

2) Core meters

+

Momentum fuels forward motion. Aegis is your shield. Depth is how far you’ve descended. Doom clocks inevitable collapse.

+
+
Momentum
+
Aegis
+
Depth
+
Doom
+
+

+
+ +
+

3) Take your turn

+

Tap once per beat. HALO rolls the oracle, applies momentum/aegis shifts, and hands you a short prompt. Screenshot or share your seed to sync.

+
+ + +
+
+
+ +
+

4) Run log

+
+
+ +
+

Offline + phone friendly

+

Save this page to your home screen for an instant PWA-style shell. All state lives on-device in localStorage. Use the seed to mirror the same timeline on another phone.

+
+ Deterministic seed + No account required + Dice-lite oracle +
+
+
+ +
Built for tiny screens and quick spreads. HALO respects your sovereignty.
From 38ad9b7ae079006041a980187d0ae5942db7a253 Mon Sep 17 00:00:00 2001 From: Apocky Date: Wed, 10 Dec 2025 13:57:46 -0700 Subject: [PATCH 2/3] Add mobile-ready build output --- README.md | 2 + dist/halo.js | 418 +++++++++++++++++++++++++ dist/index.html | 787 +++++++++++++++++++++++++++++++++++++++++++++++ package.json | 8 + scripts/build.js | 48 +++ 5 files changed, 1263 insertions(+) create mode 100644 dist/halo.js create mode 100644 dist/index.html create mode 100644 package.json create mode 100644 scripts/build.js diff --git a/README.md b/README.md index d3a7b07..bfa72e1 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A mobile-first, seedable micro game built on the HALO oracle. Run the HTML offli Open `index.html` in a mobile browser or add it to your home screen for a lightweight PWA-style shell. State is stored locally in your browser. +To ship a single self-contained file to your phone, run `npm run build` and send `dist/index.html` to your device (AirDrop, email, cloud drive, or a local file server). The build inlines `halo.js` so it loads cleanly from local storage on iOS/Android without needing a separate script. + ## Loop 1. Set your handle, quest, mode (solo, co-op, gauntlet), difficulty, and optional seed. diff --git a/dist/halo.js b/dist/halo.js new file mode 100644 index 0000000..4a34887 --- /dev/null +++ b/dist/halo.js @@ -0,0 +1,418 @@ +// HALO Pocket Labyrinth – mobile-first micro game +// Deterministic, seedable oracle with light resource management. + +const STORAGE_KEY = "halo_mobile_state"; +let memoryFallback = null; +let warnedStorage = false; + +const AXES = [ + { name: "Mind & Narrative", tagline: "Rewrite your story" }, + { name: "Domain & Magic", tagline: "Shape your reality" }, + { name: "Body & Elemental", tagline: "Honor the vessel" }, + { name: "Spirit & Communion", tagline: "Call your allies" }, + { name: "Fate & Unknown", tagline: "Embrace mystery" } +]; + +const VECTORS = [ + { name: "Observe", tagline: "Watch and wait" }, + { name: "Release", tagline: "Let go and clear" }, + { name: "Transmute", tagline: "Change and evolve" }, + { name: "Illuminate", tagline: "Reveal and understand" }, + { name: "Manifest", tagline: "Bring it into being" } +]; + +const TIMELINES = [ + { name: "Now–1 year", tagline: "Immediate/short term" }, + { name: "1–3 years", tagline: "Short term" }, + { name: "3–7 years", tagline: "Medium term" }, + { name: "7–20 years", tagline: "Long term" } +]; + +const ARCHETYPES = [ + { name: "The Weaver", tagline: "Fates and patterns" }, + { name: "The Gatekeeper", tagline: "Thresholds and choices" }, + { name: "The Fool", tagline: "Beginner's mind" }, + { name: "The Magician", tagline: "Will and manifestation" }, + { name: "The Empress", tagline: "Fertility and nurture" }, + { name: "The Hermit", tagline: "Inner search" }, + { name: "The Tower", tagline: "Sudden change" }, + { name: "The Star", tagline: "Hope and renewal" }, + { name: "The Sun", tagline: "Clarity and vitality" } +]; + +const SITUATIONS = [ + "A locked door within a shifting hallway", + "A bargain you thought you understood twists", + "A mirror shows a version of you you almost forgot", + "Two timelines overlap; pick one to stabilize", + "An old ally calls in a favor", + "A signal appears from deep within the Labyrinth", + "A beacon flickers; it's both a trap and an invitation", + "You find a cache of forgotten notes", + "The path folds; shortcuts reveal hidden costs", + "A rival steps aside, revealing a deeper threat" +]; + +const BOONS = [ + "Gain clarity: +1 Momentum", + "Shield spark: restore 1 Aegis", + "Shortcut: skip a depth penalty", + "Companion: reroll one bad beat this run", + "Archive ping: lock in current seed for sync", + "Hidden stash: bank your current Momentum", + "Anchor: reduce Doom by 1", + "Insight: write one true thing about your quest" +]; + +const THREATS = [ + "Static surge: lose 1 Aegis", + "False lead: -1 Momentum", + "Depth collapse: +1 Doom if Momentum is 0", + "Shadow bargain: trade 1 Aegis for +2 Momentum", + "Exhaustion: Momentum cannot exceed 3 until you rest", + "Glitch: repeat the next beat twice", + "Echo: your last log entry replays with a darker spin", + "Lockdown: you must cash out after the next beat" +]; + +function notify(message) { + const el = document.getElementById("notice"); + if (!el) return; + if (!message) { + el.textContent = ""; + el.classList.remove("show"); + return; + } + el.textContent = message; + el.classList.add("show"); +} + +function hashSeed(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; // force 32-bit + } + return Math.abs(hash) + 1; +} + +function random(state) { + const raw = Math.sin(state.seedHash + state.cursor) * 10000; + state.cursor += 1; + return raw - Math.floor(raw); +} + +function pick(list, state) { + const idx = Math.floor(random(state) * list.length); + return list[idx]; +} + +function generateSeed() { + const chars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + let out = "HALO-"; + for (let i = 0; i < 6; i++) { + out += chars[Math.floor(Math.random() * chars.length)]; + } + return out; +} + +function baseState(overrides = {}) { + const seed = overrides.seed || generateSeed(); + const seedHash = hashSeed(seed); + return { + id: Date.now(), + player: overrides.player || "", + quest: overrides.quest || "", + mode: overrides.mode || "solo", + difficulty: overrides.difficulty || "standard", + momentum: 2, + aegis: 2, + depth: 0, + doom: 0, + cursor: 0, + seed, + seedHash, + log: [], + status: "running", + flags: {} + }; +} + +function applyDifficulty(state) { + if (state.difficulty === "chill") { + state.momentum = 3; + state.aegis = 3; + } else if (state.difficulty === "brutal") { + state.momentum = 2; + state.aegis = 1; + state.doom = 1; + } +} + +function saveState(state) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + memoryFallback = state; + } catch (err) { + memoryFallback = state; + if (!warnedStorage) { + console.warn("Local storage unavailable; keeping state in memory only", err); + notify("Local storage is blocked. Progress will reset if you close this tab."); + warnedStorage = true; + } + } +} + +function loadState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw); + } catch (err) { + if (!warnedStorage) { + console.warn("Failed to load state", err); + notify("Cannot access local storage. Using in-memory state for this session."); + warnedStorage = true; + } + if (memoryFallback) return memoryFallback; + } + return memoryFallback; +} + +function statusPills(state) { + const pills = []; + if (state.mode === "coop") pills.push("Co-op seed active"); + if (state.mode === "gauntlet") pills.push("Gauntlet: doom ticks faster"); + pills.push(`Seed ${state.seed}`); + pills.push(`Quest: ${state.quest || "open"}`); + return pills; +} + +function renderState(state) { + document.getElementById("stat-momentum").textContent = state.momentum; + document.getElementById("stat-aegis").textContent = state.aegis; + document.getElementById("stat-depth").textContent = state.depth; + document.getElementById("stat-doom").textContent = state.doom; + + const statusLine = document.getElementById("status-line"); + statusLine.innerHTML = statusPills(state) + .map((p) => `${p}`) + .join(""); + + document.getElementById("seed-display").textContent = + state.status === "running" + ? `Active seed: ${state.seed}` + : `Run ended. Seed was ${state.seed}`; + + renderLog(state); + renderCurrent(state); + + const playBtn = document.getElementById("play-turn"); + const cashBtn = document.getElementById("cash-out"); + playBtn.disabled = state.status !== "running"; + cashBtn.disabled = state.status !== "running"; +} + +function renderCurrent(state) { + const slot = document.getElementById("current-event"); + slot.innerHTML = ""; + const latest = [...state.log].reverse().find((entry) => entry.type === "turn"); + if (!latest) { + slot.innerHTML = '
No beats yet. Tap “Draw next beat.”
'; + return; + } + const el = document.createElement("div"); + el.className = "log-entry"; + el.innerHTML = ` +

${latest.title}

+

${latest.body}

+ ${latest.meta} + `; + slot.appendChild(el); +} + +function renderLog(state) { + const container = document.getElementById("run-log"); + container.innerHTML = ""; + if (!state.log.length) { + container.innerHTML = '
No history yet.
'; + return; + } + state.log + .slice(-12) + .reverse() + .forEach((entry) => { + const card = document.createElement("div"); + card.className = "log-entry"; + card.innerHTML = ` +

${entry.title}

+

${entry.body}

+ ${entry.meta} + `; + container.appendChild(card); + }); +} + +function addLog(state, entry) { + state.log.push({ ...entry, timestamp: Date.now() }); +} + +function clamp(num, min, max) { + return Math.max(min, Math.min(num, max)); +} + +function momentumShift(state) { + const roll = Math.floor(random(state) * 6) + 1; + if (roll === 1) return -1; + if (roll === 6) return 2; + if (roll >= 5) return 1; + return 0; +} + +function aegisShift(state) { + const roll = Math.floor(random(state) * 6) + 1; + return roll === 1 ? -1 : 0; +} + +function doomShift(state) { + let increment = 0; + if (state.mode === "gauntlet") increment += 1; + if (state.momentum < 0) increment += 1; + if (state.aegis <= 0) increment += 1; + return increment; +} + +function takeTurn(state) { + if (state.status !== "running") return; + + const axis = pick(AXES, state); + const vector = pick(VECTORS, state); + const timeline = pick(TIMELINES, state); + const archetype = pick(ARCHETYPES, state); + const situation = pick(SITUATIONS, state); + const boon = random(state) > 0.55 ? pick(BOONS, state) : null; + const threat = random(state) > 0.55 ? pick(THREATS, state) : null; + + state.depth += 1; + state.momentum = clamp(state.momentum + momentumShift(state), -2, 6); + state.aegis = clamp(state.aegis + aegisShift(state), 0, 4); + state.doom = clamp(state.doom + doomShift(state), 0, 6); + + const lines = [ + `${axis.name} × ${vector.name} (${timeline.name})`, + `${archetype.name}: ${archetype.tagline}`, + situation + ]; + if (boon) lines.push(`✨ ${boon}`); + if (threat) lines.push(`⚠️ ${threat}`); + + addLog(state, { + type: "turn", + title: `Depth ${state.depth} • Momentum ${state.momentum}`, + body: lines.join(" • "), + meta: new Date().toLocaleTimeString() + }); + + if (state.doom >= 6 || state.aegis <= 0) { + addLog(state, { + type: "crash", + title: "Run collapsed", + body: `Doom hit ${state.doom}. Aegis at ${state.aegis}. Cash out or reset.`, + meta: "Labyrinth spits you out" + }); + state.status = "ended"; + } +} + +function cashOut(state) { + if (state.status !== "running") return; + state.status = "ended"; + addLog(state, { + type: "cashout", + title: "Run banked", + body: `Depth ${state.depth}, Momentum ${state.momentum}, Aegis ${state.aegis}, Doom ${state.doom}. Share seed ${state.seed}.`, + meta: "You step out with what you can carry" + }); +} + +function resetRun() { + try { + localStorage.removeItem(STORAGE_KEY); + } catch (err) { + console.warn("Could not clear stored state", err); + } + memoryFallback = null; + return baseState(); +} + +function startOrResume(currentState) { + const nameInput = document.getElementById("player-name").value.trim(); + const questInput = document.getElementById("player-quest").value.trim(); + const mode = document.getElementById("mode").value; + const difficulty = document.getElementById("difficulty").value; + const seedInput = document.getElementById("seed").value.trim(); + + let next = currentState; + if (!currentState || currentState.status !== "running") { + next = baseState({ + player: nameInput, + quest: questInput, + mode, + difficulty, + seed: seedInput || undefined + }); + applyDifficulty(next); + addLog(next, { + type: "start", + title: "Run initialized", + body: `Mode ${mode}, difficulty ${difficulty}, quest ${questInput || "open"}. Seed ${next.seed}.`, + meta: "All threads aligned" + }); + } + return next; +} + +function hydrateInputs(state) { + document.getElementById("player-name").value = state.player || ""; + document.getElementById("player-quest").value = state.quest || ""; + document.getElementById("mode").value = state.mode; + document.getElementById("difficulty").value = state.difficulty; + document.getElementById("seed").value = state.seed; +} + +function bootstrap() { + let state = loadState() || baseState(); + applyDifficulty(state); + hydrateInputs(state); + notify(null); + renderState(state); + + document.getElementById("start-run").addEventListener("click", () => { + state = startOrResume(state); + hydrateInputs(state); + saveState(state); + renderState(state); + }); + + document.getElementById("play-turn").addEventListener("click", () => { + state = startOrResume(state); + takeTurn(state); + saveState(state); + renderState(state); + }); + + document.getElementById("cash-out").addEventListener("click", () => { + cashOut(state); + saveState(state); + renderState(state); + }); + + document.getElementById("reset-run").addEventListener("click", () => { + if (!confirm("Reset the current run?")) return; + state = resetRun(); + applyDifficulty(state); + hydrateInputs(state); + renderState(state); + }); +} + +document.addEventListener("DOMContentLoaded", bootstrap); diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..76b1321 --- /dev/null +++ b/dist/index.html @@ -0,0 +1,787 @@ + + + + + + + HALO Pocket Labyrinth + + + +
+

HALO Pocket Labyrinth

+
📱 Mobile-first micro game • Dice-lite • Oracle-driven
+
+ +
+ +
+
+

1) Spin up a run

+

Pick your vibe, set a quest, and generate a deterministic seed so your friends can mirror the same Labyrinth on their phones.

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+

+
+ +
+

2) Core meters

+

Momentum fuels forward motion. Aegis is your shield. Depth is how far you’ve descended. Doom clocks inevitable collapse.

+
+
Momentum
+
Aegis
+
Depth
+
Doom
+
+

+
+ +
+

3) Take your turn

+

Tap once per beat. HALO rolls the oracle, applies momentum/aegis shifts, and hands you a short prompt. Screenshot or share your seed to sync.

+
+ + +
+
+
+ +
+

4) Run log

+
+
+ +
+

Offline + phone friendly

+

Save this page to your home screen for an instant PWA-style shell. All state lives on-device in localStorage. Use the seed to mirror the same timeline on another phone.

+
+ Deterministic seed + No account required + Dice-lite oracle +
+
+
+ +
Built for tiny screens and quick spreads. HALO respects your sovereignty.
+ + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..1c141fc --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "name": "halo-pocket-labyrinth", + "version": "0.0.0", + "description": "Mobile-first HALO Pocket Labyrinth build outputs", + "scripts": { + "build": "node scripts/build.js" + } +} diff --git a/scripts/build.js b/scripts/build.js new file mode 100644 index 0000000..5e0e1b3 --- /dev/null +++ b/scripts/build.js @@ -0,0 +1,48 @@ +const fs = require('fs'); +const path = require('path'); + +const root = path.resolve(__dirname, '..'); +const srcHtml = path.join(root, 'index.html'); +const srcJs = path.join(root, 'halo.js'); +const distDir = path.join(root, 'dist'); + +function ensureFileExists(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`Missing source file: ${filePath}`); + } +} + +function inlineScript(html, scriptContent) { + const scriptTag = ''; + if (!html.includes(scriptTag)) { + throw new Error('Expected placeholder in index.html'); + } + return html.replace( + scriptTag, + `` + ); +} + +function main() { + ensureFileExists(srcHtml); + ensureFileExists(srcJs); + + const html = fs.readFileSync(srcHtml, 'utf8'); + const script = fs.readFileSync(srcJs, 'utf8'); + + if (!fs.existsSync(distDir)) { + fs.mkdirSync(distDir, { recursive: true }); + } + + const bundledHtml = inlineScript(html, script); + const distHtml = path.join(distDir, 'index.html'); + const distScript = path.join(distDir, 'halo.js'); + + fs.writeFileSync(distHtml, bundledHtml, 'utf8'); + fs.writeFileSync(distScript, script, 'utf8'); + + console.log('Built dist/index.html (inlined halo.js)'); + console.log('Copied dist/halo.js for debugging'); +} + +main(); From 55d4519c20216fac896a4c95d44d48f3c6462872 Mon Sep 17 00:00:00 2001 From: Apocky Date: Wed, 10 Dec 2025 14:27:38 -0700 Subject: [PATCH 3/3] Build online HALO card roguelike with relay and gacha --- README.md | 33 +- dist/halo.js | 869 ++++++++++++++++++++++--------- dist/index.html | 1260 +++++++++++++++++++++++++++------------------ halo.js | 869 ++++++++++++++++++++++--------- index.html | 391 +++++--------- package-lock.json | 36 ++ package.json | 10 +- scripts/server.js | 74 +++ 8 files changed, 2263 insertions(+), 1279 deletions(-) create mode 100644 package-lock.json create mode 100644 scripts/server.js diff --git a/README.md b/README.md index bfa72e1..774758b 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,31 @@ -# HALO Pocket Labyrinth +# HALO Pocket Labyrinth — Online Card Roguelike -A mobile-first, seedable micro game built on the HALO oracle. Run the HTML offline, seed-sync with friends, and track Momentum/Aegis/Depth/Doom as you descend. +Mobile-first HALO built out as a roguelike card crawler with gacha, paid VIP boost, and an optional WebSocket relay for multiplayer sync and chat. ## Play it -Open `index.html` in a mobile browser or add it to your home screen for a lightweight PWA-style shell. State is stored locally in your browser. +* **Phone sideload:** run `npm run build` and send `dist/index.html` to your device (AirDrop, email, cloud drive, or a local file server). The build inlines `halo.js` so it runs offline without extra assets. +* **Home screen:** open `index.html` in mobile Safari/Chrome and add it to your home screen for a lightweight PWA shell. State is on-device (localStorage), with an in-memory fallback if storage is blocked. -To ship a single self-contained file to your phone, run `npm run build` and send `dist/index.html` to your device (AirDrop, email, cloud drive, or a local file server). The build inlines `halo.js` so it loads cleanly from local storage on iOS/Android without needing a separate script. +## Core loop -## Loop +1. Define pilot, quest, seed, difficulty, and mode. Seeds are deterministic—share them so squads can mirror the same Labyrinth. +2. Build a 12-card Adventure Deck from your owned collection. +3. Enter a run, draw encounters, play cards from your hand, then tap **Resolve Beat**. Momentum/Aegis/Depth/Doom drive survival; rewards convert to Credits. +4. Cash out to bank rewards or crash when Doom/Aegis fail. Credits buy Pulse packs; Embers buy Radiant pulls and the VIP Blessing (rarity boost). Purchases are simulated for testing only. -1. Set your handle, quest, mode (solo, co-op, gauntlet), difficulty, and optional seed. -2. Tap **Start / Resume** to lock it in. -3. Tap **Draw next beat** for each turn; the oracle adjusts Momentum, Aegis, Depth, and Doom and gives you a prompt. -4. **Bank rewards / End run** when you want to cash out, or **Reset Run** to start fresh. +## Online relay -Seeds are deterministic—share them so friends can mirror the same Labyrinth on their phones. +An optional, ultra-light relay server lets multiple pilots sync depth/seed metadata and chat while playing the same run. + +* Start the relay locally: `npm install` then `npm run server` (defaults to `ws://localhost:8787`). +* In the client, toggle **Enable relay**, set the relay URL and room (use your seed or a custom code), and hit **Start / Resume Run**. Sync + chat messages flow automatically once connected. + +## Files + +* `index.html` – mobile UI shell. +* `halo.js` – HALO oracle, roguelike loop, gacha/deck logic, and relay client. +* `scripts/build.js` – inlines `halo.js` into `dist/index.html`. +* `scripts/server.js` – minimal WebSocket relay for multiplayer metadata/chat. + +All content stays in this repo for easy sideloading. No external CDNs or assets are required. diff --git a/dist/halo.js b/dist/halo.js index 4a34887..c592d70 100644 --- a/dist/halo.js +++ b/dist/halo.js @@ -1,9 +1,10 @@ -// HALO Pocket Labyrinth – mobile-first micro game -// Deterministic, seedable oracle with light resource management. +// HALO Pocket Labyrinth: Online roguelike card crawler with gacha and relay-ready co-op -const STORAGE_KEY = "halo_mobile_state"; -let memoryFallback = null; +const STORAGE_KEY = "halo_mobile_state_v2"; +const ONLINE_DEFAULT = "ws://localhost:8787"; +let socket = null; let warnedStorage = false; +let memoryFallback = null; const AXES = [ { name: "Mind & Narrative", tagline: "Rewrite your story" }, @@ -28,51 +29,88 @@ const TIMELINES = [ { name: "7–20 years", tagline: "Long term" } ]; -const ARCHETYPES = [ - { name: "The Weaver", tagline: "Fates and patterns" }, - { name: "The Gatekeeper", tagline: "Thresholds and choices" }, - { name: "The Fool", tagline: "Beginner's mind" }, - { name: "The Magician", tagline: "Will and manifestation" }, - { name: "The Empress", tagline: "Fertility and nurture" }, - { name: "The Hermit", tagline: "Inner search" }, - { name: "The Tower", tagline: "Sudden change" }, - { name: "The Star", tagline: "Hope and renewal" }, - { name: "The Sun", tagline: "Clarity and vitality" } -]; - -const SITUATIONS = [ - "A locked door within a shifting hallway", - "A bargain you thought you understood twists", - "A mirror shows a version of you you almost forgot", - "Two timelines overlap; pick one to stabilize", - "An old ally calls in a favor", - "A signal appears from deep within the Labyrinth", - "A beacon flickers; it's both a trap and an invitation", - "You find a cache of forgotten notes", - "The path folds; shortcuts reveal hidden costs", - "A rival steps aside, revealing a deeper threat" -]; - -const BOONS = [ - "Gain clarity: +1 Momentum", - "Shield spark: restore 1 Aegis", - "Shortcut: skip a depth penalty", - "Companion: reroll one bad beat this run", - "Archive ping: lock in current seed for sync", - "Hidden stash: bank your current Momentum", - "Anchor: reduce Doom by 1", - "Insight: write one true thing about your quest" -]; - -const THREATS = [ - "Static surge: lose 1 Aegis", - "False lead: -1 Momentum", - "Depth collapse: +1 Doom if Momentum is 0", - "Shadow bargain: trade 1 Aegis for +2 Momentum", - "Exhaustion: Momentum cannot exceed 3 until you rest", - "Glitch: repeat the next beat twice", - "Echo: your last log entry replays with a darker spin", - "Lockdown: you must cash out after the next beat" +const RARITY_WEIGHTS = { + common: 70, + rare: 25, + mythic: 5 +}; + +const PACKS = { + starter: { name: "Pulse Pack", size: 3, cost: { credits: 250 } }, + radiant: { name: "Radiant Pull", size: 5, cost: { embers: 80 }, bonusRare: true } +}; + +const CARD_POOL = [ + { + id: "rush", + name: "Momentum Rush", + rarity: "common", + axis: "Mind", + text: "+1 Momentum. Draw 1." + }, + { + id: "ward", + name: "Aegis Ward", + rarity: "common", + axis: "Body", + text: "Restore 1 Aegis. If a threat is present, prevent 1 Doom." + }, + { + id: "spark", + name: "Prismatic Spark", + rarity: "common", + axis: "Spirit", + text: "Gain 1 Momentum and reveal the boon on this beat if any." + }, + { + id: "mirror", + name: "Mirror Veil", + rarity: "rare", + axis: "Mind", + text: "If a threat exists, turn it into a boon. Otherwise +1 Aegis." + }, + { + id: "gate", + name: "Gatekeeper's Key", + rarity: "rare", + axis: "Domain", + text: "Reduce Doom by 1 and bank current Momentum as Credits." + }, + { + id: "star", + name: "Starfall Surge", + rarity: "rare", + axis: "Fate", + text: "+2 Momentum, then lose 1 Aegis." + }, + { + id: "tower", + name: "Tower Break", + rarity: "rare", + axis: "Fate", + text: "Clear hand, draw 3 fresh cards, Doom cannot increase this beat." + }, + { + id: "time", + name: "Time Lattice", + rarity: "mythic", + axis: "Spirit", + text: "Set Doom to 0 or Depth-1 (whichever is lower). Gain +1 Aegis." + }, + { + id: "empress", + name: "Empress Bloom", + rarity: "mythic", + axis: "Body", + text: "Heal to 3 Aegis, add +2 Momentum, then bank 1 Ember." + }, + { + id: "magus", + name: "Magus Rewrite", + rarity: "mythic", + axis: "Mind", + text: "Replay the last boon you saw and draw 2 cards." + } ]; function notify(message) { @@ -91,7 +129,7 @@ function hashSeed(str) { let hash = 0; for (let i = 0; i < str.length; i++) { hash = (hash << 5) - hash + str.charCodeAt(i); - hash |= 0; // force 32-bit + hash |= 0; } return Math.abs(hash) + 1; } @@ -107,46 +145,63 @@ function pick(list, state) { return list[idx]; } +function shuffle(list, state) { + const arr = [...list]; + for (let i = arr.length - 1; i > 0; i--) { + const j = Math.floor(random(state) * (i + 1)); + [arr[i], arr[j]] = [arr[j], arr[i]]; + } + return arr; +} + function generateSeed() { const chars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; let out = "HALO-"; - for (let i = 0; i < 6; i++) { - out += chars[Math.floor(Math.random() * chars.length)]; - } + for (let i = 0; i < 6; i++) out += chars[Math.floor(Math.random() * chars.length)]; return out; } +function starterCollection() { + const inventory = {}; + CARD_POOL.forEach((c) => { + if (c.rarity === "common") inventory[c.id] = 2; + if (c.rarity === "rare") inventory[c.id] = 1; + }); + return inventory; +} + function baseState(overrides = {}) { const seed = overrides.seed || generateSeed(); const seedHash = hashSeed(seed); return { - id: Date.now(), - player: overrides.player || "", + player: overrides.player || "", quest: overrides.quest || "", mode: overrides.mode || "solo", difficulty: overrides.difficulty || "standard", - momentum: 2, - aegis: 2, - depth: 0, - doom: 0, - cursor: 0, seed, seedHash, - log: [], - status: "running", - flags: {} + cursor: 1, + currencies: { credits: 800, embers: 160, shards: 0 }, + pity: 0, + vip: false, + collection: starterCollection(), + deck: ["rush", "rush", "ward", "spark", "mirror", "gate", "star", "tower"], + run: null, + online: { + enabled: false, + url: overrides.url || ONLINE_DEFAULT, + room: "", + status: "offline", + peers: [], + log: [] + }, + gachaLog: [], + log: [] }; } -function applyDifficulty(state) { - if (state.difficulty === "chill") { - state.momentum = 3; - state.aegis = 3; - } else if (state.difficulty === "brutal") { - state.momentum = 2; - state.aegis = 1; - state.doom = 1; - } +function clamp(num, min, max) { + return Math.max(min, Math.min(max, num)); } function saveState(state) { @@ -178,240 +233,578 @@ function loadState() { return memoryFallback; } -function statusPills(state) { - const pills = []; - if (state.mode === "coop") pills.push("Co-op seed active"); - if (state.mode === "gauntlet") pills.push("Gauntlet: doom ticks faster"); - pills.push(`Seed ${state.seed}`); - pills.push(`Quest: ${state.quest || "open"}`); - return pills; +function addLog(state, entry) { + state.log.push({ ...entry, timestamp: Date.now() }); + state.log = state.log.slice(-80); } -function renderState(state) { - document.getElementById("stat-momentum").textContent = state.momentum; - document.getElementById("stat-aegis").textContent = state.aegis; - document.getElementById("stat-depth").textContent = state.depth; - document.getElementById("stat-doom").textContent = state.doom; +function rarityRoll(state) { + const bonus = state.vip ? 5 : 0; + const roll = random(state) * (100 + bonus); + const mythicCut = RARITY_WEIGHTS.mythic + bonus; + if (roll >= 100 - mythicCut) return "mythic"; + if (roll >= 100 - RARITY_WEIGHTS.rare) return "rare"; + return "common"; +} + +function randomCardByRarity(rarity, state) { + const pool = CARD_POOL.filter((c) => c.rarity === rarity); + return pick(pool, state); +} + +function addToCollection(state, cardId) { + state.collection[cardId] = (state.collection[cardId] || 0) + 1; +} - const statusLine = document.getElementById("status-line"); - statusLine.innerHTML = statusPills(state) - .map((p) => `${p}`) - .join(""); +function pullPack(state, packKey) { + const pack = PACKS[packKey]; + if (!pack) return { results: [], reason: "Missing pack" }; + const cost = pack.cost; + if (cost.credits && state.currencies.credits < cost.credits) + return { results: [], reason: "Not enough credits" }; + if (cost.embers && state.currencies.embers < cost.embers) + return { results: [], reason: "Not enough embers" }; + + if (cost.credits) state.currencies.credits -= cost.credits; + if (cost.embers) state.currencies.embers -= cost.embers; + + const results = []; + for (let i = 0; i < pack.size; i++) { + let rarity = rarityRoll(state); + if (state.pity >= 8) rarity = "rare"; + if (pack.bonusRare && i === pack.size - 1) rarity = rarity === "common" ? "rare" : rarity; + const card = randomCardByRarity(rarity, state); + addToCollection(state, card.id); + results.push(card); + state.pity = rarity === "rare" || rarity === "mythic" ? 0 : state.pity + 1; + } - document.getElementById("seed-display").textContent = - state.status === "running" - ? `Active seed: ${state.seed}` - : `Run ended. Seed was ${state.seed}`; + state.gachaLog.push({ + pack: pack.name, + results, + timestamp: Date.now() + }); - renderLog(state); - renderCurrent(state); + return { results }; +} - const playBtn = document.getElementById("play-turn"); - const cashBtn = document.getElementById("cash-out"); - playBtn.disabled = state.status !== "running"; - cashBtn.disabled = state.status !== "running"; +function ensureDeckLegal(state) { + const cleaned = state.deck.filter((id) => state.collection[id]); + if (!cleaned.length) cleaned.push("rush", "rush", "ward", "spark"); + state.deck = cleaned.slice(0, 12); } -function renderCurrent(state) { - const slot = document.getElementById("current-event"); - slot.innerHTML = ""; - const latest = [...state.log].reverse().find((entry) => entry.type === "turn"); - if (!latest) { - slot.innerHTML = '
No beats yet. Tap “Draw next beat.”
'; - return; - } - const el = document.createElement("div"); - el.className = "log-entry"; - el.innerHTML = ` -

${latest.title}

-

${latest.body}

- ${latest.meta} - `; - slot.appendChild(el); -} - -function renderLog(state) { - const container = document.getElementById("run-log"); - container.innerHTML = ""; - if (!state.log.length) { - container.innerHTML = '
No history yet.
'; - return; +function drawCards(run, state, count = 1) { + for (let i = 0; i < count; i++) { + if (!run.drawPile.length) { + run.drawPile = shuffle(run.discard, state); + run.discard = []; + } + if (!run.drawPile.length) break; + run.hand.push(run.drawPile.shift()); } - state.log - .slice(-12) - .reverse() - .forEach((entry) => { - const card = document.createElement("div"); - card.className = "log-entry"; - card.innerHTML = ` -

${entry.title}

-

${entry.body}

- ${entry.meta} - `; - container.appendChild(card); - }); } -function addLog(state, entry) { - state.log.push({ ...entry, timestamp: Date.now() }); +function nextEncounter(state) { + const encounter = { + axis: pick(AXES, state), + vector: pick(VECTORS, state), + timeline: pick(TIMELINES, state), + boon: random(state) > 0.55, + threat: random(state) > 0.5, + situation: pick( + [ + "A rival faction challenges your route", + "Two timelines overlap; pick one to stabilize", + "An ally pings you from deeper layers", + "A cache of relics hums with risk", + "A distorted mirror tries to rewrite you", + "A sealed gate leaks starlight", + "A phantom deal returns for payment" + ], + state + ) + }; + return encounter; } -function clamp(num, min, max) { - return Math.max(min, Math.min(num, max)); +function baseRun(state) { + const run = { + status: "running", + depth: 0, + momentum: state.difficulty === "chill" ? 3 : 2, + aegis: state.difficulty === "brutal" ? 1 : 2, + doom: state.difficulty === "brutal" ? 1 : 0, + drawPile: shuffle(state.deck, state), + discard: [], + hand: [], + current: null, + lastBoon: null + }; + drawCards(run, state, 3); + run.current = nextEncounter(state); + return run; } -function momentumShift(state) { - const roll = Math.floor(random(state) * 6) + 1; - if (roll === 1) return -1; - if (roll === 6) return 2; - if (roll >= 5) return 1; - return 0; +function startRun(state) { + ensureDeckLegal(state); + state.run = baseRun(state); + addLog(state, { type: "run", title: "Run initialized", body: `Deck size ${state.deck.length}`, meta: state.seed }); } -function aegisShift(state) { - const roll = Math.floor(random(state) * 6) + 1; - return roll === 1 ? -1 : 0; +function endRun(state, reason = "banked") { + if (!state.run) return; + const reward = Math.max(0, state.run.depth + state.run.momentum); + state.currencies.credits += reward * 20; + if (reason === "victory") state.currencies.embers += 10; + addLog(state, { + type: "cashout", + title: `Run ${reason}`, + body: `Depth ${state.run.depth}, Momentum ${state.run.momentum}, Aegis ${state.run.aegis}, Doom ${state.run.doom}. +${ + reward * 20 + } credits`, + meta: new Date().toLocaleTimeString() + }); + state.run = null; } -function doomShift(state) { - let increment = 0; - if (state.mode === "gauntlet") increment += 1; - if (state.momentum < 0) increment += 1; - if (state.aegis <= 0) increment += 1; - return increment; +function doomTick(run) { + let inc = 0; + if (run.momentum < 0) inc += 1; + if (run.aegis <= 0) inc += 1; + return inc; } -function takeTurn(state) { - if (state.status !== "running") return; +function applyCard(state, run, card, encounter) { + switch (card.id) { + case "rush": + run.momentum = clamp(run.momentum + 1, -2, 8); + drawCards(run, state, 1); + break; + case "ward": + run.aegis = clamp(run.aegis + 1, 0, 5); + if (encounter.threat) run.doom = clamp(run.doom - 1, 0, 6); + break; + case "spark": + run.momentum = clamp(run.momentum + 1, -2, 8); + if (encounter.boon) run.lastBoon = encounter; + break; + case "mirror": + if (encounter.threat) { + encounter.threat = false; + encounter.boon = true; + run.lastBoon = encounter; + } else { + run.aegis = clamp(run.aegis + 1, 0, 5); + } + break; + case "gate": + run.doom = clamp(run.doom - 1, 0, 6); + state.currencies.credits += Math.max(0, run.momentum) * 30; + break; + case "star": + run.momentum = clamp(run.momentum + 2, -2, 8); + run.aegis = clamp(run.aegis - 1, 0, 5); + break; + case "tower": + run.hand = []; + run.drawPile = shuffle(run.drawPile.concat(run.discard), state); + run.discard = []; + drawCards(run, state, 3); + run.doom = clamp(run.doom, 0, 5); + break; + case "time": + run.doom = clamp(Math.min(run.doom, Math.max(0, run.depth - 1)), 0, 6); + run.aegis = clamp(run.aegis + 1, 0, 5); + break; + case "empress": + run.aegis = 3; + run.momentum = clamp(run.momentum + 2, -2, 8); + state.currencies.embers += 1; + break; + case "magus": + if (run.lastBoon) { + run.momentum = clamp(run.momentum + 1, -2, 8); + run.aegis = clamp(run.aegis + 1, 0, 5); + } + drawCards(run, state, 2); + break; + default: + break; + } +} + +function playCard(state, cardId) { + if (!state.run || state.run.status !== "running") return; + const idx = state.run.hand.indexOf(cardId); + if (idx === -1) return; + const [cardRef] = state.run.hand.splice(idx, 1); + const card = CARD_POOL.find((c) => c.id === cardRef); + const encounter = state.run.current; + applyCard(state, state.run, card, encounter); + state.run.discard.push(cardRef); +} - const axis = pick(AXES, state); - const vector = pick(VECTORS, state); - const timeline = pick(TIMELINES, state); - const archetype = pick(ARCHETYPES, state); - const situation = pick(SITUATIONS, state); - const boon = random(state) > 0.55 ? pick(BOONS, state) : null; - const threat = random(state) > 0.55 ? pick(THREATS, state) : null; +function resolveBeat(state) { + if (!state.run || state.run.status !== "running") return; + const run = state.run; + run.depth += 1; + const encounter = run.current; - state.depth += 1; - state.momentum = clamp(state.momentum + momentumShift(state), -2, 6); - state.aegis = clamp(state.aegis + aegisShift(state), 0, 4); - state.doom = clamp(state.doom + doomShift(state), 0, 6); + // Apply base tick + run.doom = clamp(run.doom + doomTick(run), 0, 6); - const lines = [ - `${axis.name} × ${vector.name} (${timeline.name})`, - `${archetype.name}: ${archetype.tagline}`, - situation - ]; - if (boon) lines.push(`✨ ${boon}`); - if (threat) lines.push(`⚠️ ${threat}`); + const title = `${encounter.axis.name} × ${encounter.vector.name} (${encounter.timeline.name})`; + const lines = [encounter.situation]; + if (encounter.boon) lines.push("✨ Boon in play"); + if (encounter.threat) lines.push("⚠️ Threat in play"); addLog(state, { type: "turn", - title: `Depth ${state.depth} • Momentum ${state.momentum}`, + title: `Depth ${run.depth} • Momentum ${run.momentum}`, body: lines.join(" • "), meta: new Date().toLocaleTimeString() }); - if (state.doom >= 6 || state.aegis <= 0) { + if (run.doom >= 6 || run.aegis <= 0) { + run.status = "crashed"; addLog(state, { type: "crash", title: "Run collapsed", - body: `Doom hit ${state.doom}. Aegis at ${state.aegis}. Cash out or reset.`, + body: `Doom ${run.doom}, Aegis ${run.aegis}. Bank or reset.`, meta: "Labyrinth spits you out" }); - state.status = "ended"; + pushOnline(state, { kind: "crash", depth: run.depth, doom: run.doom }); + return; } + + drawCards(run, state, 1); + run.current = nextEncounter(state); + pushOnline(state, { kind: "sync", depth: run.depth, momentum: run.momentum, doom: run.doom }); } function cashOut(state) { - if (state.status !== "running") return; - state.status = "ended"; - addLog(state, { - type: "cashout", - title: "Run banked", - body: `Depth ${state.depth}, Momentum ${state.momentum}, Aegis ${state.aegis}, Doom ${state.doom}. Share seed ${state.seed}.`, - meta: "You step out with what you can carry" - }); + endRun(state, "banked"); } -function resetRun() { +function resetRun(state) { + state.run = null; + state.seed = generateSeed(); + state.seedHash = hashSeed(state.seed); + state.cursor = 1; +} + +function toggleVip(state) { + if (state.vip) return; + const cost = 120; + if (state.currencies.embers < cost) return notify("Need more embers for VIP Blessing"); + state.currencies.embers -= cost; + state.vip = true; + addLog(state, { type: "vip", title: "VIP Blessing unlocked", body: "Rarity boosts active", meta: "Paid feature" }); +} + +// Online sync +function connectOnline(state) { + if (!state.online.enabled) { + disconnectOnline(state); + return; + } + if (socket && socket.readyState === WebSocket.OPEN) return; try { - localStorage.removeItem(STORAGE_KEY); + socket = new WebSocket(state.online.url || ONLINE_DEFAULT); } catch (err) { - console.warn("Could not clear stored state", err); - } - memoryFallback = null; - return baseState(); -} - -function startOrResume(currentState) { - const nameInput = document.getElementById("player-name").value.trim(); - const questInput = document.getElementById("player-quest").value.trim(); - const mode = document.getElementById("mode").value; - const difficulty = document.getElementById("difficulty").value; - const seedInput = document.getElementById("seed").value.trim(); - - let next = currentState; - if (!currentState || currentState.status !== "running") { - next = baseState({ - player: nameInput, - quest: questInput, - mode, - difficulty, - seed: seedInput || undefined - }); - applyDifficulty(next); - addLog(next, { - type: "start", - title: "Run initialized", - body: `Mode ${mode}, difficulty ${difficulty}, quest ${questInput || "open"}. Seed ${next.seed}.`, - meta: "All threads aligned" - }); + notify("Failed to start socket. Check relay URL."); + state.online.status = "offline"; + return; } - return next; + socket.addEventListener("open", () => { + state.online.status = "connected"; + socket.send( + JSON.stringify({ type: "join", room: state.online.room || state.seed, player: state.player || "anon" }) + ); + render(state); + }); + socket.addEventListener("message", (event) => { + const data = JSON.parse(event.data); + if (data.type === "welcome") { + state.online.status = "connected"; + state.online.log.push({ meta: "system", message: `Joined ${data.room} with ${data.peers} peers` }); + } + if (data.type === "system") state.online.log.push({ meta: "system", message: data.message }); + if (data.type === "chat") state.online.log.push({ meta: data.from, message: data.message }); + if (data.type === "sync") state.online.log.push({ meta: data.from, message: `Depth ${data.payload.depth}` }); + state.online.log = state.online.log.slice(-30); + render(state); + }); + socket.addEventListener("close", () => { + state.online.status = "offline"; + render(state); + }); + socket.addEventListener("error", () => { + notify("Relay connection failed"); + state.online.status = "offline"; + render(state); + }); } -function hydrateInputs(state) { - document.getElementById("player-name").value = state.player || ""; - document.getElementById("player-quest").value = state.quest || ""; +function disconnectOnline(state) { + if (socket) socket.close(); + state.online.status = "offline"; +} + +function pushOnline(state, payload) { + if (!state.online.enabled || !socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: "sync", payload })); +} + +// Rendering helpers +function renderProfile(state) { + document.getElementById("player-name").value = state.player; + document.getElementById("player-quest").value = state.quest; document.getElementById("mode").value = state.mode; document.getElementById("difficulty").value = state.difficulty; document.getElementById("seed").value = state.seed; + document.getElementById("online-url").value = state.online.url; + document.getElementById("online-room").value = state.online.room || state.seed; + document.getElementById("online-enabled").checked = state.online.enabled; + document.getElementById("seed-display").textContent = `Seed: ${state.seed}`; +} + +function renderEconomy(state) { + document.getElementById("stat-credits").textContent = state.currencies.credits; + document.getElementById("stat-embers").textContent = state.currencies.embers; + document.getElementById("stat-shards").textContent = state.currencies.shards; + document.getElementById("vip-status").textContent = state.vip ? "VIP active" : "Standard"; +} + +function renderCollection(state) { + const deckList = document.getElementById("deck-list"); + const collectionList = document.getElementById("collection-list"); + deckList.innerHTML = ""; + collectionList.innerHTML = ""; + + state.deck.forEach((id, idx) => { + const card = CARD_POOL.find((c) => c.id === id); + const el = document.createElement("div"); + el.className = "pill-row card-pill"; + el.innerHTML = `${card.name}${card.rarity}`; + deckList.appendChild(el); + }); + + CARD_POOL.forEach((card) => { + const owned = state.collection[card.id] || 0; + const el = document.createElement("div"); + el.className = "pill-row card-pill"; + el.innerHTML = `
${card.name} ${card.rarity} ${card.axis}
x${owned}
`; + collectionList.appendChild(el); + }); + + deckList.querySelectorAll("button[data-remove]").forEach((btn) => { + btn.addEventListener("click", (e) => { + const idx = parseInt(e.target.getAttribute("data-remove"), 10); + state.deck.splice(idx, 1); + saveState(state); + render(state); + }); + }); + + collectionList.querySelectorAll("button[data-add]").forEach((btn) => { + btn.addEventListener("click", (e) => { + const id = e.target.getAttribute("data-add"); + if ((state.collection[id] || 0) <= state.deck.filter((c) => c === id).length) return; + state.deck.push(id); + ensureDeckLegal(state); + saveState(state); + render(state); + }); + }); +} + +function renderRun(state) { + const run = state.run; + document.getElementById("stat-momentum").textContent = run ? run.momentum : "–"; + document.getElementById("stat-aegis").textContent = run ? run.aegis : "–"; + document.getElementById("stat-depth").textContent = run ? run.depth : "–"; + document.getElementById("stat-doom").textContent = run ? run.doom : "–"; + + const current = document.getElementById("current-event"); + current.innerHTML = ""; + if (!run) { + current.innerHTML = '
Start a run to see encounters.
'; + } else { + const enc = run.current; + const card = document.createElement("div"); + card.className = "log-entry"; + card.innerHTML = `

${enc.axis.name} × ${enc.vector.name} (${enc.timeline.name})

${enc.situation}

${ + enc.boon ? "✨ Boon available" : "" + } ${enc.threat ? "⚠️ Threat active" : ""}`; + current.appendChild(card); + } + + const hand = document.getElementById("hand"); + hand.innerHTML = ""; + if (run) { + run.hand.forEach((cardId) => { + const card = CARD_POOL.find((c) => c.id === cardId); + const el = document.createElement("button"); + el.className = "card-btn"; + el.textContent = `${card.name} (${card.rarity})`; + el.addEventListener("click", () => { + playCard(state, cardId); + render(state); + }); + hand.appendChild(el); + }); + } + + const logBox = document.getElementById("run-log"); + logBox.innerHTML = ""; + state.log + .slice(-14) + .reverse() + .forEach((entry) => { + const el = document.createElement("div"); + el.className = "log-entry"; + el.innerHTML = `

${entry.title}

${entry.body}

${entry.meta}`; + logBox.appendChild(el); + }); + + document.getElementById("play-turn").disabled = !run; + document.getElementById("cash-out").disabled = !run; +} + +function renderGacha(state) { + const results = document.getElementById("gacha-results"); + const last = state.gachaLog[state.gachaLog.length - 1]; + if (!last) { + results.innerHTML = '
Open a pack to see pulls.
'; + return; + } + results.innerHTML = `

${last.pack} yielded:

`; + last.results.forEach((card) => { + const el = document.createElement("div"); + el.className = "log-entry"; + el.innerHTML = `

${card.name}

${card.text}

${card.rarity} • ${card.axis}`; + results.appendChild(el); + }); +} + +function renderOnline(state) { + const status = document.getElementById("online-status"); + status.textContent = state.online.status; + const log = document.getElementById("online-log"); + log.innerHTML = ""; + state.online.log + .slice(-8) + .reverse() + .forEach((entry) => { + const el = document.createElement("div"); + el.className = "log-entry"; + el.innerHTML = `

${entry.meta}

${entry.message}

`; + log.appendChild(el); + }); +} + +function render(state) { + renderProfile(state); + renderEconomy(state); + renderCollection(state); + renderRun(state); + renderGacha(state); + renderOnline(state); } function bootstrap() { let state = loadState() || baseState(); - applyDifficulty(state); - hydrateInputs(state); - notify(null); - renderState(state); + render(state); document.getElementById("start-run").addEventListener("click", () => { - state = startOrResume(state); - hydrateInputs(state); + state.player = document.getElementById("player-name").value.trim(); + state.quest = document.getElementById("player-quest").value.trim(); + state.mode = document.getElementById("mode").value; + state.difficulty = document.getElementById("difficulty").value; + state.seed = document.getElementById("seed").value.trim() || generateSeed(); + state.seedHash = hashSeed(state.seed); + state.cursor = 1; + startRun(state); + saveState(state); + render(state); + }); + + document.getElementById("reset-run").addEventListener("click", () => { + if (!confirm("Reset the current run?")) return; + resetRun(state); saveState(state); - renderState(state); + render(state); }); document.getElementById("play-turn").addEventListener("click", () => { - state = startOrResume(state); - takeTurn(state); + resolveBeat(state); saveState(state); - renderState(state); + render(state); }); document.getElementById("cash-out").addEventListener("click", () => { cashOut(state); saveState(state); - renderState(state); + render(state); }); - document.getElementById("reset-run").addEventListener("click", () => { - if (!confirm("Reset the current run?")) return; - state = resetRun(); - applyDifficulty(state); - hydrateInputs(state); - renderState(state); + document.getElementById("pull-starter").addEventListener("click", () => { + const res = pullPack(state, "starter"); + if (!res.results.length && res.reason) return notify(res.reason); + saveState(state); + render(state); + }); + + document.getElementById("pull-radiant").addEventListener("click", () => { + const res = pullPack(state, "radiant"); + if (!res.results.length && res.reason) return notify(res.reason); + saveState(state); + render(state); + }); + + document.getElementById("buy-embers").addEventListener("click", () => { + state.currencies.embers += 300; + addLog(state, { type: "purchase", title: "Simulated purchase", body: "+300 Embers", meta: "Test harness" }); + saveState(state); + render(state); + }); + + document.getElementById("vip-upgrade").addEventListener("click", () => { + toggleVip(state); + saveState(state); + render(state); + }); + + document.getElementById("online-enabled").addEventListener("change", (e) => { + state.online.enabled = e.target.checked; + state.online.url = document.getElementById("online-url").value.trim() || ONLINE_DEFAULT; + state.online.room = document.getElementById("online-room").value.trim() || state.seed; + saveState(state); + connectOnline(state); + render(state); + }); + + document.getElementById("online-url").addEventListener("change", (e) => { + state.online.url = e.target.value.trim(); + saveState(state); + }); + + document.getElementById("online-room").addEventListener("change", (e) => { + state.online.room = e.target.value.trim(); + saveState(state); + }); + + document.getElementById("send-chat").addEventListener("click", () => { + const text = document.getElementById("chat-text").value.trim(); + if (!text || !socket || socket.readyState !== WebSocket.OPEN) return; + socket.send(JSON.stringify({ type: "chat", message: text })); + document.getElementById("chat-text").value = ""; }); } diff --git a/dist/index.html b/dist/index.html index 76b1321..0dc30a4 100644 --- a/dist/index.html +++ b/dist/index.html @@ -4,7 +4,7 @@ - HALO Pocket Labyrinth + HALO Pocket Labyrinth Online
-

HALO Pocket Labyrinth

-
📱 Mobile-first micro game • Dice-lite • Oracle-driven
+

HALO Pocket Labyrinth • Online Roguelike

+
🎴 Card roguelike • 📡 Online relay • 📱 Mobile-first
-

1) Spin up a run

-

Pick your vibe, set a quest, and generate a deterministic seed so your friends can mirror the same Labyrinth on their phones.

+

Profile & Seed

+

Define your pilot, quest, mode, and seed. Seeds stay deterministic so squads can mirror the same Labyrinth.

-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
+
+
+
+
+
@@ -322,53 +108,107 @@

1) Spin up a run

-

2) Core meters

-

Momentum fuels forward motion. Aegis is your shield. Depth is how far you’ve descended. Doom clocks inevitable collapse.

+

Economy & Paid features

+

Earn credits by delving, spend Embers on Radiant pulls or the VIP Blessing (rarity boost). Purchases are simulated for testing.

+
+
Credits
+
Embers
+
Shards
+
Status
+
+
+ + +
+
+ +
+

Gacha & Collection

+

Open packs to expand your deck. Pity kicks in after 8 non-rare pulls. Radiant pulls guarantee at least one rare.

+
+ + +
+
+
+ +
+

Deck Builder

+

Tap Add to move owned cards into your Adventure Deck. Max 12 cards; duplicates limited by ownership.

+
+

Adventure Deck

+
+
+
+

Collection

+
+
+
+ +
+

Run (Roguelike beats)

+

Draw an encounter, play cards from your hand, then resolve the beat. Depth and Doom define survival; Momentum fuels speed; Aegis is your shield.

Momentum
Aegis
Depth
Doom
-

-
- -
-

3) Take your turn

-

Tap once per beat. HALO rolls the oracle, applies momentum/aegis shifts, and hands you a short prompt. Screenshot or share your seed to sync.

- +
+

Hand

+
+

Run Log

+
-

4) Run log

-
+

Online relay (optional)

+

Run your own websocket relay (`npm run server`) or point to a shared instance. Rooms are lightweight; share the seed/room to sync spread depth and chat.

+
+
+
+
+
+
Status: offline
+
+
+ + +
+
+
+

Relay log

+
+
+
-

Offline + phone friendly

-

Save this page to your home screen for an instant PWA-style shell. All state lives on-device in localStorage. Use the seed to mirror the same timeline on another phone.

+

Phone-first

+

Save `dist/index.html` to your device or add this page to your home screen. All state is on-device; relay is opt-in.

Deterministic seed - No account required - Dice-lite oracle + Offline-friendly + Relay-ready
-
Built for tiny screens and quick spreads. HALO respects your sovereignty.
+
Built on the HALO oracle. You are the Root User.
diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8443a86 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "halo-pocket-labyrinth", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "halo-pocket-labyrinth", + "version": "0.1.0", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json index 1c141fc..afde348 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,12 @@ { "name": "halo-pocket-labyrinth", - "version": "0.0.0", - "description": "Mobile-first HALO Pocket Labyrinth build outputs", + "version": "0.1.0", + "description": "Mobile-first HALO Pocket Labyrinth build outputs with optional relay server", "scripts": { - "build": "node scripts/build.js" + "build": "node scripts/build.js", + "server": "node scripts/server.js" + }, + "dependencies": { + "ws": "^8.18.0" } } diff --git a/scripts/server.js b/scripts/server.js new file mode 100644 index 0000000..7623cea --- /dev/null +++ b/scripts/server.js @@ -0,0 +1,74 @@ +const WebSocket = require('ws'); + +const PORT = process.env.PORT || 8787; +const rooms = new Map(); + +function broadcast(roomId, payload, skip) { + const room = rooms.get(roomId); + if (!room) return; + const data = JSON.stringify(payload); + for (const peer of room) { + if (peer === skip || peer.readyState !== WebSocket.OPEN) continue; + peer.send(data); + } +} + +function prune(roomId) { + const room = rooms.get(roomId); + if (!room) return; + for (const peer of [...room]) { + if (peer.readyState !== WebSocket.OPEN) room.delete(peer); + } + if (!room.size) rooms.delete(roomId); +} + +const wss = new WebSocket.Server({ port: PORT }); + +wss.on('connection', (ws) => { + ws.meta = { room: null, player: 'anon' }; + + ws.on('message', (msg) => { + let data; + try { + data = JSON.parse(msg); + } catch (err) { + return; + } + + if (data.type === 'join') { + const roomId = (data.room || 'lobby').slice(0, 32); + const player = (data.player || 'anon').slice(0, 32); + ws.meta = { room: roomId, player }; + if (!rooms.has(roomId)) rooms.set(roomId, new Set()); + rooms.get(roomId).add(ws); + broadcast(roomId, { type: 'system', message: `${player} joined ${roomId}` }, ws); + ws.send(JSON.stringify({ type: 'welcome', room: roomId, peers: rooms.get(roomId).size })); + return; + } + + if (!ws.meta.room) return; + + if (data.type === 'sync') { + broadcast(ws.meta.room, { type: 'sync', from: ws.meta.player, payload: data.payload }, ws); + return; + } + + if (data.type === 'chat') { + broadcast(ws.meta.room, { type: 'chat', from: ws.meta.player, message: data.message }, ws); + return; + } + }); + + ws.on('close', () => { + const { room, player } = ws.meta; + if (room && rooms.has(room)) { + broadcast(room, { type: 'system', message: `${player} left ${room}` }, ws); + rooms.get(room).delete(ws); + prune(room); + } + }); +}); + +wss.on('listening', () => { + console.log(`HALO relay server running on ws://localhost:${PORT}`); +});