sush0401 commited on
Commit
d3a7a1c
·
verified ·
1 Parent(s): 640c20c

TinyWorld + Crisis Mode, ZeroGPU in-process inference

Browse files
README.md CHANGED
@@ -1,13 +1,94 @@
1
  ---
2
- title: Tinyworld
3
- emoji: 🌍
4
- colorFrom: purple
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
  pinned: false
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: TinyWorld
3
+ emoji: 🏘️
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: gradio
7
+ sdk_version: "6.18.0"
 
8
  app_file: app.py
9
  pinned: false
10
+ tags:
11
+ - hackathon
12
+ - build-small
13
+ - adventure-in-thousand-token-wood
14
+ - openbmb/MiniCPM5-1B
15
+ - nvidia/Nemotron-Mini-4B-Instruct
16
+ - openbmb/VoxCPM2
17
+ - cohere/cohere-transcribe-03-2026
18
  ---
19
 
20
+ # 🏘️ TinyWorld
21
+
22
+ **Throw an event. Watch the neighborhood react.**
23
+
24
+ A tiny life-sim town where AI characters react unpredictably, follow routines, and obey directed commands in their own voices. Built for the **Build Small Hackathon 2026** (Hugging Face × Gradio), targeting the **Adventure in Thousand Token Wood** track.
25
+
26
+ ## Source
27
+
28
+ Public GitHub repo: [Sushruths04/tinyworld-reactive-neighborhood](https://github.com/Sushruths04/tinyworld-reactive-neighborhood)
29
+
30
+ Built with OpenAI Codex. See the commit history for Codex-attributed commits.
31
+
32
+ ## How to Play
33
+
34
+ 1. **Type an event** in the textbox (or click 🎲 Random Chaos for inspiration).
35
+ 2. **Hit ⚡ Trigger** — watch the addressed character or whole town react on the canvas.
36
+ 3. **Listen** — the most dramatic reaction auto-speaks via VoxCPM2 voice. Pick another character from the dropdown and hit 🔊 to hear them too.
37
+
38
+ ## 🎬 Crisis Mode — "The Long Summer"
39
+
40
+ Beyond the open sandbox, TinyWorld has a story-driven **co-op survival** mode inspired by *Among Us*: **you are the saboteur**. Switch to **🎬 Campaign** and press **Start Campaign** to inject a linked season of escalating crises — a café fire, a heatwave blackout, a storm, a missing child, and a finale. The five residents must perceive, coordinate, and **actually resolve** each crisis before the **chaos meter** maxes out.
41
+
42
+ - **Deterministic resolution** — each crisis declares *requirements* (e.g. fire → contain it at the café, get the medic on scene, evacuate). The engine checks each resident's chosen destination and action against those requirements; the LLM only *speaks*, the engine decides whether it's solved (`crisis.py`).
43
+ - **Multi-round coordination** — unmet requirements feed back into the next round ("the fire is still spreading, no medic on scene"), so the town has to organize across rounds. You can also type directed commands ("Jay, put the fire out at the café") to rally a specific resident.
44
+ - **Story & stakes** — clear all five chapters to trigger the **finale reveal**: every disaster traced back to one source — *you*. Each chapter is graded, names an **MVP resident**, and surfaces a teaching question about *how five different minds approach the same emergency differently* (`campaign.py`).
45
+
46
+ Play it locally end-to-end with no network:
47
+
48
+ ```bash
49
+ TINYWORLD_MOCK=1 python3 acceptance_check.py # offline win + collapse playthrough
50
+ TINYWORLD_MOCK=1 python3 -m pytest -q # full test suite incl. crisis/campaign
51
+ ```
52
+
53
+ ### Architecture note (portability)
54
+
55
+ The game engine is host-agnostic: `crisis.py`, `campaign.py`, `agents.py`, `decide.py`, `router.py`, `world_state.py`, and `worlds/` never import `gradio` (enforced by `tests/test_portability.py`). `app.py` is a thin Gradio adapter. This boundary is the seam that lets the same engine be wrapped by a Streamlit or FastAPI front-end later without touching the core.
56
+
57
+ ## What Makes It Unpredictable
58
+
59
+ Every reaction is shaped by 4 hidden layers:
60
+ - **Mood** — each character has a secret mood that re-rolls after every event.
61
+ - **Memory** — they remember their last 3 reactions and reference them.
62
+ - **Relationships** — friendships, rivalries, and crushes color how they respond.
63
+ - **Surprise Seeds** — a random creative instruction appended to every prompt.
64
+
65
+ Trigger the same event 3 times → you get 3 completely different reactions each time.
66
+
67
+ ## Models Used (all ≤ 4B params)
68
+
69
+ | Model | Params | Role | Sponsor |
70
+ |---|---|---|---|
71
+ | `nvidia/Nemotron-Mini-4B-Instruct` | 4B | Primary character dialogue model for all worlds | NVIDIA |
72
+ | `openbmb/MiniCPM5-1B` | 1B | Lightweight fallback dialogue model on Modal | OpenBMB |
73
+ | `openbmb/VoxCPM2` | 2B | Character voice synthesis (TTS) | OpenBMB |
74
+ | Whisper `base` on Modal | ~74M | Primary speech-to-text (mic input) | Modal |
75
+ | `Cohere Transcribe` | 2B | Speech-to-text fallback path | Cohere |
76
+
77
+ **Total model budget: under 10B params** (well under the 32B cap — earns the *Tiny Titan* badge).
78
+
79
+ ## Built With
80
+
81
+ - [Gradio](https://gradio.app) — web UI framework
82
+ - [Hugging Face Spaces](https://huggingface.co/spaces) — deployment
83
+ - [ZeroGPU](https://huggingface.co/docs/transformers/optimization/bnbs#zero-gpu) — GPU access on Spaces
84
+
85
+ ## Sponsor Credits
86
+
87
+ - **NVIDIA** — Nemotron-Mini-4B-Instruct (character dialogue)
88
+ - **OpenBMB** — MiniCPM5-1B fallback + VoxCPM2 voice synthesis
89
+ - **Modal** — serverless LLM, voice, and Whisper transcription endpoints
90
+ - **Cohere** — Transcribe fallback path
91
+
92
+ ---
93
+
94
+ *Built for Build Small Hackathon 2026 · Hugging Face × Gradio · Thousand Token Wood track*
__pycache__/agents.cpython-310.pyc ADDED
Binary file (28 kB). View file
 
__pycache__/app.cpython-310.pyc ADDED
Binary file (27.6 kB). View file
 
__pycache__/campaign.cpython-310.pyc ADDED
Binary file (7.04 kB). View file
 
__pycache__/crisis.cpython-310.pyc ADDED
Binary file (8.96 kB). View file
 
__pycache__/decide.cpython-310.pyc ADDED
Binary file (10.1 kB). View file
 
__pycache__/events.cpython-310.pyc ADDED
Binary file (1.13 kB). View file
 
__pycache__/router.cpython-310.pyc ADDED
Binary file (3.71 kB). View file
 
__pycache__/transcribe.cpython-310.pyc ADDED
Binary file (2.39 kB). View file
 
__pycache__/voice.cpython-310.pyc ADDED
Binary file (2.92 kB). View file
 
__pycache__/world_state.cpython-310.pyc ADDED
Binary file (13.1 kB). View file
 
agents.py ADDED
@@ -0,0 +1,912 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import random
3
+ import re
4
+ import time
5
+ import concurrent.futures
6
+ import decide
7
+ import world_state
8
+ from worlds import get_world
9
+
10
+ MOCK = os.environ.get("TINYWORLD_MOCK", "0") == "1"
11
+
12
+ MODAL_REACT_URL = os.environ.get(
13
+ "MODAL_REACT_URL",
14
+ "https://mitvho09--tinyworld-inference-react-endpoint.modal.run",
15
+ )
16
+
17
+ PRIMARY_MODEL = "nvidia/Nemotron-Mini-4B-Instruct"
18
+ _FAILURES = 0
19
+ _CIRCUIT_OPEN_UNTIL = 0.0
20
+ _RUNTIME_STATUS = {
21
+ "mode": "mock" if MOCK else "unknown",
22
+ "model": "offline demo" if MOCK else PRIMARY_MODEL,
23
+ "message": "Offline demo (mock)" if MOCK else "Model not checked yet",
24
+ "latency": None,
25
+ "error": None,
26
+ }
27
+
28
+ MOODS = [
29
+ "happy", "stressed", "bored", "excited", "hungry",
30
+ "tired", "nostalgic", "curious", "proud", "embarrassed",
31
+ ]
32
+
33
+ SURPRISE_SEEDS = [
34
+ "Slip in one oddly specific detail from earlier today.",
35
+ "React as if this event confirms a private theory you never told anyone.",
36
+ "Let a petty personal concern steer part of your response.",
37
+ "Compare the event to a memory that still annoys you.",
38
+ "Say one thing confidently, then soften or complicate it.",
39
+ "Ask one pointed question that reveals your bias.",
40
+ "Treat one small part of the event as the real emergency.",
41
+ "Reveal a secret wish connected to the situation.",
42
+ "Interpret the event through your relationship with someone nearby.",
43
+ "End with a surprising offer, warning, or demand.",
44
+ ]
45
+
46
+ MOOD_CONTEXT = {
47
+ "happy": "You are in a good mood. Let that warmth show.",
48
+ "stressed": "You are stressed and overwhelmed. It colors your view.",
49
+ "bored": "You are bored. Nothing impresses you.",
50
+ "excited": "You are buzzing with excitement.",
51
+ "hungry": "You are hungry and losing patience.",
52
+ "tired": "You are exhausted. Everything is too much effort.",
53
+ "nostalgic": "You are nostalgic. The past feels more real.",
54
+ "curious": "You are intensely curious. You want answers.",
55
+ "proud": "You are proud. This is your turf.",
56
+ "embarrassed": "You are embarrassed. You hope nobody noticed.",
57
+ }
58
+
59
+ GENERIC_MOCK = {
60
+ "happy": ["Honestly? I love this. Best thing to happen here in ages.", "Oh, this is wonderful. Count me in!"],
61
+ "stressed": ["This is exactly what I was afraid of. We need a plan, now.", "Okay, okay — everybody stay calm. Mostly me."],
62
+ "bored": ["Huh. Neat, I guess. Wake me if it gets interesting.", "Seen weirder. Slightly."],
63
+ "excited": ["No way! This is HUGE, we have to do something!", "I have been WAITING for something like this!"],
64
+ "hungry": ["Can we deal with this after I eat? Just asking.", "Great, drama on an empty stomach."],
65
+ "tired": ["I do not have the energy for this today.", "Can someone else handle it? I'm wiped."],
66
+ "nostalgic": ["This takes me back. Funny how things come around.", "Reminds me of the old days, before all... this."],
67
+ "curious": ["Wait, how does that even work? I need to know more.", "Fascinating. Let me get a closer look."],
68
+ "proud": ["Stand back — I've got this handled.", "Finally, a chance to show what I can do."],
69
+ "embarrassed": ["Oh no. Please tell me nobody saw that.", "I'm just going to pretend that didn't happen."],
70
+ }
71
+
72
+ MOCK_REACTIONS = {
73
+ "Marta Voss": {
74
+ "happy": [
75
+ "Well, that's a pleasant surprise. I could use more days like this around here.",
76
+ "Ha! Finally something good happens. Don't tell anyone I said that.",
77
+ ],
78
+ "stressed": [
79
+ "Oh great, just what I needed. As if this block wasn't chaotic enough already.",
80
+ "I'm going to need a moment. Or a time machine to 1987.",
81
+ ],
82
+ "bored": [
83
+ "Huh. That's something, I guess. Wake me when it gets interesting.",
84
+ "Back in my day, we had real excitement. This is... fine.",
85
+ ],
86
+ "excited": [
87
+ "Now THIS is what I've been waiting for! Reminds me of the great power outage of '09!",
88
+ "I've been watching this neighborhood for forty years and THIS is the day!",
89
+ ],
90
+ "hungry": [
91
+ "Nice, but can we talk about lunch first? I skipped my tea.",
92
+ "That's all well and good, but I haven't had a decent scone since Tuesday.",
93
+ ],
94
+ "tired": [
95
+ "Can this wait? I barely slept last night thanks to Jay's delivery bike.",
96
+ "*yawn* That's... very... I need my afternoon nap.",
97
+ ],
98
+ "nostalgic": [
99
+ "This reminds me of something from '83. Similar energy, different decade.",
100
+ "You know, we had something like this once before. Not quite the same though.",
101
+ ],
102
+ "curious": [
103
+ "Wait, hold on. Tell me more. What exactly did you see?",
104
+ "Something doesn't add up. I've been watching this street for decades and I notice things.",
105
+ ],
106
+ "proud": [
107
+ "I've been saying this block would get noticed. Called it years ago.",
108
+ "This is exactly the kind of thing I've seen coming. You're all welcome.",
109
+ ],
110
+ "embarrassed": [
111
+ "Oh no. Please tell me nobody saw me react to that.",
112
+ "I... uh... let's just pretend that didn't happen.",
113
+ ],
114
+ },
115
+ "Jay Park": {
116
+ "happy": [
117
+ "Ha! Finally! I KNEW something good was coming today! My luck's turning around!",
118
+ "This is amazing! I'm already texting everyone about it!",
119
+ ],
120
+ "stressed": [
121
+ "Oh no. Oh no no no. I literally cannot deal with this right now!",
122
+ "My schedule is already packed and NOW this happens?!",
123
+ ],
124
+ "bored": [
125
+ "That's... cool I guess? I've seen more exciting delivery routes.",
126
+ "Wake me when something actually interesting happens. I'm going for a ride.",
127
+ ],
128
+ "excited": [
129
+ "WAIT WAIT WAIT — did you SEE that?! This is HUGE! I'm already on it!",
130
+ "I KNEW something like this would happen! My instincts are NEVER wrong!",
131
+ ],
132
+ "hungry": [
133
+ "Cool cool cool, but has anyone seen the pizza truck? I'm STARVING.",
134
+ "I'd be more excited if I hadn't skipped lunch for three deliveries.",
135
+ ],
136
+ "tired": [
137
+ "Mmhmm. Very cool. *stares into distance* I delivered twelve orders today.",
138
+ "Can this wait? My legs are literally trembling.",
139
+ ],
140
+ "nostalgic": [
141
+ "This gives me the same vibe as that time I found twenty bucks in a taxi. Good memories.",
142
+ "You know, this reminds me of my first week on the job. Good times.",
143
+ ],
144
+ "curious": [
145
+ "Wait, what?! Tell me EVERYTHING. I need details!",
146
+ "Something's up. I ride through here every day and I notice things.",
147
+ ],
148
+ "proud": [
149
+ "Ha! I've been saying this neighborhood was special! Who's laughing now?",
150
+ "I literally called this. Check my texts from last week.",
151
+ ],
152
+ "embarrassed": [
153
+ "Oh god. Please tell me I didn't just yell that out loud.",
154
+ "I'm going to ride away now. Very fast. In the opposite direction.",
155
+ ],
156
+ },
157
+ "Nia Okafor": {
158
+ "happy": [
159
+ "Well, that's a nice change of pace. I could get used to this.",
160
+ "Good. We deserve something good around here for once.",
161
+ ],
162
+ "stressed": [
163
+ "Okay, deep breaths. I've handled worse in the ambulance. This is fine.",
164
+ "Of course this happens on my day off. Of course it does.",
165
+ ],
166
+ "bored": [
167
+ "Fascinating. Truly. *checks watch* I've seen more action at a stop sign.",
168
+ "My enthusiasm is immeasurable. *yawn*",
169
+ ],
170
+ "excited": [
171
+ "Now THIS is interesting! Finally, something that gets the blood pumping!",
172
+ "Oh, this is good. This is REALLY good. I'm alert now.",
173
+ ],
174
+ "hungry": [
175
+ "I've got one eye on this and one eye on the clock. Shift starts in an hour and I need food.",
176
+ "Cool, but I'm about to pass out from low blood sugar. Priorities.",
177
+ ],
178
+ "tired": [
179
+ "I've seen worse. Much worse. But I'd rather be sleeping right now.",
180
+ "Can we... handle this efficiently? I worked a double shift.",
181
+ ],
182
+ "nostalgic": [
183
+ "This reminds me of my first month on the job. Similar chaos, different faces.",
184
+ "You know, we had something like this at the hospital once. Long story.",
185
+ ],
186
+ "curious": [
187
+ "Hold on. What exactly happened? I need the facts before I react.",
188
+ "Something's not right here. I read situations for a living and this is off.",
189
+ ],
190
+ "proud": [
191
+ "This neighborhood doesn't get enough credit. But I've always known.",
192
+ "I've been protecting this block for years. Nice to see others noticing.",
193
+ ],
194
+ "embarrassed": [
195
+ "I... that was unprofessional. Let's move on.",
196
+ "Nobody saw that. Nobody. *straightens uniform*",
197
+ ],
198
+ },
199
+ "Luca Bell": {
200
+ "happy": [
201
+ "OH MY GOSH THIS IS THE BEST DAY EVER! I need to document everything!",
202
+ "I KNEW something amazing would happen! My notebook predicted this!",
203
+ ],
204
+ "stressed": [
205
+ "This is TERRIBLE! This is the worst thing that has EVER happened on this block!",
206
+ "I'm writing this down as a DISASTER. My notebook needs a new chapter!",
207
+ ],
208
+ "bored": [
209
+ "That's it? I expected more. My notebook is disappointed.",
210
+ "I've been waiting for something cool and THIS is what we get?",
211
+ ],
212
+ "excited": [
213
+ "THIS IS THE MOST IMPORTANT THING THAT HAS EVER HAPPENED! I'M SHAKING!",
214
+ "I need to record this IMMEDIATELY! Future historians will thank me!",
215
+ ],
216
+ "hungry": [
217
+ "I'd be more excited but I skipped lunch to wait for something cool to happen.",
218
+ "This is amazing but I'm literally starving. Can we celebrate with snacks?",
219
+ ],
220
+ "tired": [
221
+ "I want to care about this but my eyes won't stay open.",
222
+ "Can... can this wait? I was up until 2am researching neighborhood legends.",
223
+ ],
224
+ "nostalgic": [
225
+ "This reminds me of the time I found that old map in Marta's shop! Similar vibes!",
226
+ "You know, this is exactly the kind of thing I've been writing about for months!",
227
+ ],
228
+ "curious": [
229
+ "Wait WHAT?! I need to investigate this IMMEDIATELY! Where's my notebook?!",
230
+ "This is suspicious. Very suspicious. I'm going to get to the bottom of this!",
231
+ ],
232
+ "proud": [
233
+ "I TOLD everyone something amazing would happen here! Check my notebook!",
234
+ "This is proof! This neighborhood is SPECIAL and I've been saying it all along!",
235
+ ],
236
+ "embarrassed": [
237
+ "I just screamed really loud didn't I? Please tell me nobody heard that.",
238
+ "I'm going to go hide in my room now. For several days.",
239
+ ],
240
+ },
241
+ "Priya Raman": {
242
+ "happy": [
243
+ "Well, this is a welcome surprise. I'll add it to the positive column.",
244
+ "Good. This block could use some good news for a change.",
245
+ ],
246
+ "stressed": [
247
+ "First, let's assess the situation. Second, let's make a plan. Third, PANIC.",
248
+ "I'm making a list. Of everything that could go wrong. It's a long list.",
249
+ ],
250
+ "bored": [
251
+ "I'll believe it when I see it. Until then, I have zoning reports to review.",
252
+ "Everyone calm down. This probably isn't what it seems.",
253
+ ],
254
+ "excited": [
255
+ "Okay, I'll admit it — this is genuinely exciting. I'm making an exception to my skepticism.",
256
+ "This changes the whole dynamic of the block! I need to recalculate everything!",
257
+ ],
258
+ "hungry": [
259
+ "I hate to admit it, but I'm too hungry to think clearly right now.",
260
+ "Can we table this discussion until after lunch? I'm not functioning.",
261
+ ],
262
+ "tired": [
263
+ "I've got my eye on this, but I'm too exhausted to process it fully.",
264
+ "This is significant, but I need coffee before I can formulate a proper response.",
265
+ ],
266
+ "nostalgic": [
267
+ "There's something beautiful about chaos, isn't there? Even organized chaos.",
268
+ "I hate to admit it, but this is kind of poetic. In a messy sort of way.",
269
+ ],
270
+ "curious": [
271
+ "Something doesn't add up here. I'm going to look into this officially.",
272
+ "I need more data before I form an opinion. This feels... incomplete.",
273
+ ],
274
+ "proud": [
275
+ "I've spent years trying to improve this block. Nice to see it getting attention.",
276
+ "This is exactly the kind of thing I've been working toward. You're welcome.",
277
+ ],
278
+ "embarrassed": [
279
+ "I just got excited about something unprofessional didn't I? Let's never speak of this.",
280
+ "My carefully maintained composure just cracked. Wonderful.",
281
+ ],
282
+ },
283
+ }
284
+
285
+ MOVEMENT_HINTS = [
286
+ "If you'd naturally move somewhere during this event, end your reaction with [GOTO: hotspot_name].",
287
+ "If this event would make you walk to a different part of the neighborhood, end with [GOTO: hotspot_name].",
288
+ "Stay put unless the event strongly draws you somewhere else. If you move, end with [GOTO: hotspot_name].",
289
+ ]
290
+
291
+ HOTSPOT_KEYS = [
292
+ "marta_home", "cafe", "park", "square", "jay_home",
293
+ "school", "nia_clinic", "priya_office",
294
+ ]
295
+
296
+
297
+ def _build_mood_context(mood):
298
+ return MOOD_CONTEXT.get(mood, "")
299
+
300
+
301
+ def _hotspots(world):
302
+ """Destinations a character can choose. Prefer board tiles (what the app maps
303
+ and the renderer draws); fall back to the legacy top-level dict. Robust for
304
+ every world — starhaven/old_town only define board['hotspots_tile']."""
305
+ return (world.get("board", {}) or {}).get("hotspots_tile") or world.get("hotspots") or {}
306
+
307
+
308
+ def build_agent_prompt(character, event, mood, memory, relationships, world):
309
+ seed = random.choice(SURPRISE_SEEDS)
310
+ movement = random.choice(MOVEMENT_HINTS)
311
+
312
+ memory_str = ""
313
+ if memory:
314
+ memory_str = "Your recent memories: " + "; ".join(memory[-4:]) + "."
315
+
316
+ reflection = world_state.get_reflection(world["id"], character["name"])
317
+ reflection_str = ""
318
+ if reflection:
319
+ reflection_str = f"Inner reflection: {reflection}"
320
+
321
+ hotspots = ", ".join(_hotspots(world).keys())
322
+
323
+ persona = (
324
+ f"You are {character['name']}, a {character['age']}-year-old {character['job']}. "
325
+ f"Traits: {', '.join(character['traits'])}. "
326
+ f"Backstory: {character['backstory']}. "
327
+ f"Catchphrase style: {character.get('catchphrase_hint', '')}. "
328
+ f"{relationships} "
329
+ f"{memory_str} "
330
+ f"{reflection_str}"
331
+ )
332
+
333
+ first = character["name"].split()[0]
334
+ prompt = (
335
+ f"{persona}\n\n"
336
+ f"Your mood right now: {mood}. {_build_mood_context(mood)}\n\n"
337
+ f"Something just happened on your street: \"{event}\"\n\n"
338
+ f"React the way {first} truly would. In 1 to 2 short sentences, speak OUT LOUD in first person "
339
+ f"— your own voice — showing you understood what happened and what you mean to do about it. "
340
+ f"{seed} "
341
+ f"Speak ONLY as {first}: no narration, no stage directions, no notes to yourself, no labels, no quotation marks."
342
+ )
343
+ return prompt
344
+
345
+
346
+ # scratch / meta lines a weak model leaks instead of staying in character
347
+ _META_RE = re.compile(
348
+ r"^(let me\b|i need to\b|i should\b|i'll (incorporate|write|make|ensure)|now,?\s*i\b|"
349
+ r"but the format\b|i think that works\b|first,?\s*i\b|okay,?\s*(so|let)\b|here('s| is)\b|"
350
+ r"in the (do|think|say)\b|for (the )?(say|do|think)\b|the format\b|as an ai\b|sure,?\s|note:)",
351
+ re.IGNORECASE,
352
+ )
353
+
354
+ # task-referential phrases that mean the model is talking about the assignment, not the street
355
+ _META_CONTAINS = re.compile(
356
+ r"(sentence|i'?ll count|let'?s (write|count|ensure)|1 to 2|the format|in character|"
357
+ r"the prompt|stage direction|first sentence|i (will|'?ll) (say|write))",
358
+ re.IGNORECASE,
359
+ )
360
+
361
+
362
+ def _clean_line(raw):
363
+ """Pull a clean, in-character spoken line out of a messy small-model reply."""
364
+ raw = (raw or "").strip()
365
+ m = re.search(r"say\s*:\s*(.+)", raw, re.IGNORECASE) # honor old SAY: format if used
366
+ cand = m.group(1).strip() if m else raw
367
+ cand = re.sub(r"\b(think|do|say|goto)\s*:\s*", " ", cand, flags=re.IGNORECASE)
368
+ cand = re.sub(r"\[[^\]]*\]", "", cand) # [GOTO: ...] / brackets
369
+ cand = re.sub(r"\*[^*]*\*", "", cand) # *stage directions*
370
+ cand = cand.replace("\n", " ").strip()
371
+ parts = re.split(r"(?<=[.!?])\s+", cand)
372
+ keep = [p.strip(" \"'") for p in parts
373
+ if p.strip() and not _META_RE.match(p.strip()) and not _META_CONTAINS.search(p)]
374
+ # empty => nothing in-character survived; caller falls back to a persona line
375
+ return " ".join(keep[:2]).strip(" \"'")
376
+
377
+
378
+ def _parse_goto(text):
379
+ match = re.search(r'\[GOTO:\s*(\w+)\]', text)
380
+ if match:
381
+ hotspot = match.group(1)
382
+ clean_text = re.sub(r'\[GOTO:\s*\w+\]', '', text).strip()
383
+ if hotspot in HOTSPOT_KEYS:
384
+ return clean_text, hotspot
385
+ return clean_text, None
386
+ return text, None
387
+
388
+
389
+ def _field(text, key):
390
+ m = re.search(rf'^\s*{key}\s*:\s*(.+?)\s*$', text, re.IGNORECASE | re.MULTILINE)
391
+ return m.group(1).strip() if m else ""
392
+
393
+
394
+ def _parse_structured(raw, world):
395
+ """Pull THINK / DO / SAY / GOTO out of a reasoning reply. Falls back to the
396
+ older [GOTO] / free-text shape so a model that ignores the format still works."""
397
+ think = _field(raw, "THINK")
398
+ do = _field(raw, "DO")
399
+ say = _field(raw, "SAY")
400
+ goto_raw = _field(raw, "GOTO")
401
+
402
+ goto = None
403
+ keys = list(_hotspots(world).keys())
404
+ if goto_raw:
405
+ cand = re.sub(r'[^a-z_]', '', goto_raw.lower().replace(" ", "_"))
406
+ if cand in keys:
407
+ goto = cand
408
+ else:
409
+ for k in keys:
410
+ if k in cand or cand in k:
411
+ goto = k
412
+ break
413
+
414
+ if not say:
415
+ # model didn't follow the format — treat the whole thing as the spoken line
416
+ say, legacy_goto = _parse_goto(raw.strip())
417
+ goto = goto or legacy_goto
418
+
419
+ # trim a stray DO that leaked into the spoken line
420
+ say = re.sub(r'^(THINK|DO|SAY|GOTO)\s*:\s*', '', say, flags=re.IGNORECASE).strip()
421
+ return think, do, say or raw.strip(), goto
422
+
423
+
424
+ def _compute_drama(text):
425
+ drama = random.uniform(0.2, 0.9)
426
+ exclamations = text.count("!")
427
+ caps_words = sum(1 for w in text.split() if w.isupper() and len(w) > 2)
428
+ drama = min(1.0, drama + exclamations * 0.05 + caps_words * 0.05)
429
+ return round(drama, 3)
430
+
431
+
432
+ def _compute_vibe_delta(mood):
433
+ base = {"energy": 0.0, "social": 0.0}
434
+ mood_energy = {
435
+ "happy": 0.08, "excited": 0.12, "proud": 0.06, "curious": 0.04,
436
+ "stressed": -0.1, "bored": -0.08, "tired": -0.15, "hungry": -0.05,
437
+ "nostalgic": 0.02, "embarrassed": -0.03,
438
+ }
439
+ mood_social = {
440
+ "happy": 0.1, "excited": 0.08, "curious": 0.06, "proud": 0.02,
441
+ "nostalgic": 0.04, "stressed": -0.06, "bored": -0.1, "tired": -0.08,
442
+ "hungry": -0.04, "embarrassed": -0.05,
443
+ }
444
+ base["energy"] = mood_energy.get(mood, 0.0) + random.uniform(-0.02, 0.02)
445
+ base["social"] = mood_social.get(mood, 0.0) + random.uniform(-0.02, 0.02)
446
+ return base
447
+
448
+
449
+ def _compute_affinity_deltas(name, character, mood):
450
+ deltas = {}
451
+ for other_name, rel in character.get("relationships", {}).items():
452
+ if rel == "friend":
453
+ deltas[other_name] = random.uniform(0.01, 0.04)
454
+ elif rel == "rival":
455
+ deltas[other_name] = random.uniform(-0.04, -0.01)
456
+ elif rel == "crush":
457
+ deltas[other_name] = random.uniform(0.02, 0.06)
458
+ elif rel == "family":
459
+ deltas[other_name] = random.uniform(0.01, 0.03)
460
+ if mood in ("stressed", "angry"):
461
+ deltas[other_name] = deltas.get(other_name, 0) - 0.02
462
+ elif mood in ("happy", "excited"):
463
+ deltas[other_name] = deltas.get(other_name, 0) + 0.01
464
+ return deltas
465
+
466
+
467
+ # keyword -> where a sensible person would head to deal with that kind of situation
468
+ _EVENT_HOTSPOT = [
469
+ (("power", "outage", "lights", "electric"), "priya_office"),
470
+ (("school", "kids", "class", "student"), "school"),
471
+ (("hurt", "injur", "accident", "sick", "fire", "emergency", "storm"), "nia_clinic"),
472
+ (("food", "truck", "hungry", "coffee", "cafe", "café"), "cafe"),
473
+ (("park", "tree", "object", "glow", "mural", "cat", "kitten", "animal"), "park"),
474
+ (("meeting", "everyone", "gather", "crowd", "news", "vote"), "square"),
475
+ ]
476
+
477
+
478
+ def _mock_brain(character, event, world):
479
+ """Cheap stand-in for real reasoning: a relevant destination + a concrete action,
480
+ so even the offline demo shows characters engaging the situation, not just emoting."""
481
+ e = event.lower()
482
+ hs = _hotspots(world)
483
+ goto = None
484
+ for keys, spot in _EVENT_HOTSPOT:
485
+ if any(k in e for k in keys) and spot in hs:
486
+ goto = spot
487
+ break
488
+ if not goto:
489
+ goto = "square" if "square" in hs else (random.choice(list(hs.keys())) if hs else None)
490
+ # spread the cast out: about half deal with it on their own turf instead of all piling onto one spot
491
+ home = character.get("home")
492
+ if home in hs and random.random() < 0.5:
493
+ goto = home
494
+ job = character.get("job", "neighbor")
495
+ action = random.choice([
496
+ f"head to the {goto.replace('_', ' ')} to see it firsthand",
497
+ f"go to the {goto.replace('_', ' ')} and do something about it",
498
+ f"use what they know as a {job} to help",
499
+ f"rally a neighbor and act on it together",
500
+ f"check on the people most affected first",
501
+ ])
502
+ understanding = "This changes things on the block — better to deal with it than ignore it."
503
+ return understanding, action, goto
504
+
505
+
506
+ def _mock_react(character, event, mood, world):
507
+ char_name = character["name"]
508
+ mood_reactions = MOCK_REACTIONS.get(char_name, GENERIC_MOCK)
509
+ pool = mood_reactions.get(mood, mood_reactions.get("bored", ["Hmm. Interesting."]))
510
+
511
+ text = random.choice(pool)
512
+ drama = _compute_drama(text)
513
+
514
+ text, goto = _parse_goto(text)
515
+ understanding, action, brain_goto = _mock_brain(character, event, world)
516
+ if not goto:
517
+ goto = brain_goto
518
+
519
+ return {
520
+ "name": char_name,
521
+ "mood": mood,
522
+ "text": text,
523
+ "understanding": understanding,
524
+ "action": action,
525
+ "model": character["model"],
526
+ "drama": drama,
527
+ "moved_to": goto,
528
+ "vibe_delta": _compute_vibe_delta(mood),
529
+ "affinity_deltas": _compute_affinity_deltas(char_name, character, mood),
530
+ }
531
+
532
+
533
+ def _real_react(character, event, mood, memory, relationships, world):
534
+ global _FAILURES, _CIRCUIT_OPEN_UNTIL
535
+ prompt = build_agent_prompt(character, event, mood, memory, relationships, world)
536
+
537
+ try:
538
+ import httpx
539
+
540
+ now = time.time()
541
+ if _CIRCUIT_OPEN_UNTIL > now:
542
+ _set_runtime_status("error", character["model"], "LLM error", None, "circuit breaker cooling down")
543
+ return _error_reaction(
544
+ character, mood,
545
+ f"model unreachable — retry after {int(_CIRCUIT_OPEN_UNTIL - now)}s",
546
+ )
547
+
548
+ payload = {
549
+ "event": event,
550
+ "characters": [character],
551
+ "prompts": {character["name"]: prompt},
552
+ }
553
+
554
+ data = None
555
+ last_error = None
556
+ for attempt in range(2):
557
+ try:
558
+ timeout = 180.0 if attempt == 0 else 45.0
559
+ _set_runtime_status("waking", character["model"], "Waking the model (~90s)", None)
560
+ start = time.time()
561
+ with httpx.Client(timeout=timeout, follow_redirects=True) as client:
562
+ resp = client.post(MODAL_REACT_URL, json=payload)
563
+ resp.raise_for_status()
564
+ data = resp.json()
565
+ latency = round(time.time() - start, 2)
566
+ _FAILURES = 0
567
+ _set_runtime_status("live", character["model"], "Live", latency)
568
+ break
569
+ except Exception as e:
570
+ last_error = e
571
+ if attempt == 0:
572
+ time.sleep(1.0)
573
+ if data is None:
574
+ raise last_error or RuntimeError("Modal request failed")
575
+
576
+ reactions = data.get("reactions", [])
577
+ if reactions and not reactions[0].get("error"):
578
+ raw = reactions[0]["text"]
579
+ text = _clean_line(raw) # real LLM dialogue, de-noised
580
+ if len(text) < 4:
581
+ return _error_reaction(character, mood, "model returned unusable text")
582
+ # movement + action are decided by the engine so a weak model can't break them
583
+ understanding, action, goto = _mock_brain(character, event, world)
584
+ drama = _compute_drama(text + " " + action)
585
+
586
+ return {
587
+ "name": character["name"],
588
+ "mood": mood,
589
+ "text": text,
590
+ "understanding": understanding,
591
+ "action": action,
592
+ "model": character["model"],
593
+ "drama": drama,
594
+ "moved_to": goto,
595
+ "vibe_delta": _compute_vibe_delta(mood),
596
+ "affinity_deltas": _compute_affinity_deltas(character["name"], character, mood),
597
+ "error": False,
598
+ }
599
+ message = reactions[0].get("text", "model unreachable") if reactions else "model unreachable"
600
+ raise RuntimeError(message)
601
+
602
+ except Exception as e:
603
+ _FAILURES += 1
604
+ if _FAILURES >= 3:
605
+ _CIRCUIT_OPEN_UNTIL = time.time() + 60.0
606
+ print(f"[agents] Modal call failed for {character['name']}: {e}")
607
+ _set_runtime_status("error", character["model"], "LLM error", None, str(e))
608
+ return _error_reaction(character, mood, f"model unreachable — retrying ({e})")
609
+
610
+
611
+ def _set_runtime_status(mode, model, message, latency=None, error=None):
612
+ _RUNTIME_STATUS.update({
613
+ "mode": "mock" if MOCK else mode,
614
+ "model": "offline demo" if MOCK else (model or PRIMARY_MODEL),
615
+ "message": "Offline demo (mock)" if MOCK else message,
616
+ "latency": latency,
617
+ "error": error,
618
+ })
619
+
620
+
621
+ def get_runtime_status():
622
+ if MOCK:
623
+ return {
624
+ "mode": "mock",
625
+ "model": "offline demo",
626
+ "message": "Offline demo (mock)",
627
+ "latency": None,
628
+ "error": None,
629
+ }
630
+ return dict(_RUNTIME_STATUS)
631
+
632
+
633
+ def _error_reaction(character, mood, message):
634
+ return {
635
+ "name": character["name"],
636
+ "mood": mood,
637
+ "text": f"[{message}]",
638
+ "understanding": "The model call failed, so no in-character decision was produced.",
639
+ "action": "wait for the model to become reachable",
640
+ "model": character["model"],
641
+ "drama": 0.05,
642
+ "moved_to": None,
643
+ "vibe_delta": {"energy": 0.0, "social": 0.0},
644
+ "affinity_deltas": {},
645
+ "error": True,
646
+ }
647
+
648
+
649
+ def _needs_from_vibes(world_id, name):
650
+ vibes = world_state.get_vibes(world_id).get(name, {"energy": 0.5, "social": 0.5})
651
+ return {
652
+ "energy": int(vibes.get("energy", 0.5) * 100),
653
+ "hunger": 45,
654
+ "social": int(vibes.get("social", 0.5) * 100),
655
+ }
656
+
657
+
658
+ def _reaction_from_decision(character, decision):
659
+ vibe_delta = _compute_vibe_delta(decision.mood)
660
+ vibe_delta["energy"] += decision.need_deltas.get("energy", 0) / 100.0
661
+ vibe_delta["social"] += decision.need_deltas.get("social", 0) / 100.0
662
+ goto = None if decision.goto == "stay" else decision.goto
663
+ text = decision.say
664
+ if decision.degraded and decision.warning:
665
+ text = f"{text} [decision degraded: {decision.warning}]"
666
+ return {
667
+ "name": character["name"],
668
+ "mood": decision.mood,
669
+ "text": text,
670
+ "understanding": decision.think,
671
+ "action": decision.action,
672
+ "model": character["model"],
673
+ "drama": _compute_drama(f"{decision.say} {decision.action}"),
674
+ "moved_to": goto,
675
+ "vibe_delta": vibe_delta,
676
+ "affinity_deltas": _compute_affinity_deltas(character["name"], character, decision.mood),
677
+ "error": decision.error,
678
+ "degraded": decision.degraded,
679
+ }
680
+
681
+
682
+ def _backend():
683
+ """Which inference backend to use: 'mock' (offline), 'local' (ZeroGPU in-process),
684
+ or 'modal' (HTTP to Modal, the default)."""
685
+ if MOCK:
686
+ return "mock"
687
+ return os.environ.get("TINYWORLD_INFER", "modal").lower()
688
+
689
+
690
+ def _char_context(character, world):
691
+ wid = world["id"]
692
+ name = character["name"]
693
+ return {
694
+ "mood": world_state.get_mood(wid, name),
695
+ "memory": world_state.get_memory(wid, name),
696
+ "relationships": _format_relationships(character),
697
+ "position": world_state.get_position(wid, name) or character.get("home", "square"),
698
+ "needs": _needs_from_vibes(wid, name),
699
+ }
700
+
701
+
702
+ def _finalize_reaction(character, world, result, route, event):
703
+ """Apply directed-command forcing, then persist mood + memory. Shared by every
704
+ backend so the engine behaves identically however the dialogue was produced."""
705
+ wid = world["id"]
706
+ forced_goto = (route or {}).get("goto")
707
+ hotspots = decide.allowed_hotspots(world)
708
+ if (route or {}).get("type") == "directed_command" and not result.get("error"):
709
+ if forced_goto in hotspots:
710
+ result["moved_to"] = forced_goto
711
+ # Surface the player's own instruction into the action so Crisis Mode's
712
+ # resolver can read what was ordered and obeyed — the small dialogue model
713
+ # often paraphrases the order and drops the keyword the resolver looks for.
714
+ instr = (route or {}).get("instruction", "")
715
+ base = result.get("action") or "follow the instruction"
716
+ if instr and instr.lower() not in base.lower():
717
+ result["action"] = f"{base} ({instr})"
718
+ elif forced_goto in hotspots and not result.get("error"):
719
+ result["moved_to"] = forced_goto
720
+
721
+ world_state.set_mood(wid, character["name"], result["mood"])
722
+ # learn from what they DID, not just what they said, so future reactions build on it
723
+ learned = result.get("action") or result["text"]
724
+ world_state.add_memory(wid, character["name"], event, learned)
725
+ return result
726
+
727
+
728
+ def _react_one(character, event, world, route=None):
729
+ """One character via mock or Modal (per-call). The ZeroGPU path batches instead
730
+ — see _react_local."""
731
+ global _FAILURES, _CIRCUIT_OPEN_UNTIL
732
+ ctx = _char_context(character, world)
733
+ mood = ctx["mood"]
734
+
735
+ if MOCK:
736
+ decision_obj = decide.mock_decision(character, event, mood, world, route)
737
+ result = _reaction_from_decision(character, decision_obj)
738
+ else:
739
+ try:
740
+ now = time.time()
741
+ if _CIRCUIT_OPEN_UNTIL > now:
742
+ _set_runtime_status("error", character["model"], "LLM error", None, "circuit breaker cooling down")
743
+ return _error_reaction(
744
+ character, mood,
745
+ f"model unreachable — retry after {int(_CIRCUIT_OPEN_UNTIL - now)}s",
746
+ )
747
+ _set_runtime_status("waking", character["model"], "Waking the model (~90s)", None)
748
+ decision_obj = decide.decide_real(
749
+ character, event, mood, ctx["memory"], ctx["relationships"], world,
750
+ ctx["position"], ctx["needs"], route
751
+ )
752
+ _FAILURES = 0
753
+ _set_runtime_status("live", character["model"], "Live", decision_obj.latency)
754
+ result = _reaction_from_decision(character, decision_obj)
755
+ except Exception as e:
756
+ _FAILURES += 1
757
+ if _FAILURES >= 3:
758
+ _CIRCUIT_OPEN_UNTIL = time.time() + 60.0
759
+ print(f"[agents] Decision failed for {character['name']}: {e}")
760
+ _set_runtime_status("error", character["model"], "LLM error", None, str(e))
761
+ result = _error_reaction(character, mood, f"model unreachable — retrying ({e})")
762
+
763
+ return _finalize_reaction(character, world, result, route, event)
764
+
765
+
766
+ def _react_local(cast, event, world, route=None):
767
+ """All characters in ONE ZeroGPU allocation: build every prompt, run a single
768
+ batched generate, then parse + finalize each. Avoids 5 separate GPU grabs."""
769
+ import inference # heavy (torch/transformers); imported only on the local backend
770
+
771
+ contexts, prompts = {}, []
772
+ for ch in cast:
773
+ ctx = _char_context(ch, world)
774
+ contexts[ch["name"]] = ctx
775
+ prompts.append(decide.build_decision_prompt(
776
+ ch, event, ctx["mood"], ctx["memory"], ctx["relationships"],
777
+ world, ctx["position"], ctx["needs"], route))
778
+
779
+ try:
780
+ _set_runtime_status("waking", PRIMARY_MODEL, "Running on ZeroGPU…", None)
781
+ start = time.time()
782
+ raws = inference.generate_batch(prompts)
783
+ latency = round(time.time() - start, 2)
784
+ _set_runtime_status("live", PRIMARY_MODEL, "Live (ZeroGPU)", latency)
785
+ except Exception as e:
786
+ print(f"[agents] ZeroGPU batch failed: {e}")
787
+ _set_runtime_status("error", PRIMARY_MODEL, "GPU error", None, str(e))
788
+ return [_error_reaction(ch, contexts[ch["name"]]["mood"], f"GPU error ({e})") for ch in cast]
789
+
790
+ reactions = []
791
+ for ch, raw in zip(cast, raws):
792
+ decision = decide.parse_decision(raw, world, previous_mood=contexts[ch["name"]]["mood"])
793
+ result = _reaction_from_decision(ch, decision)
794
+ reactions.append(_finalize_reaction(ch, world, result, route, event))
795
+ return reactions
796
+
797
+
798
+ def _format_relationships(character):
799
+ relationships = character.get("relationships", {})
800
+ if not relationships:
801
+ return "Relationships: nobody notable in this neighborhood yet."
802
+ parts = [f"{other_name} is your {relationship}" for other_name, relationship in relationships.items()]
803
+ return "Relationships: " + "; ".join(parts) + "."
804
+
805
+
806
+ def react(world_id, event, mode="solo", route=None):
807
+ world = get_world(world_id)
808
+ if not world:
809
+ raise ValueError(f"Unknown world: {world_id}")
810
+
811
+ state = world_state.get_state(world_id)
812
+ world_state.init_cast(world)
813
+ world_state.increment_event(world_id)
814
+
815
+ target_names = set((route or {}).get("addressees") or [])
816
+ cast = [c for c in world["cast"] if not target_names or c["name"] in target_names]
817
+ if not cast:
818
+ cast = world["cast"]
819
+
820
+ if _backend() == "local":
821
+ # ZeroGPU: one batched GPU call for the whole cast.
822
+ reactions = _react_local(cast, event, world, route)
823
+ else:
824
+ with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
825
+ futures = {
826
+ executor.submit(_react_one, char, event, world, route): char
827
+ for char in cast
828
+ }
829
+ reactions = []
830
+ for future in concurrent.futures.as_completed(futures):
831
+ try:
832
+ reactions.append(future.result())
833
+ except Exception as e:
834
+ char = futures[future]
835
+ print(f"[agents] error for {char['name']}: {e}")
836
+ mood = world_state.get_mood(world_id, char["name"])
837
+ reactions.append({
838
+ "name": char["name"],
839
+ "mood": mood,
840
+ "text": f"[Something went wrong, but {char['name']} is still thinking...]",
841
+ "model": char["model"],
842
+ "drama": 0.1,
843
+ "moved_to": None,
844
+ "vibe_delta": {"energy": 0, "social": 0},
845
+ "affinity_deltas": {},
846
+ })
847
+
848
+ order = {c["name"]: i for i, c in enumerate(world["cast"])}
849
+ reactions.sort(key=lambda r: order.get(r["name"], 99))
850
+
851
+ for r in reactions:
852
+ world_state.apply_vibe_delta(world_id, r["name"], r["vibe_delta"])
853
+ world_state.apply_affinity_delta(world_id, r["name"], r["affinity_deltas"])
854
+ if r["moved_to"]:
855
+ world_state.set_position(world_id, r["name"], r["moved_to"])
856
+
857
+ town_delta = sum(r["vibe_delta"]["energy"] for r in reactions) / len(reactions)
858
+ current_town = world_state.get_town_mood(world_id)
859
+ world_state.set_town_mood(world_id, current_town + town_delta)
860
+
861
+ for c in cast:
862
+ if state["event_count"] % 3 == 0:
863
+ world_state.maybe_form_reflection(world_id, c["name"])
864
+
865
+ return {
866
+ "reactions": reactions,
867
+ "town_mood_delta": round(town_delta, 4),
868
+ "runtime": get_runtime_status(),
869
+ }
870
+
871
+
872
+ def generate_followup(reactions, event):
873
+ if random.random() > 0.4:
874
+ return None
875
+
876
+ source = random.choice(reactions)
877
+ others = [r for r in reactions if r["name"] != source["name"]]
878
+ if not others:
879
+ return None
880
+ target = random.choice(others)
881
+
882
+ templates = [
883
+ f"Because {source['name']} reacted that way, {target['name']} quietly decides to keep an eye on them.",
884
+ f"{target['name']} overheard {source['name']}'s reaction and can't stop thinking about it.",
885
+ f"After hearing {source['name']}, {target['name']} changes their mind about the whole thing.",
886
+ f"{target['name']} wonders if {source['name']} knows something they're not saying.",
887
+ f"The look on {source['name']}'s face tells {target['name']} everything they need to know.",
888
+ f"{target['name']} files away {source['name']}'s reaction for later. That was... telling.",
889
+ f"While everyone else moves on, {target['name']} notices {source['name']} is still upset.",
890
+ f"{target['name']} wants to ask {source['name']} what they really think, but not in front of everyone.",
891
+ ]
892
+
893
+ return {
894
+ "type": "followup",
895
+ "source": source["name"],
896
+ "target": target["name"],
897
+ "text": random.choice(templates),
898
+ "mood": target["mood"],
899
+ }
900
+
901
+
902
+ if __name__ == "__main__":
903
+ from worlds import get_world
904
+ w = get_world("maple_street")
905
+ world_state.init_cast(w)
906
+ result = react("maple_street", "A mysterious glowing object lands in the park")
907
+ for r in result["reactions"]:
908
+ print(f"{r['name']} | {r['mood']} | goto={r['moved_to']} | {r['text']}")
909
+ print(f"\nTown mood delta: {result['town_mood_delta']}")
910
+ followup = generate_followup(result["reactions"], "A mysterious glowing object lands in the park")
911
+ if followup:
912
+ print(f"\n[FOLLOWUP] {followup['text']}")
app.py ADDED
@@ -0,0 +1,698 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import time
4
+ import random
5
+ import html
6
+ import gradio as gr
7
+ import agents
8
+ import voice
9
+ import transcribe
10
+ import events
11
+ import router
12
+ import world_state
13
+ import campaign
14
+ from worlds import get_world, WORLDS, list_worlds
15
+
16
+ MOCK = os.environ.get("TINYWORLD_MOCK", "0") == "1"
17
+
18
+ MOOD_EMOJI = {
19
+ "happy": "😊", "stressed": "😰", "bored": "😐", "excited": "🤩",
20
+ "hungry": "🍕", "tired": "😴", "nostalgic": "🥹", "curious": "🧐",
21
+ "proud": "😤", "embarrassed": "🫣",
22
+ }
23
+
24
+ VIBE_COLORS = {
25
+ "energy": ("#ff6b6b", "#ffb15f"),
26
+ "hunger": ("#ffd76a", "#ff8a5f"),
27
+ "social": ("#38e8ff", "#9b6bff"),
28
+ }
29
+
30
+ CHAR_COLORS = {
31
+ "Marta Voss": "#ff5fa2", "Jay Park": "#ff8a5f", "Nia Okafor": "#7CFFB2",
32
+ "Luca Bell": "#38e8ff", "Priya Raman": "#9b6bff",
33
+ }
34
+ PALETTE = ["#ff5fa2", "#ff8a5f", "#7CFFB2", "#38e8ff", "#9b6bff"]
35
+
36
+
37
+ def esc(value):
38
+ return html.escape(str(value), quote=True)
39
+
40
+
41
+ def char_color(world, name):
42
+ for i, c in enumerate(world["cast"]):
43
+ if c["name"] == name:
44
+ return c.get("color") or CHAR_COLORS.get(name) or PALETTE[i % len(PALETTE)]
45
+ return "#9b6bff"
46
+
47
+
48
+ # ---------------------------------------------------------------- canvas payloads
49
+ def build_world_payload(world_id):
50
+ world = get_world(world_id)
51
+ board = world["board"]
52
+ cast = []
53
+ for c in world["cast"]:
54
+ home_key = c.get("home", "square")
55
+ tile = board["hotspots_tile"].get(home_key, [6, 6])
56
+ cast.append({
57
+ "name": c["name"], "short": c["name"].split()[0],
58
+ "emoji": c.get("emoji", "👤"),
59
+ "color": char_color(world, c["name"]),
60
+ "home": tile,
61
+ })
62
+ payload = {
63
+ "cols": board["cols"], "rows": board["rows"],
64
+ "roads": board["roads"], "plaza": board["plaza"],
65
+ "plaza_center": board.get("plaza_center", [board["cols"] / 2, board["rows"] / 2]),
66
+ "buildings": board["buildings"], "trees": board.get("trees", []),
67
+ "props": board.get("props", []), "cast": cast,
68
+ "hotspots": board.get("hotspots_tile", {}), "ambient": board.get("ambient", 12),
69
+ }
70
+ return json.dumps(payload)
71
+
72
+
73
+ def build_reactions_payload(world_id, reactions):
74
+ world = get_world(world_id)
75
+ board = world["board"]
76
+ hs = board["hotspots_tile"]
77
+ acts = board.get("activities", {})
78
+ out = {"ts": time.time(), "reactions": []}
79
+ for r in reactions:
80
+ name = r["name"]
81
+ key = r.get("moved_to") or world_state.get_position(world_id, name)
82
+ act = acts.get(name, {})
83
+ action = (r.get("action") or "").strip()
84
+ short_action = (action[:24].rstrip() + "…") if len(action) > 25 else action
85
+ label = short_action or act.get("label", "")
86
+ vehicle = act.get("vehicle", "")
87
+ if key and key in hs:
88
+ tgt = hs[key]
89
+ else:
90
+ home = next((c.get("home") for c in world["cast"] if c["name"] == name), None)
91
+ tgt = hs.get(home) or hs.get("square") or [0, 0]
92
+ out["reactions"].append({
93
+ "name": name, "short": name.split()[0],
94
+ "mood": r["mood"], "moodEmoji": MOOD_EMOJI.get(r["mood"], "😐"),
95
+ "text": r["text"], "target": tgt, "activity": label, "vehicle": vehicle,
96
+ "running": False,
97
+ })
98
+ return json.dumps(out)
99
+
100
+
101
+ def build_state_payload(world_id):
102
+ world = get_world(world_id)
103
+ board = world["board"]
104
+ hs = board["hotspots_tile"]
105
+ out = {"ts": time.time(), "silent": True, "reactions": []}
106
+ for c in world["cast"]:
107
+ name = c["name"]
108
+ key = world_state.get_position(world_id, name) or c.get("home", "square")
109
+ tile = hs.get(key) or hs.get(c.get("home")) or hs.get("square") or [0, 0]
110
+ mood = world_state.get_mood(world_id, name)
111
+ out["reactions"].append({
112
+ "name": name,
113
+ "short": name.split()[0],
114
+ "mood": mood,
115
+ "moodEmoji": MOOD_EMOJI.get(mood, "😐"),
116
+ "text": "",
117
+ "target": tile,
118
+ "activity": world_state.get_activity(world_id, name),
119
+ "vehicle": "",
120
+ "running": False,
121
+ })
122
+ return json.dumps(out)
123
+
124
+
125
+ # ---------------------------------------------------------------- roster / ticker / log
126
+ def render_vibe_bar(label, value, colors):
127
+ c1, c2 = colors
128
+ pct = int(value * 100)
129
+ return (
130
+ f'<div class="vibe-row"><span class="vibe-label">{label}</span>'
131
+ f'<div class="vibe-track"><div class="vibe-fill" style="width:{pct}%;'
132
+ f'background:linear-gradient(90deg,{c1},{c2});color:{c1}"></div></div>'
133
+ f'<span class="vibe-val">{pct}</span></div>'
134
+ )
135
+
136
+
137
+ def render_roster(world_id):
138
+ world = get_world(world_id)
139
+ world_state.init_cast(world)
140
+ state = world_state.get_state(world_id)
141
+ needs = world_state.get_needs(world_id)
142
+ cards = []
143
+ for c in world["cast"]:
144
+ name = c["name"]
145
+ mood = state["moods"].get(name, "curious")
146
+ mood_e = MOOD_EMOJI.get(mood, "😐")
147
+ n = needs.get(name, {"energy": 60, "hunger": 35, "social": 50})
148
+ cards.append(
149
+ f'<div class="roster-card" style="border-left-color:{char_color(world, name)}">'
150
+ f'<div class="roster-emoji">{c.get("emoji", "👤")}</div>'
151
+ f'<div class="roster-name">{name.split()[0]}</div>'
152
+ f'<div class="roster-mood">{mood_e} {mood}</div>'
153
+ f'<div class="roster-meters">{render_vibe_bar("⚡", n["energy"] / 100, VIBE_COLORS["energy"])}'
154
+ f'{render_vibe_bar("🍽", n["hunger"] / 100, VIBE_COLORS["hunger"])}'
155
+ f'{render_vibe_bar("💬", n["social"] / 100, VIBE_COLORS["social"])}</div>'
156
+ f'</div>'
157
+ )
158
+ return f'<div class="roster-rail">{"".join(cards)}</div>'
159
+
160
+
161
+ def render_ticker(world_id):
162
+ state = world_state.get_state(world_id)
163
+ day, game_time, paused = world_state.get_game_time(world_id)
164
+ chaos = state["chaos"]
165
+ filled = min(int(chaos * 5), 5)
166
+ bar = "▰" * filled + "▱" * (5 - filled)
167
+ unlocked = " 🔓" if chaos >= 0.5 else ""
168
+ return (
169
+ f'<div class="progress-ticker">'
170
+ f'<span class="ticker-day">DAY {day} · {world_state.format_time(game_time)}</span><span class="ticker-sep">·</span>'
171
+ f'<span class="ticker-events">{"PAUSED" if paused else "LIVE TIME"}</span><span class="ticker-sep">·</span>'
172
+ f'<span class="ticker-events">⚡ {state["event_count"]}</span><span class="ticker-sep">·</span>'
173
+ f'<span class="ticker-chaos">CHAOS {bar}{unlocked}</span></div>'
174
+ )
175
+
176
+
177
+ def render_daily_log(world_id):
178
+ world = get_world(world_id)
179
+ timeline = world_state.get_timeline(world_id)
180
+ rows = []
181
+ for c in world["cast"]:
182
+ name = c["name"]
183
+ entries = timeline.get(name, [])[-4:]
184
+ text = " · ".join(esc(e) for e in entries) if entries else "No entries yet."
185
+ rows.append(
186
+ f'<div class="daily-row"><b style="color:{char_color(world, name)}">{esc(name.split()[0])}</b> '
187
+ f'<span>{text}</span></div>'
188
+ )
189
+ return f'<div class="daily-log-inner">{"".join(rows)}</div>'
190
+
191
+
192
+ def render_mode_badge(status=None):
193
+ status = status or agents.get_runtime_status()
194
+ mode = status.get("mode")
195
+ model = status.get("model") or "unknown"
196
+ latency = status.get("latency")
197
+ error = status.get("error")
198
+ if mode == "live":
199
+ label = f"🟢 Live · {model.split('/')[-1]}"
200
+ if latency is not None:
201
+ label += f" · {latency:.1f}s"
202
+ cls = "mode-live"
203
+ elif mode == "waking":
204
+ label = f"🟡 Waking · {model.split('/')[-1]}"
205
+ cls = "mode-waking"
206
+ elif mode == "error":
207
+ msg = f" · {error}" if error else ""
208
+ label = f"🔴 LLM error{msg}"
209
+ cls = "mode-error"
210
+ else:
211
+ label = "🟡 Offline demo (mock)"
212
+ cls = "mode-mock"
213
+ return f'<div class="mode-badge {cls}">{esc(label)}</div>'
214
+
215
+
216
+ def render_town_log(world_id, reactions=None, followup=None):
217
+ world = get_world(world_id)
218
+ if not reactions:
219
+ return ('<div class="town-log-inner"><div class="town-log-empty">'
220
+ 'The town log is empty.<br>Throw an event to start the story.</div></div>')
221
+ lines = []
222
+ for r in reactions:
223
+ char = next((c for c in world["cast"] if c["name"] == r["name"]), None)
224
+ emoji = char.get("emoji", "👤") if char else "👤"
225
+ name = esc(r["name"])
226
+ text = esc(r["text"])
227
+ mood = esc(r["mood"])
228
+ lines.append(
229
+ f'<div class="log-entry"><div class="log-head">'
230
+ f'<span class="log-emoji">{emoji}</span><span class="log-name">{name}</span>'
231
+ f'<span class="log-mood">{MOOD_EMOJI.get(r["mood"], "😐")}</span></div>'
232
+ f'<div class="log-text" data-mood="{mood}">"{text}"</div></div>'
233
+ )
234
+ if followup:
235
+ lines.append(f'<div class="log-entry log-followup"><div class="log-text">🔗 <em>{esc(followup["text"])}</em></div></div>')
236
+ gossip_from = [r for r in reactions if random.random() < 0.3]
237
+ if gossip_from:
238
+ source = random.choice(gossip_from)
239
+ others = [c["name"] for c in world["cast"] if c["name"] != source["name"]]
240
+ if others:
241
+ target = random.choice(others)
242
+ snippet = source["text"][:60] + ("…" if len(source["text"]) > 60 else "")
243
+ lines.append(f'<div class="log-entry log-gossip"><div class="log-text">🗣️ '
244
+ f'<em>Word spreads: {esc(target.split()[0])} hears "{esc(snippet)}"</em></div></div>')
245
+ world_state.add_gossip(world_id, target, snippet)
246
+ return f'<div class="town-log-inner">{"".join(lines)}</div>'
247
+
248
+
249
+ def render_explainer(world_id, reactions=None, focus=None):
250
+ world = get_world(world_id)
251
+ if not reactions:
252
+ return ('<div class="explain-inner"><div class="explain-empty">'
253
+ 'Pick a <b>scenario</b> or throw an event, then this panel explains '
254
+ '<b>why each person reacted the way they did</b> — great for teaching how '
255
+ 'different people respond to the same situation.</div></div>')
256
+ parts = []
257
+ if focus:
258
+ parts.append(f'<div class="explain-focus">🎓 <b>Think about:</b> {esc(focus)}</div>')
259
+ else:
260
+ parts.append('<div class="explain-focus">🎓 <b>Why did they react this way?</b></div>')
261
+ for r in reactions:
262
+ c = next((x for x in world["cast"] if x["name"] == r["name"]), None)
263
+ if not c:
264
+ continue
265
+ trait = (c.get("traits") or ["distinct"])[0]
266
+ hint = c.get("catchphrase_hint", "responds in their own way")
267
+ col = char_color(world, r["name"])
268
+ understanding = esc((r.get("understanding") or "").strip())
269
+ action = esc((r.get("action") or "").strip())
270
+ first_name = esc(r["name"].split()[0])
271
+ mood = esc(r["mood"])
272
+ trait_text = esc(trait)
273
+ if understanding or action:
274
+ reason = (
275
+ f'reads it as: <em>{understanding}</em> ' if understanding else ''
276
+ ) + (
277
+ f'so they <b>{action}</b>.' if action else ''
278
+ )
279
+ parts.append(
280
+ f'<div class="explain-row"><b style="color:{col}">{first_name}</b> '
281
+ f'(feeling <em>{mood}</em>, naturally <em>{trait_text}</em>) {reason}</div>'
282
+ )
283
+ else:
284
+ parts.append(
285
+ f'<div class="explain-row"><b style="color:{col}">{first_name}</b> '
286
+ f'feels <em>{mood}</em> and is naturally <em>{trait_text}</em>, '
287
+ f'so they {esc(hint)}.</div>'
288
+ )
289
+ return f'<div class="explain-inner">{"".join(parts)}</div>'
290
+
291
+
292
+ # ---------------------------------------------------------------- crisis mode UI
293
+ def render_crisis_hud(world_id):
294
+ v = campaign.view(get_world(world_id))
295
+ cr = v["crisis"]
296
+ if not v["active"] or not cr:
297
+ return '<div class="crisis-hud crisis-hud-idle">No crisis active — switch to 🎬 Campaign and press <b>Start Campaign</b>.</div>'
298
+ chips = []
299
+ for req in cr["requirements"]:
300
+ cls = "met" if req["met"] else "open"
301
+ icon = "✅" if req["met"] else "⏳"
302
+ opt = " (optional)" if req["optional"] else ""
303
+ chips.append(f'<span class="crisis-chip {cls}">{icon} {esc(req["label"])}{esc(opt)}</span>')
304
+ chaos_pct = int(v["chaos"] * 100)
305
+ res = int(v["town_resilience"])
306
+ return (
307
+ f'<div class="crisis-hud">'
308
+ f'<div class="crisis-top"><span class="crisis-title">🚨 {esc(cr["title"])}</span>'
309
+ f'<span class="crisis-meta">Chapter {v["chapter_index"] + 1}/{v["chapter_count"]} · '
310
+ f'Round {cr["round"]}/{cr["time_limit"]}</span></div>'
311
+ f'<div class="crisis-chips">{"".join(chips)}</div>'
312
+ f'<div class="crisis-bars">'
313
+ f'<div class="crisis-bar"><span>🔥 Chaos</span><div class="cbar-track">'
314
+ f'<div class="cbar-fill chaos" style="width:{chaos_pct}%"></div></div><b>{chaos_pct}</b></div>'
315
+ f'<div class="crisis-bar"><span>🛡 Resilience</span><div class="cbar-track">'
316
+ f'<div class="cbar-fill res" style="width:{res}%"></div></div><b>{res}</b></div>'
317
+ f'</div></div>'
318
+ )
319
+
320
+
321
+ def render_story_card(kind="dispatch", title="", body="", extra=""):
322
+ if not title and not body and not extra:
323
+ return '<div class="story-card story-hidden"></div>'
324
+ title_html = f'<div class="story-title">{esc(title)}</div>' if title else ""
325
+ body_html = f'<div class="story-body">{esc(body)}</div>' if body else ""
326
+ return f'<div class="story-card story-{kind}">{title_html}{body_html}{extra}</div>'
327
+
328
+
329
+ def build_crisis_payload(world_id):
330
+ world = get_world(world_id)
331
+ v = campaign.view(world)
332
+ cr = v["crisis"]
333
+ out = {"ts": time.time(), "active": bool(v["active"] and cr),
334
+ "status": v["status"], "chaos": v["chaos"]}
335
+ if cr:
336
+ hs = world["board"]["hotspots_tile"]
337
+ out.update({
338
+ "kind": cr["kind"], "title": cr["title"],
339
+ "tile": hs.get(cr["affected_hotspot"]) if cr["affected_hotspot"] else None,
340
+ "round": cr["round"], "time_limit": cr["time_limit"],
341
+ "requirements": [{"id": r["id"], "label": r["label"], "met": r["met"]}
342
+ for r in cr["requirements"]],
343
+ })
344
+ return json.dumps(out)
345
+
346
+
347
+ def _outcome_card(res):
348
+ oc = res.get("outcome")
349
+ grade = res.get("grade")
350
+ if oc == "won":
351
+ return render_story_card("win", f"✅ Chapter cleared · Grade {grade}", res.get("outro", ""))
352
+ if oc == "lost_crisis":
353
+ return render_story_card("loss", f"❌ Chapter failed · Grade {grade}", res.get("outro", ""))
354
+ if oc == "campaign_lost":
355
+ return render_story_card("gameover", "🏚 The town fell",
356
+ f'{res.get("outro", "")} Town Resilience hit zero.')
357
+ if oc == "finale":
358
+ extra = f'<div class="story-reveal">{esc(res.get("reveal", ""))}</div>'
359
+ return render_story_card("finale", "🎬 The Long Summer — Finale", res.get("outro", ""), extra)
360
+ return render_story_card("dispatch", "", res.get("dispatch", ""))
361
+
362
+
363
+ def scenario_choices(world_id):
364
+ world = get_world(world_id)
365
+ return [(s["title"], s["id"]) for s in world.get("scenarios", [])]
366
+
367
+
368
+ def render_stage_shell():
369
+ return '<div class="stage" id="tw-stage"><canvas id="tw-canvas"></canvas><div class="stage-vignette"></div></div>'
370
+
371
+
372
+ # ---------------------------------------------------------------- assets
373
+ def _read(path):
374
+ full = os.path.join(os.path.dirname(__file__), path)
375
+ if os.path.exists(full):
376
+ with open(full) as f:
377
+ return f.read()
378
+ return ""
379
+
380
+
381
+ css_content = _read("assets/style.css")
382
+ js_content = _read("assets/game.js")
383
+
384
+ # Force dark mode on load. HF Spaces otherwise loads light mode, which renders
385
+ # Gradio's dark text on top of our neon-dark panels — unreadable. With ?__theme=dark
386
+ # Gradio uses its light-on-dark palette and everything is legible.
387
+ THEME_JS = (
388
+ "(function(){try{var u=new URL(window.location.href);"
389
+ "if(u.searchParams.get('__theme')!=='dark'){"
390
+ "u.searchParams.set('__theme','dark');window.location.replace(u.href);}}catch(e){}})();\n"
391
+ )
392
+ js_content = THEME_JS + js_content
393
+
394
+ DEFAULT_WORLD = os.environ.get("TW_DEFAULT_WORLD", "maple_street")
395
+ world_list = list_worlds()
396
+ world_names = {w["id"]: w["name"] for w in world_list}
397
+
398
+
399
+ # ---------------------------------------------------------------- UI
400
+ with gr.Blocks(title="TinyWorld — AI Neighborhood Game") as demo:
401
+ current_world_id = gr.State(DEFAULT_WORLD)
402
+ last_reactions_state = gr.State([])
403
+
404
+ with gr.Row():
405
+ with gr.Column(scale=7):
406
+ gr.HTML('<div class="tw-topbar"><div class="tw-logo">TINYWORLD</div>'
407
+ '<div class="tw-tagline">the town that remembers</div></div>')
408
+ with gr.Column(scale=2, min_width=160):
409
+ world_picker = gr.Dropdown(
410
+ choices=[(world_names.get(w["id"], w["id"]), w["id"]) for w in world_list],
411
+ value=DEFAULT_WORLD, label="World")
412
+ with gr.Column(scale=3, min_width=210):
413
+ ticker_html = gr.HTML(render_ticker(DEFAULT_WORLD))
414
+ with gr.Column(scale=2, min_width=210):
415
+ mode_badge_html = gr.HTML(render_mode_badge())
416
+
417
+ # the canvas stage (rendered once, never re-rendered → game loop persists)
418
+ gr.HTML(render_stage_shell())
419
+
420
+ # hidden data channels for the canvas
421
+ world_box = gr.Textbox(value=build_world_payload(DEFAULT_WORLD), elem_id="tw-world",
422
+ elem_classes="tw-data", label="", interactive=True)
423
+ reactions_box = gr.Textbox(value="", elem_id="tw-reactions", elem_classes="tw-data",
424
+ label="", interactive=True)
425
+ crisis_box = gr.Textbox(value=build_crisis_payload(DEFAULT_WORLD), elem_id="tw-crisis",
426
+ elem_classes="tw-data", label="", interactive=True)
427
+
428
+ with gr.Row(elem_id="tw-console"):
429
+ with gr.Column(scale=8):
430
+ event_input = gr.Textbox(
431
+ placeholder="Type an event to throw at the neighborhood… (e.g. a UFO lands in the park)",
432
+ label="", show_label=False, lines=1)
433
+ with gr.Column(scale=2, min_width=150):
434
+ trigger_btn = gr.Button("⚡ THROW", variant="primary", elem_id="throw-btn")
435
+
436
+ with gr.Row(elem_id="tw-actions"):
437
+ with gr.Column(scale=6, min_width=260):
438
+ scenario_dd = gr.Dropdown(choices=scenario_choices(DEFAULT_WORLD), value=None,
439
+ label="🎓 Teaching scenario — a real situation to explore")
440
+ with gr.Column(scale=2, min_width=140):
441
+ run_scenario_btn = gr.Button("🎓 Run Scenario")
442
+ with gr.Column(scale=2, min_width=140):
443
+ random_btn = gr.Button("🎲 Random Chaos")
444
+ with gr.Column(scale=1, min_width=120):
445
+ pause_btn = gr.Button("⏯ Time")
446
+ with gr.Column(scale=1, min_width=120):
447
+ step_btn = gr.Button("⏭ Step")
448
+
449
+ with gr.Row(elem_id="tw-mode"):
450
+ with gr.Column(scale=5, min_width=240):
451
+ mode_radio = gr.Radio(
452
+ ["🧪 Sandbox", "🎬 Campaign"], value="🧪 Sandbox",
453
+ label="Mode", show_label=True)
454
+ with gr.Column(scale=2, min_width=160):
455
+ start_campaign_btn = gr.Button("🎬 Start Campaign", variant="primary")
456
+ with gr.Column(scale=2, min_width=160):
457
+ next_round_btn = gr.Button("▶ Play Round")
458
+
459
+ crisis_hud_html = gr.HTML(render_crisis_hud(DEFAULT_WORLD))
460
+ story_card_html = gr.HTML(render_story_card())
461
+
462
+ with gr.Row(equal_height=False):
463
+ with gr.Column(scale=3, min_width=220):
464
+ gr.HTML('<div class="panel-title">🧑‍🤝‍🧑 Townsfolk</div>')
465
+ roster_html = gr.HTML(render_roster(DEFAULT_WORLD))
466
+ with gr.Column(scale=4, min_width=240):
467
+ gr.HTML('<div class="panel-title">📜 Town Log</div>')
468
+ town_log_html = gr.HTML(render_town_log(DEFAULT_WORLD))
469
+ with gr.Column(scale=4, min_width=240):
470
+ gr.HTML('<div class="panel-title">🎓 Learn — Why did they react?</div>')
471
+ explainer_html = gr.HTML(render_explainer(DEFAULT_WORLD))
472
+
473
+ gr.HTML('<div class="panel-title" style="margin-top:6px">🕒 Daily Log</div>')
474
+ daily_log_html = gr.HTML(render_daily_log(DEFAULT_WORLD))
475
+
476
+ gr.HTML('<div class="panel-title" style="margin-top:6px">🔊 Voice</div>')
477
+ with gr.Row(elem_id="tw-audio", equal_height=False):
478
+ with gr.Column(scale=4, min_width=240):
479
+ mic_input = gr.Audio(sources=["microphone"], type="filepath", label="🎙️ 1 · Record your event")
480
+ transcribe_btn = gr.Button("📝 2 · Transcribe to text")
481
+ mic_status = gr.HTML('<div class="audio-cap">Record → press <b>Transcribe</b>: your words fill the '
482
+ 'event box, then press <b>⚡ THROW</b>. <i>Mic only works on '
483
+ 'http://localhost:7860 (blocked on 0.0.0.0).</i></div>')
484
+ with gr.Column(scale=4, min_width=240):
485
+ voice_output = gr.Audio(label="🔊 Auto-voice (plays after each event)", autoplay=True)
486
+ gr.HTML('<div class="audio-cap">The <b>most dramatic</b> reaction is read aloud automatically.</div>')
487
+ with gr.Column(scale=4, min_width=240):
488
+ hear_name = gr.Dropdown(choices=[], label="🎧 Replay a character's voice")
489
+ hear_btn = gr.Button("▶ Play their last line")
490
+ gr.HTML('<div class="audio-cap">Pick any character and hear their <b>last line</b> again.</div>')
491
+
492
+ gr.HTML('<div class="tw-footer">Built for <strong>Build Small Hackathon 2026</strong> · '
493
+ 'Hugging Face × Gradio · Thousand Token Wood<br>'
494
+ 'Models: <strong>Nemotron-Mini-4B</strong> (NVIDIA) + <strong>VoxCPM2</strong> (OpenBMB) · '
495
+ '<strong>Whisper on Modal</strong> speech-to-text, Cohere fallback · <strong>Modal</strong><br>'
496
+ '<span class="footer-muted">Switch maps with the <b>World</b> picker, top-right · '
497
+ 'open at <b>http://localhost:7860</b> for microphone access.</span></div>')
498
+ tick_timer = gr.Timer(value=float(os.environ.get("TINYWORLD_TIME_SCALE", "6")), active=True)
499
+
500
+ # ---------------------------------------------------------- handlers
501
+ def _run_event(world_id, event_text, focus=None, route=None):
502
+ world = get_world(world_id)
503
+ world_state.init_cast(world)
504
+ result = agents.react(world_id, event_text, route=route)
505
+ reactions = result["reactions"]
506
+ runtime = result.get("runtime") or agents.get_runtime_status()
507
+ followup = agents.generate_followup(reactions, event_text)
508
+ top = max(reactions, key=lambda r: r["drama"])
509
+ audio = None
510
+ try:
511
+ cd = next(c for c in world["cast"] if c["name"] == top["name"])
512
+ audio = voice.generate_voice(top["text"], cd["voice_description"])
513
+ except Exception as e:
514
+ print(f"[app] voice failed: {e}")
515
+ names = [r["name"] for r in reactions]
516
+ return (render_town_log(world_id, reactions, followup), render_roster(world_id),
517
+ render_ticker(world_id), audio,
518
+ gr.Dropdown(choices=names, value=names[0] if names else None),
519
+ reactions, build_reactions_payload(world_id, reactions),
520
+ render_explainer(world_id, reactions, focus), render_mode_badge(runtime),
521
+ render_daily_log(world_id))
522
+
523
+ def _sandbox_tail(world_id):
524
+ # the three campaign outputs in their idle/passthrough state for sandbox events
525
+ return (render_crisis_hud(world_id), render_story_card(), build_crisis_payload(world_id))
526
+
527
+ def _empty_sandbox(world_id):
528
+ return (render_town_log(world_id), render_roster(world_id), render_ticker(world_id),
529
+ None, gr.Dropdown(choices=[], value=None), [], "", render_explainer(world_id),
530
+ render_mode_badge(), render_daily_log(world_id)) + _sandbox_tail(world_id)
531
+
532
+ def _sandbox_throw(event_text, world_id):
533
+ if not event_text or not event_text.strip():
534
+ return _empty_sandbox(world_id)
535
+ world = get_world(world_id)
536
+ route = router.classify(event_text.strip(), world)
537
+ if route["type"] == "noop":
538
+ return _empty_sandbox(world_id)
539
+ return _run_event(world_id, event_text.strip(), route=route) + _sandbox_tail(world_id)
540
+
541
+ def do_throw(event_text, world_id, mode):
542
+ v = campaign.view(get_world(world_id))
543
+ if str(mode).startswith("🎬") and v["active"] and v["status"] == "playing":
544
+ return _run_crisis_round(world_id, event_text)
545
+ return _sandbox_throw(event_text, world_id)
546
+
547
+ def start_campaign(world_id):
548
+ world = get_world(world_id)
549
+ world_state.reset_world(world_id)
550
+ world_state.init_cast(world)
551
+ v = campaign.start(world)
552
+ card = render_story_card("intro", f"🎬 {v['title']}", v.get("chapter_intro", ""))
553
+ return (render_town_log(world_id), render_roster(world_id), render_ticker(world_id),
554
+ None, gr.Dropdown(choices=[], value=None), [], build_state_payload(world_id),
555
+ render_explainer(world_id), render_mode_badge(), render_daily_log(world_id),
556
+ render_crisis_hud(world_id), card, build_crisis_payload(world_id))
557
+
558
+ def _run_crisis_round(world_id, player_text):
559
+ world = get_world(world_id)
560
+ v0 = campaign.view(world)
561
+ if not v0["active"] or not v0["crisis"] or v0["status"] != "playing":
562
+ return (render_town_log(world_id), render_roster(world_id), render_ticker(world_id),
563
+ None, gr.Dropdown(choices=[], value=None), [], build_state_payload(world_id),
564
+ render_explainer(world_id), render_mode_badge(), render_daily_log(world_id),
565
+ render_crisis_hud(world_id),
566
+ render_story_card("dispatch", "", "No active crisis — press Start Campaign."),
567
+ build_crisis_payload(world_id))
568
+
569
+ cr = campaign.current_crisis(world)
570
+ focus = cr.focus if cr else ""
571
+ event_text = campaign.round_event_text(world)
572
+ route = None
573
+ if player_text and player_text.strip():
574
+ r = router.classify(player_text.strip(), world)
575
+ if r["type"] != "noop":
576
+ route = r
577
+
578
+ result = agents.react(world_id, event_text, route=route)
579
+ reactions = result["reactions"]
580
+ runtime = result.get("runtime") or agents.get_runtime_status()
581
+ res = campaign.resolve_round(world, reactions)
582
+
583
+ audio = None
584
+ if reactions:
585
+ top = max(reactions, key=lambda r: r["drama"])
586
+ try:
587
+ cd = next(c for c in world["cast"] if c["name"] == top["name"])
588
+ audio = voice.generate_voice(top["text"], cd["voice_description"])
589
+ except Exception as e:
590
+ print(f"[app] voice failed: {e}")
591
+
592
+ names = [r["name"] for r in reactions]
593
+ dispatch = {"text": res.get("dispatch", "")} if res.get("dispatch") else None
594
+ return (render_town_log(world_id, reactions, dispatch), render_roster(world_id),
595
+ render_ticker(world_id), audio,
596
+ gr.Dropdown(choices=names, value=names[0] if names else None),
597
+ reactions, build_reactions_payload(world_id, reactions),
598
+ render_explainer(world_id, reactions, focus), render_mode_badge(runtime),
599
+ render_daily_log(world_id), render_crisis_hud(world_id),
600
+ _outcome_card(res), build_crisis_payload(world_id))
601
+
602
+ def run_scenario(scenario_id, world_id):
603
+ world = get_world(world_id)
604
+ scen = next((s for s in world.get("scenarios", []) if s["id"] == scenario_id), None)
605
+ if not scen:
606
+ return (gr.update(), gr.update(), gr.update(), gr.update(), gr.update(),
607
+ gr.update(), gr.update(), gr.update(), gr.update(), gr.update(), gr.update())
608
+ route = {"type": "world_event", "text": scen["event"], "instruction": scen["event"], "addressees": [], "goto": None}
609
+ return (scen["event"],) + _run_event(world_id, scen["event"], scen.get("focus"), route)
610
+
611
+ def random_chaos(world_id):
612
+ world = get_world(world_id)
613
+ if world and world.get("events"):
614
+ return random.choice(world["events"])
615
+ return events.random_event()
616
+
617
+ def switch_world(world_id):
618
+ world = get_world(world_id)
619
+ if world:
620
+ world_state.reset_world(world_id)
621
+ world_state.init_cast(world)
622
+ return (build_world_payload(world_id), "", render_town_log(world_id),
623
+ render_roster(world_id), render_ticker(world_id),
624
+ gr.Dropdown(choices=scenario_choices(world_id), value=None),
625
+ render_explainer(world_id), world_id, render_mode_badge(), render_daily_log(world_id),
626
+ render_crisis_hud(world_id), render_story_card(), build_crisis_payload(world_id))
627
+
628
+ def sim_tick(world_id):
629
+ world = get_world(world_id)
630
+ if world:
631
+ world_state.tick(world, hours=1.0)
632
+ return (render_ticker(world_id), render_roster(world_id),
633
+ build_state_payload(world_id), render_daily_log(world_id))
634
+
635
+ def toggle_time(world_id):
636
+ _, _, paused = world_state.get_game_time(world_id)
637
+ world_state.set_paused(world_id, not paused)
638
+ return sim_tick(world_id)
639
+
640
+ def step_time(world_id):
641
+ world = get_world(world_id)
642
+ if world:
643
+ world_state.tick(world, hours=1.0, force=True)
644
+ return (render_ticker(world_id), render_roster(world_id),
645
+ build_state_payload(world_id), render_daily_log(world_id))
646
+
647
+ def transcribe_audio(audio_path):
648
+ if not audio_path:
649
+ return ("", '<div class="audio-cap">⚠ No recording found — press the mic ● record button first. '
650
+ '<i>The mic only works on http://localhost:7860.</i></div>')
651
+ text = transcribe.transcribe(audio_path)
652
+ if not text:
653
+ return ("", '<div class="audio-cap">⚠ Could not transcribe. Try again, or just type the event.</div>')
654
+ return (text, f'<div class="audio-cap">✅ Heard: "<b>{esc(text)}</b>" — now press <b>⚡ THROW</b>.</div>')
655
+
656
+ def hear_reaction(name, world_id, reactions):
657
+ if not reactions or not name:
658
+ return None
659
+ rx = next((r for r in reactions if r["name"] == name), None)
660
+ if not rx:
661
+ return None
662
+ world = get_world(world_id)
663
+ try:
664
+ cd = next(c for c in world["cast"] if c["name"] == name)
665
+ return voice.generate_voice(rx["text"], cd["voice_description"])
666
+ except Exception as e:
667
+ print(f"[app] hear failed: {e}")
668
+ return None
669
+
670
+ def play_round(world_id):
671
+ return _run_crisis_round(world_id, "")
672
+
673
+ trig_out = [town_log_html, roster_html, ticker_html, voice_output,
674
+ hear_name, last_reactions_state, reactions_box, explainer_html, mode_badge_html,
675
+ daily_log_html]
676
+ full_out = trig_out + [crisis_hud_html, story_card_html, crisis_box]
677
+ trigger_btn.click(do_throw, [event_input, current_world_id, mode_radio], full_out)
678
+ event_input.submit(do_throw, [event_input, current_world_id, mode_radio], full_out)
679
+ start_campaign_btn.click(start_campaign, [current_world_id], full_out)
680
+ next_round_btn.click(play_round, [current_world_id], full_out)
681
+ run_scenario_btn.click(run_scenario, [scenario_dd, current_world_id], [event_input] + trig_out)
682
+ random_btn.click(random_chaos, [current_world_id], [event_input])
683
+ transcribe_btn.click(transcribe_audio, [mic_input], [event_input, mic_status])
684
+ hear_btn.click(hear_reaction, [hear_name, current_world_id, last_reactions_state], [voice_output])
685
+ world_picker.change(switch_world, [world_picker],
686
+ [world_box, reactions_box, town_log_html, roster_html, ticker_html,
687
+ scenario_dd, explainer_html, current_world_id, mode_badge_html, daily_log_html,
688
+ crisis_hud_html, story_card_html, crisis_box])
689
+ pause_btn.click(toggle_time, [current_world_id], [ticker_html, roster_html, reactions_box, daily_log_html])
690
+ step_btn.click(step_time, [current_world_id], [ticker_html, roster_html, reactions_box, daily_log_html])
691
+ tick_timer.tick(sim_tick, [current_world_id], [ticker_html, roster_html, reactions_box, daily_log_html])
692
+
693
+
694
+ if __name__ == "__main__":
695
+ port = int(os.environ.get("GRADIO_SERVER_PORT", "7860"))
696
+ print("\n TinyWorld is starting…")
697
+ print(f" ▶ Open http://localhost:{port} (use localhost, not 0.0.0.0, so the mic works)\n")
698
+ demo.launch(server_name="0.0.0.0", server_port=port, css=css_content, js=js_content or None)
assets/game.js ADDED
@@ -0,0 +1,646 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // TinyWorld — canvas isometric town: futuristic buildings, park, fountain,
2
+ // steering-physics characters that spread to in-character destinations.
3
+ // Data in via hidden textareas: #tw-world (board+cast, hot-swappable for map
4
+ // switching) and #tw-reactions (per-throw, polled).
5
+ (function () {
6
+ function $(s) { return document.querySelector(s); }
7
+ function txt(s) { var e = $(s + ' textarea') || $(s + ' input'); return e ? e.value : ''; }
8
+
9
+ function micBanner() {
10
+ if (document.getElementById('tw-mic-warn')) return;
11
+ if (location.hostname === '0.0.0.0' || (location.protocol !== 'https:' && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1')) {
12
+ var b = document.createElement('div'); b.id = 'tw-mic-warn'; b.className = 'tw-mic-warn';
13
+ var port = location.port ? ':' + location.port : '';
14
+ b.innerHTML = '⚠ For the microphone, open <b>http://localhost' + port + '</b> — voice input is blocked on this address (' + location.hostname + '). Typing works everywhere.';
15
+ document.body.appendChild(b);
16
+ }
17
+ }
18
+
19
+ function boot() {
20
+ var canvas = document.getElementById('tw-canvas');
21
+ if (!canvas) return setTimeout(boot, 200);
22
+ micBanner();
23
+ if (window.__TW) return;
24
+ window.__TW = engine(canvas);
25
+ window.__TW.start();
26
+ }
27
+
28
+ function engine(canvas) {
29
+ var ctx = canvas.getContext('2d');
30
+ var DPR = Math.min(window.devicePixelRatio || 1, 2);
31
+ var TS = 64, TH = 32;
32
+ var S = { W: null, raw: '', chars: [], tiles: {}, scale: 1, ox: 0, oy: 0, waves: [], lastTs: 0, crisis: null, crisisTs: 0 };
33
+
34
+ function shade(hex, f) {
35
+ var n = parseInt(hex.slice(1), 16), r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
36
+ r = Math.min(255, r * f); g = Math.min(255, g * f); b = Math.min(255, b * f);
37
+ return 'rgb(' + (r | 0) + ',' + (g | 0) + ',' + (b | 0) + ')';
38
+ }
39
+ function iso(gx, gy) { return { x: S.ox + (gx - gy) * (TS / 2) * S.scale, y: S.oy + (gx + gy) * (TH / 2) * S.scale }; }
40
+
41
+ function loadWorld(W) {
42
+ S.W = W; S.tiles = {};
43
+ (W.roads.cols || []).forEach(function (c) { for (var r = 0; r < W.rows; r++) S.tiles[c + ',' + r] = 'road'; });
44
+ (W.roads.rows || []).forEach(function (r) { for (var c = 0; c < W.cols; c++) S.tiles[c + ',' + r] = 'road'; });
45
+ (W.plaza || []).forEach(function (p) { S.tiles[p[0] + ',' + p[1]] = 'plaza'; });
46
+ S.chars = W.cast.map(function (c) {
47
+ return {
48
+ name: c.name, short: c.short, emoji: c.emoji, color: c.color,
49
+ x: c.home[0], y: c.home[1], vx: 0, vy: 0, tx: c.home[0], ty: c.home[1], home: c.home.slice(),
50
+ moodEmoji: '', bubble: null, bubbleAt: 0, speaking: false, activity: '', vehicle: '', running: false,
51
+ idle: 1 + Math.random() * 4, bob: Math.random() * 6, returnAt: 0
52
+ };
53
+ });
54
+ S.spots = (W.hotspots ? Object.keys(W.hotspots).map(function (k) { return W.hotspots[k]; }) : []);
55
+ // ---- physics: tiles nobody can walk into (buildings, water, tree trunks) ----
56
+ S.blocked = {};
57
+ function block(gx, gy) { S.blocked[Math.round(gx) + ',' + Math.round(gy)] = 1; }
58
+ W.buildings.forEach(function (b) { for (var bx = b.gx; bx < b.gx + b.w; bx++) for (var by = b.gy; by < b.gy + b.d; by++) block(bx, by); });
59
+ (W.props || []).forEach(function (p) {
60
+ if (p.type === 'pond') { for (var px = 0; px < (p.w || 2); px++) for (var py = 0; py < (p.d || 2); py++) block(p.gx + px, p.gy + py); }
61
+ else if (p.type === 'fountain') { block(p.gx, p.gy); block(p.gx - 0.5, p.gy - 0.5); block(p.gx + 0.5, p.gy + 0.5); }
62
+ });
63
+ (W.trees || []).forEach(function (t) { block(t[0], t[1]); });
64
+ // never trap a character: a home/spawn tile stays walkable even if it overlaps scenery
65
+ S.chars.forEach(function (c) { delete S.blocked[Math.round(c.home[0]) + ',' + Math.round(c.home[1])]; });
66
+ // seats people can amble over to and rest at, so the town isn't a permanent jog
67
+ S.seats = [];
68
+ (W.props || []).forEach(function (p) { if (p.type === 'bench' || p.type === 'patio' || p.type === 'table') S.seats.push([Math.round(p.gx), Math.round(p.gy) + 1]); });
69
+ // ambient crowd — pedestrians, a cyclist, dogs that bring the town to life
70
+ var SHIRTS = ['#c85a54', '#4a7bc8', '#caa23f', '#5aa86a', '#8a6fc0', '#c86fa0', '#3f9c9c', '#b5703f'];
71
+ var HAIR = ['#2a2018', '#4a3526', '#6b6b6b', '#161616', '#5a3a22', '#3a2a40', '#7a6a55'];
72
+ var n = (W.ambient != null) ? W.ambient : 6;
73
+ S.amb = [];
74
+ for (var i = 0; i < n; i++) {
75
+ var t = randWalkable();
76
+ var type = (i % 6 === 0) ? 'dog' : ((i % 6 === 1) ? 'bike' : 'ped');
77
+ S.amb.push({
78
+ type: type, x: t[0], y: t[1], vx: 0, vy: 0, tx: t[0], ty: t[1],
79
+ sp: (type === 'bike' ? 1.0 : 0.5) + Math.random() * 0.4,
80
+ idle: Math.random() * 3, ph: Math.random() * 6, face: 1,
81
+ shirt: SHIRTS[i % SHIRTS.length], hair: HAIR[i % HAIR.length],
82
+ sc: (type === 'dog' ? 0.85 : 1.1),
83
+ follow: type === 'dog' ? Math.floor(Math.random() * S.chars.length) : -1
84
+ });
85
+ }
86
+ // cars are off unless a world opts in (W.traffic), and they cruise slowly when on
87
+ var CARC = ['#d24b4b', '#3f6fb0', '#d6b13f', '#4a9c6a', '#8a8f9c'], ci = 0;
88
+ S.cars = [];
89
+ if (W.traffic) {
90
+ (W.roads.rows || []).forEach(function (r) { S.cars.push({ axis: 'h', line: r, pos: Math.random() * W.cols, dir: Math.random() < 0.5 ? 1 : -1, sp: 0.5 + Math.random() * 0.3, color: CARC[ci++ % CARC.length] }); });
91
+ (W.roads.cols || []).forEach(function (c) { S.cars.push({ axis: 'v', line: c, pos: Math.random() * W.rows, dir: Math.random() < 0.5 ? 1 : -1, sp: 0.5 + Math.random() * 0.3, color: CARC[ci++ % CARC.length] }); });
92
+ }
93
+ S.waves = [];
94
+ fit();
95
+ }
96
+
97
+ function randWalkable() {
98
+ for (var k = 0; k < 30; k++) { var gx = Math.floor(Math.random() * S.W.cols), gy = Math.floor(Math.random() * S.W.rows); if (walkable(gx, gy)) return [gx, gy]; }
99
+ return [S.W.cols >> 1, S.W.rows >> 1];
100
+ }
101
+
102
+ function fit() {
103
+ if (!S.W) return;
104
+ var rect = canvas.getBoundingClientRect();
105
+ canvas.width = Math.max(1, rect.width * DPR); canvas.height = Math.max(1, rect.height * DPR);
106
+ var minX = 1e9, maxX = -1e9, minY = 1e9, maxY = -1e9;
107
+ for (var gy = 0; gy <= S.W.rows; gy++) for (var gx = 0; gx <= S.W.cols; gx++) {
108
+ var x = (gx - gy) * TS / 2, y = (gx + gy) * TH / 2;
109
+ if (x < minX) minX = x; if (x > maxX) maxX = x; if (y < minY) minY = y; if (y > maxY) maxY = y;
110
+ }
111
+ var bw = maxX - minX, bh = (maxY - minY) + 130;
112
+ S.scale = Math.min(rect.width / bw, rect.height / bh) * 1.06;
113
+ S.ox = rect.width / 2 - ((minX + maxX) / 2) * S.scale;
114
+ S.oy = rect.height / 2 - ((minY + maxY) / 2) * S.scale + 18 * S.scale;
115
+ }
116
+ window.addEventListener('resize', fit);
117
+
118
+ function walkable(gx, gy) {
119
+ gx = Math.round(gx); gy = Math.round(gy);
120
+ if (gx < 0 || gy < 0 || gx >= S.W.cols || gy >= S.W.rows) return false;
121
+ return !(S.blocked && S.blocked[gx + ',' + gy]);
122
+ }
123
+
124
+ // ---------- tiles ----------
125
+ function drawTile(gx, gy) {
126
+ var t = S.tiles[gx + ',' + gy] || 'grass';
127
+ var a = iso(gx, gy), b = iso(gx + 1, gy), c = iso(gx + 1, gy + 1), d = iso(gx, gy + 1);
128
+ ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(c.x, c.y); ctx.lineTo(d.x, d.y); ctx.closePath();
129
+ var fill = ((gx + gy) % 2 === 0) ? '#1e6b3e' : '#1a6038';
130
+ if (t === 'road') fill = ((gx + gy) % 2 === 0) ? '#33384c' : '#2e3344';
131
+ else if (t === 'plaza') fill = '#46415f';
132
+ ctx.fillStyle = fill; ctx.fill();
133
+ ctx.strokeStyle = 'rgba(120,200,255,0.07)'; ctx.lineWidth = 1; ctx.stroke();
134
+ if (t === 'road') {
135
+ ctx.strokeStyle = 'rgba(255,215,106,0.45)'; ctx.lineWidth = Math.max(1, 1.6 * S.scale);
136
+ ctx.setLineDash([5 * S.scale, 5 * S.scale]); ctx.beginPath();
137
+ ctx.moveTo((a.x + d.x) / 2, (a.y + d.y) / 2); ctx.lineTo((b.x + c.x) / 2, (b.y + c.y) / 2);
138
+ ctx.stroke(); ctx.setLineDash([]);
139
+ }
140
+ }
141
+
142
+ // ---------- buildings ----------
143
+ function corners(b, H) {
144
+ var bt = iso(b.gx, b.gy), br = iso(b.gx + b.w, b.gy), bb = iso(b.gx + b.w, b.gy + b.d), bl = iso(b.gx, b.gy + b.d);
145
+ return { bt: bt, br: br, bb: bb, bl: bl, tt: u(bt, H), tr: u(br, H), tb: u(bb, H), tl: u(bl, H) };
146
+ function u(p, h) { return { x: p.x, y: p.y - h }; }
147
+ }
148
+ function quad(p0, p1, p2, p3, fill) { ctx.fillStyle = fill; ctx.beginPath(); ctx.moveTo(p0.x, p0.y); ctx.lineTo(p1.x, p1.y); ctx.lineTo(p2.x, p2.y); ctx.lineTo(p3.x, p3.y); ctx.closePath(); ctx.fill(); }
149
+ // p0=base-near, p1=base-far, p2=top-far, p3=top-near
150
+ function onQuad(p0, p1, p2, p3, u, v) {
151
+ var ax = p0.x + (p1.x - p0.x) * u, ay = p0.y + (p1.y - p0.y) * u;
152
+ var bx = p3.x + (p2.x - p3.x) * u, by = p3.y + (p2.y - p3.y) * u;
153
+ return { x: ax + (bx - ax) * v, y: ay + (by - ay) * v };
154
+ }
155
+ function windowGrid(p0, p1, p2, p3, cols, rows, f) {
156
+ var ws = 3.4 * S.scale;
157
+ for (var i = 1; i <= cols; i++) for (var j = 1; j <= rows; j++) {
158
+ var q = onQuad(p0, p1, p2, p3, i / (cols + 1), j / (rows + 1));
159
+ var lit = ((i * 7 + j * 3) % 4 !== 0);
160
+ ctx.fillStyle = 'rgba(10,14,26,0.85)'; ctx.fillRect(q.x - ws, q.y - ws, ws * 2, ws * 2);
161
+ ctx.fillStyle = lit ? 'rgba(255,232,150,' + (0.78 * f) + ')' : 'rgba(120,160,210,' + (0.4 * f) + ')';
162
+ ctx.fillRect(q.x - ws * 0.7, q.y - ws * 0.7, ws * 1.4, ws * 1.4);
163
+ }
164
+ }
165
+ function door(p0, p1, p2, p3, col) {
166
+ var b = onQuad(p0, p1, p2, p3, 0.5, 0.02), t = onQuad(p0, p1, p2, p3, 0.5, 0.22);
167
+ var w = 4 * S.scale;
168
+ ctx.fillStyle = shade(col, 0.5);
169
+ ctx.beginPath(); ctx.moveTo(b.x - w, b.y); ctx.lineTo(b.x + w, b.y); ctx.lineTo(t.x + w, t.y); ctx.lineTo(t.x - w, t.y); ctx.closePath(); ctx.fill();
170
+ }
171
+
172
+ function drawBuilding(b, now) {
173
+ var H = b.h * S.scale, kind = b.kind || 'block';
174
+ var c = corners(b, H);
175
+ // ground shadow
176
+ quad(c.bt, c.br, { x: c.bb.x + 7, y: c.bb.y + 7 }, c.bl, 'rgba(0,0,0,0.30)');
177
+ // walls
178
+ quad(c.bl, c.bb, c.tb, c.tl, shade(b.wall, 0.7)); // left
179
+ quad(c.br, c.bb, c.tb, c.tr, shade(b.wall, 1.0)); // right
180
+ var floors = Math.max(2, Math.round(b.h / 16));
181
+ windowGrid(c.bl, c.bb, c.tb, c.tl, Math.max(2, b.d), floors, 0.7);
182
+ windowGrid(c.br, c.bb, c.tb, c.tr, Math.max(2, b.w), floors, 1.0);
183
+ // subtle edge highlight
184
+ ctx.strokeStyle = shade(b.roof, 1.0); ctx.lineWidth = 1.2 * S.scale;
185
+ ctx.beginPath(); ctx.moveTo(c.bb.x, c.bb.y); ctx.lineTo(c.tb.x, c.tb.y); ctx.stroke();
186
+ // roof / crown
187
+ ctx.save(); ctx.shadowColor = b.roof; ctx.shadowBlur = 7 * S.scale;
188
+ if (kind === 'dome') {
189
+ var cx = (c.tt.x + c.tb.x) / 2, cy = (c.tt.y + c.tb.y) / 2;
190
+ var rw = Math.abs(c.tr.x - c.tl.x) / 2, rh = Math.abs(c.tb.y - c.tt.y) / 2 + 14 * S.scale;
191
+ quad(c.tt, c.tr, c.tb, c.tl, shade(b.roof, 0.85));
192
+ var g = ctx.createLinearGradient(cx, cy - rh, cx, cy); g.addColorStop(0, shade(b.roof, 1.3)); g.addColorStop(1, b.roof);
193
+ ctx.fillStyle = g; ctx.beginPath(); ctx.ellipse(cx, cy, rw, rh, 0, Math.PI, 2 * Math.PI); ctx.fill();
194
+ } else if (kind === 'tower') {
195
+ quad(c.tt, c.tr, c.tb, c.tl, b.roof);
196
+ // antenna + pulsing beacon
197
+ var apex = { x: (c.tt.x + c.tb.x) / 2, y: (c.tt.y + c.tb.y) / 2 };
198
+ ctx.strokeStyle = shade(b.roof, 1.2); ctx.lineWidth = 1.6 * S.scale;
199
+ ctx.beginPath(); ctx.moveTo(apex.x, apex.y); ctx.lineTo(apex.x, apex.y - 22 * S.scale); ctx.stroke();
200
+ var pulse = 0.5 + 0.5 * Math.sin(now / 350);
201
+ ctx.fillStyle = 'rgba(56,232,255,' + pulse + ')';
202
+ ctx.beginPath(); ctx.arc(apex.x, apex.y - 22 * S.scale, 3.2 * S.scale, 0, 7); ctx.fill();
203
+ } else if (kind === 'house') { // residential — pitched gable roof, no neon
204
+ ctx.shadowBlur = 0;
205
+ var rh = 13 * S.scale;
206
+ function upR(p) { return { x: p.x, y: p.y - rh }; }
207
+ var rA = upR({ x: (c.tt.x + c.tl.x) / 2, y: (c.tt.y + c.tl.y) / 2 });
208
+ var rB = upR({ x: (c.tr.x + c.tb.x) / 2, y: (c.tr.y + c.tb.y) / 2 });
209
+ quad(c.tt, c.tr, rB, rA, shade(b.roof, 1.05)); // sunlit slope
210
+ quad(c.tl, c.tb, rB, rA, shade(b.roof, 0.74)); // shaded slope
211
+ tri(c.tt, c.tl, rA, shade(b.wall, 1.08)); // gable end
212
+ tri(c.tr, c.tb, rB, shade(b.wall, 0.92)); // gable end
213
+ ctx.strokeStyle = shade(b.roof, 1.2); ctx.lineWidth = 1.4 * S.scale;
214
+ ctx.beginPath(); ctx.moveTo(rA.x, rA.y); ctx.lineTo(rB.x, rB.y); ctx.stroke(); // ridge
215
+ } else { // block — setback crown
216
+ quad(c.tt, c.tr, c.tb, c.tl, b.roof);
217
+ var inH = 16 * S.scale;
218
+ var sb = { gx: b.gx + b.w * 0.25, gy: b.gy + b.d * 0.25, w: b.w * 0.5, d: b.d * 0.5 };
219
+ var cc = corners(sb, H + inH);
220
+ // shift base of crown up to roof level
221
+ var lift = H;
222
+ function L(p) { return { x: p.x, y: p.y }; }
223
+ var cb = corners(sb, 0);
224
+ var ct = corners(sb, inH);
225
+ function up(p) { return { x: p.x, y: p.y - lift }; }
226
+ quad(up(cb.bl), up(cb.bb), up(ct.tb), up(ct.tl), shade(b.wall, 0.8));
227
+ quad(up(cb.br), up(cb.bb), up(ct.tb), up(ct.tr), shade(b.wall, 1.05));
228
+ quad(up(ct.tt), up(ct.tr), up(ct.tb), up(ct.tl), shade(b.roof, 1.1));
229
+ }
230
+ ctx.restore();
231
+ // ground-floor storefront only for shops/civic buildings — houses just get a door
232
+ if (kind === 'house') { door(c.br, c.bb, c.tb, c.tr, b.wall); houseSign(b, c); }
233
+ else drawStorefront(b, c);
234
+ }
235
+ function tri(a, b, d, fill) { ctx.fillStyle = fill; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(d.x, d.y); ctx.closePath(); ctx.fill(); }
236
+ // small house number / family plate above the door (not a glowing holo sign)
237
+ function houseSign(b, c) {
238
+ if (!b.label) return;
239
+ var p = onQuad(c.br, c.bb, c.tb, c.tr, 0.5, 0.34);
240
+ ctx.font = '600 ' + (7 * S.scale) + 'px Rajdhani, sans-serif';
241
+ var tw = ctx.measureText(b.label).width + 8 * S.scale;
242
+ ctx.fillStyle = 'rgba(18,14,10,0.8)'; rr(p.x - tw / 2, p.y - 6 * S.scale, tw, 11 * S.scale, 2 * S.scale); ctx.fill();
243
+ ctx.fillStyle = '#e8dcc0'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(b.label, p.x, p.y);
244
+ }
245
+
246
+ // street-level retail on the most-visible (right) wall: p0=br p1=bb p2=tb p3=tr
247
+ function drawStorefront(b, c) {
248
+ var p0 = c.br, p1 = c.bb, p2 = c.tb, p3 = c.tr, accent = b.roof;
249
+ // big glowing shop windows along the ground floor
250
+ var cols = Math.max(2, Math.round(b.w * 1.6));
251
+ for (var i = 1; i <= cols; i++) {
252
+ var wt = onQuad(p0, p1, p2, p3, i / (cols + 1), 0.175), wb = onQuad(p0, p1, p2, p3, i / (cols + 1), 0.03);
253
+ var ww = 5 * S.scale, wh = wb.y - wt.y;
254
+ ctx.fillStyle = 'rgba(6,10,20,0.92)'; ctx.fillRect(wt.x - ww, wt.y, ww * 2, wh);
255
+ ctx.fillStyle = 'rgba(255,222,158,0.92)'; ctx.fillRect(wt.x - ww * 0.78, wt.y + 1.2 * S.scale, ww * 1.56, wh - 2.4 * S.scale);
256
+ ctx.fillStyle = 'rgba(255,255,255,0.18)'; ctx.fillRect(wt.x - ww * 0.78, wt.y + 1.2 * S.scale, ww * 0.5, wh - 2.4 * S.scale);
257
+ }
258
+ door(p0, p1, p2, p3, b.wall);
259
+ // awning: a coloured strip that juts toward the street with a scalloped valance
260
+ var al = onQuad(p0, p1, p2, p3, 0.05, 0.205), ar = onQuad(p0, p1, p2, p3, 0.95, 0.205), jut = 9 * S.scale, seg = 8;
261
+ function lerp(t) { return { x: al.x + (ar.x - al.x) * t, y: al.y + (ar.y - al.y) * t }; }
262
+ for (var k = 0; k < seg; k++) {
263
+ var a0 = lerp(k / seg), a1 = lerp((k + 1) / seg);
264
+ ctx.fillStyle = (k % 2) ? shade(accent, 1.25) : shade(accent, 0.92);
265
+ ctx.beginPath(); ctx.moveTo(a0.x, a0.y); ctx.lineTo(a1.x, a1.y); ctx.lineTo(a1.x, a1.y + jut); ctx.lineTo(a0.x, a0.y + jut); ctx.closePath(); ctx.fill();
266
+ ctx.fillStyle = shade(accent, 0.78);
267
+ ctx.beginPath(); ctx.moveTo(a0.x, a0.y + jut); ctx.lineTo(a1.x, a1.y + jut); ctx.lineTo((a0.x + a1.x) / 2, (a0.y + a1.y) / 2 + jut + 4 * S.scale); ctx.closePath(); ctx.fill();
268
+ }
269
+ // shop sign mounted on the façade above the awning (replaces the old floating word)
270
+ if (b.label) {
271
+ var sc = onQuad(p0, p1, p2, p3, 0.5, 0.40);
272
+ ctx.font = '700 ' + (8.5 * S.scale) + 'px Orbitron, sans-serif';
273
+ var tw = ctx.measureText(b.label).width + 12 * S.scale, th = 14 * S.scale;
274
+ ctx.fillStyle = 'rgba(6,10,20,0.94)'; rr(sc.x - tw / 2, sc.y - th / 2, tw, th, 3 * S.scale); ctx.fill();
275
+ ctx.strokeStyle = shade(accent, 1.2); ctx.lineWidth = 1.3 * S.scale; ctx.stroke();
276
+ ctx.save(); ctx.shadowColor = accent; ctx.shadowBlur = 6 * S.scale;
277
+ ctx.fillStyle = shade(accent, 1.3); ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(b.label, sc.x, sc.y);
278
+ ctx.restore();
279
+ }
280
+ }
281
+
282
+ // ---------- props ----------
283
+ function drawProp(p, now) {
284
+ if (p.type === 'fountain') {
285
+ var c = iso(p.gx, p.gy);
286
+ ctx.fillStyle = 'rgba(40,60,90,0.9)'; ctx.beginPath(); ctx.ellipse(c.x, c.y, 22 * S.scale, 11 * S.scale, 0, 0, 7); ctx.fill();
287
+ ctx.fillStyle = '#2bc7ff'; ctx.beginPath(); ctx.ellipse(c.x, c.y, 18 * S.scale, 8 * S.scale, 0, 0, 7); ctx.fill();
288
+ for (var k = 0; k < 3; k++) {
289
+ var t = (now / 700 + k / 3) % 1, rr2 = 4 + t * 14;
290
+ ctx.strokeStyle = 'rgba(160,235,255,' + (0.6 * (1 - t)) + ')'; ctx.lineWidth = 1.4 * S.scale;
291
+ ctx.beginPath(); ctx.ellipse(c.x, c.y, rr2 * S.scale, rr2 * 0.5 * S.scale, 0, 0, 7); ctx.stroke();
292
+ }
293
+ ctx.strokeStyle = 'rgba(180,240,255,0.8)'; ctx.lineWidth = 2 * S.scale;
294
+ ctx.beginPath(); ctx.moveTo(c.x, c.y - 4 * S.scale); ctx.lineTo(c.x, c.y - 16 * S.scale); ctx.stroke();
295
+ } else if (p.type === 'pond') {
296
+ var a = iso(p.gx, p.gy), b = iso(p.gx + (p.w || 2), p.gy), d = iso(p.gx + (p.w || 2), p.gy + (p.d || 2)), e = iso(p.gx, p.gy + (p.d || 2));
297
+ var g = ctx.createLinearGradient(a.x, a.y, d.x, d.y); g.addColorStop(0, '#1f6fa8'); g.addColorStop(1, '#0e4a78');
298
+ ctx.fillStyle = g; ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.lineTo(d.x, d.y); ctx.lineTo(e.x, e.y); ctx.closePath(); ctx.fill();
299
+ ctx.strokeStyle = 'rgba(160,220,255,0.4)'; ctx.lineWidth = 1; ctx.stroke();
300
+ } else if (p.type === 'bench') {
301
+ var c2 = iso(p.gx + 0.5, p.gy + 0.5);
302
+ ctx.fillStyle = '#6a4a2e'; ctx.fillRect(c2.x - 8 * S.scale, c2.y - 4 * S.scale, 16 * S.scale, 4 * S.scale);
303
+ } else if (p.type === 'lamp') {
304
+ var c3 = iso(p.gx + 0.5, p.gy + 0.5);
305
+ ctx.strokeStyle = '#556'; ctx.lineWidth = 2 * S.scale; ctx.beginPath(); ctx.moveTo(c3.x, c3.y); ctx.lineTo(c3.x, c3.y - 22 * S.scale); ctx.stroke();
306
+ var rg = ctx.createRadialGradient(c3.x, c3.y - 22 * S.scale, 1, c3.x, c3.y - 22 * S.scale, 12 * S.scale);
307
+ rg.addColorStop(0, 'rgba(255,235,150,0.9)'); rg.addColorStop(1, 'rgba(255,235,150,0)');
308
+ ctx.fillStyle = rg; ctx.beginPath(); ctx.arc(c3.x, c3.y - 22 * S.scale, 12 * S.scale, 0, 7); ctx.fill();
309
+ } else if (p.type === 'patio') {
310
+ var c4 = iso(p.gx + 0.5, p.gy + 0.5);
311
+ ctx.fillStyle = '#d65b5b'; ctx.beginPath(); ctx.moveTo(c4.x, c4.y - 16 * S.scale); ctx.lineTo(c4.x - 11 * S.scale, c4.y - 7 * S.scale); ctx.lineTo(c4.x + 11 * S.scale, c4.y - 7 * S.scale); ctx.closePath(); ctx.fill();
312
+ ctx.strokeStyle = '#888'; ctx.lineWidth = 1.5 * S.scale; ctx.beginPath(); ctx.moveTo(c4.x, c4.y - 7 * S.scale); ctx.lineTo(c4.x, c4.y); ctx.stroke();
313
+ } else if (p.type === 'table') { // café table with chairs + parasol
314
+ var ct = iso(p.gx + 0.5, p.gy + 0.5), s = S.scale;
315
+ [[-9, 1], [9, 1], [0, -5], [0, 6]].forEach(function (o) {
316
+ ctx.fillStyle = '#39425a'; ctx.beginPath(); ctx.ellipse(ct.x + o[0] * s, ct.y + o[1] * s * 0.6, 2.8 * s, 1.6 * s, 0, 0, 7); ctx.fill();
317
+ ctx.fillStyle = '#2a3146'; ctx.fillRect(ct.x + o[0] * s - 2.4 * s, ct.y + o[1] * s * 0.6 - 5 * s, 4.8 * s, 5 * s);
318
+ });
319
+ ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.ellipse(ct.x, ct.y + 2 * s, 8 * s, 3.5 * s, 0, 0, 7); ctx.fill();
320
+ ctx.strokeStyle = '#7a8398'; ctx.lineWidth = 1.6 * s; ctx.beginPath(); ctx.moveTo(ct.x, ct.y - 1 * s); ctx.lineTo(ct.x, ct.y + 3 * s); ctx.stroke();
321
+ ctx.fillStyle = '#cdd6ea'; ctx.beginPath(); ctx.ellipse(ct.x, ct.y - 3 * s, 7 * s, 3.2 * s, 0, 0, 7); ctx.fill();
322
+ ctx.strokeStyle = '#8a93a8'; ctx.lineWidth = 1.4 * s; ctx.beginPath(); ctx.moveTo(ct.x, ct.y - 4 * s); ctx.lineTo(ct.x, ct.y - 17 * s); ctx.stroke();
323
+ ctx.fillStyle = '#d65b5b'; ctx.beginPath(); ctx.moveTo(ct.x, ct.y - 22 * s); ctx.lineTo(ct.x - 11 * s, ct.y - 15 * s); ctx.lineTo(ct.x + 11 * s, ct.y - 15 * s); ctx.closePath(); ctx.fill();
324
+ ctx.fillStyle = '#b94a4a'; for (var pz = 0; pz < 3; pz += 2) { ctx.beginPath(); ctx.moveTo(ct.x - 11 * s + pz * 7.3 * s, ct.y - 15 * s); ctx.lineTo(ct.x - 3.7 * s + pz * 7.3 * s, ct.y - 15 * s); ctx.lineTo(ct.x - 7.3 * s + pz * 7.3 * s, ct.y - 18.5 * s); ctx.closePath(); ctx.fill(); }
325
+ } else if (p.type === 'stall') { // market stall: counter, goods, striped canopy
326
+ var cs = iso(p.gx + 0.5, p.gy + 0.5), s2 = S.scale, col = p.color || '#caa23f';
327
+ ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.ellipse(cs.x, cs.y + 2 * s2, 15 * s2, 4 * s2, 0, 0, 7); ctx.fill();
328
+ ctx.fillStyle = '#5a4632'; ctx.fillRect(cs.x - 13 * s2, cs.y - 6 * s2, 26 * s2, 8 * s2);
329
+ ctx.fillStyle = '#48372a'; ctx.fillRect(cs.x - 13 * s2, cs.y - 6 * s2, 26 * s2, 2 * s2);
330
+ ['#e5663e', '#e0b53f', '#5aa86a', '#d6543f'].forEach(function (gc, gi) { ctx.fillStyle = gc; ctx.beginPath(); ctx.arc(cs.x - 9 * s2 + gi * 6 * s2, cs.y - 7 * s2, 2.6 * s2, 0, 7); ctx.fill(); });
331
+ ctx.strokeStyle = '#6a5a48'; ctx.lineWidth = 1.8 * s2; ctx.beginPath();
332
+ ctx.moveTo(cs.x - 13 * s2, cs.y - 6 * s2); ctx.lineTo(cs.x - 13 * s2, cs.y - 22 * s2);
333
+ ctx.moveTo(cs.x + 13 * s2, cs.y - 6 * s2); ctx.lineTo(cs.x + 13 * s2, cs.y - 22 * s2); ctx.stroke();
334
+ for (var q = 0; q < 7; q++) { ctx.fillStyle = (q % 2) ? col : shade(col, 1.28); ctx.fillRect(cs.x - 13 * s2 + q * (26 / 7) * s2, cs.y - 25 * s2, (26 / 7) * s2 + 0.5, 5 * s2); }
335
+ ctx.fillStyle = shade(col, 0.85); for (var v = 0; v < 7; v++) { var vx = cs.x - 13 * s2 + (v + 0.5) * (26 / 7) * s2; ctx.beginPath(); ctx.moveTo(vx - (13 / 7) * s2, cs.y - 20 * s2); ctx.lineTo(vx + (13 / 7) * s2, cs.y - 20 * s2); ctx.lineTo(vx, cs.y - 17 * s2); ctx.closePath(); ctx.fill(); }
336
+ } else if (p.type === 'planter') { // raised flower bed
337
+ var cp = iso(p.gx + 0.5, p.gy + 0.5), s3 = S.scale;
338
+ ctx.fillStyle = '#5a4632'; ctx.fillRect(cp.x - 8 * s3, cp.y - 3 * s3, 16 * s3, 5 * s3);
339
+ ctx.fillStyle = '#48372a'; ctx.fillRect(cp.x - 8 * s3, cp.y - 3 * s3, 16 * s3, 1.6 * s3);
340
+ var fc = ['#f06ea0', '#ffd76a', '#7CFFB2', '#d65b5b'];
341
+ for (var f = 0; f < 5; f++) { ctx.fillStyle = '#2f8f55'; ctx.fillRect(cp.x - 6 * s3 + f * 3 * s3, cp.y - 7 * s3, 1.4 * s3, 4 * s3); ctx.fillStyle = fc[f % fc.length]; ctx.beginPath(); ctx.arc(cp.x - 5.3 * s3 + f * 3 * s3, cp.y - 8 * s3, 1.8 * s3, 0, 7); ctx.fill(); }
342
+ }
343
+ }
344
+
345
+ function drawTree(t) {
346
+ var p = iso(t[0] + 0.5, t[1] + 0.5);
347
+ ctx.fillStyle = 'rgba(0,0,0,0.22)'; ctx.beginPath(); ctx.ellipse(p.x, p.y, 10 * S.scale, 5 * S.scale, 0, 0, 7); ctx.fill();
348
+ ctx.fillStyle = '#4a2e1a'; ctx.fillRect(p.x - 2 * S.scale, p.y - 13 * S.scale, 4 * S.scale, 13 * S.scale);
349
+ var g = ctx.createRadialGradient(p.x - 4 * S.scale, p.y - 22 * S.scale, 2, p.x, p.y - 18 * S.scale, 15 * S.scale);
350
+ g.addColorStop(0, '#5fe0a0'); g.addColorStop(1, '#1c7a4a'); ctx.fillStyle = g;
351
+ ctx.beginPath(); ctx.arc(p.x, p.y - 18 * S.scale, 12 * S.scale, 0, 7); ctx.fill();
352
+ }
353
+
354
+ // ---------- sprite primitives ----------
355
+ // a little walking person; returns head-top y for bubble/mood anchoring
356
+ function person(x, y, sc, shirt, hair, ph, moving) {
357
+ var s = S.scale * sc;
358
+ ctx.fillStyle = 'rgba(0,0,0,0.28)'; ctx.beginPath(); ctx.ellipse(x, y, 7 * s, 2.8 * s, 0, 0, 7); ctx.fill();
359
+ var sw = moving ? Math.sin(ph) * 3 * s : 0.6 * s;
360
+ var lift = moving ? Math.abs(Math.cos(ph)) * 1.1 * s : 0;
361
+ ctx.lineCap = 'round';
362
+ // legs
363
+ ctx.strokeStyle = '#34384e'; ctx.lineWidth = 2.6 * s;
364
+ ctx.beginPath(); ctx.moveTo(x, y - 8 * s); ctx.lineTo(x - 2.4 * s + sw, y); ctx.stroke();
365
+ ctx.beginPath(); ctx.moveTo(x, y - 8 * s); ctx.lineTo(x + 2.4 * s - sw, y); ctx.stroke();
366
+ var ty = y - 8 * s - lift;
367
+ // arms (behind torso)
368
+ ctx.strokeStyle = shade(shirt, 0.82); ctx.lineWidth = 2.3 * s;
369
+ ctx.beginPath(); ctx.moveTo(x - 3.5 * s, ty - 8 * s); ctx.lineTo(x - 6 * s - sw * 0.5, ty - 1.5 * s); ctx.stroke();
370
+ ctx.beginPath(); ctx.moveTo(x + 3.5 * s, ty - 8 * s); ctx.lineTo(x + 6 * s + sw * 0.5, ty - 1.5 * s); ctx.stroke();
371
+ // torso
372
+ ctx.fillStyle = shirt; rr(x - 4.6 * s, ty - 12 * s, 9.2 * s, 13 * s, 3 * s); ctx.fill();
373
+ // head
374
+ var hcy = ty - 16 * s;
375
+ ctx.fillStyle = '#e7b893'; ctx.beginPath(); ctx.arc(x, hcy, 4.6 * s, 0, 7); ctx.fill();
376
+ ctx.fillStyle = hair; ctx.beginPath(); ctx.arc(x, hcy - 1.4 * s, 4.7 * s, Math.PI, 2 * Math.PI); ctx.fill();
377
+ return hcy - 5 * s;
378
+ }
379
+ function dog(x, y, sc, ph, moving) {
380
+ var s = S.scale * sc, wag = moving ? Math.sin(ph * 1.4) * 2 * s : 0;
381
+ ctx.fillStyle = 'rgba(0,0,0,0.26)'; ctx.beginPath(); ctx.ellipse(x, y, 8 * s, 3 * s, 0, 0, 7); ctx.fill();
382
+ ctx.fillStyle = '#8a6a45'; rr(x - 7 * s, y - 8 * s, 12 * s, 6 * s, 3 * s); ctx.fill(); // body
383
+ ctx.strokeStyle = '#6a4f33'; ctx.lineWidth = 1.8 * s; // legs
384
+ ctx.beginPath(); ctx.moveTo(x - 5 * s, y - 3 * s); ctx.lineTo(x - 5 * s, y); ctx.moveTo(x + 3 * s, y - 3 * s); ctx.lineTo(x + 3 * s, y); ctx.stroke();
385
+ ctx.fillStyle = '#8a6a45'; ctx.beginPath(); ctx.arc(x + 6 * s, y - 9 * s, 3.4 * s, 0, 7); ctx.fill(); // head
386
+ ctx.strokeStyle = '#8a6a45'; ctx.lineWidth = 2 * s; ctx.beginPath(); ctx.moveTo(x - 7 * s, y - 7 * s); ctx.lineTo(x - 10 * s - wag, y - 9 * s); ctx.stroke(); // tail
387
+ }
388
+ function wheel(x, y, r, ph) {
389
+ ctx.strokeStyle = '#1a1d28'; ctx.lineWidth = 2.2 * S.scale; ctx.beginPath(); ctx.arc(x, y, r, 0, 7); ctx.stroke();
390
+ ctx.strokeStyle = 'rgba(200,210,230,0.5)'; ctx.lineWidth = 1 * S.scale;
391
+ for (var a = 0; a < 3; a++) { var an = ph + a * 2.1; ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + Math.cos(an) * r, y + Math.sin(an) * r); ctx.stroke(); }
392
+ }
393
+ function bike(x, y, ph, moving, shirt) {
394
+ var s = S.scale;
395
+ ctx.fillStyle = 'rgba(0,0,0,0.28)'; ctx.beginPath(); ctx.ellipse(x, y, 12 * s, 3 * s, 0, 0, 7); ctx.fill();
396
+ var spin = moving ? ph * 2 : 0;
397
+ wheel(x - 7 * s, y - 4 * s, 4.6 * s, spin); wheel(x + 7 * s, y - 4 * s, 4.6 * s, spin);
398
+ ctx.strokeStyle = '#cfd6ea'; ctx.lineWidth = 1.8 * s; // frame
399
+ ctx.beginPath(); ctx.moveTo(x - 7 * s, y - 4 * s); ctx.lineTo(x, y - 4 * s); ctx.lineTo(x + 7 * s, y - 4 * s); ctx.moveTo(x, y - 4 * s); ctx.lineTo(x + 2 * s, y - 12 * s); ctx.stroke();
400
+ // rider
401
+ var rsw = moving ? Math.sin(ph) * 2 * s : 0;
402
+ ctx.fillStyle = shirt; rr(x - 2.5 * s, y - 19 * s, 7 * s, 9 * s, 2.5 * s); ctx.fill();
403
+ ctx.fillStyle = '#e7b893'; ctx.beginPath(); ctx.arc(x + 1 * s, y - 21 * s, 3.6 * s, 0, 7); ctx.fill();
404
+ ctx.strokeStyle = shade(shirt, 0.8); ctx.lineWidth = 1.8 * s;
405
+ ctx.beginPath(); ctx.moveTo(x + 1 * s, y - 15 * s); ctx.lineTo(x + 7 * s, y - 8 * s); ctx.stroke(); // arm to bars
406
+ ctx.beginPath(); ctx.moveTo(x + 1 * s, y - 11 * s); ctx.lineTo(x - 1 * s + rsw, y - 4 * s); ctx.stroke(); // leg pedaling
407
+ return y - 26 * s;
408
+ }
409
+
410
+ // ---------- ambient crowd ----------
411
+ function stepAmbient(a, dt) {
412
+ if (a.follow >= 0 && S.chars[a.follow]) { var f = S.chars[a.follow]; a.tx = f.x + 0.7; a.ty = f.y + 0.7; }
413
+ var dx = a.tx - a.x, dy = a.ty - a.y, d = Math.hypot(dx, dy);
414
+ if (d > 0.05) {
415
+ var sp = a.sp * Math.min(1, d / 1.0);
416
+ a.vx += ((dx / d) * sp - a.vx) * Math.min(1, dt * 4);
417
+ a.vy += ((dy / d) * sp - a.vy) * Math.min(1, dt * 4);
418
+ var nx = a.x + a.vx * dt, ny = a.y + a.vy * dt;
419
+ if (walkable(nx, a.y)) a.x = nx; else { a.vx = 0; a.tx = a.x; }
420
+ if (walkable(a.x, ny)) a.y = ny; else { a.vy = 0; a.ty = a.y; }
421
+ a.ph += dt * 6;
422
+ } else if (a.follow < 0) {
423
+ a.idle -= dt;
424
+ if (a.idle <= 0) { var t = randWalkable(); a.tx = t[0]; a.ty = t[1]; a.idle = 2.5 + Math.random() * 5; }
425
+ }
426
+ }
427
+ function drawAmbient(a) {
428
+ var p = iso(a.x + 0.5, a.y + 0.5), mv = Math.hypot(a.vx, a.vy) > 0.08;
429
+ if (a.type === 'dog') dog(p.x, p.y, a.sc, a.ph, mv);
430
+ else if (a.type === 'bike') bike(p.x, p.y, a.ph, mv, a.shirt);
431
+ else person(p.x, p.y, a.sc, a.shirt, a.hair, a.ph, mv);
432
+ }
433
+ function stepCar(c, dt) {
434
+ var max = (c.axis === 'h') ? S.W.cols : S.W.rows; c.pos += c.dir * c.sp * dt;
435
+ if (c.pos < 0) { c.pos = 0; c.dir = 1; } if (c.pos > max) { c.pos = max; c.dir = -1; }
436
+ }
437
+ function drawCar(c) {
438
+ var gx = (c.axis === 'h') ? c.pos : c.line + 0.5, gy = (c.axis === 'h') ? c.line + 0.5 : c.pos, p = iso(gx, gy);
439
+ var s = S.scale, fwd = c.dir > 0 ? 1 : -1;
440
+ ctx.fillStyle = 'rgba(0,0,0,0.32)'; ctx.beginPath(); ctx.ellipse(p.x, p.y, 13 * s, 4.5 * s, 0, 0, 7); ctx.fill();
441
+ // wheels
442
+ ctx.fillStyle = '#15171f'; ctx.beginPath(); ctx.arc(p.x - 7 * s, p.y - 2 * s, 2.6 * s, 0, 7); ctx.arc(p.x + 7 * s, p.y - 2 * s, 2.6 * s, 0, 7); ctx.fill();
443
+ // body
444
+ var g = ctx.createLinearGradient(p.x, p.y - 13 * s, p.x, p.y - 2 * s); g.addColorStop(0, shade(c.color, 1.15)); g.addColorStop(1, c.color);
445
+ ctx.fillStyle = g; rr(p.x - 11 * s, p.y - 9 * s, 22 * s, 8 * s, 3 * s); ctx.fill();
446
+ // cabin
447
+ ctx.fillStyle = shade(c.color, 1.05); rr(p.x - 5 * s, p.y - 14 * s, 11 * s, 6 * s, 2.5 * s); ctx.fill();
448
+ ctx.fillStyle = 'rgba(180,225,255,0.9)'; rr(p.x - 3.5 * s, p.y - 13 * s, 8 * s, 4 * s, 1.5 * s); ctx.fill();
449
+ // lights
450
+ ctx.fillStyle = 'rgba(255,240,180,0.95)'; ctx.beginPath(); ctx.arc(p.x + 10 * s * fwd, p.y - 6 * s, 1.6 * s, 0, 7); ctx.fill();
451
+ ctx.fillStyle = 'rgba(255,90,90,0.9)'; ctx.beginPath(); ctx.arc(p.x - 10 * s * fwd, p.y - 6 * s, 1.4 * s, 0, 7); ctx.fill();
452
+ }
453
+
454
+ // ---------- characters ----------
455
+ function drawChar(c, now) {
456
+ var p = iso(c.x + 0.5, c.y + 0.5);
457
+ var moving = Math.hypot(c.vx, c.vy) > 0.08;
458
+ if (c.speaking) {
459
+ var pr = 1 + Math.sin(now / 220) * 0.12;
460
+ ctx.strokeStyle = 'rgba(56,232,255,0.9)'; ctx.lineWidth = 2.4 * S.scale;
461
+ ctx.beginPath(); ctx.ellipse(p.x, p.y, 13 * S.scale * pr, 5.5 * S.scale * pr, 0, 0, 7); ctx.stroke();
462
+ }
463
+ var hy = (c.vehicle === 'bike') ? bike(p.x, p.y, c.bob, moving, c.color)
464
+ : person(p.x, p.y, 1.35, c.color, '#241c14', c.bob, moving);
465
+ if (c.moodEmoji) { ctx.font = (12 * S.scale) + 'px serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(c.moodEmoji, p.x + 9 * S.scale, hy + 3 * S.scale); }
466
+ var label = c.short + (c.activity ? ' ' + c.activity : '');
467
+ ctx.font = '600 ' + (9.5 * S.scale) + 'px Rajdhani, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
468
+ var nw = ctx.measureText(label).width + 12 * S.scale;
469
+ ctx.fillStyle = 'rgba(8,12,24,0.8)'; rr(p.x - nw / 2, p.y + 3 * S.scale, nw, 13 * S.scale, 5 * S.scale); ctx.fill();
470
+ ctx.strokeStyle = c.color; ctx.lineWidth = 1; ctx.stroke();
471
+ ctx.fillStyle = '#eaf0ff'; ctx.fillText(label, p.x, p.y + 9.5 * S.scale);
472
+ if (c.bubble) drawBubble(c, p.x, hy, now);
473
+ }
474
+ function drawBubble(c, x, topY, now) {
475
+ var shown = Math.min(c.bubble.length, Math.floor((now - c.bubbleAt) / 22));
476
+ var s = c.bubble.slice(0, shown) || ' ';
477
+ ctx.font = '600 ' + (12 * S.scale) + 'px Rajdhani, sans-serif';
478
+ var maxW = 180 * S.scale, lines = wrap(s, maxW), lh = 15 * S.scale, pad = 8 * S.scale;
479
+ var bw = 0; lines.forEach(function (l) { bw = Math.max(bw, ctx.measureText(l).width); });
480
+ bw += pad * 2; var bh = lines.length * lh + pad * 2;
481
+ var bx = x - bw / 2, by = topY - 14 * S.scale - bh;
482
+ ctx.fillStyle = 'rgba(12,18,34,0.93)'; rr(bx, by, bw, bh, 8 * S.scale); ctx.fill();
483
+ ctx.strokeStyle = 'rgba(120,200,255,0.4)'; ctx.lineWidth = 1; ctx.stroke();
484
+ ctx.fillStyle = 'rgba(12,18,34,0.93)'; ctx.beginPath(); ctx.moveTo(x - 6 * S.scale, by + bh); ctx.lineTo(x + 6 * S.scale, by + bh); ctx.lineTo(x, by + bh + 8 * S.scale); ctx.closePath(); ctx.fill();
485
+ ctx.fillStyle = '#eaf0ff'; ctx.textAlign = 'left'; ctx.textBaseline = 'top';
486
+ lines.forEach(function (l, i) { ctx.fillText(l, bx + pad, by + pad + i * lh); });
487
+ ctx.textAlign = 'center';
488
+ }
489
+ function wrap(s, maxW) {
490
+ var w = s.split(' '), out = [], cur = '';
491
+ for (var i = 0; i < w.length; i++) { var t = cur ? cur + ' ' + w[i] : w[i]; if (ctx.measureText(t).width > maxW && cur) { out.push(cur); cur = w[i]; } else cur = t; }
492
+ if (cur) out.push(cur); return out.slice(0, 4);
493
+ }
494
+ function rr(x, y, w, h, r) { ctx.beginPath(); ctx.moveTo(x + r, y); ctx.arcTo(x + w, y, x + w, y + h, r); ctx.arcTo(x + w, y + h, x, y + h, r); ctx.arcTo(x, y + h, x, y, r); ctx.arcTo(x, y, x + w, y, r); ctx.closePath(); }
495
+
496
+ // ---------- physics ----------
497
+ function step(c, dt, now) {
498
+ var dx = c.tx - c.x, dy = c.ty - c.y, dist = Math.hypot(dx, dy);
499
+ if (dist > 0.04) {
500
+ var maxV = (c.vehicle === 'bike') ? 1.9 : (c.running ? 1.3 : 0.85), arrive = 1.1;
501
+ var sp = maxV * Math.min(1, dist / arrive);
502
+ c.vx += ((dx / dist) * sp - c.vx) * Math.min(1, dt * 5);
503
+ c.vy += ((dy / dist) * sp - c.vy) * Math.min(1, dt * 5);
504
+ // resolve per-axis so people slide along walls and never walk into buildings or the pool
505
+ var nx = c.x + c.vx * dt, ny = c.y + c.vy * dt;
506
+ if (walkable(nx, c.y)) c.x = nx; else c.vx = 0;
507
+ if (walkable(c.x, ny)) c.y = ny; else c.vy = 0;
508
+ c.bob += dt * 6;
509
+ } else { c.x = c.tx; c.y = c.ty; c.vx *= 0.8; c.vy *= 0.8; }
510
+ if (c.speaking && now - c.bubbleAt > 7500) { c.speaking = false; c.bubble = null; c.returnAt = now + 800; }
511
+ if (!c.speaking && c.returnAt && now > c.returnAt) { c.tx = c.home[0]; c.ty = c.home[1]; c.returnAt = 0; c.activity = ''; c.vehicle = ''; c.running = false; }
512
+ if (!c.speaking && !c.returnAt) {
513
+ c.idle -= dt;
514
+ if (c.idle <= 0 && Math.hypot(c.tx - c.x, c.ty - c.y) < 0.1) {
515
+ // a calm town: mostly stand and watch, sometimes rest at a seat, rarely amble a short way
516
+ var roll = Math.random();
517
+ if (roll < 0.5) { // linger where they are
518
+ c.idle = 5 + Math.random() * 7;
519
+ } else if (roll < 0.78 && S.seats && S.seats.length) { // go sit down for a while
520
+ var st = S.seats[Math.floor(Math.random() * S.seats.length)];
521
+ if (walkable(st[0], st[1])) { c.tx = st[0]; c.ty = st[1]; }
522
+ c.idle = 7 + Math.random() * 6;
523
+ } else { // gentle amble to a nearby tile
524
+ for (var k = 0; k < 8; k++) { var ax = Math.round(c.x + (Math.random() * 4 - 2)), ay = Math.round(c.y + (Math.random() * 4 - 2)); if (walkable(ax, ay)) { c.tx = ax; c.ty = ay; break; } }
525
+ c.idle = 4 + Math.random() * 5;
526
+ }
527
+ c.running = false; // never run while idle — only when actually reacting to an event
528
+ }
529
+ }
530
+ }
531
+
532
+ // ---------- crisis overlay ----------
533
+ function drawCrisis(now, rect) {
534
+ var cr = S.crisis;
535
+ if (!cr || !cr.active) return;
536
+ var t = now / 1000, s = S.scale;
537
+ var has = cr.tile && S.W;
538
+ var c = has ? iso(cr.tile[0] + 0.5, cr.tile[1] + 0.5) : { x: rect.width / 2, y: rect.height / 2 };
539
+ var kind = cr.kind || 'fire';
540
+
541
+ if (has && kind === 'fire') {
542
+ var glow = ctx.createRadialGradient(c.x, c.y, 0, c.x, c.y, 64 * s);
543
+ glow.addColorStop(0, 'rgba(255,150,50,0.5)'); glow.addColorStop(1, 'rgba(255,80,0,0)');
544
+ ctx.fillStyle = glow; ctx.beginPath(); ctx.ellipse(c.x, c.y, 64 * s, 32 * s, 0, 0, 7); ctx.fill();
545
+ for (var i = 0; i < 5; i++) {
546
+ var fx = c.x + Math.sin(t * 4 + i) * 6 * s, base = c.y - 2 * s, h = (18 + Math.sin(t * 9 + i * 2) * 7) * s;
547
+ var fg = ctx.createLinearGradient(fx, base, fx, base - h);
548
+ fg.addColorStop(0, 'rgba(255,210,80,0.95)'); fg.addColorStop(0.6, 'rgba(255,110,20,0.85)'); fg.addColorStop(1, 'rgba(200,40,0,0)');
549
+ ctx.fillStyle = fg; ctx.beginPath(); ctx.moveTo(fx - 5 * s, base);
550
+ ctx.quadraticCurveTo(fx - 3 * s, base - h * 0.6, fx, base - h);
551
+ ctx.quadraticCurveTo(fx + 3 * s, base - h * 0.6, fx + 5 * s, base); ctx.closePath(); ctx.fill();
552
+ }
553
+ for (var k = 0; k < 4; k++) {
554
+ var pt = (t * 0.5 + k * 0.25) % 1, sx = c.x + Math.sin(t + k) * 10 * s, sy = c.y - 20 * s - pt * 40 * s;
555
+ ctx.fillStyle = 'rgba(70,70,80,' + (0.35 * (1 - pt)) + ')';
556
+ ctx.beginPath(); ctx.arc(sx, sy, (6 + pt * 10) * s, 0, 7); ctx.fill();
557
+ }
558
+ } else if (has && kind === 'storm') {
559
+ ctx.fillStyle = 'rgba(20,30,60,0.22)'; ctx.fillRect(0, 0, rect.width, rect.height);
560
+ ctx.strokeStyle = 'rgba(150,200,255,0.5)'; ctx.lineWidth = 1 * s;
561
+ for (var r = 0; r < 40; r++) {
562
+ var rx = c.x + (Math.random() * 120 - 60) * s, ry = c.y + (Math.random() * 80 - 60) * s;
563
+ ctx.beginPath(); ctx.moveTo(rx, ry); ctx.lineTo(rx - 2 * s, ry + 8 * s); ctx.stroke();
564
+ }
565
+ } else if (kind === 'blackout') {
566
+ var fl = 0.5 + Math.sin(t * 18) * 0.06;
567
+ var v = ctx.createRadialGradient(c.x, c.y, 24 * s, c.x, c.y, Math.max(rect.width, rect.height) * 0.8);
568
+ v.addColorStop(0, 'rgba(0,0,12,0.08)'); v.addColorStop(1, 'rgba(0,0,12,' + fl + ')');
569
+ ctx.fillStyle = v; ctx.fillRect(0, 0, rect.width, rect.height);
570
+ } else if (has && kind === 'search') {
571
+ var pr = (t % 1.2) / 1.2;
572
+ ctx.strokeStyle = 'rgba(120,220,255,' + (0.7 * (1 - pr)) + ')'; ctx.lineWidth = 2 * s;
573
+ ctx.beginPath(); ctx.ellipse(c.x, c.y, (10 + pr * 42) * s, (5 + pr * 21) * s, 0, 0, 7); ctx.stroke();
574
+ }
575
+
576
+ if (cr.chaos > 0.01) {
577
+ var cx = rect.width / 2, cy = rect.height / 2;
578
+ var cv = ctx.createRadialGradient(cx, cy, rect.height * 0.3, cx, cy, rect.height * 0.78);
579
+ cv.addColorStop(0, 'rgba(255,40,20,0)'); cv.addColorStop(1, 'rgba(255,30,10,' + (0.38 * cr.chaos) + ')');
580
+ ctx.fillStyle = cv; ctx.fillRect(0, 0, rect.width, rect.height);
581
+ }
582
+ }
583
+
584
+ // ---------- loop ----------
585
+ var last = performance.now();
586
+ function frame(now) {
587
+ var dt = Math.min(0.05, (now - last) / 1000); last = now;
588
+ var rect = canvas.getBoundingClientRect();
589
+ ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
590
+ var sky = ctx.createLinearGradient(0, 0, 0, rect.height);
591
+ sky.addColorStop(0, '#161038'); sky.addColorStop(0.45, '#2a1c52'); sky.addColorStop(1, '#0b1e16');
592
+ ctx.fillStyle = sky; ctx.fillRect(0, 0, rect.width, rect.height);
593
+ if (!S.W) { requestAnimationFrame(frame); return; }
594
+ for (var s = 0; s <= (S.W.cols + S.W.rows); s++) for (var gx = 0; gx < S.W.cols; gx++) { var gy = s - gx; if (gy < 0 || gy >= S.W.rows) continue; drawTile(gx, gy); }
595
+ S.chars.forEach(function (c) { step(c, dt, now); });
596
+ (S.amb || []).forEach(function (a) { stepAmbient(a, dt); });
597
+ (S.cars || []).forEach(function (c) { stepCar(c, dt); });
598
+ var ents = [];
599
+ (S.W.props || []).forEach(function (p) { var k = (p.gy + (p.d || 1)) + (p.gx + (p.w || 1)); ents.push({ d: k - 0.6, f: function () { drawProp(p, now); } }); });
600
+ S.W.buildings.forEach(function (b) { ents.push({ d: (b.gx + b.gy) + (b.w + b.d) - 0.5, f: function () { drawBuilding(b, now); } }); });
601
+ (S.W.trees || []).forEach(function (t) { ents.push({ d: t[0] + t[1] + 0.4, f: function () { drawTree(t); } }); });
602
+ (S.cars || []).forEach(function (c) { var gx = (c.axis === 'h') ? c.pos : c.line + 0.5, gy = (c.axis === 'h') ? c.line + 0.5 : c.pos; ents.push({ d: gx + gy + 0.45, f: function () { drawCar(c); } }); });
603
+ (S.amb || []).forEach(function (a) { ents.push({ d: a.x + a.y + 0.45, f: function () { drawAmbient(a); } }); });
604
+ S.chars.forEach(function (c) { ents.push({ d: c.x + c.y + 0.5, f: function () { drawChar(c, now); } }); });
605
+ ents.sort(function (a, b) { return a.d - b.d; }); ents.forEach(function (e) { e.f(); });
606
+ for (var i = S.waves.length - 1; i >= 0; i--) {
607
+ var w = S.waves[i]; w.t += dt; var rr2 = w.t * 560 * S.scale, al = Math.max(0, 0.7 - w.t);
608
+ if (al <= 0) { S.waves.splice(i, 1); continue; }
609
+ ctx.strokeStyle = 'rgba(56,232,255,' + al + ')'; ctx.lineWidth = 2.4 * S.scale;
610
+ ctx.beginPath(); ctx.ellipse(w.x, w.y, rr2, rr2 * 0.5, 0, 0, 7); ctx.stroke();
611
+ }
612
+ drawCrisis(now, rect);
613
+ requestAnimationFrame(frame);
614
+ }
615
+
616
+ function applyReactions(d) {
617
+ if (!d || !d.reactions || !S.W) return;
618
+ if (!d.silent) {
619
+ var pc = S.W.plaza_center || [S.W.cols / 2, S.W.rows / 2]; var p = iso(pc[0], pc[1]); S.waves.push({ x: p.x, y: p.y, t: 0 });
620
+ }
621
+ d.reactions.forEach(function (r) {
622
+ var c = S.chars.find(function (x) { return x.name === r.name; }); if (!c) return;
623
+ c.moodEmoji = r.moodEmoji || c.moodEmoji || '';
624
+ if (r.text) { c.bubble = r.text; c.bubbleAt = performance.now(); c.speaking = true; c.returnAt = 0; }
625
+ c.activity = r.activity || c.activity || ''; c.vehicle = r.vehicle || ''; c.running = !!r.running;
626
+ if (r.target) { c.tx = r.target[0]; c.ty = r.target[1]; }
627
+ });
628
+ }
629
+
630
+ function poll() {
631
+ var wj = txt('#tw-world');
632
+ if (wj && wj !== S.raw) { try { var W = JSON.parse(wj); S.raw = wj; loadWorld(W); } catch (e) { } }
633
+ var rj = txt('#tw-reactions');
634
+ if (rj) { try { var d = JSON.parse(rj); if (d.ts && d.ts !== S.lastTs) { S.lastTs = d.ts; applyReactions(d); } } catch (e) { } }
635
+ var cj = txt('#tw-crisis');
636
+ if (cj) { try { var cd = JSON.parse(cj); if (cd.ts && cd.ts !== S.crisisTs) { S.crisisTs = cd.ts; S.crisis = cd; } } catch (e) { } }
637
+ }
638
+
639
+ return {
640
+ start: function () { window.TINYWORLD = { applyReactions: applyReactions }; requestAnimationFrame(frame); setInterval(poll, 140); poll(); }
641
+ };
642
+ }
643
+
644
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
645
+ else boot();
646
+ })();
assets/map.js ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // TinyWorld — stage life: staggered bubbles, typewriter, shockwave, wander + juice.
2
+ (function () {
3
+ var BUBBLE_LIFETIME_MS = 8000;
4
+ var STAGGER_DELAY_MS = 120;
5
+
6
+ function animateBubbles() {
7
+ var bubbles = document.querySelectorAll('.bubble:not([data-animated])');
8
+ var i = 0;
9
+ bubbles.forEach(function (b) {
10
+ b.dataset.animated = '1';
11
+ b.dataset.spawned = Date.now();
12
+ b.style.animationDelay = (i * STAGGER_DELAY_MS) + 'ms';
13
+ setTimeout(function () { typewriter(b); }, i * STAGGER_DELAY_MS + 120);
14
+ i++;
15
+ });
16
+ // Juice: trigger roster flash + mood pop for each speaking pawn
17
+ setTimeout(function () {
18
+ document.querySelectorAll('.pawn.speaking').forEach(function (p) {
19
+ var moodEl = p.querySelector('.pawn-mood');
20
+ if (moodEl) { moodEl.classList.remove('pop'); void moodEl.offsetWidth; moodEl.classList.add('pop'); }
21
+ });
22
+ document.querySelectorAll('.roster-card').forEach(function (c) {
23
+ c.classList.remove('speaking-flash'); void c.offsetWidth; c.classList.add('speaking-flash');
24
+ });
25
+ }, 200);
26
+ }
27
+
28
+ function typewriter(bubble) {
29
+ var node = bubble.firstChild;
30
+ if (!node || node.nodeType !== 3) return;
31
+ var full = node.textContent;
32
+ if (full.length < 4) return;
33
+ node.textContent = '';
34
+ var k = 0;
35
+ var speed = Math.max(9, Math.min(22, 520 / full.length));
36
+ (function step() {
37
+ if (k < full.length) { node.textContent += full[k++]; setTimeout(step, speed); }
38
+ })();
39
+ }
40
+
41
+ function dismissOld() {
42
+ document.querySelectorAll('.bubble[data-spawned]').forEach(function (b) {
43
+ if (Date.now() - parseInt(b.dataset.spawned, 10) > BUBBLE_LIFETIME_MS) {
44
+ b.style.transition = 'opacity .4s ease, transform .4s ease';
45
+ b.style.opacity = '0';
46
+ b.style.transform = 'translateX(-50%) translateY(8px) scale(.9)';
47
+ setTimeout(function () { if (b.parentNode) b.remove(); }, 400);
48
+ }
49
+ });
50
+ }
51
+ setInterval(dismissOld, 1000);
52
+
53
+ function fireShockwave() {
54
+ var sw = document.querySelector('.shockwave');
55
+ if (!sw) return;
56
+ sw.classList.remove('fire'); void sw.offsetWidth; sw.classList.add('fire');
57
+ setTimeout(function () { sw.classList.remove('fire'); }, 900);
58
+ }
59
+
60
+ // Juice: screen shake on event throw
61
+ function fireScreenShake() {
62
+ var stage = document.querySelector('.stage');
63
+ if (!stage) return;
64
+ stage.classList.remove('shake'); void stage.offsetWidth; stage.classList.add('shake');
65
+ setTimeout(function () { stage.classList.remove('shake'); }, 550);
66
+ }
67
+
68
+ // Juice: button pulse on throw
69
+ function fireButtonPulse() {
70
+ var btn = document.querySelector('#throw-btn') || document.querySelector('button.primary');
71
+ if (!btn) return;
72
+ btn.classList.remove('pulse'); void btn.offsetWidth; btn.classList.add('pulse');
73
+ setTimeout(function () { btn.classList.remove('pulse'); }, 2500);
74
+ }
75
+
76
+ // Juice: meter tick glow when vibe-fill width changes
77
+ function watchMeters() {
78
+ var fills = document.querySelectorAll('.vibe-fill');
79
+ fills.forEach(function (f) {
80
+ if (f.dataset.watched) return;
81
+ f.dataset.watched = '1';
82
+ var lastW = f.style.width;
83
+ var m = new MutationObserver(function () {
84
+ if (f.style.width !== lastW) {
85
+ lastW = f.style.width;
86
+ f.classList.remove('tick'); void f.offsetWidth; f.classList.add('tick');
87
+ }
88
+ });
89
+ m.observe(f, { attributes: true, attributeFilter: ['style'] });
90
+ });
91
+ }
92
+ setInterval(watchMeters, 500);
93
+
94
+ // Juice: day/night tint drift
95
+ function startTintDrift() {
96
+ var tint = document.querySelector('.stage-tint');
97
+ if (tint && !tint.classList.contains('drift')) tint.classList.add('drift');
98
+ }
99
+
100
+ // gentle idle wander so the town feels alive between events
101
+ function wander() {
102
+ document.querySelectorAll('.pawn').forEach(function (p) {
103
+ if (p.classList.contains('speaking')) return;
104
+ var dx = (Math.random() * 2 - 1) * 0.7;
105
+ var dy = (Math.random() * 2 - 1) * 0.4;
106
+ var t = p.querySelector('.pawn-token');
107
+ if (t) t.style.transform = 'translate(' + dx.toFixed(2) + 'px,' + dy.toFixed(2) + 'px)';
108
+ });
109
+ }
110
+ setInterval(wander, 2600);
111
+
112
+ // Juice: track mouse on buttons for radial highlight
113
+ document.addEventListener('mousemove', function (e) {
114
+ var btn = e.target.closest('button.primary, .gr-button-primary, #throw-btn');
115
+ if (!btn) return;
116
+ var rect = btn.getBoundingClientRect();
117
+ btn.style.setProperty('--mx', ((e.clientX - rect.left) / rect.width * 100) + '%');
118
+ btn.style.setProperty('--my', ((e.clientY - rect.top) / rect.height * 100) + '%');
119
+ });
120
+
121
+ var observer = new MutationObserver(function (muts) {
122
+ var fresh = false;
123
+ muts.forEach(function (m) {
124
+ m.addedNodes && m.addedNodes.forEach(function (n) {
125
+ if (n.nodeType === 1 && (n.querySelector ? (n.querySelector('.bubble') || n.classList.contains('bubble')) : false)) fresh = true;
126
+ });
127
+ });
128
+ if (fresh) {
129
+ setTimeout(function () {
130
+ animateBubbles();
131
+ fireShockwave();
132
+ fireScreenShake();
133
+ fireButtonPulse();
134
+ startTintDrift();
135
+ }, 40);
136
+ }
137
+ });
138
+
139
+ function watch() {
140
+ var root = document.querySelector('.gradio-container') || document.body;
141
+ if (root) { observer.observe(root, { childList: true, subtree: true }); animateBubbles(); startTintDrift(); }
142
+ else setTimeout(watch, 300);
143
+ }
144
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watch);
145
+ else watch();
146
+ })();
assets/style.css ADDED
@@ -0,0 +1,701 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Rajdhani:wght@500;600;700&family=Inter:wght@400;500;600;700&display=swap');
2
+
3
+ :root {
4
+ --bg-0: #070912;
5
+ --bg-1: #0e1322;
6
+ --bg-2: #161d31;
7
+ --glass: rgba(18,26,46,0.62);
8
+ --glass-2: rgba(12,18,34,0.8);
9
+ --stroke: rgba(120,160,255,0.16);
10
+ --stroke-bright: rgba(120,200,255,0.4);
11
+ --neon-cyan: #38e8ff;
12
+ --neon-violet: #9b6bff;
13
+ --neon-pink: #ff5fa2;
14
+ --neon-lime: #7CFFB2;
15
+ --gold: #ffd76a;
16
+ --text: #eaf0ff;
17
+ --muted: #8ea0c8;
18
+ --r: 14px;
19
+ }
20
+
21
+ * { box-sizing: border-box; }
22
+
23
+ html, body {
24
+ background: var(--bg-0) !important;
25
+ margin: 0;
26
+ }
27
+
28
+ body, .gradio-container {
29
+ color: var(--text);
30
+ font-family: 'Inter', system-ui, sans-serif;
31
+ }
32
+
33
+ /* full-bleed dark backdrop so there is never a black dead-zone */
34
+ .gradio-container {
35
+ background:
36
+ radial-gradient(1100px 500px at 15% -10%, rgba(56,232,255,0.10), transparent 60%),
37
+ radial-gradient(1000px 600px at 100% 0%, rgba(155,107,255,0.12), transparent 55%),
38
+ var(--bg-0) !important;
39
+ max-width: 100% !important;
40
+ margin: 0 auto !important;
41
+ padding: 12px 22px 24px !important;
42
+ min-height: 100vh;
43
+ }
44
+
45
+ /* hidden data channels that drive the canvas (kept in DOM, off-screen) */
46
+ .tw-data { position: absolute !important; left: -9999px !important; width: 1px !important; height: 1px !important; overflow: hidden !important; opacity: 0 !important; pointer-events: none !important; }
47
+
48
+ /* kill Gradio's default footer / "Use via API" / Settings strip */
49
+ footer, .gradio-container > .main > .wrap > footer { display: none !important; }
50
+ .show-api, .api-docs { display: none !important; }
51
+
52
+ /* tame Gradio block chrome so our panels read as one surface */
53
+ .gr-block, .block, .form { background: transparent !important; border: none !important; box-shadow: none !important; }
54
+ .gr-group, .gr-box { background: transparent !important; border: none !important; }
55
+ label > span { color: var(--muted) !important; font-family: 'Rajdhani', sans-serif !important; font-weight: 600 !important; letter-spacing: .5px; }
56
+ .gradio-container .gap, .row { gap: 10px !important; }
57
+
58
+ /* ===================== TOP BAR ===================== */
59
+ .tw-topbar {
60
+ display: flex;
61
+ align-items: center;
62
+ gap: 14px;
63
+ padding: 14px 20px;
64
+ border-radius: var(--r);
65
+ background: linear-gradient(120deg, rgba(56,232,255,0.06), rgba(155,107,255,0.06)), var(--glass);
66
+ border: 1px solid var(--stroke);
67
+ backdrop-filter: blur(10px);
68
+ }
69
+ .tw-logo {
70
+ font-family: 'Orbitron', sans-serif;
71
+ font-weight: 900;
72
+ font-size: 24px;
73
+ letter-spacing: 4px;
74
+ color: var(--neon-cyan);
75
+ text-shadow: 0 0 14px rgba(56,232,255,0.5), 0 0 30px rgba(155,107,255,0.3);
76
+ }
77
+ .tw-tagline {
78
+ font-family: 'Rajdhani', sans-serif;
79
+ font-size: 16px; font-weight: 600; letter-spacing: 2px;
80
+ color: var(--muted); text-transform: uppercase;
81
+ }
82
+ .tw-spacer { flex: 1; }
83
+
84
+ /* ===================== TICKER ===================== */
85
+ .progress-ticker {
86
+ font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 18px;
87
+ color: var(--muted); display: flex; align-items: center; gap: 8px;
88
+ padding: 8px 14px; border-radius: 10px;
89
+ background: var(--glass); border: 1px solid var(--stroke);
90
+ }
91
+ .ticker-day { color: var(--neon-cyan); }
92
+ .ticker-sep { opacity: 0.35; }
93
+ .ticker-chaos { color: var(--neon-violet); letter-spacing: 1px; }
94
+ .ticker-events { color: var(--gold); }
95
+ .mode-badge {
96
+ font-family: 'Rajdhani', sans-serif;
97
+ font-weight: 800;
98
+ font-size: 16px;
99
+ color: var(--text);
100
+ padding: 8px 14px;
101
+ border-radius: 10px;
102
+ background: var(--glass);
103
+ border: 1px solid var(--stroke);
104
+ white-space: nowrap;
105
+ overflow: hidden;
106
+ text-overflow: ellipsis;
107
+ }
108
+ .mode-live { border-color: rgba(124,255,178,0.55); color: var(--neon-lime); }
109
+ .mode-waking, .mode-mock { border-color: rgba(255,215,106,0.55); color: var(--gold); }
110
+ .mode-error { border-color: rgba(255,95,162,0.65); color: var(--neon-pink); }
111
+
112
+ /* ===================== PANELS ===================== */
113
+ .glass-panel {
114
+ background: var(--glass);
115
+ backdrop-filter: blur(12px);
116
+ border: 1px solid var(--stroke);
117
+ border-radius: var(--r);
118
+ padding: 12px;
119
+ height: 100%;
120
+ }
121
+ .panel-title {
122
+ font-family: 'Orbitron', sans-serif; font-weight: 700;
123
+ font-size: 12px; letter-spacing: 2px; text-transform: uppercase;
124
+ color: var(--neon-cyan); margin-bottom: 10px;
125
+ display: flex; align-items: center; gap: 6px;
126
+ }
127
+ .daily-log-inner {
128
+ display: grid;
129
+ grid-template-columns: repeat(5, minmax(0, 1fr));
130
+ gap: 8px;
131
+ padding: 10px 12px;
132
+ border-radius: var(--r);
133
+ background: var(--glass);
134
+ border: 1px solid var(--stroke);
135
+ }
136
+ .daily-row {
137
+ min-width: 0;
138
+ font-family: 'Rajdhani', sans-serif;
139
+ font-size: 14px;
140
+ line-height: 1.25;
141
+ color: var(--muted);
142
+ }
143
+ .daily-row b {
144
+ display: block;
145
+ font-family: 'Orbitron', sans-serif;
146
+ font-size: 11px;
147
+ margin-bottom: 4px;
148
+ }
149
+ .daily-row span {
150
+ display: block;
151
+ overflow-wrap: anywhere;
152
+ }
153
+ @media (max-width: 900px) {
154
+ .daily-log-inner { grid-template-columns: 1fr; }
155
+ }
156
+
157
+ /* ===================== ROSTER ===================== */
158
+ .roster-rail {
159
+ display: flex; flex-direction: column; gap: 8px;
160
+ }
161
+ .roster-card {
162
+ display: grid;
163
+ grid-template-columns: 40px 1fr;
164
+ grid-template-rows: auto auto;
165
+ align-items: center;
166
+ gap: 2px 10px;
167
+ background: var(--glass);
168
+ border: 1px solid var(--stroke);
169
+ border-left: 3px solid var(--neon-violet);
170
+ border-radius: 12px;
171
+ padding: 9px 11px;
172
+ transition: transform .2s ease, border-color .2s ease, box-shadow .2s ease;
173
+ }
174
+ .roster-card:hover { transform: translateX(3px); border-left-color: var(--neon-cyan); box-shadow: 0 0 18px rgba(56,232,255,0.12); }
175
+ .roster-emoji { grid-row: 1 / 3; font-size: 28px; text-align: center; filter: drop-shadow(0 2px 4px rgba(0,0,0,.5)); }
176
+ .roster-name { font-family: 'Orbitron', sans-serif; font-weight: 700; font-size: 12px; color: var(--text); letter-spacing: .5px; }
177
+ .roster-mood { font-family: 'Rajdhani', sans-serif; font-weight: 600; font-size: 13px; color: var(--muted); }
178
+ .roster-meters { grid-column: 1 / 3; display: flex; flex-direction: column; gap: 3px; margin-top: 4px; }
179
+ .vibe-row { display: flex; align-items: center; gap: 6px; }
180
+ .vibe-label { font-size: 11px; width: 14px; text-align: center; }
181
+ .vibe-track { flex: 1; height: 6px; background: rgba(0,0,0,0.35); border-radius: 4px; overflow: hidden; }
182
+ .vibe-fill { height: 100%; border-radius: 4px; transition: width .9s cubic-bezier(.2,.8,.2,1); box-shadow: 0 0 8px currentColor; }
183
+ .vibe-val { font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 11px; color: var(--muted); width: 22px; text-align: right; }
184
+
185
+ /* ===================== STAGE / ISOMETRIC TOWN ===================== */
186
+ .stage {
187
+ position: relative;
188
+ width: 100%;
189
+ height: min(74vh, 860px);
190
+ border-radius: var(--r);
191
+ overflow: hidden;
192
+ border: 1px solid var(--stroke-bright);
193
+ box-shadow: 0 0 40px rgba(56,232,255,0.08), 0 24px 60px rgba(0,0,0,0.55);
194
+ background: #0a0e1c;
195
+ isolation: isolate;
196
+ }
197
+ .stage canvas { display: block; width: 100%; height: 100%; }
198
+ .stage-vignette {
199
+ position: absolute; inset: 0; pointer-events: none; border-radius: var(--r);
200
+ box-shadow: inset 0 0 120px rgba(0,0,0,0.55), inset 0 0 30px rgba(56,232,255,0.05);
201
+ }
202
+ .sky {
203
+ position: absolute; inset: 0; height: 46%;
204
+ background:
205
+ radial-gradient(2px 2px at 20% 30%, rgba(255,255,255,.7), transparent),
206
+ radial-gradient(1.5px 1.5px at 60% 18%, rgba(255,255,255,.6), transparent),
207
+ radial-gradient(1.5px 1.5px at 80% 40%, rgba(255,255,255,.5), transparent),
208
+ radial-gradient(2px 2px at 38% 50%, rgba(255,255,255,.5), transparent),
209
+ linear-gradient(180deg, #1a1340 0%, #2a1c54 40%, #4a2a63 72%, #6b3a5a 100%);
210
+ }
211
+ .sky::after {
212
+ content: ''; position: absolute; right: 12%; top: 14%;
213
+ width: 46px; height: 46px; border-radius: 50%;
214
+ background: radial-gradient(circle at 38% 38%, #fff3d6, #ffd76a 55%, #ff9a5f 100%);
215
+ box-shadow: 0 0 40px rgba(255,200,120,0.55);
216
+ }
217
+ .iso-ground {
218
+ position: absolute; inset: 38% 0 0 0;
219
+ background: linear-gradient(180deg, #16432f 0%, #0f3424 45%, #0a261b 100%);
220
+ }
221
+ .iso-ground::before {
222
+ content: ''; position: absolute; inset: 0;
223
+ background-image:
224
+ repeating-linear-gradient(63.43deg, rgba(120,220,255,0.08) 0 1px, transparent 1px 7.2%),
225
+ repeating-linear-gradient(-63.43deg, rgba(120,220,255,0.08) 0 1px, transparent 1px 7.2%);
226
+ -webkit-mask: linear-gradient(180deg, transparent 0%, #000 16%, #000 100%);
227
+ mask: linear-gradient(180deg, transparent 0%, #000 16%, #000 100%);
228
+ }
229
+ .scene { position: absolute; inset: 0; }
230
+
231
+ /* mood tint overlay */
232
+ .stage-tint { position: absolute; inset: 0; pointer-events: none; z-index: 60; mix-blend-mode: overlay; transition: background .8s ease; }
233
+
234
+ /* roads */
235
+ .iso-road {
236
+ position: absolute;
237
+ background: linear-gradient(180deg, #2b3350, #1c2238);
238
+ border-top: 1px solid rgba(160,190,255,0.18);
239
+ border-bottom: 1px solid rgba(0,0,0,0.4);
240
+ box-shadow: inset 0 0 18px rgba(0,0,0,0.5);
241
+ transform-origin: center;
242
+ }
243
+ .iso-road::after {
244
+ content: ''; position: absolute; left: 0; right: 0; top: 50%; height: 0;
245
+ border-top: 2px dashed rgba(255,215,106,0.5);
246
+ }
247
+
248
+ /* buildings: front face + skewed roof + thin side */
249
+ .bld {
250
+ position: absolute;
251
+ background: var(--front);
252
+ background: linear-gradient(180deg, var(--front), color-mix(in srgb, var(--front) 70%, #000));
253
+ border: 1px solid rgba(0,0,0,0.35);
254
+ border-radius: 3px 3px 2px 2px;
255
+ box-shadow: 0 12px 22px rgba(0,0,0,0.45);
256
+ }
257
+ .bld::before { /* roof */
258
+ content: ''; position: absolute; left: 0; top: calc(-1 * var(--rd, 34%));
259
+ width: 100%; height: var(--rd, 34%);
260
+ background: var(--roof);
261
+ transform: skewX(-45deg);
262
+ transform-origin: bottom left;
263
+ border-radius: 3px 3px 0 0;
264
+ box-shadow: 0 0 16px -2px var(--roof);
265
+ filter: brightness(1.05);
266
+ }
267
+ .bld::after { /* thin right side */
268
+ content: ''; position: absolute; left: 100%; top: 0;
269
+ width: var(--sd, 7%); height: 100%;
270
+ background: var(--side);
271
+ transform: skewY(-45deg);
272
+ transform-origin: top left;
273
+ }
274
+ .bld-sign {
275
+ position: absolute; left: 50%; top: 52%; transform: translate(-50%,-50%);
276
+ font-size: clamp(14px, 2.2vw, 26px);
277
+ filter: drop-shadow(0 2px 3px rgba(0,0,0,0.6));
278
+ }
279
+ .bld .win {
280
+ position: absolute; inset: 18% 14% 14% 14%;
281
+ background-image:
282
+ radial-gradient(circle at 50% 50%, rgba(255,235,160,0.85), transparent 60%);
283
+ background-size: 30% 40%;
284
+ background-repeat: repeat;
285
+ opacity: .35; pointer-events: none;
286
+ }
287
+
288
+ /* trees */
289
+ .tree { position: absolute; transform: translate(-50%, -100%) scale(var(--s, 1)); }
290
+ .tree::before { /* foliage */
291
+ content: ''; display: block; width: 22px; height: 22px; border-radius: 50% 50% 48% 48%;
292
+ background: radial-gradient(circle at 38% 32%, #4fd38a, #1f7a4a 70%, #145a36);
293
+ box-shadow: 0 0 10px rgba(40,180,110,0.3);
294
+ }
295
+ .tree::after { /* trunk */
296
+ content: ''; display: block; width: 5px; height: 10px; margin: -2px auto 0;
297
+ background: linear-gradient(#5a3a22, #3a2414); border-radius: 2px;
298
+ }
299
+ .prop { position: absolute; transform: translate(-50%, -100%); font-size: 26px; filter: drop-shadow(0 3px 5px rgba(0,0,0,0.55)); }
300
+
301
+ /* ===================== PAWNS (characters) ===================== */
302
+ .pawn {
303
+ position: absolute;
304
+ transform: translate(-50%, -100%);
305
+ transition: left 1.1s cubic-bezier(.34,.8,.34,1), top 1.1s cubic-bezier(.34,.8,.34,1);
306
+ display: flex; flex-direction: column; align-items: center;
307
+ }
308
+ .pawn-shadow {
309
+ position: absolute; bottom: -7px; left: 50%; transform: translateX(-50%);
310
+ width: 34px; height: 9px; background: rgba(0,0,0,0.4); border-radius: 50%; filter: blur(3px);
311
+ }
312
+ .pawn-token {
313
+ position: relative;
314
+ width: 44px; height: 44px; border-radius: 50%;
315
+ display: flex; align-items: center; justify-content: center;
316
+ font-size: 25px; line-height: 1;
317
+ background: radial-gradient(circle at 38% 30%, rgba(255,255,255,0.18), rgba(20,28,48,0.92));
318
+ border: 2px solid rgba(160,200,255,0.45);
319
+ box-shadow: 0 5px 12px rgba(0,0,0,0.5), inset 0 0 10px rgba(0,0,0,0.4);
320
+ animation: pawn-bob 3.2s ease-in-out infinite;
321
+ }
322
+ .pawn-mood {
323
+ position: absolute; top: -8px; right: -8px;
324
+ font-size: 15px; filter: drop-shadow(0 1px 2px rgba(0,0,0,0.7));
325
+ }
326
+ .pawn-name {
327
+ margin-top: 4px; font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 11px;
328
+ color: var(--text); letter-spacing: .5px;
329
+ background: rgba(8,12,24,0.7); padding: 1px 7px; border-radius: 8px;
330
+ border: 1px solid var(--stroke); white-space: nowrap;
331
+ }
332
+ .pawn.speaking .pawn-token {
333
+ border-color: var(--neon-cyan);
334
+ box-shadow: 0 0 0 3px rgba(56,232,255,0.25), 0 0 22px rgba(56,232,255,0.55);
335
+ animation: pawn-bob 1.4s ease-in-out infinite, speak-pulse 1.4s ease-in-out infinite;
336
+ }
337
+ .pawn.speaking .plumb {
338
+ position: absolute; top: -22px; left: 50%; transform: translateX(-50%) rotate(45deg);
339
+ width: 9px; height: 9px; background: var(--neon-cyan);
340
+ box-shadow: 0 0 12px var(--neon-cyan); animation: plumb-bob 1.4s ease-in-out infinite;
341
+ }
342
+ @keyframes pawn-bob { 0%,100% { transform: translateY(0);} 50% { transform: translateY(-3px);} }
343
+ @keyframes speak-pulse { 0%,100% { box-shadow: 0 0 0 3px rgba(56,232,255,0.25), 0 0 18px rgba(56,232,255,0.4);} 50% { box-shadow: 0 0 0 5px rgba(56,232,255,0.18), 0 0 28px rgba(56,232,255,0.7);} }
344
+ @keyframes plumb-bob { 0%,100% { top: -22px;} 50% { top: -27px;} }
345
+
346
+ /* speech bubbles */
347
+ .bubble {
348
+ position: absolute; bottom: calc(100% + 14px); left: 50%;
349
+ transform: translateX(-50%);
350
+ background: var(--glass-2);
351
+ color: var(--text);
352
+ font-family: 'Rajdhani', sans-serif; font-weight: 600; font-size: 14px; line-height: 1.35;
353
+ padding: 9px 13px; border-radius: 12px; width: 190px;
354
+ border: 1px solid var(--stroke-bright);
355
+ box-shadow: 0 8px 24px rgba(0,0,0,0.55), 0 0 18px rgba(56,232,255,0.12);
356
+ opacity: 0; animation: bubble-spring .4s cubic-bezier(.2,.9,.3,1.2) forwards;
357
+ word-wrap: break-word; backdrop-filter: blur(6px);
358
+ }
359
+ .bubble::after {
360
+ content: ''; position: absolute; bottom: -8px; left: 50%; transform: translateX(-50%);
361
+ border-left: 8px solid transparent; border-right: 8px solid transparent;
362
+ border-top: 8px solid var(--glass-2);
363
+ }
364
+ @keyframes bubble-spring {
365
+ 0% { opacity: 0; transform: translateX(-50%) translateY(8px) scale(.9); }
366
+ 70% { transform: translateX(-50%) translateY(-3px) scale(1.02); }
367
+ 100% { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); }
368
+ }
369
+
370
+ /* shockwave on throw */
371
+ .shockwave {
372
+ position: absolute; top: 62%; left: 50%; width: 40px; height: 24px;
373
+ margin: -12px 0 0 -20px; border-radius: 50%;
374
+ border: 2px solid var(--neon-cyan); pointer-events: none; z-index: 55; opacity: 0;
375
+ }
376
+ .shockwave.fire { animation: shock 0.85s ease-out forwards; }
377
+ @keyframes shock {
378
+ 0% { width: 40px; height: 24px; margin: -12px 0 0 -20px; opacity: .85; }
379
+ 100% { width: 540px; height: 320px; margin: -160px 0 0 -270px; opacity: 0; }
380
+ }
381
+
382
+ .stage-idle {
383
+ position: absolute; left: 50%; top: 22%; transform: translateX(-50%);
384
+ z-index: 58; font-family: 'Rajdhani', sans-serif; font-weight: 600; font-size: 16px;
385
+ color: rgba(234,240,255,0.85); letter-spacing: 1px;
386
+ background: rgba(8,12,24,0.55); padding: 8px 16px; border-radius: 20px;
387
+ border: 1px solid var(--stroke); animation: idle-breathe 3s ease-in-out infinite;
388
+ }
389
+ @keyframes idle-breathe { 0%,100% { opacity:.55;} 50% { opacity:1;} }
390
+
391
+ /* ===================== TOWN LOG ===================== */
392
+ .town-log-inner { max-height: 460px; overflow-y: auto; padding-right: 4px; display: flex; flex-direction: column; gap: 8px; }
393
+ .town-log-empty { font-family: 'Rajdhani', sans-serif; font-size: 15px; color: var(--muted); padding: 24px 12px; text-align: center; line-height: 1.5; }
394
+ .log-entry {
395
+ padding: 8px 10px; border-radius: 10px;
396
+ background: rgba(10,16,30,0.5); border: 1px solid var(--stroke);
397
+ animation: log-in .35s ease both;
398
+ }
399
+ @keyframes log-in { from { opacity:0; transform: translateY(6px);} to { opacity:1; transform: none;} }
400
+ .log-head { display: flex; align-items: center; gap: 6px; margin-bottom: 3px; }
401
+ .log-emoji { font-size: 16px; }
402
+ .log-name { font-family: 'Orbitron', sans-serif; font-weight: 700; font-size: 11px; color: var(--neon-cyan); }
403
+ .log-mood { font-size: 13px; }
404
+ .log-text { font-family: 'Rajdhani', sans-serif; font-weight: 500; font-size: 14px; color: var(--text); line-height: 1.4; }
405
+ .log-followup { border-left: 3px solid var(--neon-violet); }
406
+ .log-gossip { border-left: 3px solid var(--gold); font-style: italic; }
407
+
408
+ /* ===================== LEARN / EXPLAINER ===================== */
409
+ .explain-inner { display: flex; flex-direction: column; gap: 8px; max-height: 460px; overflow-y: auto; padding-right: 4px; }
410
+ .explain-empty { font-family: 'Rajdhani', sans-serif; font-size: 14px; color: var(--muted); line-height: 1.55; padding: 14px 12px; background: rgba(10,16,30,0.4); border-radius: 10px; border: 1px dashed var(--stroke); }
411
+ .explain-focus { font-family: 'Rajdhani', sans-serif; font-size: 14.5px; color: var(--gold); background: rgba(255,215,106,0.08); border: 1px solid rgba(255,215,106,0.25); border-radius: 10px; padding: 9px 11px; line-height: 1.45; }
412
+ .explain-row { font-family: 'Rajdhani', sans-serif; font-weight: 500; font-size: 14px; color: var(--text); line-height: 1.45; background: rgba(10,16,30,0.5); border: 1px solid var(--stroke); border-radius: 10px; padding: 8px 11px; }
413
+ .explain-row em { color: var(--neon-cyan); font-style: normal; font-weight: 600; }
414
+
415
+ /* ===================== AUDIO CAPTIONS ===================== */
416
+ #tw-audio .gr-audio, #tw-audio .audio-container { background: var(--glass) !important; border-radius: 12px !important; }
417
+ .audio-cap { font-family: 'Rajdhani', sans-serif; font-size: 12.5px; color: var(--muted); line-height: 1.45; padding: 5px 4px 0; }
418
+ .audio-cap b { color: var(--neon-cyan); }
419
+ .audio-cap i { color: var(--neon-pink); font-style: normal; }
420
+ #tw-actions { margin-top: 4px; align-items: end; }
421
+
422
+ /* ===================== CONSOLE ===================== */
423
+ #tw-console { padding: 14px 16px; }
424
+ #tw-console textarea, #tw-console input[type="text"] {
425
+ background: var(--bg-2) !important; color: var(--text) !important;
426
+ border: 1px solid var(--stroke) !important; border-radius: 12px !important;
427
+ font-family: 'Rajdhani', sans-serif !important; font-weight: 600 !important; font-size: 18px !important;
428
+ padding: 14px 16px !important; min-height: 56px !important;
429
+ }
430
+ #tw-console textarea:focus, #tw-console input[type="text"]:focus {
431
+ border-color: var(--neon-cyan) !important; box-shadow: 0 0 0 3px rgba(56,232,255,0.15) !important;
432
+ }
433
+ .tw-hint { font-family: 'Rajdhani', sans-serif; font-size: 13px; color: var(--muted); padding: 4px 2px 0; }
434
+ .tw-hint b { color: var(--neon-cyan); }
435
+
436
+ /* ===================== BUTTONS ===================== */
437
+ button.primary, .gr-button-primary, #throw-btn {
438
+ background: linear-gradient(135deg, var(--neon-cyan), var(--neon-violet)) !important;
439
+ color: #06121f !important;
440
+ font-family: 'Orbitron', sans-serif !important; font-weight: 900 !important;
441
+ font-size: 16px !important; letter-spacing: 1px !important;
442
+ border: none !important; border-radius: 12px !important; padding: 14px 18px !important;
443
+ cursor: pointer !important; box-shadow: 0 0 22px rgba(56,232,255,0.35) !important;
444
+ transition: transform .15s cubic-bezier(.2,.8,.2,1), box-shadow .2s ease !important;
445
+ }
446
+ button.primary:hover, .gr-button-primary:hover, #throw-btn:hover {
447
+ box-shadow: 0 0 30px rgba(56,232,255,0.6), 0 0 60px rgba(155,107,255,0.35) !important;
448
+ transform: translateY(-2px) !important;
449
+ }
450
+ button.primary:active, .gr-button-primary:active, #throw-btn:active { transform: scale(.96) !important; }
451
+
452
+ .gr-button, button.secondary {
453
+ background: var(--glass) !important; color: var(--text) !important;
454
+ border: 1px solid var(--stroke) !important; border-radius: 12px !important;
455
+ font-family: 'Rajdhani', sans-serif !important; font-weight: 700 !important; font-size: 15px !important;
456
+ letter-spacing: .5px !important; transition: all .2s ease !important;
457
+ }
458
+ .gr-button:hover, button.secondary:hover { border-color: var(--neon-violet) !important; box-shadow: 0 0 14px rgba(155,107,255,0.2) !important; transform: translateY(-1px) !important; }
459
+
460
+ /* dropdowns + audio */
461
+ .gr-dropdown, .wrap.svelte-1cl284s, input[role="listbox"] {
462
+ background: var(--bg-2) !important; color: var(--text) !important;
463
+ border: 1px solid var(--stroke) !important; border-radius: 12px !important;
464
+ }
465
+ .gr-dropdown input { font-family: 'Rajdhani', sans-serif !important; font-weight: 600 !important; }
466
+
467
+ /* ===================== FOOTER (ours) ===================== */
468
+ .tw-footer {
469
+ text-align: center; padding: 18px; margin-top: 16px;
470
+ font-family: 'Rajdhani', sans-serif; font-size: 14px; color: var(--muted);
471
+ border-top: 1px solid var(--stroke); line-height: 1.9;
472
+ }
473
+ .tw-footer strong { color: var(--neon-cyan); }
474
+ .footer-muted { opacity: 0.7; font-size: 12px; }
475
+
476
+ /* ===================== SCROLLBARS ===================== */
477
+ ::-webkit-scrollbar { width: 7px; height: 7px; }
478
+ ::-webkit-scrollbar-track { background: transparent; }
479
+ ::-webkit-scrollbar-thumb { background: var(--bg-2); border-radius: 4px; }
480
+ ::-webkit-scrollbar-thumb:hover { background: var(--neon-violet); }
481
+
482
+ /* ===================== FORCE-DARK ALL GRADIO WIDGETS (no white boxes) ===================== */
483
+ input, textarea, select { background: var(--bg-2) !important; color: var(--text) !important; border-color: var(--stroke) !important; }
484
+ input::placeholder, textarea::placeholder { color: rgba(150,165,200,0.6) !important; }
485
+ /* dropdown trigger + popup list */
486
+ .gr-dropdown, [data-testid="dropdown"], .wrap-inner, ul.options, .options {
487
+ background: var(--bg-2) !important; color: var(--text) !important; border: 1px solid var(--stroke) !important;
488
+ }
489
+ ul.options li, .options .item, li[role="option"] { background: var(--bg-2) !important; color: var(--text) !important; }
490
+ ul.options li:hover, .options .item:hover, li[role="option"]:hover, .item.selected, li[aria-selected="true"] {
491
+ background: var(--neon-violet) !important; color: #0a0f1c !important;
492
+ }
493
+ /* audio widgets */
494
+ .gr-audio, [data-testid="audio"], .audio-container, .controls, .waveform-container, .component-wrap {
495
+ background: var(--glass) !important; color: var(--text) !important; border-color: var(--stroke) !important;
496
+ }
497
+ .gr-audio *, [data-testid="audio"] * { color: var(--text) !important; }
498
+ .gr-audio button, [data-testid="audio"] button { background: var(--bg-2) !important; color: var(--text) !important; border: 1px solid var(--stroke) !important; }
499
+ [data-testid="waveform"], canvas.waveform { filter: hue-rotate(180deg) brightness(0.9); }
500
+ /* generic svelte blocks that default to light */
501
+ .block.padded, .form, .panel, .wrap { background: transparent !important; }
502
+ label, label span, .gr-text-input label { color: var(--muted) !important; }
503
+
504
+ /* mic warning banner */
505
+ .tw-mic-warn {
506
+ position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
507
+ background: linear-gradient(90deg, rgba(255,95,162,0.95), rgba(155,107,255,0.95));
508
+ color: #0a0f1c; font-family: 'Rajdhani', sans-serif; font-weight: 700; font-size: 14px;
509
+ text-align: center; padding: 8px 14px; letter-spacing: 0.4px;
510
+ box-shadow: 0 4px 18px rgba(0,0,0,0.4);
511
+ }
512
+ .tw-mic-warn b { color: #161038; }
513
+
514
+ /* ===================== JUICE: SCREEN SHAKE ===================== */
515
+ @keyframes screen-shake {
516
+ 0%, 100% { transform: translate(0, 0) rotate(0); }
517
+ 10% { transform: translate(-3px, -2px) rotate(-0.3deg); }
518
+ 20% { transform: translate(4px, 1px) rotate(0.4deg); }
519
+ 30% { transform: translate(-2px, 3px) rotate(-0.2deg); }
520
+ 40% { transform: translate(3px, -1px) rotate(0.3deg); }
521
+ 50% { transform: translate(-1px, 2px) rotate(-0.1deg); }
522
+ 60% { transform: translate(2px, -2px) rotate(0.2deg); }
523
+ 70% { transform: translate(-3px, 1px) rotate(-0.3deg); }
524
+ 80% { transform: translate(1px, -1px) rotate(0.1deg); }
525
+ 90% { transform: translate(-1px, 2px) rotate(-0.1deg); }
526
+ }
527
+ .stage.shake {
528
+ animation: screen-shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
529
+ }
530
+
531
+ /* ===================== JUICE: BUTTON SQUISH ===================== */
532
+ button.primary, .gr-button-primary, #throw-btn {
533
+ position: relative;
534
+ overflow: hidden;
535
+ }
536
+ button.primary::after, .gr-button-primary::after, #throw-btn::after {
537
+ content: '';
538
+ position: absolute; inset: 0;
539
+ background: radial-gradient(circle at var(--mx, 50%) var(--my, 50%), rgba(255,255,255,0.3), transparent 60%);
540
+ opacity: 0;
541
+ transition: opacity .3s ease;
542
+ }
543
+ button.primary:active::after, .gr-button-primary:active::after, #throw-btn:active::after {
544
+ opacity: 1;
545
+ }
546
+ @keyframes btn-pulse-glow {
547
+ 0%, 100% { box-shadow: 0 0 22px rgba(56,232,255,0.35); }
548
+ 50% { box-shadow: 0 0 36px rgba(56,232,255,0.6), 0 0 60px rgba(155,107,255,0.3); }
549
+ }
550
+ button.primary.pulse, .gr-button-primary.pulse {
551
+ animation: btn-pulse-glow 0.8s ease-in-out 3;
552
+ }
553
+
554
+ /* ===================== JUICE: METER TICK GLOW ===================== */
555
+ @keyframes meter-tick {
556
+ 0% { box-shadow: 0 0 8px currentColor; }
557
+ 50% { box-shadow: 0 0 18px currentColor, 0 0 6px currentColor; }
558
+ 100% { box-shadow: 0 0 8px currentColor; }
559
+ }
560
+ .vibe-fill.tick {
561
+ animation: meter-tick 0.6s ease-in-out;
562
+ }
563
+ @keyframes roster-flash {
564
+ 0% { border-left-color: var(--neon-cyan); background: rgba(56,232,255,0.08); }
565
+ 100% { border-left-color: var(--neon-violet); background: var(--glass); }
566
+ }
567
+ .roster-card.speaking-flash {
568
+ animation: roster-flash 0.5s ease forwards;
569
+ }
570
+
571
+ /* ===================== JUICE: MOOD BOUNCE ===================== */
572
+ @keyframes mood-pop {
573
+ 0% { transform: scale(1); }
574
+ 40% { transform: scale(1.5) rotate(-8deg); }
575
+ 70% { transform: scale(0.85) rotate(4deg); }
576
+ 100% { transform: scale(1) rotate(0); }
577
+ }
578
+ .pawn-mood.pop {
579
+ animation: mood-pop 0.5s cubic-bezier(.34,.8,.34,1.4);
580
+ }
581
+
582
+ /* ===================== JUICE: DAY/NIGHT DRIFT ===================== */
583
+ @keyframes tint-drift {
584
+ 0% { background: rgba(255,170,80,0.04); }
585
+ 25% { background: rgba(56,232,255,0.03); }
586
+ 50% { background: rgba(155,107,255,0.04); }
587
+ 75% { background: rgba(255,200,100,0.03); }
588
+ 100% { background: rgba(255,170,80,0.04); }
589
+ }
590
+ .stage-tint.drift {
591
+ animation: tint-drift 30s ease-in-out infinite;
592
+ }
593
+
594
+ /* ===================== JUICE: LOG ENTRIES ===================== */
595
+ @keyframes log-slide-in {
596
+ 0% { opacity: 0; transform: translateX(-12px) scale(0.97); }
597
+ 60% { transform: translateX(2px) scale(1.01); }
598
+ 100% { opacity: 1; transform: none; }
599
+ }
600
+ .log-entry { animation: log-slide-in 0.4s cubic-bezier(.2,.8,.2,1) both; }
601
+
602
+ /* ===================== JUICE: TITLE GLOW ===================== */
603
+ @keyframes title-glow {
604
+ 0%, 100% { text-shadow: 0 0 14px rgba(56,232,255,0.5), 0 0 30px rgba(155,107,255,0.3); }
605
+ 50% { text-shadow: 0 0 20px rgba(56,232,255,0.7), 0 0 44px rgba(155,107,255,0.45); }
606
+ }
607
+ .tw-logo { animation: title-glow 4s ease-in-out infinite; }
608
+
609
+ /* ===================== RESPONSIVE ===================== */
610
+ @media (max-width: 1024px) {
611
+ .roster-rail { flex-direction: row; flex-wrap: wrap; }
612
+ .roster-card { flex: 1 1 45%; }
613
+ }
614
+ @media (max-width: 720px) {
615
+ .tw-logo { font-size: 18px; letter-spacing: 2px; }
616
+ .tw-tagline { display: none; }
617
+ .stage { aspect-ratio: 4 / 3; }
618
+ .roster-card { flex-basis: 100%; }
619
+ }
620
+
621
+ /* ---------------------------------------------- Crisis Mode */
622
+ #tw-crisis { display: none !important; }
623
+
624
+ .crisis-hud {
625
+ margin: 8px 0 4px;
626
+ padding: 12px 14px;
627
+ border-radius: var(--r);
628
+ background: linear-gradient(120deg, rgba(255,95,60,0.10), rgba(255,40,20,0.04)), var(--glass-2);
629
+ border: 1px solid rgba(255,120,80,0.35);
630
+ box-shadow: 0 0 22px rgba(255,80,40,0.12) inset;
631
+ }
632
+ .crisis-hud-idle {
633
+ color: var(--muted);
634
+ border: 1px dashed var(--stroke);
635
+ background: var(--glass);
636
+ box-shadow: none;
637
+ font-family: 'Rajdhani', sans-serif;
638
+ }
639
+ .crisis-top { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; }
640
+ .crisis-title { font-family: 'Orbitron', sans-serif; font-weight: 700; color: #ff8a5f; letter-spacing: .5px; }
641
+ .crisis-meta { font-family: 'Rajdhani', sans-serif; font-weight: 600; color: var(--muted); font-size: 13px; }
642
+ .crisis-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 10px 0; }
643
+ .crisis-chip {
644
+ font-family: 'Rajdhani', sans-serif; font-weight: 600; font-size: 12.5px;
645
+ padding: 4px 10px; border-radius: 999px; border: 1px solid var(--stroke);
646
+ background: var(--glass); color: var(--muted);
647
+ }
648
+ .crisis-chip.met { color: var(--neon-lime); border-color: rgba(124,255,178,0.5); background: rgba(124,255,178,0.10); }
649
+ .crisis-chip.open { color: #ffd76a; border-color: rgba(255,215,106,0.35); }
650
+ .crisis-bars { display: flex; gap: 16px; flex-wrap: wrap; }
651
+ .crisis-bar { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 180px; font-family: 'Rajdhani', sans-serif; font-weight: 600; font-size: 12.5px; color: var(--muted); }
652
+ .crisis-bar > span { white-space: nowrap; }
653
+ .crisis-bar > b { color: var(--text); min-width: 26px; text-align: right; }
654
+ .cbar-track { flex: 1; height: 8px; border-radius: 6px; background: rgba(0,0,0,0.35); overflow: hidden; border: 1px solid var(--stroke); }
655
+ .cbar-fill { height: 100%; transition: width .4s ease; }
656
+ .cbar-fill.chaos { background: linear-gradient(90deg, #ff8a5f, #ff3b3b); }
657
+ .cbar-fill.res { background: linear-gradient(90deg, #7CFFB2, #38e8ff); }
658
+
659
+ .story-card {
660
+ margin: 6px 0 10px; padding: 14px 16px; border-radius: var(--r);
661
+ background: var(--glass-2); border: 1px solid var(--stroke);
662
+ animation: storyIn .4s ease;
663
+ }
664
+ .story-hidden { display: none; }
665
+ .story-title { font-family: 'Orbitron', sans-serif; font-weight: 700; letter-spacing: .5px; margin-bottom: 6px; }
666
+ .story-body { font-family: 'Rajdhani', sans-serif; font-size: 15px; line-height: 1.45; color: var(--text); }
667
+ .story-intro { border-color: var(--stroke-bright); }
668
+ .story-intro .story-title { color: var(--neon-cyan); }
669
+ .story-dispatch .story-body { color: var(--muted); font-style: italic; }
670
+ .story-win { border-color: rgba(124,255,178,0.5); background: linear-gradient(120deg, rgba(124,255,178,0.10), transparent), var(--glass-2); }
671
+ .story-win .story-title { color: var(--neon-lime); }
672
+ .story-loss, .story-gameover { border-color: rgba(255,95,80,0.5); background: linear-gradient(120deg, rgba(255,80,60,0.12), transparent), var(--glass-2); }
673
+ .story-loss .story-title, .story-gameover .story-title { color: #ff7a5f; }
674
+ .story-finale { border-color: var(--neon-violet); background: linear-gradient(120deg, rgba(155,107,255,0.16), rgba(255,95,162,0.08)), var(--glass-2); box-shadow: 0 0 30px rgba(155,107,255,0.18); }
675
+ .story-finale .story-title { color: var(--neon-violet); }
676
+ .story-reveal {
677
+ margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--stroke);
678
+ font-family: 'Orbitron', sans-serif; font-size: 15px; line-height: 1.5; color: var(--neon-pink);
679
+ text-shadow: 0 0 14px rgba(255,95,162,0.5);
680
+ }
681
+ @keyframes storyIn { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
682
+
683
+ /* ---------------------------------------------- High-contrast readability
684
+ Belt-and-suspenders so text is always legible on the dark theme regardless
685
+ of how HF Spaces initialises light/dark mode. */
686
+ .gradio-container, .gradio-container .prose,
687
+ .gradio-container p, .gradio-container span, .gradio-container li,
688
+ .gradio-container label, .gradio-container .gr-box { color: #eaf0ff; }
689
+ .gradio-container { background: var(--bg-0) !important; }
690
+ label > span, .panel-title { color: #cdd9ff !important; }
691
+ input, textarea, select,
692
+ .gr-input, .gr-text-input, .gr-box input, .gr-box textarea {
693
+ color: #f4f8ff !important;
694
+ background: rgba(12,18,34,0.92) !important;
695
+ border-color: var(--stroke-bright) !important;
696
+ }
697
+ ::placeholder { color: #8ea0c8 !important; opacity: 1; }
698
+ button, .gr-button { color: #ffffff !important; }
699
+ .gr-radio label, fieldset label { color: #eaf0ff !important; }
700
+ .town-log-empty, .explain-empty, .audio-cap, .footer-muted, .tw-tagline { color: #aab8e0 !important; }
701
+ .log-text, .explain-row, .daily-row span { color: #e8eeff !important; }
campaign.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyWorld Crisis Mode — campaign state machine.
2
+
3
+ A *campaign* chains crises into a story ("Season 1 · The Long Summer"). Each
4
+ *chapter* wraps one crisis with narrative beats and a meta-mystery clue. The town
5
+ survives the campaign by keeping its Town Resilience above zero; the finale reveals
6
+ that the disasters were never random — the saboteur is the player.
7
+
8
+ This module is pure orchestration: it never calls the LLM itself. The UI computes
9
+ reactions with ``agents.react`` and hands them to :func:`resolve_round`, which keeps
10
+ the engine testable offline with scripted reactions.
11
+ """
12
+
13
+ from dataclasses import dataclass, field
14
+
15
+ import crisis as crisis_mod
16
+ import world_state
17
+
18
+
19
+ # Resilience cost of finishing a crisis at each grade (a clean run barely dents it).
20
+ GRADE_COST = {"A": 0, "B": 3, "C": 7, "D": 12, "F": 25}
21
+
22
+
23
+ @dataclass
24
+ class Chapter:
25
+ id: str
26
+ crisis_id: str
27
+ intro: str = ""
28
+ outro_win: str = ""
29
+ outro_loss: str = ""
30
+ clue: str = ""
31
+
32
+
33
+ @dataclass
34
+ class Campaign:
35
+ id: str
36
+ title: str
37
+ chapters: list = field(default_factory=list)
38
+ finale_reveal: str = ""
39
+
40
+
41
+ # --------------------------------------------------------------------------- loading
42
+ def get_campaign_def(world):
43
+ data = world.get("campaign")
44
+ if not data:
45
+ return None
46
+ chapters = [Chapter(**ch) for ch in data.get("chapters", [])]
47
+ return Campaign(
48
+ id=data["id"],
49
+ title=data["title"],
50
+ chapters=chapters,
51
+ finale_reveal=data.get("finale_reveal", ""),
52
+ )
53
+
54
+
55
+ def current_chapter(world):
56
+ camp = get_campaign_def(world)
57
+ if not camp:
58
+ return None
59
+ idx = world_state.get_campaign(world["id"])["chapter_index"]
60
+ if 0 <= idx < len(camp.chapters):
61
+ return camp.chapters[idx]
62
+ return None
63
+
64
+
65
+ def current_crisis(world):
66
+ state = world_state.get_campaign(world["id"])
67
+ if not state.get("crisis_id"):
68
+ return None
69
+ return crisis_mod.get_crisis(world, state["crisis_id"])
70
+
71
+
72
+ # --------------------------------------------------------------------------- lifecycle
73
+ def start(world):
74
+ """Begin the campaign at chapter 1 and inject its crisis."""
75
+ wid = world["id"]
76
+ world_state.reset_campaign(wid)
77
+ camp = get_campaign_def(world)
78
+ if not camp or not camp.chapters:
79
+ return view(world)
80
+ first = camp.chapters[0]
81
+ world_state.campaign_update(
82
+ wid,
83
+ active=True,
84
+ campaign_id=camp.id,
85
+ chapter_index=0,
86
+ status="playing",
87
+ )
88
+ _inject(world, first)
89
+ return view(world)
90
+
91
+
92
+ def _inject(world, chapter):
93
+ wid = world["id"]
94
+ world_state.campaign_update(
95
+ wid,
96
+ crisis_id=chapter.crisis_id,
97
+ crisis_round=0,
98
+ crisis_met=set(),
99
+ last_dispatch="",
100
+ )
101
+ world_state.reset_chaos(wid)
102
+
103
+
104
+ def round_event_text(world):
105
+ """The event string to feed agents this round (carries prior-round feedback)."""
106
+ cr = current_crisis(world)
107
+ if not cr:
108
+ return ""
109
+ state = world_state.get_campaign(world["id"])
110
+ if state["crisis_round"] == 0:
111
+ return crisis_mod.build_event_text(cr)
112
+ # rebuild a progress-shaped object from the sticky met set for the feedback text
113
+ prior = crisis_mod.CrisisProgress(
114
+ crisis_id=cr.id,
115
+ met=state["crisis_met"],
116
+ unmet=[r for r in cr.requirements if r.id not in state["crisis_met"]],
117
+ )
118
+ return crisis_mod.build_event_text(cr, prior_progress=prior)
119
+
120
+
121
+ # --------------------------------------------------------------------------- a round
122
+ def resolve_round(world, reactions):
123
+ """Advance the campaign by one player round given the residents' reactions."""
124
+ wid = world["id"]
125
+ state = world_state.get_campaign(wid)
126
+ cr = current_crisis(world)
127
+ if not cr or state["status"] not in ("playing",):
128
+ return {"outcome": "idle", **view(world)}
129
+
130
+ round_no = state["crisis_round"] + 1
131
+ prog = crisis_mod.evaluate(cr, reactions, world, prior_met=state["crisis_met"])
132
+ world_state.campaign_update(wid, crisis_met=prog.met, crisis_round=round_no)
133
+
134
+ if prog.mvp:
135
+ tally = state["mvp_tally"]
136
+ tally[prog.mvp] = tally.get(prog.mvp, 0) + 1
137
+ world_state.campaign_update(wid, mvp_tally=tally)
138
+
139
+ if prog.resolved:
140
+ grade = _grade(round_no, world_state.get_chaos(wid), won=True)
141
+ dispatch = crisis_mod.narrate(cr, prog, round_no=round_no)
142
+ return _finish_chapter(world, cr, grade, prog, dispatch, won=True)
143
+
144
+ # not resolved — chaos ramps with each round so the time limit is the real
145
+ # budget (campaign owns the chaos gauge; we overwrite any per-event drift).
146
+ pressure = min(1.0, (round_no / cr.time_limit) * (0.8 + 0.05 * cr.severity))
147
+ world_state.reset_chaos(wid)
148
+ world_state.add_chaos(wid, pressure)
149
+ if round_no >= cr.time_limit:
150
+ grade = "F"
151
+ dispatch = crisis_mod.narrate(cr, prog, round_no=round_no, failed=True)
152
+ return _finish_chapter(world, cr, grade, prog, dispatch, won=False)
153
+
154
+ dispatch = crisis_mod.narrate(cr, prog, round_no=round_no)
155
+ world_state.campaign_update(wid, last_dispatch=dispatch)
156
+ return {"outcome": "ongoing", "dispatch": dispatch, "progress": prog, "grade": None, **view(world)}
157
+
158
+
159
+ def _finish_chapter(world, cr, grade, prog, dispatch, won):
160
+ wid = world["id"]
161
+ state = world_state.get_campaign(wid)
162
+ camp = get_campaign_def(world)
163
+ chapter = current_chapter(world)
164
+
165
+ resilience = max(0, state["town_resilience"] - GRADE_COST.get(grade, 12))
166
+ grades = state["chapter_grades"] + [{
167
+ "chapter": chapter.id if chapter else cr.id,
168
+ "title": cr.title,
169
+ "grade": grade,
170
+ "rounds": state["crisis_round"],
171
+ "mvp": prog.mvp,
172
+ "won": won,
173
+ }]
174
+ clues = state["clues"] + ([chapter.clue] if chapter and chapter.clue else [])
175
+
176
+ world_state.campaign_update(
177
+ wid,
178
+ town_resilience=resilience,
179
+ chapter_grades=grades,
180
+ clues=clues,
181
+ last_dispatch=dispatch,
182
+ )
183
+
184
+ next_index = state["chapter_index"] + 1
185
+ outcome = "won" if won else "lost_crisis"
186
+ outro = (chapter.outro_win if won else chapter.outro_loss) if chapter else ""
187
+
188
+ if resilience <= 0:
189
+ world_state.campaign_update(wid, status="lost", crisis_id=None)
190
+ return {"outcome": "campaign_lost", "dispatch": dispatch, "outro": outro,
191
+ "grade": grade, "progress": prog, **view(world)}
192
+
193
+ if next_index >= len(camp.chapters):
194
+ # campaign cleared — the meta-mystery resolves
195
+ world_state.campaign_update(wid, status="finale", chapter_index=next_index, crisis_id=None)
196
+ return {"outcome": "finale", "dispatch": dispatch, "outro": outro, "grade": grade,
197
+ "reveal": camp.finale_reveal, "progress": prog, **view(world)}
198
+
199
+ world_state.campaign_update(wid, chapter_index=next_index)
200
+ _inject(world, camp.chapters[next_index])
201
+ return {"outcome": outcome, "dispatch": dispatch, "outro": outro, "grade": grade,
202
+ "progress": prog, **view(world)}
203
+
204
+
205
+ def _grade(round_no, chaos, won):
206
+ if not won:
207
+ return "F"
208
+ if round_no == 1:
209
+ base = "A"
210
+ elif round_no == 2:
211
+ base = "B"
212
+ elif round_no == 3:
213
+ base = "C"
214
+ else:
215
+ base = "D"
216
+ # a messy scene (high chaos) knocks the grade down one notch
217
+ if chaos >= 0.6 and base in ("A", "B", "C"):
218
+ base = {"A": "B", "B": "C", "C": "D"}[base]
219
+ return base
220
+
221
+
222
+ # --------------------------------------------------------------------------- snapshot
223
+ def view(world):
224
+ """A serializable snapshot of campaign + active crisis for the UI/HUD."""
225
+ wid = world["id"]
226
+ camp = get_campaign_def(world)
227
+ state = world_state.get_campaign(wid)
228
+ cr = current_crisis(world)
229
+ chapter = current_chapter(world)
230
+
231
+ crisis_view = None
232
+ if cr:
233
+ crisis_view = {
234
+ "id": cr.id,
235
+ "title": cr.title,
236
+ "kind": cr.kind,
237
+ "affected_hotspot": cr.affected_hotspot,
238
+ "severity": cr.severity,
239
+ "time_limit": cr.time_limit,
240
+ "round": state["crisis_round"],
241
+ "focus": cr.focus,
242
+ "requirements": [
243
+ {"id": r.id, "label": r.label, "met": r.id in state["crisis_met"],
244
+ "optional": r.optional}
245
+ for r in cr.requirements
246
+ ],
247
+ }
248
+
249
+ return {
250
+ "active": state["active"],
251
+ "status": state["status"],
252
+ "title": camp.title if camp else "",
253
+ "chapter_index": state["chapter_index"],
254
+ "chapter_count": len(camp.chapters) if camp else 0,
255
+ "chapter_intro": chapter.intro if chapter else "",
256
+ "town_resilience": state["town_resilience"],
257
+ "chaos": round(world_state.get_chaos(wid), 3),
258
+ "clues": state["clues"],
259
+ "chapter_grades": state["chapter_grades"],
260
+ "mvp_tally": state["mvp_tally"],
261
+ "last_dispatch": state["last_dispatch"],
262
+ "crisis": crisis_view,
263
+ }
crisis.py ADDED
@@ -0,0 +1,208 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """TinyWorld Crisis Mode — deterministic crisis resolver.
2
+
3
+ A *crisis* is a structured emergency injected into the town. Each crisis declares a
4
+ set of *requirements* — concrete responses (be at a place, take a kind of action,
5
+ maybe be the right role) that together constitute "handling it". After the five
6
+ residents react (via ``agents.react``), :func:`evaluate` checks their structured
7
+ output against the requirements. This is the deterministic source of truth — the
8
+ LLM only *speaks*; the engine decides whether the crisis is solved.
9
+
10
+ Design mirrors the rest of the codebase: pure logic, no ``gradio`` import, fully
11
+ testable offline with synthetic reaction dicts.
12
+ """
13
+
14
+ from collections import defaultdict
15
+ from dataclasses import dataclass, field
16
+
17
+ import world_state
18
+
19
+
20
+ # --------------------------------------------------------------------------- model
21
+ @dataclass
22
+ class Requirement:
23
+ id: str # "containment" | "medical" | "coordination" ...
24
+ label: str # HUD text, e.g. "🔥 Contain the fire"
25
+ hotspots: list = field(default_factory=list) # any-of acceptable goto targets
26
+ action_keywords: list = field(default_factory=list) # any-of words in action/text
27
+ role: str = None # if set, only this character role can satisfy it
28
+ weight: float = 1.0
29
+ optional: bool = False # optional reqs add progress but aren't required to win
30
+
31
+
32
+ @dataclass
33
+ class Crisis:
34
+ id: str
35
+ title: str
36
+ narrative: str # event text injected to agents (round 1)
37
+ severity: int = 3 # 1-5, drives chaos accrual per unresolved round
38
+ focus: str = "" # teaching question (reused by the explainer)
39
+ requirements: list = field(default_factory=list)
40
+ time_limit: int = 4 # max rounds before failure
41
+ escalation: str = "" # appended to the event each unresolved round
42
+ success_text: str = "The town pulled together and handled it."
43
+ failure_text: str = "It got away from them this time."
44
+ affected_hotspot: str = None # where the crisis visually manifests on the map
45
+ kind: str = "fire" # visual effect hint for the canvas (fire/flood/storm/blackout)
46
+
47
+
48
+ @dataclass
49
+ class CrisisProgress:
50
+ crisis_id: str
51
+ met: set = field(default_factory=set) # requirement ids met cumulatively
52
+ newly_met: set = field(default_factory=set) # ids first satisfied this round
53
+ unmet: list = field(default_factory=list) # Requirement objects still open
54
+ contributions: dict = field(default_factory=dict) # name -> [req_id] satisfied this round
55
+ progress: float = 0.0
56
+ resolved: bool = False
57
+ mvp: str = None # resident who satisfied the most reqs
58
+
59
+
60
+ # --------------------------------------------------------------------------- loading
61
+ def load_crisis(data):
62
+ """Build a :class:`Crisis` from a plain dict (world files stay data-only)."""
63
+ reqs = [Requirement(**r) for r in data.get("requirements", [])]
64
+ fields = {k: v for k, v in data.items() if k != "requirements"}
65
+ return Crisis(requirements=reqs, **fields)
66
+
67
+
68
+ def get_crisis(world, crisis_id):
69
+ for c in world.get("crises", []):
70
+ if c["id"] == crisis_id:
71
+ return load_crisis(c)
72
+ return None
73
+
74
+
75
+ # --------------------------------------------------------------------------- resolver
76
+ def _char_for(world, name):
77
+ return next((c for c in world.get("cast", []) if c["name"] == name), None)
78
+
79
+
80
+ def _norm(s):
81
+ """Lowercase + de-accent so 'café' matches the 'cafe' hotspot key."""
82
+ return (s or "").lower().replace("é", "e").replace("è", "e").replace("ê", "e")
83
+
84
+
85
+ def _mentions(hotspot, blob):
86
+ """The resident named the place in their own words. Use the full phrase or the
87
+ place-type token (clinic/office/home/park…) — never the person-name token, so
88
+ saying 'Priya' doesn't count as being at priya_office."""
89
+ if hotspot.replace("_", " ") in blob:
90
+ return True
91
+ place = hotspot.split("_")[-1]
92
+ return len(place) > 3 and place in blob
93
+
94
+
95
+ def _satisfies(req, reaction, character):
96
+ """True when one reaction fulfils one requirement.
97
+
98
+ Each declared condition (location / keywords / role) must hold; an empty
99
+ condition is a wildcard. The small dialogue model often picks the wrong
100
+ structured ``goto`` while clearly stating the right place in its action/text,
101
+ so a location counts if it's the chosen ``goto`` OR named in the words.
102
+ """
103
+ if reaction.get("error"):
104
+ return False
105
+
106
+ blob = _norm(f"{reaction.get('action', '')} {reaction.get('text', '')}")
107
+
108
+ if req.hotspots:
109
+ location_ok = (reaction.get("moved_to") in req.hotspots
110
+ or any(_mentions(h, blob) for h in req.hotspots))
111
+ if not location_ok:
112
+ return False
113
+
114
+ if req.action_keywords:
115
+ if not any(kw.lower() in blob for kw in req.action_keywords):
116
+ return False
117
+
118
+ if req.role:
119
+ if character is None or world_state.character_role(character) != req.role:
120
+ return False
121
+
122
+ return True
123
+
124
+
125
+ def evaluate(crisis, reactions, world, prior_met=None):
126
+ """Score a round of reactions against a crisis. Met requirements are sticky."""
127
+ prior_met = set(prior_met or set())
128
+ contributions = defaultdict(list)
129
+ newly_met = set()
130
+
131
+ for req in crisis.requirements:
132
+ if req.id in prior_met:
133
+ continue
134
+ for r in reactions:
135
+ char = _char_for(world, r.get("name"))
136
+ if _satisfies(req, r, char):
137
+ newly_met.add(req.id)
138
+ contributions[r["name"]].append(req.id)
139
+ break # first responder satisfies it
140
+
141
+ met = prior_met | newly_met
142
+
143
+ total_weight = sum(req.weight for req in crisis.requirements) or 1.0
144
+ met_weight = sum(req.weight for req in crisis.requirements if req.id in met)
145
+ progress = round(met_weight / total_weight, 4)
146
+
147
+ required = [req for req in crisis.requirements if not req.optional]
148
+ resolved = all(req.id in met for req in required)
149
+ unmet = [req for req in crisis.requirements if req.id not in met]
150
+
151
+ mvp = None
152
+ if contributions:
153
+ mvp = max(contributions, key=lambda n: (len(contributions[n]), n))
154
+
155
+ return CrisisProgress(
156
+ crisis_id=crisis.id,
157
+ met=met,
158
+ newly_met=newly_met,
159
+ unmet=unmet,
160
+ contributions=dict(contributions),
161
+ progress=progress,
162
+ resolved=resolved,
163
+ mvp=mvp,
164
+ )
165
+
166
+
167
+ # --------------------------------------------------------------------------- round event
168
+ def build_event_text(crisis, prior_progress=None):
169
+ """The event string fed to agents for a round. Subsequent rounds carry feedback
170
+ about what is still unhandled, which is what drives multi-round coordination."""
171
+ if prior_progress is None:
172
+ return crisis.narrative
173
+ parts = [crisis.narrative]
174
+ if crisis.escalation:
175
+ parts.append(crisis.escalation)
176
+ if prior_progress.unmet:
177
+ labels = ", ".join(_plain(req.label) for req in prior_progress.unmet)
178
+ parts.append(f"Still unresolved: {labels}.")
179
+ return " ".join(parts)
180
+
181
+
182
+ # --------------------------------------------------------------------------- narration
183
+ def narrate(crisis, progress, round_no=1, failed=False):
184
+ """A short 'Dispatch' verdict. Deterministic — used directly in MOCK mode and as
185
+ the offline fallback. The optional live-LLM flourish wraps this (see app)."""
186
+ if failed:
187
+ return f"📟 Dispatch — {crisis.title}: time ran out. {crisis.failure_text}"
188
+ if progress.resolved:
189
+ return f"📟 Dispatch — {crisis.title} contained. {crisis.success_text}"
190
+ if progress.newly_met:
191
+ done = ", ".join(_plain(_label(crisis, rid)) for rid in sorted(progress.newly_met))
192
+ open_ = ", ".join(_plain(req.label) for req in progress.unmet)
193
+ return f"📟 Dispatch — round {round_no}: {done} handled. Still open: {open_}."
194
+ open_ = ", ".join(_plain(req.label) for req in progress.unmet)
195
+ tail = f" {crisis.escalation}" if crisis.escalation else ""
196
+ return f"📟 Dispatch — round {round_no}: no progress. {open_} still unhandled.{tail}"
197
+
198
+
199
+ def _label(crisis, req_id):
200
+ return next((r.label for r in crisis.requirements if r.id == req_id), req_id)
201
+
202
+
203
+ def _plain(label):
204
+ """Strip a leading emoji so labels read cleanly mid-sentence."""
205
+ parts = label.split(" ", 1)
206
+ if len(parts) == 2 and not parts[0].isascii():
207
+ return parts[1]
208
+ return label
decide.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import random
4
+ import re
5
+ import time
6
+ from dataclasses import dataclass, field
7
+
8
+
9
+ MOODS = [
10
+ "happy", "stressed", "bored", "excited", "hungry",
11
+ "tired", "nostalgic", "curious", "proud", "embarrassed",
12
+ ]
13
+
14
+ SURPRISE_SEEDS = [
15
+ "Slip in one oddly specific detail from earlier today.",
16
+ "React as if this event confirms a private theory you never told anyone.",
17
+ "Let a petty personal concern steer part of your response.",
18
+ "Compare the event to a memory that still annoys you.",
19
+ "Say one thing confidently, then soften or complicate it.",
20
+ "Ask one pointed question that reveals your bias.",
21
+ "Treat one small part of the event as the real emergency.",
22
+ "Reveal a secret wish connected to the situation.",
23
+ "Interpret the event through your relationship with someone nearby.",
24
+ "End with a surprising offer, warning, or demand.",
25
+ ]
26
+
27
+ MODAL_REACT_URL = os.environ.get(
28
+ "MODAL_REACT_URL",
29
+ "https://mitvho09--tinyworld-inference-react-endpoint.modal.run",
30
+ )
31
+
32
+
33
+ @dataclass
34
+ class Decision:
35
+ think: str
36
+ say: str
37
+ action: str
38
+ goto: str
39
+ mood: str
40
+ need_deltas: dict = field(default_factory=dict)
41
+ degraded: bool = False
42
+ error: bool = False
43
+ warning: str = ""
44
+ raw: str = ""
45
+ latency: float | None = None
46
+
47
+
48
+ def allowed_hotspots(world):
49
+ return list(((world.get("board", {}) or {}).get("hotspots_tile") or {}).keys())
50
+
51
+
52
+ def current_activity(character, world, position):
53
+ activities = (world.get("board", {}) or {}).get("activities", {})
54
+ if character["name"] in activities:
55
+ return activities[character["name"]].get("label", "going about their day")
56
+ if position:
57
+ return f"near {position.replace('_', ' ')}"
58
+ return "going about their day"
59
+
60
+
61
+ def build_decision_prompt(character, event, mood, memory, relationships, world, position=None, needs=None, command=None):
62
+ first = character["name"].split()[0]
63
+ hotspots = allowed_hotspots(world)
64
+ needs = needs or {"energy": 50, "hunger": 50, "social": 50}
65
+ activity = current_activity(character, world, position)
66
+ memory_text = "; ".join(memory[-4:]) if memory else "nothing recent"
67
+ seed = random.choice(SURPRISE_SEEDS)
68
+
69
+ command_text = ""
70
+ if command and command.get("type") == "directed_command":
71
+ command_text = (
72
+ "\nDirected command:\n"
73
+ f"- Addressee: {character['name']}\n"
74
+ f"- Instruction to obey unless strongly out of character: {command.get('instruction', event)}\n"
75
+ f"- Required destination if relevant: {command.get('goto') or 'none'}\n"
76
+ "If you refuse, explain why in the say field and use goto=stay.\n"
77
+ )
78
+
79
+ return f"""You are controlling one TinyWorld character.
80
+
81
+ Persona:
82
+ - Name: {character['name']}
83
+ - Age/job: {character['age']}, {character['job']}
84
+ - Traits: {', '.join(character.get('traits', []))}
85
+ - Backstory: {character.get('backstory', '')}
86
+ - Relationships: {relationships}
87
+
88
+ Current state:
89
+ - Mood before deciding: {mood}
90
+ - Current place: {position or character.get('home', 'unknown')}
91
+ - Current activity: {activity}
92
+ - Needs: energy={needs.get('energy', 50)}, hunger={needs.get('hunger', 50)}, social={needs.get('social', 50)}
93
+ - Recent memory: {memory_text}
94
+
95
+ Allowed goto values: {', '.join(hotspots)}, stay
96
+ Triggering input: {event}
97
+ {command_text}
98
+ Surprise instruction: {seed}
99
+
100
+ Return ONLY strict JSON with exactly these keys:
101
+ {{
102
+ "think": "one short private thought, max 120 chars",
103
+ "say": "what {first} says aloud, 1-2 short sentences, in-character",
104
+ "action": "a concrete verb phrase describing what {first} does",
105
+ "goto": "one allowed goto value or stay",
106
+ "mood": "one of: {', '.join(MOODS)}",
107
+ "need_deltas": {{"energy": 0, "hunger": 0, "social": 0}}
108
+ }}
109
+
110
+ No markdown, no commentary, no labels outside the JSON object."""
111
+
112
+
113
+ def decide_real(character, event, mood, memory, relationships, world, position=None, needs=None, command=None):
114
+ prompt = build_decision_prompt(character, event, mood, memory, relationships, world, position, needs, command)
115
+ raw, latency = _call_modal(character, event, prompt)
116
+ decision = parse_decision(raw, world, previous_mood=mood)
117
+ decision.latency = latency
118
+ if decision.degraded:
119
+ retry_prompt = prompt + "\n\nYour previous response was invalid. Return ONLY the JSON object."
120
+ raw_retry, retry_latency = _call_modal(character, event, retry_prompt, timeout=45.0)
121
+ decision = parse_decision(raw_retry, world, previous_mood=mood)
122
+ decision.latency = retry_latency
123
+ return decision
124
+
125
+
126
+ def mock_decision(character, event, mood, world, command=None):
127
+ hotspots = allowed_hotspots(world)
128
+ goto = command.get("goto") if command and command.get("goto") in hotspots else _event_goto(event, hotspots)
129
+ chosen_mood = random.choice(MOODS)
130
+ first = character["name"].split()[0]
131
+ if command and command.get("type") == "directed_command":
132
+ instruction = command.get("instruction") or event
133
+ action = f"follow the instruction: {instruction}"
134
+ else:
135
+ action = _mock_action(character, goto)
136
+ templates = [
137
+ f"{first} says this feels {chosen_mood}, but I'm going to {action}.",
138
+ f"I don't love surprises like this. I'll {action} and see what is really going on.",
139
+ f"This is exactly the sort of thing my day was missing. I'm going to {action}.",
140
+ ]
141
+ return Decision(
142
+ think=f"{first} connects the event to their role.",
143
+ say=random.choice(templates),
144
+ action=action,
145
+ goto=goto,
146
+ mood=chosen_mood,
147
+ need_deltas={
148
+ "energy": random.randint(-8, 4),
149
+ "hunger": random.randint(-3, 6),
150
+ "social": random.randint(-4, 8),
151
+ },
152
+ )
153
+
154
+
155
+ def parse_decision(raw, world, previous_mood="curious"):
156
+ data = _extract_json(raw)
157
+ if data is None:
158
+ return _safe_decision(previous_mood, "malformed JSON", raw)
159
+
160
+ hotspots = allowed_hotspots(world)
161
+ think = _limit(_clean_text(data.get("think", "")), 120)
162
+ say = _limit(_clean_dialogue(data.get("say", "")), 220)
163
+ action = _limit(_clean_text(data.get("action", "")), 160)
164
+ goto = _clean_goto(data.get("goto", "stay"), hotspots)
165
+ mood = data.get("mood", previous_mood)
166
+ if mood not in MOODS:
167
+ mood = previous_mood if previous_mood in MOODS else "curious"
168
+
169
+ deltas = data.get("need_deltas", {})
170
+ if not isinstance(deltas, dict):
171
+ deltas = {}
172
+ need_deltas = {
173
+ "energy": _clamp_int(deltas.get("energy", 0), -25, 25),
174
+ "hunger": _clamp_int(deltas.get("hunger", 0), -25, 25),
175
+ "social": _clamp_int(deltas.get("social", 0), -25, 25),
176
+ }
177
+
178
+ if not say:
179
+ return _safe_decision(mood, "empty dialogue after cleaning", raw)
180
+ if not action:
181
+ action = "stay alert and watch what happens next"
182
+ return Decision(think, say, action, goto, mood, need_deltas, raw=raw)
183
+
184
+
185
+ def _call_modal(character, event, prompt, timeout=180.0):
186
+ import httpx
187
+
188
+ payload = {
189
+ "event": event,
190
+ "characters": [character],
191
+ "prompts": {character["name"]: prompt},
192
+ }
193
+ started = time.time()
194
+ with httpx.Client(timeout=timeout, follow_redirects=True) as client:
195
+ response = client.post(MODAL_REACT_URL, json=payload)
196
+ response.raise_for_status()
197
+ data = response.json()
198
+ reactions = data.get("reactions", [])
199
+ if not reactions or reactions[0].get("error"):
200
+ message = reactions[0].get("text", "model unreachable") if reactions else "model unreachable"
201
+ raise RuntimeError(message)
202
+ return reactions[0].get("text", ""), round(time.time() - started, 2)
203
+
204
+
205
+ def _extract_json(raw):
206
+ raw = (raw or "").strip()
207
+ try:
208
+ return json.loads(raw)
209
+ except json.JSONDecodeError:
210
+ match = re.search(r"\{.*\}", raw, re.DOTALL)
211
+ if not match:
212
+ return None
213
+ try:
214
+ return json.loads(match.group(0))
215
+ except json.JSONDecodeError:
216
+ return None
217
+
218
+
219
+ def _clean_goto(value, hotspots):
220
+ goto = str(value or "stay").strip().lower().replace(" ", "_")
221
+ goto = re.sub(r"[^a-z0-9_]", "", goto)
222
+ if goto == "stay":
223
+ return "stay"
224
+ return goto if goto in hotspots else "stay"
225
+
226
+
227
+ def _safe_decision(mood, warning, raw):
228
+ return Decision(
229
+ think="The model output could not be used.",
230
+ say="I need a second before I can make sense of that.",
231
+ action="stay put and reassess",
232
+ goto="stay",
233
+ mood=mood if mood in MOODS else "curious",
234
+ need_deltas={"energy": 0, "hunger": 0, "social": 0},
235
+ degraded=True,
236
+ warning=warning,
237
+ raw=raw or "",
238
+ )
239
+
240
+
241
+ def _clean_text(value):
242
+ text = re.sub(r"[\x00-\x1f\x7f]", " ", str(value or ""))
243
+ return re.sub(r"\s+", " ", text).strip()
244
+
245
+
246
+ def _clean_dialogue(value):
247
+ text = _clean_text(value)
248
+ text = re.sub(r"\[[^\]]*\]", "", text)
249
+ text = re.sub(r"\*[^*]*\*", "", text)
250
+ text = re.sub(r"^(say|do|think|goto)\s*:\s*", "", text, flags=re.IGNORECASE)
251
+ meta = re.compile(r"(as an ai|the prompt|return only json|1-2 sentences|in character)", re.IGNORECASE)
252
+ parts = [p.strip(" \"'") for p in re.split(r"(?<=[.!?])\s+", text) if p.strip()]
253
+ keep = [p for p in parts if not meta.search(p)]
254
+ return " ".join(keep[:2]).strip()
255
+
256
+
257
+ def _limit(text, max_chars):
258
+ text = _clean_text(text)
259
+ return text[:max_chars].rstrip()
260
+
261
+
262
+ def _clamp_int(value, low, high):
263
+ try:
264
+ value = int(value)
265
+ except (TypeError, ValueError):
266
+ value = 0
267
+ return max(low, min(high, value))
268
+
269
+
270
+ def _event_goto(event, hotspots):
271
+ e = event.lower()
272
+ rules = [
273
+ (("clinic", "hurt", "injur", "sick", "emergency", "storm"), "nia_clinic"),
274
+ (("school", "student", "class", "teacher"), "school"),
275
+ (("cafe", "café", "coffee", "food", "hungry"), "cafe"),
276
+ (("park", "tree", "glow", "object", "cat", "mural"), "park"),
277
+ (("office", "city", "budget", "permit", "power"), "priya_office"),
278
+ (("home", "rest"), "marta_home"),
279
+ ]
280
+ for words, goto in rules:
281
+ if any(word in e for word in words) and goto in hotspots:
282
+ return goto
283
+ return "square" if "square" in hotspots else "stay"
284
+
285
+
286
+ def _mock_action(character, goto):
287
+ place = goto.replace("_", " ")
288
+ role = character.get("job", "neighbor")
289
+ return random.choice([
290
+ f"go to {place} and check the situation",
291
+ f"use my {role} instincts at {place}",
292
+ f"head for {place} before rumors outrun the facts",
293
+ ])
events.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+
3
+ CHAOS_EVENTS = [
4
+ "A food truck rolls in offering free pizza to everyone on the block.",
5
+ "A mysterious glowing object lands in the park.",
6
+ "The mayor announces all shops on this street must close by Friday.",
7
+ "Someone on the block wins the lottery and starts spending lavishly.",
8
+ "A local resident goes viral on social media for a ridiculous dance.",
9
+ "The water supply is cut off for 24 hours — no one knows why.",
10
+ "A celebrity is spotted moving into the apartment on 3rd floor.",
11
+ "City Hall passes a new law: everyone must work from home starting tomorrow.",
12
+ "A free outdoor concert is announced for tonight in the parking lot.",
13
+ "A strange smell drifts from the abandoned building on the corner.",
14
+ "Forecast says it will snow two inches in the next two hours — in June.",
15
+ "A mysterious new neighbor arrives with no name tag and a locked suitcase.",
16
+ ]
17
+
18
+
19
+ def random_event() -> str:
20
+ return random.choice(CHAOS_EVENTS)
inference.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-process GPU inference for Hugging Face Spaces ZeroGPU.
2
+
3
+ Mirrors modal_app.py, but instead of calling Modal over HTTP the models run
4
+ locally inside the Space behind @spaces.GPU (which allocates a ZeroGPU for the
5
+ duration of each call). Selected by TINYWORLD_INFER=local.
6
+
7
+ Imported LAZILY (only when the local backend is active) because it pulls in
8
+ torch / transformers / voxcpm / whisper — heavy deps the offline test suite and
9
+ the Modal-backed path never need. Models are loaded on CPU once and moved to CUDA
10
+ inside the GPU-decorated functions (CUDA is only available there on ZeroGPU).
11
+ """
12
+
13
+ import os
14
+ import re
15
+ import tempfile
16
+
17
+ import spaces
18
+ import torch
19
+
20
+ LLM_MODEL_ID = os.environ.get("TINYWORLD_LLM", "nvidia/Nemotron-Mini-4B-Instruct")
21
+ VOICE_MODEL_ID = os.environ.get("TINYWORLD_VOICE_MODEL", "openbmb/VoxCPM2")
22
+ WHISPER_SIZE = os.environ.get("TINYWORLD_WHISPER", "base")
23
+
24
+ _llm = None
25
+ _tok = None
26
+ _voice = None
27
+ _whisper = None
28
+
29
+
30
+ # --------------------------------------------------------------------------- LLM
31
+ def _load_llm():
32
+ global _llm, _tok
33
+ if _llm is None:
34
+ from transformers import AutoModelForCausalLM, AutoTokenizer
35
+ print(f"[inference] loading {LLM_MODEL_ID} …")
36
+ _tok = AutoTokenizer.from_pretrained(LLM_MODEL_ID, trust_remote_code=True)
37
+ _llm = AutoModelForCausalLM.from_pretrained(
38
+ LLM_MODEL_ID, torch_dtype=torch.bfloat16, trust_remote_code=True,
39
+ )
40
+ return _llm, _tok
41
+
42
+
43
+ def _strip_think(text):
44
+ text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
45
+ if "<think>" in text:
46
+ parts = text.split("</think>")
47
+ text = parts[-1].strip() if len(parts) > 1 else text
48
+ return text.strip()
49
+
50
+
51
+ @spaces.GPU(duration=120)
52
+ def generate_batch(prompts):
53
+ """One raw completion per prompt, all in a single GPU allocation. Returns a
54
+ list of raw strings aligned with ``prompts`` (the reaction engine parses them)."""
55
+ mdl, tok = _load_llm()
56
+ mdl.to("cuda")
57
+ outputs = []
58
+ for prompt in prompts:
59
+ messages = [{"role": "user", "content": prompt}]
60
+ input_text = tok.apply_chat_template(messages, add_generation_prompt=True, tokenize=False)
61
+ inputs = tok(input_text, return_tensors="pt").to("cuda")
62
+ with torch.no_grad():
63
+ out = mdl.generate(
64
+ **inputs, max_new_tokens=400, do_sample=True, temperature=0.8, top_p=0.9,
65
+ )
66
+ new = out[0][inputs["input_ids"].shape[1]:]
67
+ text = tok.decode(new, skip_special_tokens=True).strip()
68
+ outputs.append(_strip_think(text))
69
+ return outputs
70
+
71
+
72
+ # --------------------------------------------------------------------------- TTS
73
+ def _load_voice():
74
+ global _voice
75
+ if _voice is None:
76
+ from voxcpm import VoxCPM
77
+ print(f"[inference] loading {VOICE_MODEL_ID} …")
78
+ _voice = VoxCPM.from_pretrained(VOICE_MODEL_ID)
79
+ return _voice
80
+
81
+
82
+ @spaces.GPU(duration=60)
83
+ def synthesize_voice(text, voice_desc):
84
+ """Returns a path to a WAV file (matches voice.generate_voice's contract)."""
85
+ import soundfile as sf
86
+ model = _load_voice()
87
+ wav = model.generate(text=f"{voice_desc}{text}", cfg_value=2.0, inference_timesteps=10)
88
+ path = os.path.join(tempfile.gettempdir(), f"tinyworld_voice_{os.getpid()}.wav")
89
+ sf.write(path, wav, model.tts_model.sample_rate)
90
+ return path
91
+
92
+
93
+ # --------------------------------------------------------------------------- ASR
94
+ def _load_whisper():
95
+ global _whisper
96
+ if _whisper is None:
97
+ import whisper
98
+ print(f"[inference] loading Whisper {WHISPER_SIZE} …")
99
+ _whisper = whisper.load_model(WHISPER_SIZE)
100
+ return _whisper
101
+
102
+
103
+ @spaces.GPU(duration=60)
104
+ def transcribe_audio(audio_path):
105
+ model = _load_whisper()
106
+ result = model.transcribe(audio_path, fp16=True)
107
+ return (result.get("text") or "").strip()
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # TinyWorld Space runtime for ZeroGPU (in-process inference via inference.py).
2
+ # Use this as requirements.txt ONLY on a PRO account with ZeroGPU hardware, and set
3
+ # the Space variable TINYWORLD_INFER=local. Also keep packages.txt (ffmpeg) for Whisper.
4
+ gradio>=6.18.0
5
+ httpx
6
+ numpy
7
+ soundfile
8
+ spaces
9
+ torch
10
+ transformers
11
+ accelerate
12
+ sentencepiece
13
+ voxcpm
14
+ openai-whisper
15
+ cohere
router.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+
4
+ COMMAND_VERBS = {
5
+ "go", "head", "walk", "run", "visit", "check", "rest", "help",
6
+ "tell", "make", "ask", "send", "move", "bring", "look",
7
+ }
8
+
9
+ BASE_LOCATION_ALIASES = {
10
+ "clinic": "nia_clinic",
11
+ "hospital": "nia_clinic",
12
+ "nurse": "nia_clinic",
13
+ "cafe": "cafe",
14
+ "café": "cafe",
15
+ "coffee": "cafe",
16
+ "school": "school",
17
+ "class": "school",
18
+ "park": "park",
19
+ "fountain": "fountain",
20
+ "square": "square",
21
+ "plaza": "square",
22
+ "office": "priya_office",
23
+ "city hall": "priya_office",
24
+ "shop": "shop",
25
+ "store": "shop",
26
+ }
27
+
28
+
29
+ def classify(text, world):
30
+ raw = (text or "").strip()
31
+ if not raw:
32
+ return {"type": "noop", "text": raw, "addressees": [], "instruction": "", "goto": None}
33
+ if not re.search(r"[A-Za-z0-9]", raw):
34
+ return {"type": "ambient", "text": raw, "addressees": [], "instruction": raw, "goto": None}
35
+
36
+ cast = world.get("cast", [])
37
+ names = _matched_names(raw, cast)
38
+ lowered = raw.lower()
39
+ looks_command = bool(names) and (
40
+ _starts_with_name_command(lowered, names)
41
+ or lowered.startswith("tell ")
42
+ or lowered.startswith("make ")
43
+ or lowered.startswith("ask ")
44
+ or any(re.search(rf"\b{verb}\b", lowered) for verb in COMMAND_VERBS)
45
+ )
46
+ if looks_command:
47
+ instruction = _strip_command_prefix(raw, names)
48
+ goto = resolve_location(instruction, world, cast, names)
49
+ return {
50
+ "type": "directed_command",
51
+ "text": raw,
52
+ "addressees": names,
53
+ "instruction": instruction,
54
+ "goto": goto,
55
+ }
56
+
57
+ return {"type": "world_event", "text": raw, "addressees": [], "instruction": raw, "goto": None}
58
+
59
+
60
+ def resolve_location(text, world, cast=None, addressees=None):
61
+ lowered = (text or "").lower()
62
+ hotspots = ((world.get("board", {}) or {}).get("hotspots_tile") or {})
63
+
64
+ if "home" in lowered and cast and addressees and len(addressees) == 1:
65
+ char = next((c for c in cast if c["name"] == addressees[0]), None)
66
+ home = char.get("home") if char else None
67
+ if home in hotspots:
68
+ return home
69
+
70
+ aliases = dict(BASE_LOCATION_ALIASES)
71
+ if "nurse_office" in hotspots:
72
+ aliases["clinic"] = "nurse_office"
73
+ aliases["nurse"] = "nurse_office"
74
+ aliases["nurse office"] = "nurse_office"
75
+ if "quad" in hotspots:
76
+ aliases["quad"] = "quad"
77
+ aliases["campus"] = "quad"
78
+ if "riverside_walk" in hotspots:
79
+ aliases["river"] = "riverside_walk"
80
+ aliases["riverside"] = "riverside_walk"
81
+ for key in hotspots:
82
+ aliases[key] = key
83
+ aliases[key.replace("_", " ")] = key
84
+ aliases[key.replace("_", "")] = key
85
+
86
+ for phrase, hotspot in sorted(aliases.items(), key=lambda item: len(item[0]), reverse=True):
87
+ if phrase in lowered and hotspot in hotspots:
88
+ return hotspot
89
+ return None
90
+
91
+
92
+ def _matched_names(text, cast):
93
+ lowered = text.lower()
94
+ matches = []
95
+ for char in cast:
96
+ first = char["name"].split()[0].lower()
97
+ if re.search(rf"\b{re.escape(first)}\b", lowered):
98
+ matches.append(char["name"])
99
+ return matches
100
+
101
+
102
+ def _starts_with_name_command(lowered, names):
103
+ first_names = [name.split()[0].lower() for name in names]
104
+ for first in first_names:
105
+ if re.match(rf"^\s*{re.escape(first)}\s*[,:\-]\s*", lowered):
106
+ return True
107
+ return False
108
+
109
+
110
+ def _strip_command_prefix(text, names):
111
+ instruction = text.strip()
112
+ first_names = "|".join(re.escape(name.split()[0]) for name in names)
113
+ patterns = [
114
+ rf"^\s*(?:tell|ask|make|send)\s+(?:{first_names})\s+(?:to\s+)?",
115
+ rf"^\s*(?:{first_names})\s*[,:\-]\s*",
116
+ ]
117
+ for pattern in patterns:
118
+ instruction = re.sub(pattern, "", instruction, flags=re.IGNORECASE).strip()
119
+ return instruction or text.strip()
transcribe.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+
4
+ MODAL_TRANSCRIBE_URL = os.environ.get(
5
+ "MODAL_TRANSCRIBE_URL",
6
+ "https://mitvho09--tinyworld-inference-transcribe-endpoint.modal.run",
7
+ )
8
+
9
+ MOCK_SENTENCES = [
10
+ "A street parade just showed up out of nowhere.",
11
+ "Someone left a mysterious package on the sidewalk.",
12
+ "The neighbors are arguing about something loud again.",
13
+ "There's a strange light coming from the old building.",
14
+ "I just saw something really weird down the block.",
15
+ ]
16
+
17
+
18
+ def _is_mock() -> bool:
19
+ return os.environ.get("TINYWORLD_MOCK", "0") == "1"
20
+
21
+
22
+ def _backend() -> str:
23
+ return os.environ.get("TINYWORLD_INFER", "modal").lower()
24
+
25
+
26
+ def transcribe(audio_path: str | None) -> str:
27
+ if not audio_path or _is_mock():
28
+ import random
29
+ return random.choice(MOCK_SENTENCES)
30
+
31
+ if _backend() == "local":
32
+ try:
33
+ import inference # ZeroGPU Whisper, imported lazily
34
+ text = inference.transcribe_audio(audio_path)
35
+ if text:
36
+ return text
37
+ except Exception as e:
38
+ print(f"[transcribe] local failed: {e}")
39
+
40
+ if MODAL_TRANSCRIBE_URL:
41
+ try:
42
+ text = _modal_transcribe(audio_path)
43
+ if text:
44
+ return text
45
+ except Exception as e:
46
+ print(f"[transcribe] Modal failed: {e}")
47
+
48
+ try:
49
+ return _cohere_transcribe(audio_path)
50
+ except Exception as e:
51
+ print(f"[transcribe] failed: {e}")
52
+ return ""
53
+
54
+
55
+ def _modal_transcribe(audio_path: str) -> str:
56
+ import httpx
57
+
58
+ path = Path(audio_path)
59
+ headers = {"x-audio-suffix": path.suffix or ".wav"}
60
+ with path.open("rb") as f:
61
+ audio_bytes = f.read()
62
+
63
+ with httpx.Client(timeout=300.0, follow_redirects=True) as client:
64
+ response = client.post(
65
+ MODAL_TRANSCRIBE_URL,
66
+ content=audio_bytes,
67
+ headers=headers,
68
+ )
69
+ response.raise_for_status()
70
+
71
+ data = response.json()
72
+ return (data.get("text") or "").strip()
73
+
74
+
75
+ def _cohere_transcribe(audio_path: str) -> str:
76
+ api_key = os.environ.get("COHERE_API_KEY", "")
77
+ if not api_key:
78
+ print("[transcribe] no COHERE_API_KEY set")
79
+ return ""
80
+
81
+ import cohere
82
+
83
+ co = cohere.ClientV2(api_key=api_key)
84
+
85
+ with open(audio_path, "rb") as f:
86
+ response = co.audio.transcriptions.create(
87
+ model="cohere-transcribe-03-2026",
88
+ language="en",
89
+ file=f,
90
+ )
91
+
92
+ return response.text.strip() if response.text else ""
93
+
94
+
95
+ if __name__ == "__main__":
96
+ print("Mock transcribe:", transcribe(None))
voice.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+ import numpy as np
4
+
5
+ MODAL_VOICE_URL = os.environ.get(
6
+ "MODAL_VOICE_URL",
7
+ "https://mitvho09--tinyworld-inference-voice-endpoint.modal.run",
8
+ )
9
+ VOICE_TIMEOUT = float(os.environ.get("TINYWORLD_VOICE_TIMEOUT", "25"))
10
+
11
+
12
+ def _is_mock() -> bool:
13
+ return os.environ.get("TINYWORLD_MOCK", "0") == "1"
14
+
15
+
16
+ def _backend() -> str:
17
+ return os.environ.get("TINYWORLD_INFER", "modal").lower()
18
+
19
+
20
+ def build_voice_description(character) -> str:
21
+ return character.get("voice_description", "(a neutral voice)")
22
+
23
+
24
+ def generate_voice(text: str, voice_desc: str) -> str:
25
+ try:
26
+ if _is_mock():
27
+ return _mock_generate(text)
28
+ if _backend() == "local":
29
+ import inference # ZeroGPU VoxCPM2, imported lazily
30
+ return inference.synthesize_voice(text, voice_desc)
31
+ return _real_generate(text, voice_desc)
32
+ except Exception as e:
33
+ print(f"[voice] generation failed: {e}")
34
+ return _mock_generate(text) if _is_mock() else None
35
+
36
+
37
+ def _mock_generate(text: str) -> str:
38
+ # Audible placeholder so the voice/hear features are verifiable without a GPU.
39
+ sample_rate = 24000
40
+ duration = min(1.6, 0.5 + 0.03 * len(text.split()))
41
+ t = np.linspace(0, duration, int(sample_rate * duration), endpoint=False)
42
+ base = 150 + (hash(text) % 120) # per-line pitch
43
+ wobble = 1 + 0.04 * np.sin(2 * np.pi * 5 * t) # gentle speech-like wobble
44
+ tone = 0.22 * np.sin(2 * np.pi * base * wobble * t)
45
+ tone += 0.08 * np.sin(2 * np.pi * base * 2 * t)
46
+ env = np.minimum(1.0, np.minimum(t * 12, (duration - t) * 8)) # fade in/out
47
+ audio = (tone * env).astype(np.float32)
48
+ path = os.path.join(tempfile.gettempdir(), f"tinyworld_voice_{os.getpid()}.wav")
49
+ try:
50
+ import soundfile as sf
51
+ sf.write(path, audio, sample_rate)
52
+ except ImportError:
53
+ import wave
54
+ with wave.open(path, "w") as wf:
55
+ wf.setnchannels(1)
56
+ wf.setsampwidth(2)
57
+ wf.setframerate(sample_rate)
58
+ wf.writeframes((audio * 32767).astype(np.int16).tobytes())
59
+ return path
60
+
61
+
62
+ def _real_generate(text: str, voice_desc: str) -> str:
63
+ try:
64
+ import httpx
65
+
66
+ payload = {"text": text, "voice_desc": voice_desc}
67
+
68
+ with httpx.Client(timeout=VOICE_TIMEOUT, follow_redirects=True) as client:
69
+ resp = client.post(MODAL_VOICE_URL, json=payload)
70
+ resp.raise_for_status()
71
+
72
+ path = os.path.join(tempfile.gettempdir(), f"tinyworld_voice_{os.getpid()}.wav")
73
+ with open(path, "wb") as f:
74
+ f.write(resp.content)
75
+
76
+ return path
77
+
78
+ except Exception as e:
79
+ print(f"[voice] Modal call failed: {e}")
80
+ return None
81
+
82
+
83
+ if __name__ == "__main__":
84
+ import characters as c
85
+ path = generate_voice("Hello there!", c.CHARACTERS[0]["voice_description"])
86
+ print(path)
87
+ assert os.path.exists(path), f"File not found: {path}"
88
+ print("OK")
world_state.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import threading
3
+ from collections import defaultdict, deque
4
+
5
+ _lock = threading.Lock()
6
+
7
+ _states = {}
8
+
9
+ DEFAULT_SCHEDULES = {
10
+ "student": [
11
+ [7, 8, "school", "getting ready for class", {"energy": -2, "social": 1}],
12
+ [8, 15, "school", "in class", {"energy": -8, "social": 6}],
13
+ [15, 17, "cafe", "after-school break", {"hunger": -10, "social": 8}],
14
+ [17, 22, "school", "homework and hobbies", {"energy": -4}],
15
+ [22, 7, "school", "sleeping", {"energy": 25}],
16
+ ],
17
+ "worker": [
18
+ [7, 9, "cafe", "breakfast before work", {"hunger": -8, "energy": 3}],
19
+ [9, 17, "priya_office", "working", {"energy": -10, "social": 3}],
20
+ [17, 19, "park", "walking after work", {"energy": -2, "social": 4}],
21
+ [19, 23, "cafe", "evening errands", {"hunger": -8, "social": 3}],
22
+ [23, 7, "priya_office", "resting", {"energy": 24}],
23
+ ],
24
+ "shopkeeper": [
25
+ [6, 8, "shop", "opening up", {"energy": -2}],
26
+ [8, 18, "shop", "serving customers", {"energy": -9, "social": 8}],
27
+ [18, 21, "cafe", "closing-day dinner", {"hunger": -10, "social": 3}],
28
+ [21, 6, "shop", "resting", {"energy": 22}],
29
+ ],
30
+ "medic": [
31
+ [7, 8, "cafe", "coffee before shift", {"hunger": -6, "energy": 2}],
32
+ [8, 18, "nia_clinic", "clinic shift", {"energy": -12, "social": 5}],
33
+ [18, 20, "park", "decompressing", {"energy": -2, "social": 2}],
34
+ [20, 7, "nia_clinic", "resting on call", {"energy": 24}],
35
+ ],
36
+ "retiree": [
37
+ [6, 9, "marta_home", "quiet morning routine", {"energy": 4}],
38
+ [9, 12, "shop", "checking the old storefront", {"energy": -3, "social": 3}],
39
+ [12, 16, "park", "watching the neighborhood", {"energy": -2, "social": 4}],
40
+ [16, 20, "cafe", "tea and gossip", {"hunger": -8, "social": 5}],
41
+ [20, 6, "marta_home", "resting at home", {"energy": 20}],
42
+ ],
43
+ }
44
+
45
+
46
+ def _new_state(world_id):
47
+ return {
48
+ "world_id": world_id,
49
+ "day": 1,
50
+ "event_count": 0,
51
+ "chaos": 0.0,
52
+ "town_mood": 0.0,
53
+ "game_time": 7.0,
54
+ "paused": False,
55
+ "vibes": {},
56
+ "needs": {},
57
+ "affinity": {},
58
+ "moods": {},
59
+ "memory": defaultdict(lambda: deque(maxlen=4)),
60
+ "timeline": defaultdict(lambda: deque(maxlen=20)),
61
+ "reflections": {},
62
+ "positions": {},
63
+ "activities": {},
64
+ "campaign": _new_campaign(),
65
+ }
66
+
67
+
68
+ def _new_campaign():
69
+ return {
70
+ "active": False,
71
+ "campaign_id": None,
72
+ "chapter_index": 0,
73
+ "crisis_id": None,
74
+ "crisis_round": 0,
75
+ "crisis_met": set(), # requirement ids met in the current crisis (sticky)
76
+ "town_resilience": 100, # campaign-long score, 0-100
77
+ "clues": [], # meta-mystery breadcrumbs revealed so far
78
+ "chapter_grades": [], # [{chapter, title, grade, rounds, mvp}]
79
+ "mvp_tally": {}, # name -> times they were crisis MVP
80
+ "status": "idle", # idle | playing | won | lost | finale
81
+ "last_dispatch": "", # most recent narrated verdict
82
+ }
83
+
84
+
85
+ def get_state(world_id):
86
+ with _lock:
87
+ if world_id not in _states:
88
+ _states[world_id] = _new_state(world_id)
89
+ return _states[world_id]
90
+
91
+
92
+ def reset_world(world_id):
93
+ with _lock:
94
+ _states[world_id] = _new_state(world_id)
95
+ return _states[world_id]
96
+
97
+
98
+ def init_cast(world):
99
+ state = get_state(world["id"])
100
+ with _lock:
101
+ for c in world["cast"]:
102
+ name = c["name"]
103
+ if name not in state["vibes"]:
104
+ state["vibes"][name] = {"energy": 0.6, "social": 0.5}
105
+ if name not in state["needs"]:
106
+ state["needs"][name] = c.get("needs", {"energy": 60, "hunger": 35, "social": 50}).copy()
107
+ if name not in state["moods"]:
108
+ state["moods"][name] = random.choice([
109
+ "happy", "stressed", "bored", "excited", "hungry",
110
+ "tired", "nostalgic", "curious", "proud", "embarrassed",
111
+ ])
112
+ if name not in state["reflections"]:
113
+ state["reflections"][name] = ""
114
+ if name not in state["positions"]:
115
+ state["positions"][name] = c.get("home", "square")
116
+ if name not in state["activities"]:
117
+ state["activities"][name] = "starting the day"
118
+ if not state["timeline"][name]:
119
+ state["timeline"][name].append(f"{format_time(state['game_time'])} at {state['positions'][name].replace('_', ' ')}")
120
+ pairs = [(a["name"], b["name"]) for a in world["cast"] for b in world["cast"] if a["name"] < b["name"]]
121
+ for a, b in pairs:
122
+ if (a, b) not in state["affinity"]:
123
+ state["affinity"][(a, b)] = 0.0
124
+
125
+
126
+ def get_mood(world_id, name):
127
+ state = get_state(world_id)
128
+ with _lock:
129
+ return state["moods"].get(name, "curious")
130
+
131
+
132
+ def set_mood(world_id, name, mood):
133
+ state = get_state(world_id)
134
+ with _lock:
135
+ state["moods"][name] = mood
136
+
137
+
138
+ def get_memory(world_id, name):
139
+ state = get_state(world_id)
140
+ with _lock:
141
+ return list(state["memory"][name])
142
+
143
+
144
+ def add_memory(world_id, name, event, reaction_text):
145
+ state = get_state(world_id)
146
+ entry = f"Event: {event} | Reaction: {reaction_text}"
147
+ with _lock:
148
+ state["memory"][name].append(entry)
149
+
150
+
151
+ def add_gossip(world_id, target_name, snippet):
152
+ state = get_state(world_id)
153
+ entry = f"Gossip: {snippet}"
154
+ with _lock:
155
+ state["memory"][target_name].append(entry)
156
+
157
+
158
+ def get_reflection(world_id, name):
159
+ state = get_state(world_id)
160
+ with _lock:
161
+ return state["reflections"].get(name, "")
162
+
163
+
164
+ def set_reflection(world_id, name, text):
165
+ state = get_state(world_id)
166
+ with _lock:
167
+ state["reflections"][name] = text
168
+
169
+
170
+ def get_vibes(world_id):
171
+ state = get_state(world_id)
172
+ with _lock:
173
+ return dict(state["vibes"])
174
+
175
+
176
+ def get_affinity(world_id):
177
+ state = get_state(world_id)
178
+ with _lock:
179
+ return dict(state["affinity"])
180
+
181
+
182
+ def get_position(world_id, name):
183
+ state = get_state(world_id)
184
+ with _lock:
185
+ return state["positions"].get(name)
186
+
187
+
188
+ def set_position(world_id, name, hotspot):
189
+ state = get_state(world_id)
190
+ with _lock:
191
+ state["positions"][name] = hotspot
192
+
193
+
194
+ def get_needs(world_id):
195
+ state = get_state(world_id)
196
+ with _lock:
197
+ return {name: vals.copy() for name, vals in state["needs"].items()}
198
+
199
+
200
+ def get_activity(world_id, name):
201
+ state = get_state(world_id)
202
+ with _lock:
203
+ return state["activities"].get(name, "")
204
+
205
+
206
+ def get_timeline(world_id):
207
+ state = get_state(world_id)
208
+ with _lock:
209
+ return {name: list(entries) for name, entries in state["timeline"].items()}
210
+
211
+
212
+ def get_game_time(world_id):
213
+ state = get_state(world_id)
214
+ with _lock:
215
+ return state["day"], state["game_time"], state["paused"]
216
+
217
+
218
+ def set_paused(world_id, paused):
219
+ state = get_state(world_id)
220
+ with _lock:
221
+ state["paused"] = bool(paused)
222
+ return state["paused"]
223
+
224
+
225
+ def apply_vibe_delta(world_id, name, delta):
226
+ state = get_state(world_id)
227
+ with _lock:
228
+ v = state["vibes"].setdefault(name, {"energy": 0.5, "social": 0.5})
229
+ v["energy"] = max(0.0, min(1.0, v["energy"] + delta.get("energy", 0)))
230
+ v["social"] = max(0.0, min(1.0, v["social"] + delta.get("social", 0)))
231
+
232
+
233
+ def apply_affinity_delta(world_id, name, delta):
234
+ state = get_state(world_id)
235
+ with _lock:
236
+ for other, d in delta.items():
237
+ pair = tuple(sorted([name, other]))
238
+ state["affinity"][pair] = max(-1.0, min(1.0, state["affinity"].get(pair, 0.0) + d))
239
+
240
+
241
+ def increment_event(world_id, chaos_delta=0.1):
242
+ state = get_state(world_id)
243
+ with _lock:
244
+ state["event_count"] += 1
245
+ state["chaos"] = min(1.0, state["chaos"] + chaos_delta)
246
+ if state["event_count"] % 5 == 0:
247
+ state["day"] += 1
248
+
249
+
250
+ def tick(world, hours=1.0, force=False):
251
+ state = get_state(world["id"])
252
+ with _lock:
253
+ if state["paused"] and not force:
254
+ return False
255
+ previous_hour = state["game_time"]
256
+ state["game_time"] += hours
257
+ while state["game_time"] >= 24:
258
+ state["game_time"] -= 24
259
+ state["day"] += 1
260
+ for c in world["cast"]:
261
+ name = c["name"]
262
+ entry = schedule_entry(c, state["game_time"])
263
+ if not entry:
264
+ continue
265
+ _, _, hotspot, activity, effects = entry
266
+ if hotspot not in (world.get("board", {}) or {}).get("hotspots_tile", {}):
267
+ hotspot = c.get("home", "square")
268
+ old_pos = state["positions"].get(name)
269
+ old_activity = state["activities"].get(name)
270
+ state["positions"][name] = hotspot
271
+ state["activities"][name] = activity
272
+ needs = state["needs"].setdefault(name, {"energy": 60, "hunger": 35, "social": 50})
273
+ needs["hunger"] = _clamp_need(needs.get("hunger", 35) + 4 + effects.get("hunger", 0))
274
+ needs["energy"] = _clamp_need(needs.get("energy", 60) + effects.get("energy", 0))
275
+ needs["social"] = _clamp_need(needs.get("social", 50) + effects.get("social", 0))
276
+ if old_pos != hotspot or old_activity != activity or int(previous_hour) != int(state["game_time"]):
277
+ state["timeline"][name].append(
278
+ f"{format_time(state['game_time'])} -> {hotspot.replace('_', ' ')} ({activity})"
279
+ )
280
+ return True
281
+
282
+
283
+ def schedule_entry(character, hour):
284
+ schedule = character.get("schedule") or DEFAULT_SCHEDULES.get(character_role(character), [])
285
+ for entry in schedule:
286
+ start, end = entry[0], entry[1]
287
+ if start <= end:
288
+ active = start <= hour < end
289
+ else:
290
+ active = hour >= start or hour < end
291
+ if active:
292
+ return entry
293
+ home = character.get("home", "square")
294
+ return [0, 24, home, "at home", {"energy": 0, "hunger": 0, "social": 0}]
295
+
296
+
297
+ def character_role(character):
298
+ role = character.get("role")
299
+ if role:
300
+ return role
301
+ job = character.get("job", "").lower()
302
+ if "student" in job:
303
+ return "student"
304
+ if "paramedic" in job or "nurse" in job or "medic" in job:
305
+ return "medic"
306
+ if "retired" in job:
307
+ return "retiree"
308
+ if "cafe" in job or "shop" in job or "owner" in job:
309
+ return "shopkeeper"
310
+ return "worker"
311
+
312
+
313
+ def format_time(hour):
314
+ hour = hour % 24
315
+ h = int(hour)
316
+ m = int(round((hour - h) * 60)) % 60
317
+ return f"{h:02d}:{m:02d}"
318
+
319
+
320
+ def _clamp_need(value):
321
+ return max(0, min(100, int(value)))
322
+
323
+
324
+ def get_town_mood(world_id):
325
+ state = get_state(world_id)
326
+ with _lock:
327
+ return state["town_mood"]
328
+
329
+
330
+ def set_town_mood(world_id, val):
331
+ state = get_state(world_id)
332
+ with _lock:
333
+ state["town_mood"] = max(-1.0, min(1.0, val))
334
+
335
+
336
+ def get_event_count(world_id):
337
+ state = get_state(world_id)
338
+ with _lock:
339
+ return state["event_count"]
340
+
341
+
342
+ def get_day(world_id):
343
+ state = get_state(world_id)
344
+ with _lock:
345
+ return state["day"]
346
+
347
+
348
+ def get_chaos(world_id):
349
+ state = get_state(world_id)
350
+ with _lock:
351
+ return state["chaos"]
352
+
353
+
354
+ def get_campaign(world_id):
355
+ """A copy of the campaign state (mutable containers copied so callers can't
356
+ corrupt the locked state by accident)."""
357
+ state = get_state(world_id)
358
+ with _lock:
359
+ c = state["campaign"]
360
+ snap = dict(c)
361
+ snap["crisis_met"] = set(c["crisis_met"])
362
+ snap["clues"] = list(c["clues"])
363
+ snap["chapter_grades"] = [dict(g) for g in c["chapter_grades"]]
364
+ snap["mvp_tally"] = dict(c["mvp_tally"])
365
+ return snap
366
+
367
+
368
+ def campaign_update(world_id, **fields):
369
+ """Merge fields into the campaign state under the lock."""
370
+ state = get_state(world_id)
371
+ with _lock:
372
+ state["campaign"].update(fields)
373
+ return dict(state["campaign"])
374
+
375
+
376
+ def reset_campaign(world_id):
377
+ state = get_state(world_id)
378
+ with _lock:
379
+ state["campaign"] = _new_campaign()
380
+ return dict(state["campaign"])
381
+
382
+
383
+ def add_chaos(world_id, delta):
384
+ state = get_state(world_id)
385
+ with _lock:
386
+ state["chaos"] = max(0.0, min(1.0, state["chaos"] + delta))
387
+ return state["chaos"]
388
+
389
+
390
+ def reset_chaos(world_id):
391
+ state = get_state(world_id)
392
+ with _lock:
393
+ state["chaos"] = 0.0
394
+
395
+
396
+ def maybe_form_reflection(world_id, name):
397
+ state = get_state(world_id)
398
+ with _lock:
399
+ memories = list(state["memory"][name])
400
+ if not memories:
401
+ return
402
+ last = memories[-1]
403
+ learned = last.split("| Reaction:")[-1].strip() if "| Reaction:" in last else last
404
+ learned = learned.rstrip(".")
405
+ templates = [
406
+ f"I'm learning how this block reacts. Last time, my move was to {learned.lower()}.",
407
+ f"Each thing that happens teaches me something. I keep choosing to act, not just watch.",
408
+ f"I remember what I did last time, and I'd do it again: {learned}.",
409
+ f"These events are changing how I read my neighbors — and myself.",
410
+ f"I trust my instincts more now. {learned} felt right.",
411
+ ]
412
+ state["reflections"][name] = random.choice(templates)
worlds/__init__.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from worlds.maple_street import WORLD as MAPLE_STREET
2
+ from worlds.riverside_campus import WORLD as RIVERSIDE_CAMPUS
3
+
4
+ WORLDS = {
5
+ "maple_street": MAPLE_STREET,
6
+ "riverside_campus": RIVERSIDE_CAMPUS,
7
+ }
8
+
9
+
10
+ def get_world(world_id):
11
+ return WORLDS.get(world_id)
12
+
13
+
14
+ def list_worlds():
15
+ return [
16
+ {"id": wid, "name": w["name"], "theme": w["theme"]}
17
+ for wid, w in WORLDS.items()
18
+ ]
worlds/__pycache__/__init__.cpython-310.pyc ADDED
Binary file (754 Bytes). View file
 
worlds/__pycache__/maple_street.cpython-310.pyc ADDED
Binary file (14.5 kB). View file
 
worlds/__pycache__/riverside_campus.cpython-310.pyc ADDED
Binary file (6.54 kB). View file
 
worlds/maple_street.py ADDED
@@ -0,0 +1,542 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ WORLD = {
2
+ "id": "maple_street",
3
+ "name": "Maple Street",
4
+ "theme": "cozy modern suburb",
5
+ "diorama": "assets/worlds/maple_street.png",
6
+ "palette": {
7
+ "bg": "#0b0f1a",
8
+ "grass": "#1a4a2a",
9
+ "road": "#1e2235",
10
+ "house": "#c45b5b",
11
+ "park": "#0a3a2a",
12
+ "tree": "#0a2a1a",
13
+ "sky": "#0a0f1a",
14
+ "tint_day": "#fff3d6",
15
+ "tint_night": "#1a2342",
16
+ },
17
+ "hotspots": {
18
+ "marta_home": [17, 63],
19
+ "cafe": [72, 58],
20
+ "park": [58, 50],
21
+ "square": [48, 73],
22
+ "jay_home": [89, 87],
23
+ "school": [37, 56],
24
+ "nia_clinic": [13, 81],
25
+ "priya_office": [82, 78],
26
+ },
27
+ "scenery": {
28
+ "buildings": [
29
+ {"left": 5, "top": 40, "w": 15, "h": 15, "front": "#3a2b4d", "roof": "#ff5fa2", "side": "#241a33", "label": "🕰️"},
30
+ {"left": 27, "top": 33, "w": 20, "h": 14, "front": "#23314f", "roof": "#38e8ff", "side": "#16213a", "label": "🏫"},
31
+ {"left": 63, "top": 36, "w": 17, "h": 14, "front": "#4a3b2b", "roof": "#ffd76a", "side": "#33291d", "label": "☕"},
32
+ {"left": 3, "top": 61, "w": 14, "h": 14, "front": "#1f3d3a", "roof": "#7CFFB2", "side": "#142826", "label": "🚑"},
33
+ {"left": 71, "top": 53, "w": 16, "h": 17, "front": "#2c2b4d", "roof": "#9b6bff", "side": "#1d1c33", "label": "🏢"},
34
+ {"left": 82, "top": 69, "w": 14, "h": 12, "front": "#3a2030", "roof": "#ff8a5f", "side": "#281622", "label": "🏪"},
35
+ ],
36
+ "roads": [
37
+ {"left": -5, "top": 66, "w": 120, "h": 9, "rot": -8},
38
+ {"left": 40, "top": 30, "w": 9, "h": 70, "rot": 6},
39
+ ],
40
+ "trees": [
41
+ {"left": 21, "top": 52, "s": 1.0},
42
+ {"left": 52, "top": 46, "s": 0.8},
43
+ {"left": 62, "top": 66, "s": 1.1},
44
+ {"left": 33, "top": 80, "s": 0.9},
45
+ {"left": 94, "top": 52, "s": 0.8},
46
+ ],
47
+ "props": [
48
+ {"left": 49, "top": 64, "emoji": "⛲"},
49
+ {"left": 30, "top": 67, "emoji": "🪧"},
50
+ ],
51
+ },
52
+ "cast": [
53
+ {
54
+ "name": "Marta Voss",
55
+ "age": 68,
56
+ "job": "retired watchmaker",
57
+ "traits": ["blunt", "observant", "nostalgic"],
58
+ "backstory": "She ran the neighborhood clock shop for four decades and still notices every tiny change on the street.",
59
+ "relationships": {
60
+ "Jay Park": "rival",
61
+ "Nia Okafor": "friend",
62
+ "Luca Bell": "family",
63
+ "Priya Raman": "friend",
64
+ },
65
+ "voice_description": "(a grumpy elderly woman, deep measured low-pitched voice)",
66
+ "model": "nvidia/Nemotron-Mini-4B-Instruct",
67
+ "emoji": "🧓",
68
+ "home": "marta_home",
69
+ "catchphrase_hint": "always compares things to the past",
70
+ },
71
+ {
72
+ "name": "Jay Park",
73
+ "age": 24,
74
+ "job": "food delivery cyclist",
75
+ "traits": ["restless", "funny", "impulsive"],
76
+ "backstory": "He knows every shortcut in the district and treats ordinary afternoons like they might become legends.",
77
+ "relationships": {
78
+ "Marta Voss": "rival",
79
+ "Nia Okafor": "crush",
80
+ "Luca Bell": "friend",
81
+ "Priya Raman": "friend",
82
+ },
83
+ "voice_description": "(an energetic young man, quick bright mid-pitched voice)",
84
+ "model": "nvidia/Nemotron-Mini-4B-Instruct",
85
+ "emoji": "🚴",
86
+ "home": "jay_home",
87
+ "catchphrase_hint": "turns everything into an adventure",
88
+ },
89
+ {
90
+ "name": "Nia Okafor",
91
+ "age": 35,
92
+ "job": "paramedic",
93
+ "traits": ["calm", "protective", "dry-witted"],
94
+ "backstory": "After years on emergency shifts, she reads chaos quickly and trusts people more than they expect.",
95
+ "relationships": {
96
+ "Marta Voss": "friend",
97
+ "Jay Park": "crush",
98
+ "Luca Bell": "friend",
99
+ "Priya Raman": "family",
100
+ },
101
+ "voice_description": "(a steady adult woman, warm confident medium-low voice)",
102
+ "model": "nvidia/Nemotron-Mini-4B-Instruct",
103
+ "emoji": "🚑",
104
+ "home": "nia_clinic",
105
+ "catchphrase_hint": "downplays danger but always steps up",
106
+ },
107
+ {
108
+ "name": "Luca Bell",
109
+ "age": 15,
110
+ "job": "student and arcade regular",
111
+ "traits": ["curious", "dramatic", "inventive"],
112
+ "backstory": "He documents neighborhood rumors in a battered notebook and is convinced something world-changing will happen here first.",
113
+ "relationships": {
114
+ "Marta Voss": "family",
115
+ "Jay Park": "friend",
116
+ "Nia Okafor": "friend",
117
+ "Priya Raman": "rival",
118
+ },
119
+ "voice_description": "(a teenage boy, eager expressive slightly high-pitched voice)",
120
+ "model": "nvidia/Nemotron-Mini-4B-Instruct",
121
+ "emoji": "🧒",
122
+ "home": "school",
123
+ "catchphrase_hint": "treats everything like breaking news",
124
+ },
125
+ {
126
+ "name": "Priya Raman",
127
+ "age": 42,
128
+ "job": "city planner",
129
+ "traits": ["organized", "skeptical", "secretly romantic"],
130
+ "backstory": "She came to fix the block's chronic little problems and keeps getting pulled into everyone else's dramas.",
131
+ "relationships": {
132
+ "Marta Voss": "friend",
133
+ "Jay Park": "friend",
134
+ "Nia Okafor": "family",
135
+ "Luca Bell": "rival",
136
+ },
137
+ "voice_description": "(a precise middle-aged woman, crisp thoughtful medium voice)",
138
+ "model": "nvidia/Nemotron-Mini-4B-Instruct",
139
+ "emoji": "👩‍💼",
140
+ "home": "priya_office",
141
+ "catchphrase_hint": "tries to stay logical but feelings leak through",
142
+ },
143
+ ],
144
+ "board": {
145
+ # A real residential street: a suburban grid of homes with a small high street,
146
+ # a school, a clinic and a park. Deliberately NOT the same shape as the other worlds.
147
+ "cols": 22,
148
+ "rows": 18,
149
+ "ambient": 6,
150
+ "roads": {"cols": [5, 11, 17], "rows": [6, 13]},
151
+ "plaza": [[12, 9], [13, 9], [12, 10], [13, 10]],
152
+ "plaza_center": [12.5, 9.5],
153
+ "buildings": [
154
+ # homes — pitched-roof houses in warm, lived-in colours (most of a neighborhood is houses)
155
+ {"gx": 1, "gy": 1, "w": 2, "d": 2, "h": 24, "kind": "house", "wall": "#caa074", "roof": "#8a3b2e", "label": "MARTA"},
156
+ {"gx": 8, "gy": 1, "w": 2, "d": 2, "h": 24, "kind": "house", "wall": "#b6c081", "roof": "#4a6b32", "label": "THE PARKS"},
157
+ {"gx": 19, "gy": 1, "w": 2, "d": 2, "h": 26, "kind": "house", "wall": "#b79ec0", "roof": "#5a3b6b", "label": "RAMAN"},
158
+ {"gx": 1, "gy": 15, "w": 2, "d": 2, "h": 24, "kind": "house", "wall": "#c79a78", "roof": "#7a4a2e", "label": "No. 12"},
159
+ {"gx": 15, "gy": 15, "w": 2, "d": 2, "h": 24, "kind": "house", "wall": "#9ab0c0", "roof": "#3b5a6b", "label": "No. 9"},
160
+ {"gx": 19, "gy": 9, "w": 2, "d": 2, "h": 24, "kind": "house", "wall": "#c0a86a", "roof": "#6b5a2e", "label": "No. 7"},
161
+ # the little high street — a café and a corner store with real storefronts
162
+ {"gx": 14, "gy": 1, "w": 2, "d": 2, "h": 30, "kind": "shop", "wall": "#8a6f3f", "roof": "#ff8a5f", "label": "CAFÉ"},
163
+ {"gx": 1, "gy": 8, "w": 2, "d": 2, "h": 28, "kind": "shop", "wall": "#6f5a8a", "roof": "#38e8ff", "label": "CORNER STORE"},
164
+ # civic — a small school and a clinic
165
+ {"gx": 8, "gy": 15, "w": 3, "d": 2, "h": 34, "kind": "block", "wall": "#6a6f8a", "roof": "#ffd76a", "label": "SCHOOL"},
166
+ {"gx": 19, "gy": 15, "w": 2, "d": 2, "h": 30, "kind": "block", "wall": "#4f8576", "roof": "#7CFFB2", "label": "CLINIC"},
167
+ ],
168
+ "trees": [
169
+ [9, 11], [10, 12], [7, 4], [16, 4], [4, 10], [20, 12], [7, 16], [12, 16], [18, 6], [6, 7],
170
+ ],
171
+ "props": [
172
+ {"type": "fountain", "gx": 12, "gy": 12},
173
+ {"type": "pond", "gx": 15, "gy": 11, "w": 2, "d": 2},
174
+ {"type": "bench", "gx": 10, "gy": 11},
175
+ {"type": "bench", "gx": 14, "gy": 12},
176
+ # café seating on the high street
177
+ {"type": "table", "gx": 13, "gy": 3},
178
+ {"type": "table", "gx": 16, "gy": 3},
179
+ # corner-store produce stall
180
+ {"type": "stall", "gx": 3, "gy": 9, "color": "#caa23f"},
181
+ # greenery + lamps to make it feel lived-in
182
+ {"type": "planter", "gx": 7, "gy": 12},
183
+ {"type": "planter", "gx": 12, "gy": 7},
184
+ {"type": "lamp", "gx": 11, "gy": 8},
185
+ {"type": "lamp", "gx": 14, "gy": 8},
186
+ {"type": "lamp", "gx": 5, "gy": 12},
187
+ ],
188
+ "hotspots_tile": {
189
+ "marta_home": [3, 2],
190
+ "jay_home": [8, 3],
191
+ "priya_office": [18, 2],
192
+ "school": [10, 14],
193
+ "nia_clinic": [18, 15],
194
+ "cafe": [14, 4],
195
+ "shop": [3, 8],
196
+ "park": [10, 11],
197
+ "fountain": [11, 12],
198
+ "square": [13, 10],
199
+ },
200
+ # where each character goes (and what they do) when an event hits — spread out,
201
+ # in-character, so the street reads like a real place reacting, not a mob.
202
+ "activities": {
203
+ "Marta Voss": {"tile": [10, 11], "label": "🪑 watches from the park bench"},
204
+ "Jay Park": {"tile": [14, 4], "label": "🚴 races to the café", "vehicle": "bike"},
205
+ "Nia Okafor": {"tile": [18, 15], "label": "🚑 heads to the clinic"},
206
+ "Luca Bell": {"tile": [11, 12], "label": "🔎 investigates the fountain"},
207
+ "Priya Raman": {"tile": [18, 2], "label": "🏠 hurries home to plan"},
208
+ },
209
+ },
210
+ "scenarios": [
211
+ {
212
+ "id": "school_closing",
213
+ "title": "The school will be closed by the city",
214
+ "event": "The city announces Maple Street Elementary will be shut down at the end of the semester due to budget cuts.",
215
+ "focus": "How do different generations react to losing a community institution?",
216
+ },
217
+ {
218
+ "id": "new_neighbor",
219
+ "title": "A new family moves in next door",
220
+ "event": "A young family with two kids moves into the empty house on Maple Street. They seem nice but awfully quiet.",
221
+ "focus": "How does the neighborhood welcome (or distrust) newcomers?",
222
+ },
223
+ {
224
+ "id": "park_renovation",
225
+ "title": "The park gets a makeover",
226
+ "event": "The city approves a full renovation of the tiny park on Maple Street. Construction starts Monday.",
227
+ "focus": "Who benefits from change and who loses something familiar?",
228
+ },
229
+ {
230
+ "id": "power_outage",
231
+ "title": "A summer power outage hits the block",
232
+ "event": "A heatwave knocks out power to the entire block for an estimated six hours. No AC, no lights, no screens.",
233
+ "focus": "How do people cope when their routines are stripped away?",
234
+ },
235
+ ],
236
+ "events": [
237
+ "A mysterious glowing object lands in the park",
238
+ "The power goes out across the entire neighborhood",
239
+ "A stray cat starts following people around town",
240
+ "Someone paints a giant mural on the side of the corner store",
241
+ "A food truck rolls in and parks in the middle of the square",
242
+ "The old clock tower starts chiming at random hours",
243
+ "A thunderstorm knocks down the biggest tree on Maple Street",
244
+ "Someone leaves a box of kittens on the church doorstep",
245
+ "The neighborhood gets its own Wikipedia page overnight",
246
+ "A film crew shows up to shoot a commercial on the block",
247
+ ],
248
+ # ---- Crisis Mode content -------------------------------------------------
249
+ # Each crisis declares the concrete responses that count as "handling it".
250
+ # The resolver (crisis.py) checks the residents' goto + action against these.
251
+ "crises": [
252
+ {
253
+ "id": "cafe_fire",
254
+ "title": "Kitchen fire at the café",
255
+ "narrative": "Smoke is pouring out of the café on the high street — a grease fire has caught in the kitchen and it's spreading fast.",
256
+ "severity": 3,
257
+ "kind": "fire",
258
+ "affected_hotspot": "cafe",
259
+ "time_limit": 4,
260
+ "escalation": "The flames are climbing toward the upstairs flats.",
261
+ "focus": "When the same emergency hits, who runs toward it, who organizes, and who freezes?",
262
+ "success_text": "The fire's out, everyone's accounted for, and the café will reopen.",
263
+ "failure_text": "The café is gutted and the block is shaken.",
264
+ "requirements": [
265
+ {
266
+ "id": "containment",
267
+ "label": "🔥 Contain the fire",
268
+ "hotspots": ["cafe"],
269
+ "action_keywords": [
270
+ "fire", "flame", "douse", "extinguish", "put out",
271
+ "water", "smoke", "hose", "blanket",
272
+ ],
273
+ "weight": 1.5,
274
+ },
275
+ {
276
+ "id": "medical",
277
+ "label": "🚑 Get medical on scene",
278
+ "hotspots": ["cafe", "nia_clinic"],
279
+ "action_keywords": [
280
+ "help", "treat", "check", "injur", "hurt", "burn",
281
+ "aid", "care", "first aid", "ambulance",
282
+ ],
283
+ "role": "medic",
284
+ "weight": 1.0,
285
+ },
286
+ {
287
+ "id": "evacuation",
288
+ "label": "🏃 Evacuate the building",
289
+ "hotspots": ["cafe", "square"],
290
+ "action_keywords": [
291
+ "evacuat", "get everyone out", "clear", "warn",
292
+ "usher", "people out", "safe", "away",
293
+ ],
294
+ "weight": 1.0,
295
+ },
296
+ {
297
+ "id": "coordination",
298
+ "label": "📋 Coordinate the response",
299
+ "hotspots": ["priya_office", "square"],
300
+ "action_keywords": [
301
+ "coordinat", "organi", "plan", "call", "rally",
302
+ "manage", "direct", "report", "emergency",
303
+ ],
304
+ "role": "worker",
305
+ "weight": 1.0,
306
+ "optional": True,
307
+ },
308
+ ],
309
+ },
310
+ {
311
+ "id": "heatwave_blackout",
312
+ "title": "Heatwave blackout",
313
+ "narrative": "A record heatwave has knocked out power to the whole block — no AC, no fans, and the elderly are sweltering in their flats.",
314
+ "severity": 3,
315
+ "kind": "blackout",
316
+ "affected_hotspot": "priya_office",
317
+ "time_limit": 4,
318
+ "escalation": "It's getting dangerously hot indoors.",
319
+ "focus": "In a slow-burning emergency with no obvious villain, who takes initiative and who waits?",
320
+ "success_text": "Power's restored, the vulnerable were looked after, and the block cooled down.",
321
+ "failure_text": "People suffered through the heat with no one taking charge.",
322
+ "requirements": [
323
+ {
324
+ "id": "restore",
325
+ "label": "⚡ Get the power back",
326
+ "hotspots": ["priya_office"],
327
+ "action_keywords": ["power", "grid", "restore", "electric", "generator", "call", "utility", "fix"],
328
+ "role": "worker",
329
+ "weight": 1.5,
330
+ },
331
+ {
332
+ "id": "welfare",
333
+ "label": "🚑 Check on the vulnerable",
334
+ "hotspots": ["marta_home", "nia_clinic", "square"],
335
+ "action_keywords": ["check", "elderly", "vulnerable", "welfare", "water", "cool", "help", "care"],
336
+ "role": "medic",
337
+ "weight": 1.0,
338
+ },
339
+ {
340
+ "id": "coordination",
341
+ "label": "📋 Organize a cooling shelter",
342
+ "hotspots": ["square", "priya_office"],
343
+ "action_keywords": ["organi", "plan", "coordinat", "shelter", "gather", "rally"],
344
+ "weight": 1.0,
345
+ },
346
+ {
347
+ "id": "relief",
348
+ "label": "💧 Hand out water & shade",
349
+ "hotspots": ["cafe", "square"],
350
+ "action_keywords": ["water", "cool", "shade", "drinks", "ice", "relief"],
351
+ "weight": 1.0,
352
+ "optional": True,
353
+ },
354
+ ],
355
+ },
356
+ {
357
+ "id": "storm_tree",
358
+ "title": "Storm downs the big maple",
359
+ "narrative": "A violent summer storm has torn down the giant maple in the park — and someone is trapped under the branches.",
360
+ "severity": 4,
361
+ "kind": "storm",
362
+ "affected_hotspot": "park",
363
+ "time_limit": 4,
364
+ "escalation": "The trapped neighbor is hurt and the rain is still coming down.",
365
+ "focus": "Under real physical danger, who acts on instinct and who needs a plan first?",
366
+ "success_text": "They freed the trapped neighbor and got everyone to safety.",
367
+ "failure_text": "Help came too slow and the night turned grim.",
368
+ "requirements": [
369
+ {
370
+ "id": "rescue",
371
+ "label": "🌳 Free the trapped neighbor",
372
+ "hotspots": ["park"],
373
+ "action_keywords": ["lift", "free", "rescue", "trapped", "move", "clear", "branch", "pull", "dig"],
374
+ "weight": 1.5,
375
+ },
376
+ {
377
+ "id": "medical",
378
+ "label": "🚑 Treat the injured",
379
+ "hotspots": ["park", "nia_clinic"],
380
+ "action_keywords": ["treat", "help", "injur", "aid", "first aid", "hurt", "care", "bandage"],
381
+ "role": "medic",
382
+ "weight": 1.0,
383
+ },
384
+ {
385
+ "id": "safety",
386
+ "label": "🚧 Secure the area",
387
+ "hotspots": ["park", "square"],
388
+ "action_keywords": ["clear", "warn", "safe", "away", "cordon", "keep back", "secure"],
389
+ "weight": 1.0,
390
+ },
391
+ {
392
+ "id": "coordination",
393
+ "label": "📋 Call for help",
394
+ "hotspots": ["priya_office", "square"],
395
+ "action_keywords": ["coordinat", "plan", "call", "report", "organi", "emergency"],
396
+ "role": "worker",
397
+ "weight": 1.0,
398
+ "optional": True,
399
+ },
400
+ ],
401
+ },
402
+ {
403
+ "id": "missing_child",
404
+ "title": "A child goes missing",
405
+ "narrative": "In all the chaos, a little one has wandered off — nobody has seen them for an hour and it's getting dark.",
406
+ "severity": 4,
407
+ "kind": "search",
408
+ "affected_hotspot": "square",
409
+ "time_limit": 4,
410
+ "escalation": "Night is falling and the search is getting harder.",
411
+ "focus": "When the task needs everyone spread out, who organizes the search and who just runs?",
412
+ "success_text": "They found the child safe — a whole block searching together.",
413
+ "failure_text": "The search stayed scattered and frightened.",
414
+ "requirements": [
415
+ {
416
+ "id": "search_park",
417
+ "label": "🔦 Search the park",
418
+ "hotspots": ["park"],
419
+ "action_keywords": ["search", "look", "find", "check", "comb", "scour"],
420
+ "weight": 1.0,
421
+ },
422
+ {
423
+ "id": "search_school",
424
+ "label": "🔦 Search the school",
425
+ "hotspots": ["school"],
426
+ "action_keywords": ["search", "look", "find", "check", "comb", "scour"],
427
+ "weight": 1.0,
428
+ },
429
+ {
430
+ "id": "coordination",
431
+ "label": "📋 Organize the search",
432
+ "hotspots": ["square", "priya_office"],
433
+ "action_keywords": ["organi", "coordinat", "plan", "split up", "call", "rally", "direct"],
434
+ "weight": 1.0,
435
+ },
436
+ {
437
+ "id": "reassure",
438
+ "label": "🫂 Reassure the family",
439
+ "hotspots": ["square", "marta_home"],
440
+ "action_keywords": ["calm", "reassure", "comfort", "stay", "with the family"],
441
+ "weight": 1.0,
442
+ "optional": True,
443
+ },
444
+ ],
445
+ },
446
+ {
447
+ "id": "everything_at_once",
448
+ "title": "The town comes apart",
449
+ "narrative": "It's all happening at once — a fire on the high street, the power failing, and panic spreading through the square. This is no accident.",
450
+ "severity": 5,
451
+ "kind": "fire",
452
+ "affected_hotspot": "square",
453
+ "time_limit": 5,
454
+ "escalation": "Everything that can go wrong is going wrong together.",
455
+ "focus": "When everything fails at once, does the town fall apart or pull together?",
456
+ "success_text": "Against the odds, the block held — every person did their part.",
457
+ "failure_text": "It was too much, all at once.",
458
+ "requirements": [
459
+ {
460
+ "id": "containment",
461
+ "label": "🔥 Contain the fire",
462
+ "hotspots": ["cafe"],
463
+ "action_keywords": ["fire", "flame", "douse", "extinguish", "put out", "water", "smoke"],
464
+ "weight": 1.5,
465
+ },
466
+ {
467
+ "id": "restore",
468
+ "label": "⚡ Restore the power",
469
+ "hotspots": ["priya_office"],
470
+ "action_keywords": ["power", "grid", "restore", "electric", "generator", "call", "fix"],
471
+ "role": "worker",
472
+ "weight": 1.0,
473
+ },
474
+ {
475
+ "id": "medical",
476
+ "label": "🚑 Tend the hurt",
477
+ "hotspots": ["cafe", "square", "nia_clinic"],
478
+ "action_keywords": ["treat", "help", "injur", "aid", "first aid", "hurt", "care"],
479
+ "role": "medic",
480
+ "weight": 1.0,
481
+ },
482
+ {
483
+ "id": "evacuation",
484
+ "label": "🏃 Calm the square",
485
+ "hotspots": ["square"],
486
+ "action_keywords": ["evacuat", "calm", "clear", "warn", "safe", "away", "usher", "people out"],
487
+ "weight": 1.0,
488
+ },
489
+ ],
490
+ },
491
+ ],
492
+ "campaign": {
493
+ "id": "long_summer",
494
+ "title": "Season 1 · The Long Summer",
495
+ "chapters": [
496
+ {
497
+ "id": "ch1",
498
+ "crisis_id": "cafe_fire",
499
+ "intro": "A hot, ordinary afternoon on Maple Street. Then smoke starts rising from the café…",
500
+ "outro_win": "The fire's out. People hug on the pavement. 'Could've been so much worse,' someone says.",
501
+ "outro_loss": "The café smolders. Nobody quite knows how it got so bad so fast.",
502
+ "clue": "Nia notices the fire started right after the gas line was 'serviced' this morning. Odd.",
503
+ },
504
+ {
505
+ "id": "ch2",
506
+ "crisis_id": "heatwave_blackout",
507
+ "intro": "A week later, the hottest day on record — and then every light on the block goes dark at once.",
508
+ "outro_win": "The fans whir back to life. Marta mutters that nothing on this street is a coincidence anymore.",
509
+ "outro_loss": "A long, sweltering night. People start asking why this keeps happening here.",
510
+ "clue": "Priya checks the grid logs: the outage was triggered manually, from outside the neighborhood.",
511
+ },
512
+ {
513
+ "id": "ch3",
514
+ "crisis_id": "storm_tree",
515
+ "intro": "The forecast promised clear skies. The storm that hits the park was nowhere on the radar.",
516
+ "outro_win": "Soaked and shaking, they carry their neighbor to safety. 'Who's doing this to us?'",
517
+ "outro_loss": "A hard night under the fallen maple. The block feels watched.",
518
+ "clue": "Luca's notebook: every disaster has hit a different corner of Maple Street, in order, like a map.",
519
+ },
520
+ {
521
+ "id": "ch4",
522
+ "crisis_id": "missing_child",
523
+ "intro": "Fear has settled over the block. And now, in the middle of it, a child is gone.",
524
+ "outro_win": "The child is found, safe. The relief is enormous — but so is the dread.",
525
+ "outro_loss": "The search drags on into the dark. The town has never felt so alone.",
526
+ "clue": "The child says a 'nice voice on the speaker' told them where to go. Someone is steering all of this.",
527
+ },
528
+ {
529
+ "id": "ch5",
530
+ "crisis_id": "everything_at_once",
531
+ "intro": "Then it all comes at once — fire, blackout, panic. The town finally understands: none of it was an accident.",
532
+ "outro_win": "Battered but unbroken, the five of them stand in the square. They made it through. Together.",
533
+ "outro_loss": "The Long Summer breaks the block. But they'll remember who they were to each other.",
534
+ "clue": "The pieces fit. There was always a hand behind the chaos.",
535
+ },
536
+ ],
537
+ "finale_reveal": (
538
+ "Every fire, every blackout, every storm — traced back to one source. "
539
+ "The one who threw each event at the town. The one at the controls. You."
540
+ ),
541
+ },
542
+ }
worlds/riverside_campus.py ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ _M = "nvidia/Nemotron-Mini-4B-Instruct"
2
+
3
+ WORLD = {
4
+ "id": "riverside_campus",
5
+ "name": "Riverside Campus",
6
+ "theme": "school-town campus beside a river",
7
+ "palette": {"bg": "#071018", "grass": "#214b36", "road": "#253044"},
8
+ "board": {
9
+ "cols": 24,
10
+ "rows": 16,
11
+ "ambient": 10,
12
+ "roads": {"cols": [3, 18], "rows": [4, 11]},
13
+ "plaza": [[10, 7], [11, 7], [12, 7], [10, 8], [11, 8], [12, 8]],
14
+ "plaza_center": [11, 7.5],
15
+ "buildings": [
16
+ {"gx": 6, "gy": 1, "w": 4, "d": 2, "h": 38, "kind": "block", "wall": "#7a7257", "roof": "#ffd76a", "label": "SCHOOL"},
17
+ {"gx": 12, "gy": 1, "w": 3, "d": 2, "h": 32, "kind": "block", "wall": "#5e7779", "roof": "#38e8ff", "label": "SCIENCE"},
18
+ {"gx": 20, "gy": 1, "w": 2, "d": 2, "h": 26, "kind": "house", "wall": "#9b8663", "roof": "#7a4a2e", "label": "GROUNDS"},
19
+ {"gx": 5, "gy": 6, "w": 2, "d": 2, "h": 28, "kind": "shop", "wall": "#8a6b50", "roof": "#ff8a5f", "label": "RIVER CAFÉ"},
20
+ {"gx": 16, "gy": 6, "w": 2, "d": 2, "h": 30, "kind": "block", "wall": "#5a8a7d", "roof": "#7CFFB2", "label": "NURSE"},
21
+ {"gx": 8, "gy": 13, "w": 3, "d": 2, "h": 24, "kind": "house", "wall": "#8a789b", "roof": "#9b6bff", "label": "DORMS"},
22
+ {"gx": 14, "gy": 13, "w": 3, "d": 2, "h": 24, "kind": "house", "wall": "#7f9a72", "roof": "#4a6b32", "label": "FAMILY HALL"},
23
+ ],
24
+ "trees": [[2, 2], [4, 2], [5, 10], [8, 9], [13, 10], [16, 10], [20, 9], [21, 13], [6, 14], [2, 13]],
25
+ "props": [
26
+ {"type": "pond", "gx": 21, "gy": 5, "w": 2, "d": 6},
27
+ {"type": "fountain", "gx": 11, "gy": 8},
28
+ {"type": "bench", "gx": 9, "gy": 7},
29
+ {"type": "bench", "gx": 13, "gy": 8},
30
+ {"type": "table", "gx": 7, "gy": 8},
31
+ {"type": "table", "gx": 5, "gy": 9},
32
+ {"type": "lamp", "gx": 10, "gy": 6},
33
+ {"type": "lamp", "gx": 14, "gy": 6},
34
+ {"type": "planter", "gx": 12, "gy": 10},
35
+ ],
36
+ "hotspots_tile": {
37
+ "school": [8, 3],
38
+ "science_lab": [13, 3],
39
+ "grounds_shed": [20, 3],
40
+ "cafe": [6, 8],
41
+ "nurse_office": [16, 8],
42
+ "quad": [11, 7],
43
+ "library_steps": [12, 10],
44
+ "riverside_walk": [20, 11],
45
+ "dorms": [9, 12],
46
+ "family_hall": [15, 12],
47
+ "square": [9, 8],
48
+ "park": [14, 10],
49
+ },
50
+ "activities": {
51
+ "Elena Brooks": {"tile": [8, 3], "label": "teaches in the classroom"},
52
+ "Samir Patel": {"tile": [13, 3], "label": "checks the science lab"},
53
+ "Maya Chen": {"tile": [11, 7], "label": "sketches on the quad"},
54
+ "Rosa Alvarez": {"tile": [6, 8], "label": "runs the café counter"},
55
+ "Talia Reed": {"tile": [16, 8], "label": "keeps the nurse office ready"},
56
+ "Owen Moss": {"tile": [20, 11], "label": "walks the riverside grounds"},
57
+ },
58
+ },
59
+ "cast": [
60
+ {
61
+ "name": "Elena Brooks", "age": 39, "job": "history teacher", "role": "worker",
62
+ "traits": ["patient", "principled", "quietly funny"],
63
+ "backstory": "She grew up nearby and came back to teach because the campus gave her a future.",
64
+ "relationships": {"Samir Patel": "mentor", "Maya Chen": "mentor", "Rosa Alvarez": "friend", "Talia Reed": "friend", "Owen Moss": "ally"},
65
+ "voice_description": "(a calm adult woman, clear warm teacherly voice)", "model": _M,
66
+ "emoji": "👩‍🏫", "home": "school", "catchphrase_hint": "turns trouble into a lesson",
67
+ "schedule": [[7, 8, "cafe", "coffee before homeroom", {"hunger": -8, "energy": 2}], [8, 15, "school", "teaching classes", {"energy": -10, "social": 8}], [15, 17, "library_steps", "grading on the steps", {"energy": -3}], [17, 22, "family_hall", "calling families and planning", {"energy": -4, "social": 2}], [22, 7, "family_hall", "resting", {"energy": 25}]],
68
+ },
69
+ {
70
+ "name": "Samir Patel", "age": 16, "job": "science student", "role": "student",
71
+ "traits": ["analytical", "anxious", "earnest"],
72
+ "backstory": "He tracks campus patterns in spreadsheets and worries whenever the data gets weird.",
73
+ "relationships": {"Elena Brooks": "mentor", "Maya Chen": "friend", "Rosa Alvarez": "friend", "Talia Reed": "trusts", "Owen Moss": "curious-about"},
74
+ "voice_description": "(a thoughtful teenage boy, precise quick voice)", "model": _M,
75
+ "emoji": "🧑‍🔬", "home": "dorms", "catchphrase_hint": "wants evidence before deciding",
76
+ "schedule": [[7, 8, "dorms", "getting ready", {"energy": -2}], [8, 15, "science_lab", "classes and lab work", {"energy": -8, "social": 5}], [15, 17, "cafe", "study snack", {"hunger": -10, "social": 5}], [17, 22, "dorms", "homework", {"energy": -4}], [22, 7, "dorms", "sleeping", {"energy": 28}]],
77
+ },
78
+ {
79
+ "name": "Maya Chen", "age": 15, "job": "art student", "role": "student",
80
+ "traits": ["creative", "bold", "empathetic"],
81
+ "backstory": "She sketches everyone on campus and notices feelings before facts.",
82
+ "relationships": {"Elena Brooks": "mentor", "Samir Patel": "friend", "Rosa Alvarez": "family-friend", "Talia Reed": "trusts", "Owen Moss": "friend"},
83
+ "voice_description": "(an expressive teenage girl, bright confident voice)", "model": _M,
84
+ "emoji": "🎨", "home": "dorms", "catchphrase_hint": "describes events like scenes in a mural",
85
+ "schedule": [[7, 8, "quad", "morning sketching", {"energy": -1}], [8, 15, "school", "classes", {"energy": -8, "social": 6}], [15, 18, "quad", "art club on the grass", {"energy": -4, "social": 8}], [18, 22, "dorms", "finishing projects", {"energy": -4}], [22, 7, "dorms", "sleeping", {"energy": 28}]],
86
+ },
87
+ {
88
+ "name": "Rosa Alvarez", "age": 51, "job": "campus café owner", "role": "shopkeeper",
89
+ "traits": ["generous", "sharp", "protective"],
90
+ "backstory": "Her café is where students hear news, borrow courage, and sometimes get fed for free.",
91
+ "relationships": {"Elena Brooks": "friend", "Samir Patel": "friend", "Maya Chen": "family-friend", "Talia Reed": "friend", "Owen Moss": "rival"},
92
+ "voice_description": "(a lively middle-aged woman, rich amused voice)", "model": _M,
93
+ "emoji": "☕", "home": "cafe", "catchphrase_hint": "connects every problem to feeding people",
94
+ "schedule": [[6, 8, "cafe", "opening the café", {"energy": -3}], [8, 16, "cafe", "serving students", {"energy": -9, "social": 10}], [16, 18, "quad", "checking on regulars", {"social": 5}], [18, 22, "cafe", "closing and prep", {"energy": -4}], [22, 6, "cafe", "resting upstairs", {"energy": 24}]],
95
+ },
96
+ {
97
+ "name": "Talia Reed", "age": 34, "job": "school nurse", "role": "medic",
98
+ "traits": ["steady", "direct", "kind"],
99
+ "backstory": "She knows which kids are actually sick and which ones just need someone to listen.",
100
+ "relationships": {"Elena Brooks": "friend", "Samir Patel": "trusts", "Maya Chen": "trusts", "Rosa Alvarez": "friend", "Owen Moss": "ally"},
101
+ "voice_description": "(a steady adult woman, gentle firm voice)", "model": _M,
102
+ "emoji": "🩺", "home": "nurse_office", "catchphrase_hint": "triages feelings and injuries together",
103
+ "schedule": [[7, 8, "cafe", "tea before clinic hours", {"hunger": -6}], [8, 16, "nurse_office", "nurse office hours", {"energy": -10, "social": 6}], [16, 18, "riverside_walk", "walking off the day", {"energy": -2}], [18, 22, "family_hall", "checking messages", {"social": 2}], [22, 7, "nurse_office", "resting on call", {"energy": 24}]],
104
+ },
105
+ {
106
+ "name": "Owen Moss", "age": 58, "job": "groundskeeper", "role": "worker",
107
+ "traits": ["patient", "superstitious", "observant"],
108
+ "backstory": "He has maintained the campus paths for decades and knows every pipe, root, and rumor.",
109
+ "relationships": {"Elena Brooks": "ally", "Samir Patel": "curious-about", "Maya Chen": "friend", "Rosa Alvarez": "rival", "Talia Reed": "ally"},
110
+ "voice_description": "(an older man, low slow outdoorsy voice)", "model": _M,
111
+ "emoji": "🧑‍🌾", "home": "grounds_shed", "catchphrase_hint": "blames the river, the weather, or old pipes first",
112
+ "schedule": [[6, 8, "grounds_shed", "tool check", {"energy": -2}], [8, 12, "riverside_walk", "grounds rounds", {"energy": -8}], [12, 13, "cafe", "lunch at the counter", {"hunger": -10, "social": 4}], [13, 17, "quad", "repairs around the quad", {"energy": -7}], [17, 21, "grounds_shed", "locking up", {"energy": -3}], [21, 6, "grounds_shed", "sleeping", {"energy": 26}]],
113
+ },
114
+ ],
115
+ "scenarios": [
116
+ {"id": "exam_postponed", "title": "Final exams are postponed", "event": "The principal announces final exams are postponed because the riverside road flooded overnight.", "focus": "How do students and adults react differently when a stressful deadline suddenly moves?"},
117
+ {"id": "cafe_closure", "title": "The café may lose its lease", "event": "A notice appears saying the campus café lease may not be renewed next semester.", "focus": "Why do shared places matter more than their official purpose?"},
118
+ {"id": "nurse_overflow", "title": "The nurse office fills up", "event": "Half the school comes to the nurse after a mysterious rash starts spreading during lunch.", "focus": "How does a community balance urgency, fear and practical care?"},
119
+ ],
120
+ "events": [
121
+ "The riverside path floods just before morning classes",
122
+ "A rumor spreads that the old gym is haunted",
123
+ "Someone paints a huge mural on the cafeteria wall overnight",
124
+ "The school bell starts ringing five minutes early every hour",
125
+ "A lost dog runs across the quad during lunch",
126
+ "The science lab windows glow blue after sunset",
127
+ "The café runs out of food during a surprise assembly",
128
+ "A teacher finds an anonymous protest flyer in every locker",
129
+ ],
130
+ }