TinyWorld + Crisis Mode, ZeroGPU in-process inference
Browse files- README.md +88 -7
- __pycache__/agents.cpython-310.pyc +0 -0
- __pycache__/app.cpython-310.pyc +0 -0
- __pycache__/campaign.cpython-310.pyc +0 -0
- __pycache__/crisis.cpython-310.pyc +0 -0
- __pycache__/decide.cpython-310.pyc +0 -0
- __pycache__/events.cpython-310.pyc +0 -0
- __pycache__/router.cpython-310.pyc +0 -0
- __pycache__/transcribe.cpython-310.pyc +0 -0
- __pycache__/voice.cpython-310.pyc +0 -0
- __pycache__/world_state.cpython-310.pyc +0 -0
- agents.py +912 -0
- app.py +698 -0
- assets/game.js +646 -0
- assets/map.js +146 -0
- assets/style.css +701 -0
- campaign.py +263 -0
- crisis.py +208 -0
- decide.py +293 -0
- events.py +20 -0
- inference.py +107 -0
- packages.txt +1 -0
- requirements.txt +15 -0
- router.py +119 -0
- transcribe.py +96 -0
- voice.py +88 -0
- world_state.py +412 -0
- worlds/__init__.py +18 -0
- worlds/__pycache__/__init__.cpython-310.pyc +0 -0
- worlds/__pycache__/maple_street.cpython-310.pyc +0 -0
- worlds/__pycache__/riverside_campus.cpython-310.pyc +0 -0
- worlds/maple_street.py +542 -0
- worlds/riverside_campus.py +130 -0
README.md
CHANGED
|
@@ -1,13 +1,94 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|