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..81f2cad 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,34 @@ -# Collab_Reposit -The place to discuss plans and share ideas for the game. +# HALO Meta Labyrinth — Archetype oracle roguelike + +Mobile-first HALO rebuilt as a meta-divination card roguelike with a Wu Xing elemental matchup system, gacha economy, and optional WebSocket relay for room sync/chat. Everything is bundled for offline play or side-loading into the Android WebView shell. + +## Play it + +- **Phone sideload:** run `npm run build` and send `dist/index.html` to your phone. The bundle inlines `halo.js` so it runs offline. +- **Home screen:** open `index.html` in mobile Safari/Chrome and “Add to Home Screen.” State stays on-device with an in-memory fallback if storage is blocked. +- **Android APK (WebView shell):** build with Android Studio after running `./scripts/sync_android_assets.sh` to copy the latest `dist/index.html` into `android-app/app/src/main/assets/`. + +## Core loop + +1. Set pilot, quest, seed, difficulty, and mode from the main menu. Seeds are deterministic so squads can mirror runs. +2. Build a 12-card deck from Wu Xing archetype cards. Elemental advantage follows the controlling cycle (Wood>Earth>Water>Fire>Metal>Wood). +3. Enter a run: draw an oracle-driven encounter, play archetype cards from your hand, then resolve the beat. Momentum/Aegis/Doom govern survival; Depth tracks progress. +4. Cash out to bank Credits/Embers/Shards or crash if Doom/Aegis fail. Credits buy Pulse packs; Embers/Shards feed Radiant/Axis pulls and VIP rarity boosts. + +## Multiplayer relay + +- 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 (defaults to the seed), and play. Depth pings and chat flow automatically once connected. + +## Build & test locally + +1. Install dependencies: `npm install`. +2. Build the bundled client: `npm run build` (outputs `dist/index.html` and `dist/halo.js`). +3. Open `dist/index.html` in a browser (desktop or mobile) to sanity-check the UI and run loop. Use the seed field to validate deterministic behavior across devices. +4. (Optional multiplayer) Start the relay with `npm run server`, set the relay URL/room in the client, and confirm depth/chat sync with a second browser tab. + +The project does not ship automated tests; the build completes in a few seconds and acts as the smoke test. Relay and client run entirely offline aside from optional WebSocket connectivity. + +## Serverless API (DynamoDB) deployment note + +The Lambda handler in `halo_lambda/index.js` auto-corrects placeholder or malformed regions (e.g., `MY_AWS_REGION`, `LOCAL`, blank) 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 a valid AWS region pattern (for example, `us-west-2`, `us-gov-west-1`, or `cn-north-1`) before deploying the function. 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..f8200e0 --- /dev/null +++ b/dist/halo.js @@ -0,0 +1,804 @@ +// HALO Meta-Divination Engine — rebuilt from scratch for the meta-game roguelike +// Main menu + multiplayer relay + gacha + Wu Xing archetype combat + oracle-driven encounters + +const STORAGE_KEY = "halo_meta_state_v1"; +const LEGACY_KEYS = ["halo_mobile_state_v2", "halo_state", "halo_meta_arc_v0"]; +const ONLINE_DEFAULT = "ws://localhost:8787"; +let socket = null; +let warnedStorage = false; +let memoryFallback = null; + +// Wu Xing elemental wheel (using the controlling cycle for advantage) +const ELEMENTS = { + wood: { strong: "earth", weak: "metal", color: "#4CAF50" }, + fire: { strong: "metal", weak: "water", color: "#FF7043" }, + earth: { strong: "water", weak: "wood", color: "#C0A46B" }, + metal: { strong: "wood", weak: "fire", color: "#B0BEC5" }, + water: { strong: "fire", weak: "earth", color: "#4FC3F7" } +}; + +const DIVINATION_DICE = [ + { name: "Tarot Major", sides: 22 }, + { name: "I Ching", sides: 64 }, + { name: "Runes", sides: 24 }, + { name: "Astral Houses", sides: 12 }, + { name: "Void Die", sides: 33 } +]; + +const DIVINATION_THEMES = [ + "Initiation", "Challenge", "Reversal", "Breakthrough", "Union", "Fragment", "Signal", "Riddle", + "Gift", "Debt", "Memory", "Future Echo", "Threshold", "Labyrinth", "Guardian", "Bloom" +]; + +const ARCHETYPES = [ + { + id: "sovereign", + name: "Sovereign Ember", + rarity: "common", + element: "fire", + text: "+2 Momentum. If advantaged, burn 1 Doom.", + effect: { momentum: 2, doom: -1 } + }, + { + id: "warden", + name: "Warden of Roots", + rarity: "common", + element: "wood", + text: "Restore 1 Aegis. If advantaged, draw 1 archetype.", + effect: { aegis: 1, draw: 1 } + }, + { + id: "seeker", + name: "Seeker of Currents", + rarity: "common", + element: "water", + text: "+1 Momentum, reveal omen. If advantaged, convert omen to boon.", + effect: { momentum: 1, boon: true } + }, + { + id: "architect", + name: "Architect of Stone", + rarity: "rare", + element: "earth", + text: "Stabilize: set Doom to 0 if advantaged; otherwise -1 Doom.", + effect: { doom: -1, stabilize: true } + }, + { + id: "mirror", + name: "Mirror of Blades", + rarity: "rare", + element: "metal", + text: "Reflect threat. If advantaged, gain 1 Momentum and 1 Aegis.", + effect: { reflect: true, momentum: 1, aegis: 1 } + }, + { + id: "oracle", + name: "Prismatic Oracle", + rarity: "rare", + element: "water", + text: "Roll two oracle dice, pick best. Gain +1 Momentum per hit.", + effect: { doubleRoll: true } + }, + { + id: "phoenix", + name: "Phoenix Crown", + rarity: "mythic", + element: "fire", + text: "Heal to 3 Aegis, +2 Momentum. Advantage adds bonus credit.", + effect: { aegis: 3, momentum: 2, bonus: true } + }, + { + id: "river", + name: "River Between Worlds", + rarity: "mythic", + element: "water", + text: "Summon ally: set Momentum to 3, draw 2, reveal omen.", + effect: { momentumSet: 3, draw: 2, boon: true } + }, + { + id: "forge", + name: "Starforge Anvil", + rarity: "mythic", + element: "metal", + text: "Cash any Momentum into Credits x10, then set Momentum to 1.", + effect: { cashMomentum: true } + }, + { + id: "labyrinth", + name: "Labyrinth Keeper", + rarity: "rare", + element: "earth", + text: "Mark the path: reduce Depth cost by 1 this beat; draw 1.", + effect: { depthShield: true, draw: 1 } + } +]; + +const RARITY_WEIGHTS = { common: 68, rare: 25, mythic: 7 }; +const PACKS = { + pulse: { name: "Pulse Pack", size: 3, cost: { credits: 250 } }, + radiant: { name: "Radiant Cache", size: 5, cost: { embers: 80 }, bonusRare: true }, + legend: { name: "Axis Vault", size: 1, cost: { shards: 1 }, guaranteed: "mythic" } +}; + +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 = {}; + ARCHETYPES.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 { + profile: { + pilot: overrides.pilot || "", + title: "Axis Runner", + quest: overrides.quest || "", + seed, + difficulty: overrides.difficulty || "standard", + mode: overrides.mode || "solo" + }, + seedHash, + cursor: 1, + currencies: { credits: 1200, embers: 220, shards: 1 }, + vip: false, + pity: 0, + collection: starterCollection(), + deck: ["sovereign", "warden", "seeker", "architect", "mirror", "labyrinth"], + relics: [], + run: null, + online: { + enabled: false, + url: overrides.url || ONLINE_DEFAULT, + room: "", + status: "offline", + peers: [], + log: [] + }, + codex: [], + gachaLog: [], + log: [] + }; +} + +function migrateLegacy() { + try { + for (const key of LEGACY_KEYS) { + const raw = localStorage.getItem(key); + if (raw) { + const parsed = JSON.parse(raw); + memoryFallback = parsed; + return parsed; + } + } + } catch (err) { + console.warn("Legacy migration failed", err); + } + return null; +} + +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) { + console.warn("Failed to load state", err); + if (memoryFallback) return memoryFallback; + } + const legacy = migrateLegacy(); + if (legacy) return { ...baseState(), ...legacy }; + return memoryFallback || baseState(); +} + +function addLog(state, entry) { + state.log.push({ ...entry, timestamp: Date.now() }); + state.log = state.log.slice(-120); +} + +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 = ARCHETYPES.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.shards && state.currencies.shards < cost.shards) + return { results: [], reason: "Not enough shards" }; + + if (cost.credits) state.currencies.credits -= cost.credits; + if (cost.embers) state.currencies.embers -= cost.embers; + if (cost.shards) state.currencies.shards -= cost.shards; + + const results = []; + for (let i = 0; i < pack.size; i++) { + let rarity = pack.guaranteed || rarityRoll(state); + if (state.pity >= 8) rarity = "rare"; + if (pack.bonusRare && i === pack.size - 1 && rarity === "common") rarity = "rare"; + 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("sovereign", "warden", "seeker"); + state.deck = cleaned.slice(0, 12); +} + +function elementAdvantage(cardElement, encounterElement) { + if (!ELEMENTS[cardElement] || !ELEMENTS[encounterElement]) return 0; + if (ELEMENTS[cardElement].strong === encounterElement) return 1; + if (ELEMENTS[cardElement].weak === encounterElement) return -1; + return 0; +} + +function rollOracleDie(state) { + const die = pick(DIVINATION_DICE, state); + const roll = Math.floor(random(state) * die.sides) + 1; + return { die: die.name, sides: die.sides, roll }; +} + +function nextEncounter(state) { + const omen = pick(DIVINATION_THEMES, state); + const elementKeys = Object.keys(ELEMENTS); + return { + element: pick(elementKeys, state), + theme: omen, + dice: rollOracleDie(state), + boon: random(state) > 0.55, + threat: random(state) > 0.45, + lore: pick( + [ + "Echoes of past selves ask for alignment.", + "Two timelines braid together and demand a choice.", + "A silent guardian flips an unseen coin.", + "The Labyrinth grows new corridors in real time.", + "Archetype avatars convene at the Inner Court.", + "A future self hands you a mirrored key.", + "A rival faction offers a pact of convenience." + ], + state + ) + }; +} + +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 baseRun(state) { + const run = { + status: "running", + depth: 0, + momentum: state.profile.difficulty === "chill" ? 3 : 2, + aegis: state.profile.difficulty === "brutal" ? 1 : 3, + doom: 0, + drawPile: shuffle(state.deck, state), + discard: [], + hand: [], + current: null, + lastOmen: null, + route: [] + }; + drawCards(run, state, 4); + run.current = nextEncounter(state); + return run; +} + +function startRun(state) { + ensureDeckLegal(state); + state.run = baseRun(state); + addLog(state, { type: "run", title: "Run initialized", body: `Seed ${state.profile.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 * 15; + if (reason === "victory") state.currencies.embers += 12; + 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 * 15 + } 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 resolveCard(state, run, card) { + const encounter = run.current; + const advantage = elementAdvantage(card.element, encounter.element); + let bonusCredits = 0; + + if (card.effect.momentum) run.momentum = clamp(run.momentum + card.effect.momentum + (advantage > 0 ? 1 : 0), -3, 9); + if (card.effect.aegis) + run.aegis = clamp(run.aegis + card.effect.aegis + (advantage > 0 ? 1 : 0), 0, 6); + if (card.effect.doom) run.doom = clamp(run.doom + card.effect.doom - (advantage > 0 ? 1 : 0), 0, 8); + if (card.effect.draw) drawCards(run, state, card.effect.draw); + if (card.effect.boon) run.lastOmen = encounter; + if (card.effect.reflect && encounter.threat) { + encounter.threat = false; + encounter.boon = true; + } + if (card.effect.stabilize && advantage > 0) run.doom = 0; + if (card.effect.doubleRoll) { + const first = rollOracleDie(state); + const second = rollOracleDie(state); + const winner = first.roll >= second.roll ? first : second; + run.lastOmen = { ...encounter, dice: winner }; + run.momentum = clamp(run.momentum + 1 + (winner.roll > winner.sides / 2 ? 1 : 0), -3, 9); + } + if (card.effect.momentumSet !== undefined) run.momentum = card.effect.momentumSet; + if (card.effect.bonus) bonusCredits += 50; + if (card.effect.cashMomentum) { + bonusCredits += run.momentum * 10; + run.momentum = 1; + } + if (card.effect.depthShield) run.route.push({ depth: run.depth, note: "Marked" }); + + if (advantage < 0) run.doom = clamp(run.doom + 1, 0, 8); + state.currencies.credits += bonusCredits; +} + +function resolveEncounter(state) { + const run = state.run; + if (!run) return; + + // Threat/Boon gates + if (run.current.threat && run.doom >= 6) { + run.aegis -= 1; + addLog(state, { type: "threat", title: "Threat overloaded", body: "Aegis cracked under pressure" }); + } + if (run.current.boon && run.momentum >= 4) { + state.currencies.credits += 20; + state.currencies.embers += 2; + } + + run.depth += 1; + run.discard.push(...run.hand); + run.hand = []; + drawCards(run, state, 4); + run.current = nextEncounter(state); + + run.doom = clamp(run.doom + doomTick(run), 0, 8); + if (run.aegis <= 0 || run.doom >= 8) { + run.status = "crashed"; + endRun(state, "crashed"); + } +} + +function playCard(cardId) { + const state = window.haloState; + if (!state.run) return; + const idx = state.run.hand.findIndex((c) => c === cardId); + if (idx === -1) return; + const card = ARCHETYPES.find((c) => c.id === cardId); + if (!card) return; + state.run.hand.splice(idx, 1); + resolveCard(state, state.run, card); + state.run.discard.push(cardId); + addLog(state, { type: "card", title: card.name, body: card.text, meta: `vs ${state.run.current.element}` }); + saveState(state); + render(); +} + +function resolveBeat() { + const state = window.haloState; + if (!state.run) return; + resolveEncounter(state); + saveState(state); + render(); +} + +function cashOut() { + const state = window.haloState; + if (!state.run) return; + endRun(state, "banked"); + saveState(state); + render(); +} + +function addCardToDeck(cardId) { + const state = window.haloState; + if (!state.collection[cardId]) return; + if (state.deck.length >= 12) return; + state.deck.push(cardId); + saveState(state); + render(); +} + +function removeCardFromDeck(cardId) { + const state = window.haloState; + const idx = state.deck.indexOf(cardId); + if (idx > -1) state.deck.splice(idx, 1); + saveState(state); + render(); +} + +function toggleVIP() { + const state = window.haloState; + state.vip = !state.vip; + saveState(state); + render(); +} + +function connectRelay() { + const state = window.haloState; + if (!state.online.enabled) return; + if (socket) socket.close(); + socket = new WebSocket(state.online.url); + socket.onopen = () => { + state.online.status = "online"; + state.online.log.push({ sender: "system", message: "Connected" }); + sendRelay({ type: "join", room: state.online.room || state.profile.seed, seed: state.profile.seed }); + render(); + }; + socket.onmessage = (ev) => { + try { + const msg = JSON.parse(ev.data); + if (msg.type === "chat") state.online.log.push({ sender: msg.from, message: msg.text }); + if (msg.type === "sync") { + state.online.peers = msg.peers || []; + if (state.run) state.run.depth = Math.max(state.run.depth, msg.depth || 0); + } + render(); + } catch (err) { + console.error("Relay parse", err); + } + }; + socket.onclose = () => { + state.online.status = "offline"; + render(); + }; +} + +function sendRelay(payload) { + const state = window.haloState; + if (!socket || socket.readyState !== 1) return; + const packet = { room: state.online.room || state.profile.seed, ...payload }; + socket.send(JSON.stringify(packet)); +} + +function sendChat(text) { + const state = window.haloState; + if (!state.online.enabled || !text) return; + sendRelay({ type: "chat", from: state.profile.pilot || "anon", text }); + state.online.log.push({ sender: state.profile.pilot || "me", message: text }); + render(); +} + +function syncDepth() { + const state = window.haloState; + if (!state.run) return; + sendRelay({ type: "sync", depth: state.run.depth, seed: state.profile.seed }); +} + +function renderDeck() { + const deckEl = document.getElementById("deck-list"); + const poolEl = document.getElementById("collection-list"); + const state = window.haloState; + if (!deckEl || !poolEl) return; + deckEl.innerHTML = ""; + poolEl.innerHTML = ""; + + state.deck.forEach((id) => { + const card = ARCHETYPES.find((c) => c.id === id); + if (!card) return; + const li = document.createElement("li"); + li.textContent = `${card.name} (${card.element})`; + li.onclick = () => removeCardFromDeck(id); + deckEl.appendChild(li); + }); + + ARCHETYPES.forEach((card) => { + const owned = state.collection[card.id] || 0; + const li = document.createElement("li"); + li.textContent = `${card.name} [${card.rarity}] (${owned} owned)`; + li.style.color = ELEMENTS[card.element].color; + li.onclick = () => addCardToDeck(card.id); + poolEl.appendChild(li); + }); +} + +function renderRun() { + const state = window.haloState; + const runPanel = document.getElementById("run-panel"); + if (!runPanel) return; + if (!state.run) { + runPanel.innerHTML = "

No active run. Start from the main menu.

"; + return; + } + + const encounter = state.run.current; + const handButtons = state.run.hand + .map((id) => { + const card = ARCHETYPES.find((c) => c.id === id); + return ``; + }) + .join(""); + + runPanel.innerHTML = ` +
+
Depth: ${state.run.depth}
+
Momentum: ${state.run.momentum}
+
Aegis: ${state.run.aegis}
+
Doom: ${state.run.doom}
+
+
+ Encounter +
Element: ${encounter.element}
+
Theme: ${encounter.theme}
+
Die: ${encounter.dice.die} → ${encounter.dice.roll}/${encounter.dice.sides}
+
${encounter.lore}
+
+
${handButtons || "Empty hand"}
+
+ + + +
+ `; + + runPanel.querySelectorAll(".card-btn").forEach((btn) => { + btn.onclick = () => playCard(btn.dataset.card); + }); + const resBtn = document.getElementById("resolve-beat"); + if (resBtn) resBtn.onclick = resolveBeat; + const cashBtn = document.getElementById("cash-out"); + if (cashBtn) cashBtn.onclick = cashOut; + const syncBtn = document.getElementById("sync-depth"); + if (syncBtn) syncBtn.onclick = syncDepth; +} + +function renderMeta() { + const state = window.haloState; + const currencyEl = document.getElementById("currency"); + const logEl = document.getElementById("log"); + if (currencyEl) + currencyEl.textContent = `Credits ${state.currencies.credits} | Embers ${state.currencies.embers} | Shards ${state.currencies.shards}`; + if (logEl) { + logEl.innerHTML = state.log + .map((l) => `
  • ${l.title} — ${l.body || ""} ${new Date(l.timestamp).toLocaleTimeString()}
  • `) + .join(""); + } +} + +function renderRelay() { + const state = window.haloState; + const relayEl = document.getElementById("relay-log"); + const peersEl = document.getElementById("peers"); + if (relayEl) + relayEl.innerHTML = state.online.log + .slice(-20) + .map((m) => `
  • ${m.sender}: ${m.message}
  • `) + .join(""); + if (peersEl) peersEl.textContent = state.online.peers.join(", "); +} + +function renderGachaResult(results) { + const box = document.getElementById("gacha-results"); + if (!box) return; + if (!results.length) { + box.innerHTML = "

    No pull yet.

    "; + return; + } + box.innerHTML = results + .map((c) => `
    ${c.name}
    ${c.rarity}
    `) + .join(""); +} + +function render() { + renderDeck(); + renderRun(); + renderMeta(); + renderRelay(); +} + +function bindUI() { + const startBtn = document.getElementById("start-run"); + const pilotInput = document.getElementById("pilot-name"); + const questInput = document.getElementById("quest"); + const seedInput = document.getElementById("seed"); + const diffInput = document.getElementById("difficulty"); + const modeInput = document.getElementById("mode"); + const vipBtn = document.getElementById("toggle-vip"); + const packBtns = document.querySelectorAll("[data-pack]"); + const chatForm = document.getElementById("chat-form"); + const chatInput = document.getElementById("chat-text"); + const relayToggle = document.getElementById("relay-enabled"); + const relayUrl = document.getElementById("relay-url"); + const relayRoom = document.getElementById("relay-room"); + + if (startBtn) { + startBtn.onclick = () => { + const state = window.haloState; + state.profile.pilot = pilotInput.value; + state.profile.quest = questInput.value; + state.profile.seed = seedInput.value || generateSeed(); + state.profile.difficulty = diffInput.value; + state.profile.mode = modeInput.value; + state.seedHash = hashSeed(state.profile.seed); + state.cursor = 1; + startRun(state); + saveState(state); + render(); + }; + } + + const resumeBtn = document.getElementById("resume-run"); + if (resumeBtn) resumeBtn.onclick = () => { + const state = window.haloState; + if (!state.run) startRun(state); + render(); + }; + + if (vipBtn) vipBtn.onclick = toggleVIP; + + packBtns.forEach((btn) => { + btn.onclick = () => { + const state = window.haloState; + const packKey = btn.dataset.pack; + const { results, reason } = pullPack(state, packKey); + if (!results.length && reason) notify(reason); + else renderGachaResult(results); + saveState(state); + render(); + }; + }); + + if (chatForm) { + chatForm.onsubmit = (e) => { + e.preventDefault(); + sendChat(chatInput.value); + chatInput.value = ""; + }; + } + + if (relayToggle) + relayToggle.onchange = (e) => { + const state = window.haloState; + state.online.enabled = e.target.checked; + if (state.online.enabled) connectRelay(); + saveState(state); + render(); + }; + if (relayUrl) + relayUrl.onchange = (e) => { + const state = window.haloState; + state.online.url = e.target.value; + saveState(state); + }; + if (relayRoom) + relayRoom.onchange = (e) => { + const state = window.haloState; + state.online.room = e.target.value; + saveState(state); + }; +} + +function init() { + window.haloState = loadState(); + const state = window.haloState; + document.getElementById("pilot-name").value = state.profile.pilot; + document.getElementById("quest").value = state.profile.quest; + document.getElementById("seed").value = state.profile.seed; + document.getElementById("difficulty").value = state.profile.difficulty; + document.getElementById("mode").value = state.profile.mode; + document.getElementById("relay-enabled").checked = state.online.enabled; + document.getElementById("relay-url").value = state.online.url; + document.getElementById("relay-room").value = state.online.room; + bindUI(); + render(); + if (state.online.enabled) connectRelay(); +} + +window.addEventListener("DOMContentLoaded", init); diff --git a/dist/index.html b/dist/index.html new file mode 100644 index 0000000..f69a5f2 --- /dev/null +++ b/dist/index.html @@ -0,0 +1,967 @@ + + + + + + + HALO Meta Labyrinth + + + +
    +

    HALO Meta Labyrinth

    +

    Meta-divination roguelike • Wu Xing archetypes • Dice oracle • Relay-ready multiplayer

    +
    +
    +
    +
    +

    Main menu

    +

    Define your pilot, quest, and seed. Seeds are deterministic so squads can share a run.

    +
    +
    +
    +
    +
    +
    +
    +
    + + + +
    +
    + Meta-divination oracle + Wu Xing matchup + Deck up to 12 archetypes +
    +
    + +
    +

    Run status

    +
    +
    +
    + +
    +

    Deck & Codex

    +

    Tap to add/remove archetypes. Elemental advantages follow the controlling cycle.

    +
    +
    +

    Deck

    +
      +
      +
      +

      Collection

      +
        +
        +
        +
        + +
        +

        Gacha

        +

        Pulse packs spend Credits; Radiant/Axis spend premium currency. VIP nudges rarity odds.

        +
        + + + +
        +
        +
        + +
        +

        Online relay

        +

        Optional WebSocket relay for room sync and squad chat. Room defaults to your seed.

        +
        +
        +
        +
        +
        +
        Peers:
        +
        + + +
        + +
        + +
        +

        Recent log

        + +
        +
        + + + diff --git a/halo.js b/halo.js index fd750bd..f8200e0 100644 --- a/halo.js +++ b/halo.js @@ -1,286 +1,804 @@ -// 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. -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" } -]; +// HALO Meta-Divination Engine — rebuilt from scratch for the meta-game roguelike +// Main menu + multiplayer relay + gacha + Wu Xing archetype combat + oracle-driven encounters + +const STORAGE_KEY = "halo_meta_state_v1"; +const LEGACY_KEYS = ["halo_mobile_state_v2", "halo_state", "halo_meta_arc_v0"]; +const ONLINE_DEFAULT = "ws://localhost:8787"; +let socket = null; +let warnedStorage = false; +let memoryFallback = null; + +// Wu Xing elemental wheel (using the controlling cycle for advantage) +const ELEMENTS = { + wood: { strong: "earth", weak: "metal", color: "#4CAF50" }, + fire: { strong: "metal", weak: "water", color: "#FF7043" }, + earth: { strong: "water", weak: "wood", color: "#C0A46B" }, + metal: { strong: "wood", weak: "fire", color: "#B0BEC5" }, + water: { strong: "fire", weak: "earth", color: "#4FC3F7" } +}; -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 DIVINATION_DICE = [ + { name: "Tarot Major", sides: 22 }, + { name: "I Ching", sides: 64 }, + { name: "Runes", sides: 24 }, + { name: "Astral Houses", sides: 12 }, + { name: "Void Die", sides: 33 } ]; -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 DIVINATION_THEMES = [ + "Initiation", "Challenge", "Reversal", "Breakthrough", "Union", "Fragment", "Signal", "Riddle", + "Gift", "Debt", "Memory", "Future Echo", "Threshold", "Labyrinth", "Guardian", "Bloom" ]; 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" } + { + id: "sovereign", + name: "Sovereign Ember", + rarity: "common", + element: "fire", + text: "+2 Momentum. If advantaged, burn 1 Doom.", + effect: { momentum: 2, doom: -1 } + }, + { + id: "warden", + name: "Warden of Roots", + rarity: "common", + element: "wood", + text: "Restore 1 Aegis. If advantaged, draw 1 archetype.", + effect: { aegis: 1, draw: 1 } + }, + { + id: "seeker", + name: "Seeker of Currents", + rarity: "common", + element: "water", + text: "+1 Momentum, reveal omen. If advantaged, convert omen to boon.", + effect: { momentum: 1, boon: true } + }, + { + id: "architect", + name: "Architect of Stone", + rarity: "rare", + element: "earth", + text: "Stabilize: set Doom to 0 if advantaged; otherwise -1 Doom.", + effect: { doom: -1, stabilize: true } + }, + { + id: "mirror", + name: "Mirror of Blades", + rarity: "rare", + element: "metal", + text: "Reflect threat. If advantaged, gain 1 Momentum and 1 Aegis.", + effect: { reflect: true, momentum: 1, aegis: 1 } + }, + { + id: "oracle", + name: "Prismatic Oracle", + rarity: "rare", + element: "water", + text: "Roll two oracle dice, pick best. Gain +1 Momentum per hit.", + effect: { doubleRoll: true } + }, + { + id: "phoenix", + name: "Phoenix Crown", + rarity: "mythic", + element: "fire", + text: "Heal to 3 Aegis, +2 Momentum. Advantage adds bonus credit.", + effect: { aegis: 3, momentum: 2, bonus: true } + }, + { + id: "river", + name: "River Between Worlds", + rarity: "mythic", + element: "water", + text: "Summon ally: set Momentum to 3, draw 2, reveal omen.", + effect: { momentumSet: 3, draw: 2, boon: true } + }, + { + id: "forge", + name: "Starforge Anvil", + rarity: "mythic", + element: "metal", + text: "Cash any Momentum into Credits x10, then set Momentum to 1.", + effect: { cashMomentum: true } + }, + { + id: "labyrinth", + name: "Labyrinth Keeper", + rarity: "rare", + element: "earth", + text: "Mark the path: reduce Depth cost by 1 this beat; draw 1.", + effect: { depthShield: true, draw: 1 } + } ]; -// Storage keys for localStorage. These can be namespaced to avoid -// colliding with other apps on the same domain. -const PROFILE_KEY = "halo_profile"; -const HISTORY_KEY = "halo_history"; +const RARITY_WEIGHTS = { common: 68, rare: 25, mythic: 7 }; +const PACKS = { + pulse: { name: "Pulse Pack", size: 3, cost: { credits: 250 } }, + radiant: { name: "Radiant Cache", size: 5, cost: { embers: 80 }, bonusRare: true }, + legend: { name: "Axis Vault", size: 1, cost: { shards: 1 }, guaranteed: "mythic" } +}; + +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; +} -// Utility: Roll a die with a given number of sides. -function roll(sides) { - return Math.floor(Math.random() * sides); +function generateSeed() { + const chars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; + let out = "HALO-"; + for (let i = 0; i < 6; i++) out += chars[Math.floor(Math.random() * chars.length)]; + return out; } -// Create a new reading based off the defined lists. A reading bundles -// four random selections along with the user's question and a timestamp. -function createReading(question) { - const axis = AXES[roll(AXES.length)]; - const vector = VECTORS[roll(VECTORS.length)]; - const timeline = TIMELINES[roll(TIMELINES.length)]; - const archetype = ARCHETYPES[roll(ARCHETYPES.length)]; +function starterCollection() { + const inventory = {}; + ARCHETYPES.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() + profile: { + pilot: overrides.pilot || "", + title: "Axis Runner", + quest: overrides.quest || "", + seed, + difficulty: overrides.difficulty || "standard", + mode: overrides.mode || "solo" + }, + seedHash, + cursor: 1, + currencies: { credits: 1200, embers: 220, shards: 1 }, + vip: false, + pity: 0, + collection: starterCollection(), + deck: ["sovereign", "warden", "seeker", "architect", "mirror", "labyrinth"], + relics: [], + run: null, + online: { + enabled: false, + url: overrides.url || ONLINE_DEFAULT, + room: "", + status: "offline", + peers: [], + log: [] + }, + codex: [], + gachaLog: [], + log: [] }; } -// Load the saved profile from localStorage and populate the form fields. -function loadProfile() { +function migrateLegacy() { 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 || ""; + for (const key of LEGACY_KEYS) { + const raw = localStorage.getItem(key); + if (raw) { + const parsed = JSON.parse(raw); + memoryFallback = parsed; + return parsed; + } + } } catch (err) { - console.warn("Failed to load profile", err); + console.warn("Legacy migration failed", err); } + return null; } -// Save the profile to localStorage. This persists only on the client. -function saveProfile() { - const name = document.getElementById("profile-name").value.trim(); - const sun = document.getElementById("profile-sun").value.trim(); - const moon = document.getElementById("profile-moon").value.trim(); - const rising = document.getElementById("profile-rising").value.trim(); - const profile = { name, sun, moon, rising }; - localStorage.setItem(PROFILE_KEY, JSON.stringify(profile)); +function clamp(num, min, max) { + return Math.max(min, Math.min(max, num)); } -// Load reading history from storage. Returns an array (possibly empty). -function loadHistory() { +function saveState(state) { try { - const raw = localStorage.getItem(HISTORY_KEY); - return raw ? JSON.parse(raw) : []; + localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); + memoryFallback = state; } catch (err) { - console.warn("Failed to load history", err); - return []; + 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 reading history back to storage. -function saveHistory(history) { - localStorage.setItem(HISTORY_KEY, JSON.stringify(history)); +function loadState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw) return JSON.parse(raw); + } catch (err) { + console.warn("Failed to load state", err); + if (memoryFallback) return memoryFallback; + } + const legacy = migrateLegacy(); + if (legacy) return { ...baseState(), ...legacy }; + return memoryFallback || baseState(); } -// 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 addLog(state, entry) { + state.log.push({ ...entry, timestamp: Date.now() }); + state.log = state.log.slice(-120); +} + +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 = ARCHETYPES.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.shards && state.currencies.shards < cost.shards) + return { results: [], reason: "Not enough shards" }; + + if (cost.credits) state.currencies.credits -= cost.credits; + if (cost.embers) state.currencies.embers -= cost.embers; + if (cost.shards) state.currencies.shards -= cost.shards; + + const results = []; + for (let i = 0; i < pack.size; i++) { + let rarity = pack.guaranteed || rarityRoll(state); + if (state.pity >= 8) rarity = "rare"; + if (pack.bonusRare && i === pack.size - 1 && rarity === "common") rarity = "rare"; + 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}

        ` : ""} -
        - `; + + state.gachaLog.push({ pack: pack.name, results, timestamp: Date.now() }); + return { results }; } -// 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; +function ensureDeckLegal(state) { + const cleaned = state.deck.filter((id) => state.collection[id]); + if (!cleaned.length) cleaned.push("sovereign", "warden", "seeker"); + state.deck = cleaned.slice(0, 12); +} + +function elementAdvantage(cardElement, encounterElement) { + if (!ELEMENTS[cardElement] || !ELEMENTS[encounterElement]) return 0; + if (ELEMENTS[cardElement].strong === encounterElement) return 1; + if (ELEMENTS[cardElement].weak === encounterElement) return -1; + return 0; +} + +function rollOracleDie(state) { + const die = pick(DIVINATION_DICE, state); + const roll = Math.floor(random(state) * die.sides) + 1; + return { die: die.name, sides: die.sides, roll }; +} + +function nextEncounter(state) { + const omen = pick(DIVINATION_THEMES, state); + const elementKeys = Object.keys(ELEMENTS); + return { + element: pick(elementKeys, state), + theme: omen, + dice: rollOracleDie(state), + boon: random(state) > 0.55, + threat: random(state) > 0.45, + lore: pick( + [ + "Echoes of past selves ask for alignment.", + "Two timelines braid together and demand a choice.", + "A silent guardian flips an unseen coin.", + "The Labyrinth grows new corridors in real time.", + "Archetype avatars convene at the Inner Court.", + "A future self hands you a mirrored key.", + "A rival faction offers a pact of convenience." + ], + state + ) + }; +} + +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 baseRun(state) { + const run = { + status: "running", + depth: 0, + momentum: state.profile.difficulty === "chill" ? 3 : 2, + aegis: state.profile.difficulty === "brutal" ? 1 : 3, + doom: 0, + drawPile: shuffle(state.deck, state), + discard: [], + hand: [], + current: null, + lastOmen: null, + route: [] + }; + drawCards(run, state, 4); + run.current = nextEncounter(state); + return run; +} + +function startRun(state) { + ensureDeckLegal(state); + state.run = baseRun(state); + addLog(state, { type: "run", title: "Run initialized", body: `Seed ${state.profile.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 * 15; + if (reason === "victory") state.currencies.embers += 12; + 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 * 15 + } credits`, + meta: new Date().toLocaleTimeString() }); - html += "
        WhenAxisVectorTimelineArchetype
        ${ts}${r.axis.name}${r.vector.name}${r.timeline.name}${r.archetype.name}
        "; - container.innerHTML = html; + state.run = null; } -// 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"); - }); +function doomTick(run) { + let inc = 0; + if (run.momentum < 0) inc += 1; + if (run.aegis <= 0) inc += 1; + return inc; +} + +function resolveCard(state, run, card) { + const encounter = run.current; + const advantage = elementAdvantage(card.element, encounter.element); + let bonusCredits = 0; + + if (card.effect.momentum) run.momentum = clamp(run.momentum + card.effect.momentum + (advantage > 0 ? 1 : 0), -3, 9); + if (card.effect.aegis) + run.aegis = clamp(run.aegis + card.effect.aegis + (advantage > 0 ? 1 : 0), 0, 6); + if (card.effect.doom) run.doom = clamp(run.doom + card.effect.doom - (advantage > 0 ? 1 : 0), 0, 8); + if (card.effect.draw) drawCards(run, state, card.effect.draw); + if (card.effect.boon) run.lastOmen = encounter; + if (card.effect.reflect && encounter.threat) { + encounter.threat = false; + encounter.boon = true; + } + if (card.effect.stabilize && advantage > 0) run.doom = 0; + if (card.effect.doubleRoll) { + const first = rollOracleDie(state); + const second = rollOracleDie(state); + const winner = first.roll >= second.roll ? first : second; + run.lastOmen = { ...encounter, dice: winner }; + run.momentum = clamp(run.momentum + 1 + (winner.roll > winner.sides / 2 ? 1 : 0), -3, 9); } - // 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); - }); + if (card.effect.momentumSet !== undefined) run.momentum = card.effect.momentumSet; + if (card.effect.bonus) bonusCredits += 50; + if (card.effect.cashMomentum) { + bonusCredits += run.momentum * 10; + run.momentum = 1; } + if (card.effect.depthShield) run.route.push({ depth: run.depth, note: "Marked" }); + + if (advantage < 0) run.doom = clamp(run.doom + 1, 0, 8); + state.currencies.credits += bonusCredits; } -// === Supporter and Co‑op Features === +function resolveEncounter(state) { + const run = state.run; + if (!run) return; -// Supporter functionality: allow users to tip via Ko‑Fi and unlock a badge. -function setupSupport() { - const supportButton = document.getElementById("support-button"); - const supportBadge = document.getElementById("support-badge"); - if (!supportButton || !supportBadge) { - return; + // Threat/Boon gates + if (run.current.threat && run.doom >= 6) { + run.aegis -= 1; + addLog(state, { type: "threat", title: "Threat overloaded", body: "Aegis cracked under pressure" }); } - // Display supporter badge if previously activated. - const isSupporter = localStorage.getItem("halo_supporter") === "true"; - if (isSupporter) { - supportBadge.style.display = "inline-flex"; + if (run.current.boon && run.momentum >= 4) { + state.currencies.credits += 20; + state.currencies.embers += 2; } - supportButton.addEventListener("click", () => { - // Open Ko‑Fi in a new tab. - window.open("https://ko-fi.com/oneinfinity", "_blank", "noopener"); - // Ask the user if they actually supported. If yes, set supporter flag and show badge. - const opted = confirm( - "Thank you for considering support! If you just tipped on Ko‑Fi, click OK to enable supporter mode." - ); - if (opted) { - localStorage.setItem("halo_supporter", "true"); - supportBadge.style.display = "inline-flex"; + + run.depth += 1; + run.discard.push(...run.hand); + run.hand = []; + drawCards(run, state, 4); + run.current = nextEncounter(state); + + run.doom = clamp(run.doom + doomTick(run), 0, 8); + if (run.aegis <= 0 || run.doom >= 8) { + run.status = "crashed"; + endRun(state, "crashed"); + } +} + +function playCard(cardId) { + const state = window.haloState; + if (!state.run) return; + const idx = state.run.hand.findIndex((c) => c === cardId); + if (idx === -1) return; + const card = ARCHETYPES.find((c) => c.id === cardId); + if (!card) return; + state.run.hand.splice(idx, 1); + resolveCard(state, state.run, card); + state.run.discard.push(cardId); + addLog(state, { type: "card", title: card.name, body: card.text, meta: `vs ${state.run.current.element}` }); + saveState(state); + render(); +} + +function resolveBeat() { + const state = window.haloState; + if (!state.run) return; + resolveEncounter(state); + saveState(state); + render(); +} + +function cashOut() { + const state = window.haloState; + if (!state.run) return; + endRun(state, "banked"); + saveState(state); + render(); +} + +function addCardToDeck(cardId) { + const state = window.haloState; + if (!state.collection[cardId]) return; + if (state.deck.length >= 12) return; + state.deck.push(cardId); + saveState(state); + render(); +} + +function removeCardFromDeck(cardId) { + const state = window.haloState; + const idx = state.deck.indexOf(cardId); + if (idx > -1) state.deck.splice(idx, 1); + saveState(state); + render(); +} + +function toggleVIP() { + const state = window.haloState; + state.vip = !state.vip; + saveState(state); + render(); +} + +function connectRelay() { + const state = window.haloState; + if (!state.online.enabled) return; + if (socket) socket.close(); + socket = new WebSocket(state.online.url); + socket.onopen = () => { + state.online.status = "online"; + state.online.log.push({ sender: "system", message: "Connected" }); + sendRelay({ type: "join", room: state.online.room || state.profile.seed, seed: state.profile.seed }); + render(); + }; + socket.onmessage = (ev) => { + try { + const msg = JSON.parse(ev.data); + if (msg.type === "chat") state.online.log.push({ sender: msg.from, message: msg.text }); + if (msg.type === "sync") { + state.online.peers = msg.peers || []; + if (state.run) state.run.depth = Math.max(state.run.depth, msg.depth || 0); + } + render(); + } catch (err) { + console.error("Relay parse", err); } + }; + socket.onclose = () => { + state.online.status = "offline"; + render(); + }; +} + +function sendRelay(payload) { + const state = window.haloState; + if (!socket || socket.readyState !== 1) return; + const packet = { room: state.online.room || state.profile.seed, ...payload }; + socket.send(JSON.stringify(packet)); +} + +function sendChat(text) { + const state = window.haloState; + if (!state.online.enabled || !text) return; + sendRelay({ type: "chat", from: state.profile.pilot || "anon", text }); + state.online.log.push({ sender: state.profile.pilot || "me", message: text }); + render(); +} + +function syncDepth() { + const state = window.haloState; + if (!state.run) return; + sendRelay({ type: "sync", depth: state.run.depth, seed: state.profile.seed }); +} + +function renderDeck() { + const deckEl = document.getElementById("deck-list"); + const poolEl = document.getElementById("collection-list"); + const state = window.haloState; + if (!deckEl || !poolEl) return; + deckEl.innerHTML = ""; + poolEl.innerHTML = ""; + + state.deck.forEach((id) => { + const card = ARCHETYPES.find((c) => c.id === id); + if (!card) return; + const li = document.createElement("li"); + li.textContent = `${card.name} (${card.element})`; + li.onclick = () => removeCardFromDeck(id); + deckEl.appendChild(li); + }); + + ARCHETYPES.forEach((card) => { + const owned = state.collection[card.id] || 0; + const li = document.createElement("li"); + li.textContent = `${card.name} [${card.rarity}] (${owned} owned)`; + li.style.color = ELEMENTS[card.element].color; + li.onclick = () => addCardToDeck(card.id); + poolEl.appendChild(li); }); } -// 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 renderRun() { + const state = window.haloState; + const runPanel = document.getElementById("run-panel"); + if (!runPanel) return; + if (!state.run) { + runPanel.innerHTML = "

        No active run. Start from the main menu.

        "; 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"; + + const encounter = state.run.current; + const handButtons = state.run.hand + .map((id) => { + const card = ARCHETYPES.find((c) => c.id === id); + return ``; + }) + .join(""); + + runPanel.innerHTML = ` +
        +
        Depth: ${state.run.depth}
        +
        Momentum: ${state.run.momentum}
        +
        Aegis: ${state.run.aegis}
        +
        Doom: ${state.run.doom}
        +
        +
        + Encounter +
        Element: ${encounter.element}
        +
        Theme: ${encounter.theme}
        +
        Die: ${encounter.dice.die} → ${encounter.dice.roll}/${encounter.dice.sides}
        +
        ${encounter.lore}
        +
        +
        ${handButtons || "Empty hand"}
        +
        + + + +
        + `; + + runPanel.querySelectorAll(".card-btn").forEach((btn) => { + btn.onclick = () => playCard(btn.dataset.card); }); - // 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."); - }); + const resBtn = document.getElementById("resolve-beat"); + if (resBtn) resBtn.onclick = resolveBeat; + const cashBtn = document.getElementById("cash-out"); + if (cashBtn) cashBtn.onclick = cashOut; + const syncBtn = document.getElementById("sync-depth"); + if (syncBtn) syncBtn.onclick = syncDepth; +} + +function renderMeta() { + const state = window.haloState; + const currencyEl = document.getElementById("currency"); + const logEl = document.getElementById("log"); + if (currencyEl) + currencyEl.textContent = `Credits ${state.currencies.credits} | Embers ${state.currencies.embers} | Shards ${state.currencies.shards}`; + if (logEl) { + logEl.innerHTML = state.log + .map((l) => `
      • ${l.title} — ${l.body || ""} ${new Date(l.timestamp).toLocaleTimeString()}
      • `) + .join(""); + } +} + +function renderRelay() { + const state = window.haloState; + const relayEl = document.getElementById("relay-log"); + const peersEl = document.getElementById("peers"); + if (relayEl) + relayEl.innerHTML = state.online.log + .slice(-20) + .map((m) => `
      • ${m.sender}: ${m.message}
      • `) + .join(""); + if (peersEl) peersEl.textContent = state.online.peers.join(", "); +} + +function renderGachaResult(results) { + const box = document.getElementById("gacha-results"); + if (!box) return; + if (!results.length) { + box.innerHTML = "

        No pull yet.

        "; + return; + } + box.innerHTML = results + .map((c) => `
        ${c.name}
        ${c.rarity}
        `) + .join(""); +} + +function render() { + renderDeck(); + renderRun(); + renderMeta(); + renderRelay(); +} + +function bindUI() { + const startBtn = document.getElementById("start-run"); + const pilotInput = document.getElementById("pilot-name"); + const questInput = document.getElementById("quest"); + const seedInput = document.getElementById("seed"); + const diffInput = document.getElementById("difficulty"); + const modeInput = document.getElementById("mode"); + const vipBtn = document.getElementById("toggle-vip"); + const packBtns = document.querySelectorAll("[data-pack]"); + const chatForm = document.getElementById("chat-form"); + const chatInput = document.getElementById("chat-text"); + const relayToggle = document.getElementById("relay-enabled"); + const relayUrl = document.getElementById("relay-url"); + const relayRoom = document.getElementById("relay-room"); + + if (startBtn) { + startBtn.onclick = () => { + const state = window.haloState; + state.profile.pilot = pilotInput.value; + state.profile.quest = questInput.value; + state.profile.seed = seedInput.value || generateSeed(); + state.profile.difficulty = diffInput.value; + state.profile.mode = modeInput.value; + state.seedHash = hashSeed(state.profile.seed); + state.cursor = 1; + startRun(state); + saveState(state); + render(); + }; + } + + const resumeBtn = document.getElementById("resume-run"); + if (resumeBtn) resumeBtn.onclick = () => { + const state = window.haloState; + if (!state.run) startRun(state); + render(); + }; + + if (vipBtn) vipBtn.onclick = toggleVIP; + + packBtns.forEach((btn) => { + btn.onclick = () => { + const state = window.haloState; + const packKey = btn.dataset.pack; + const { results, reason } = pullPack(state, packKey); + if (!results.length && reason) notify(reason); + else renderGachaResult(results); + saveState(state); + render(); + }; }); - // 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); - } + + if (chatForm) { + chatForm.onsubmit = (e) => { + e.preventDefault(); + sendChat(chatInput.value); + chatInput.value = ""; + }; } + + if (relayToggle) + relayToggle.onchange = (e) => { + const state = window.haloState; + state.online.enabled = e.target.checked; + if (state.online.enabled) connectRelay(); + saveState(state); + render(); + }; + if (relayUrl) + relayUrl.onchange = (e) => { + const state = window.haloState; + state.online.url = e.target.value; + saveState(state); + }; + if (relayRoom) + relayRoom.onchange = (e) => { + const state = window.haloState; + state.online.room = e.target.value; + saveState(state); + }; +} + +function init() { + window.haloState = loadState(); + const state = window.haloState; + document.getElementById("pilot-name").value = state.profile.pilot; + document.getElementById("quest").value = state.profile.quest; + document.getElementById("seed").value = state.profile.seed; + document.getElementById("difficulty").value = state.profile.difficulty; + document.getElementById("mode").value = state.profile.mode; + document.getElementById("relay-enabled").checked = state.online.enabled; + document.getElementById("relay-url").value = state.online.url; + document.getElementById("relay-room").value = state.online.room; + bindUI(); + render(); + if (state.online.enabled) connectRelay(); } -// 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 +window.addEventListener("DOMContentLoaded", init); diff --git a/halo_lambda/index.js b/halo_lambda/index.js index 9ed17f1..495580f 100644 --- a/halo_lambda/index.js +++ b/halo_lambda/index.js @@ -16,9 +16,24 @@ 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 || ""; + + // Treat obvious placeholders or malformed regions as invalid and fall back. + // A valid AWS region looks like "us-east-1", "eu-west-3", "us-gov-west-1", + // etc. Allow multiple dash-delimited fragments before the trailing number so + // GovCloud/ISO/CN regions are accepted instead of misflagged as malformed. + const isValidRegion = /^[a-z]{2}(?:-[a-z]+)+-\d+$/.test(candidate); + if (candidate && candidate !== "MY_AWS_REGION" && isValidRegion) 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) => { diff --git a/index.html b/index.html index 412b4c8..28d1d99 100644 --- a/index.html +++ b/index.html @@ -3,264 +3,158 @@ - HALO Meta-Oracle – Local Prototype + + HALO Meta Labyrinth -
        -

        HALO Meta-Oracle – Local Prototype

        - -
        -

        1. Profile

        -

        Optional but helpful—used for tags and flavor text.

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

        HALO Meta Labyrinth

        +

        Meta-divination roguelike • Wu Xing archetypes • Dice oracle • Relay-ready multiplayer

        +
        +
        +
        +
        +

        Main menu

        +

        Define your pilot, quest, and seed. Seeds are deterministic so squads can share a run.

        +
        +
        +
        +
        +
        +
        +
        +
        + + + +
        +
        + Meta-divination oracle + Wu Xing matchup + Deck up to 12 archetypes +
        +
        + +
        +

        Run status

        +
        +
        +
        + +
        +

        Deck & Codex

        +

        Tap to add/remove archetypes. Elemental advantages follow the controlling cycle.

        +
        +
        +

        Deck

        +
          -
          - - +
          +

          Collection

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

            2. Ask HALO

            - - - -
            -
            - -
            -

            3. Past Readings (this device)

            -

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

            -
            -
            - - -
            -

            4. Co‑op Session

            -

            Share your Labyrinth session with a friend.

            - - - -
            - - -
            -

            5. Support the Oracle

            -

            Optional – helps keep the project going.

            - - -
            -
            - + + +
            +

            Gacha

            +

            Pulse packs spend Credits; Radiant/Axis spend premium currency. VIP nudges rarity odds.

            +
            + + + +
            +
            +
            + +
            +

            Online relay

            +

            Optional WebSocket relay for room sync and squad chat. Room defaults to your seed.

            +
            +
            +
            +
            +
            +
            Peers:
            +
            + + +
            +
              +
              + +
              +

              Recent log

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