Aswini-Kumar commited on
Commit
7d99fde
·
0 Parent(s):

Initial commit — Omniscient Reader Scenario Simulator

Browse files
.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 &nbsp;·&nbsp; 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 &nbsp;{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 &nbsp;·&nbsp; <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 &nbsp;{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;">&#9654;&#9654;</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

  • SHA256: 4fcd306af716425207fadbdda11c8155a67fad474150e0d65663196756160708
  • Pointer size: 131 Bytes
  • Size of remote file: 940 kB
assets/dokja_portrait.png ADDED

Git LFS Details

  • SHA256: 13d86a76a83cb0612a2c5402b1a944ee852063a47c9697c98a517a9e8b66c301
  • Pointer size: 131 Bytes
  • Size of remote file: 974 kB
assets/dokkaebi_character.png ADDED

Git LFS Details

  • SHA256: f6a8954bb38c466fd9f502a85531662f7b7f8889f1e087785ba6787172850c49
  • Pointer size: 131 Bytes
  • Size of remote file: 271 kB
assets/han_river_bridge.png ADDED

Git LFS Details

  • SHA256: 775caa03eab6967646ad80e84148b131cba546099c77b41443f22ff7594e0f85
  • Pointer size: 131 Bytes
  • Size of remote file: 891 kB
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

  • SHA256: bad34b3c5ebead715d5eb0c583517f9872c3246f9f26b3cea1499661dc516f16
  • Pointer size: 130 Bytes
  • Size of remote file: 52.9 kB
assets/lee_hyunsung_portrait.png ADDED

Git LFS Details

  • SHA256: 086a9084158bbaad6e523e2af3825defce06d6c076a04d52392ed269145aa768
  • Pointer size: 131 Bytes
  • Size of remote file: 825 kB
assets/rank_s_bg.png ADDED

Git LFS Details

  • SHA256: 61f47f303b5a8095b5c259caa76e53451b6b6526b6d49beb2a8692879c55f91b
  • Pointer size: 132 Bytes
  • Size of remote file: 1.04 MB
assets/reader_noticed.png ADDED

Git LFS Details

  • SHA256: cdc5ef33adf4177aa5941fa302d79b2ab9f557355a63defe1d0e53c1df3f3006
  • Pointer size: 132 Bytes
  • Size of remote file: 1.17 MB
assets/seoul_street.png ADDED

Git LFS Details

  • SHA256: d50b82b6054bd943f6e3abc8fb33d5c577a4aefdf945bdb60be28498e66f86cb
  • Pointer size: 131 Bytes
  • Size of remote file: 906 kB
assets/seoul_subway.png ADDED

Git LFS Details

  • SHA256: 438be9d7c6cbefec8e7f90aabcd145aa9c7878611e9afbe3ed46e6f1436cfcca
  • Pointer size: 131 Bytes
  • Size of remote file: 919 kB
assets/star_stream_bg.png ADDED

Git LFS Details

  • SHA256: b556a14b4ca769ba56e1b3e47cfbc7c1b33dea2091523da921b6fd1b8e69ffdd
  • Pointer size: 132 Bytes
  • Size of remote file: 1.09 MB
assets/title_banner.png ADDED

Git LFS Details

  • SHA256: 728f2baa929d850a906ab0f08058b97d2530c77b182cc07c85c8cdbda2f7d902
  • Pointer size: 131 Bytes
  • Size of remote file: 816 kB
assets/yoo_sangah_portrait.png ADDED

Git LFS Details

  • SHA256: 94f201ab79ab38a3486804cb58cbbbcc5afa118af3f08023057391a718ee7f41
  • Pointer size: 131 Bytes
  • Size of remote file: 155 kB
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
+ }