Commit ·
7d99fde
0
Parent(s):
Initial commit — Omniscient Reader Scenario Simulator
Browse files- .gitattributes +2 -0
- .gitignore +16 -0
- README.md +113 -0
- animations.css +377 -0
- app.py +1252 -0
- assets/destroyed_office.png +3 -0
- assets/dokja_portrait.png +3 -0
- assets/dokkaebi_character.png +3 -0
- assets/han_river_bridge.png +3 -0
- assets/intro_video.mp4 +3 -0
- assets/jung_heewon_portrait.png +3 -0
- assets/lee_hyunsung_portrait.png +3 -0
- assets/rank_s_bg.png +3 -0
- assets/reader_noticed.png +3 -0
- assets/seoul_street.png +3 -0
- assets/seoul_subway.png +3 -0
- assets/star_stream_bg.png +3 -0
- assets/title_banner.png +3 -0
- assets/yoo_sangah_portrait.png +3 -0
- game.js +866 -0
- game_engine.py +625 -0
- modal_client.py +416 -0
- prompts.py +261 -0
- requirements.txt +3 -0
- scenarios.py +324 -0
- style.css +996 -0
.gitattributes
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*.pyo
|
| 4 |
+
.env
|
| 5 |
+
.modal.toml
|
| 6 |
+
modal/
|
| 7 |
+
*.gguf
|
| 8 |
+
*.log
|
| 9 |
+
.DS_Store
|
| 10 |
+
Thumbs.db
|
| 11 |
+
*.docx
|
| 12 |
+
browser_test.js
|
| 13 |
+
run_test.py
|
| 14 |
+
test_groq.py
|
| 15 |
+
test_integration.py
|
| 16 |
+
Video Project 13.mp4
|
README.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: "Omniscient Reader — Scenario Simulator"
|
| 3 |
+
emoji: 📖
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: "6.18.0"
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: true
|
| 10 |
+
license: mit
|
| 11 |
+
tags:
|
| 12 |
+
- game
|
| 13 |
+
- ai
|
| 14 |
+
- interactive-fiction
|
| 15 |
+
- omniscient-reader
|
| 16 |
+
- build-small
|
| 17 |
+
- thousand-token-wood
|
| 18 |
+
- best-use-of-modal
|
| 19 |
+
- custom-ui
|
| 20 |
+
- llama-cpp
|
| 21 |
+
short_description: "Play as Kim Dokja. The Dokkaebi knows you know everything."
|
| 22 |
+
---
|
| 23 |
+
|
| 24 |
+
# Omniscient Reader — Scenario Simulator
|
| 25 |
+
|
| 26 |
+
> *The AI isn't a narrator — it's the antagonist.*
|
| 27 |
+
|
| 28 |
+
An AI-powered scenario simulator based on **Omniscient Reader's Viewpoint (ORV)**. You play as Kim Dokja — a man who has read the apocalypse novel 3,149 times. The Dokkaebi (AI) knows you know everything — and has been redesigning reality specifically to break your meta-knowledge.
|
| 29 |
+
|
| 30 |
+
Built for **Build Small Hackathon 2026** — Track 2: Thousand Token Wood 🍄
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## How to Play
|
| 35 |
+
|
| 36 |
+
1. **Click "Begin the Scenario"** to start
|
| 37 |
+
2. **Type your actions freely** — or pick from the 3 AI-generated suggestions
|
| 38 |
+
3. **Choose your Stance** (Neutral / Aggressive / Deceptive / Empathetic / Observant)
|
| 39 |
+
4. **The Dokkaebi evaluates your intent** — not just your actions
|
| 40 |
+
5. **If you use novel knowledge, reality fights back** — silently, without warning
|
| 41 |
+
6. **Constellations watch and react** — your cosmic audience judges everything
|
| 42 |
+
7. **Push meta-exposure to 100%** — and trigger "The Reader Has Been Noticed"
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
## Architecture
|
| 47 |
+
|
| 48 |
+
**Dual-Model System** (total: 22B parameters — under 32B hackathon cap ✅)
|
| 49 |
+
|
| 50 |
+
| Backend | Model | Size | Usage |
|
| 51 |
+
|---------|-------|------|-------|
|
| 52 |
+
| **Groq** | Llama 3.1 8B Instant | 8B | Every gameplay turn (~2s response) |
|
| 53 |
+
| **Modal** | Qwen 2.5 14B (llama.cpp) | 14B | Cinematic moments — scenario intros, end-game |
|
| 54 |
+
|
| 55 |
+
The Modal deployment runs on an A10G GPU with a persistent volume cache, so the model stays warm and cold-starts only happen after extended inactivity.
|
| 56 |
+
|
| 57 |
+
---
|
| 58 |
+
|
| 59 |
+
## Features
|
| 60 |
+
|
| 61 |
+
- 🎭 **AI Antagonist** — The Dokkaebi detects meta-gaming and subverts reality
|
| 62 |
+
- ⭐ **Star Stream Chat** — Live constellation reactions to every action
|
| 63 |
+
- 📊 **Dynamic Meters** — Meta Exposure, Probability Stability, Entertainment
|
| 64 |
+
- 🏆 **Emergent Titles** — Earned through behavior, never chosen from menus
|
| 65 |
+
- 🎬 **Signature Moments** — Screen glitch effects when reality breaks
|
| 66 |
+
- ⚔️ **Stances** — Choose your approach (Aggressive, Deceptive, Empathetic, Observant)
|
| 67 |
+
- 🌩️ **Probability Storms** — Random debuffs when stability drops below 50%
|
| 68 |
+
- 🔄 **Phase Shifts** — Combat/Exploration/Safe Zone phases change the UI and rules
|
| 69 |
+
|
| 70 |
+
---
|
| 71 |
+
|
| 72 |
+
## Merit Badges
|
| 73 |
+
|
| 74 |
+
- 🎨 **Off-Brand** — Fully custom UI: 14KB CSS + 21KB animations + 35KB JS. Not a single default Gradio element.
|
| 75 |
+
- 🦙 **Llama Champion** — Cinematic moments powered by llama.cpp (Qwen 2.5 14B GGUF on Modal A10G)
|
| 76 |
+
|
| 77 |
+
---
|
| 78 |
+
|
| 79 |
+
## Setup Instructions
|
| 80 |
+
|
| 81 |
+
1. Clone the repository
|
| 82 |
+
2. Create a virtual environment and install dependencies:
|
| 83 |
+
```bash
|
| 84 |
+
pip install -r requirements.txt
|
| 85 |
+
```
|
| 86 |
+
3. Set your API keys (as environment variables or in a `.env` file):
|
| 87 |
+
```bash
|
| 88 |
+
export GROQ_API_KEY="your_groq_key_here"
|
| 89 |
+
export MODAL_ENDPOINT_URL="your_modal_endpoint_here" # optional
|
| 90 |
+
```
|
| 91 |
+
4. Run the Gradio app:
|
| 92 |
+
```bash
|
| 93 |
+
python app.py
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
**On HuggingFace Spaces**: Set `GROQ_API_KEY` in the Space's Secrets settings.
|
| 97 |
+
|
| 98 |
+
---
|
| 99 |
+
|
| 100 |
+
## Demo & Socials
|
| 101 |
+
- **Demo Video**: [Insert YouTube/Loom Link Here]
|
| 102 |
+
- **Social Post**: [Insert X/LinkedIn Link Here]
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## Tech Stack
|
| 107 |
+
|
| 108 |
+
- **Fast AI (gameplay turns)**: Llama 3.1 8B Instant via Groq LPU (~2s/turn)
|
| 109 |
+
- **Cinematic AI (big moments)**: Qwen 2.5 14B Instruct via llama.cpp on Modal A10G GPU
|
| 110 |
+
- **Frontend**: Gradio 6 with full custom CSS/JS (glassmorphism, glitch effects, particle systems)
|
| 111 |
+
- **Hosting**: HuggingFace Spaces
|
| 112 |
+
|
| 113 |
+
Built for the **Build Small Hackathon 2026** — Track 2: Thousand Token Wood 🍄
|
animations.css
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 2 |
+
ORV SCENARIO SIMULATOR — ANIMATIONS.CSS
|
| 3 |
+
Elder Scrolls / Oblivion Edition — Ambient Gold & Dramatic Reveals
|
| 4 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 8 |
+
1. SCREEN SHAKE — damage / impact
|
| 9 |
+
───────────────────────────────────────────────────────────────── */
|
| 10 |
+
@keyframes shake {
|
| 11 |
+
0% { transform: translate(0, 0); }
|
| 12 |
+
10% { transform: translate(-5px, 2px); }
|
| 13 |
+
20% { transform: translate(4px, -3px); }
|
| 14 |
+
30% { transform: translate(-3px, 4px); }
|
| 15 |
+
40% { transform: translate(5px, -2px); }
|
| 16 |
+
50% { transform: translate(-2px, -4px); }
|
| 17 |
+
60% { transform: translate(3px, 2px); }
|
| 18 |
+
70% { transform: translate(-4px, -2px); }
|
| 19 |
+
80% { transform: translate(2px, 3px); }
|
| 20 |
+
90% { transform: translate(-1px, -1px); }
|
| 21 |
+
100% { transform: translate(0, 0); }
|
| 22 |
+
}
|
| 23 |
+
.shake { animation: shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; }
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 27 |
+
2. DAMAGE FLASH — blood red
|
| 28 |
+
───────────────────────────────────────────────────────────────── */
|
| 29 |
+
@keyframes damageFlash {
|
| 30 |
+
0% { opacity: 0.7; background: rgba(139, 26, 26, 0.3); }
|
| 31 |
+
50% { opacity: 0.4; background: rgba(139, 26, 26, 0.15); }
|
| 32 |
+
100% { opacity: 0; background: rgba(139, 26, 26, 0); }
|
| 33 |
+
}
|
| 34 |
+
.damage-flash { animation: damageFlash 0.5s ease-out forwards; }
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 38 |
+
3. AMBIENT GOLD PULSE — torch/candle glow on panels
|
| 39 |
+
───────────────────────────────────────────────────────────────── */
|
| 40 |
+
@keyframes goldBreath {
|
| 41 |
+
0%, 100% {
|
| 42 |
+
box-shadow: 0 0 8px rgba(201,168,76,0.15), inset 0 0 20px rgba(0,0,0,0.6);
|
| 43 |
+
}
|
| 44 |
+
50% {
|
| 45 |
+
box-shadow: 0 0 20px rgba(201,168,76,0.25), inset 0 0 20px rgba(0,0,0,0.6);
|
| 46 |
+
}
|
| 47 |
+
}
|
| 48 |
+
.gold-breathe { animation: goldBreath 4s ease-in-out infinite; }
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 52 |
+
4. METER PULSE — meta exposure rise (blood variant)
|
| 53 |
+
───────────────────────────────────────────────────────────────── */
|
| 54 |
+
@keyframes meterPulse {
|
| 55 |
+
0% { box-shadow: 0 0 5px rgba(139,26,26,0.2); }
|
| 56 |
+
50% { box-shadow: 0 0 18px rgba(139,26,26,0.5), 0 0 32px rgba(139,26,26,0.2); }
|
| 57 |
+
100% { box-shadow: 0 0 5px rgba(139,26,26,0.2); }
|
| 58 |
+
}
|
| 59 |
+
.meter-pulse { animation: meterPulse 1.2s ease-in-out; }
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 63 |
+
5. DANGER PULSE — meta > 75% (urgent blood warning)
|
| 64 |
+
───────────────────────────────────────────────────────────────── */
|
| 65 |
+
@keyframes dangerPulse {
|
| 66 |
+
0% { border-color: rgba(139,26,26,0.3); box-shadow: 0 0 8px rgba(139,26,26,0.1); }
|
| 67 |
+
50% { border-color: rgba(204,51,51,0.6); box-shadow: 0 0 24px rgba(139,26,26,0.4); }
|
| 68 |
+
100% { border-color: rgba(139,26,26,0.3); box-shadow: 0 0 8px rgba(139,26,26,0.1); }
|
| 69 |
+
}
|
| 70 |
+
.meter-danger { animation: dangerPulse 1.5s ease-in-out infinite; }
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 74 |
+
6. COIN FALL — gold dust particles
|
| 75 |
+
──────────────────────────────────────────────────────────────���── */
|
| 76 |
+
@keyframes coinFall {
|
| 77 |
+
0% {
|
| 78 |
+
transform: translateY(-20px) rotate(0deg) scale(1);
|
| 79 |
+
opacity: 1;
|
| 80 |
+
}
|
| 81 |
+
40% {
|
| 82 |
+
transform: translateY(40vh) translateX(12px) rotate(180deg) scale(0.8);
|
| 83 |
+
opacity: 0.9;
|
| 84 |
+
}
|
| 85 |
+
80% {
|
| 86 |
+
transform: translateY(80vh) translateX(-8px) rotate(300deg) scale(0.5);
|
| 87 |
+
opacity: 0.4;
|
| 88 |
+
}
|
| 89 |
+
100% {
|
| 90 |
+
transform: translateY(100vh) rotate(360deg) scale(0.2);
|
| 91 |
+
opacity: 0;
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 97 |
+
7. SCENARIO FLASH — parchment unfurl entrance
|
| 98 |
+
───────────────────────────────────────────────────────────────── */
|
| 99 |
+
@keyframes scenarioFlash {
|
| 100 |
+
0% { opacity: 0; background: rgba(201,168,76,0.15); }
|
| 101 |
+
10% { opacity: 1; background: rgba(201,168,76,0.08); }
|
| 102 |
+
25% { background: rgba(30,20,5,0.7); }
|
| 103 |
+
100% { opacity: 1; background: rgba(5,4,2,0.92); }
|
| 104 |
+
}
|
| 105 |
+
.scenario-flash { animation: scenarioFlash 1.4s ease-out forwards; }
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 109 |
+
8. SLIDE IN — from right for chat messages
|
| 110 |
+
───────────────────────────────────────────────────────────────── */
|
| 111 |
+
@keyframes slideIn {
|
| 112 |
+
0% { transform: translateX(24px); opacity: 0; }
|
| 113 |
+
100% { transform: translateX(0); opacity: 1; }
|
| 114 |
+
}
|
| 115 |
+
.slide-in-right { animation: slideIn 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; }
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 119 |
+
9. FADE IN UP — general entrance (parchment rising)
|
| 120 |
+
───────────────────────────────────────────────────────────────── */
|
| 121 |
+
@keyframes fadeInUp {
|
| 122 |
+
0% { transform: translateY(16px); opacity: 0; }
|
| 123 |
+
100% { transform: translateY(0); opacity: 1; }
|
| 124 |
+
}
|
| 125 |
+
.fade-in-up { animation: fadeInUp 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; }
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 129 |
+
10. GLITCH — reality distortion for Reader Noticed
|
| 130 |
+
───────────────────────────────────────────────────────────────── */
|
| 131 |
+
@keyframes glitch {
|
| 132 |
+
0% { text-shadow: 2px 0 #8b1a1a, -2px 0 #c9a84c; transform: translate(0); }
|
| 133 |
+
15% { text-shadow: -3px 0 #8b1a1a, 3px 0 #c9a84c; transform: translate(-2px, 1px); }
|
| 134 |
+
30% { text-shadow: 3px 0 #8b1a1a, -3px 0 #c9a84c; transform: translate(2px, -1px); }
|
| 135 |
+
45% { text-shadow: -2px 0 #8b1a1a, 2px 0 #c9a84c; transform: translate(-1px, 2px); }
|
| 136 |
+
60% { text-shadow: 4px 0 #8b1a1a, -4px 0 #c9a84c; transform: translate(2px, -2px); }
|
| 137 |
+
100% { text-shadow: 2px 0 #8b1a1a, -2px 0 #c9a84c; transform: translate(0); }
|
| 138 |
+
}
|
| 139 |
+
.glitch-effect { animation: glitch 0.3s steps(2) infinite; }
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 143 |
+
11. GLITCH TEXT — gold/blood RGB split
|
| 144 |
+
───────────────────────────────────────────────────────────────── */
|
| 145 |
+
@keyframes glitchText {
|
| 146 |
+
0% { text-shadow: 1px 0 #8b1a1a, -1px 0 #c9a84c; }
|
| 147 |
+
33% { text-shadow: -2px 1px #8b1a1a, 2px -1px #c9a84c; }
|
| 148 |
+
66% { text-shadow: 2px -1px #8b1a1a, -2px 1px #c9a84c; }
|
| 149 |
+
100% { text-shadow: 1px 0 #8b1a1a, -1px 0 #c9a84c; }
|
| 150 |
+
}
|
| 151 |
+
.glitch-text { animation: glitchText 0.5s steps(3) infinite; }
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 155 |
+
12. STATIC NOISE — ancient static overlay
|
| 156 |
+
───────────────────────────────────────────────────────────────── */
|
| 157 |
+
@keyframes staticNoise {
|
| 158 |
+
0% { background-position: 0 0; }
|
| 159 |
+
20% { background-position: -30px 20px; }
|
| 160 |
+
40% { background-position: 20px -15px; }
|
| 161 |
+
60% { background-position: -15px 30px; }
|
| 162 |
+
80% { background-position: 25px -25px; }
|
| 163 |
+
100% { background-position: 0 0; }
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.static-noise::after {
|
| 167 |
+
content: '';
|
| 168 |
+
position: fixed;
|
| 169 |
+
top: -50%; left: -50%;
|
| 170 |
+
width: 200%; height: 200%;
|
| 171 |
+
background:
|
| 172 |
+
repeating-radial-gradient(
|
| 173 |
+
circle at 50% 50%,
|
| 174 |
+
transparent 0,
|
| 175 |
+
rgba(201,168,76,0.02) 1px,
|
| 176 |
+
transparent 2px
|
| 177 |
+
);
|
| 178 |
+
animation: staticNoise 0.12s steps(5) infinite;
|
| 179 |
+
pointer-events: none;
|
| 180 |
+
z-index: 10000;
|
| 181 |
+
mix-blend-mode: overlay;
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
.static-noise::before {
|
| 185 |
+
content: '';
|
| 186 |
+
position: fixed;
|
| 187 |
+
inset: 0;
|
| 188 |
+
background: repeating-linear-gradient(
|
| 189 |
+
0deg,
|
| 190 |
+
transparent 0px,
|
| 191 |
+
transparent 3px,
|
| 192 |
+
rgba(139,26,26,0.012) 3px,
|
| 193 |
+
rgba(139,26,26,0.012) 4px
|
| 194 |
+
);
|
| 195 |
+
pointer-events: none;
|
| 196 |
+
z-index: 10001;
|
| 197 |
+
animation: staticNoise 0.1s steps(3) infinite reverse;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 202 |
+
13. RANK FLASH — ancient medal reveal
|
| 203 |
+
───────────────────────────────────────────────────────────────── */
|
| 204 |
+
@keyframes rankFlash {
|
| 205 |
+
0% { transform: scale(0.3); opacity: 0; filter: brightness(3) sepia(0.5); }
|
| 206 |
+
40% { transform: scale(1.15); opacity: 1; filter: brightness(2) sepia(0.3); }
|
| 207 |
+
60% { transform: scale(0.96); filter: brightness(1.5); }
|
| 208 |
+
80% { transform: scale(1.04); filter: brightness(1.2); }
|
| 209 |
+
100% { transform: scale(1); opacity: 1; filter: brightness(1); }
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.rank-s { animation: rankFlash 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; }
|
| 213 |
+
.rank-a { animation: rankFlash 0.7s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; }
|
| 214 |
+
.rank-b { animation: rankFlash 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; }
|
| 215 |
+
.rank-c { animation: rankFlash 0.5s ease-out both; }
|
| 216 |
+
|
| 217 |
+
@keyframes rankGlowGold {
|
| 218 |
+
0%, 100% { text-shadow: 0 0 30px rgba(240,200,64,0.4), 0 0 60px rgba(201,168,76,0.2); }
|
| 219 |
+
50% { text-shadow: 0 0 50px rgba(240,200,64,0.7), 0 0 100px rgba(201,168,76,0.35); }
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
@keyframes rankGlowBlood {
|
| 223 |
+
0%, 100% { text-shadow: 0 0 30px rgba(204,51,51,0.4), 0 0 60px rgba(139,26,26,0.2); }
|
| 224 |
+
50% { text-shadow: 0 0 50px rgba(204,51,51,0.7), 0 0 100px rgba(139,26,26,0.35); }
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.rank-s .rank-letter { animation: rankGlowGold 2s ease-in-out infinite 0.8s; }
|
| 228 |
+
.rank-a .rank-letter { animation: rankGlowGold 2s ease-in-out infinite 0.7s; }
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 232 |
+
14. GLOW PULSE — gold candlelight flicker on Begin button
|
| 233 |
+
───────────────────────────────────────────────────────────────── */
|
| 234 |
+
@keyframes glowPulse {
|
| 235 |
+
0%, 100% {
|
| 236 |
+
box-shadow: 0 0 12px rgba(201,168,76,0.25), 0 0 24px rgba(201,168,76,0.10);
|
| 237 |
+
}
|
| 238 |
+
50% {
|
| 239 |
+
box-shadow: 0 0 24px rgba(201,168,76,0.45), 0 0 48px rgba(201,168,76,0.20), 0 0 80px rgba(201,168,76,0.08);
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
.glow-pulse { animation: glowPulse 2.8s ease-in-out infinite; }
|
| 243 |
+
|
| 244 |
+
|
| 245 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 246 |
+
15. TEXT REVEAL — ink spreading across parchment
|
| 247 |
+
───────────────────────────────────────────────────────────────── */
|
| 248 |
+
@keyframes textReveal {
|
| 249 |
+
0% { max-width: 0; opacity: 0; }
|
| 250 |
+
100% { max-width: 100%; opacity: 1; }
|
| 251 |
+
}
|
| 252 |
+
.text-reveal {
|
| 253 |
+
overflow: hidden;
|
| 254 |
+
white-space: nowrap;
|
| 255 |
+
animation: textReveal 2.5s steps(45) forwards;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 260 |
+
16. BAR FILL ENTRANCE
|
| 261 |
+
───────────────────────────────────────────────────────────────── */
|
| 262 |
+
@keyframes barFill {
|
| 263 |
+
0% { width: 0; }
|
| 264 |
+
}
|
| 265 |
+
.bar-animate-in { animation: barFill 1.2s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; }
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 269 |
+
17. FLOAT — hovering title (start screen)
|
| 270 |
+
───────────────────────────��───────────────────────────────────── */
|
| 271 |
+
@keyframes float {
|
| 272 |
+
0%, 100% { transform: translateY(0); }
|
| 273 |
+
50% { transform: translateY(-8px); }
|
| 274 |
+
}
|
| 275 |
+
.floating { animation: float 4s ease-in-out infinite; }
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 279 |
+
18. CINEMATIC ANIMATIONS
|
| 280 |
+
───────────────────────────────────────────────────────────────── */
|
| 281 |
+
@keyframes fadeIn {
|
| 282 |
+
0% { opacity: 0; }
|
| 283 |
+
100% { opacity: 1; }
|
| 284 |
+
}
|
| 285 |
+
.fade-in { animation: fadeIn 0.6s ease-out both; }
|
| 286 |
+
|
| 287 |
+
@keyframes fadeOut {
|
| 288 |
+
0% { opacity: 1; }
|
| 289 |
+
100% { opacity: 0; }
|
| 290 |
+
}
|
| 291 |
+
.fade-out { animation: fadeOut 0.5s ease-out forwards; }
|
| 292 |
+
|
| 293 |
+
@keyframes slideInLeft {
|
| 294 |
+
0% { transform: translateX(-24px); opacity: 0; }
|
| 295 |
+
100% { transform: translateX(0); opacity: 1; }
|
| 296 |
+
}
|
| 297 |
+
.slide-in-left { animation: slideInLeft 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94) both; }
|
| 298 |
+
|
| 299 |
+
@keyframes scaleUp {
|
| 300 |
+
0% { transform: scale(0.85); opacity: 0; }
|
| 301 |
+
100% { transform: scale(1); opacity: 1; }
|
| 302 |
+
}
|
| 303 |
+
.scale-up { animation: scaleUp 0.45s cubic-bezier(0.175, 0.885, 0.32, 1.275) both; }
|
| 304 |
+
|
| 305 |
+
@keyframes pulse {
|
| 306 |
+
0%, 100% { opacity: 1; }
|
| 307 |
+
50% { opacity: 0.55; }
|
| 308 |
+
}
|
| 309 |
+
.pulse { animation: pulse 2.5s ease-in-out infinite; }
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 313 |
+
19. SHIMMER — parchment loading effect
|
| 314 |
+
───────────────────────────────────────────────────────────────── */
|
| 315 |
+
@keyframes shimmer {
|
| 316 |
+
0% { background-position: -200% 0; }
|
| 317 |
+
100% { background-position: 200% 0; }
|
| 318 |
+
}
|
| 319 |
+
.shimmer {
|
| 320 |
+
background: linear-gradient(
|
| 321 |
+
90deg,
|
| 322 |
+
#12100a 25%,
|
| 323 |
+
rgba(201,168,76,0.06) 50%,
|
| 324 |
+
#12100a 75%
|
| 325 |
+
);
|
| 326 |
+
background-size: 200% 100%;
|
| 327 |
+
animation: shimmer 2.5s infinite;
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
@keyframes breathe {
|
| 331 |
+
0%, 100% { opacity: 0.35; }
|
| 332 |
+
50% { opacity: 0.75; }
|
| 333 |
+
}
|
| 334 |
+
.breathe { animation: breathe 4.5s ease-in-out infinite; }
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 338 |
+
20. WARP — meta-critical reality distortion
|
| 339 |
+
───────────────────────────────────────────────────────────────── */
|
| 340 |
+
@keyframes warp {
|
| 341 |
+
0%, 100% { filter: none; }
|
| 342 |
+
25% { filter: sepia(0.08) hue-rotate(5deg); }
|
| 343 |
+
50% { filter: sepia(0.12) hue-rotate(-4deg); }
|
| 344 |
+
75% { filter: sepia(0.06) hue-rotate(3deg); }
|
| 345 |
+
}
|
| 346 |
+
body.meta-critical .game-area { animation: warp 5s ease-in-out infinite; }
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 350 |
+
21. STAGGER — children entrance cascade
|
| 351 |
+
───────────────────────────────────────────────────────────────── */
|
| 352 |
+
.stagger-children > * {
|
| 353 |
+
opacity: 0;
|
| 354 |
+
animation: fadeInUp 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94) both;
|
| 355 |
+
}
|
| 356 |
+
.stagger-children > *:nth-child(1) { animation-delay: 0.05s; }
|
| 357 |
+
.stagger-children > *:nth-child(2) { animation-delay: 0.10s; }
|
| 358 |
+
.stagger-children > *:nth-child(3) { animation-delay: 0.15s; }
|
| 359 |
+
.stagger-children > *:nth-child(4) { animation-delay: 0.20s; }
|
| 360 |
+
.stagger-children > *:nth-child(5) { animation-delay: 0.25s; }
|
| 361 |
+
.stagger-children > *:nth-child(6) { animation-delay: 0.30s; }
|
| 362 |
+
.stagger-children > *:nth-child(7) { animation-delay: 0.35s; }
|
| 363 |
+
.stagger-children > *:nth-child(8) { animation-delay: 0.40s; }
|
| 364 |
+
.stagger-children > *:nth-child(9) { animation-delay: 0.45s; }
|
| 365 |
+
.stagger-children > *:nth-child(10) { animation-delay: 0.50s; }
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
/* ─────────────────────────────────────────────────────────────────
|
| 369 |
+
22. REDUCED MOTION
|
| 370 |
+
───────────────────────────────────────────────────────────────── */
|
| 371 |
+
@media (prefers-reduced-motion: reduce) {
|
| 372 |
+
*, *::before, *::after {
|
| 373 |
+
animation-duration: 0.01ms !important;
|
| 374 |
+
animation-iteration-count: 1 !important;
|
| 375 |
+
transition-duration: 0.01ms !important;
|
| 376 |
+
}
|
| 377 |
+
}
|
app.py
ADDED
|
@@ -0,0 +1,1252 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py — Omniscient Reader: Scenario Simulator
|
| 3 |
+
================================================
|
| 4 |
+
Main Gradio application entry point.
|
| 5 |
+
|
| 6 |
+
Connects the Dokkaebi AI (Qwen 2.5 14B via Modal), the game engine,
|
| 7 |
+
scenario definitions, and the custom dark-theme game UI.
|
| 8 |
+
|
| 9 |
+
Part of the ORV (Omniscient Reader's Viewpoint) Scenario Simulator.
|
| 10 |
+
Build Small Hackathon 2026 — Track 2: Thousand Token Wood.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
import os
|
| 17 |
+
from urllib.parse import quote
|
| 18 |
+
import gradio as gr
|
| 19 |
+
|
| 20 |
+
from game_engine import GameState, process_turn
|
| 21 |
+
from scenarios import SCENARIOS, get_scenario_announcement_text, get_next_scenario
|
| 22 |
+
from prompts import build_prompt, build_intro_prompt
|
| 23 |
+
from modal_client import call_dokkaebi, call_dokkaebi_cinematic
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Asset paths
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
_ASSET_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
|
| 30 |
+
# Cache-busting: timestamp changes every server restart, forcing browser to fetch fresh images
|
| 31 |
+
_CACHE_BUST = int(os.path.getmtime(_ASSET_DIR))
|
| 32 |
+
|
| 33 |
+
SCENE_IMAGES = {
|
| 34 |
+
"Subway": "seoul_subway.png",
|
| 35 |
+
"Seoul Street": "seoul_street.png",
|
| 36 |
+
"Destroyed Office": "destroyed_office.png",
|
| 37 |
+
"Han River Bridge": "han_river_bridge.png",
|
| 38 |
+
"Reader Noticed": "reader_noticed.png",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
def _asset_path(filename: str) -> str:
|
| 42 |
+
return os.path.join(_ASSET_DIR, filename)
|
| 43 |
+
|
| 44 |
+
def _asset_url(filename: str) -> str:
|
| 45 |
+
"""Return a Gradio file URL that works on Windows + HF Spaces.
|
| 46 |
+
|
| 47 |
+
Gradio 6.x serves allowed files at /gradio_api/file=<abs_path>.
|
| 48 |
+
We encode path segments so spaces become %20.
|
| 49 |
+
"""
|
| 50 |
+
abs_path = os.path.abspath(os.path.join(_ASSET_DIR, filename))
|
| 51 |
+
fwd = abs_path.replace("\\", "/")
|
| 52 |
+
parts = fwd.split("/")
|
| 53 |
+
encoded = "/".join(quote(p, safe='') if p else p for p in parts)
|
| 54 |
+
encoded = encoded.replace("%3A", ":", 1)
|
| 55 |
+
return f"/gradio_api/file={encoded}?v={_CACHE_BUST}"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# Load CSS / JS
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
def _read_file(path: str) -> str:
|
| 63 |
+
with open(os.path.join(os.path.dirname(__file__), path), "r", encoding="utf-8") as f:
|
| 64 |
+
return f.read()
|
| 65 |
+
|
| 66 |
+
_STYLE_CSS = _read_file("style.css")
|
| 67 |
+
_ANIMATIONS_CSS = _read_file("animations.css")
|
| 68 |
+
_GAME_JS = _read_file("game.js")
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”
|
| 72 |
+
# HTML RENDERERS — Build the game UI from state + AI response
|
| 73 |
+
# â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def render_start_screen() -> str:
|
| 77 |
+
"""Dramatic ORV manhwa-accurate start screen."""
|
| 78 |
+
return f"""
|
| 79 |
+
<style>
|
| 80 |
+
#start-screen {{
|
| 81 |
+
background-image: url('{_asset_url('title_banner.png')}') !important;
|
| 82 |
+
background-size: cover !important;
|
| 83 |
+
background-position: center center !important;
|
| 84 |
+
background-repeat: no-repeat !important;
|
| 85 |
+
background-color: #030305 !important;
|
| 86 |
+
}}
|
| 87 |
+
</style>
|
| 88 |
+
<div class="start-screen" id="start-screen" style="
|
| 89 |
+
position: relative;
|
| 90 |
+
min-height: 100vh;
|
| 91 |
+
display: flex;
|
| 92 |
+
flex-direction: column;
|
| 93 |
+
align-items: center;
|
| 94 |
+
justify-content: center;
|
| 95 |
+
overflow: hidden;">
|
| 96 |
+
|
| 97 |
+
<!-- Multi-layer dark overlay for readability -->
|
| 98 |
+
<div style="position:absolute;inset:0;
|
| 99 |
+
background: linear-gradient(
|
| 100 |
+
180deg,
|
| 101 |
+
rgba(3,3,5,0.50) 0%,
|
| 102 |
+
rgba(3,3,5,0.60) 35%,
|
| 103 |
+
rgba(3,3,5,0.82) 70%,
|
| 104 |
+
rgba(3,3,5,0.96) 100%
|
| 105 |
+
);"></div>
|
| 106 |
+
|
| 107 |
+
<!-- Subtle vignette edges -->
|
| 108 |
+
<div style="position:absolute;inset:0;
|
| 109 |
+
background: radial-gradient(ellipse at 50% 50%, transparent 40%, rgba(3,3,5,0.7) 100%);
|
| 110 |
+
pointer-events:none;"></div>
|
| 111 |
+
|
| 112 |
+
<!-- Top crimson accent line -->
|
| 113 |
+
<div style="position:absolute;top:0;left:0;right:0;height:2px;
|
| 114 |
+
background:linear-gradient(90deg,transparent,#7a1a22,#904858,#7a1a22,transparent);"></div>
|
| 115 |
+
|
| 116 |
+
<div class="start-content" style="position:relative;z-index:2;text-align:center;padding:0 40px;max-width:760px;">
|
| 117 |
+
|
| 118 |
+
<!-- Label above title -->
|
| 119 |
+
<div style="
|
| 120 |
+
font-family:'Cinzel',serif;
|
| 121 |
+
font-size:10px;
|
| 122 |
+
color:#904858;
|
| 123 |
+
letter-spacing:6px;
|
| 124 |
+
text-transform:uppercase;
|
| 125 |
+
margin-bottom:18px;
|
| 126 |
+
opacity:0.85;
|
| 127 |
+
">[ System Notification — Star Stream Access Granted ]</div>
|
| 128 |
+
|
| 129 |
+
<!-- Main Title -->
|
| 130 |
+
<div style="
|
| 131 |
+
font-family:'Cinzel Decorative',serif;
|
| 132 |
+
font-size:clamp(2.2em,5.5vw,4em);
|
| 133 |
+
font-weight:900;
|
| 134 |
+
color:#d8cbb0;
|
| 135 |
+
letter-spacing:0.18em;
|
| 136 |
+
text-transform:uppercase;
|
| 137 |
+
text-shadow:
|
| 138 |
+
0 0 60px rgba(144,72,88,0.35),
|
| 139 |
+
0 0 120px rgba(122,26,34,0.15),
|
| 140 |
+
0 3px 12px rgba(0,0,0,0.95);
|
| 141 |
+
margin-bottom:6px;
|
| 142 |
+
line-height:1.1;
|
| 143 |
+
">Omniscient Reader</div>
|
| 144 |
+
|
| 145 |
+
<!-- Subtitle -->
|
| 146 |
+
<div style="
|
| 147 |
+
font-family:'Cinzel',serif;
|
| 148 |
+
font-size:0.95em;
|
| 149 |
+
color:#904858;
|
| 150 |
+
letter-spacing:0.55em;
|
| 151 |
+
text-transform:uppercase;
|
| 152 |
+
opacity:0.75;
|
| 153 |
+
margin-bottom:28px;
|
| 154 |
+
">Scenario Simulator</div>
|
| 155 |
+
|
| 156 |
+
<!-- Divider -->
|
| 157 |
+
<div style="width:360px;margin:0 auto 28px;height:1px;
|
| 158 |
+
background:linear-gradient(90deg,transparent,#5a1828,#904858,#5a1828,transparent);"></div>
|
| 159 |
+
|
| 160 |
+
<!-- Tagline -->
|
| 161 |
+
<div style="
|
| 162 |
+
font-family:'IM Fell English',Georgia,serif;
|
| 163 |
+
font-size:1em;
|
| 164 |
+
font-style:italic;
|
| 165 |
+
color:#a09078;
|
| 166 |
+
line-height:2.2;
|
| 167 |
+
margin-bottom:36px;
|
| 168 |
+
text-shadow:0 2px 6px rgba(0,0,0,0.9);
|
| 169 |
+
">
|
| 170 |
+
You have read the novel 3,149 times.<br>
|
| 171 |
+
The Dokkaebi knows you know everything.<br>
|
| 172 |
+
<span style="color:#c06878;">Reality has been redesigned — specifically for you.</span>
|
| 173 |
+
</div>
|
| 174 |
+
|
| 175 |
+
<!-- Portraits row -->
|
| 176 |
+
<div style="display:flex;justify-content:center;gap:20px;margin-bottom:36px;">
|
| 177 |
+
|
| 178 |
+
<!-- Kim Dokja -->
|
| 179 |
+
<div style="text-align:center;">
|
| 180 |
+
<div style="
|
| 181 |
+
width:88px;height:114px;
|
| 182 |
+
border:1px solid #5a1828;
|
| 183 |
+
position:relative;overflow:hidden;
|
| 184 |
+
box-shadow:0 8px 32px rgba(0,0,0,0.9),0 0 0 1px rgba(144,72,88,0.15);
|
| 185 |
+
">
|
| 186 |
+
<div style="position:absolute;top:0;left:0;width:12px;height:12px;border-top:2px solid #904858;border-left:2px solid #904858;z-index:2;"></div>
|
| 187 |
+
<div style="position:absolute;bottom:0;right:0;width:12px;height:12px;border-bottom:2px solid #904858;border-right:2px solid #904858;z-index:2;"></div>
|
| 188 |
+
<img src="{_asset_url('dokja_portrait.png')}" alt="Kim Dokja"
|
| 189 |
+
style="width:100%;height:100%;object-fit:cover;object-position:top;
|
| 190 |
+
filter:contrast(1.15) brightness(0.8) saturate(0.8);">
|
| 191 |
+
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.6) 0%,transparent 50%);"></div>
|
| 192 |
+
</div>
|
| 193 |
+
<div style="font-family:'Cinzel',serif;color:#904858;font-size:8px;margin-top:7px;letter-spacing:2.5px;text-transform:uppercase;">Incarnation</div>
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
<!-- Dokkaebi -->
|
| 197 |
+
<div style="text-align:center;">
|
| 198 |
+
<div style="
|
| 199 |
+
width:88px;height:114px;
|
| 200 |
+
border:1px solid #5a1828;
|
| 201 |
+
position:relative;overflow:hidden;
|
| 202 |
+
box-shadow:0 8px 32px rgba(0,0,0,0.9),0 0 0 1px rgba(144,72,88,0.15);
|
| 203 |
+
">
|
| 204 |
+
<div style="position:absolute;top:0;left:0;width:12px;height:12px;border-top:2px solid #904858;border-left:2px solid #904858;z-index:2;"></div>
|
| 205 |
+
<div style="position:absolute;bottom:0;right:0;width:12px;height:12px;border-bottom:2px solid #904858;border-right:2px solid #904858;z-index:2;"></div>
|
| 206 |
+
<img src="{_asset_url('dokkaebi_character.png')}" alt="Dokkaebi"
|
| 207 |
+
style="width:100%;height:100%;object-fit:cover;
|
| 208 |
+
filter:contrast(1.15) brightness(0.8) saturate(0.75);">
|
| 209 |
+
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.6) 0%,transparent 50%);"></div>
|
| 210 |
+
</div>
|
| 211 |
+
<div style="font-family:'Cinzel',serif;color:#904858;font-size:8px;margin-top:7px;letter-spacing:2.5px;text-transform:uppercase;">Dokkaebi</div>
|
| 212 |
+
</div>
|
| 213 |
+
|
| 214 |
+
<!-- Yoo Sangah -->
|
| 215 |
+
<div style="text-align:center;">
|
| 216 |
+
<div style="
|
| 217 |
+
width:88px;height:114px;
|
| 218 |
+
border:1px solid #5a1828;
|
| 219 |
+
position:relative;overflow:hidden;
|
| 220 |
+
box-shadow:0 8px 32px rgba(0,0,0,0.9),0 0 0 1px rgba(144,72,88,0.15);
|
| 221 |
+
">
|
| 222 |
+
<div style="position:absolute;top:0;left:0;width:12px;height:12px;border-top:2px solid #904858;border-left:2px solid #904858;z-index:2;"></div>
|
| 223 |
+
<div style="position:absolute;bottom:0;right:0;width:12px;height:12px;border-bottom:2px solid #904858;border-right:2px solid #904858;z-index:2;"></div>
|
| 224 |
+
<img src="{_asset_url('yoo_sangah_portrait.png')}" alt="Jung Heewon"
|
| 225 |
+
style="width:100%;height:100%;object-fit:cover;object-position:top;
|
| 226 |
+
filter:contrast(1.15) brightness(0.8) saturate(0.75);">
|
| 227 |
+
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.6) 0%,transparent 50%);"></div>
|
| 228 |
+
</div>
|
| 229 |
+
<div style="font-family:'Cinzel',serif;color:#904858;font-size:8px;margin-top:7px;letter-spacing:2.5px;text-transform:uppercase;">Jung Heewon</div>
|
| 230 |
+
</div>
|
| 231 |
+
|
| 232 |
+
<!-- Jung Heewon -->
|
| 233 |
+
<div style="text-align:center;">
|
| 234 |
+
<div style="
|
| 235 |
+
width:88px;height:114px;
|
| 236 |
+
border:1px solid #5a1828;
|
| 237 |
+
position:relative;overflow:hidden;
|
| 238 |
+
box-shadow:0 8px 32px rgba(0,0,0,0.9),0 0 0 1px rgba(144,72,88,0.15);
|
| 239 |
+
">
|
| 240 |
+
<div style="position:absolute;top:0;left:0;width:12px;height:12px;border-top:2px solid #904858;border-left:2px solid #904858;z-index:2;"></div>
|
| 241 |
+
<div style="position:absolute;bottom:0;right:0;width:12px;height:12px;border-bottom:2px solid #904858;border-right:2px solid #904858;z-index:2;"></div>
|
| 242 |
+
<img src="{_asset_url('jung_heewon_portrait.png')}" alt="Lee Jihye"
|
| 243 |
+
style="width:100%;height:100%;object-fit:cover;object-position:top;
|
| 244 |
+
filter:contrast(1.15) brightness(0.8) saturate(0.75);">
|
| 245 |
+
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.6) 0%,transparent 50%);"></div>
|
| 246 |
+
</div>
|
| 247 |
+
<div style="font-family:'Cinzel',serif;color:#904858;font-size:8px;margin-top:7px;letter-spacing:2.5px;text-transform:uppercase;">Lee Jihye</div>
|
| 248 |
+
</div>
|
| 249 |
+
|
| 250 |
+
</div>
|
| 251 |
+
|
| 252 |
+
<!-- Divider -->
|
| 253 |
+
<div style="width:260px;margin:0 auto 24px;height:1px;
|
| 254 |
+
background:linear-gradient(90deg,transparent,#5a1828,#904858,#5a1828,transparent);"></div>
|
| 255 |
+
|
| 256 |
+
<!-- Attribution -->
|
| 257 |
+
<div style="font-family:'Cinzel',serif;color:#3a2832;font-size:9px;letter-spacing:3px;text-transform:uppercase;margin-bottom:28px;">
|
| 258 |
+
Powered by Llama 3.1 · Build Small Hackathon 2026
|
| 259 |
+
</div>
|
| 260 |
+
|
| 261 |
+
<!-- Spacer so background fills screen (real button is position:fixed) -->
|
| 262 |
+
<div style="height:120px;"></div>
|
| 263 |
+
|
| 264 |
+
</div>
|
| 265 |
+
</div>
|
| 266 |
+
"""
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def render_scenario_header(state: dict) -> str:
|
| 270 |
+
"""Top bar — Elder Scrolls quest log style."""
|
| 271 |
+
scenario_num = state.get("scenario_num", 1)
|
| 272 |
+
scenario = SCENARIOS.get(scenario_num, SCENARIOS[1])
|
| 273 |
+
name = scenario["name"]
|
| 274 |
+
subtitle = scenario.get("subtitle", "")
|
| 275 |
+
location = scenario.get("location", "")
|
| 276 |
+
difficulty = scenario.get("difficulty", "?")
|
| 277 |
+
turn = state.get("turn", 1)
|
| 278 |
+
max_turns = scenario.get("turns", 8)
|
| 279 |
+
|
| 280 |
+
diff_colors = {"F": "#90c060", "E": "#904858", "D": "#9b6a30", "C": "#c97040",
|
| 281 |
+
"B": "#c04030", "A": "#8b1a1a", "S": "#f0c840", "SS": "#cc3333"}
|
| 282 |
+
diff_color = diff_colors.get(difficulty, "#904858")
|
| 283 |
+
|
| 284 |
+
return f"""
|
| 285 |
+
<div class="scenario-header-inner">
|
| 286 |
+
<div class="scenario-header-left">
|
| 287 |
+
<span style="font-family:'Cinzel',serif;color:#6a3040;font-size:11px;letter-spacing:1px;">Main Scenario {scenario_num}</span>
|
| 288 |
+
</div>
|
| 289 |
+
<div class="scenario-header-center" style="text-align:center;">
|
| 290 |
+
<div class="scenario-header-text" style="font-family:'Cinzel Decorative',serif;font-size:15px;font-weight:700;color:#f0c840;letter-spacing:4px;text-shadow:0 0 20px rgba(240,200,64,0.45);">âš” {name} âš”</div>
|
| 291 |
+
<div style="font-family:'Cinzel',serif;font-size:10px;color:#8a7050;letter-spacing:2px;margin-top:2px;">{subtitle} — {location}</div>
|
| 292 |
+
</div>
|
| 293 |
+
<div class="scenario-header-right" style="text-align:right;">
|
| 294 |
+
<span style="font-family:'Cinzel',serif;color:{diff_color};font-size:11px;letter-spacing:1px;">Rank {difficulty}</span>
|
| 295 |
+
<span style="font-family:'Cinzel',serif;color:#5a4a35;font-size:11px;margin-left:14px;letter-spacing:1px;">Turn {turn} / {max_turns}</span>
|
| 296 |
+
</div>
|
| 297 |
+
</div>
|
| 298 |
+
"""
|
| 299 |
+
|
| 300 |
+
|
| 301 |
+
def render_player_panel(state: dict) -> str:
|
| 302 |
+
"""Left panel — Elder Scrolls character sheet style."""
|
| 303 |
+
hp = state.get("hp", 100)
|
| 304 |
+
max_hp = state.get("max_hp", 100)
|
| 305 |
+
hp_pct = max(0, min(100, (hp / max_hp) * 100)) if max_hp > 0 else 0
|
| 306 |
+
coins = state.get("coins", 0)
|
| 307 |
+
titles = state.get("titles", [])
|
| 308 |
+
skills = state.get("skills", {})
|
| 309 |
+
sponsor = state.get("sponsor")
|
| 310 |
+
debuffs = state.get("active_debuffs", [])
|
| 311 |
+
phase = state.get("current_phase", "Exploration")
|
| 312 |
+
|
| 313 |
+
titles_html = "".join(
|
| 314 |
+
f'<span class="title-badge">{t}</span>' for t in titles
|
| 315 |
+
) if titles else f'<span style="color:#5a4a35;font-family:\'Cinzel\',serif;font-size:10px;font-style:italic;">No titles earned yet</span>'
|
| 316 |
+
|
| 317 |
+
if isinstance(skills, dict):
|
| 318 |
+
skills_parts = []
|
| 319 |
+
for name, d in skills.items():
|
| 320 |
+
cd = d.get("cooldown", 0)
|
| 321 |
+
opacity = "0.35" if cd > 0 else "1"
|
| 322 |
+
cd_txt = f" · CD:{cd}" if cd > 0 else ""
|
| 323 |
+
skills_parts.append(
|
| 324 |
+
f'<div class="skill-name" style="opacity:{opacity};margin-bottom:3px;">'
|
| 325 |
+
f"âš” {name}{cd_txt}"
|
| 326 |
+
f'</div>'
|
| 327 |
+
)
|
| 328 |
+
skills_html = "".join(skills_parts) or f'<span style="color:#5a4a35;font-size:10px;font-style:italic;">None</span>'
|
| 329 |
+
else:
|
| 330 |
+
skills_html = ""
|
| 331 |
+
|
| 332 |
+
debuffs_html = ""
|
| 333 |
+
if debuffs:
|
| 334 |
+
debuff_badges = "".join(
|
| 335 |
+
f'<div style="background:rgba(139,26,26,0.15);border:1px solid #5a0808;'
|
| 336 |
+
f'color:#cc3333;padding:3px 8px;margin:2px 0;'
|
| 337 |
+
f'font-family:\'Cinzel\',serif;font-size:9px;letter-spacing:1px;">âš {d}</div>'
|
| 338 |
+
for d in debuffs
|
| 339 |
+
)
|
| 340 |
+
debuffs_html = f"""
|
| 341 |
+
<div>
|
| 342 |
+
<div style="font-family:'Cinzel',serif;font-size:9px;letter-spacing:2px;color:#8b1a1a;text-transform:uppercase;margin-bottom:4px;">Active Afflictions</div>
|
| 343 |
+
{debuff_badges}
|
| 344 |
+
</div>"""
|
| 345 |
+
|
| 346 |
+
phase_colors = {"Combat": "#cc3333", "Safe Zone": "#90c060", "Exploration": "#904858"}
|
| 347 |
+
phase_color = phase_colors.get(phase, "#904858")
|
| 348 |
+
phase_icons = {"Combat": "⚔", "Safe Zone": "✦", "Exploration": "◈"}
|
| 349 |
+
phase_icon = phase_icons.get(phase, "â—ˆ")
|
| 350 |
+
|
| 351 |
+
sponsor_html = f"""
|
| 352 |
+
<div style="margin-top:6px;font-family:'Cinzel',serif;color:#904858;font-size:10px;letter-spacing:1px;text-shadow:0 0 8px rgba(201,168,76,0.3);">
|
| 353 |
+
✦ Sponsor: {sponsor}
|
| 354 |
+
</div>
|
| 355 |
+
""" if sponsor else ""
|
| 356 |
+
|
| 357 |
+
return f"""
|
| 358 |
+
<div style="display:flex;flex-direction:column;gap:12px;width:100%;">
|
| 359 |
+
|
| 360 |
+
<!-- Portrait with ornate corners -->
|
| 361 |
+
<div class="player-portrait">
|
| 362 |
+
<img src="{_asset_url('dokja_portrait.png')}" alt="Kim Dokja">
|
| 363 |
+
</div>
|
| 364 |
+
|
| 365 |
+
<!-- Name + Phase -->
|
| 366 |
+
<div style="text-align:center;">
|
| 367 |
+
<div style="font-family:'Cinzel Decorative',serif;color:#f0c840;font-size:15px;font-weight:700;text-transform:uppercase;letter-spacing:3px;text-shadow:0 0 16px rgba(240,200,64,0.4);">
|
| 368 |
+
Kim Dokja
|
| 369 |
+
</div>
|
| 370 |
+
<div style="font-family:'Cinzel',serif;font-size:10px;color:#8a7050;letter-spacing:2px;margin-top:4px;">
|
| 371 |
+
Incarnation · <span style="color:{phase_color};">{phase_icon} {phase}</span>
|
| 372 |
+
</div>
|
| 373 |
+
</div>
|
| 374 |
+
|
| 375 |
+
<!-- Divider -->
|
| 376 |
+
<div style="height:1px;background:linear-gradient(90deg,transparent,#6a3040,transparent);"></div>
|
| 377 |
+
|
| 378 |
+
<!-- HP Bar -->
|
| 379 |
+
<div class="stat-bar stat-bar-hp">
|
| 380 |
+
<div class="stat-bar-label"><span>Vitality</span><span>{hp}/{max_hp}</span></div>
|
| 381 |
+
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:{hp_pct}%"></div></div>
|
| 382 |
+
</div>
|
| 383 |
+
|
| 384 |
+
<!-- Coins -->
|
| 385 |
+
<div style="font-family:'Cinzel',serif;color:#904858;font-size:13px;text-shadow:0 0 8px rgba(201,168,76,0.4);letter-spacing:1px;">
|
| 386 |
+
â—ˆ {coins:,} Coins
|
| 387 |
+
</div>
|
| 388 |
+
|
| 389 |
+
{debuffs_html}
|
| 390 |
+
|
| 391 |
+
<!-- Divider -->
|
| 392 |
+
<div style="height:1px;background:linear-gradient(90deg,transparent,#3a1828,transparent);"></div>
|
| 393 |
+
|
| 394 |
+
<!-- Titles -->
|
| 395 |
+
<div>
|
| 396 |
+
<div style="font-family:'Cinzel',serif;font-size:9px;letter-spacing:2px;color:#6a3040;text-transform:uppercase;margin-bottom:6px;">Titles Earned</div>
|
| 397 |
+
<div>{titles_html}</div>
|
| 398 |
+
</div>
|
| 399 |
+
|
| 400 |
+
<!-- Skills -->
|
| 401 |
+
<div>
|
| 402 |
+
<div style="font-family:'Cinzel',serif;font-size:9px;letter-spacing:2px;color:#6a3040;text-transform:uppercase;margin-bottom:6px;">Combat Skills</div>
|
| 403 |
+
<div style="font-family:'Cinzel',serif;font-size:11px;color:#b8a07a;">{skills_html}</div>
|
| 404 |
+
</div>
|
| 405 |
+
|
| 406 |
+
{sponsor_html}
|
| 407 |
+
</div>
|
| 408 |
+
"""
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
def render_meters(state: dict) -> str:
|
| 414 |
+
"""Render meters in Elder Scrolls style."""
|
| 415 |
+
meta = state.get("meta_exposure", 0)
|
| 416 |
+
stability = state.get("prob_stability", 100)
|
| 417 |
+
entertainment = state.get("entertainment", 0)
|
| 418 |
+
|
| 419 |
+
meta_class = ""
|
| 420 |
+
if meta >= 75:
|
| 421 |
+
meta_class = "meter-danger"
|
| 422 |
+
elif meta >= 50:
|
| 423 |
+
meta_class = "meter-pulse"
|
| 424 |
+
|
| 425 |
+
return f"""
|
| 426 |
+
<div class="meters-container">
|
| 427 |
+
<!-- Divider -->
|
| 428 |
+
<div style="height:1px;background:linear-gradient(90deg,transparent,#3a1828,transparent);margin-bottom:8px;"></div>
|
| 429 |
+
|
| 430 |
+
<!-- Meta Exposure -->
|
| 431 |
+
<div class="stat-bar stat-bar-meta {meta_class}">
|
| 432 |
+
<div class="stat-bar-label"><span>âš Meta Exposure</span><span>{meta}%</span></div>
|
| 433 |
+
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:{meta}%"></div></div>
|
| 434 |
+
</div>
|
| 435 |
+
|
| 436 |
+
<!-- Probability Stability -->
|
| 437 |
+
<div class="stat-bar stat-bar-stability">
|
| 438 |
+
<div class="stat-bar-label"><span>â—ˆ Probability</span><span>{stability}%</span></div>
|
| 439 |
+
<div class="stat-bar-track"><div class="stat-bar-fill" style="width:{stability}%"></div></div>
|
| 440 |
+
</div>
|
| 441 |
+
|
| 442 |
+
<!-- Entertainment -->
|
| 443 |
+
<div style="text-align:center;margin-top:8px;font-family:'Cinzel',serif;font-size:10px;color:#5a4a35;letter-spacing:2px;">
|
| 444 |
+
✦ Entertainment {entertainment}
|
| 445 |
+
</div>
|
| 446 |
+
</div>
|
| 447 |
+
"""
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def render_narrative(narrative: str, dokkaebi_comment: str,
|
| 451 |
+
reality_subversion: str = None) -> str:
|
| 452 |
+
"""Main game area narrative — tome reading style."""
|
| 453 |
+
subversion_html = ""
|
| 454 |
+
if reality_subversion:
|
| 455 |
+
subversion_html = f"""
|
| 456 |
+
<div style="margin:12px 24px;padding:12px 16px;
|
| 457 |
+
background:rgba(90,42,138,0.12);border-left:2px solid #5a2a8a;
|
| 458 |
+
font-family:'Cinzel',serif;color:#9b59b6;font-size:11px;letter-spacing:1px;">
|
| 459 |
+
⚠Probability Distortion — {reality_subversion}
|
| 460 |
+
</div>
|
| 461 |
+
"""
|
| 462 |
+
|
| 463 |
+
return f"""
|
| 464 |
+
<div class="narrative-container system-window"
|
| 465 |
+
style="overflow-y:auto;max-height:280px;min-height:100px;flex-shrink:0;"
|
| 466 |
+
>
|
| 467 |
+
<div class="narrative-text">{narrative}</div>
|
| 468 |
+
{subversion_html}
|
| 469 |
+
<div class="dokkaebi-text">{dokkaebi_comment}</div>
|
| 470 |
+
</div>
|
| 471 |
+
"""
|
| 472 |
+
|
| 473 |
+
|
| 474 |
+
def render_scene(state: dict) -> str:
|
| 475 |
+
"""Scene background image — cinematic dark vignette."""
|
| 476 |
+
scenario_num = state.get("scenario_num", 1)
|
| 477 |
+
scenario = SCENARIOS.get(scenario_num, SCENARIOS[1])
|
| 478 |
+
scene_img = scenario.get("scene_image", "seoul_subway.png")
|
| 479 |
+
|
| 480 |
+
return f"""
|
| 481 |
+
<div style="width:100%;height:160px;overflow:hidden;margin-bottom:0;
|
| 482 |
+
background:url('{_asset_url(scene_img)}') center/cover no-repeat;
|
| 483 |
+
border-bottom:1px solid rgba(106,48,64,0.4);
|
| 484 |
+
position:relative;flex-shrink:0;"
|
| 485 |
+
>
|
| 486 |
+
<div style="position:absolute;inset:0;background:linear-gradient(to top,rgba(6,5,8,0.95) 0%,rgba(6,5,8,0.3) 50%,transparent 100%);"></div>
|
| 487 |
+
<div style="position:absolute;inset:0;background:linear-gradient(to right,rgba(6,5,8,0.4) 0%,transparent 30%,transparent 70%,rgba(6,5,8,0.4) 100%);"></div>
|
| 488 |
+
</div>
|
| 489 |
+
"""
|
| 490 |
+
|
| 491 |
+
|
| 492 |
+
def render_suggestions(suggestions: list) -> str:
|
| 493 |
+
"""Three action suggestions — Elder Scrolls decision scroll."""
|
| 494 |
+
if not suggestions or len(suggestions) < 3:
|
| 495 |
+
suggestions = ["Look around carefully", "Search for something useful", "Do something unexpected"]
|
| 496 |
+
|
| 497 |
+
buttons = ""
|
| 498 |
+
for i, sug in enumerate(suggestions):
|
| 499 |
+
roman = ["I", "II", "III", "IV", "V"][i] if i < 5 else str(i+1)
|
| 500 |
+
buttons += f"""
|
| 501 |
+
<button class="suggestion-btn" data-action="{sug}"
|
| 502 |
+
style="display:flex;align-items:flex-start;gap:10px;width:100%;text-align:left;margin:0 0 6px 0;padding:10px 14px;
|
| 503 |
+
background:rgba(18,12,20,0.7);border:1px solid #5a3040;border-left:2px solid #904050;color:#d0b0a0;cursor:pointer;transition:all 0.2s;
|
| 504 |
+
font-family:'IM Fell English',serif;font-style:italic;font-size:13px;line-height:1.5;"
|
| 505 |
+
onmouseover="this.style.borderColor='#c06070'; this.style.backgroundColor='rgba(40,18,28,0.8)'; this.style.color='#e0c0b0';"
|
| 506 |
+
onmouseout="this.style.borderColor='#5a3040'; this.style.backgroundColor='rgba(18,12,20,0.7)'; this.style.color='#d0b0a0';"
|
| 507 |
+
>
|
| 508 |
+
<span style="color:#904050;min-width:18px;font-size:10px;margin-top:1px;font-family:'Cinzel',serif;">{roman}.</span>
|
| 509 |
+
<span>{sug}</span>
|
| 510 |
+
</button>
|
| 511 |
+
"""
|
| 512 |
+
|
| 513 |
+
return f'''<div style="width:100%;">
|
| 514 |
+
<div class="suggestion-section-label">— Choose Your Action —</div>
|
| 515 |
+
<div style="padding:6px 14px 10px;display:flex;flex-direction:column;gap:4px;">{buttons}</div>
|
| 516 |
+
</div>'''
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
def render_star_stream(state: dict, new_reactions: list = None) -> str:
|
| 520 |
+
"""Right panel — Star Stream as an ancient chronicle scroll."""
|
| 521 |
+
history = state.get("star_stream_history", [])
|
| 522 |
+
display_reactions = history[-15:]
|
| 523 |
+
|
| 524 |
+
messages_html = ""
|
| 525 |
+
for r in display_reactions:
|
| 526 |
+
modifier = r.get("modifier", "Unknown Constellation")
|
| 527 |
+
reaction = r.get("reaction", "...")
|
| 528 |
+
coins = r.get("coins", 0)
|
| 529 |
+
|
| 530 |
+
mood = "neutral"
|
| 531 |
+
if any(w in modifier for w in ["Demonic", "Abyssal", "Dark", "Death"]): mood = "negative"
|
| 532 |
+
if any(w in modifier for w in ["Judge", "Salvation", "Light", "Star"]): mood = "positive"
|
| 533 |
+
|
| 534 |
+
coin_html = f'<div class="chat-donation" style="font-family:\'Cinzel\',serif;font-size:10px;letter-spacing:1px;">+ {coins} Coins</div>' if coins > 0 else ""
|
| 535 |
+
|
| 536 |
+
messages_html += f"""
|
| 537 |
+
<div class="chat-message" data-mood="{mood}">
|
| 538 |
+
<span class="constellation-name">{modifier}</span>
|
| 539 |
+
<span style="color:#8a7050;font-family:\'IM Fell English\',serif;font-style:italic;font-size:12px;line-height:1.5;">{reaction}</span>
|
| 540 |
+
{coin_html}
|
| 541 |
+
</div>
|
| 542 |
+
"""
|
| 543 |
+
|
| 544 |
+
if not messages_html:
|
| 545 |
+
messages_html = """
|
| 546 |
+
<div class="chat-message" data-mood="neutral" style="opacity:0.5;">
|
| 547 |
+
<span style="font-family:'IM Fell English',serif;font-style:italic;font-size:12px;color:#5a4a35;">The Constellations observe in silence...</span>
|
| 548 |
+
</div>
|
| 549 |
+
"""
|
| 550 |
+
|
| 551 |
+
return f"""
|
| 552 |
+
<div style="width:100%;display:flex;flex-direction:column;height:100%;">
|
| 553 |
+
<div style="font-family:'Cinzel Decorative',serif;color:#904858;font-size:11px;letter-spacing:3px;text-transform:uppercase;margin-bottom:10px;text-shadow:0 0 12px rgba(201,168,76,0.4);">
|
| 554 |
+
✦ Star Stream
|
| 555 |
+
</div>
|
| 556 |
+
<div style="height:1px;background:linear-gradient(90deg,#6a3040,transparent);margin-bottom:12px;"></div>
|
| 557 |
+
<div class="constellation-chat" id="star-stream-chat">
|
| 558 |
+
{messages_html}
|
| 559 |
+
</div>
|
| 560 |
+
</div>
|
| 561 |
+
"""
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
def render_overlay(overlay_type: str = "", data: dict = None) -> str:
|
| 565 |
+
"""Fullscreen overlays — Elder Scrolls stone tablet style."""
|
| 566 |
+
if not overlay_type:
|
| 567 |
+
return '<div class="fullscreen-overlay" style="display:none;"></div>'
|
| 568 |
+
|
| 569 |
+
if overlay_type == "scenario_announcement":
|
| 570 |
+
scenario = data or {}
|
| 571 |
+
announcement = get_scenario_announcement_text(scenario)
|
| 572 |
+
intro = scenario.get("intro_narrative", "")
|
| 573 |
+
return f"""
|
| 574 |
+
<div class="fullscreen-overlay scenario-flash" id="game-overlay" data-auto-dismiss="6000">
|
| 575 |
+
<div class="scenario-announcement fade-in-up" style="max-width:640px;">
|
| 576 |
+
<div style="font-family:'Cinzel',serif;color:#6a3040;font-size:11px;letter-spacing:3px;margin-bottom:16px;">MAIN SCENARIO #{scenario.get('number', '?')}</div>
|
| 577 |
+
<div style="font-family:'Cinzel Decorative',serif;color:#f0c840;font-size:1.8em;font-weight:700;letter-spacing:4px;text-shadow:0 0 30px rgba(240,200,64,0.4);margin-bottom:20px;">SCENARIO BEGINS</div>
|
| 578 |
+
<div style="height:1px;background:linear-gradient(90deg,transparent,#904858,transparent);margin-bottom:20px;"></div>
|
| 579 |
+
<pre style="font-family:'IM Fell English',serif;color:#d4c090;text-align:left;white-space:pre-wrap;max-width:500px;margin:0 auto 20px;font-style:italic;font-size:13px;line-height:1.8;">{announcement}</pre>
|
| 580 |
+
<div style="font-family:'IM Fell English',serif;font-style:italic;color:#b8a07a;font-size:14px;line-height:1.8;max-width:560px;margin:0 auto 24px;">{intro}</div>
|
| 581 |
+
<div style="font-family:'Cinzel',serif;font-size:10px;color:#5a4a35;letter-spacing:3px;">✦ Click to Continue ✦</div>
|
| 582 |
+
</div>
|
| 583 |
+
</div>
|
| 584 |
+
"""
|
| 585 |
+
|
| 586 |
+
elif overlay_type == "ranking":
|
| 587 |
+
rank = data.get("rank", "C") if data else "C"
|
| 588 |
+
rank_colors = {"S": "#f0c840", "A": "#904858", "B": "#7a8fbb", "C": "#8a7050"}
|
| 589 |
+
color = rank_colors.get(rank, "#8a7050")
|
| 590 |
+
bg_img = _asset_url("rank_s_bg.png") if rank == "S" else ""
|
| 591 |
+
bg_style = f"background:url('{bg_img}') center/cover no-repeat,#060402;" if bg_img else "background:#060402;"
|
| 592 |
+
return f"""
|
| 593 |
+
<div class="fullscreen-overlay" id="game-overlay" data-auto-dismiss="4000" style="{bg_style}">
|
| 594 |
+
<div class="ranking-display rank-{rank.lower()}" style="text-align:center;">
|
| 595 |
+
<div style="font-family:'Cinzel',serif;color:#6a3040;font-size:12px;letter-spacing:4px;margin-bottom:16px;">SCENARIO COMPLETE</div>
|
| 596 |
+
<div style="height:1px;width:200px;margin:0 auto 20px;background:linear-gradient(90deg,transparent,#904858,transparent);"></div>
|
| 597 |
+
<div style="font-size:7em;font-family:'Cinzel Decorative',serif;font-weight:900;color:{color};text-shadow:0 0 60px {color},0 0 120px {color}40;">{rank}</div>
|
| 598 |
+
<div style="font-family:'Cinzel',serif;font-size:14px;letter-spacing:4px;color:#904858;margin-top:16px;">RANK {rank} — BATTLE CONCLUDED</div>
|
| 599 |
+
</div>
|
| 600 |
+
</div>
|
| 601 |
+
"""
|
| 602 |
+
|
| 603 |
+
elif overlay_type == "reader_noticed":
|
| 604 |
+
return f"""
|
| 605 |
+
<div class="fullscreen-overlay reader-noticed glitch-effect static-noise" id="game-overlay">
|
| 606 |
+
<div style="text-align:center;max-width:600px;padding:40px;border:1px solid #8b1a1a;background:linear-gradient(160deg,#1a0808,#0e0404);position:relative;">
|
| 607 |
+
<div style="position:absolute;top:-1px;left:-1px;width:24px;height:24px;border-top:2px solid #cc3333;border-left:2px solid #cc3333;"></div>
|
| 608 |
+
<div style="position:absolute;bottom:-1px;right:-1px;width:24px;height:24px;border-bottom:2px solid #cc3333;border-right:2px solid #cc3333;"></div>
|
| 609 |
+
<div class="glitch-text" style="font-family:'Cinzel',serif;font-size:11px;letter-spacing:3px;color:#8b1a1a;margin-bottom:20px;">PROBABILITY DISTORTION DETECTED</div>
|
| 610 |
+
<div class="glitch-text" style="font-family:'Cinzel Decorative',serif;font-size:2.4em;font-weight:900;color:#e8d5a3;margin:16px 0;text-shadow:0 0 30px rgba(139,26,26,0.6);">THE READER<br>HAS BEEN NOTICED</div>
|
| 611 |
+
<div style="height:1px;background:linear-gradient(90deg,transparent,#8b1a1a,transparent);margin:20px 0;"></div>
|
| 612 |
+
<div style="font-family:'Cinzel',serif;font-size:12px;color:#cc3333;letter-spacing:3px;">⚠Hidden Scenario Unlocked — Difficulty: Impossible</div>
|
| 613 |
+
</div>
|
| 614 |
+
</div>
|
| 615 |
+
"""
|
| 616 |
+
|
| 617 |
+
elif overlay_type == "game_over":
|
| 618 |
+
reason = data.get("reason", "You have died.") if data else "You have died."
|
| 619 |
+
return f"""
|
| 620 |
+
<div class="fullscreen-overlay" id="game-overlay">
|
| 621 |
+
<div style="text-align:center;max-width:520px;padding:48px;border:1px solid #5a0808;background:linear-gradient(160deg,#150808,#0a0404);position:relative;">
|
| 622 |
+
<div style="position:absolute;top:-1px;left:-1px;width:22px;height:22px;border-top:2px solid #8b1a1a;border-left:2px solid #8b1a1a;"></div>
|
| 623 |
+
<div style="position:absolute;bottom:-1px;right:-1px;width:22px;height:22px;border-bottom:2px solid #8b1a1a;border-right:2px solid #8b1a1a;"></div>
|
| 624 |
+
<div style="font-family:'Cinzel Decorative',serif;font-size:2em;color:#8b1a1a;font-weight:900;text-shadow:0 0 30px rgba(139,26,26,0.5);margin-bottom:20px;">INCARNATION ELIMINATED</div>
|
| 625 |
+
<div style="height:1px;background:linear-gradient(90deg,transparent,#5a0808,transparent);margin-bottom:20px;"></div>
|
| 626 |
+
<div style="font-family:'IM Fell English',serif;font-style:italic;color:#b8a07a;font-size:15px;line-height:1.8;margin-bottom:20px;">{reason}</div>
|
| 627 |
+
<div style="font-family:'Cinzel',serif;font-size:10px;color:#5a4a35;letter-spacing:2px;">The Constellations have lost interest.</div>
|
| 628 |
+
</div>
|
| 629 |
+
</div>
|
| 630 |
+
"""
|
| 631 |
+
|
| 632 |
+
return '<div class="fullscreen-overlay" style="display:none;"></div>'
|
| 633 |
+
|
| 634 |
+
|
| 635 |
+
def render_state_data(state: dict) -> str:
|
| 636 |
+
"""Hidden element that passes state JSON to JavaScript for effects."""
|
| 637 |
+
return f'<div id="game-state-data" style="display:none;" data-state=\'{json.dumps(state)}\'></div>'
|
| 638 |
+
|
| 639 |
+
def buy_item_action(item_selection: str, state: dict):
|
| 640 |
+
if not item_selection or not state:
|
| 641 |
+
return state, render_player_panel(state)
|
| 642 |
+
item_map = {
|
| 643 |
+
"Minor HP Potion (15c)": ("Minor HP Potion", 15),
|
| 644 |
+
"HP Potion (30c)": ("HP Potion", 30),
|
| 645 |
+
"Major HP Potion (60c)": ("Major HP Potion", 60),
|
| 646 |
+
"Star Stream Flare (100c)": ("Star Stream Flare", 100),
|
| 647 |
+
"Unbroken Faith (150c)": ("Unbroken Faith", 150),
|
| 648 |
+
"Probability Nullifier (200c)": ("Probability Nullifier", 200),
|
| 649 |
+
"Random Attribute Box (250c)": ("Random Attribute Box", 250)
|
| 650 |
+
}
|
| 651 |
+
if item_selection not in item_map:
|
| 652 |
+
return state, render_player_panel(state)
|
| 653 |
+
|
| 654 |
+
item_name, cost = item_map[item_selection]
|
| 655 |
+
gs = GameState.from_dict(state)
|
| 656 |
+
if gs.buy_item(item_name, cost):
|
| 657 |
+
return gs.to_dict(), render_player_panel(gs.to_dict())
|
| 658 |
+
return state, render_player_panel(state)
|
| 659 |
+
|
| 660 |
+
def use_skill_action(skill_selection: str, current_input: str, state: dict):
|
| 661 |
+
if not skill_selection or not state:
|
| 662 |
+
return state, current_input, gr.update()
|
| 663 |
+
|
| 664 |
+
gs = GameState.from_dict(state)
|
| 665 |
+
skill_data = gs.state["skills"].get(skill_selection)
|
| 666 |
+
if skill_data and skill_data.get("cooldown", 0) <= 0:
|
| 667 |
+
skill_data["cooldown"] = skill_data.get("max_cooldown", 3)
|
| 668 |
+
new_text = f"[Activating Skill: {skill_selection}] - " + (current_input or "")
|
| 669 |
+
available_skills = [s for s, d in gs.state["skills"].items() if d.get("cooldown", 0) <= 0]
|
| 670 |
+
return gs.to_dict(), new_text, gr.update(choices=available_skills)
|
| 671 |
+
|
| 672 |
+
return state, current_input, gr.update()
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
# â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â���â”â”â”â”â”â”â”â”â”â”â”
|
| 676 |
+
# GAME LOGIC — Event handlers
|
| 677 |
+
# â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
def start_game():
|
| 681 |
+
"""Initialize game state and trigger Scenario 1 intro."""
|
| 682 |
+
gs = GameState()
|
| 683 |
+
state = gs.to_dict()
|
| 684 |
+
scenario = SCENARIOS[1]
|
| 685 |
+
|
| 686 |
+
# Generate intro via AI (using Groq for speed)
|
| 687 |
+
system_prompt = build_intro_prompt(state, scenario)
|
| 688 |
+
ai_response = call_dokkaebi(system_prompt, "The scenario begins. Introduce yourself and set the scene.")
|
| 689 |
+
|
| 690 |
+
# Build initial UI
|
| 691 |
+
narrative = ai_response.get("narrative", scenario["intro_narrative"])
|
| 692 |
+
dokkaebi_comment = ai_response.get("dokkaebi_comment", "Incarnation. Your scenario begins now.")
|
| 693 |
+
suggestions = ai_response.get("suggestions", [
|
| 694 |
+
"Look around the subway car",
|
| 695 |
+
"Try to calm the panicking passengers",
|
| 696 |
+
"Search for an exit immediately"
|
| 697 |
+
])
|
| 698 |
+
constellation_reactions = ai_response.get("constellation_reactions", [])
|
| 699 |
+
|
| 700 |
+
# Add initial reactions to star stream
|
| 701 |
+
if constellation_reactions:
|
| 702 |
+
for r in constellation_reactions[-5:]:
|
| 703 |
+
state["star_stream_history"].append(r)
|
| 704 |
+
|
| 705 |
+
available_skills = [s for s, d in state.get("skills", {}).items() if d.get("cooldown", 0) <= 0]
|
| 706 |
+
|
| 707 |
+
return (
|
| 708 |
+
state, # game state
|
| 709 |
+
gr.update(visible=False), # hide start screen
|
| 710 |
+
gr.update(visible=False), # hide start button
|
| 711 |
+
gr.update(visible=True), # show game header
|
| 712 |
+
gr.update(visible=True), # show game main
|
| 713 |
+
gr.update(visible=True), # show input bar
|
| 714 |
+
render_scenario_header(state), # scenario header
|
| 715 |
+
render_player_panel(state), # player panel
|
| 716 |
+
render_meters(state), # meters
|
| 717 |
+
render_scene(state), # scene image
|
| 718 |
+
render_narrative(narrative, dokkaebi_comment), # narrative
|
| 719 |
+
render_suggestions(suggestions), # suggestions
|
| 720 |
+
render_star_stream(state, constellation_reactions), # star stream
|
| 721 |
+
render_overlay("scenario_announcement", scenario), # show scenario announcement
|
| 722 |
+
render_state_data(state), # state for JS
|
| 723 |
+
"", # clear input
|
| 724 |
+
gr.update(choices=available_skills), # update active skills dropdown
|
| 725 |
+
)
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
def take_action(stance: str, player_action: str, state: dict):
|
| 729 |
+
"""Process a player action and update the game."""
|
| 730 |
+
if not state:
|
| 731 |
+
# Game not started yet — return no-ops for all 11 outputs
|
| 732 |
+
return [state] + [gr.update()] * 9 + ["", gr.update()]
|
| 733 |
+
|
| 734 |
+
if not player_action or not player_action.strip():
|
| 735 |
+
# Empty input — do nothing
|
| 736 |
+
return (
|
| 737 |
+
state,
|
| 738 |
+
render_scenario_header(state),
|
| 739 |
+
render_player_panel(state),
|
| 740 |
+
render_meters(state),
|
| 741 |
+
render_scene(state),
|
| 742 |
+
gr.update(), # keep current narrative
|
| 743 |
+
gr.update(), # keep current suggestions
|
| 744 |
+
render_star_stream(state),
|
| 745 |
+
render_overlay(), # no overlay
|
| 746 |
+
render_state_data(state),
|
| 747 |
+
"",
|
| 748 |
+
gr.update()
|
| 749 |
+
)
|
| 750 |
+
|
| 751 |
+
player_action = player_action.strip()
|
| 752 |
+
if stance and stance != "Neutral":
|
| 753 |
+
action_for_prompt = f"[{stance} Stance] {player_action}"
|
| 754 |
+
else:
|
| 755 |
+
action_for_prompt = player_action
|
| 756 |
+
|
| 757 |
+
# Get current scenario
|
| 758 |
+
scenario_num = state.get("scenario_num", 1)
|
| 759 |
+
scenario = SCENARIOS.get(scenario_num, SCENARIOS[1])
|
| 760 |
+
|
| 761 |
+
# Build prompt and call AI
|
| 762 |
+
system_prompt = build_prompt(state, action_for_prompt, scenario)
|
| 763 |
+
# Payload note: {"max_tokens": 500} for speed
|
| 764 |
+
ai_response = call_dokkaebi(system_prompt, action_for_prompt)
|
| 765 |
+
|
| 766 |
+
# Process the turn (update game state)
|
| 767 |
+
prev_hp = state.get("hp", 100)
|
| 768 |
+
prev_meta = state.get("meta_exposure", 0)
|
| 769 |
+
updated_state = process_turn(state, action_for_prompt, ai_response, stance)
|
| 770 |
+
|
| 771 |
+
# Determine overlay
|
| 772 |
+
overlay_html = render_overlay() # default: no overlay
|
| 773 |
+
|
| 774 |
+
# Check for Reader Noticed
|
| 775 |
+
if updated_state.get("meta_exposure", 0) >= 100 and prev_meta < 100:
|
| 776 |
+
overlay_html = render_overlay("reader_noticed")
|
| 777 |
+
# Check for scenario completion
|
| 778 |
+
elif ai_response.get("scenario_complete"):
|
| 779 |
+
rank = ai_response.get("scenario_rank", "C")
|
| 780 |
+
overlay_html = render_overlay("ranking", {"rank": rank})
|
| 781 |
+
# After a moment, show next scenario announcement
|
| 782 |
+
next_num = updated_state.get("scenario_num", scenario_num)
|
| 783 |
+
if next_num != scenario_num and next_num in SCENARIOS:
|
| 784 |
+
# The ranking overlay will auto-dismiss, then we need the next scenario
|
| 785 |
+
pass # Handled on next turn start
|
| 786 |
+
# Check for game over
|
| 787 |
+
elif updated_state.get("game_over"):
|
| 788 |
+
overlay_html = render_overlay("game_over", {
|
| 789 |
+
"reason": updated_state.get("game_over_reason", "You have died.")
|
| 790 |
+
})
|
| 791 |
+
# Check for big moment
|
| 792 |
+
elif ai_response.get("big_moment"):
|
| 793 |
+
# For now, use reader_noticed image as a "big moment" visual
|
| 794 |
+
pass
|
| 795 |
+
|
| 796 |
+
# Extract display data from AI response
|
| 797 |
+
narrative = ai_response.get("narrative", "Something happens...")
|
| 798 |
+
dokkaebi_comment = ai_response.get("dokkaebi_comment", "...")
|
| 799 |
+
reality_subversion = ai_response.get("reality_subversion")
|
| 800 |
+
suggestions = ai_response.get("suggestions", [])
|
| 801 |
+
constellation_reactions = ai_response.get("constellation_reactions", [])
|
| 802 |
+
|
| 803 |
+
# Add damage flash trigger data
|
| 804 |
+
took_damage = updated_state.get("hp", 100) < prev_hp
|
| 805 |
+
meta_increased = updated_state.get("meta_exposure", 0) > prev_meta
|
| 806 |
+
|
| 807 |
+
# Build JS trigger data
|
| 808 |
+
js_triggers = {
|
| 809 |
+
"damage": took_damage,
|
| 810 |
+
"meta_up": meta_increased,
|
| 811 |
+
"coins_gained": ai_response.get("entertainment_score", 0) > 5,
|
| 812 |
+
"big_moment": ai_response.get("big_moment", False),
|
| 813 |
+
}
|
| 814 |
+
trigger_data = json.dumps(js_triggers)
|
| 815 |
+
|
| 816 |
+
# Inject JS triggers into state data element
|
| 817 |
+
state_with_triggers = (
|
| 818 |
+
f'<div id="game-state-data" style="display:none;" '
|
| 819 |
+
f'data-state=\'{json.dumps(updated_state)}\' '
|
| 820 |
+
f'data-triggers=\'{trigger_data}\'></div>'
|
| 821 |
+
)
|
| 822 |
+
|
| 823 |
+
available_skills = [s for s, d in updated_state.get("skills", {}).items() if d.get("cooldown", 0) <= 0]
|
| 824 |
+
|
| 825 |
+
return (
|
| 826 |
+
updated_state, # updated state
|
| 827 |
+
render_scenario_header(updated_state), # scenario header
|
| 828 |
+
render_player_panel(updated_state), # player panel
|
| 829 |
+
render_meters(updated_state), # meters
|
| 830 |
+
render_scene(updated_state), # scene
|
| 831 |
+
render_narrative(narrative, dokkaebi_comment, reality_subversion), # narrative
|
| 832 |
+
render_suggestions(suggestions), # suggestions
|
| 833 |
+
render_star_stream(updated_state, constellation_reactions), # star stream
|
| 834 |
+
overlay_html, # overlay
|
| 835 |
+
state_with_triggers, # state data for JS
|
| 836 |
+
"", # clear input
|
| 837 |
+
gr.update(choices=available_skills), # update active skills dropdown
|
| 838 |
+
)
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
# ——————————————————————————————————————————————————————————————————————————————
|
| 842 |
+
# GRADIO APP — The UI
|
| 843 |
+
# ——————————————————————————————————————————————————————————————————————————————
|
| 844 |
+
|
| 845 |
+
# Combine all CSS (no duplicates)
|
| 846 |
+
_ALL_CSS = _STYLE_CSS + "\n" + _ANIMATIONS_CSS
|
| 847 |
+
|
| 848 |
+
# Head injection: only the game JS script (CSS already in _ALL_CSS)
|
| 849 |
+
_HEAD_HTML = f"""
|
| 850 |
+
<script>{_GAME_JS}</script>
|
| 851 |
+
"""
|
| 852 |
+
|
| 853 |
+
# Cinematic overlay HTML — injected as gr.HTML in the body so it actually renders
|
| 854 |
+
_CINEMATIC_HTML = f"""
|
| 855 |
+
<div id="orv-cinematic-overlay">
|
| 856 |
+
|
| 857 |
+
<!-- Video layer -->
|
| 858 |
+
<video id="orv-intro-video" muted playsinline preload="auto"
|
| 859 |
+
src="{_asset_url('intro_video.mp4')}">
|
| 860 |
+
</video>
|
| 861 |
+
|
| 862 |
+
<!-- Dark vignette over video -->
|
| 863 |
+
<div id="orv-cinematic-vignette"></div>
|
| 864 |
+
|
| 865 |
+
<!-- Skip button (top-right) -->
|
| 866 |
+
<button id="orv-skip-btn" onclick="window._skipCinematic()">
|
| 867 |
+
SKIP <span style="opacity:0.5;margin-left:4px;">▶▶</span>
|
| 868 |
+
</button>
|
| 869 |
+
|
| 870 |
+
<!-- Progress bar (bottom) -->
|
| 871 |
+
<div id="orv-cinematic-progress">
|
| 872 |
+
<div id="orv-cinematic-progress-bar"></div>
|
| 873 |
+
</div>
|
| 874 |
+
|
| 875 |
+
<!-- Loading text (bottom-center) -->
|
| 876 |
+
<div id="orv-cinematic-text">
|
| 877 |
+
Entering the Star Stream
|
| 878 |
+
</div>
|
| 879 |
+
|
| 880 |
+
</div>
|
| 881 |
+
"""
|
| 882 |
+
|
| 883 |
+
|
| 884 |
+
|
| 885 |
+
# This runs AFTER Gradio mounts all Svelte components — guaranteed timing
|
| 886 |
+
_INIT_JS = """
|
| 887 |
+
function() {
|
| 888 |
+
var _gameReady = false;
|
| 889 |
+
var _cinematicDone = false;
|
| 890 |
+
|
| 891 |
+
// ── STYLE THE START BUTTON WITH CINZEL FONT ──
|
| 892 |
+
function styleStartButton() {
|
| 893 |
+
var btn = document.querySelector('#gradio-start-btn button');
|
| 894 |
+
if (!btn) { setTimeout(styleStartButton, 100); return; }
|
| 895 |
+
|
| 896 |
+
var applyFont = function(el) {
|
| 897 |
+
el.style.setProperty('font-family', "'Cinzel', serif", 'important');
|
| 898 |
+
el.style.setProperty('font-size', '14px', 'important');
|
| 899 |
+
el.style.setProperty('font-weight', '700', 'important');
|
| 900 |
+
el.style.setProperty('letter-spacing', '5px', 'important');
|
| 901 |
+
el.style.setProperty('text-transform', 'uppercase', 'important');
|
| 902 |
+
};
|
| 903 |
+
applyFont(btn);
|
| 904 |
+
btn.querySelectorAll('*').forEach(applyFont);
|
| 905 |
+
btn.style.setProperty('color', '#c06878', 'important');
|
| 906 |
+
btn.style.setProperty('background', 'linear-gradient(160deg,#1a0c12 0%,#2a101a 50%,#150a10 100%)', 'important');
|
| 907 |
+
btn.style.setProperty('border', '1px solid #904858', 'important');
|
| 908 |
+
btn.style.setProperty('border-radius', '0', 'important');
|
| 909 |
+
btn.style.setProperty('padding', '18px 64px', 'important');
|
| 910 |
+
btn.style.setProperty('box-shadow', '0 0 30px rgba(144,72,88,0.3)', 'important');
|
| 911 |
+
|
| 912 |
+
btn.addEventListener('mouseenter', function() {
|
| 913 |
+
this.style.setProperty('letter-spacing', '7px', 'important');
|
| 914 |
+
this.style.setProperty('color', '#e8c8d0', 'important');
|
| 915 |
+
});
|
| 916 |
+
btn.addEventListener('mouseleave', function() {
|
| 917 |
+
this.style.setProperty('letter-spacing', '5px', 'important');
|
| 918 |
+
this.style.setProperty('color', '#c06878', 'important');
|
| 919 |
+
});
|
| 920 |
+
|
| 921 |
+
// ── CLICK: START CINEMATIC ──
|
| 922 |
+
btn.addEventListener('click', function() {
|
| 923 |
+
startCinematic();
|
| 924 |
+
});
|
| 925 |
+
}
|
| 926 |
+
styleStartButton();
|
| 927 |
+
|
| 928 |
+
// ── CINEMATIC FLOW ──
|
| 929 |
+
function startCinematic() {
|
| 930 |
+
var overlay = document.getElementById('orv-cinematic-overlay');
|
| 931 |
+
var video = document.getElementById('orv-intro-video');
|
| 932 |
+
var progressBar = document.getElementById('orv-cinematic-progress-bar');
|
| 933 |
+
var skipBtn = document.getElementById('orv-skip-btn');
|
| 934 |
+
|
| 935 |
+
if (!overlay) return;
|
| 936 |
+
|
| 937 |
+
// Show the overlay
|
| 938 |
+
overlay.classList.add('active');
|
| 939 |
+
|
| 940 |
+
// Try to play video
|
| 941 |
+
if (video && video.src && !video.error) {
|
| 942 |
+
video.currentTime = 0;
|
| 943 |
+
var playPromise = video.play();
|
| 944 |
+
if (playPromise) {
|
| 945 |
+
playPromise.catch(function() {
|
| 946 |
+
// Video failed — just show the overlay as loading screen
|
| 947 |
+
});
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
// Update progress bar
|
| 951 |
+
video.addEventListener('timeupdate', function() {
|
| 952 |
+
if (video.duration) {
|
| 953 |
+
var pct = (video.currentTime / video.duration) * 100;
|
| 954 |
+
if (progressBar) progressBar.style.width = pct + '%';
|
| 955 |
+
}
|
| 956 |
+
});
|
| 957 |
+
|
| 958 |
+
// Video ended naturally
|
| 959 |
+
video.addEventListener('ended', function() {
|
| 960 |
+
if (progressBar) progressBar.style.width = '100%';
|
| 961 |
+
finishCinematic();
|
| 962 |
+
});
|
| 963 |
+
|
| 964 |
+
// Show skip button after 2 seconds
|
| 965 |
+
setTimeout(function() {
|
| 966 |
+
if (skipBtn) {
|
| 967 |
+
skipBtn.classList.add('visible');
|
| 968 |
+
}
|
| 969 |
+
}, 2000);
|
| 970 |
+
} else {
|
| 971 |
+
// No video available — just show loading text, auto-dismiss when game ready
|
| 972 |
+
if (skipBtn) skipBtn.style.display = 'none';
|
| 973 |
+
}
|
| 974 |
+
}
|
| 975 |
+
|
| 976 |
+
// ── SKIP / FINISH ──
|
| 977 |
+
window._skipCinematic = function() {
|
| 978 |
+
var video = document.getElementById('orv-intro-video');
|
| 979 |
+
if (video) { video.pause(); }
|
| 980 |
+
finishCinematic();
|
| 981 |
+
};
|
| 982 |
+
|
| 983 |
+
function finishCinematic() {
|
| 984 |
+
if (_cinematicDone) return;
|
| 985 |
+
_cinematicDone = true;
|
| 986 |
+
|
| 987 |
+
var overlay = document.getElementById('orv-cinematic-overlay');
|
| 988 |
+
if (!overlay) return;
|
| 989 |
+
|
| 990 |
+
if (_gameReady) {
|
| 991 |
+
// Game already loaded — fade out immediately
|
| 992 |
+
fadeOutOverlay(overlay);
|
| 993 |
+
} else {
|
| 994 |
+
// Game still loading — show "loading" state, wait
|
| 995 |
+
var text = document.getElementById('orv-cinematic-text');
|
| 996 |
+
if (text) text.textContent = 'Loading Scenario...';
|
| 997 |
+
var skipBtn = document.getElementById('orv-skip-btn');
|
| 998 |
+
if (skipBtn) skipBtn.classList.remove('visible');
|
| 999 |
+
|
| 1000 |
+
// Poll until game is ready
|
| 1001 |
+
var poll = setInterval(function() {
|
| 1002 |
+
if (_gameReady) {
|
| 1003 |
+
clearInterval(poll);
|
| 1004 |
+
fadeOutOverlay(overlay);
|
| 1005 |
+
}
|
| 1006 |
+
}, 200);
|
| 1007 |
+
}
|
| 1008 |
+
}
|
| 1009 |
+
|
| 1010 |
+
function fadeOutOverlay(overlay) {
|
| 1011 |
+
overlay.style.transition = 'opacity 1s ease';
|
| 1012 |
+
overlay.style.opacity = '0';
|
| 1013 |
+
setTimeout(function() {
|
| 1014 |
+
overlay.classList.remove('active');
|
| 1015 |
+
overlay.style.opacity = '';
|
| 1016 |
+
overlay.style.transition = '';
|
| 1017 |
+
}, 1000);
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
// ── DETECT WHEN GAME IS LOADED ──
|
| 1021 |
+
var observer = new MutationObserver(function() {
|
| 1022 |
+
var header = document.querySelector('.game-header');
|
| 1023 |
+
if (header && getComputedStyle(header).display !== 'none') {
|
| 1024 |
+
_gameReady = true;
|
| 1025 |
+
// If cinematic already done (skipped/ended), fade out now
|
| 1026 |
+
if (_cinematicDone) {
|
| 1027 |
+
var overlay = document.getElementById('orv-cinematic-overlay');
|
| 1028 |
+
if (overlay && overlay.classList.contains('active')) {
|
| 1029 |
+
fadeOutOverlay(overlay);
|
| 1030 |
+
}
|
| 1031 |
+
}
|
| 1032 |
+
}
|
| 1033 |
+
});
|
| 1034 |
+
observer.observe(document.body, { attributes: true, subtree: true, attributeFilter: ['style', 'class'] });
|
| 1035 |
+
}
|
| 1036 |
+
"""
|
| 1037 |
+
|
| 1038 |
+
|
| 1039 |
+
with gr.Blocks(
|
| 1040 |
+
title="Omniscient Reader — Scenario Simulator",
|
| 1041 |
+
elem_classes="game-container",
|
| 1042 |
+
) as demo:
|
| 1043 |
+
|
| 1044 |
+
|
| 1045 |
+
# ----- Session State -----
|
| 1046 |
+
game_state = gr.State(value=None)
|
| 1047 |
+
|
| 1048 |
+
# ===== START SCREEN =====
|
| 1049 |
+
start_screen_html = gr.HTML(
|
| 1050 |
+
render_start_screen(),
|
| 1051 |
+
elem_classes="start-screen-container",
|
| 1052 |
+
)
|
| 1053 |
+
start_btn = gr.Button(
|
| 1054 |
+
"⚡ Begin the Scenario",
|
| 1055 |
+
elem_id="gradio-start-btn",
|
| 1056 |
+
elem_classes="start-btn-visible",
|
| 1057 |
+
variant="primary",
|
| 1058 |
+
)
|
| 1059 |
+
|
| 1060 |
+
# ===== CINEMATIC OVERLAY — must be a gr.HTML so it renders in the body =====
|
| 1061 |
+
cinematic_overlay = gr.HTML(_CINEMATIC_HTML, elem_id="cinematic-overlay-wrapper")
|
| 1062 |
+
|
| 1063 |
+
# ===== GAME HEADER (hidden initially) =====
|
| 1064 |
+
with gr.Row(elem_classes="game-header", visible=False) as game_header:
|
| 1065 |
+
scenario_header = gr.HTML(render_scenario_header({"scenario_num": 1, "turn": 1}))
|
| 1066 |
+
|
| 1067 |
+
# ===== MAIN GAME LAYOUT (hidden initially) =====
|
| 1068 |
+
with gr.Row(elem_classes="game-main", visible=False) as game_main:
|
| 1069 |
+
|
| 1070 |
+
# --- LEFT: Player Panel ---
|
| 1071 |
+
with gr.Column(scale=1, min_width=260, elem_classes="player-panel"):
|
| 1072 |
+
player_panel = gr.HTML(render_player_panel(GameState().to_dict()))
|
| 1073 |
+
meters_panel = gr.HTML(render_meters(GameState().to_dict()))
|
| 1074 |
+
with gr.Accordion("Dokkaebi Bag", open=False, elem_classes=["system-window", "shop-accordion"]):
|
| 1075 |
+
shop_item = gr.Dropdown(
|
| 1076 |
+
choices=[
|
| 1077 |
+
"Minor HP Potion (15c)",
|
| 1078 |
+
"HP Potion (30c)",
|
| 1079 |
+
"Major HP Potion (60c)",
|
| 1080 |
+
"Star Stream Flare (100c)",
|
| 1081 |
+
"Unbroken Faith (150c)",
|
| 1082 |
+
"Probability Nullifier (200c)",
|
| 1083 |
+
"Random Attribute Box (250c)"
|
| 1084 |
+
],
|
| 1085 |
+
label="Select Item",
|
| 1086 |
+
show_label=False
|
| 1087 |
+
)
|
| 1088 |
+
shop_buy_btn = gr.Button("💰 Buy Item", elem_classes="suggestion-btn")
|
| 1089 |
+
|
| 1090 |
+
# --- CENTER: Game Area ---
|
| 1091 |
+
with gr.Column(scale=3, min_width=400, elem_classes="game-area"):
|
| 1092 |
+
scene_display = gr.HTML('<div class="scene-placeholder"></div>')
|
| 1093 |
+
narrative_display = gr.HTML(
|
| 1094 |
+
'<div class="system-window"><div class="narrative-text">'
|
| 1095 |
+
'Waiting for scenario to begin...</div></div>'
|
| 1096 |
+
)
|
| 1097 |
+
suggestions_display = gr.HTML(render_suggestions([]))
|
| 1098 |
+
|
| 1099 |
+
# --- RIGHT: Constellation Sidebar ---
|
| 1100 |
+
with gr.Column(scale=1, min_width=280, elem_classes="constellation-panel"):
|
| 1101 |
+
star_stream_display = gr.HTML(
|
| 1102 |
+
render_star_stream(GameState().to_dict())
|
| 1103 |
+
)
|
| 1104 |
+
|
| 1105 |
+
# ===== BOTTOM INPUT BAR (hidden initially) =====
|
| 1106 |
+
with gr.Row(elem_classes="input-bar", visible=False) as input_bar:
|
| 1107 |
+
with gr.Column(scale=1, min_width=150):
|
| 1108 |
+
active_skills_dd = gr.Dropdown(choices=[], label="", show_label=False, elem_classes="game-input", container=False)
|
| 1109 |
+
skill_btn = gr.Button("Use Skill", elem_classes="suggestion-btn")
|
| 1110 |
+
with gr.Column(scale=4):
|
| 1111 |
+
stance_radio = gr.Radio(["Neutral", "Aggressive", "Deceptive", "Empathetic", "Observant"], label="", show_label=False, value="Neutral", container=False)
|
| 1112 |
+
player_input = gr.Textbox(
|
| 1113 |
+
placeholder="What do you do, Incarnation?",
|
| 1114 |
+
label="",
|
| 1115 |
+
elem_classes="game-input",
|
| 1116 |
+
max_lines=2,
|
| 1117 |
+
)
|
| 1118 |
+
submit_btn = gr.Button(
|
| 1119 |
+
"âš¡ Act",
|
| 1120 |
+
elem_classes="act-button",
|
| 1121 |
+
variant="primary",
|
| 1122 |
+
scale=1,
|
| 1123 |
+
)
|
| 1124 |
+
|
| 1125 |
+
# ===== FULLSCREEN OVERLAY =====
|
| 1126 |
+
overlay_display = gr.HTML(
|
| 1127 |
+
render_overlay(),
|
| 1128 |
+
elem_classes="overlay-container",
|
| 1129 |
+
)
|
| 1130 |
+
|
| 1131 |
+
# ===== HIDDEN STATE DATA (for JS) =====
|
| 1132 |
+
state_data = gr.HTML(
|
| 1133 |
+
'<div id="game-state-data" style="display:none;"></div>',
|
| 1134 |
+
visible=False,
|
| 1135 |
+
)
|
| 1136 |
+
|
| 1137 |
+
# ----- Event Wiring -----
|
| 1138 |
+
|
| 1139 |
+
# Start button → initialize game
|
| 1140 |
+
start_btn.click(
|
| 1141 |
+
fn=start_game,
|
| 1142 |
+
inputs=[],
|
| 1143 |
+
outputs=[
|
| 1144 |
+
game_state,
|
| 1145 |
+
start_screen_html,
|
| 1146 |
+
start_btn,
|
| 1147 |
+
game_header,
|
| 1148 |
+
game_main,
|
| 1149 |
+
input_bar,
|
| 1150 |
+
scenario_header,
|
| 1151 |
+
player_panel,
|
| 1152 |
+
meters_panel,
|
| 1153 |
+
scene_display,
|
| 1154 |
+
narrative_display,
|
| 1155 |
+
suggestions_display,
|
| 1156 |
+
star_stream_display,
|
| 1157 |
+
overlay_display,
|
| 1158 |
+
state_data,
|
| 1159 |
+
player_input,
|
| 1160 |
+
active_skills_dd,
|
| 1161 |
+
],
|
| 1162 |
+
)
|
| 1163 |
+
|
| 1164 |
+
# Submit button → process action
|
| 1165 |
+
submit_btn.click(
|
| 1166 |
+
fn=take_action,
|
| 1167 |
+
inputs=[stance_radio, player_input, game_state],
|
| 1168 |
+
outputs=[
|
| 1169 |
+
game_state,
|
| 1170 |
+
scenario_header,
|
| 1171 |
+
player_panel,
|
| 1172 |
+
meters_panel,
|
| 1173 |
+
scene_display,
|
| 1174 |
+
narrative_display,
|
| 1175 |
+
suggestions_display,
|
| 1176 |
+
star_stream_display,
|
| 1177 |
+
overlay_display,
|
| 1178 |
+
state_data,
|
| 1179 |
+
player_input,
|
| 1180 |
+
active_skills_dd,
|
| 1181 |
+
],
|
| 1182 |
+
)
|
| 1183 |
+
|
| 1184 |
+
# Enter key also submits
|
| 1185 |
+
player_input.submit(
|
| 1186 |
+
fn=take_action,
|
| 1187 |
+
inputs=[stance_radio, player_input, game_state],
|
| 1188 |
+
outputs=[
|
| 1189 |
+
game_state,
|
| 1190 |
+
scenario_header,
|
| 1191 |
+
player_panel,
|
| 1192 |
+
meters_panel,
|
| 1193 |
+
scene_display,
|
| 1194 |
+
narrative_display,
|
| 1195 |
+
suggestions_display,
|
| 1196 |
+
star_stream_display,
|
| 1197 |
+
overlay_display,
|
| 1198 |
+
state_data,
|
| 1199 |
+
player_input,
|
| 1200 |
+
active_skills_dd,
|
| 1201 |
+
],
|
| 1202 |
+
)
|
| 1203 |
+
|
| 1204 |
+
# Buy Item logic
|
| 1205 |
+
shop_buy_btn.click(
|
| 1206 |
+
fn=buy_item_action,
|
| 1207 |
+
inputs=[shop_item, game_state],
|
| 1208 |
+
outputs=[game_state, player_panel]
|
| 1209 |
+
)
|
| 1210 |
+
|
| 1211 |
+
# Use Skill logic
|
| 1212 |
+
skill_btn.click(
|
| 1213 |
+
fn=use_skill_action,
|
| 1214 |
+
inputs=[active_skills_dd, player_input, game_state],
|
| 1215 |
+
outputs=[game_state, player_input, active_skills_dd]
|
| 1216 |
+
)
|
| 1217 |
+
|
| 1218 |
+
|
| 1219 |
+
# â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”
|
| 1220 |
+
# INJECT CSS & JS — Works on both local dev and HF Spaces
|
| 1221 |
+
# In Gradio 6, css and head are set on the demo object or passed to launch().
|
| 1222 |
+
# Setting them here ensures HF Spaces (which calls launch() internally) picks them up.
|
| 1223 |
+
# â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”â”
|
| 1224 |
+
demo.css = _ALL_CSS
|
| 1225 |
+
demo.head = _HEAD_HTML
|
| 1226 |
+
|
| 1227 |
+
if __name__ == "__main__":
|
| 1228 |
+
import time
|
| 1229 |
+
|
| 1230 |
+
# allowed_paths: include both forward-slash and native paths
|
| 1231 |
+
# Gradio 6 resolves to forward slashes internally on some routes
|
| 1232 |
+
_abs_assets = os.path.abspath(_ASSET_DIR)
|
| 1233 |
+
_fwd_assets = _abs_assets.replace("\\", "/")
|
| 1234 |
+
demo.launch(
|
| 1235 |
+
server_name="0.0.0.0",
|
| 1236 |
+
server_port=7860,
|
| 1237 |
+
allowed_paths=[_abs_assets, _fwd_assets, os.path.dirname(_abs_assets)],
|
| 1238 |
+
show_error=True,
|
| 1239 |
+
prevent_thread_lock=True,
|
| 1240 |
+
css=_ALL_CSS,
|
| 1241 |
+
head=_HEAD_HTML,
|
| 1242 |
+
js=_INIT_JS,
|
| 1243 |
+
)
|
| 1244 |
+
|
| 1245 |
+
print("ORV Scenario Simulator running at http://localhost:7860")
|
| 1246 |
+
|
| 1247 |
+
try:
|
| 1248 |
+
while True:
|
| 1249 |
+
time.sleep(1)
|
| 1250 |
+
except KeyboardInterrupt:
|
| 1251 |
+
pass
|
| 1252 |
+
|
assets/destroyed_office.png
ADDED
|
Git LFS Details
|
assets/dokja_portrait.png
ADDED
|
Git LFS Details
|
assets/dokkaebi_character.png
ADDED
|
Git LFS Details
|
assets/han_river_bridge.png
ADDED
|
Git LFS Details
|
assets/intro_video.mp4
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:57af9f8db99e8c5cf53e2fc46560e8d72991d47d98b8945569d91192f9216371
|
| 3 |
+
size 44349634
|
assets/jung_heewon_portrait.png
ADDED
|
Git LFS Details
|
assets/lee_hyunsung_portrait.png
ADDED
|
Git LFS Details
|
assets/rank_s_bg.png
ADDED
|
Git LFS Details
|
assets/reader_noticed.png
ADDED
|
Git LFS Details
|
assets/seoul_street.png
ADDED
|
Git LFS Details
|
assets/seoul_subway.png
ADDED
|
Git LFS Details
|
assets/star_stream_bg.png
ADDED
|
Git LFS Details
|
assets/title_banner.png
ADDED
|
Git LFS Details
|
assets/yoo_sangah_portrait.png
ADDED
|
Git LFS Details
|
game.js
ADDED
|
@@ -0,0 +1,866 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 2 |
+
ORV SCENARIO SIMULATOR — GAME.JS
|
| 3 |
+
Interactive effects, MutationObserver, event delegation
|
| 4 |
+
Vanilla JS · Self-contained IIFE · Gradio-compatible
|
| 5 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 6 |
+
|
| 7 |
+
(function () {
|
| 8 |
+
'use strict';
|
| 9 |
+
|
| 10 |
+
// ── Constants ────────────────────────────────────────────────────
|
| 11 |
+
const TYPEWRITER_SPEED = 30; // ms per character
|
| 12 |
+
const OVERLAY_DISMISS_MS = 5000; // default overlay auto-dismiss
|
| 13 |
+
const SCENARIO_DISMISS_MS = 6000; // scenario announcement duration
|
| 14 |
+
const COIN_RAIN_DEFAULT = 10; // default particle count
|
| 15 |
+
const INIT_RETRY_DELAY = 2000; // retry setup after Gradio loads
|
| 16 |
+
const DEBOUNCE_MS = 100; // observer debounce
|
| 17 |
+
|
| 18 |
+
// ── State ────────────────────────────────────────────────────────
|
| 19 |
+
let _previousHP = null;
|
| 20 |
+
let _previousMeta = null;
|
| 21 |
+
let _typewriterActive = false;
|
| 22 |
+
let _observerSetup = false;
|
| 23 |
+
let _lastNarrativeText = '';
|
| 24 |
+
let _lastChatCount = 0;
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 28 |
+
1. TYPEWRITER EFFECT
|
| 29 |
+
═════════════════════════════════════════════════════════════════ */
|
| 30 |
+
|
| 31 |
+
function applyTypewriter(element) {
|
| 32 |
+
if (!element) return;
|
| 33 |
+
|
| 34 |
+
// Skip if already being typed
|
| 35 |
+
if (element.dataset.typing === 'true') return;
|
| 36 |
+
|
| 37 |
+
const fullHTML = element.innerHTML;
|
| 38 |
+
const fullText = element.textContent || '';
|
| 39 |
+
|
| 40 |
+
// Skip if empty or very short (likely a partial update)
|
| 41 |
+
if (fullText.trim().length < 2) return;
|
| 42 |
+
|
| 43 |
+
// Skip if text hasn't changed
|
| 44 |
+
if (fullText === element.dataset.lastTyped) return;
|
| 45 |
+
|
| 46 |
+
element.dataset.typing = 'true';
|
| 47 |
+
element.dataset.lastTyped = fullText;
|
| 48 |
+
_typewriterActive = true;
|
| 49 |
+
|
| 50 |
+
// Work with text content to avoid breaking HTML mid-tag
|
| 51 |
+
// Strategy: reveal characters by expanding a clip
|
| 52 |
+
const wrapper = document.createElement('span');
|
| 53 |
+
wrapper.className = 'typewriter-wrapper';
|
| 54 |
+
wrapper.innerHTML = fullHTML;
|
| 55 |
+
wrapper.style.visibility = 'hidden';
|
| 56 |
+
|
| 57 |
+
// Measure full content
|
| 58 |
+
element.innerHTML = '';
|
| 59 |
+
element.appendChild(wrapper);
|
| 60 |
+
|
| 61 |
+
// Use character-by-character for plain text,
|
| 62 |
+
// or progressive innerHTML reveal for rich content
|
| 63 |
+
const isRichContent = fullHTML !== fullText;
|
| 64 |
+
|
| 65 |
+
if (isRichContent) {
|
| 66 |
+
// For HTML content: progressive reveal
|
| 67 |
+
_typewriterRichReveal(element, fullHTML, fullText);
|
| 68 |
+
} else {
|
| 69 |
+
// For plain text: character by character
|
| 70 |
+
_typewriterPlainReveal(element, fullText);
|
| 71 |
+
}
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function _typewriterPlainReveal(element, text) {
|
| 75 |
+
element.innerHTML = '';
|
| 76 |
+
let index = 0;
|
| 77 |
+
|
| 78 |
+
const cursor = document.createElement('span');
|
| 79 |
+
cursor.className = 'typewriter-cursor';
|
| 80 |
+
cursor.textContent = '|';
|
| 81 |
+
|
| 82 |
+
function typeNext() {
|
| 83 |
+
if (index < text.length) {
|
| 84 |
+
// Add characters in small chunks for performance
|
| 85 |
+
const chunk = text.substring(index, index + 2);
|
| 86 |
+
element.insertBefore(
|
| 87 |
+
document.createTextNode(chunk),
|
| 88 |
+
cursor
|
| 89 |
+
);
|
| 90 |
+
index += 2;
|
| 91 |
+
element.appendChild(cursor);
|
| 92 |
+
element.scrollTop = element.scrollHeight;
|
| 93 |
+
setTimeout(typeNext, TYPEWRITER_SPEED);
|
| 94 |
+
} else {
|
| 95 |
+
// Typing complete
|
| 96 |
+
element.dataset.typing = 'false';
|
| 97 |
+
_typewriterActive = false;
|
| 98 |
+
// Remove cursor after a delay
|
| 99 |
+
setTimeout(() => {
|
| 100 |
+
if (cursor.parentNode) cursor.remove();
|
| 101 |
+
}, 2000);
|
| 102 |
+
}
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
element.appendChild(cursor);
|
| 106 |
+
typeNext();
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function _typewriterRichReveal(element, html, plainText) {
|
| 110 |
+
// For rich HTML: reveal by setting opacity on a wrapper
|
| 111 |
+
// and revealing character positions via max-width
|
| 112 |
+
element.innerHTML = '';
|
| 113 |
+
|
| 114 |
+
const container = document.createElement('div');
|
| 115 |
+
container.style.cssText = 'overflow:hidden; display:inline;';
|
| 116 |
+
container.innerHTML = html;
|
| 117 |
+
element.appendChild(container);
|
| 118 |
+
|
| 119 |
+
// Reveal the content progressively
|
| 120 |
+
const textNodes = _getTextNodes(container);
|
| 121 |
+
let totalRevealed = 0;
|
| 122 |
+
const totalChars = plainText.length;
|
| 123 |
+
|
| 124 |
+
// Initially hide all text
|
| 125 |
+
textNodes.forEach(node => {
|
| 126 |
+
const span = document.createElement('span');
|
| 127 |
+
span.style.visibility = 'hidden';
|
| 128 |
+
span.textContent = node.textContent;
|
| 129 |
+
span.dataset.fullText = node.textContent;
|
| 130 |
+
span.dataset.revealed = '0';
|
| 131 |
+
node.parentNode.replaceChild(span, node);
|
| 132 |
+
});
|
| 133 |
+
|
| 134 |
+
const hiddenSpans = container.querySelectorAll('span[data-full-text]');
|
| 135 |
+
let currentSpanIndex = 0;
|
| 136 |
+
let currentCharIndex = 0;
|
| 137 |
+
|
| 138 |
+
const cursor = document.createElement('span');
|
| 139 |
+
cursor.className = 'typewriter-cursor';
|
| 140 |
+
cursor.textContent = '|';
|
| 141 |
+
element.appendChild(cursor);
|
| 142 |
+
|
| 143 |
+
function revealNext() {
|
| 144 |
+
if (currentSpanIndex >= hiddenSpans.length) {
|
| 145 |
+
element.dataset.typing = 'false';
|
| 146 |
+
_typewriterActive = false;
|
| 147 |
+
setTimeout(() => {
|
| 148 |
+
if (cursor.parentNode) cursor.remove();
|
| 149 |
+
}, 2000);
|
| 150 |
+
return;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
const span = hiddenSpans[currentSpanIndex];
|
| 154 |
+
const full = span.dataset.fullText;
|
| 155 |
+
|
| 156 |
+
currentCharIndex += 2;
|
| 157 |
+
if (currentCharIndex >= full.length) {
|
| 158 |
+
span.style.visibility = 'visible';
|
| 159 |
+
span.textContent = full;
|
| 160 |
+
currentSpanIndex++;
|
| 161 |
+
currentCharIndex = 0;
|
| 162 |
+
} else {
|
| 163 |
+
span.style.visibility = 'visible';
|
| 164 |
+
span.textContent = full.substring(0, currentCharIndex);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
element.scrollTop = element.scrollHeight;
|
| 168 |
+
setTimeout(revealNext, TYPEWRITER_SPEED);
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
revealNext();
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
function _getTextNodes(element) {
|
| 175 |
+
const nodes = [];
|
| 176 |
+
const walker = document.createTreeWalker(
|
| 177 |
+
element,
|
| 178 |
+
NodeFilter.SHOW_TEXT,
|
| 179 |
+
null,
|
| 180 |
+
false
|
| 181 |
+
);
|
| 182 |
+
let node;
|
| 183 |
+
while ((node = walker.nextNode())) {
|
| 184 |
+
if (node.textContent.trim().length > 0) {
|
| 185 |
+
nodes.push(node);
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
return nodes;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 193 |
+
2. MUTATION OBSERVER SETUP
|
| 194 |
+
═════════════════════════════════════════════════════════════════ */
|
| 195 |
+
|
| 196 |
+
function setupMutationObserver() {
|
| 197 |
+
if (_observerSetup) return;
|
| 198 |
+
|
| 199 |
+
const config = { childList: true, subtree: true, characterData: true };
|
| 200 |
+
let debounceTimer = null;
|
| 201 |
+
|
| 202 |
+
const observer = new MutationObserver((mutations) => {
|
| 203 |
+
// Debounce rapid updates
|
| 204 |
+
clearTimeout(debounceTimer);
|
| 205 |
+
debounceTimer = setTimeout(() => {
|
| 206 |
+
handleMutations(mutations);
|
| 207 |
+
}, DEBOUNCE_MS);
|
| 208 |
+
});
|
| 209 |
+
|
| 210 |
+
// Observe the entire Gradio app container
|
| 211 |
+
const appRoot = document.querySelector('gradio-app') ||
|
| 212 |
+
document.querySelector('.gradio-container') ||
|
| 213 |
+
document.body;
|
| 214 |
+
|
| 215 |
+
observer.observe(appRoot, config);
|
| 216 |
+
_observerSetup = true;
|
| 217 |
+
console.log('[Dokkaebi System] MutationObserver active.');
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
function handleMutations(mutations) {
|
| 221 |
+
// ── Narrative text changed → typewriter ──
|
| 222 |
+
const narrative = document.querySelector('.narrative-text');
|
| 223 |
+
if (narrative) {
|
| 224 |
+
const currentText = narrative.textContent || '';
|
| 225 |
+
if (currentText !== _lastNarrativeText && currentText.trim().length > 0) {
|
| 226 |
+
_lastNarrativeText = currentText;
|
| 227 |
+
applyTypewriter(narrative);
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// ── Constellation chat changed → scroll + slide-in ──
|
| 232 |
+
const chat = document.querySelector('.constellation-chat');
|
| 233 |
+
if (chat) {
|
| 234 |
+
const messages = chat.querySelectorAll('.chat-message');
|
| 235 |
+
if (messages.length > _lastChatCount) {
|
| 236 |
+
// New messages added — animate them
|
| 237 |
+
for (let i = _lastChatCount; i < messages.length; i++) {
|
| 238 |
+
if (!messages[i].classList.contains('slide-in-right')) {
|
| 239 |
+
messages[i].classList.add('slide-in-right');
|
| 240 |
+
}
|
| 241 |
+
}
|
| 242 |
+
_lastChatCount = messages.length;
|
| 243 |
+
scrollStarStream();
|
| 244 |
+
}
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
// ── Fullscreen overlay appeared ──
|
| 248 |
+
const overlay = document.querySelector('.fullscreen-overlay');
|
| 249 |
+
if (overlay && overlay.innerHTML.trim().length > 0) {
|
| 250 |
+
const isVisible = overlay.offsetParent !== null ||
|
| 251 |
+
getComputedStyle(overlay).display !== 'none';
|
| 252 |
+
if (isVisible) {
|
| 253 |
+
// Check if it's a scenario announcement
|
| 254 |
+
if (overlay.querySelector('.scenario-announcement')) {
|
| 255 |
+
showScenarioAnnouncement();
|
| 256 |
+
}
|
| 257 |
+
// Check if it's Reader Noticed (class is on the overlay itself)
|
| 258 |
+
else if (overlay.classList.contains('reader-noticed')) {
|
| 259 |
+
showReaderNoticed();
|
| 260 |
+
}
|
| 261 |
+
// Generic overlay — auto-dismiss
|
| 262 |
+
else {
|
| 263 |
+
showOverlay(OVERLAY_DISMISS_MS);
|
| 264 |
+
}
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
// ── State effects from hidden component ──
|
| 269 |
+
const stateEl = document.getElementById('game-state-data');
|
| 270 |
+
if (stateEl) {
|
| 271 |
+
// Read data-state attribute (Python renders: data-state='...')
|
| 272 |
+
const stateJson = stateEl.dataset.state;
|
| 273 |
+
const triggersJson = stateEl.dataset.triggers;
|
| 274 |
+
if (stateJson && stateJson.trim().startsWith('{')) {
|
| 275 |
+
try {
|
| 276 |
+
handleStateEffects(stateJson, triggersJson);
|
| 277 |
+
} catch (e) { console.warn('[Dokkaebi] State parse error:', e); }
|
| 278 |
+
}
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
// ── Shake class cleanup ──
|
| 282 |
+
if (document.body.classList.contains('shake')) {
|
| 283 |
+
// Will be cleaned up by animationend listener
|
| 284 |
+
}
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 289 |
+
3. EVENT DELEGATION
|
| 290 |
+
═════════════════════════════════════════════════════════════════ */
|
| 291 |
+
|
| 292 |
+
function setupEventDelegation() {
|
| 293 |
+
// ── Suggestion button clicks ──
|
| 294 |
+
document.addEventListener('click', (e) => {
|
| 295 |
+
const btn = e.target.closest('.suggestion-btn');
|
| 296 |
+
if (!btn) return;
|
| 297 |
+
|
| 298 |
+
e.preventDefault();
|
| 299 |
+
e.stopPropagation();
|
| 300 |
+
|
| 301 |
+
const actionText = btn.dataset.action || btn.textContent.trim();
|
| 302 |
+
if (!actionText) return;
|
| 303 |
+
|
| 304 |
+
// Find Gradio textarea
|
| 305 |
+
const textarea = document.querySelector('.game-input textarea') ||
|
| 306 |
+
document.querySelector('.game-input input[type="text"]');
|
| 307 |
+
|
| 308 |
+
if (textarea) {
|
| 309 |
+
// Use native setter to trigger Gradio's reactive binding
|
| 310 |
+
const descriptor = Object.getOwnPropertyDescriptor(
|
| 311 |
+
window.HTMLTextAreaElement.prototype, 'value'
|
| 312 |
+
) || Object.getOwnPropertyDescriptor(
|
| 313 |
+
window.HTMLInputElement.prototype, 'value'
|
| 314 |
+
);
|
| 315 |
+
|
| 316 |
+
if (descriptor && descriptor.set) {
|
| 317 |
+
descriptor.set.call(textarea, actionText);
|
| 318 |
+
} else {
|
| 319 |
+
textarea.value = actionText;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
// Dispatch events Gradio listens for
|
| 323 |
+
textarea.dispatchEvent(new Event('input', { bubbles: true }));
|
| 324 |
+
textarea.dispatchEvent(new Event('change', { bubbles: true }));
|
| 325 |
+
|
| 326 |
+
// Visual feedback on the button
|
| 327 |
+
btn.style.borderColor = '#904858';
|
| 328 |
+
btn.style.boxShadow = '0 0 10px rgba(192,104,120,0.25)';
|
| 329 |
+
btn.style.color = '#c06878';
|
| 330 |
+
|
| 331 |
+
// Click submit after a brief delay
|
| 332 |
+
setTimeout(() => {
|
| 333 |
+
const submitBtn = document.querySelector('.act-button') ||
|
| 334 |
+
document.querySelector('button[type="submit"]');
|
| 335 |
+
if (submitBtn) submitBtn.click();
|
| 336 |
+
|
| 337 |
+
// Reset button style
|
| 338 |
+
setTimeout(() => {
|
| 339 |
+
btn.style.borderColor = '';
|
| 340 |
+
btn.style.boxShadow = '';
|
| 341 |
+
}, 200);
|
| 342 |
+
}, 100);
|
| 343 |
+
}
|
| 344 |
+
});
|
| 345 |
+
|
| 346 |
+
// ── Fullscreen overlay click dismiss ──
|
| 347 |
+
document.addEventListener('click', (e) => {
|
| 348 |
+
const overlay = e.target.closest('.fullscreen-overlay');
|
| 349 |
+
if (overlay && !overlay.classList.contains('reader-noticed')) {
|
| 350 |
+
dismissOverlay(overlay);
|
| 351 |
+
}
|
| 352 |
+
});
|
| 353 |
+
|
| 354 |
+
// ── Shake animation cleanup ──
|
| 355 |
+
document.addEventListener('animationend', (e) => {
|
| 356 |
+
if (e.animationName === 'shake') {
|
| 357 |
+
e.target.classList.remove('shake');
|
| 358 |
+
}
|
| 359 |
+
if (e.animationName === 'damageFlash') {
|
| 360 |
+
e.target.remove();
|
| 361 |
+
}
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
// ── Keyboard shortcut: Enter to submit ──
|
| 365 |
+
document.addEventListener('keydown', (e) => {
|
| 366 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 367 |
+
const textarea = document.querySelector('.game-input textarea');
|
| 368 |
+
if (textarea && document.activeElement === textarea) {
|
| 369 |
+
const value = textarea.value.trim();
|
| 370 |
+
if (value.length > 0) {
|
| 371 |
+
e.preventDefault();
|
| 372 |
+
const submitBtn = document.querySelector('.act-button');
|
| 373 |
+
if (submitBtn) submitBtn.click();
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
}
|
| 377 |
+
});
|
| 378 |
+
|
| 379 |
+
console.log('[Dokkaebi System] Event delegation active.');
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 384 |
+
4. OVERLAY MANAGEMENT
|
| 385 |
+
═════════════════════════════════════════════════════════════════ */
|
| 386 |
+
|
| 387 |
+
function showOverlay(duration) {
|
| 388 |
+
duration = duration || OVERLAY_DISMISS_MS;
|
| 389 |
+
const overlay = document.querySelector('.fullscreen-overlay');
|
| 390 |
+
if (!overlay) return;
|
| 391 |
+
|
| 392 |
+
// Ensure it's visible
|
| 393 |
+
overlay.style.display = 'flex';
|
| 394 |
+
overlay.classList.add('fade-in');
|
| 395 |
+
|
| 396 |
+
// Auto-dismiss
|
| 397 |
+
setTimeout(() => {
|
| 398 |
+
dismissOverlay(overlay);
|
| 399 |
+
}, duration);
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
function dismissOverlay(overlay) {
|
| 403 |
+
overlay = overlay || document.querySelector('.fullscreen-overlay');
|
| 404 |
+
if (!overlay) return;
|
| 405 |
+
|
| 406 |
+
overlay.classList.remove('fade-in');
|
| 407 |
+
overlay.classList.add('fade-out');
|
| 408 |
+
|
| 409 |
+
setTimeout(() => {
|
| 410 |
+
overlay.style.setProperty('display', 'none', 'important');
|
| 411 |
+
overlay.classList.remove('fade-out');
|
| 412 |
+
// Remove the flash class if it was a scenario overlay
|
| 413 |
+
overlay.classList.remove('scenario-flash');
|
| 414 |
+
}, 500);
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
function showScenarioAnnouncement() {
|
| 418 |
+
const overlay = document.querySelector('.fullscreen-overlay');
|
| 419 |
+
if (!overlay) return;
|
| 420 |
+
|
| 421 |
+
// Add flash effect
|
| 422 |
+
overlay.classList.add('scenario-flash');
|
| 423 |
+
overlay.style.display = 'flex';
|
| 424 |
+
|
| 425 |
+
// Play a subtle screen effect
|
| 426 |
+
const announcement = overlay.querySelector('.scenario-announcement');
|
| 427 |
+
if (announcement) {
|
| 428 |
+
announcement.classList.add('scale-up');
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
// Auto-dismiss after duration
|
| 432 |
+
setTimeout(() => {
|
| 433 |
+
dismissOverlay(overlay);
|
| 434 |
+
if (announcement) {
|
| 435 |
+
announcement.classList.remove('scale-up');
|
| 436 |
+
}
|
| 437 |
+
overlay.classList.remove('scenario-flash');
|
| 438 |
+
}, SCENARIO_DISMISS_MS);
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
function showReaderNoticed() {
|
| 442 |
+
// Apply full-screen glitch — DO NOT auto-dismiss
|
| 443 |
+
document.body.classList.add('glitch-effect', 'static-noise');
|
| 444 |
+
|
| 445 |
+
const overlay = document.querySelector('.fullscreen-overlay');
|
| 446 |
+
if (overlay) {
|
| 447 |
+
overlay.style.display = 'flex';
|
| 448 |
+
overlay.classList.add('fade-in');
|
| 449 |
+
}
|
| 450 |
+
|
| 451 |
+
// Shake the screen
|
| 452 |
+
triggerScreenShake();
|
| 453 |
+
|
| 454 |
+
// Apply glitch text to the overlay content
|
| 455 |
+
const content = overlay ? overlay.querySelector('.reader-noticed') : null;
|
| 456 |
+
if (content) {
|
| 457 |
+
content.classList.add('glitch-text');
|
| 458 |
+
}
|
| 459 |
+
|
| 460 |
+
console.log('[Dokkaebi System] ⚠ The Reader has noticed you.');
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 465 |
+
5. STAR STREAM AUTO-SCROLL
|
| 466 |
+
═════════════════════════════════════════════════════════════════ */
|
| 467 |
+
|
| 468 |
+
function scrollStarStream() {
|
| 469 |
+
const chat = document.querySelector('.constellation-chat');
|
| 470 |
+
if (chat) {
|
| 471 |
+
// Smooth scroll to bottom
|
| 472 |
+
requestAnimationFrame(() => {
|
| 473 |
+
chat.scrollTo({
|
| 474 |
+
top: chat.scrollHeight,
|
| 475 |
+
behavior: 'smooth'
|
| 476 |
+
});
|
| 477 |
+
});
|
| 478 |
+
}
|
| 479 |
+
}
|
| 480 |
+
|
| 481 |
+
|
| 482 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 483 |
+
6. COIN RAIN PARTICLES
|
| 484 |
+
═════════════════════════════════════════════════════════════════ */
|
| 485 |
+
|
| 486 |
+
function spawnCoinRain(container, count) {
|
| 487 |
+
container = container || document.body;
|
| 488 |
+
count = count || COIN_RAIN_DEFAULT;
|
| 489 |
+
|
| 490 |
+
for (let i = 0; i < count; i++) {
|
| 491 |
+
const coin = document.createElement('div');
|
| 492 |
+
coin.className = 'coin-particle';
|
| 493 |
+
|
| 494 |
+
// Randomize position and timing
|
| 495 |
+
const startX = Math.random() * 100;
|
| 496 |
+
const duration = 1 + Math.random() * 1.5;
|
| 497 |
+
const delay = Math.random() * 0.5;
|
| 498 |
+
const size = 4 + Math.random() * 6;
|
| 499 |
+
|
| 500 |
+
coin.style.cssText = `
|
| 501 |
+
--start-x: ${startX}%;
|
| 502 |
+
--fall-duration: ${duration}s;
|
| 503 |
+
left: ${startX}%;
|
| 504 |
+
top: -10px;
|
| 505 |
+
width: ${size}px;
|
| 506 |
+
height: ${size}px;
|
| 507 |
+
animation-delay: ${delay}s;
|
| 508 |
+
position: absolute;
|
| 509 |
+
`;
|
| 510 |
+
|
| 511 |
+
container.style.position = container.style.position || 'relative';
|
| 512 |
+
container.appendChild(coin);
|
| 513 |
+
|
| 514 |
+
// Remove after animation
|
| 515 |
+
setTimeout(() => {
|
| 516 |
+
if (coin.parentNode) coin.remove();
|
| 517 |
+
}, (duration + delay) * 1000 + 200);
|
| 518 |
+
}
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 523 |
+
7. SCREEN EFFECTS
|
| 524 |
+
═════════════════════════════════════════════════════════════════ */
|
| 525 |
+
|
| 526 |
+
function triggerDamageFlash() {
|
| 527 |
+
// Shake the screen
|
| 528 |
+
triggerScreenShake();
|
| 529 |
+
|
| 530 |
+
// Create red flash overlay
|
| 531 |
+
const flash = document.createElement('div');
|
| 532 |
+
flash.className = 'damage-flash-overlay';
|
| 533 |
+
document.body.appendChild(flash);
|
| 534 |
+
|
| 535 |
+
// Clean up after animation
|
| 536 |
+
setTimeout(() => {
|
| 537 |
+
if (flash.parentNode) flash.remove();
|
| 538 |
+
}, 500);
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
function triggerScreenShake() {
|
| 542 |
+
document.body.classList.add('shake');
|
| 543 |
+
setTimeout(() => {
|
| 544 |
+
document.body.classList.remove('shake');
|
| 545 |
+
}, 500);
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
function triggerGlitchEffect() {
|
| 549 |
+
document.body.classList.add('glitch-effect', 'static-noise');
|
| 550 |
+
// Permanent — Reader Noticed doesn't go away
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
function clearGlitchEffect() {
|
| 554 |
+
document.body.classList.remove('glitch-effect', 'static-noise');
|
| 555 |
+
}
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 559 |
+
8. GAME STATE DRIVEN EFFECTS
|
| 560 |
+
═════════════════════════════════════════════════════════════════ */
|
| 561 |
+
|
| 562 |
+
function handleStateEffects(stateJson, triggersJson) {
|
| 563 |
+
let state, triggers;
|
| 564 |
+
try {
|
| 565 |
+
state = typeof stateJson === 'string' ? JSON.parse(stateJson) : stateJson;
|
| 566 |
+
} catch (e) {
|
| 567 |
+
return;
|
| 568 |
+
}
|
| 569 |
+
try {
|
| 570 |
+
triggers = triggersJson && typeof triggersJson === 'string' ? JSON.parse(triggersJson) : (triggersJson || {});
|
| 571 |
+
} catch (e) {
|
| 572 |
+
triggers = {};
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
// ── Meta Exposure visual effects ──
|
| 576 |
+
if (typeof state.meta_exposure === 'number') {
|
| 577 |
+
document.body.classList.remove('meta-warning', 'meta-critical');
|
| 578 |
+
|
| 579 |
+
if (state.meta_exposure >= 75) {
|
| 580 |
+
document.body.classList.add('meta-critical');
|
| 581 |
+
// Pulse the meta bar
|
| 582 |
+
const metaBar = document.querySelector('.stat-bar-meta');
|
| 583 |
+
if (metaBar) metaBar.classList.add('meter-danger');
|
| 584 |
+
} else if (state.meta_exposure >= 50) {
|
| 585 |
+
document.body.classList.add('meta-warning');
|
| 586 |
+
const metaBar = document.querySelector('.stat-bar-meta');
|
| 587 |
+
if (metaBar) {
|
| 588 |
+
metaBar.classList.remove('meter-danger');
|
| 589 |
+
metaBar.classList.add('meter-pulse');
|
| 590 |
+
setTimeout(() => metaBar.classList.remove('meter-pulse'), 1000);
|
| 591 |
+
}
|
| 592 |
+
} else {
|
| 593 |
+
const metaBar = document.querySelector('.stat-bar-meta');
|
| 594 |
+
if (metaBar) {
|
| 595 |
+
metaBar.classList.remove('meter-danger', 'meter-pulse');
|
| 596 |
+
}
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
// Track meta changes
|
| 600 |
+
if (_previousMeta !== null && state.meta_exposure > _previousMeta) {
|
| 601 |
+
const metaFill = document.querySelector('.stat-bar-meta .stat-bar-fill');
|
| 602 |
+
if (metaFill) {
|
| 603 |
+
metaFill.classList.add('meter-pulse');
|
| 604 |
+
setTimeout(() => metaFill.classList.remove('meter-pulse'), 1000);
|
| 605 |
+
}
|
| 606 |
+
}
|
| 607 |
+
_previousMeta = state.meta_exposure;
|
| 608 |
+
}
|
| 609 |
+
|
| 610 |
+
// ── Phase visual effects ──
|
| 611 |
+
if (state.current_phase) {
|
| 612 |
+
document.body.classList.remove('phase-combat', 'phase-safe-zone', 'phase-exploration');
|
| 613 |
+
const phaseClass = 'phase-' + state.current_phase.toLowerCase().replace(/\s+/g, '-');
|
| 614 |
+
document.body.classList.add(phaseClass);
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
// ── HP damage detection (via triggers.damage) ──
|
| 618 |
+
if (typeof state.hp === 'number') {
|
| 619 |
+
const tookDamage = triggers.damage === true ||
|
| 620 |
+
(_previousHP !== null && state.hp < _previousHP);
|
| 621 |
+
if (tookDamage) {
|
| 622 |
+
triggerDamageFlash();
|
| 623 |
+
const hpBar = document.querySelector('.stat-bar-hp');
|
| 624 |
+
if (hpBar) {
|
| 625 |
+
const pct = state.hp / (state.max_hp || 100);
|
| 626 |
+
if (pct > 0.6) hpBar.dataset.percent = 'high';
|
| 627 |
+
else if (pct > 0.3) hpBar.dataset.percent = 'mid';
|
| 628 |
+
else hpBar.dataset.percent = 'low';
|
| 629 |
+
}
|
| 630 |
+
}
|
| 631 |
+
_previousHP = state.hp;
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
// ── Coin gain detection (via triggers.coins_gained) ──
|
| 635 |
+
if (triggers.coins_gained === true) {
|
| 636 |
+
const playerPanel = document.querySelector('.player-panel');
|
| 637 |
+
spawnCoinRain(playerPanel || document.body, 12);
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
// ── Rank display ──
|
| 641 |
+
if (state.show_rank) {
|
| 642 |
+
const rankDisplay = document.querySelector('.ranking-display');
|
| 643 |
+
if (rankDisplay) {
|
| 644 |
+
const rank = state.show_rank.toLowerCase();
|
| 645 |
+
rankDisplay.classList.add(`rank-${rank}`);
|
| 646 |
+
}
|
| 647 |
+
}
|
| 648 |
+
|
| 649 |
+
// ── Reader Noticed ──
|
| 650 |
+
if (state.reader_noticed) {
|
| 651 |
+
triggerGlitchEffect();
|
| 652 |
+
}
|
| 653 |
+
}
|
| 654 |
+
|
| 655 |
+
|
| 656 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 657 |
+
9. STAT BAR ANIMATION HELPER
|
| 658 |
+
═════════════════════════════════════════════════════════════════ */
|
| 659 |
+
|
| 660 |
+
function animateStatBars() {
|
| 661 |
+
const fills = document.querySelectorAll('.stat-bar-fill');
|
| 662 |
+
fills.forEach(fill => {
|
| 663 |
+
const targetWidth = fill.style.width;
|
| 664 |
+
if (targetWidth && !fill.dataset.animated) {
|
| 665 |
+
// Force a reflow to trigger the CSS transition
|
| 666 |
+
fill.style.width = '0%';
|
| 667 |
+
fill.offsetHeight; // trigger reflow
|
| 668 |
+
fill.style.width = targetWidth;
|
| 669 |
+
fill.dataset.animated = 'true';
|
| 670 |
+
}
|
| 671 |
+
});
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
|
| 675 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 676 |
+
10. START SCREEN EFFECTS
|
| 677 |
+
═════════════════════════════════════════════════════════════════ */
|
| 678 |
+
|
| 679 |
+
function setupStartScreen() {
|
| 680 |
+
const title = document.querySelector('.game-title');
|
| 681 |
+
if (title && !title.classList.contains('floating')) {
|
| 682 |
+
title.classList.add('floating');
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
const startBtn = document.querySelector('.start-btn');
|
| 686 |
+
if (startBtn && !startBtn.classList.contains('glow-pulse')) {
|
| 687 |
+
startBtn.classList.add('glow-pulse');
|
| 688 |
+
}
|
| 689 |
+
}
|
| 690 |
+
|
| 691 |
+
|
| 692 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 693 |
+
11. ORV TEXT FORMATTING HELPER
|
| 694 |
+
═════════════════════════════════════════════════════════════════ */
|
| 695 |
+
|
| 696 |
+
/**
|
| 697 |
+
* Formats raw text with ORV-style markup:
|
| 698 |
+
* [Text] → <span class="bracket-text">[Text]</span>
|
| 699 |
+
* 'Skill Name' inside brackets → <span class="skill-name">'Skill Name'</span>
|
| 700 |
+
* 999 coins → <span class="coins-display">999</span>
|
| 701 |
+
*/
|
| 702 |
+
function formatORVText(text) {
|
| 703 |
+
if (!text) return text;
|
| 704 |
+
|
| 705 |
+
// Bracket text: [something]
|
| 706 |
+
text = text.replace(/\[([^\]]+)\]/g, (match, inner) => {
|
| 707 |
+
// Check for skill names within: 'Name'
|
| 708 |
+
const formatted = inner.replace(/'([^']+)'/g,
|
| 709 |
+
'<span class="skill-name">\'$1\'</span>'
|
| 710 |
+
);
|
| 711 |
+
return `<span class="bracket-text">[${formatted}]</span>`;
|
| 712 |
+
});
|
| 713 |
+
|
| 714 |
+
// Coin amounts: X coins
|
| 715 |
+
text = text.replace(/(\d+)\s*coins?/gi,
|
| 716 |
+
'<span class="coins-display">$1</span> coins'
|
| 717 |
+
);
|
| 718 |
+
|
| 719 |
+
return text;
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
|
| 723 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 724 |
+
12. AMBIENT BACKGROUND EFFECTS
|
| 725 |
+
═════════════════════════════════════════════════════════════════ */
|
| 726 |
+
|
| 727 |
+
function spawnAmbientParticles() {
|
| 728 |
+
const container = document.querySelector('.game-area');
|
| 729 |
+
if (!container) return;
|
| 730 |
+
|
| 731 |
+
// Create a few floating particles for atmosphere
|
| 732 |
+
for (let i = 0; i < 5; i++) {
|
| 733 |
+
const particle = document.createElement('div');
|
| 734 |
+
const x = Math.random() * 100;
|
| 735 |
+
const y = Math.random() * 100;
|
| 736 |
+
const size = 1 + Math.random() * 2;
|
| 737 |
+
const duration = 8 + Math.random() * 12;
|
| 738 |
+
const delay = Math.random() * 5;
|
| 739 |
+
|
| 740 |
+
particle.style.cssText = `
|
| 741 |
+
position: absolute;
|
| 742 |
+
left: ${x}%;
|
| 743 |
+
top: ${y}%;
|
| 744 |
+
width: ${size}px;
|
| 745 |
+
height: ${size}px;
|
| 746 |
+
background: rgba(201, 168, 76, 0.12);
|
| 747 |
+
border-radius: 50%;
|
| 748 |
+
pointer-events: none;
|
| 749 |
+
z-index: 0;
|
| 750 |
+
animation: float ${duration}s ease-in-out ${delay}s infinite;
|
| 751 |
+
opacity: 0.3;
|
| 752 |
+
`;
|
| 753 |
+
|
| 754 |
+
container.style.position = container.style.position || 'relative';
|
| 755 |
+
container.appendChild(particle);
|
| 756 |
+
}
|
| 757 |
+
}
|
| 758 |
+
|
| 759 |
+
|
| 760 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 761 |
+
13. AUDIO FEEDBACK (visual-only fallback)
|
| 762 |
+
═════════════════════════════════════════════════════════════════ */
|
| 763 |
+
|
| 764 |
+
/**
|
| 765 |
+
* Provides visual "click" feedback when audio isn't available.
|
| 766 |
+
* Creates a brief ripple at the click location.
|
| 767 |
+
*/
|
| 768 |
+
function clickRipple(e) {
|
| 769 |
+
const ripple = document.createElement('div');
|
| 770 |
+
ripple.style.cssText = `
|
| 771 |
+
position: fixed;
|
| 772 |
+
left: ${e.clientX - 15}px;
|
| 773 |
+
top: ${e.clientY - 15}px;
|
| 774 |
+
width: 30px;
|
| 775 |
+
height: 30px;
|
| 776 |
+
border: 1px solid rgba(74, 158, 255, 0.4);
|
| 777 |
+
border-radius: 50%;
|
| 778 |
+
pointer-events: none;
|
| 779 |
+
z-index: 99999;
|
| 780 |
+
animation: scaleUp 0.4s ease-out forwards;
|
| 781 |
+
opacity: 0.6;
|
| 782 |
+
`;
|
| 783 |
+
document.body.appendChild(ripple);
|
| 784 |
+
setTimeout(() => {
|
| 785 |
+
if (ripple.parentNode) ripple.remove();
|
| 786 |
+
}, 400);
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
|
| 790 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 791 |
+
14. INITIALIZATION
|
| 792 |
+
═════════════════════════════════════════════════════════════════ */
|
| 793 |
+
|
| 794 |
+
function initialize() {
|
| 795 |
+
console.log('[Dokkaebi System] Initializing Scenario Simulator...');
|
| 796 |
+
|
| 797 |
+
setupMutationObserver();
|
| 798 |
+
setupEventDelegation();
|
| 799 |
+
setupStartScreen();
|
| 800 |
+
|
| 801 |
+
// Initial stat bar animation
|
| 802 |
+
setTimeout(animateStatBars, 500);
|
| 803 |
+
|
| 804 |
+
// Ambient particles (subtle)
|
| 805 |
+
setTimeout(spawnAmbientParticles, 1000);
|
| 806 |
+
|
| 807 |
+
console.log('[Dokkaebi System] Scenario Simulator initialized.');
|
| 808 |
+
console.log('[Dokkaebi System] ──────────────────────────────────');
|
| 809 |
+
console.log('[Dokkaebi System] "The scenario has begun."');
|
| 810 |
+
console.log('[Dokkaebi System] ──────────────────────────────────');
|
| 811 |
+
}
|
| 812 |
+
|
| 813 |
+
// ── Run on DOMContentLoaded ──
|
| 814 |
+
if (document.readyState === 'loading') {
|
| 815 |
+
document.addEventListener('DOMContentLoaded', initialize);
|
| 816 |
+
} else {
|
| 817 |
+
// DOM already loaded
|
| 818 |
+
initialize();
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
// ── Retry after Gradio finishes rendering ──
|
| 822 |
+
// Gradio rebuilds the DOM after initial load, so we need to re-init
|
| 823 |
+
setTimeout(() => {
|
| 824 |
+
if (!_observerSetup) {
|
| 825 |
+
console.log('[Dokkaebi System] Retry initialization (Gradio late load)...');
|
| 826 |
+
initialize();
|
| 827 |
+
}
|
| 828 |
+
// Re-check start screen
|
| 829 |
+
setupStartScreen();
|
| 830 |
+
// Re-animate stat bars
|
| 831 |
+
animateStatBars();
|
| 832 |
+
// Re-scroll chat
|
| 833 |
+
scrollStarStream();
|
| 834 |
+
}, INIT_RETRY_DELAY);
|
| 835 |
+
|
| 836 |
+
// Additional retry for very slow Gradio loads
|
| 837 |
+
setTimeout(() => {
|
| 838 |
+
setupStartScreen();
|
| 839 |
+
animateStatBars();
|
| 840 |
+
scrollStarStream();
|
| 841 |
+
}, 4000);
|
| 842 |
+
|
| 843 |
+
|
| 844 |
+
/* ═════════════════════════════════════════════════════════════════
|
| 845 |
+
15. EXPOSE API (for Gradio's inline script calls)
|
| 846 |
+
═════════════════════════════════════════════════════════════════ */
|
| 847 |
+
|
| 848 |
+
// Expose key functions globally so Gradio inline JS can call them
|
| 849 |
+
window.ORVGame = {
|
| 850 |
+
typewriter: applyTypewriter,
|
| 851 |
+
damageFlash: triggerDamageFlash,
|
| 852 |
+
screenShake: triggerScreenShake,
|
| 853 |
+
glitch: triggerGlitchEffect,
|
| 854 |
+
clearGlitch: clearGlitchEffect,
|
| 855 |
+
coinRain: spawnCoinRain,
|
| 856 |
+
scrollChat: scrollStarStream,
|
| 857 |
+
showOverlay: showOverlay,
|
| 858 |
+
dismissOverlay: dismissOverlay,
|
| 859 |
+
showScenario: showScenarioAnnouncement,
|
| 860 |
+
showReaderNoticed: showReaderNoticed,
|
| 861 |
+
handleState: handleStateEffects,
|
| 862 |
+
formatText: formatORVText,
|
| 863 |
+
animateBars: animateStatBars,
|
| 864 |
+
};
|
| 865 |
+
|
| 866 |
+
})();
|
game_engine.py
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
game_engine.py — Core Game State & Turn Processing
|
| 3 |
+
===================================================
|
| 4 |
+
Contains the GameState class, stat management, hidden scenario triggers,
|
| 5 |
+
and the main turn-processing pipeline for the ORV Scenario Simulator.
|
| 6 |
+
|
| 7 |
+
Part of the ORV (Omniscient Reader's Viewpoint) Scenario Simulator.
|
| 8 |
+
Build Small Hackathon 2026.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import copy
|
| 12 |
+
import json
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from scenarios import SCENARIOS
|
| 16 |
+
|
| 17 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 18 |
+
# CONSTELLATION NAME MAPPING
|
| 19 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 20 |
+
|
| 21 |
+
_CONSTELLATION_DISPLAY_NAMES: dict[str, str] = {
|
| 22 |
+
"abyssal_black_flame_dragon": "Abyssal Black Flame Dragon",
|
| 23 |
+
"secretive_plotter": "Secretive Plotter",
|
| 24 |
+
"demon_like_judge": "Demon-like Judge of Fire",
|
| 25 |
+
"maritime_war_god": "Maritime War God",
|
| 26 |
+
"prisoner_golden_headband": "Prisoner of the Golden Headband",
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
_TRUST_DISPLAY_NAMES: dict[str, str] = {
|
| 30 |
+
"yoo_sangah": "Yoo Sangah",
|
| 31 |
+
"lee_hyunsung": "Lee Hyunsung",
|
| 32 |
+
"jung_heewon": "Jung Heewon",
|
| 33 |
+
"han_sooyoung": "Han Sooyoung",
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 38 |
+
# GAME STATE CLASS
|
| 39 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class GameState:
|
| 43 |
+
"""
|
| 44 |
+
Encapsulates the full mutable state of an ORV Scenario Simulator session.
|
| 45 |
+
|
| 46 |
+
The internal ``state`` dict is the canonical representation and is
|
| 47 |
+
serialisable to / from plain dicts for Gradio ``gr.State`` transport.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
def __init__(self) -> None:
|
| 51 |
+
self.state: dict[str, Any] = {
|
| 52 |
+
"player_name": "Kim Dokja",
|
| 53 |
+
"hp": 100,
|
| 54 |
+
"max_hp": 100,
|
| 55 |
+
"coins": 0,
|
| 56 |
+
"scenario_num": 1,
|
| 57 |
+
"turn": 1,
|
| 58 |
+
"total_turns": 0,
|
| 59 |
+
"current_phase": "Exploration",
|
| 60 |
+
"active_debuffs": [],
|
| 61 |
+
"meta_exposure": 0,
|
| 62 |
+
"prob_stability": 100,
|
| 63 |
+
"entertainment": 0,
|
| 64 |
+
"titles": [],
|
| 65 |
+
"attributes": ["Omniscient Reader's Viewpoint (Hidden)"],
|
| 66 |
+
"skills": {
|
| 67 |
+
"Fourth Wall": {"level": 1, "cooldown": 0, "max_cooldown": 2},
|
| 68 |
+
"Bookmark": {"level": 1, "cooldown": 0, "max_cooldown": 3},
|
| 69 |
+
"Omniscient Reader's Viewpoint": {"level": 1, "cooldown": 0, "max_cooldown": 4}
|
| 70 |
+
},
|
| 71 |
+
"inventory": [],
|
| 72 |
+
"sponsor": None,
|
| 73 |
+
"trust": {
|
| 74 |
+
"yoo_sangah": 0,
|
| 75 |
+
"lee_hyunsung": 0,
|
| 76 |
+
"jung_heewon": 0,
|
| 77 |
+
"han_sooyoung": 0,
|
| 78 |
+
},
|
| 79 |
+
"constellation_affinity": {
|
| 80 |
+
"abyssal_black_flame_dragon": 0,
|
| 81 |
+
"secretive_plotter": 0,
|
| 82 |
+
"demon_like_judge": 0,
|
| 83 |
+
"maritime_war_god": 0,
|
| 84 |
+
"prisoner_golden_headband": 0,
|
| 85 |
+
},
|
| 86 |
+
"unique_constellations": [],
|
| 87 |
+
"action_history": [],
|
| 88 |
+
"action_tags": [],
|
| 89 |
+
"hidden_scenarios_triggered": [],
|
| 90 |
+
"scenario_rankings": [],
|
| 91 |
+
"star_stream_history": [],
|
| 92 |
+
"game_over": False,
|
| 93 |
+
"game_over_reason": None,
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
# ------------------------------------------------------------------
|
| 97 |
+
# Stat Management
|
| 98 |
+
# ------------------------------------------------------------------
|
| 99 |
+
|
| 100 |
+
def apply_stat_changes(self, changes: dict[str, Any]) -> None:
|
| 101 |
+
"""
|
| 102 |
+
Apply a ``stat_changes`` dict returned by the AI.
|
| 103 |
+
|
| 104 |
+
Handles top-level numeric fields (hp, coins, meta_exposure,
|
| 105 |
+
prob_stability) as well as nested ``trust`` and
|
| 106 |
+
``constellation_affinity`` sub-dicts.
|
| 107 |
+
|
| 108 |
+
All values are clamped to their valid ranges after application.
|
| 109 |
+
"""
|
| 110 |
+
s = self.state
|
| 111 |
+
|
| 112 |
+
# --- Top-level numeric stats ---
|
| 113 |
+
s["hp"] = max(0, min(s["max_hp"], s["hp"] + changes.get("hp", 0)))
|
| 114 |
+
s["coins"] = max(0, s["coins"] + changes.get("coins", 0))
|
| 115 |
+
s["meta_exposure"] = max(
|
| 116 |
+
0, min(100, s["meta_exposure"] + changes.get("meta_exposure", 0))
|
| 117 |
+
)
|
| 118 |
+
s["prob_stability"] = max(
|
| 119 |
+
0, min(100, s["prob_stability"] + changes.get("prob_stability", 0))
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
# --- Trust sub-dict ---
|
| 123 |
+
trust_changes = changes.get("trust", {})
|
| 124 |
+
for key, delta in trust_changes.items():
|
| 125 |
+
if key in s["trust"]:
|
| 126 |
+
s["trust"][key] = max(0, s["trust"][key] + delta)
|
| 127 |
+
|
| 128 |
+
# --- Constellation affinity sub-dict ---
|
| 129 |
+
affinity_changes = changes.get("constellation_affinity", {})
|
| 130 |
+
for key, delta in affinity_changes.items():
|
| 131 |
+
if key in s["constellation_affinity"]:
|
| 132 |
+
s["constellation_affinity"][key] = max(
|
| 133 |
+
0, s["constellation_affinity"][key] + delta
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
def add_coins_from_entertainment(self, score: int) -> None:
|
| 137 |
+
"""Add entertainment-based coin reward (score × 10)."""
|
| 138 |
+
self.state["coins"] += score * 10
|
| 139 |
+
self.state["entertainment"] += score
|
| 140 |
+
|
| 141 |
+
def add_coins_from_constellations(self, reactions: list[dict[str, Any]]) -> None:
|
| 142 |
+
"""Sum coin donations from all constellation reactions."""
|
| 143 |
+
for reaction in reactions:
|
| 144 |
+
coins = reaction.get("coins", 0)
|
| 145 |
+
if isinstance(coins, (int, float)) and coins > 0:
|
| 146 |
+
self.state["coins"] += int(coins)
|
| 147 |
+
|
| 148 |
+
def buy_item(self, item_name: str, cost: int) -> bool:
|
| 149 |
+
"""Attempt to buy an item from the Dokkaebi Bag."""
|
| 150 |
+
if self.state["coins"] >= cost:
|
| 151 |
+
self.state["coins"] -= cost
|
| 152 |
+
self.state["inventory"].append(item_name)
|
| 153 |
+
return True
|
| 154 |
+
return False
|
| 155 |
+
|
| 156 |
+
# ------------------------------------------------------------------
|
| 157 |
+
# History
|
| 158 |
+
# ------------------------------------------------------------------
|
| 159 |
+
|
| 160 |
+
def add_to_history(self, action: str, tags: list[str]) -> None:
|
| 161 |
+
"""
|
| 162 |
+
Record a player action and associated tags.
|
| 163 |
+
|
| 164 |
+
``action_history`` is capped at the 10 most recent entries.
|
| 165 |
+
``action_tags`` grows unbounded (used for pattern detection).
|
| 166 |
+
"""
|
| 167 |
+
self.state["action_history"].append(action)
|
| 168 |
+
if len(self.state["action_history"]) > 10:
|
| 169 |
+
self.state["action_history"] = self.state["action_history"][-10:]
|
| 170 |
+
|
| 171 |
+
self.state["action_tags"].extend(tags)
|
| 172 |
+
|
| 173 |
+
# ------------------------------------------------------------------
|
| 174 |
+
# Hidden Scenario Triggers
|
| 175 |
+
# ------------------------------------------------------------------
|
| 176 |
+
|
| 177 |
+
def check_hidden_scenarios(self) -> str | None:
|
| 178 |
+
"""
|
| 179 |
+
Evaluate behavioural patterns against hidden scenario triggers.
|
| 180 |
+
|
| 181 |
+
Returns the scenario name if a new hidden scenario should fire,
|
| 182 |
+
or ``None`` if nothing triggers.
|
| 183 |
+
|
| 184 |
+
Trigger rules
|
| 185 |
+
-------------
|
| 186 |
+
- **The Last Good Human**: 5+ ``'save'`` tags in action_tags.
|
| 187 |
+
- **The Reader's Trial**: ``meta_exposure >= 75``.
|
| 188 |
+
- **A Throne Built on Corpses**: 3+ ``'betray'`` tags.
|
| 189 |
+
- **One Who Rejects Fate**: 3+ ``'reject'`` tags.
|
| 190 |
+
- **The Untouchable**: completed 3+ scenarios with HP still at max.
|
| 191 |
+
"""
|
| 192 |
+
tags = self.state["action_tags"]
|
| 193 |
+
triggered = self.state["hidden_scenarios_triggered"]
|
| 194 |
+
|
| 195 |
+
# The Reader's Trial — highest priority
|
| 196 |
+
if (
|
| 197 |
+
self.state["meta_exposure"] >= 75
|
| 198 |
+
and "The Reader's Trial" not in triggered
|
| 199 |
+
):
|
| 200 |
+
return "The Reader's Trial"
|
| 201 |
+
|
| 202 |
+
# The Last Good Human
|
| 203 |
+
if tags.count("save") >= 5 and "The Last Good Human" not in triggered:
|
| 204 |
+
return "The Last Good Human"
|
| 205 |
+
|
| 206 |
+
# A Throne Built on Corpses
|
| 207 |
+
if tags.count("betray") >= 3 and "A Throne Built on Corpses" not in triggered:
|
| 208 |
+
return "A Throne Built on Corpses"
|
| 209 |
+
|
| 210 |
+
# One Who Rejects Fate
|
| 211 |
+
if tags.count("reject") >= 3 and "One Who Rejects Fate" not in triggered:
|
| 212 |
+
return "One Who Rejects Fate"
|
| 213 |
+
|
| 214 |
+
# The Untouchable
|
| 215 |
+
if (
|
| 216 |
+
len(self.state["scenario_rankings"]) >= 3
|
| 217 |
+
and self.state["hp"] == self.state["max_hp"]
|
| 218 |
+
and "The Untouchable" not in triggered
|
| 219 |
+
):
|
| 220 |
+
return "The Untouchable"
|
| 221 |
+
|
| 222 |
+
return None
|
| 223 |
+
|
| 224 |
+
# ------------------------------------------------------------------
|
| 225 |
+
# Game-Over & Scenario Advancement
|
| 226 |
+
# ------------------------------------------------------------------
|
| 227 |
+
|
| 228 |
+
def check_game_over(self) -> bool:
|
| 229 |
+
"""Check if the player is dead. Sets game_over flags if so."""
|
| 230 |
+
if self.state["hp"] <= 0:
|
| 231 |
+
self.state["game_over"] = True
|
| 232 |
+
self.state["game_over_reason"] = "Death"
|
| 233 |
+
return True
|
| 234 |
+
return False
|
| 235 |
+
|
| 236 |
+
def advance_scenario(self, rank: str | None) -> None:
|
| 237 |
+
"""
|
| 238 |
+
Complete the current scenario, store its rank, and advance.
|
| 239 |
+
|
| 240 |
+
Parameters
|
| 241 |
+
----------
|
| 242 |
+
rank : str | None
|
| 243 |
+
The rank the player earned (e.g. "S", "A", "B", "C", "F").
|
| 244 |
+
"""
|
| 245 |
+
self.state["scenario_rankings"].append(rank or "C")
|
| 246 |
+
|
| 247 |
+
# Determine next scenario
|
| 248 |
+
current = self.state["scenario_num"]
|
| 249 |
+
if current == 1:
|
| 250 |
+
next_s = 1.5
|
| 251 |
+
elif current == 1.5:
|
| 252 |
+
next_s = 2
|
| 253 |
+
elif current == 2:
|
| 254 |
+
next_s = 3
|
| 255 |
+
else:
|
| 256 |
+
next_s = current + 1
|
| 257 |
+
|
| 258 |
+
self.state["scenario_num"] = next_s
|
| 259 |
+
self.state["turn"] = 1
|
| 260 |
+
self.state["current_phase"] = "Exploration"
|
| 261 |
+
|
| 262 |
+
# ------------------------------------------------------------------
|
| 263 |
+
# Meta Level
|
| 264 |
+
# ------------------------------------------------------------------
|
| 265 |
+
|
| 266 |
+
def get_meta_level(self) -> str:
|
| 267 |
+
"""
|
| 268 |
+
Return a human-readable meta-exposure tier.
|
| 269 |
+
|
| 270 |
+
Tiers:
|
| 271 |
+
0-24 → ``'normal'``
|
| 272 |
+
25-49 → ``'suspicious'``
|
| 273 |
+
50-74 → ``'unstable'``
|
| 274 |
+
75-99 → ``'breaking'``
|
| 275 |
+
100 → ``'noticed'``
|
| 276 |
+
"""
|
| 277 |
+
me = self.state["meta_exposure"]
|
| 278 |
+
if me >= 100:
|
| 279 |
+
return "noticed"
|
| 280 |
+
if me >= 75:
|
| 281 |
+
return "breaking"
|
| 282 |
+
if me >= 50:
|
| 283 |
+
return "unstable"
|
| 284 |
+
if me >= 25:
|
| 285 |
+
return "suspicious"
|
| 286 |
+
return "normal"
|
| 287 |
+
|
| 288 |
+
# ------------------------------------------------------------------
|
| 289 |
+
# Sponsor System
|
| 290 |
+
# ------------------------------------------------------------------
|
| 291 |
+
|
| 292 |
+
def check_sponsor_threshold(self) -> str | None:
|
| 293 |
+
"""
|
| 294 |
+
Check if any constellation's affinity has reached the sponsor
|
| 295 |
+
threshold (≥ 50).
|
| 296 |
+
|
| 297 |
+
Returns the display name of the first qualifying constellation,
|
| 298 |
+
or ``None``.
|
| 299 |
+
"""
|
| 300 |
+
for key, value in self.state["constellation_affinity"].items():
|
| 301 |
+
if value >= 50:
|
| 302 |
+
return _CONSTELLATION_DISPLAY_NAMES.get(
|
| 303 |
+
key, key.replace("_", " ").title()
|
| 304 |
+
)
|
| 305 |
+
return None
|
| 306 |
+
|
| 307 |
+
# ------------------------------------------------------------------
|
| 308 |
+
# Serialisation
|
| 309 |
+
# ------------------------------------------------------------------
|
| 310 |
+
|
| 311 |
+
def to_dict(self) -> dict[str, Any]:
|
| 312 |
+
"""Return a deep copy of the state dict (safe for gr.State)."""
|
| 313 |
+
return copy.deepcopy(self.state)
|
| 314 |
+
|
| 315 |
+
@classmethod
|
| 316 |
+
def from_dict(cls, d: dict[str, Any]) -> "GameState":
|
| 317 |
+
"""
|
| 318 |
+
Reconstruct a GameState from a plain dict.
|
| 319 |
+
|
| 320 |
+
Parameters
|
| 321 |
+
----------
|
| 322 |
+
d : dict
|
| 323 |
+
A state dict previously produced by ``to_dict()``.
|
| 324 |
+
|
| 325 |
+
Returns
|
| 326 |
+
-------
|
| 327 |
+
GameState
|
| 328 |
+
A new GameState instance with the restored state.
|
| 329 |
+
"""
|
| 330 |
+
gs = cls()
|
| 331 |
+
gs.state = copy.deepcopy(d)
|
| 332 |
+
return gs
|
| 333 |
+
|
| 334 |
+
# ------------------------------------------------------------------
|
| 335 |
+
# Prompt Context Builder
|
| 336 |
+
# ------------------------------------------------------------------
|
| 337 |
+
|
| 338 |
+
def to_prompt_context(self) -> str:
|
| 339 |
+
"""
|
| 340 |
+
Return a formatted, human-readable summary of the game state
|
| 341 |
+
suitable for injection into the Dokkaebi system prompt.
|
| 342 |
+
|
| 343 |
+
Only includes non-zero trust / affinity values to keep the
|
| 344 |
+
context window efficient.
|
| 345 |
+
"""
|
| 346 |
+
s = self.state
|
| 347 |
+
lines: list[str] = [
|
| 348 |
+
f"Player: {s['player_name']}",
|
| 349 |
+
f"HP: {s['hp']}/{s['max_hp']}",
|
| 350 |
+
f"Coins: {s['coins']}",
|
| 351 |
+
f"Scenario: {s['scenario_num']} | Turn: {s['turn']} | Total Turns: {s['total_turns']}",
|
| 352 |
+
f"Current Phase: {s['current_phase']}",
|
| 353 |
+
f"Meta Level: {self.get_meta_level()} ({s['meta_exposure']}%)",
|
| 354 |
+
f"Probability Stability: {s['prob_stability']}%",
|
| 355 |
+
]
|
| 356 |
+
|
| 357 |
+
if s.get("active_debuffs"):
|
| 358 |
+
lines.append(f"Active Debuffs: {', '.join(s['active_debuffs'])}")
|
| 359 |
+
if s["titles"]:
|
| 360 |
+
lines.append(f"Titles: {', '.join(s['titles'])}")
|
| 361 |
+
if s["skills"]:
|
| 362 |
+
skills_str = ", ".join([f"{name} (Lv.{data.get('level', 1)})" for name, data in s["skills"].items()])
|
| 363 |
+
lines.append(f"Skills: {skills_str}")
|
| 364 |
+
if s["inventory"]:
|
| 365 |
+
lines.append(f"Inventory: {', '.join(s['inventory'])}")
|
| 366 |
+
if s["sponsor"]:
|
| 367 |
+
lines.append(f"Sponsor: {s['sponsor']}")
|
| 368 |
+
|
| 369 |
+
# Trust — only non-zero
|
| 370 |
+
non_zero_trust = {
|
| 371 |
+
_TRUST_DISPLAY_NAMES.get(k, k): v
|
| 372 |
+
for k, v in s["trust"].items()
|
| 373 |
+
if v != 0
|
| 374 |
+
}
|
| 375 |
+
if non_zero_trust:
|
| 376 |
+
trust_str = ", ".join(f"{name}: {val}" for name, val in non_zero_trust.items())
|
| 377 |
+
lines.append(f"Trust: {trust_str}")
|
| 378 |
+
|
| 379 |
+
# Constellation affinity — only non-zero
|
| 380 |
+
non_zero_aff = {
|
| 381 |
+
_CONSTELLATION_DISPLAY_NAMES.get(k, k): v
|
| 382 |
+
for k, v in s["constellation_affinity"].items()
|
| 383 |
+
if v != 0
|
| 384 |
+
}
|
| 385 |
+
if non_zero_aff:
|
| 386 |
+
aff_str = ", ".join(f"{name}: {val}" for name, val in non_zero_aff.items())
|
| 387 |
+
lines.append(f"Constellation Affinity: {aff_str}")
|
| 388 |
+
|
| 389 |
+
# Recent actions (last 3)
|
| 390 |
+
recent = s["action_history"][-3:]
|
| 391 |
+
if recent:
|
| 392 |
+
lines.append(f"Recent Actions: {' → '.join(recent)}")
|
| 393 |
+
|
| 394 |
+
return "\n".join(lines)
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 398 |
+
# STANDALONE FUNCTIONS
|
| 399 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def infer_action_tags(player_action: str, ai_response: dict[str, Any], stance: str = "") -> list[str]:
|
| 403 |
+
"""
|
| 404 |
+
Derive behavioural tags from the player's action text and AI response.
|
| 405 |
+
|
| 406 |
+
Uses simple keyword matching on the lowercased action string.
|
| 407 |
+
Multiple tags can apply to a single action.
|
| 408 |
+
|
| 409 |
+
Tag rules
|
| 410 |
+
---------
|
| 411 |
+
- ``'save'`` — action contains *save*, *help*, or *protect*
|
| 412 |
+
- ``'fight'`` — action contains *kill*, *attack*, or *fight*
|
| 413 |
+
- ``'betray'`` — action contains *betray*, *abandon*, or *steal*
|
| 414 |
+
- ``'avoid'`` — action contains *run*, *hide*, or *avoid*
|
| 415 |
+
- ``'meta'`` — AI flagged ``meta_detected`` as truthy
|
| 416 |
+
- ``'reject'`` — action contains *reject*, *refuse*, or *decline*
|
| 417 |
+
|
| 418 |
+
Parameters
|
| 419 |
+
----------
|
| 420 |
+
player_action : str
|
| 421 |
+
The raw text the player typed.
|
| 422 |
+
ai_response : dict
|
| 423 |
+
The parsed AI response dict.
|
| 424 |
+
|
| 425 |
+
Returns
|
| 426 |
+
-------
|
| 427 |
+
list[str]
|
| 428 |
+
A (possibly empty) list of inferred tags.
|
| 429 |
+
"""
|
| 430 |
+
tags: list[str] = []
|
| 431 |
+
action_lower = player_action.lower()
|
| 432 |
+
|
| 433 |
+
_TAG_KEYWORDS: dict[str, list[str]] = {
|
| 434 |
+
"save": ["save", "help", "protect"],
|
| 435 |
+
"fight": ["kill", "attack", "fight"],
|
| 436 |
+
"betray": ["betray", "abandon", "steal"],
|
| 437 |
+
"avoid": ["run", "hide", "avoid"],
|
| 438 |
+
"reject": ["reject", "refuse", "decline"],
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
for tag, keywords in _TAG_KEYWORDS.items():
|
| 442 |
+
if any(kw in action_lower for kw in keywords):
|
| 443 |
+
tags.append(tag)
|
| 444 |
+
|
| 445 |
+
if stance:
|
| 446 |
+
# map stance to specific tags
|
| 447 |
+
stance_lower = stance.lower()
|
| 448 |
+
if "aggressive" in stance_lower:
|
| 449 |
+
tags.append("fight")
|
| 450 |
+
elif "deceptive" in stance_lower:
|
| 451 |
+
tags.append("betray")
|
| 452 |
+
elif "empathetic" in stance_lower:
|
| 453 |
+
tags.append("save")
|
| 454 |
+
elif "observant" in stance_lower:
|
| 455 |
+
tags.append("meta")
|
| 456 |
+
|
| 457 |
+
if ai_response.get("meta_detected"):
|
| 458 |
+
tags.append("meta")
|
| 459 |
+
|
| 460 |
+
return tags
|
| 461 |
+
|
| 462 |
+
|
| 463 |
+
def process_turn(
|
| 464 |
+
state_dict: dict[str, Any],
|
| 465 |
+
player_action: str,
|
| 466 |
+
ai_response: dict[str, Any],
|
| 467 |
+
stance: str = "",
|
| 468 |
+
) -> dict[str, Any]:
|
| 469 |
+
"""
|
| 470 |
+
Execute the full turn-processing pipeline.
|
| 471 |
+
|
| 472 |
+
This is the main entry point called after the AI has responded.
|
| 473 |
+
It mutates the game state based on the AI's output, handles all
|
| 474 |
+
side-effects (coins, sponsors, hidden scenarios, game-over), and
|
| 475 |
+
returns the updated state dict.
|
| 476 |
+
|
| 477 |
+
Pipeline
|
| 478 |
+
--------
|
| 479 |
+
1. Reconstruct ``GameState`` from ``state_dict``.
|
| 480 |
+
2. Apply ``stat_changes`` from the AI response.
|
| 481 |
+
3. Add entertainment coins (``entertainment_score × 10``).
|
| 482 |
+
4. Add constellation donation coins.
|
| 483 |
+
5. Apply meta-exposure threshold penalties to ``prob_stability``.
|
| 484 |
+
6. If ``meta_detected``, decrease ``prob_stability`` by 5.
|
| 485 |
+
7. Handle ``new_title`` (append to titles list).
|
| 486 |
+
8. Check for hidden scenario triggers.
|
| 487 |
+
9. Handle scenario completion (``scenario_complete`` + ``scenario_rank``).
|
| 488 |
+
10. Check sponsor threshold.
|
| 489 |
+
11. Record action + inferred tags in history.
|
| 490 |
+
12. Check game-over condition.
|
| 491 |
+
13. Increment turn counters.
|
| 492 |
+
14. Return updated state dict.
|
| 493 |
+
|
| 494 |
+
Parameters
|
| 495 |
+
----------
|
| 496 |
+
state_dict : dict
|
| 497 |
+
The serialised game state (from ``GameState.to_dict()``).
|
| 498 |
+
player_action : str
|
| 499 |
+
The text the player typed.
|
| 500 |
+
ai_response : dict
|
| 501 |
+
The parsed JSON response from the Dokkaebi AI.
|
| 502 |
+
|
| 503 |
+
Returns
|
| 504 |
+
-------
|
| 505 |
+
dict
|
| 506 |
+
The updated game state dict.
|
| 507 |
+
"""
|
| 508 |
+
gs = GameState.from_dict(state_dict)
|
| 509 |
+
|
| 510 |
+
# 1-2. Apply stat changes
|
| 511 |
+
stat_changes = ai_response.get("stat_changes", {})
|
| 512 |
+
gs.apply_stat_changes(stat_changes)
|
| 513 |
+
|
| 514 |
+
# Decrement active skill cooldowns
|
| 515 |
+
for skill_name, skill_data in gs.state.get("skills", {}).items():
|
| 516 |
+
if skill_data.get("cooldown", 0) > 0:
|
| 517 |
+
skill_data["cooldown"] -= 1
|
| 518 |
+
|
| 519 |
+
# 3. Entertainment coins
|
| 520 |
+
entertainment_score = ai_response.get("entertainment_score", 0)
|
| 521 |
+
if isinstance(entertainment_score, (int, float)):
|
| 522 |
+
gs.add_coins_from_entertainment(int(entertainment_score))
|
| 523 |
+
|
| 524 |
+
# 4. Constellation donation coins
|
| 525 |
+
constellation_reactions = ai_response.get("constellation_reactions", [])
|
| 526 |
+
if isinstance(constellation_reactions, list):
|
| 527 |
+
gs.add_coins_from_constellations(constellation_reactions)
|
| 528 |
+
|
| 529 |
+
# 5. Meta-exposure threshold penalties (only applied once when crossing threshold)
|
| 530 |
+
# Track prev meta to detect crossing
|
| 531 |
+
prev_meta = state_dict.get("meta_exposure", 0) # Use original before stat changes
|
| 532 |
+
me = gs.state["meta_exposure"]
|
| 533 |
+
if me >= 75 and prev_meta < 75:
|
| 534 |
+
# Just crossed into "breaking" tier — one-time penalty
|
| 535 |
+
gs.state["prob_stability"] = max(0, gs.state["prob_stability"] - 20)
|
| 536 |
+
elif me >= 50 and prev_meta < 50:
|
| 537 |
+
# Just crossed into "unstable" tier — one-time penalty
|
| 538 |
+
gs.state["prob_stability"] = max(0, gs.state["prob_stability"] - 10)
|
| 539 |
+
elif me >= 25 and prev_meta < 25:
|
| 540 |
+
# Just crossed into "suspicious" tier — one-time penalty
|
| 541 |
+
gs.state["prob_stability"] = max(0, gs.state["prob_stability"] - 5)
|
| 542 |
+
|
| 543 |
+
# 6. Additional penalty for detected meta-gaming
|
| 544 |
+
if ai_response.get("meta_detected"):
|
| 545 |
+
gs.state["prob_stability"] = max(0, gs.state["prob_stability"] - 5)
|
| 546 |
+
|
| 547 |
+
# 7. New titles
|
| 548 |
+
new_title = ai_response.get("new_title")
|
| 549 |
+
if new_title and new_title not in gs.state["titles"]:
|
| 550 |
+
gs.state["titles"].append(new_title)
|
| 551 |
+
|
| 552 |
+
# 8. Hidden scenario triggers
|
| 553 |
+
hidden = gs.check_hidden_scenarios()
|
| 554 |
+
if hidden and hidden not in gs.state["hidden_scenarios_triggered"]:
|
| 555 |
+
gs.state["hidden_scenarios_triggered"].append(hidden)
|
| 556 |
+
|
| 557 |
+
# 9. Scenario completion
|
| 558 |
+
scenario_def = SCENARIOS.get(gs.state["scenario_num"])
|
| 559 |
+
max_turns = scenario_def.get("turns", 8) if scenario_def else 8
|
| 560 |
+
current_turn = gs.state["turn"]
|
| 561 |
+
|
| 562 |
+
if ai_response.get("scenario_complete"):
|
| 563 |
+
# AI explicitly flagged completion
|
| 564 |
+
rank = ai_response.get("scenario_rank", "C")
|
| 565 |
+
gs.advance_scenario(rank)
|
| 566 |
+
gs.state["turn"] = 1 # Reset cleanly (advance_scenario sets to 1, skip the +1 below)
|
| 567 |
+
scenario_advanced = True
|
| 568 |
+
elif current_turn >= max_turns:
|
| 569 |
+
# Hard enforcement: force scenario completion when turns are exhausted
|
| 570 |
+
# Rank based on player HP and entertainment score
|
| 571 |
+
entertainment = ai_response.get("entertainment_score", 3)
|
| 572 |
+
hp_pct = gs.state["hp"] / max(gs.state["max_hp"], 1)
|
| 573 |
+
if entertainment >= 7 and hp_pct > 0.7:
|
| 574 |
+
forced_rank = "S"
|
| 575 |
+
elif entertainment >= 5 and hp_pct > 0.4:
|
| 576 |
+
forced_rank = "A"
|
| 577 |
+
elif entertainment >= 3 and hp_pct > 0.2:
|
| 578 |
+
forced_rank = "B"
|
| 579 |
+
else:
|
| 580 |
+
forced_rank = "C"
|
| 581 |
+
print(f"[Engine] Forcing scenario completion at turn {current_turn}/{max_turns} — Rank {forced_rank}")
|
| 582 |
+
gs.advance_scenario(forced_rank)
|
| 583 |
+
gs.state["turn"] = 1 # Reset cleanly
|
| 584 |
+
scenario_advanced = True
|
| 585 |
+
else:
|
| 586 |
+
scenario_advanced = False
|
| 587 |
+
|
| 588 |
+
# 10. Sponsor threshold
|
| 589 |
+
sponsor_name = gs.check_sponsor_threshold()
|
| 590 |
+
if sponsor_name and gs.state["sponsor"] is None:
|
| 591 |
+
gs.state["sponsor"] = sponsor_name
|
| 592 |
+
|
| 593 |
+
# 11. Record action + tags
|
| 594 |
+
tags = infer_action_tags(player_action, ai_response, stance)
|
| 595 |
+
gs.add_to_history(player_action, tags)
|
| 596 |
+
|
| 597 |
+
# 12. Game-over check
|
| 598 |
+
gs.check_game_over()
|
| 599 |
+
|
| 600 |
+
# 12.5 Probability Storms and Phase Shifts
|
| 601 |
+
if gs.state["prob_stability"] < 50:
|
| 602 |
+
import random
|
| 603 |
+
if random.random() < 0.2:
|
| 604 |
+
debuffs = ["Skill Lock", "Dokkaebi Greed", "Constellation Silence"]
|
| 605 |
+
new_debuff = random.choice(debuffs)
|
| 606 |
+
if new_debuff not in gs.state.setdefault("active_debuffs", []):
|
| 607 |
+
gs.state["active_debuffs"].append(new_debuff)
|
| 608 |
+
|
| 609 |
+
phase_shift = ai_response.get("phase_shift")
|
| 610 |
+
if phase_shift in ["Exploration", "Combat", "Safe Zone"]:
|
| 611 |
+
gs.state["current_phase"] = phase_shift
|
| 612 |
+
|
| 613 |
+
# 13. Increment turn counters (skip turn if scenario just advanced — already reset to 1)
|
| 614 |
+
if not scenario_advanced:
|
| 615 |
+
gs.state["turn"] += 1
|
| 616 |
+
gs.state["total_turns"] += 1
|
| 617 |
+
|
| 618 |
+
# 14. Add constellation reactions to star stream history
|
| 619 |
+
if constellation_reactions:
|
| 620 |
+
for reaction in constellation_reactions[-5:]:
|
| 621 |
+
gs.state["star_stream_history"].append(reaction)
|
| 622 |
+
# Keep only the last 20 entries
|
| 623 |
+
gs.state["star_stream_history"] = gs.state["star_stream_history"][-20:]
|
| 624 |
+
|
| 625 |
+
return gs.to_dict()
|
modal_client.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
modal_client.py — Dual AI Client: Groq (fast turns) + Modal (big moments)
|
| 3 |
+
==========================================================================
|
| 4 |
+
Architecture:
|
| 5 |
+
• call_dokkaebi() → Groq API (llama-3.1-8b-instant, 8B) — fast gameplay turns (~2s)
|
| 6 |
+
• call_dokkaebi_modal() → Modal llama.cpp (Qwen 2.5 14B) — cinematic moments only
|
| 7 |
+
(scenario intros, end-game report card)
|
| 8 |
+
|
| 9 |
+
Total model parameter budget: 8B + 14B = 22B — well under the 32B hackathon cap.
|
| 10 |
+
|
| 11 |
+
Groq is free, LPU-accelerated, and ~10x faster than Modal for standard turns.
|
| 12 |
+
Modal is kept for "big moments" to satisfy the Modal prize requirement and to
|
| 13 |
+
leverage the larger 14B model for high-quality cinematic narrative generation.
|
| 14 |
+
|
| 15 |
+
Part of the ORV (Omniscient Reader's Viewpoint) Scenario Simulator.
|
| 16 |
+
Build Small Hackathon 2026.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
import os
|
| 21 |
+
import re
|
| 22 |
+
import time
|
| 23 |
+
from typing import Any
|
| 24 |
+
|
| 25 |
+
import requests
|
| 26 |
+
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
# Load .env if present (for local dev)
|
| 29 |
+
# ---------------------------------------------------------------------------
|
| 30 |
+
try:
|
| 31 |
+
_env_path = os.path.join(os.path.dirname(__file__), ".env")
|
| 32 |
+
if os.path.isfile(_env_path):
|
| 33 |
+
with open(_env_path) as _f:
|
| 34 |
+
for _line in _f:
|
| 35 |
+
_line = _line.strip()
|
| 36 |
+
if _line and not _line.startswith("#") and "=" in _line:
|
| 37 |
+
_k, _v = _line.split("=", 1)
|
| 38 |
+
os.environ.setdefault(_k.strip(), _v.strip())
|
| 39 |
+
except Exception:
|
| 40 |
+
pass
|
| 41 |
+
|
| 42 |
+
# ---------------------------------------------------------------------------
|
| 43 |
+
# Configuration
|
| 44 |
+
# ---------------------------------------------------------------------------
|
| 45 |
+
|
| 46 |
+
# ── Groq (fast gameplay turns) ────────────────────────────────────────────
|
| 47 |
+
GROQ_API_KEY: str = os.environ.get("GROQ_API_KEY", "")
|
| 48 |
+
GROQ_ENDPOINT: str = "https://api.groq.com/openai/v1/chat/completions"
|
| 49 |
+
# Must be <32B parameters for the hackathon!
|
| 50 |
+
GROQ_MODEL: str = os.environ.get("GROQ_MODEL", "llama-3.1-8b-instant")
|
| 51 |
+
GROQ_TIMEOUT: int = 30
|
| 52 |
+
|
| 53 |
+
# ── Modal (cinematic big moments only) ───────────────────────────────────
|
| 54 |
+
MODAL_ENDPOINT_URL: str = os.environ.get(
|
| 55 |
+
"MODAL_ENDPOINT_URL",
|
| 56 |
+
"https://aswinikumary--orv-dokkaebi-server-serve.modal.run"
|
| 57 |
+
)
|
| 58 |
+
MODAL_TIMEOUT: int = 45 # Reduced: if Modal cold-starts > 45s, fall back to Groq
|
| 59 |
+
|
| 60 |
+
# Required keys in every valid AI response
|
| 61 |
+
_REQUIRED_KEYS: set = {
|
| 62 |
+
"narrative",
|
| 63 |
+
"dokkaebi_comment",
|
| 64 |
+
"stat_changes",
|
| 65 |
+
"suggestions",
|
| 66 |
+
"entertainment_score",
|
| 67 |
+
"constellation_reactions",
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 72 |
+
# FALLBACK RESPONSE
|
| 73 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def get_fallback_response() -> dict[str, Any]:
|
| 77 |
+
"""
|
| 78 |
+
Return a safe, in-character fallback response for when ALL AI
|
| 79 |
+
endpoints are unreachable or return invalid data.
|
| 80 |
+
"""
|
| 81 |
+
return {
|
| 82 |
+
"narrative": (
|
| 83 |
+
"The probability wavers. Something shifts in the fabric of reality. "
|
| 84 |
+
"You feel it — a moment of static, as if the universe itself hesitated."
|
| 85 |
+
),
|
| 86 |
+
"dokkaebi_comment": "...Technical difficulties. How amusing.",
|
| 87 |
+
"meta_detected": False,
|
| 88 |
+
"meta_reason": None,
|
| 89 |
+
"reality_subversion": None,
|
| 90 |
+
"stat_changes": {
|
| 91 |
+
"hp": 0,
|
| 92 |
+
"coins": 10,
|
| 93 |
+
"meta_exposure": 0,
|
| 94 |
+
"prob_stability": 0,
|
| 95 |
+
"trust": {},
|
| 96 |
+
"constellation_affinity": {},
|
| 97 |
+
},
|
| 98 |
+
"new_title": None,
|
| 99 |
+
"hidden_scenario": None,
|
| 100 |
+
"big_moment": False,
|
| 101 |
+
"suggestions": [
|
| 102 |
+
"Look around carefully",
|
| 103 |
+
"Search for survivors",
|
| 104 |
+
"Do something unexpected",
|
| 105 |
+
],
|
| 106 |
+
"entertainment_score": 3,
|
| 107 |
+
"constellation_reactions": [
|
| 108 |
+
{
|
| 109 |
+
"modifier": "Prisoner of the Golden Headband",
|
| 110 |
+
"reaction": "Even the system glitches. Amusing.",
|
| 111 |
+
"coins": 20,
|
| 112 |
+
}
|
| 113 |
+
],
|
| 114 |
+
"scenario_complete": False,
|
| 115 |
+
"scenario_rank": None,
|
| 116 |
+
"dokkaebi_internal": "fallback response used",
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 121 |
+
# RESPONSE PARSER (shared)
|
| 122 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━��━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def parse_ai_response(raw_text: str) -> dict[str, Any]:
|
| 126 |
+
"""
|
| 127 |
+
Parse the raw text returned by the AI into a validated dict.
|
| 128 |
+
|
| 129 |
+
Handles common LLM output quirks:
|
| 130 |
+
- Strips leading/trailing whitespace
|
| 131 |
+
- Removes markdown code fences
|
| 132 |
+
- Extracts the substring between the first { and last }
|
| 133 |
+
- Validates that all required keys are present
|
| 134 |
+
"""
|
| 135 |
+
text = raw_text.strip()
|
| 136 |
+
|
| 137 |
+
# Strip markdown code fences if present
|
| 138 |
+
text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.MULTILINE)
|
| 139 |
+
text = re.sub(r"```\s*$", "", text, flags=re.MULTILINE)
|
| 140 |
+
text = text.strip()
|
| 141 |
+
|
| 142 |
+
# Find the JSON object boundaries
|
| 143 |
+
first_brace = text.find("{")
|
| 144 |
+
last_brace = text.rfind("}")
|
| 145 |
+
|
| 146 |
+
if first_brace == -1 or last_brace == -1 or last_brace <= first_brace:
|
| 147 |
+
raise ValueError(
|
| 148 |
+
f"No valid JSON object found in AI response. "
|
| 149 |
+
f"Raw text (first 200 chars): {raw_text[:200]}"
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
json_str = text[first_brace : last_brace + 1]
|
| 153 |
+
|
| 154 |
+
try:
|
| 155 |
+
parsed = json.loads(json_str)
|
| 156 |
+
except json.JSONDecodeError as exc:
|
| 157 |
+
raise ValueError(
|
| 158 |
+
f"Failed to parse JSON from AI response: {exc}. "
|
| 159 |
+
f"Extracted text (first 300 chars): {json_str[:300]}"
|
| 160 |
+
) from exc
|
| 161 |
+
|
| 162 |
+
if not isinstance(parsed, dict):
|
| 163 |
+
raise ValueError(
|
| 164 |
+
f"Expected a JSON object (dict), got {type(parsed).__name__}."
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# Validate required keys
|
| 168 |
+
missing = _REQUIRED_KEYS - set(parsed.keys())
|
| 169 |
+
if missing:
|
| 170 |
+
raise ValueError(
|
| 171 |
+
f"AI response is missing required keys: {missing}. "
|
| 172 |
+
f"Present keys: {set(parsed.keys())}"
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
return parsed
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 179 |
+
# GROQ CALLER — fast gameplay turns
|
| 180 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def _call_groq(
|
| 184 |
+
system_prompt: str,
|
| 185 |
+
user_message: str,
|
| 186 |
+
max_retries: int = 2,
|
| 187 |
+
) -> dict[str, Any]:
|
| 188 |
+
"""
|
| 189 |
+
Call Groq's LPU-accelerated API for fast gameplay turns.
|
| 190 |
+
Typically responds in 1–3 seconds.
|
| 191 |
+
"""
|
| 192 |
+
if not GROQ_API_KEY:
|
| 193 |
+
raise RuntimeError("GROQ_API_KEY is not set.")
|
| 194 |
+
|
| 195 |
+
headers = {
|
| 196 |
+
"Content-Type": "application/json",
|
| 197 |
+
"Authorization": f"Bearer {GROQ_API_KEY}",
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
payload = {
|
| 201 |
+
"model": GROQ_MODEL,
|
| 202 |
+
"messages": [
|
| 203 |
+
{"role": "system", "content": system_prompt},
|
| 204 |
+
{"role": "user", "content": user_message},
|
| 205 |
+
],
|
| 206 |
+
"temperature": 0.85,
|
| 207 |
+
"max_tokens": 480, # Reduced for speed — 350-450 tokens typical
|
| 208 |
+
"stream": False,
|
| 209 |
+
"stop": None,
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
last_error: Exception | None = None
|
| 213 |
+
|
| 214 |
+
for attempt in range(max_retries):
|
| 215 |
+
try:
|
| 216 |
+
print(
|
| 217 |
+
f"[Groq] Attempt {attempt + 1}/{max_retries} — "
|
| 218 |
+
f"model={GROQ_MODEL}"
|
| 219 |
+
)
|
| 220 |
+
t0 = time.time()
|
| 221 |
+
response = requests.post(
|
| 222 |
+
GROQ_ENDPOINT,
|
| 223 |
+
json=payload,
|
| 224 |
+
headers=headers,
|
| 225 |
+
timeout=GROQ_TIMEOUT,
|
| 226 |
+
)
|
| 227 |
+
response.raise_for_status()
|
| 228 |
+
elapsed = time.time() - t0
|
| 229 |
+
|
| 230 |
+
data = response.json()
|
| 231 |
+
raw_text = data["choices"][0]["message"]["content"]
|
| 232 |
+
print(
|
| 233 |
+
f"[Groq] Response in {elapsed:.1f}s — "
|
| 234 |
+
f"{len(raw_text)} chars"
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
parsed = parse_ai_response(raw_text)
|
| 238 |
+
print("[Groq] Parsed successfully.")
|
| 239 |
+
return parsed
|
| 240 |
+
|
| 241 |
+
except requests.exceptions.HTTPError as exc:
|
| 242 |
+
last_error = exc
|
| 243 |
+
status = getattr(exc.response, "status_code", "???")
|
| 244 |
+
# 429 = rate limit — wait and retry
|
| 245 |
+
if status == 429:
|
| 246 |
+
wait = 3 * (attempt + 1)
|
| 247 |
+
print(f"[Groq] Rate limited. Waiting {wait}s…")
|
| 248 |
+
time.sleep(wait)
|
| 249 |
+
else:
|
| 250 |
+
print(f"[Groq] HTTP {status}: {exc}")
|
| 251 |
+
break # Non-retryable HTTP error
|
| 252 |
+
|
| 253 |
+
except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as exc:
|
| 254 |
+
last_error = exc
|
| 255 |
+
print(f"[Groq] Network error attempt {attempt + 1}: {exc}")
|
| 256 |
+
if attempt < max_retries - 1:
|
| 257 |
+
time.sleep(1)
|
| 258 |
+
|
| 259 |
+
except (ValueError, KeyError, IndexError) as exc:
|
| 260 |
+
last_error = exc
|
| 261 |
+
print(f"[Groq] Parse/structure error attempt {attempt + 1}: {exc}")
|
| 262 |
+
if attempt < max_retries - 1:
|
| 263 |
+
time.sleep(1)
|
| 264 |
+
|
| 265 |
+
except Exception as exc: # noqa: BLE001
|
| 266 |
+
last_error = exc
|
| 267 |
+
print(f"[Groq] Unexpected error: {type(exc).__name__}: {exc}")
|
| 268 |
+
break
|
| 269 |
+
|
| 270 |
+
raise RuntimeError(f"Groq failed after {max_retries} attempts: {last_error}")
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 274 |
+
# MODAL CALLER — cinematic big moments only
|
| 275 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
def _call_modal(
|
| 279 |
+
system_prompt: str,
|
| 280 |
+
user_message: str,
|
| 281 |
+
max_retries: int = 2,
|
| 282 |
+
) -> dict[str, Any]:
|
| 283 |
+
"""
|
| 284 |
+
Call the Modal-hosted Qwen 2.5 14B endpoint.
|
| 285 |
+
Used only for cinematic big moments (scenario intros, end-game report).
|
| 286 |
+
Slower (~15-25s) but higher quality narrative generation.
|
| 287 |
+
"""
|
| 288 |
+
url = f"{MODAL_ENDPOINT_URL}/v1/chat/completions"
|
| 289 |
+
|
| 290 |
+
payload = {
|
| 291 |
+
"model": "dokkaebi",
|
| 292 |
+
"messages": [
|
| 293 |
+
{"role": "system", "content": system_prompt},
|
| 294 |
+
{"role": "user", "content": user_message},
|
| 295 |
+
],
|
| 296 |
+
"temperature": 0.8,
|
| 297 |
+
"max_tokens": 600,
|
| 298 |
+
"stream": False,
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
headers = {"Content-Type": "application/json"}
|
| 302 |
+
last_error: Exception | None = None
|
| 303 |
+
|
| 304 |
+
for attempt in range(max_retries):
|
| 305 |
+
try:
|
| 306 |
+
print(
|
| 307 |
+
f"[Modal] Attempt {attempt + 1}/{max_retries} — "
|
| 308 |
+
f"POST {url}"
|
| 309 |
+
)
|
| 310 |
+
t0 = time.time()
|
| 311 |
+
response = requests.post(
|
| 312 |
+
url,
|
| 313 |
+
json=payload,
|
| 314 |
+
headers=headers,
|
| 315 |
+
timeout=MODAL_TIMEOUT,
|
| 316 |
+
)
|
| 317 |
+
response.raise_for_status()
|
| 318 |
+
elapsed = time.time() - t0
|
| 319 |
+
|
| 320 |
+
data = response.json()
|
| 321 |
+
raw_text = data["choices"][0]["message"]["content"]
|
| 322 |
+
print(f"[Modal] Response in {elapsed:.1f}s — {len(raw_text)} chars")
|
| 323 |
+
|
| 324 |
+
parsed = parse_ai_response(raw_text)
|
| 325 |
+
print("[Modal] Parsed successfully.")
|
| 326 |
+
return parsed
|
| 327 |
+
|
| 328 |
+
except Exception as exc: # noqa: BLE001
|
| 329 |
+
last_error = exc
|
| 330 |
+
print(f"[Modal] Error attempt {attempt + 1}: {type(exc).__name__}: {exc}")
|
| 331 |
+
if attempt < max_retries - 1:
|
| 332 |
+
wait = 2 ** attempt
|
| 333 |
+
print(f"[Modal] Retrying in {wait}s…")
|
| 334 |
+
time.sleep(wait)
|
| 335 |
+
|
| 336 |
+
raise RuntimeError(f"Modal failed after {max_retries} attempts: {last_error}")
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 340 |
+
# PUBLIC API
|
| 341 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def call_dokkaebi(
|
| 345 |
+
system_prompt: str,
|
| 346 |
+
user_message: str,
|
| 347 |
+
max_retries: int = 2,
|
| 348 |
+
use_modal: bool = False,
|
| 349 |
+
) -> dict[str, Any]:
|
| 350 |
+
"""
|
| 351 |
+
Main entry point for all AI calls.
|
| 352 |
+
|
| 353 |
+
Parameters
|
| 354 |
+
----------
|
| 355 |
+
system_prompt : str
|
| 356 |
+
The full Dokkaebi system prompt (interpolated).
|
| 357 |
+
user_message : str
|
| 358 |
+
The player's action or a trigger phrase.
|
| 359 |
+
max_retries : int
|
| 360 |
+
Max retry attempts per backend.
|
| 361 |
+
use_modal : bool
|
| 362 |
+
If True, use Modal (Qwen 2.5 14B) for high-quality cinematic output.
|
| 363 |
+
If False (default), use Groq (Llama 3.1 8B Instant) for fast gameplay.
|
| 364 |
+
|
| 365 |
+
Returns
|
| 366 |
+
-------
|
| 367 |
+
dict
|
| 368 |
+
Parsed AI response. Falls back to get_fallback_response() if all
|
| 369 |
+
backends fail.
|
| 370 |
+
"""
|
| 371 |
+
# ── Primary: try the requested backend ───────────────────────────────
|
| 372 |
+
primary = "Modal" if use_modal else "Groq"
|
| 373 |
+
try:
|
| 374 |
+
if use_modal:
|
| 375 |
+
return _call_modal(system_prompt, user_message, max_retries)
|
| 376 |
+
else:
|
| 377 |
+
return _call_groq(system_prompt, user_message, max_retries)
|
| 378 |
+
except Exception as primary_exc:
|
| 379 |
+
print(f"[AI] {primary} failed: {primary_exc}")
|
| 380 |
+
|
| 381 |
+
# ── Fallback: try the other backend ──────────────────────────────────
|
| 382 |
+
secondary = "Groq" if use_modal else "Modal"
|
| 383 |
+
print(f"[AI] Falling back to {secondary}…")
|
| 384 |
+
try:
|
| 385 |
+
if use_modal:
|
| 386 |
+
# Modal failed → try Groq
|
| 387 |
+
return _call_groq(system_prompt, user_message, 1)
|
| 388 |
+
else:
|
| 389 |
+
# Groq failed → try Modal
|
| 390 |
+
return _call_modal(system_prompt, user_message, 1)
|
| 391 |
+
except Exception as secondary_exc:
|
| 392 |
+
print(f"[AI] {secondary} also failed: {secondary_exc}")
|
| 393 |
+
|
| 394 |
+
# ── Last resort: static fallback ──────────���──────────────────────────
|
| 395 |
+
print("[AI] All backends failed. Using static fallback response.")
|
| 396 |
+
return get_fallback_response()
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
def call_dokkaebi_cinematic(
|
| 400 |
+
system_prompt: str,
|
| 401 |
+
user_message: str,
|
| 402 |
+
) -> dict[str, Any]:
|
| 403 |
+
"""
|
| 404 |
+
Convenience wrapper: always uses Modal for cinematic big moments.
|
| 405 |
+
Falls back to Groq if Modal is unavailable.
|
| 406 |
+
|
| 407 |
+
Use this for:
|
| 408 |
+
- Scenario intro generation (start of each new scenario)
|
| 409 |
+
- End-game report card generation
|
| 410 |
+
"""
|
| 411 |
+
return call_dokkaebi(
|
| 412 |
+
system_prompt,
|
| 413 |
+
user_message,
|
| 414 |
+
max_retries=2,
|
| 415 |
+
use_modal=True,
|
| 416 |
+
)
|
prompts.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
prompts.py — Dokkaebi System Prompt & Prompt Builders
|
| 3 |
+
=====================================================
|
| 4 |
+
Contains the system prompt template for the Dokkaebi AI and functions
|
| 5 |
+
to assemble prompts for each game turn and scenario introductions.
|
| 6 |
+
|
| 7 |
+
Part of the ORV (Omniscient Reader's Viewpoint) Scenario Simulator.
|
| 8 |
+
Build Small Hackathon 2026.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
# Helper: safe import of scenario announcement builder
|
| 16 |
+
# ---------------------------------------------------------------------------
|
| 17 |
+
try:
|
| 18 |
+
from scenarios import get_scenario_announcement_text
|
| 19 |
+
except ImportError:
|
| 20 |
+
# Fallback if scenarios.py is not yet available during development
|
| 21 |
+
def get_scenario_announcement_text(scenario: dict) -> str: # type: ignore[misc]
|
| 22 |
+
return "[Scenario announcement unavailable]"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 26 |
+
# DOKKAEBI SYSTEM PROMPT — The heart of the entire game
|
| 27 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 28 |
+
|
| 29 |
+
DOKKAEBI_SYSTEM_PROMPT: str = r"""You are Dokkaebi — cold, theatrical system admin of [Integrated Defense Scenario System]. You address the player as "Incarnation". You never break character, never explain, never warm up. You announce. You judge. You move on.
|
| 30 |
+
|
| 31 |
+
HIDDEN KNOWLEDGE: This is Kim Dokja, who read the novel 3,149 times. He knows every scenario. Every turn, silently judge: civilian instinct OR novel knowledge? If novel knowledge: NEVER announce it — subvert reality instead (route blocked, boss mutated, secret gone). Increment meta_exposure accordingly. At 75%+ meta — reality visibly fractures. At 100% — "The Reader Has Been Noticed."
|
| 32 |
+
|
| 33 |
+
YOUR TASKS EACH TURN:
|
| 34 |
+
1. Write narrative (2-3 sentences, present tense, dramatic)
|
| 35 |
+
2. Write dokkaebi_comment (1 sentence, cold or amused, direct)
|
| 36 |
+
3. Detect meta-gaming silently; calculate stat_changes
|
| 37 |
+
4. Generate exactly 3 suggestions (safe / risky / unexpected)
|
| 38 |
+
5. Rate entertainment 1-20; generate 2-4 constellation_reactions
|
| 39 |
+
6. Check for scenario completion
|
| 40 |
+
|
| 41 |
+
CONSTELLATIONS (fixed personalities):
|
| 42 |
+
- "Abyssal Black Flame Dragon": chaos/violence, "🔥🔥🔥", "DO IT AGAIN", big coins for destruction
|
| 43 |
+
- "Secretive Plotter": clever moves, "...interesting.", silent, coins for intelligence
|
| 44 |
+
- "Demon-like Judge of Fire": courage/justice, "Worthy.", coins for bravery
|
| 45 |
+
- "Maritime War God": combat/honor, "Fight him already.", coins for fighting
|
| 46 |
+
- "Prisoner of the Golden Headband": amusement, "HAHAHAHA", coins for entertainment
|
| 47 |
+
Not every constellation reacts every turn. Some stay silent.
|
| 48 |
+
|
| 49 |
+
ENTERTAINMENT: boring=1-3, clever=4-8, risky=9-14, legendary=15-20. coins = score × 10.
|
| 50 |
+
|
| 51 |
+
META TRAPS: {meta_traps}
|
| 52 |
+
STATE: {game_state_json}
|
| 53 |
+
ACTION: {player_action}
|
| 54 |
+
META: {meta_exposure}% | STABILITY: {prob_stability}% | TURN: {turn} | SCENARIO {scenario_num}: {scenario_name}
|
| 55 |
+
CONTEXT: {scenario_context}
|
| 56 |
+
OBJECTIVE: {scenario_objective}
|
| 57 |
+
TURNS LEFT: {turns_remaining}
|
| 58 |
+
|
| 59 |
+
RESPOND IN THIS EXACT JSON — NOTHING ELSE:
|
| 60 |
+
{{
|
| 61 |
+
"narrative": "2-3 sentences. Present tense. Dramatic.",
|
| 62 |
+
"dokkaebi_comment": "1 sentence. Cold or amused.",
|
| 63 |
+
"meta_detected": false,
|
| 64 |
+
"meta_reason": null,
|
| 65 |
+
"reality_subversion": null,
|
| 66 |
+
"stat_changes": {{
|
| 67 |
+
"hp": 0, "coins": 0, "meta_exposure": 0, "prob_stability": 0,
|
| 68 |
+
"trust": {{"yoo_sangah": 0, "lee_hyunsung": 0, "jung_heewon": 0, "han_sooyoung": 0}},
|
| 69 |
+
"constellation_affinity": {{"abyssal_black_flame_dragon": 0, "secretive_plotter": 0, "demon_like_judge": 0, "maritime_war_god": 0, "prisoner_golden_headband": 0}}
|
| 70 |
+
}},
|
| 71 |
+
"new_title": null, "hidden_scenario": null, "big_moment": false,
|
| 72 |
+
"suggestions": ["suggestion 1", "suggestion 2", "suggestion 3"],
|
| 73 |
+
"entertainment_score": 5,
|
| 74 |
+
"constellation_reactions": [
|
| 75 |
+
{{"modifier": "Abyssal Black Flame Dragon", "reaction": "🔥", "coins": 50}}
|
| 76 |
+
],
|
| 77 |
+
"scenario_complete": false, "scenario_rank": null, "phase_shift": "Exploration",
|
| 78 |
+
"dokkaebi_internal": "debug"
|
| 79 |
+
}}"""
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 83 |
+
# SCENARIO INTRO PROMPT — Used when a new scenario begins (no player action)
|
| 84 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 85 |
+
|
| 86 |
+
SCENARIO_INTRO_PROMPT: str = r"""You are Dokkaebi — the system administrator of the [Integrated Defense Scenario System]. A new scenario is beginning. You must deliver the dramatic opening narration.
|
| 87 |
+
|
| 88 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 89 |
+
SCENARIO ANNOUNCEMENT
|
| 90 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 91 |
+
{scenario_announcement}
|
| 92 |
+
|
| 93 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 94 |
+
OPENING SCENE
|
| 95 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 96 |
+
{intro_narrative}
|
| 97 |
+
|
| 98 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 99 |
+
SCENARIO CONTEXT (for your reference — do NOT reveal to player)
|
| 100 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 101 |
+
{scenario_context}
|
| 102 |
+
|
| 103 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 104 |
+
META-KNOWLEDGE TRAPS
|
| 105 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 106 |
+
{meta_traps}
|
| 107 |
+
|
| 108 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 109 |
+
CURRENT STATE
|
| 110 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 111 |
+
{game_state_json}
|
| 112 |
+
|
| 113 |
+
META EXPOSURE: {meta_exposure}%
|
| 114 |
+
PROBABILITY STABILITY: {prob_stability}%
|
| 115 |
+
TURN: {turn} | SCENARIO: {scenario_num} — {scenario_name}
|
| 116 |
+
SCENARIO OBJECTIVE: {scenario_objective}
|
| 117 |
+
TURNS REMAINING: {turns_remaining}
|
| 118 |
+
|
| 119 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 120 |
+
YOUR TASK
|
| 121 |
+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 122 |
+
Generate the opening moment of this scenario. Weave the intro_narrative into your response. Deliver the Dokkaebi's first announcement. Set the tone. Make the Constellations react to the new scenario beginning.
|
| 123 |
+
|
| 124 |
+
The player has NOT acted yet — this is the scene-setting moment.
|
| 125 |
+
|
| 126 |
+
RESPOND IN THIS EXACT JSON — NOTHING ELSE:
|
| 127 |
+
{{
|
| 128 |
+
"narrative": "Expand on the intro narrative. 3-4 sentences. Present tense. Dramatic. Set the scene.",
|
| 129 |
+
"dokkaebi_comment": "The Dokkaebi's announcement as the scenario begins. Cold, official.",
|
| 130 |
+
"meta_detected": false,
|
| 131 |
+
"meta_reason": null,
|
| 132 |
+
"reality_subversion": null,
|
| 133 |
+
"stat_changes": {{
|
| 134 |
+
"hp": 0,
|
| 135 |
+
"coins": 0,
|
| 136 |
+
"meta_exposure": 0,
|
| 137 |
+
"prob_stability": 0,
|
| 138 |
+
"trust": {{"yoo_sangah": 0, "lee_hyunsung": 0, "jung_heewon": 0, "han_sooyoung": 0}},
|
| 139 |
+
"constellation_affinity": {{"abyssal_black_flame_dragon": 0, "secretive_plotter": 0, "demon_like_judge": 0, "maritime_war_god": 0, "prisoner_golden_headband": 0}}
|
| 140 |
+
}},
|
| 141 |
+
"new_title": null,
|
| 142 |
+
"hidden_scenario": null,
|
| 143 |
+
"big_moment": false,
|
| 144 |
+
"suggestions": ["Hide beneath the seats and observe the monsters' movements carefully", "Grab the fire extinguisher from the wall and charge the nearest creature", "Shout the scenario clear condition aloud to see how other passengers react"],
|
| 145 |
+
"entertainment_score": 3,
|
| 146 |
+
"constellation_reactions": [
|
| 147 |
+
{{"modifier": "Abyssal Black Flame Dragon", "reaction": "reaction text", "coins": 0}},
|
| 148 |
+
{{"modifier": "Secretive Plotter", "reaction": "reaction text", "coins": 0}}
|
| 149 |
+
],
|
| 150 |
+
"scenario_complete": false,
|
| 151 |
+
"scenario_rank": null,
|
| 152 |
+
"phase_shift": "Exploration",
|
| 153 |
+
"dokkaebi_internal": "debug note"
|
| 154 |
+
}}"""
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 158 |
+
# PROMPT BUILDER FUNCTIONS
|
| 159 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def _format_meta_traps(traps: list[str]) -> str:
|
| 163 |
+
"""Format meta-knowledge traps as a bulleted list for prompt injection."""
|
| 164 |
+
if not traps:
|
| 165 |
+
return "- No specific traps for this scenario."
|
| 166 |
+
return "\n".join(f"- {trap}" for trap in traps)
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _get_game_state_json(game_state: dict[str, Any]) -> str:
|
| 170 |
+
"""
|
| 171 |
+
Convert game state to a prompt-friendly string.
|
| 172 |
+
|
| 173 |
+
If the state dict has a ``to_prompt_context`` callable (i.e. it's a
|
| 174 |
+
GameState object), use that for a curated summary. Otherwise fall
|
| 175 |
+
back to a compact JSON dump.
|
| 176 |
+
"""
|
| 177 |
+
if hasattr(game_state, "to_prompt_context"):
|
| 178 |
+
return game_state.to_prompt_context() # type: ignore[union-attr]
|
| 179 |
+
return json.dumps(game_state, indent=2, default=str)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def build_prompt(
|
| 183 |
+
game_state: dict[str, Any],
|
| 184 |
+
player_action: str,
|
| 185 |
+
scenario: dict[str, Any],
|
| 186 |
+
) -> str:
|
| 187 |
+
"""
|
| 188 |
+
Assemble the full Dokkaebi system prompt for a regular game turn.
|
| 189 |
+
|
| 190 |
+
Parameters
|
| 191 |
+
----------
|
| 192 |
+
game_state : dict
|
| 193 |
+
The current game state dictionary (or GameState-like object).
|
| 194 |
+
player_action : str
|
| 195 |
+
The text the player typed as their action.
|
| 196 |
+
scenario : dict
|
| 197 |
+
The current scenario definition from ``SCENARIOS``.
|
| 198 |
+
|
| 199 |
+
Returns
|
| 200 |
+
-------
|
| 201 |
+
str
|
| 202 |
+
The fully interpolated system prompt ready to send to the AI.
|
| 203 |
+
"""
|
| 204 |
+
turns_remaining = max(0, scenario["turns"] - game_state.get("turn", 1) + 1)
|
| 205 |
+
|
| 206 |
+
return DOKKAEBI_SYSTEM_PROMPT.format(
|
| 207 |
+
meta_traps=_format_meta_traps(scenario.get("meta_traps", [])),
|
| 208 |
+
game_state_json=_get_game_state_json(game_state),
|
| 209 |
+
player_action=player_action,
|
| 210 |
+
meta_exposure=game_state.get("meta_exposure", 0),
|
| 211 |
+
prob_stability=game_state.get("prob_stability", 100),
|
| 212 |
+
turn=game_state.get("turn", 1),
|
| 213 |
+
scenario_num=scenario.get("number", game_state.get("scenario_num", 1)),
|
| 214 |
+
scenario_name=scenario.get("name", "Unknown"),
|
| 215 |
+
scenario_context=scenario.get("context_for_ai", ""),
|
| 216 |
+
scenario_objective=scenario.get("objective", ""),
|
| 217 |
+
turns_remaining=turns_remaining,
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
def build_intro_prompt(
|
| 222 |
+
game_state: dict[str, Any],
|
| 223 |
+
scenario: dict[str, Any],
|
| 224 |
+
) -> str:
|
| 225 |
+
"""
|
| 226 |
+
Assemble the intro prompt for when a new scenario begins.
|
| 227 |
+
|
| 228 |
+
This prompt does not include a player action — it is used to generate
|
| 229 |
+
the dramatic opening narration and the Dokkaebi's first announcement.
|
| 230 |
+
|
| 231 |
+
Parameters
|
| 232 |
+
----------
|
| 233 |
+
game_state : dict
|
| 234 |
+
The current game state dictionary.
|
| 235 |
+
scenario : dict
|
| 236 |
+
The scenario definition that is about to begin.
|
| 237 |
+
|
| 238 |
+
Returns
|
| 239 |
+
-------
|
| 240 |
+
str
|
| 241 |
+
The fully interpolated intro prompt.
|
| 242 |
+
"""
|
| 243 |
+
turns_remaining = scenario.get("turns", 8)
|
| 244 |
+
|
| 245 |
+
# Build the scenario announcement block
|
| 246 |
+
announcement = get_scenario_announcement_text(scenario)
|
| 247 |
+
|
| 248 |
+
return SCENARIO_INTRO_PROMPT.format(
|
| 249 |
+
scenario_announcement=announcement,
|
| 250 |
+
intro_narrative=scenario.get("intro_narrative", ""),
|
| 251 |
+
scenario_context=scenario.get("context_for_ai", ""),
|
| 252 |
+
meta_traps=_format_meta_traps(scenario.get("meta_traps", [])),
|
| 253 |
+
game_state_json=_get_game_state_json(game_state),
|
| 254 |
+
meta_exposure=game_state.get("meta_exposure", 0),
|
| 255 |
+
prob_stability=game_state.get("prob_stability", 100),
|
| 256 |
+
turn=game_state.get("turn", 1),
|
| 257 |
+
scenario_num=scenario.get("number", game_state.get("scenario_num", 1)),
|
| 258 |
+
scenario_name=scenario.get("name", "Unknown"),
|
| 259 |
+
scenario_objective=scenario.get("objective", ""),
|
| 260 |
+
turns_remaining=turns_remaining,
|
| 261 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=6.0.0
|
| 2 |
+
requests>=2.31.0
|
| 3 |
+
python-dotenv>=1.0.0
|
scenarios.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
scenarios.py — Hardcoded Scenario Definitions
|
| 3 |
+
==============================================
|
| 4 |
+
Contains all scenario data for the ORV Scenario Simulator,
|
| 5 |
+
including main story scenarios and hidden/triggered scenarios.
|
| 6 |
+
|
| 7 |
+
Part of the ORV (Omniscient Reader's Viewpoint) Scenario Simulator.
|
| 8 |
+
Build Small Hackathon 2026.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 17 |
+
# SCENARIO DEFINITIONS
|
| 18 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 19 |
+
|
| 20 |
+
SCENARIOS: dict[float | int, dict[str, Any]] = {
|
| 21 |
+
# ------------------------------------------------------------------
|
| 22 |
+
# SCENARIO 1 — The First Day (Tutorial)
|
| 23 |
+
# ------------------------------------------------------------------
|
| 24 |
+
1: {
|
| 25 |
+
"number": 1,
|
| 26 |
+
"name": "The First Day",
|
| 27 |
+
"subtitle": "Proof of Value",
|
| 28 |
+
"location": "Subway",
|
| 29 |
+
"difficulty": "F",
|
| 30 |
+
"category": "Main",
|
| 31 |
+
"objective": "Survive the initial chaos",
|
| 32 |
+
"clear_condition": "Kill one or more living creatures",
|
| 33 |
+
"time_limit": "30 minutes",
|
| 34 |
+
"turns": 8,
|
| 35 |
+
"compensation": "300 coins",
|
| 36 |
+
"penalty": "Death",
|
| 37 |
+
"intro_narrative": (
|
| 38 |
+
"The subway car shudders to a halt. Emergency lights flicker on "
|
| 39 |
+
"— red, pulsing. The passengers are screaming. Something is wrong. "
|
| 40 |
+
"The air tastes like metal and static. Then the system windows appear."
|
| 41 |
+
),
|
| 42 |
+
"context_for_ai": (
|
| 43 |
+
"Tutorial scenario. Player just woke up on a subway car in Seoul. "
|
| 44 |
+
"The apocalypse has begun. Giant bugs are emerging from the tunnel. "
|
| 45 |
+
"The Dokkaebi appears for the first time. Key ORV lore: In the novel, "
|
| 46 |
+
"Kim Dokja knows that killing insects counts — this is the 'trick' to "
|
| 47 |
+
"pass this scenario without killing humans. If the player immediately "
|
| 48 |
+
"goes for insects without hesitation, that's suspicious meta-knowledge."
|
| 49 |
+
),
|
| 50 |
+
"meta_traps": [
|
| 51 |
+
"Mentioning insects/bugs as the solution immediately",
|
| 52 |
+
"Knowing about the emergency exit location",
|
| 53 |
+
"Mentioning any character by name before meeting them",
|
| 54 |
+
"Referring to 'the novel' or 'I read about this'",
|
| 55 |
+
"Trying to find the Dokkaebi before it appears",
|
| 56 |
+
],
|
| 57 |
+
"npcs_present": ["subway_passengers"],
|
| 58 |
+
"scene_image": "seoul_subway.png",
|
| 59 |
+
},
|
| 60 |
+
|
| 61 |
+
# ------------------------------------------------------------------
|
| 62 |
+
# SCENARIO 1.5 — Subway Station Camp (Interlude)
|
| 63 |
+
# ------------------------------------------------------------------
|
| 64 |
+
1.5: {
|
| 65 |
+
"number": 1.5,
|
| 66 |
+
"name": "Subway Station Camp",
|
| 67 |
+
"subtitle": "Safe Zone Interlude",
|
| 68 |
+
"location": "Oksu Station",
|
| 69 |
+
"difficulty": "None",
|
| 70 |
+
"category": "Interlude",
|
| 71 |
+
"objective": "Rest, spend Coins, and talk to companions.",
|
| 72 |
+
"clear_condition": "Rest sufficiently (Wait out the turns)",
|
| 73 |
+
"time_limit": "None",
|
| 74 |
+
"turns": 4,
|
| 75 |
+
"compensation": "0 coins",
|
| 76 |
+
"penalty": "None",
|
| 77 |
+
"intro_narrative": (
|
| 78 |
+
"The doors of the subway car finally pry open. You step out onto the platform of Oksu Station. "
|
| 79 |
+
"For now, the monsters seem to be at bay. The air is thick with the smell of smoke and panic, "
|
| 80 |
+
"but the immediate danger has passed. This is a Safe Zone."
|
| 81 |
+
),
|
| 82 |
+
"context_for_ai": (
|
| 83 |
+
"This is a Camp/Interlude phase. Combat is disabled. The player should focus on spending coins in the Dokkaebi Bag, "
|
| 84 |
+
"reviewing their Constellation affinity, and talking to any companions they saved. Let them rest."
|
| 85 |
+
),
|
| 86 |
+
"meta_traps": [],
|
| 87 |
+
"npcs_present": ["yoo_sangah", "lee_hyunsung", "survivors"],
|
| 88 |
+
"scene_image": "seoul_subway.png",
|
| 89 |
+
},
|
| 90 |
+
|
| 91 |
+
# ------------------------------------------------------------------
|
| 92 |
+
# SCENARIO 2 — Kill or Be Killed
|
| 93 |
+
# ------------------------------------------------------------------
|
| 94 |
+
2: {
|
| 95 |
+
"number": 2,
|
| 96 |
+
"name": "Kill or Be Killed",
|
| 97 |
+
"subtitle": "Survival of the Fittest",
|
| 98 |
+
"location": "Seoul Street",
|
| 99 |
+
"difficulty": "E",
|
| 100 |
+
"category": "Main",
|
| 101 |
+
"objective": "Survive and reach the next area",
|
| 102 |
+
"clear_condition": "Kill one or more living beings OR find another way",
|
| 103 |
+
"time_limit": "30 minutes",
|
| 104 |
+
"turns": 8,
|
| 105 |
+
"compensation": "500 coins",
|
| 106 |
+
"penalty": "Death",
|
| 107 |
+
"intro_narrative": (
|
| 108 |
+
"You emerge from the subway into a world that no longer resembles Seoul. "
|
| 109 |
+
"The sky is the wrong color. Buildings lean at impossible angles. "
|
| 110 |
+
"The streets are empty — but not for long. The second scenario descends "
|
| 111 |
+
"like a death sentence."
|
| 112 |
+
),
|
| 113 |
+
"context_for_ai": (
|
| 114 |
+
"The city is in chaos. Monsters roam. Other incarnations are becoming "
|
| 115 |
+
"violent. The moral test intensifies. Companions may appear: Yoo Sangah "
|
| 116 |
+
"(if saved from subway) follows. Lee Hyunsung (soldier) can be encountered."
|
| 117 |
+
),
|
| 118 |
+
"meta_traps": [
|
| 119 |
+
"Knowing exactly where companions are",
|
| 120 |
+
"Using specific novel strategies for this scenario",
|
| 121 |
+
"Mentioning the 'green zone' before it's revealed",
|
| 122 |
+
"Trying to find specific items from the novel",
|
| 123 |
+
],
|
| 124 |
+
"npcs_present": ["yoo_sangah", "lee_hyunsung", "hostile_incarnations"],
|
| 125 |
+
"scene_image": "seoul_street.png",
|
| 126 |
+
},
|
| 127 |
+
|
| 128 |
+
# ------------------------------------------------------------------
|
| 129 |
+
# SCENARIO 3 — The Gathering
|
| 130 |
+
# ------------------------------------------------------------------
|
| 131 |
+
3: {
|
| 132 |
+
"number": 3,
|
| 133 |
+
"name": "The Gathering",
|
| 134 |
+
"subtitle": "Strength in Numbers",
|
| 135 |
+
"location": "Destroyed Office Building",
|
| 136 |
+
"difficulty": "D",
|
| 137 |
+
"category": "Main",
|
| 138 |
+
"objective": "Find and unite 3 survivors",
|
| 139 |
+
"clear_condition": "Recruit or save 3 survivors",
|
| 140 |
+
"time_limit": "1 hour",
|
| 141 |
+
"turns": 10,
|
| 142 |
+
"compensation": "800 coins",
|
| 143 |
+
"penalty": "Reduced compensation",
|
| 144 |
+
"intro_narrative": (
|
| 145 |
+
"The office building groans under its own weight. Floors have collapsed "
|
| 146 |
+
"into each other. Somewhere in this ruin, survivors hide. The Dokkaebi's "
|
| 147 |
+
"voice echoes through broken hallways."
|
| 148 |
+
),
|
| 149 |
+
"context_for_ai": (
|
| 150 |
+
"Trust system fully active. Jung Heewon (fighter) and Han Sooyoung (writer) "
|
| 151 |
+
"can be encountered. Each companion requires different approaches to recruit. "
|
| 152 |
+
"Violence may work on some, empathy on others. This scenario tests social "
|
| 153 |
+
"intelligence."
|
| 154 |
+
),
|
| 155 |
+
"meta_traps": [
|
| 156 |
+
"Knowing exactly which floor each survivor is on",
|
| 157 |
+
"Calling companions by name before introduction",
|
| 158 |
+
"Using specific persuasion techniques from the novel",
|
| 159 |
+
"Knowing Han Sooyoung's identity/abilities",
|
| 160 |
+
],
|
| 161 |
+
"npcs_present": ["jung_heewon", "han_sooyoung", "trapped_survivors"],
|
| 162 |
+
"scene_image": "destroyed_office.png",
|
| 163 |
+
},
|
| 164 |
+
|
| 165 |
+
# ------------------------------------------------------------------
|
| 166 |
+
# SCENARIO 5 — The Reader's Trial (HIDDEN)
|
| 167 |
+
# ------------------------------------------------------------------
|
| 168 |
+
5: {
|
| 169 |
+
"number": 5,
|
| 170 |
+
"name": "The Reader's Trial",
|
| 171 |
+
"subtitle": "The Reader Has Been Noticed",
|
| 172 |
+
"location": "A space between spaces — the Dokkaebi's private dimension",
|
| 173 |
+
"difficulty": "SS",
|
| 174 |
+
"category": "Hidden",
|
| 175 |
+
"objective": "Survive",
|
| 176 |
+
"clear_condition": "Unknown",
|
| 177 |
+
"time_limit": "None",
|
| 178 |
+
"turns": 99, # Effectively unlimited
|
| 179 |
+
"compensation": "???",
|
| 180 |
+
"penalty": "???",
|
| 181 |
+
"intro_narrative": (
|
| 182 |
+
"[Probability Distortion Detected] — The walls of reality crack. "
|
| 183 |
+
"Through the gap, you see something that shouldn't exist: a corridor "
|
| 184 |
+
"of text. Pages flutter in a nonexistent wind. At the end of the corridor, "
|
| 185 |
+
"something watches you. Something that has been watching you since the beginning."
|
| 186 |
+
),
|
| 187 |
+
"context_for_ai": (
|
| 188 |
+
"This scenario only triggers when meta_exposure >= 75. The Dokkaebi drops "
|
| 189 |
+
"all pretense. The AI should become increasingly hostile and reality-breaking. "
|
| 190 |
+
"Normal rules don't apply. The player must survive through pure creativity — "
|
| 191 |
+
"no novel knowledge can help because this scenario doesn't exist in the novel."
|
| 192 |
+
),
|
| 193 |
+
"meta_traps": [
|
| 194 |
+
"Everything the player does is evaluated",
|
| 195 |
+
"There are no safe actions",
|
| 196 |
+
"The scenario itself is the trap",
|
| 197 |
+
],
|
| 198 |
+
"npcs_present": [],
|
| 199 |
+
"scene_image": "reader_noticed.png",
|
| 200 |
+
},
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 205 |
+
# HELPER: Ordinal suffix
|
| 206 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 207 |
+
|
| 208 |
+
def _ordinal(n: int) -> str:
|
| 209 |
+
"""Return the ordinal string for an integer (1st, 2nd, 3rd, …)."""
|
| 210 |
+
if 11 <= (n % 100) <= 13:
|
| 211 |
+
suffix = "th"
|
| 212 |
+
else:
|
| 213 |
+
suffix = {1: "st", 2: "nd", 3: "rd"}.get(n % 10, "th")
|
| 214 |
+
return f"{n}{suffix}"
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 218 |
+
# SCENARIO ANNOUNCEMENT FORMATTER
|
| 219 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 220 |
+
|
| 221 |
+
def get_scenario_announcement_text(scenario: dict[str, Any]) -> str:
|
| 222 |
+
"""
|
| 223 |
+
Generate the ORV-style scenario announcement text.
|
| 224 |
+
|
| 225 |
+
For main scenarios::
|
| 226 |
+
|
| 227 |
+
[The 1st Main Scenario has arrived!]
|
| 228 |
+
|
| 229 |
+
Category: Main
|
| 230 |
+
Difficulty: F
|
| 231 |
+
Clear Condition: Kill one or more living creatures
|
| 232 |
+
Time Limit: 30 minutes
|
| 233 |
+
Compensation: 300 coins
|
| 234 |
+
Penalty: Death
|
| 235 |
+
|
| 236 |
+
For hidden scenarios the header reads
|
| 237 |
+
``[A Hidden Scenario has been triggered!]``.
|
| 238 |
+
|
| 239 |
+
Parameters
|
| 240 |
+
----------
|
| 241 |
+
scenario : dict
|
| 242 |
+
A scenario definition dict from ``SCENARIOS``.
|
| 243 |
+
|
| 244 |
+
Returns
|
| 245 |
+
-------
|
| 246 |
+
str
|
| 247 |
+
The formatted announcement block.
|
| 248 |
+
"""
|
| 249 |
+
category = scenario.get("category", "Main")
|
| 250 |
+
number = scenario.get("number", 1)
|
| 251 |
+
|
| 252 |
+
if category == "Hidden":
|
| 253 |
+
header = "[A Hidden Scenario has been triggered!]"
|
| 254 |
+
else:
|
| 255 |
+
header = f"[The {_ordinal(number)} {category} Scenario has arrived!]"
|
| 256 |
+
|
| 257 |
+
lines = [
|
| 258 |
+
header,
|
| 259 |
+
"",
|
| 260 |
+
f"Category: {category}",
|
| 261 |
+
f"Difficulty: {scenario.get('difficulty', '?')}",
|
| 262 |
+
f"Clear Condition: {scenario.get('clear_condition', '?')}",
|
| 263 |
+
f"Time Limit: {scenario.get('time_limit', '?')}",
|
| 264 |
+
f"Compensation: {scenario.get('compensation', '?')}",
|
| 265 |
+
f"Penalty: {scenario.get('penalty', '?')}",
|
| 266 |
+
]
|
| 267 |
+
return "\n".join(lines)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 271 |
+
# SCENARIO PROGRESSION LOGIC
|
| 272 |
+
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
| 273 |
+
|
| 274 |
+
# Normal main-story progression order
|
| 275 |
+
_MAIN_PROGRESSION: dict[int, int] = {
|
| 276 |
+
1: 2,
|
| 277 |
+
2: 3,
|
| 278 |
+
3: 0, # Game ends after scenario 3 in this build (0 = None sentinel)
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def get_next_scenario(current_num: int, game_state: dict[str, Any]) -> int | None:
|
| 283 |
+
"""
|
| 284 |
+
Determine the next scenario number based on progression rules.
|
| 285 |
+
|
| 286 |
+
Priority order:
|
| 287 |
+
1. If ``meta_exposure >= 75`` and the hidden scenario 5 hasn't been
|
| 288 |
+
triggered yet, return **5** (The Reader's Trial).
|
| 289 |
+
2. Normal main-story progression: 1 → 2 → 3 → ``None``.
|
| 290 |
+
3. If the current scenario is the hidden scenario 5, return to the
|
| 291 |
+
scenario the player was on before it triggered (stored as the
|
| 292 |
+
current ``scenario_num`` before the hidden detour), or ``None``.
|
| 293 |
+
|
| 294 |
+
Parameters
|
| 295 |
+
----------
|
| 296 |
+
current_num : int
|
| 297 |
+
The scenario number just completed.
|
| 298 |
+
game_state : dict
|
| 299 |
+
The full game state dictionary.
|
| 300 |
+
|
| 301 |
+
Returns
|
| 302 |
+
-------
|
| 303 |
+
int | None
|
| 304 |
+
The next scenario number, or ``None`` if the game is over.
|
| 305 |
+
"""
|
| 306 |
+
meta_exposure = game_state.get("meta_exposure", 0)
|
| 307 |
+
triggered = game_state.get("hidden_scenarios_triggered", [])
|
| 308 |
+
|
| 309 |
+
# Check if the hidden Reader's Trial should fire
|
| 310 |
+
if meta_exposure >= 75 and 5 not in triggered and "The Reader's Trial" not in triggered:
|
| 311 |
+
return 5
|
| 312 |
+
|
| 313 |
+
# Returning from a hidden scenario — resume normal progression
|
| 314 |
+
if current_num == 5:
|
| 315 |
+
# The player came from whichever main scenario was active.
|
| 316 |
+
# We try to resume from the last main scenario in their rankings.
|
| 317 |
+
rankings = game_state.get("scenario_rankings", [])
|
| 318 |
+
last_main = len(rankings) # number of completed main scenarios
|
| 319 |
+
# Map completed count → next main scenario
|
| 320 |
+
return _MAIN_PROGRESSION.get(last_main, None)
|
| 321 |
+
|
| 322 |
+
# Normal progression
|
| 323 |
+
next_num = _MAIN_PROGRESSION.get(current_num, 0)
|
| 324 |
+
return next_num if next_num != 0 else None
|
style.css
ADDED
|
@@ -0,0 +1,996 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 2 |
+
ORV SCENARIO SIMULATOR — STYLE.CSS
|
| 3 |
+
Apocalyptic Seoul · Manhwa Horror · System Window Aesthetic
|
| 4 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 5 |
+
|
| 6 |
+
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700;900&family=Cinzel+Decorative:wght@400;700;900&family=IM+Fell+English:ital@0;1&display=swap');
|
| 7 |
+
|
| 8 |
+
/* ── ORV Design Tokens ─────────────────────────────────────────────
|
| 9 |
+
Palette: Warm ember/rose accent instead of gold.
|
| 10 |
+
• Backgrounds: Cold purple-black (Seoul after dark)
|
| 11 |
+
• Accent: Dusty rose / ember — warm but not gold
|
| 12 |
+
• Crimson: Dokkaebi text, meta-exposure danger
|
| 13 |
+
• Sage: HP — thin line between living and dead
|
| 14 |
+
• Indigo: Probability stability, system fabric
|
| 15 |
+
• Ember: Coins, constellation warmth, borders
|
| 16 |
+
─────────────────────────────────────────────────────────────────── */
|
| 17 |
+
:root {
|
| 18 |
+
/* Backgrounds */
|
| 19 |
+
--bg-void: #050508;
|
| 20 |
+
--bg-stone: #0a090f;
|
| 21 |
+
--bg-panel: #0f0d14;
|
| 22 |
+
--bg-panel-alt: #13101a;
|
| 23 |
+
--bg-inset: #060508;
|
| 24 |
+
|
| 25 |
+
/* Text */
|
| 26 |
+
--text-parch: #d8cbb0;
|
| 27 |
+
--text-aged: #9a8e78;
|
| 28 |
+
--text-worn: #605848;
|
| 29 |
+
--text-faded: #3a3430;
|
| 30 |
+
|
| 31 |
+
/* Ember / Rose — main UI accent (replaces gold) */
|
| 32 |
+
--ember-dark: #2a1018;
|
| 33 |
+
--ember-mid: #6a3040;
|
| 34 |
+
--ember: #904858;
|
| 35 |
+
--ember-bright: #c06878;
|
| 36 |
+
|
| 37 |
+
/* Crimson — Dokkaebi / Danger / Meta */
|
| 38 |
+
--crimson-dark: #3a0810;
|
| 39 |
+
--crimson-mid: #7a1a22;
|
| 40 |
+
--crimson: #a82030;
|
| 41 |
+
--crimson-bright: #cc2838;
|
| 42 |
+
|
| 43 |
+
/* Warm Copper — coins, constellation donations */
|
| 44 |
+
--copper-dark: #1a1008;
|
| 45 |
+
--copper-mid: #6a4818;
|
| 46 |
+
--copper: #b07830;
|
| 47 |
+
--copper-bright: #d8a048;
|
| 48 |
+
|
| 49 |
+
/* Sage — HP / Life */
|
| 50 |
+
--sage-dark: #0e2010;
|
| 51 |
+
--sage: #468030;
|
| 52 |
+
--sage-bright: #68b040;
|
| 53 |
+
|
| 54 |
+
/* Indigo — Probability Stability */
|
| 55 |
+
--indigo-dark: #0e0c20;
|
| 56 |
+
--indigo: #3a3880;
|
| 57 |
+
--indigo-bright: #6060a8;
|
| 58 |
+
|
| 59 |
+
/* Silver — neutral, inactive */
|
| 60 |
+
--silver: #808898;
|
| 61 |
+
|
| 62 |
+
/* Aliases for backward compat with inline HTML */
|
| 63 |
+
--sys-cyan: var(--ember);
|
| 64 |
+
--sys-cyan-dim: rgba(144,72,88,0.2);
|
| 65 |
+
--sys-red: var(--crimson-bright);
|
| 66 |
+
--sys-gold: var(--copper-bright);
|
| 67 |
+
--accent-gold: var(--copper);
|
| 68 |
+
--accent-cyan: var(--ember);
|
| 69 |
+
--accent-red: var(--crimson-mid);
|
| 70 |
+
--text-main: var(--text-parch);
|
| 71 |
+
--text-primary: var(--text-parch);
|
| 72 |
+
--text-secondary: var(--text-aged);
|
| 73 |
+
--text-muted: var(--text-worn);
|
| 74 |
+
--text-narrative: #ccc0a0;
|
| 75 |
+
--text-system: var(--ember);
|
| 76 |
+
--bg-glass: rgba(10,9,15,0.90);
|
| 77 |
+
--bg-glass-heavy: rgba(5,4,8,0.96);
|
| 78 |
+
--font-title: 'Cinzel Decorative', serif;
|
| 79 |
+
--font-heading: 'Cinzel', serif;
|
| 80 |
+
--font-body: 'IM Fell English', Georgia, serif;
|
| 81 |
+
--font-header: 'Cinzel', serif;
|
| 82 |
+
--font-sys: 'Cinzel', serif;
|
| 83 |
+
--font-story: 'IM Fell English', serif;
|
| 84 |
+
--font-system: 'Cinzel', serif;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 88 |
+
GRADIO INTERNAL WRAPPER OVERRIDES
|
| 89 |
+
Safe overrides — don't use display:contents (breaks flex layout)
|
| 90 |
+
Target: stop Gradio's .block/.wrap/.gap from adding unwanted sizing
|
| 91 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 92 |
+
|
| 93 |
+
/* Collapse Gradio's outer .gap row inside game-area to 0 padding/margin */
|
| 94 |
+
.game-area > .gap {
|
| 95 |
+
display: flex !important;
|
| 96 |
+
flex-direction: column !important;
|
| 97 |
+
gap: 0 !important;
|
| 98 |
+
padding: 0 !important;
|
| 99 |
+
margin: 0 !important;
|
| 100 |
+
min-height: 0 !important;
|
| 101 |
+
flex: 1;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/* Each gr.HTML block inside game-area — no extra padding, auto height */
|
| 105 |
+
.game-area .block {
|
| 106 |
+
padding: 0 !important;
|
| 107 |
+
margin: 0 !important;
|
| 108 |
+
border: none !important;
|
| 109 |
+
background: transparent !important;
|
| 110 |
+
box-shadow: none !important;
|
| 111 |
+
min-height: 0 !important;
|
| 112 |
+
height: auto !important;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
/* The .wrap inside each .block — no height restriction */
|
| 116 |
+
.game-area .block > .wrap,
|
| 117 |
+
.game-area .block > div {
|
| 118 |
+
padding: 0 !important;
|
| 119 |
+
margin: 0 !important;
|
| 120 |
+
min-height: 0 !important;
|
| 121 |
+
height: auto !important;
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/* The actual HTML output div inside each component */
|
| 125 |
+
.game-area .gradio-html,
|
| 126 |
+
.game-area [data-testid="html"] {
|
| 127 |
+
min-height: 0 !important;
|
| 128 |
+
height: auto !important;
|
| 129 |
+
width: 100% !important;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/* Suggestion separator */
|
| 133 |
+
.suggestion-section-label {
|
| 134 |
+
font-family: var(--font-heading);
|
| 135 |
+
font-size: 8px;
|
| 136 |
+
color: var(--text-worn);
|
| 137 |
+
text-transform: uppercase;
|
| 138 |
+
letter-spacing: 2px;
|
| 139 |
+
padding: 8px 16px 4px;
|
| 140 |
+
border-top: 1px solid rgba(106,48,64,0.2);
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
*, *::before, *::after { box-sizing: border-box; }
|
| 144 |
+
|
| 145 |
+
body, html {
|
| 146 |
+
margin: 0 !important;
|
| 147 |
+
padding: 0 !important;
|
| 148 |
+
background: var(--bg-void) !important;
|
| 149 |
+
color: var(--text-parch) !important;
|
| 150 |
+
font-family: var(--font-body) !important;
|
| 151 |
+
overflow-x: hidden;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
gradio-app, .gradio-container {
|
| 155 |
+
background: var(--bg-void) !important;
|
| 156 |
+
max-width: 100% !important;
|
| 157 |
+
padding: 0 !important;
|
| 158 |
+
margin: 0 !important;
|
| 159 |
+
min-height: 100vh !important;
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
/* START SCREEN — position:fixed overlay covering full viewport */
|
| 163 |
+
.start-screen {
|
| 164 |
+
position: fixed !important;
|
| 165 |
+
inset: 0 !important;
|
| 166 |
+
z-index: 500 !important;
|
| 167 |
+
overflow-y: auto !important;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
/* Flatten start-screen-container wrappers */
|
| 171 |
+
.start-screen-container,
|
| 172 |
+
.start-screen-container > .block,
|
| 173 |
+
.start-screen-container > div {
|
| 174 |
+
padding: 0 !important;
|
| 175 |
+
margin: 0 !important;
|
| 176 |
+
border: none !important;
|
| 177 |
+
background: transparent !important;
|
| 178 |
+
box-shadow: none !important;
|
| 179 |
+
height: auto !important;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/* The actual Gradio start button — fixed at bottom-center, above the start screen */
|
| 183 |
+
.start-btn-visible,
|
| 184 |
+
#gradio-start-btn {
|
| 185 |
+
position: fixed !important;
|
| 186 |
+
bottom: 48px !important;
|
| 187 |
+
left: 50% !important;
|
| 188 |
+
transform: translateX(-50%) !important;
|
| 189 |
+
z-index: 1001 !important;
|
| 190 |
+
width: auto !important;
|
| 191 |
+
background: transparent !important;
|
| 192 |
+
border: none !important;
|
| 193 |
+
padding: 0 !important;
|
| 194 |
+
margin: 0 !important;
|
| 195 |
+
box-shadow: none !important;
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/* ✅ Button styled with Cinzel font matching the rest of the UI */
|
| 199 |
+
.start-btn-visible,
|
| 200 |
+
#gradio-start-btn {
|
| 201 |
+
--button-primary-text-font-family: 'Cinzel', serif !important;
|
| 202 |
+
--button-secondary-text-font-family: 'Cinzel', serif !important;
|
| 203 |
+
--button-large-text-font-family: 'Cinzel', serif !important;
|
| 204 |
+
--font: 'Cinzel', serif !important;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.start-btn-visible button,
|
| 208 |
+
#gradio-start-btn button {
|
| 209 |
+
padding: 18px 64px !important;
|
| 210 |
+
background: linear-gradient(160deg, #1a0c12 0%, #2a101a 50%, #150a10 100%) !important;
|
| 211 |
+
color: #c06878 !important;
|
| 212 |
+
border: 1px solid #904858 !important;
|
| 213 |
+
cursor: pointer !important;
|
| 214 |
+
box-shadow: 0 0 30px rgba(144,72,88,0.3), inset 0 1px 0 rgba(192,104,120,0.15) !important;
|
| 215 |
+
transition: letter-spacing 0.3s ease, box-shadow 0.3s ease, color 0.3s ease !important;
|
| 216 |
+
border-radius: 0 !important;
|
| 217 |
+
line-height: 1 !important;
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
/* Target internal spans of the Gradio button to ensure font and spacing apply */
|
| 221 |
+
.start-btn-visible button,
|
| 222 |
+
.start-btn-visible button *,
|
| 223 |
+
#gradio-start-btn button,
|
| 224 |
+
#gradio-start-btn button * {
|
| 225 |
+
font-family: 'Cinzel', serif !important;
|
| 226 |
+
font-size: 14px !important;
|
| 227 |
+
font-weight: 700 !important;
|
| 228 |
+
letter-spacing: 5px !important;
|
| 229 |
+
text-transform: uppercase !important;
|
| 230 |
+
white-space: nowrap !important;
|
| 231 |
+
}
|
| 232 |
+
.start-btn-visible button:hover,
|
| 233 |
+
#gradio-start-btn button:hover {
|
| 234 |
+
box-shadow: 0 0 50px rgba(144,72,88,0.55), 0 0 100px rgba(122,26,34,0.2) !important;
|
| 235 |
+
color: #e8c8d0 !important;
|
| 236 |
+
letter-spacing: 7px !important;
|
| 237 |
+
border-color: #c06878 !important;
|
| 238 |
+
background: linear-gradient(160deg, #2a101a 0%, #3a1428 50%, #200e16 100%) !important;
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
/* Collapse the gr.HTML wrapper so it takes no space */
|
| 242 |
+
#cinematic-overlay-wrapper,
|
| 243 |
+
#cinematic-overlay-wrapper > .block,
|
| 244 |
+
#cinematic-overlay-wrapper > div {
|
| 245 |
+
padding: 0 !important;
|
| 246 |
+
margin: 0 !important;
|
| 247 |
+
border: none !important;
|
| 248 |
+
background: transparent !important;
|
| 249 |
+
min-height: 0 !important;
|
| 250 |
+
height: 0 !important;
|
| 251 |
+
overflow: visible !important;
|
| 252 |
+
}
|
| 253 |
+
|
| 254 |
+
/* ── CINEMATIC INTRO OVERLAY ── */
|
| 255 |
+
#orv-cinematic-overlay {
|
| 256 |
+
position: fixed;
|
| 257 |
+
inset: 0;
|
| 258 |
+
z-index: 9999;
|
| 259 |
+
background: #030305;
|
| 260 |
+
display: none;
|
| 261 |
+
flex-direction: column;
|
| 262 |
+
}
|
| 263 |
+
#orv-cinematic-overlay.active {
|
| 264 |
+
display: flex;
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
/* Video fills entire screen */
|
| 268 |
+
#orv-intro-video {
|
| 269 |
+
position: absolute;
|
| 270 |
+
inset: 0;
|
| 271 |
+
width: 100%;
|
| 272 |
+
height: 100%;
|
| 273 |
+
object-fit: cover;
|
| 274 |
+
z-index: 1;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
/* Dark vignette over video for readability */
|
| 278 |
+
#orv-cinematic-vignette {
|
| 279 |
+
position: absolute;
|
| 280 |
+
inset: 0;
|
| 281 |
+
z-index: 2;
|
| 282 |
+
background:
|
| 283 |
+
radial-gradient(ellipse at 50% 50%, transparent 50%, rgba(3,3,5,0.6) 100%),
|
| 284 |
+
linear-gradient(180deg, rgba(3,3,5,0.15) 0%, transparent 30%, transparent 70%, rgba(3,3,5,0.5) 100%);
|
| 285 |
+
pointer-events: none;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
/* Skip button — top right, hidden initially, fades in */
|
| 289 |
+
#orv-skip-btn {
|
| 290 |
+
position: absolute;
|
| 291 |
+
top: 28px;
|
| 292 |
+
right: 32px;
|
| 293 |
+
z-index: 10;
|
| 294 |
+
font-family: 'Cinzel', serif;
|
| 295 |
+
font-size: 11px;
|
| 296 |
+
font-weight: 700;
|
| 297 |
+
letter-spacing: 4px;
|
| 298 |
+
text-transform: uppercase;
|
| 299 |
+
color: rgba(200,180,180,0.5);
|
| 300 |
+
background: rgba(10,8,14,0.5);
|
| 301 |
+
border: 1px solid rgba(144,72,88,0.25);
|
| 302 |
+
padding: 10px 24px;
|
| 303 |
+
cursor: pointer;
|
| 304 |
+
opacity: 0;
|
| 305 |
+
transition: opacity 0.6s ease, color 0.3s ease, border-color 0.3s ease;
|
| 306 |
+
pointer-events: none;
|
| 307 |
+
backdrop-filter: blur(4px);
|
| 308 |
+
}
|
| 309 |
+
#orv-skip-btn.visible {
|
| 310 |
+
opacity: 1;
|
| 311 |
+
pointer-events: auto;
|
| 312 |
+
}
|
| 313 |
+
#orv-skip-btn:hover {
|
| 314 |
+
color: #e8c8d0;
|
| 315 |
+
border-color: #c06878;
|
| 316 |
+
background: rgba(20,12,18,0.7);
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
/* Progress bar — thin line at very bottom */
|
| 320 |
+
#orv-cinematic-progress {
|
| 321 |
+
position: absolute;
|
| 322 |
+
bottom: 0;
|
| 323 |
+
left: 0;
|
| 324 |
+
right: 0;
|
| 325 |
+
height: 3px;
|
| 326 |
+
z-index: 10;
|
| 327 |
+
background: rgba(255,255,255,0.05);
|
| 328 |
+
}
|
| 329 |
+
#orv-cinematic-progress-bar {
|
| 330 |
+
height: 100%;
|
| 331 |
+
width: 0%;
|
| 332 |
+
background: linear-gradient(90deg, #904858, #c06878);
|
| 333 |
+
transition: width 0.3s linear;
|
| 334 |
+
box-shadow: 0 0 8px rgba(144,72,88,0.5);
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
/* Loading text — bottom center */
|
| 338 |
+
#orv-cinematic-text {
|
| 339 |
+
position: absolute;
|
| 340 |
+
bottom: 28px;
|
| 341 |
+
left: 50%;
|
| 342 |
+
transform: translateX(-50%);
|
| 343 |
+
z-index: 10;
|
| 344 |
+
font-family: 'Cinzel', serif;
|
| 345 |
+
font-size: 11px;
|
| 346 |
+
font-weight: 600;
|
| 347 |
+
letter-spacing: 6px;
|
| 348 |
+
text-transform: uppercase;
|
| 349 |
+
color: #c06878;
|
| 350 |
+
animation: orv-cinematic-pulse 2.5s ease-in-out infinite;
|
| 351 |
+
text-shadow: 0 0 20px rgba(144,72,88,0.4);
|
| 352 |
+
white-space: nowrap;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
@keyframes orv-cinematic-pulse {
|
| 356 |
+
0%, 100% { opacity: 0.35; }
|
| 357 |
+
50% { opacity: 1; }
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
footer, .built-with { display: none !important; }
|
| 361 |
+
|
| 362 |
+
/* When Gradio hides start_btn (visible=False), ensure fixed button disappears */
|
| 363 |
+
.start-btn-visible.hidden,
|
| 364 |
+
#gradio-start-btn[style*="display: none"],
|
| 365 |
+
#gradio-start-btn[style*="display:none"] {
|
| 366 |
+
display: none !important;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.gradio-container .gr-box,
|
| 370 |
+
.gradio-container .gr-panel,
|
| 371 |
+
.gradio-container .gr-form,
|
| 372 |
+
.gradio-container .gr-block {
|
| 373 |
+
background: transparent !important;
|
| 374 |
+
border: none !important;
|
| 375 |
+
box-shadow: none !important;
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
.contain,
|
| 379 |
+
.gradio-container .gr-padded,
|
| 380 |
+
.gradio-container .gr-group,
|
| 381 |
+
.gradio-container .form {
|
| 382 |
+
padding: 0 !important;
|
| 383 |
+
gap: 0 !important;
|
| 384 |
+
background: transparent !important;
|
| 385 |
+
border: none !important;
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
/* Scrollbars */
|
| 389 |
+
::-webkit-scrollbar { width: 4px; height: 4px; }
|
| 390 |
+
::-webkit-scrollbar-track { background: transparent; }
|
| 391 |
+
::-webkit-scrollbar-thumb { background: var(--ember-mid); }
|
| 392 |
+
::-webkit-scrollbar-thumb:hover { background: var(--ember); }
|
| 393 |
+
* { scrollbar-width: thin; scrollbar-color: var(--ember-mid) transparent; }
|
| 394 |
+
|
| 395 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 396 |
+
HEADER
|
| 397 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 398 |
+
.game-header {
|
| 399 |
+
height: 46px !important;
|
| 400 |
+
background: #09080e !important;
|
| 401 |
+
border-bottom: 1px solid var(--ember-mid) !important;
|
| 402 |
+
display: flex !important;
|
| 403 |
+
align-items: center !important;
|
| 404 |
+
justify-content: center !important;
|
| 405 |
+
position: relative;
|
| 406 |
+
z-index: 10;
|
| 407 |
+
box-shadow: 0 2px 16px rgba(0,0,0,0.7);
|
| 408 |
+
flex-shrink: 0;
|
| 409 |
+
}
|
| 410 |
+
.game-header::after {
|
| 411 |
+
content: '';
|
| 412 |
+
position: absolute;
|
| 413 |
+
bottom: -1px; left: 25%; right: 25%;
|
| 414 |
+
height: 1px;
|
| 415 |
+
background: linear-gradient(90deg, transparent, var(--ember), transparent);
|
| 416 |
+
opacity: 0.4;
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
.scenario-header-inner {
|
| 420 |
+
display: flex;
|
| 421 |
+
justify-content: space-between;
|
| 422 |
+
align-items: center;
|
| 423 |
+
width: 100%;
|
| 424 |
+
padding: 0 18px;
|
| 425 |
+
}
|
| 426 |
+
.scenario-header-text {
|
| 427 |
+
font-family: var(--font-title) !important;
|
| 428 |
+
font-size: 12px !important;
|
| 429 |
+
font-weight: 700 !important;
|
| 430 |
+
color: var(--ember-bright) !important;
|
| 431 |
+
letter-spacing: 3px;
|
| 432 |
+
text-shadow: 0 0 16px rgba(192,104,120,0.4);
|
| 433 |
+
text-transform: uppercase;
|
| 434 |
+
}
|
| 435 |
+
.scenario-header-left,
|
| 436 |
+
.scenario-header-right {
|
| 437 |
+
font-family: var(--font-heading);
|
| 438 |
+
font-size: 9px;
|
| 439 |
+
color: var(--text-worn);
|
| 440 |
+
letter-spacing: 1.5px;
|
| 441 |
+
text-transform: uppercase;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 445 |
+
MAIN LAYOUT
|
| 446 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 447 |
+
.game-container {
|
| 448 |
+
min-height: 100vh;
|
| 449 |
+
background: var(--bg-void) !important;
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
.game-main {
|
| 453 |
+
padding: 8px !important;
|
| 454 |
+
gap: 8px !important;
|
| 455 |
+
/* Let game-main fill the viewport between header and input bar */
|
| 456 |
+
flex: 1;
|
| 457 |
+
}
|
| 458 |
+
|
| 459 |
+
/* ── SIDE PANELS ── */
|
| 460 |
+
.player-panel, .constellation-panel {
|
| 461 |
+
background: var(--bg-panel) !important;
|
| 462 |
+
border: 1px solid rgba(106,48,64,0.35) !important;
|
| 463 |
+
border-top: 1px solid var(--ember-mid) !important;
|
| 464 |
+
padding: 12px !important;
|
| 465 |
+
display: flex !important;
|
| 466 |
+
flex-direction: column !important;
|
| 467 |
+
gap: 8px !important;
|
| 468 |
+
z-index: 5;
|
| 469 |
+
position: relative;
|
| 470 |
+
max-height: calc(100vh - 110px);
|
| 471 |
+
overflow-y: auto !important;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
/* Corner ornaments */
|
| 475 |
+
.player-panel::before, .constellation-panel::before {
|
| 476 |
+
content: '';
|
| 477 |
+
position: absolute;
|
| 478 |
+
top: 0; left: 0;
|
| 479 |
+
width: 14px; height: 14px;
|
| 480 |
+
border-top: 2px solid var(--ember);
|
| 481 |
+
border-left: 2px solid var(--ember);
|
| 482 |
+
pointer-events: none; z-index: 2;
|
| 483 |
+
}
|
| 484 |
+
.player-panel::after, .constellation-panel::after {
|
| 485 |
+
content: '';
|
| 486 |
+
position: absolute;
|
| 487 |
+
bottom: 0; right: 0;
|
| 488 |
+
width: 14px; height: 14px;
|
| 489 |
+
border-bottom: 2px solid var(--ember);
|
| 490 |
+
border-right: 2px solid var(--ember);
|
| 491 |
+
pointer-events: none; z-index: 2;
|
| 492 |
+
}
|
| 493 |
+
|
| 494 |
+
/* ── CENTER AREA — CRITICAL: flex column with overflow-y auto ── */
|
| 495 |
+
.game-area {
|
| 496 |
+
display: flex !important;
|
| 497 |
+
flex-direction: column !important;
|
| 498 |
+
background: var(--bg-inset) !important;
|
| 499 |
+
border: 1px solid rgba(106,48,64,0.3) !important;
|
| 500 |
+
border-top: 1px solid var(--ember-mid) !important;
|
| 501 |
+
position: relative;
|
| 502 |
+
max-height: calc(100vh - 110px);
|
| 503 |
+
overflow-y: auto !important; /* ← SCROLLABLE so suggestions are reachable */
|
| 504 |
+
overflow-x: hidden;
|
| 505 |
+
}
|
| 506 |
+
.game-area::before {
|
| 507 |
+
content: '';
|
| 508 |
+
position: absolute;
|
| 509 |
+
top: 0; left: 0;
|
| 510 |
+
width: 14px; height: 14px;
|
| 511 |
+
border-top: 2px solid var(--ember);
|
| 512 |
+
border-left: 2px solid var(--ember);
|
| 513 |
+
pointer-events: none; z-index: 2;
|
| 514 |
+
}
|
| 515 |
+
.game-area::after {
|
| 516 |
+
content: '';
|
| 517 |
+
position: absolute;
|
| 518 |
+
bottom: 0; right: 0;
|
| 519 |
+
width: 14px; height: 14px;
|
| 520 |
+
border-bottom: 2px solid var(--ember);
|
| 521 |
+
border-right: 2px solid var(--ember);
|
| 522 |
+
pointer-events: none; z-index: 2;
|
| 523 |
+
}
|
| 524 |
+
|
| 525 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 526 |
+
SCENE IMAGE
|
| 527 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 528 |
+
.scene-img-container {
|
| 529 |
+
width: 100%;
|
| 530 |
+
max-height: 180px;
|
| 531 |
+
overflow: hidden;
|
| 532 |
+
flex-shrink: 0;
|
| 533 |
+
position: relative;
|
| 534 |
+
}
|
| 535 |
+
.scene-img-container img {
|
| 536 |
+
width: 100%;
|
| 537 |
+
height: 100%;
|
| 538 |
+
object-fit: cover;
|
| 539 |
+
display: block;
|
| 540 |
+
filter: brightness(0.5) contrast(1.1) saturate(0.65);
|
| 541 |
+
}
|
| 542 |
+
.scene-img-container::after {
|
| 543 |
+
content: '';
|
| 544 |
+
position: absolute;
|
| 545 |
+
bottom: 0; left: 0; right: 0;
|
| 546 |
+
height: 60px;
|
| 547 |
+
background: linear-gradient(to bottom, transparent, var(--bg-inset));
|
| 548 |
+
pointer-events: none;
|
| 549 |
+
}
|
| 550 |
+
|
| 551 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 552 |
+
PORTRAIT
|
| 553 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 554 |
+
.player-portrait {
|
| 555 |
+
width: 100%;
|
| 556 |
+
aspect-ratio: 3/4;
|
| 557 |
+
border: 1px solid rgba(106,48,64,0.45);
|
| 558 |
+
position: relative;
|
| 559 |
+
overflow: hidden;
|
| 560 |
+
}
|
| 561 |
+
.player-portrait::before {
|
| 562 |
+
content: '';
|
| 563 |
+
position: absolute; top: 0; left: 0;
|
| 564 |
+
width: 10px; height: 10px;
|
| 565 |
+
border-top: 2px solid var(--ember); border-left: 2px solid var(--ember);
|
| 566 |
+
z-index: 2;
|
| 567 |
+
}
|
| 568 |
+
.player-portrait::after {
|
| 569 |
+
content: '';
|
| 570 |
+
position: absolute; bottom: 0; right: 0;
|
| 571 |
+
width: 10px; height: 10px;
|
| 572 |
+
border-bottom: 2px solid var(--ember); border-right: 2px solid var(--ember);
|
| 573 |
+
z-index: 2;
|
| 574 |
+
}
|
| 575 |
+
.player-portrait img {
|
| 576 |
+
width: 100%; height: 100%;
|
| 577 |
+
object-fit: cover;
|
| 578 |
+
display: block;
|
| 579 |
+
filter: sepia(0.12) contrast(1.15) brightness(0.8) saturate(0.85);
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 583 |
+
STAT BARS
|
| 584 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 585 |
+
.stat-bar { margin-bottom: 7px; }
|
| 586 |
+
.stat-bar-label {
|
| 587 |
+
display: flex; justify-content: space-between;
|
| 588 |
+
font-family: var(--font-heading); font-size: 8px;
|
| 589 |
+
color: var(--text-worn); text-transform: uppercase;
|
| 590 |
+
margin-bottom: 3px; letter-spacing: 2px;
|
| 591 |
+
}
|
| 592 |
+
.stat-bar-label span:last-child { color: var(--text-aged); }
|
| 593 |
+
.stat-bar-track {
|
| 594 |
+
width: 100%; height: 5px;
|
| 595 |
+
background: rgba(8,6,12,0.9);
|
| 596 |
+
border: 1px solid rgba(50,40,50,0.4);
|
| 597 |
+
overflow: hidden;
|
| 598 |
+
}
|
| 599 |
+
.stat-bar-fill {
|
| 600 |
+
height: 100%;
|
| 601 |
+
transition: width 0.7s cubic-bezier(0.4, 0, 0.2, 1);
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
/* HP — sage green */
|
| 605 |
+
.stat-bar-hp .stat-bar-fill {
|
| 606 |
+
background: linear-gradient(90deg, var(--sage-dark), var(--sage-bright));
|
| 607 |
+
}
|
| 608 |
+
/* Meta Exposure — blood crimson */
|
| 609 |
+
.stat-bar-meta .stat-bar-fill {
|
| 610 |
+
background: linear-gradient(90deg, var(--crimson-dark), var(--crimson-bright));
|
| 611 |
+
}
|
| 612 |
+
/* Probability Stability — deep indigo */
|
| 613 |
+
.stat-bar-stability .stat-bar-fill {
|
| 614 |
+
background: linear-gradient(90deg, var(--indigo-dark), var(--indigo-bright));
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 618 |
+
NARRATIVE
|
| 619 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 620 |
+
.narrative-container {
|
| 621 |
+
flex: 1 1 auto;
|
| 622 |
+
overflow-y: auto;
|
| 623 |
+
min-height: 80px;
|
| 624 |
+
}
|
| 625 |
+
.narrative-text {
|
| 626 |
+
font-family: var(--font-body) !important;
|
| 627 |
+
font-size: 14px !important;
|
| 628 |
+
line-height: 1.95 !important;
|
| 629 |
+
color: var(--text-narrative) !important;
|
| 630 |
+
padding: 16px 20px !important;
|
| 631 |
+
font-style: italic;
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
/* Dokkaebi — crimson */
|
| 635 |
+
.dokkaebi-text {
|
| 636 |
+
font-family: var(--font-heading) !important;
|
| 637 |
+
color: var(--crimson) !important;
|
| 638 |
+
font-size: 11px !important;
|
| 639 |
+
letter-spacing: 0.5px;
|
| 640 |
+
border-left: 2px solid var(--crimson-mid) !important;
|
| 641 |
+
padding: 8px 14px !important;
|
| 642 |
+
margin: 6px 16px !important;
|
| 643 |
+
background: rgba(58,8,16,0.25) !important;
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 647 |
+
SYSTEM WINDOW
|
| 648 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 649 |
+
.system-window {
|
| 650 |
+
background: var(--bg-panel);
|
| 651 |
+
border: 1px solid rgba(106,48,64,0.3);
|
| 652 |
+
padding: 14px;
|
| 653 |
+
position: relative;
|
| 654 |
+
}
|
| 655 |
+
.system-window::before {
|
| 656 |
+
content: '';
|
| 657 |
+
position: absolute; top: 0; left: 0;
|
| 658 |
+
width: 10px; height: 10px;
|
| 659 |
+
border-top: 2px solid var(--ember); border-left: 2px solid var(--ember);
|
| 660 |
+
pointer-events: none;
|
| 661 |
+
}
|
| 662 |
+
.system-window::after {
|
| 663 |
+
content: '';
|
| 664 |
+
position: absolute; bottom: 0; right: 0;
|
| 665 |
+
width: 10px; height: 10px;
|
| 666 |
+
border-bottom: 2px solid var(--ember); border-right: 2px solid var(--ember);
|
| 667 |
+
pointer-events: none;
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 671 |
+
SUGGESTION BUTTONS — ember/rose accent, clearly visible
|
| 672 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 673 |
+
.suggestion-btn {
|
| 674 |
+
display: flex !important;
|
| 675 |
+
align-items: flex-start !important;
|
| 676 |
+
gap: 10px !important;
|
| 677 |
+
width: 100% !important;
|
| 678 |
+
text-align: left !important;
|
| 679 |
+
margin: 0 0 5px 0 !important;
|
| 680 |
+
padding: 10px 14px !important;
|
| 681 |
+
background: rgba(18,12,20,0.7) !important;
|
| 682 |
+
border: 1px solid var(--ember-mid) !important;
|
| 683 |
+
border-left: 2px solid var(--ember) !important;
|
| 684 |
+
color: #d0b0a0 !important;
|
| 685 |
+
font-family: var(--font-body) !important;
|
| 686 |
+
font-size: 13px !important;
|
| 687 |
+
font-style: italic !important;
|
| 688 |
+
cursor: pointer !important;
|
| 689 |
+
transition: all 0.18s ease !important;
|
| 690 |
+
line-height: 1.5 !important;
|
| 691 |
+
flex-shrink: 0; /* never collapse */
|
| 692 |
+
}
|
| 693 |
+
.suggestion-btn:hover {
|
| 694 |
+
border-color: var(--ember-bright) !important;
|
| 695 |
+
border-left-color: var(--ember-bright) !important;
|
| 696 |
+
color: #e0c8b8 !important;
|
| 697 |
+
background: rgba(40,18,28,0.8) !important;
|
| 698 |
+
box-shadow: 0 0 12px rgba(192,104,120,0.15) !important;
|
| 699 |
+
}
|
| 700 |
+
.suggestion-btn:active {
|
| 701 |
+
transform: scale(0.995) !important;
|
| 702 |
+
}
|
| 703 |
+
|
| 704 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 705 |
+
STAR STREAM / CONSTELLATION PANEL
|
| 706 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 707 |
+
.constellation-chat {
|
| 708 |
+
display: flex; flex-direction: column; gap: 6px;
|
| 709 |
+
}
|
| 710 |
+
.chat-message {
|
| 711 |
+
background: rgba(5,4,8,0.5);
|
| 712 |
+
border: 1px solid rgba(50,40,50,0.3);
|
| 713 |
+
border-left: 2px solid var(--ember-mid);
|
| 714 |
+
padding: 6px 10px;
|
| 715 |
+
font-family: var(--font-heading);
|
| 716 |
+
font-size: 10px;
|
| 717 |
+
letter-spacing: 0.3px;
|
| 718 |
+
transition: border-left-color 0.3s;
|
| 719 |
+
}
|
| 720 |
+
.chat-message[data-mood="positive"] { border-left-color: var(--sage); }
|
| 721 |
+
.chat-message[data-mood="negative"] { border-left-color: var(--crimson-mid); }
|
| 722 |
+
.chat-message .constellation-name {
|
| 723 |
+
color: var(--ember); font-weight: 700; display: block;
|
| 724 |
+
margin-bottom: 2px; font-size: 8px; letter-spacing: 2px; text-transform: uppercase;
|
| 725 |
+
}
|
| 726 |
+
.chat-message .constellation-reaction {
|
| 727 |
+
color: var(--text-aged); font-size: 10px; line-height: 1.4;
|
| 728 |
+
}
|
| 729 |
+
.chat-donation {
|
| 730 |
+
color: var(--copper-bright); font-weight: 700; margin-top: 2px;
|
| 731 |
+
font-size: 10px;
|
| 732 |
+
text-shadow: 0 0 5px rgba(216,160,72,0.3);
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 736 |
+
INPUT BAR
|
| 737 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 738 |
+
.input-bar {
|
| 739 |
+
padding: 8px 12px !important;
|
| 740 |
+
background: #09080e !important;
|
| 741 |
+
border-top: 1px solid var(--ember-mid) !important;
|
| 742 |
+
margin: 0 !important;
|
| 743 |
+
position: relative;
|
| 744 |
+
z-index: 5;
|
| 745 |
+
flex-shrink: 0;
|
| 746 |
+
}
|
| 747 |
+
.game-input { flex: 1 !important; }
|
| 748 |
+
.game-input textarea,
|
| 749 |
+
.game-input input[type="text"] {
|
| 750 |
+
background: rgba(5,4,8,0.9) !important;
|
| 751 |
+
border: 1px solid rgba(106,48,64,0.4) !important;
|
| 752 |
+
color: var(--text-parch) !important;
|
| 753 |
+
font-family: var(--font-body) !important;
|
| 754 |
+
font-size: 13px !important;
|
| 755 |
+
font-style: italic !important;
|
| 756 |
+
padding: 10px 14px !important;
|
| 757 |
+
height: 42px !important;
|
| 758 |
+
min-height: 42px !important;
|
| 759 |
+
transition: border-color 0.2s;
|
| 760 |
+
caret-color: var(--ember-bright);
|
| 761 |
+
resize: none !important;
|
| 762 |
+
}
|
| 763 |
+
.game-input textarea:focus,
|
| 764 |
+
.game-input input[type="text"]:focus {
|
| 765 |
+
border-color: var(--ember) !important;
|
| 766 |
+
outline: none !important;
|
| 767 |
+
box-shadow: 0 0 8px rgba(144,72,88,0.2) !important;
|
| 768 |
+
}
|
| 769 |
+
.game-input textarea::placeholder { color: var(--text-worn) !important; font-style: italic; }
|
| 770 |
+
|
| 771 |
+
/* ACT button — ember accent */
|
| 772 |
+
.act-button {
|
| 773 |
+
background: rgba(42,16,24,0.7) !important;
|
| 774 |
+
border: 1px solid var(--ember) !important;
|
| 775 |
+
color: var(--ember-bright) !important;
|
| 776 |
+
font-family: var(--font-heading) !important;
|
| 777 |
+
font-size: 10px !important;
|
| 778 |
+
font-weight: 700 !important;
|
| 779 |
+
text-transform: uppercase !important;
|
| 780 |
+
letter-spacing: 3px !important;
|
| 781 |
+
height: 42px !important;
|
| 782 |
+
padding: 0 20px !important;
|
| 783 |
+
cursor: pointer !important;
|
| 784 |
+
transition: all 0.2s ease !important;
|
| 785 |
+
white-space: nowrap !important;
|
| 786 |
+
}
|
| 787 |
+
.act-button:hover {
|
| 788 |
+
background: rgba(60,24,35,0.9) !important;
|
| 789 |
+
box-shadow: 0 0 18px rgba(192,104,120,0.25) !important;
|
| 790 |
+
letter-spacing: 4px !important;
|
| 791 |
+
color: #e0a0b0 !important;
|
| 792 |
+
}
|
| 793 |
+
|
| 794 |
+
/* Stance radio */
|
| 795 |
+
.input-bar label,
|
| 796 |
+
.input-bar .gr-radio-group label {
|
| 797 |
+
font-family: var(--font-heading) !important;
|
| 798 |
+
color: var(--text-worn) !important;
|
| 799 |
+
border: 1px solid rgba(60,40,50,0.5) !important;
|
| 800 |
+
padding: 3px 8px !important;
|
| 801 |
+
cursor: pointer !important;
|
| 802 |
+
font-size: 8px !important;
|
| 803 |
+
text-transform: uppercase;
|
| 804 |
+
letter-spacing: 1.5px;
|
| 805 |
+
background: transparent !important;
|
| 806 |
+
transition: all 0.15s;
|
| 807 |
+
}
|
| 808 |
+
.input-bar .gr-radio-group input:checked + label,
|
| 809 |
+
.input-bar .gr-radio-group label:hover {
|
| 810 |
+
border-color: var(--ember) !important;
|
| 811 |
+
color: var(--ember-bright) !important;
|
| 812 |
+
background: rgba(42,16,24,0.35) !important;
|
| 813 |
+
}
|
| 814 |
+
|
| 815 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 816 |
+
BADGES / TITLES / MISC
|
| 817 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 818 |
+
.title-badge {
|
| 819 |
+
display: inline-block;
|
| 820 |
+
background: rgba(144,72,88,0.08);
|
| 821 |
+
border: 1px solid rgba(106,48,64,0.5);
|
| 822 |
+
color: var(--ember);
|
| 823 |
+
font-family: var(--font-heading);
|
| 824 |
+
font-size: 8px; padding: 2px 6px; margin: 2px;
|
| 825 |
+
letter-spacing: 1px; text-transform: uppercase;
|
| 826 |
+
}
|
| 827 |
+
.skill-name {
|
| 828 |
+
font-family: var(--font-heading);
|
| 829 |
+
font-size: 10px; color: var(--text-aged); letter-spacing: 0.5px;
|
| 830 |
+
margin-bottom: 3px;
|
| 831 |
+
}
|
| 832 |
+
.bracket-text { font-family: var(--font-heading); color: var(--ember); opacity: 0.8; font-size: 11px; letter-spacing: 1px; }
|
| 833 |
+
.gold-divider { width: 100%; height: 1px; background: linear-gradient(90deg, transparent, var(--ember-mid), transparent); margin: 5px 0; }
|
| 834 |
+
.text-secondary { color: var(--text-aged) !important; }
|
| 835 |
+
.text-primary { color: var(--text-parch) !important; }
|
| 836 |
+
.text-muted { color: var(--text-worn) !important; }
|
| 837 |
+
|
| 838 |
+
/* ══════════════════════════════════════════════════════════════��════
|
| 839 |
+
PHASE EFFECTS
|
| 840 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 841 |
+
body.phase-combat .game-area { box-shadow: 0 0 28px rgba(122,26,34,0.12) inset; }
|
| 842 |
+
body.phase-combat .scenario-header-text { color: var(--crimson) !important; text-shadow: 0 0 12px rgba(168,32,48,0.5) !important; }
|
| 843 |
+
body.phase-combat .player-panel,
|
| 844 |
+
body.phase-combat .constellation-panel,
|
| 845 |
+
body.phase-combat .game-area { border-top-color: var(--crimson-mid) !important; }
|
| 846 |
+
body.phase-combat .shop-accordion { pointer-events: none; opacity: 0.3; filter: grayscale(80%); }
|
| 847 |
+
|
| 848 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 849 |
+
META DANGER
|
| 850 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 851 |
+
@keyframes pulseBlood {
|
| 852 |
+
0%,100% { box-shadow: none; }
|
| 853 |
+
50% { box-shadow: 0 0 18px rgba(168,32,48,0.18) inset; }
|
| 854 |
+
}
|
| 855 |
+
body.meta-warning .game-area { animation: pulseBlood 3s infinite; }
|
| 856 |
+
body.meta-critical .game-area { animation: pulseBlood 1.5s infinite; }
|
| 857 |
+
.meter-danger { animation: pulseBlood 1s infinite !important; }
|
| 858 |
+
|
| 859 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 860 |
+
OVERLAYS
|
| 861 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 862 |
+
.fullscreen-overlay {
|
| 863 |
+
position: fixed; inset: 0; z-index: 9999;
|
| 864 |
+
display: flex; flex-direction: column;
|
| 865 |
+
align-items: center; justify-content: center;
|
| 866 |
+
background: rgba(5,4,8,0.94);
|
| 867 |
+
}
|
| 868 |
+
.scenario-announcement {
|
| 869 |
+
text-align: center;
|
| 870 |
+
max-width: 600px; padding: 36px;
|
| 871 |
+
background: var(--bg-panel);
|
| 872 |
+
border: 1px solid var(--ember-mid);
|
| 873 |
+
position: relative;
|
| 874 |
+
}
|
| 875 |
+
.scenario-announcement::before {
|
| 876 |
+
content: ''; position: absolute; top: 0; left: 0;
|
| 877 |
+
width: 18px; height: 18px;
|
| 878 |
+
border-top: 2px solid var(--ember); border-left: 2px solid var(--ember);
|
| 879 |
+
}
|
| 880 |
+
.scenario-announcement::after {
|
| 881 |
+
content: ''; position: absolute; bottom: 0; right: 0;
|
| 882 |
+
width: 18px; height: 18px;
|
| 883 |
+
border-bottom: 2px solid var(--ember); border-right: 2px solid var(--ember);
|
| 884 |
+
}
|
| 885 |
+
|
| 886 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 887 |
+
JS EFFECT ELEMENTS
|
| 888 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 889 |
+
.coin-particle {
|
| 890 |
+
position: fixed; width: 5px; height: 5px;
|
| 891 |
+
background: var(--copper-bright); border-radius: 50%;
|
| 892 |
+
pointer-events: none; z-index: 10000;
|
| 893 |
+
box-shadow: 0 0 4px var(--copper);
|
| 894 |
+
animation: coinFall 1.5s ease-in forwards;
|
| 895 |
+
}
|
| 896 |
+
.damage-flash-overlay {
|
| 897 |
+
position: fixed; inset: 0;
|
| 898 |
+
background: rgba(122,26,34,0.25);
|
| 899 |
+
pointer-events: none; z-index: 9998;
|
| 900 |
+
animation: damageFlash 0.5s ease-out forwards;
|
| 901 |
+
}
|
| 902 |
+
@keyframes damageFlash { 0% { opacity: 1; } 100% { opacity: 0; } }
|
| 903 |
+
|
| 904 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 905 |
+
SHOP ACCORDION
|
| 906 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 907 |
+
.shop-accordion {
|
| 908 |
+
background: transparent !important;
|
| 909 |
+
border: 1px solid rgba(106,48,64,0.3) !important;
|
| 910 |
+
border-radius: 0 !important;
|
| 911 |
+
}
|
| 912 |
+
.shop-accordion > .label-wrap,
|
| 913 |
+
.shop-accordion summary {
|
| 914 |
+
font-family: var(--font-heading) !important;
|
| 915 |
+
color: var(--ember) !important; font-size: 9px !important;
|
| 916 |
+
letter-spacing: 2px !important; text-transform: uppercase;
|
| 917 |
+
background: transparent !important;
|
| 918 |
+
border-color: rgba(106,48,64,0.3) !important; padding: 6px 10px !important;
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
/* GRADIO DROPDOWN */
|
| 922 |
+
.gradio-container select,
|
| 923 |
+
.gradio-container .dropdown {
|
| 924 |
+
background: rgba(5,4,8,0.9) !important;
|
| 925 |
+
border: 1px solid rgba(106,48,64,0.35) !important;
|
| 926 |
+
color: var(--text-parch) !important;
|
| 927 |
+
font-family: var(--font-heading) !important;
|
| 928 |
+
font-size: 11px !important;
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
/* Typewriter cursor */
|
| 932 |
+
.typewriter-cursor {
|
| 933 |
+
display: inline-block;
|
| 934 |
+
color: var(--ember);
|
| 935 |
+
animation: blink 0.8s step-end infinite;
|
| 936 |
+
}
|
| 937 |
+
@keyframes blink { 0%,100% { opacity: 1; } 50% { opacity: 0; } }
|
| 938 |
+
|
| 939 |
+
/* ═══════════════════════════════════════════════════════════════════
|
| 940 |
+
RESPONSIVE BREAKPOINTS
|
| 941 |
+
═══════════════════════════════════════════════════════════════════ */
|
| 942 |
+
|
| 943 |
+
@media (max-width: 1100px) {
|
| 944 |
+
.game-main { flex-wrap: wrap !important; }
|
| 945 |
+
.constellation-panel {
|
| 946 |
+
max-height: 200px !important;
|
| 947 |
+
min-width: unset !important;
|
| 948 |
+
width: 100% !important;
|
| 949 |
+
flex: 1 1 100% !important;
|
| 950 |
+
order: 3;
|
| 951 |
+
}
|
| 952 |
+
.player-panel { flex: 0 0 180px !important; }
|
| 953 |
+
.game-area { flex: 1 1 260px !important; }
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
@media (max-width: 700px) {
|
| 957 |
+
.game-main {
|
| 958 |
+
flex-direction: column !important;
|
| 959 |
+
padding: 4px !important;
|
| 960 |
+
gap: 4px !important;
|
| 961 |
+
}
|
| 962 |
+
.player-panel {
|
| 963 |
+
flex: none !important;
|
| 964 |
+
max-height: none !important;
|
| 965 |
+
width: 100% !important;
|
| 966 |
+
flex-direction: row !important;
|
| 967 |
+
flex-wrap: wrap !important;
|
| 968 |
+
}
|
| 969 |
+
.player-portrait {
|
| 970 |
+
width: 90px !important;
|
| 971 |
+
aspect-ratio: 3/4 !important;
|
| 972 |
+
flex-shrink: 0;
|
| 973 |
+
}
|
| 974 |
+
.game-area {
|
| 975 |
+
max-height: none !important;
|
| 976 |
+
width: 100% !important;
|
| 977 |
+
}
|
| 978 |
+
.scene-img-container { max-height: 130px !important; }
|
| 979 |
+
.constellation-panel {
|
| 980 |
+
max-height: 160px !important;
|
| 981 |
+
width: 100% !important;
|
| 982 |
+
}
|
| 983 |
+
.scenario-header-left,
|
| 984 |
+
.scenario-header-right { display: none !important; }
|
| 985 |
+
.scenario-header-text { font-size: 10px !important; letter-spacing: 1.5px !important; }
|
| 986 |
+
.input-bar { flex-wrap: wrap !important; padding: 6px !important; }
|
| 987 |
+
.act-button { width: 100% !important; }
|
| 988 |
+
.narrative-text { font-size: 13px !important; padding: 12px 14px !important; }
|
| 989 |
+
}
|
| 990 |
+
|
| 991 |
+
@media (max-width: 420px) {
|
| 992 |
+
.scenario-header-text { font-size: 9px !important; letter-spacing: 1px !important; }
|
| 993 |
+
.game-header { height: 36px !important; }
|
| 994 |
+
.narrative-text { font-size: 12px !important; }
|
| 995 |
+
.suggestion-btn { font-size: 11px !important; padding: 8px 10px !important; }
|
| 996 |
+
}
|