From 0663a2a3025fa70915d686462b11beedf6d5ac6f Mon Sep 17 00:00:00 2001 From: Apocky Date: Wed, 10 Dec 2025 15:03:03 -0700 Subject: [PATCH 1/2] Add Android WebView shell for HALO --- HALO_Decision_Engine.md | 214 ++++ README.md | 40 +- android-app/app/build.gradle | 44 + android-app/app/proguard-rules.pro | 0 android-app/app/src/main/AndroidManifest.xml | 28 + android-app/app/src/main/assets/index.html | 1019 +++++++++++++++++ .../halo/pocketlabyrinth/MainActivity.java | 30 + .../res/drawable/ic_launcher_foreground.xml | 16 + .../app/src/main/res/layout/activity_main.xml | 11 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + .../app/src/main/res/values/colors.xml | 6 + .../app/src/main/res/values/strings.xml | 4 + .../app/src/main/res/values/themes.xml | 6 + .../app/src/main/res/xml/backup_rules.xml | 4 + android-app/build.gradle | 20 + android-app/settings.gradle | 2 + dist/halo.js | 811 +++++++++++++ dist/index.html | 1019 +++++++++++++++++ halo.js | 963 ++++++++++++---- index.html | 423 +++---- package-lock.json | 36 + package.json | 12 + scripts/build.js | 48 + scripts/server.js | 74 ++ scripts/sync_android_assets.sh | 15 + 26 files changed, 4392 insertions(+), 463 deletions(-) create mode 100644 HALO_Decision_Engine.md create mode 100644 android-app/app/build.gradle create mode 100644 android-app/app/proguard-rules.pro create mode 100644 android-app/app/src/main/AndroidManifest.xml create mode 100644 android-app/app/src/main/assets/index.html create mode 100644 android-app/app/src/main/java/com/halo/pocketlabyrinth/MainActivity.java create mode 100644 android-app/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android-app/app/src/main/res/layout/activity_main.xml create mode 100644 android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 android-app/app/src/main/res/values/colors.xml create mode 100644 android-app/app/src/main/res/values/strings.xml create mode 100644 android-app/app/src/main/res/values/themes.xml create mode 100644 android-app/app/src/main/res/xml/backup_rules.xml create mode 100644 android-app/build.gradle create mode 100644 android-app/settings.gradle create mode 100644 dist/halo.js create mode 100644 dist/index.html create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 scripts/build.js create mode 100644 scripts/server.js create mode 100755 scripts/sync_android_assets.sh 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..f8ad169 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,38 @@ -# Collab_Reposit -The place to discuss plans and share ideas for the game. +# HALO Pocket Labyrinth — Online Card Roguelike + +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 + +* **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. + +## Android APK (WebView shell) + +1. Run `npm run build` to refresh `dist/index.html`. +2. Sync the HTML into the Android project: `./scripts/sync_android_assets.sh`. +3. Open the `android-app` folder in Android Studio (Giraffe+), let it download the Android Gradle Plugin, and build **app → assembleDebug**. +4. Install `app-debug.apk` on your device. The shell runs offline and keeps relay/WebSocket support when you point it at your server. + +## Core 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. + +## Online relay + +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/android-app/app/build.gradle b/android-app/app/build.gradle new file mode 100644 index 0000000..72eb145 --- /dev/null +++ b/android-app/app/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'com.android.application' +} + +android { + namespace 'com.halo.pocketlabyrinth' + compileSdk 34 + + defaultConfig { + applicationId 'com.halo.pocketlabyrinth' + minSdk 26 + targetSdk 34 + versionCode 1 + versionName '0.1.0' + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + debug { + applicationIdSuffix '.debug' + debuggable true + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + packagingOptions { + resources { + excludes += ['/META-INF/{AL2.0,LGPL2.1}'] + } + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.7.0' + implementation 'com.google.android.material:material:1.12.0' + implementation 'androidx.webkit:webkit:1.10.0' +} diff --git a/android-app/app/proguard-rules.pro b/android-app/app/proguard-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3d08abc --- /dev/null +++ b/android-app/app/src/main/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + diff --git a/android-app/app/src/main/assets/index.html b/android-app/app/src/main/assets/index.html new file mode 100644 index 0000000..0dc30a4 --- /dev/null +++ b/android-app/app/src/main/assets/index.html @@ -0,0 +1,1019 @@ + + + + + + + HALO Pocket Labyrinth Online + + + +
+

HALO Pocket Labyrinth • Online Roguelike

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

Profile & Seed

+

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

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

+
+ +
+

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
+
+
+ + +
+
+

Hand

+
+

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

+
+
+
+
+ +
+

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 + Offline-friendly + Relay-ready +
+
+
+ + + + + + diff --git a/android-app/app/src/main/java/com/halo/pocketlabyrinth/MainActivity.java b/android-app/app/src/main/java/com/halo/pocketlabyrinth/MainActivity.java new file mode 100644 index 0000000..e8f552e --- /dev/null +++ b/android-app/app/src/main/java/com/halo/pocketlabyrinth/MainActivity.java @@ -0,0 +1,30 @@ +package com.halo.pocketlabyrinth; + +import android.annotation.SuppressLint; +import android.os.Bundle; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +import androidx.appcompat.app.AppCompatActivity; + +public class MainActivity extends AppCompatActivity { + + @SuppressLint("SetJavaScriptEnabled") + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_main); + + WebView webView = findViewById(R.id.webview); + WebSettings webSettings = webView.getSettings(); + webSettings.setJavaScriptEnabled(true); + webSettings.setDomStorageEnabled(true); + webSettings.setAllowFileAccessFromFileURLs(true); + webSettings.setAllowUniversalAccessFromFileURLs(true); + webSettings.setMediaPlaybackRequiresUserGesture(false); + + webView.setWebViewClient(new WebViewClient()); + webView.loadUrl("file:///android_asset/index.html"); + } +} diff --git a/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml b/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..c7c8e06 --- /dev/null +++ b/android-app/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..8df8d93 --- /dev/null +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..a8a8fa5 --- /dev/null +++ b/android-app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android-app/app/src/main/res/values/colors.xml b/android-app/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..0cc31ab --- /dev/null +++ b/android-app/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #050816 + #6dd5ff + #7485ff + diff --git a/android-app/app/src/main/res/values/strings.xml b/android-app/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..f2c4795 --- /dev/null +++ b/android-app/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + HALO Pocket Labyrinth + diff --git a/android-app/app/src/main/res/values/themes.xml b/android-app/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..1e88b67 --- /dev/null +++ b/android-app/app/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + + diff --git a/android-app/app/src/main/res/xml/backup_rules.xml b/android-app/app/src/main/res/xml/backup_rules.xml new file mode 100644 index 0000000..be1d8fd --- /dev/null +++ b/android-app/app/src/main/res/xml/backup_rules.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android-app/build.gradle b/android-app/build.gradle new file mode 100644 index 0000000..d697625 --- /dev/null +++ b/android-app/build.gradle @@ -0,0 +1,20 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.5.1' + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register('clean', Delete) { + delete rootProject.buildDir +} diff --git a/android-app/settings.gradle b/android-app/settings.gradle new file mode 100644 index 0000000..8932cc6 --- /dev/null +++ b/android-app/settings.gradle @@ -0,0 +1,2 @@ +rootProject.name = "HALOAndroid" +include(":app") diff --git a/dist/halo.js b/dist/halo.js new file mode 100644 index 0000000..c592d70 --- /dev/null +++ b/dist/halo.js @@ -0,0 +1,811 @@ +// HALO Pocket Labyrinth: Online roguelike card crawler with gacha and relay-ready co-op + +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" }, + { 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 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) { + 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; + } + 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 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)]; + 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 { + player: overrides.player || "", + quest: overrides.quest || "", + mode: overrides.mode || "solo", + difficulty: overrides.difficulty || "standard", + seed, + seedHash, + 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 clamp(num, min, max) { + return Math.max(min, Math.min(max, num)); +} + +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 addLog(state, entry) { + state.log.push({ ...entry, timestamp: Date.now() }); + state.log = state.log.slice(-80); +} + +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; +} + +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; + } + + state.gachaLog.push({ + pack: pack.name, + results, + timestamp: Date.now() + }); + + return { results }; +} + +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 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()); + } +} + +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 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 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 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 doomTick(run) { + let inc = 0; + if (run.momentum < 0) inc += 1; + if (run.aegis <= 0) inc += 1; + return inc; +} + +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); +} + +function resolveBeat(state) { + if (!state.run || state.run.status !== "running") return; + const run = state.run; + run.depth += 1; + const encounter = run.current; + + // Apply base tick + run.doom = clamp(run.doom + doomTick(run), 0, 6); + + 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 ${run.depth} • Momentum ${run.momentum}`, + body: lines.join(" • "), + meta: new Date().toLocaleTimeString() + }); + + if (run.doom >= 6 || run.aegis <= 0) { + run.status = "crashed"; + addLog(state, { + type: "crash", + title: "Run collapsed", + body: `Doom ${run.doom}, Aegis ${run.aegis}. Bank or reset.`, + meta: "Labyrinth spits you out" + }); + 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) { + endRun(state, "banked"); +} + +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 { + socket = new WebSocket(state.online.url || ONLINE_DEFAULT); + } catch (err) { + notify("Failed to start socket. Check relay URL."); + state.online.status = "offline"; + return; + } + 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 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(); + render(state); + + document.getElementById("start-run").addEventListener("click", () => { + 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); + render(state); + }); + + document.getElementById("play-turn").addEventListener("click", () => { + resolveBeat(state); + saveState(state); + render(state); + }); + + document.getElementById("cash-out").addEventListener("click", () => { + cashOut(state); + saveState(state); + render(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 = ""; + }); +} + +document.addEventListener("DOMContentLoaded", bootstrap); diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..0dc30a4 --- /dev/null +++ b/dist/index.html @@ -0,0 +1,1019 @@ + + + + + + + HALO Pocket Labyrinth Online + + + +
+

HALO Pocket Labyrinth • Online Roguelike

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

Profile & Seed

+

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

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

+
+ +
+

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
+
+
+ + +
+
+

Hand

+
+

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

+
+
+
+
+ +
+

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 + Offline-friendly + Relay-ready +
+
+
+ + + + + + diff --git a/halo.js b/halo.js index fd750bd..c592d70 100644 --- a/halo.js +++ b/halo.js @@ -1,12 +1,11 @@ -// 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: Online roguelike card crawler with gacha and relay-ready co-op + +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" }, { name: "Domain & Magic", tagline: "Shape your reality" }, @@ -30,257 +29,783 @@ const TIMELINES = [ { name: "7–20 years", tagline: "Long term" } ]; -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" } +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." + } ]; -// 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"; +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"); +} -// Utility: Roll a die with a given number of sides. -function roll(sides) { - return Math.floor(Math.random() * sides); +function hashSeed(str) { + let hash = 0; + for (let i = 0; i < str.length; i++) { + hash = (hash << 5) - hash + str.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash) + 1; } -// 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 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 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)]; + 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 { - axis, - vector, - timeline, - archetype, - question: question || "", - timestamp: new Date().toISOString() + player: overrides.player || "", + quest: overrides.quest || "", + mode: overrides.mode || "solo", + difficulty: overrides.difficulty || "standard", + seed, + seedHash, + 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: [] }; } -// Load the saved profile from localStorage and populate the form fields. -function loadProfile() { +function clamp(num, min, max) { + return Math.max(min, Math.min(max, num)); +} + +function saveState(state) { 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 || ""; + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + memoryFallback = state; } catch (err) { - console.warn("Failed to load profile", 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; + } } } -// 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)); -} - -// 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 addLog(state, entry) { + state.log.push({ ...entry, timestamp: Date.now() }); + state.log = state.log.slice(-80); } -// 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 = ""; - return; +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; +} + +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; } - // 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}

` : ""} -
- `; -} - -// 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.

"; - return; + + state.gachaLog.push({ + pack: pack.name, + results, + timestamp: Date.now() + }); + + return { results }; +} + +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 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()); } - // Build a simple HTML table. - let html = ""; - history.forEach((r) => { - const ts = new Date(r.timestamp).toLocaleString(); - html += ``; +} + +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 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 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 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() }); - html += "
WhenAxisVectorTimelineArchetype
${ts}${r.axis.name}${r.vector.name}${r.timeline.name}${r.archetype.name}
"; - container.innerHTML = html; -} - -// 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"); - }); + state.run = null; +} + +function doomTick(run) { + let inc = 0; + if (run.momentum < 0) inc += 1; + if (run.aegis <= 0) inc += 1; + return inc; +} + +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; } - // 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 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); +} + +function resolveBeat(state) { + if (!state.run || state.run.status !== "running") return; + const run = state.run; + run.depth += 1; + const encounter = run.current; + + // Apply base tick + run.doom = clamp(run.doom + doomTick(run), 0, 6); + + 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 ${run.depth} • Momentum ${run.momentum}`, + body: lines.join(" • "), + meta: new Date().toLocaleTimeString() + }); + + if (run.doom >= 6 || run.aegis <= 0) { + run.status = "crashed"; + addLog(state, { + type: "crash", + title: "Run collapsed", + body: `Doom ${run.doom}, Aegis ${run.aegis}. Bank or reset.`, + meta: "Labyrinth spits you out" }); + 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) { + endRun(state, "banked"); } -// === Supporter and Co‑op Features === +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" }); +} -// 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) { +// Online sync +function connectOnline(state) { + if (!state.online.enabled) { + disconnectOnline(state); return; } - // Display supporter badge if previously activated. - const isSupporter = localStorage.getItem("halo_supporter") === "true"; - if (isSupporter) { - supportBadge.style.display = "inline-flex"; + if (socket && socket.readyState === WebSocket.OPEN) return; + try { + socket = new WebSocket(state.online.url || ONLINE_DEFAULT); + } catch (err) { + notify("Failed to start socket. Check relay URL."); + state.online.status = "offline"; + return; } - 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." + socket.addEventListener("open", () => { + state.online.status = "connected"; + socket.send( + JSON.stringify({ type: "join", room: state.online.room || state.seed, player: state.player || "anon" }) ); - if (opted) { - localStorage.setItem("halo_supporter", "true"); - supportBadge.style.display = "inline-flex"; + 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); }); } -// 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) { +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; } - // 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"; + 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); }); - // 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."); - }); +} + +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(); + render(state); + + document.getElementById("start-run").addEventListener("click", () => { + 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); + render(state); + }); + + document.getElementById("play-turn").addEventListener("click", () => { + resolveBeat(state); + saveState(state); + render(state); + }); + + document.getElementById("cash-out").addEventListener("click", () => { + cashOut(state); + saveState(state); + render(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 = ""; }); - // 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..ff1b208 100644 --- a/index.html +++ b/index.html @@ -3,263 +3,202 @@ - HALO Meta-Oracle – Local Prototype + + HALO Pocket Labyrinth Online -
-

HALO Meta-Oracle – Local Prototype

- -
-

1. Profile

-

Optional but helpful—used for tags and flavor text.

-
-
- - -
-
- - -
-
- - +
+

HALO Pocket Labyrinth • Online Roguelike

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

Profile & Seed

+

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

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

+
+ +
+

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
+
+
+ + +
+
+

Hand

+
+

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

+
- - 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.

- - - -
+
+ +
+

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 + Offline-friendly + Relay-ready +
+
+
- -
-

5. Support the Oracle

-

Optional – helps keep the project going.

- - -
-
+
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 new file mode 100644 index 0000000..afde348 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "halo-pocket-labyrinth", + "version": "0.1.0", + "description": "Mobile-first HALO Pocket Labyrinth build outputs with optional relay server", + "scripts": { + "build": "node scripts/build.js", + "server": "node scripts/server.js" + }, + "dependencies": { + "ws": "^8.18.0" + } +} 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(); 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}`); +}); diff --git a/scripts/sync_android_assets.sh b/scripts/sync_android_assets.sh new file mode 100755 index 0000000..13a1d9e --- /dev/null +++ b/scripts/sync_android_assets.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SRC="$ROOT_DIR/dist/index.html" +DEST="$ROOT_DIR/android-app/app/src/main/assets/index.html" + +if [[ ! -f "$SRC" ]]; then + echo "Build output not found at $SRC. Run npm run build first." >&2 + exit 1 +fi + +mkdir -p "$(dirname "$DEST")" +cp "$SRC" "$DEST" +echo "Synced $SRC -> $DEST" From b154e8957232fe6c1f4cf84b5b6623e09fb990ea Mon Sep 17 00:00:00 2001 From: Apocky Date: Wed, 10 Dec 2025 15:19:41 -0700 Subject: [PATCH 2/2] Handle placeholder AWS region in lambda --- README.md | 8 ++++++++ halo_lambda/index.js | 14 +++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f8ad169..4f89f5c 100644 --- a/README.md +++ b/README.md @@ -36,3 +36,11 @@ An optional, ultra-light relay server lets multiple pilots sync depth/seed metad * `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. + +## Serverless API (DynamoDB) deployment note + +The Lambda handler in `halo_lambda/index.js` now auto-corrects placeholder +regions (e.g., `MY_AWS_REGION`) to `us-east-1` so builds do not fail when a +default value is left unchanged. For production, set `AWS_REGION` or +`AWS_DEFAULT_REGION` to your real AWS region (for example, `us-west-2`) before +deploying the function. diff --git a/halo_lambda/index.js b/halo_lambda/index.js index 9ed17f1..a02ef11 100644 --- a/halo_lambda/index.js +++ b/halo_lambda/index.js @@ -16,9 +16,17 @@ const { PutCommand, } = require("@aws-sdk/lib-dynamodb"); -// Create low‑level and Document clients. The region and credentials -// are resolved automatically from the Lambda execution environment. -const ddbClient = new DynamoDBClient({}); +// Create low‑level and Document clients. The region defaults to a +// real AWS region even if someone left a placeholder like +// "MY_AWS_REGION" in their environment variables. +const resolvedRegion = (() => { + const candidate = + process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || ""; + if (candidate && candidate !== "MY_AWS_REGION") return candidate; + return "us-east-1"; // safe fallback so deployments don't fail on placeholders +})(); + +const ddbClient = new DynamoDBClient({ region: resolvedRegion }); const ddbDocClient = DynamoDBDocumentClient.from(ddbClient); exports.handler = async (event) => {