Spaces:
Sleeping
Sleeping
Add Creative Mode + browser TTS + multi-day timeline + emotion heatmap + 3 new towns
Browse files- SUBMISSION_BLOG.md +73 -45
- VIDEO_SCRIPT.md +93 -56
- app.py +808 -67
- docs/plans/2026-06-12-wave3-timeline-mode.md +912 -0
- model_client.py +28 -0
- prompts.py +62 -0
- sample_towns/custom_happy_sanctuary_town.json +255 -0
- simulator.py +30 -13
- theme.py +193 -5
- town_generator.py +45 -0
SUBMISSION_BLOG.md
CHANGED
|
@@ -4,13 +4,13 @@
|
|
| 4 |
|
| 5 |
> **Live Space:** https://huggingface.co/spaces/build-small-hackathon/analog-town
|
| 6 |
> **Model:** `Qwen/Qwen2.5-7B-Instruct` (fallback: `Qwen/Qwen2.5-14B-Instruct`)
|
| 7 |
-
> **SDK:** Gradio · CPU-basic ·
|
| 8 |
|
| 9 |
---
|
| 10 |
|
| 11 |
## The Pitch in One Paragraph
|
| 12 |
|
| 13 |
-
You drop a single piece of news into a fictional town — *"the old grain silo will be demolished next month"* — and the town reacts. Six residents each have their own values, fears, hopes, and grudges. Instead of chatting with them, you tune an analog radio dial through static and
|
| 14 |
|
| 15 |
---
|
| 16 |
|
|
@@ -18,12 +18,13 @@ You drop a single piece of news into a fictional town — *"the old grain silo w
|
|
| 18 |
|
| 19 |
Writers, tabletop RPG game masters, classroom debate moderators, and community planners all share the same workflow problem: when something new lands in a fictional community — a factory closing, a sacred grove being bisected, a stranger arriving — they have to **mentally simulate every stakeholder's reaction** to make the story (or the policy roleplay) feel honest.
|
| 20 |
|
| 21 |
-
The usual fallback is to open a chat window and ask a large model to roleplay each character one by one. That works, but it has
|
| 22 |
|
| 23 |
1. The model collapses every character into the same voice within a few turns.
|
| 24 |
2. You get vivid prose but no **structured signal** about what each character noticed, what they feared, or which value got triggered.
|
|
|
|
| 25 |
|
| 26 |
-
We wanted a tool that pushed in the opposite direction: **small model, structured output, whimsical interface**. A signal node, not a conversation.
|
| 27 |
|
| 28 |
---
|
| 29 |
|
|
@@ -32,7 +33,7 @@ We wanted a tool that pushed in the opposite direction: **small model, structure
|
|
| 32 |
Under the hood, Analog Town is not roleplay. Each character is a **typed Pydantic state machine**, and the model's only job is to compute one well-defined transition:
|
| 33 |
|
| 34 |
```
|
| 35 |
-
(Agent Profile + Previous Agent State + Broadcast Event)
|
| 36 |
→ Updated Agent State + Internal Monologue + Likely Actions
|
| 37 |
```
|
| 38 |
|
|
@@ -48,9 +49,39 @@ Every transition returns a strict JSON object (see `schemas.py`) with fields lik
|
|
| 48 |
- `uncertainty` — what the model itself isn't sure about
|
| 49 |
- `safety_note` — a fixed disclaimer that this is fictional rehearsal, not prediction
|
| 50 |
|
| 51 |
-
The system prompt is uncompromising: *"Your job is not to produce the most dramatic answer. Your job is to produce a plausible, grounded, internally consistent state transition."* If the JSON comes back malformed, a
|
| 52 |
|
| 53 |
-
This is the design lever that lets a **7B model** carry a six-character simulation: we never ask it to generate freely, we ask it to fill a tightly-shaped form.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
---
|
| 56 |
|
|
@@ -58,13 +89,13 @@ This is the design lever that lets a **7B model** carry a six-character simulati
|
|
| 58 |
|
| 59 |
The interface is the point. Analog Town is built to feel like the inside of a beat-up radio van parked at the edge of a town you don't live in.
|
| 60 |
|
| 61 |
-
**The CRT theme.** A custom Gradio CSS theme
|
| 62 |
|
| 63 |
-
**The dial.** A frequency slider runs from 87.0 to 108.0 FM. Each character lives on a fixed frequency. As you slide closer to a station, the signal-strength bar climbs
|
| 64 |
|
| 65 |
-
**The static.**
|
| 66 |
|
| 67 |
-
**The map.**
|
| 68 |
|
| 69 |
- **Graybridge** — a foggy mill town arguing about a luxury hotel conversion
|
| 70 |
- **Neighborhood Council** — a suburban block meeting about a 5G tower
|
|
@@ -72,33 +103,35 @@ The interface is the point. Analog Town is built to feel like the inside of a be
|
|
| 72 |
- **Brimstone Hollow** — a desert outpost rattled by an unexplained sinkhole
|
| 73 |
- **Echo Summit** — a polar research station picking up a strange signal
|
| 74 |
- **Rustwood** — a rust-belt town whose factory just announced reopening
|
|
|
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
Characters are placed on
|
| 77 |
|
| 78 |
---
|
| 79 |
|
| 80 |
-
## A Walkthrough:
|
| 81 |
|
| 82 |
-
You load **
|
| 83 |
|
| 84 |
-
> *"The
|
| 85 |
|
| 86 |
-
You hit **
|
| 87 |
|
| 88 |
-
You
|
| 89 |
|
| 90 |
-
> *"They're putting
|
| 91 |
|
| 92 |
-
`
|
| 93 |
-
`value_conflict`: *preservation of working-class history vs. economic survival of the town*.
|
| 94 |
-
`emotion_delta`: `trust: -0.15, anger: +0.20, hope: -0.10`.
|
| 95 |
-
`likely_public_action`: *"Write a letter to the council. Sign it 'M. Halloran, retired.'"*
|
| 96 |
|
| 97 |
-
|
| 98 |
|
| 99 |
-
|
| 100 |
|
| 101 |
-
|
|
|
|
|
|
|
| 102 |
|
| 103 |
---
|
| 104 |
|
|
@@ -106,15 +139,13 @@ Same event. Same town. Two minds. The dial keeps going.
|
|
| 106 |
|
| 107 |
Analog Town was engineered against the **Build Small Hackathon** constraints from the first commit:
|
| 108 |
|
| 109 |
-
1. **Models under 32B.** We use `Qwen/Qwen2.5-7B-Instruct` as the primary inference target through the Hugging Face Inference Client, with `Qwen/Qwen2.5-14B-Instruct` as an automatic fallback if rate limits hit.
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
5. **Inspectable exports.** Every simulation run can be exported as a Hugging Face-compatible dataset (`town.json`, `broadcast_event.json`, `agent_traces.jsonl`, auto-generated dataset card). Naive PII patterns are stripped during export (`export_hub.py`), and a safety note is attached to every transition.
|
| 118 |
|
| 119 |
---
|
| 120 |
|
|
@@ -122,22 +153,19 @@ Analog Town was engineered against the **Build Small Hackathon** constraints fro
|
|
| 122 |
|
| 123 |
- **No persistent chat.** Each broadcast is one transition. The simulator is a *rehearsal*, not a relationship.
|
| 124 |
- **No "predict the real person" mode.** The prompts explicitly forbid revealing hidden facts not in the profile, and the system prompt refuses to treat output as factual prediction.
|
| 125 |
-
- **No multi-turn drift.** Because the model never sees its own prior monologue as context — only the last *structured* state — we sidestep the slow voice-collapse problem of long roleplay sessions.
|
| 126 |
-
|
| 127 |
-
---
|
| 128 |
-
|
| 129 |
-
## What We'd Love Feedback On
|
| 130 |
-
|
| 131 |
-
- **Whimsy density.** Is the radio metaphor doing real work for you, or is it set dressing? We want it to feel like an *instrument*, not a costume.
|
| 132 |
-
- **Town presets.** Which of the six conflicts pulls you in fastest? We'll lean into that one next.
|
| 133 |
-
- **The structured trace panel.** We currently surface the monologue prominently and the JSON deltas in a collapsible "trace" view. Should the deltas be more visible by default for GMs / writers who want the mechanical readout?
|
| 134 |
|
| 135 |
---
|
| 136 |
|
| 137 |
## Try It
|
| 138 |
|
| 139 |
-
🎙
|
| 140 |
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
*Analog Town is not a chatbot. It's a tiny social weather station for fictional worlds.*
|
|
|
|
| 4 |
|
| 5 |
> **Live Space:** https://huggingface.co/spaces/build-small-hackathon/analog-town
|
| 6 |
> **Model:** `Qwen/Qwen2.5-7B-Instruct` (fallback: `Qwen/Qwen2.5-14B-Instruct`)
|
| 7 |
+
> **SDK:** Gradio · CPU-basic · browser-side TTS, no GPU required
|
| 8 |
|
| 9 |
---
|
| 10 |
|
| 11 |
## The Pitch in One Paragraph
|
| 12 |
|
| 13 |
+
You drop a single piece of news into a fictional town — *"the old grain silo will be demolished next month"* — and the town reacts. Six residents each have their own values, fears, hopes, and grudges. Instead of chatting with them, you tune an analog radio dial through static and **hear** each resident's inner monologue in a different voice. The next day, you broadcast a follow-up. The town remembers. The map slowly drifts — angry, hopeful, suspicious. That is **Analog Town**: a perspective-rehearsal tool dressed up as a 1970s shortwave receiver, run by a 7B model with a typed state machine glued behind it.
|
| 14 |
|
| 15 |
---
|
| 16 |
|
|
|
|
| 18 |
|
| 19 |
Writers, tabletop RPG game masters, classroom debate moderators, and community planners all share the same workflow problem: when something new lands in a fictional community — a factory closing, a sacred grove being bisected, a stranger arriving — they have to **mentally simulate every stakeholder's reaction** to make the story (or the policy roleplay) feel honest.
|
| 20 |
|
| 21 |
+
The usual fallback is to open a chat window and ask a large model to roleplay each character one by one. That works, but it has three failure modes:
|
| 22 |
|
| 23 |
1. The model collapses every character into the same voice within a few turns.
|
| 24 |
2. You get vivid prose but no **structured signal** about what each character noticed, what they feared, or which value got triggered.
|
| 25 |
+
3. There's no sense of *time* — every prompt is a one-shot, never a multi-day arc.
|
| 26 |
|
| 27 |
+
We wanted a tool that pushed in the opposite direction: **small model, structured output, whimsical interface, time built in**. A signal node, not a conversation.
|
| 28 |
|
| 29 |
---
|
| 30 |
|
|
|
|
| 33 |
Under the hood, Analog Town is not roleplay. Each character is a **typed Pydantic state machine**, and the model's only job is to compute one well-defined transition:
|
| 34 |
|
| 35 |
```
|
| 36 |
+
(Agent Profile + Previous Agent State + Broadcast Event + Day N)
|
| 37 |
→ Updated Agent State + Internal Monologue + Likely Actions
|
| 38 |
```
|
| 39 |
|
|
|
|
| 49 |
- `uncertainty` — what the model itself isn't sure about
|
| 50 |
- `safety_note` — a fixed disclaimer that this is fictional rehearsal, not prediction
|
| 51 |
|
| 52 |
+
The system prompt is uncompromising: *"Your job is not to produce the most dramatic answer. Your job is to produce a plausible, grounded, internally consistent state transition."* If the JSON comes back malformed, a dedicated `REPAIR_PROMPT` rewrites it cleanly.
|
| 53 |
|
| 54 |
+
This is the design lever that lets a **7B model** carry a six-character, multi-day simulation: we never ask it to generate freely, we ask it to fill a tightly-shaped form.
|
| 55 |
+
|
| 56 |
+
---
|
| 57 |
+
|
| 58 |
+
## What's New This Build (the headline features)
|
| 59 |
+
|
| 60 |
+
The hackathon push added five differentiators on top of the original single-shot prototype.
|
| 61 |
+
|
| 62 |
+
### 🔥 1. Emotion heatmap on the map
|
| 63 |
+
|
| 64 |
+
After every broadcast, each sprite's avatar ring tints to that character's dominant emotion: red for anger, orange for fear, amber for hope, green for trust, blue for curiosity, purple for social energy. The map becomes a glanceable **mood report** — you can see at a single look which way each resident bent.
|
| 65 |
+
|
| 66 |
+
### 📢 2. Anomaly flags
|
| 67 |
+
|
| 68 |
+
If any axis of an agent's `emotion_delta` swings by more than 0.6 in a single broadcast, a pulsing 📢 badge appears on their sprite. Writers and GMs use this to spot dramatic story beats automatically — these are the residents whose minds just lurched.
|
| 69 |
+
|
| 70 |
+
### 🎙 3. Browser-side TTS through the radio static
|
| 71 |
+
|
| 72 |
+
Tune the dial to an agent or click their sprite, and a distinct voice **speaks their monologue aloud** through the existing ambient-static loop. The static smoothly ducks while the voice plays and swells back up when it ends. Each character gets a deterministically-picked voice from a curated pool of 30+ system voices (male/female, with pitch shifts for elder/young characters), so Margaret the retired stationmaster doesn't sound like Devon the line cook.
|
| 73 |
+
|
| 74 |
+
We chose browser-side `SpeechSynthesisUtterance` over an HF Inference TTS endpoint for three reasons: zero latency, zero rate-limit risk, and zero GPU cost — true to the "build small" spirit.
|
| 75 |
+
|
| 76 |
+
### 📅 4. Day-N chained broadcasts + timeline
|
| 77 |
+
|
| 78 |
+
Today's most differentiating feature. Press **TRANSMIT** once for Day 1. Press it again — agents continue from their post-Day-1 emotional state, with a prompt prefix that tells the model *"this is Day 2, do not echo what they said last time."* A small horizontal **timeline strip** shows each broadcast as a clickable pill (`DAY 1 · Hotel announcement · ⚠ 2 anomalies`). The map heatmap visibly drifts day over day. Click any past pill to time-travel: the map reverts to that day's state, the slider re-tunes through that day's monologues, the day badge updates. A 🔁 **RESET** button next to TRANSMIT clears the chain back to Day 1.
|
| 79 |
+
|
| 80 |
+
This turns a one-shot rehearsal into a **multi-day arc**. Day 1: "the factory will reopen." Day 3: "wages will be 30% lower than last time." Day 5: "first walkout scheduled." Watch each character's mood lurch across the map.
|
| 81 |
+
|
| 82 |
+
### 🎨 5. Creative Mode — design your own town
|
| 83 |
+
|
| 84 |
+
Open the **🎨 CREATIVE MODE** accordion, type a 1–3 sentence concept (*"a haunted seaside lighthouse where the keeper has vanished and the village is split on whether to investigate"*), pick how many residents you want (3–6), and hit **🪄 GENERATE WITH AI**. Qwen 2.5 7B drafts a complete, schema-validated `Town` JSON — full agent dossiers, frequencies, map positions, relationships, and a default broadcast event. You can edit the JSON inline, upload a custom map PNG, then **💾 SAVE & LOAD** to register the town and start broadcasting. Frequencies are auto-spread, sprite positions clamped, and the town gets a `custom_` prefix so it never overwrites a preset.
|
| 85 |
|
| 86 |
---
|
| 87 |
|
|
|
|
| 89 |
|
| 90 |
The interface is the point. Analog Town is built to feel like the inside of a beat-up radio van parked at the edge of a town you don't live in.
|
| 91 |
|
| 92 |
+
**The CRT theme.** A custom Gradio CSS theme renders the whole app in amber-and-green phosphor type on pitch-black cards, with scanning-line overlays and glowing horizontal signal-strength bars. IBM Plex Mono for instrument readouts, Crimson Pro for monologues — the typography itself splits "machine talking" from "human talking."
|
| 93 |
|
| 94 |
+
**The dial.** A frequency slider runs from 87.0 to 108.0 FM. Each character lives on a fixed frequency. As you slide closer to a station, the signal-strength bar climbs, the corresponding character's dossier comes into focus, and their voice fades in through the static.
|
| 95 |
|
| 96 |
+
**The static.** A loopable 15-second analog hiss bed lives under everything. A small client-side audio manager watches the signal-strength value and **smoothly fades the static out** when you tune into a station, then back in when you scan between them — and ducks further whenever a voice is speaking.
|
| 97 |
|
| 98 |
+
**The map.** Nine town presets each ship with a custom 2D isometric pixel-art background, each representing a unique narrative conflict:
|
| 99 |
|
| 100 |
- **Graybridge** — a foggy mill town arguing about a luxury hotel conversion
|
| 101 |
- **Neighborhood Council** — a suburban block meeting about a 5G tower
|
|
|
|
| 103 |
- **Brimstone Hollow** — a desert outpost rattled by an unexplained sinkhole
|
| 104 |
- **Echo Summit** — a polar research station picking up a strange signal
|
| 105 |
- **Rustwood** — a rust-belt town whose factory just announced reopening
|
| 106 |
+
- **Tin Lantern Junction** — a sun-bleached Route 66 stopover town facing an autonomous-truck depot
|
| 107 |
+
- **Lotus Wharf** — a SE Asian floating river-market village threatened by a bridge that bypasses the market
|
| 108 |
+
- **Verdant Spire** — a near-future solarpunk vertical city where a bonded municipal AI has just filed for mayor
|
| 109 |
|
| 110 |
+
Characters are placed on each map as circular pixel-art tokens. Clicking a token tunes the receiver, swaps in their dossier, and starts speaking their latest intercepted thought.
|
| 111 |
|
| 112 |
---
|
| 113 |
|
| 114 |
+
## A Walkthrough: Three Days, One Town
|
| 115 |
|
| 116 |
+
You load **Port Whisper** — a coastal fishing village. Four residents, one foggy harbor map. The broadcast box pre-fills with the town's default scenario:
|
| 117 |
|
| 118 |
+
> *Day 1: "The state energy department has approved the construction of a 40-turbine offshore wind array, 5 miles off the Port Whisper coast."*
|
| 119 |
|
| 120 |
+
You hit **TRANSMIT**. Four model calls run, four typed transitions come back. The map sprites tint: Captain Arthur (the elder fisherman) goes red, Mayor Sarah (the pragmatist) goes amber, Chloe (the marine biologist) goes blue. A 📢 badge pulses on Captain Arthur — his emotional delta exceeded 0.6.
|
| 121 |
|
| 122 |
+
You tune to **89.1 MHz** — the static fades and Captain Arthur's voice comes through:
|
| 123 |
|
| 124 |
+
> *"They're putting iron in my ocean. They didn't ask. They didn't visit. They never do."*
|
| 125 |
|
| 126 |
+
You change the broadcast text for Day 2 to *"The state has scheduled a public hearing at the harbor next Friday and is offering union-scale jobs in turbine maintenance."* You TRANSMIT again. Now the prompt carries `DAY 2`, the temperature bumps up slightly, and the explicit instruction tells the model *don't echo prior beats.*
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
+
The map redraws. Captain Arthur is still red but the anomaly badge is gone (his delta this round was smaller). Mayor Sarah turned green — trust climbing. A new `DAY 2` pill appears in the timeline strip and the map's day badge updates.
|
| 129 |
|
| 130 |
+
You tune back to Arthur:
|
| 131 |
|
| 132 |
+
> *"A hearing. I've sat through a hundred hearings. But maintenance jobs — that's the first thing they've said that sounds like remembering us."*
|
| 133 |
+
|
| 134 |
+
You click the `DAY 1` pill — the map snaps back to Day 1's heatmap and Arthur's Day 1 voice returns. Click `DAY 2` to come back. Press 🔁 **RESET** to clear the chain.
|
| 135 |
|
| 136 |
---
|
| 137 |
|
|
|
|
| 139 |
|
| 140 |
Analog Town was engineered against the **Build Small Hackathon** constraints from the first commit:
|
| 141 |
|
| 142 |
+
1. **Models under 32B.** We use `Qwen/Qwen2.5-7B-Instruct` as the primary inference target through the Hugging Face Inference Client, with `Qwen/Qwen2.5-14B-Instruct` as an automatic fallback if rate limits hit.
|
| 143 |
+
2. **Structured generation beats brute force.** Because every model call is shaped by a Pydantic schema and a strict system prompt, we run a full town reaction (4–6 characters) on a small model without losing coherence. We keep `max_tokens=900` and `temperature=0.3` for Day 1, ramping to ~0.55 for chained days to encourage variation without losing JSON discipline.
|
| 144 |
+
3. **Browser-side TTS.** We use `window.speechSynthesis` instead of a server-side TTS endpoint. Zero GPU, zero rate-limits, instant playback, distinct per-character voices.
|
| 145 |
+
4. **Client-side audio mixing.** Static fade is computed in JavaScript from the signal-strength bars. Voice ducking is handled the same way. Zero server-side audio processing.
|
| 146 |
+
5. **Static map assets.** Each town's pixel-art map is a single PNG; sprite tokens are static avatars composed on top via CSS. The Gradio app runs comfortably on `cpu-basic` hardware.
|
| 147 |
+
6. **AI-generated towns reuse the same model.** Creative Mode's town generation uses the *same* `Qwen 2.5 7B` endpoint as the simulator — no separate model, no separate inference path.
|
| 148 |
+
7. **Inspectable exports.** Every simulation run can be exported as a Hugging Face-compatible dataset (`town.json`, `broadcast_event.json`, `agent_traces.jsonl`, auto-generated dataset card). Naive PII patterns are stripped during export, and a safety note is attached to every transition.
|
|
|
|
|
|
|
| 149 |
|
| 150 |
---
|
| 151 |
|
|
|
|
| 153 |
|
| 154 |
- **No persistent chat.** Each broadcast is one transition. The simulator is a *rehearsal*, not a relationship.
|
| 155 |
- **No "predict the real person" mode.** The prompts explicitly forbid revealing hidden facts not in the profile, and the system prompt refuses to treat output as factual prediction.
|
| 156 |
+
- **No multi-turn voice drift.** Because the model never sees its own prior monologue as direct context — only the last *structured* state — we sidestep the slow voice-collapse problem of long roleplay sessions.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
| 158 |
---
|
| 159 |
|
| 160 |
## Try It
|
| 161 |
|
| 162 |
+
🎙 **Tune in:** https://huggingface.co/spaces/build-small-hackathon/analog-town
|
| 163 |
|
| 164 |
+
1. Click anywhere once (unlocks ambient audio + browser TTS).
|
| 165 |
+
2. Pick a town (or open **🎨 CREATIVE MODE** and generate your own).
|
| 166 |
+
3. Type a broadcast.
|
| 167 |
+
4. TRANSMIT. Watch the map tint.
|
| 168 |
+
5. Tune the dial. Listen.
|
| 169 |
+
6. TRANSMIT again. Watch the town drift.
|
| 170 |
|
| 171 |
*Analog Town is not a chatbot. It's a tiny social weather station for fictional worlds.*
|
VIDEO_SCRIPT.md
CHANGED
|
@@ -1,21 +1,23 @@
|
|
| 1 |
-
# Analog Town — Demo Video Script
|
| 2 |
|
| 3 |
-
**Target length:**
|
| 4 |
**Tone:** Calm, slightly conspiratorial — like you really are operating a radio van. Don't oversell. Let the interface do the work.
|
| 5 |
-
**Recording tip:** Use OBS or QuickTime. Capture system audio so the static loop
|
| 6 |
|
| 7 |
---
|
| 8 |
|
| 9 |
## Pre-Recording Checklist
|
| 10 |
|
| 11 |
-
- [ ] App is running and loaded —
|
| 12 |
-
- [ ] Browser window sized 1440×900, zoom 110%
|
| 13 |
-
- [ ] System volume on
|
| 14 |
-
- [ ] Pick the town: **
|
| 15 |
-
- [ ] Pre-write the
|
| 16 |
-
|
| 17 |
-
-
|
| 18 |
-
|
|
|
|
|
|
|
| 19 |
- [ ] Close all other tabs / Slack / notifications
|
| 20 |
- [ ] Mic check — say a sentence, play it back, then start
|
| 21 |
|
|
@@ -27,119 +29,152 @@
|
|
| 27 |
|
| 28 |
**You say:**
|
| 29 |
|
| 30 |
-
> "This is Analog Town. It's a shortwave radio that picks up the inside of fictional people's heads. You drop a piece of news into a town — and you tune the dial to
|
| 31 |
|
| 32 |
**Action:** Slowly drag the frequency slider 1–2 ticks while talking. Let the static crackle change pitch. Don't land on a station yet.
|
| 33 |
|
| 34 |
---
|
| 35 |
|
| 36 |
-
## Scene 2 —
|
| 37 |
|
| 38 |
-
**On screen:** Open the
|
| 39 |
|
| 40 |
**You say:**
|
| 41 |
|
| 42 |
-
> "We ship
|
| 43 |
|
| 44 |
-
**Action:** Click **
|
| 45 |
|
| 46 |
---
|
| 47 |
|
| 48 |
-
## Scene 3 —
|
| 49 |
|
| 50 |
-
**On screen:** The
|
| 51 |
|
| 52 |
**You say:**
|
| 53 |
|
| 54 |
-
> "
|
| 55 |
|
| 56 |
-
**Action:**
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
| 58 |
---
|
| 59 |
|
| 60 |
-
## Scene 4 —
|
| 61 |
|
| 62 |
-
**On screen:** Move to the
|
| 63 |
|
| 64 |
**You say:**
|
| 65 |
|
| 66 |
-
> "I
|
|
|
|
|
|
|
| 67 |
|
| 68 |
-
**
|
| 69 |
|
| 70 |
-
|
| 71 |
|
| 72 |
-
|
| 73 |
|
| 74 |
-
|
| 75 |
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
---
|
| 79 |
|
| 80 |
-
## Scene 5 —
|
| 81 |
|
| 82 |
-
**On screen:**
|
| 83 |
|
| 84 |
**You say:**
|
| 85 |
|
| 86 |
-
> "Now
|
| 87 |
|
| 88 |
-
**Action:**
|
| 89 |
|
| 90 |
-
**
|
| 91 |
|
| 92 |
-
|
| 93 |
|
| 94 |
-
|
| 95 |
|
| 96 |
-
|
| 97 |
|
| 98 |
-
|
| 99 |
|
| 100 |
-
**
|
| 101 |
|
| 102 |
-
|
| 103 |
|
| 104 |
-
|
| 105 |
|
| 106 |
---
|
| 107 |
|
| 108 |
-
## Scene 6 —
|
| 109 |
|
| 110 |
-
**On screen:**
|
| 111 |
|
| 112 |
**You say:**
|
| 113 |
|
| 114 |
-
> "
|
|
|
|
|
|
|
| 115 |
|
| 116 |
-
**
|
| 117 |
|
| 118 |
-
|
| 119 |
|
| 120 |
-
|
| 121 |
|
| 122 |
---
|
| 123 |
|
| 124 |
-
## Scene 7 —
|
| 125 |
|
| 126 |
-
**On screen:** Open the **
|
| 127 |
|
| 128 |
**You say:**
|
| 129 |
|
| 130 |
-
> "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
-
|
| 133 |
|
| 134 |
---
|
| 135 |
|
| 136 |
-
## Scene 8 — Close (
|
| 137 |
|
| 138 |
-
**On screen:** Pull back to a wide shot of the full interface — map, dial, monologue panel.
|
| 139 |
|
| 140 |
**You say:**
|
| 141 |
|
| 142 |
-
> "Analog Town. A 7B model, a typed state machine,
|
| 143 |
|
| 144 |
**Action:** Hold on the wide shot for 2 beats. Cut.
|
| 145 |
|
|
@@ -147,8 +182,9 @@ Click **Broadcast**.
|
|
| 147 |
|
| 148 |
## Backup Lines (in case something breaks)
|
| 149 |
|
| 150 |
-
- **If
|
| 151 |
-
- **If
|
|
|
|
| 152 |
- **If the static doesn't fade audibly on recording:** *"The static you can't quite hear right now is fading out because the signal strength just climbed — it's a client-side audio effect tied to the dial."*
|
| 153 |
|
| 154 |
---
|
|
@@ -159,9 +195,10 @@ Click **Broadcast**.
|
|
| 159 |
- Don't promise it predicts real people. The safety framing is part of the design.
|
| 160 |
- Don't read JSON fields out loud word-for-word — paraphrase.
|
| 161 |
- Don't apologize for the small model. Frame the smallness as the design choice it is.
|
|
|
|
| 162 |
|
| 163 |
---
|
| 164 |
|
| 165 |
## One-Line Elevator Pitch (for thumbnail / caption)
|
| 166 |
|
| 167 |
-
> *"A shortwave radio for fictional minds. Drop news into a town. Tune the dial.
|
|
|
|
| 1 |
+
# Analog Town — Demo Video Script (v2, with TTS / Heatmap / Timeline / Creative Mode)
|
| 2 |
|
| 3 |
+
**Target length:** 3 min (hard cap 3 min 30 sec)
|
| 4 |
**Tone:** Calm, slightly conspiratorial — like you really are operating a radio van. Don't oversell. Let the interface do the work.
|
| 5 |
+
**Recording tip:** Use OBS or QuickTime. Capture system audio so the static loop *and the spoken monologues* are audible. Set browser zoom to 110% so the CRT panel reads on mobile playback.
|
| 6 |
|
| 7 |
---
|
| 8 |
|
| 9 |
## Pre-Recording Checklist
|
| 10 |
|
| 11 |
+
- [ ] App is running and **loaded once in this browser session** — TTS unlocks on first click, ambient audio unlocks on first interaction. Click anywhere before recording.
|
| 12 |
+
- [ ] Browser window sized 1440×900, zoom 110%, DevTools closed
|
| 13 |
+
- [ ] System volume on, **headphones recommended** so you can hear the static fade and voice ducking
|
| 14 |
+
- [ ] Pick the town: **Port Whisper** (best demo because the wind-farm conflict has clear sides and the heatmap is dramatic)
|
| 15 |
+
- [ ] Pre-write the broadcasts in a notes window — you'll paste them in:
|
| 16 |
+
- **Day 1:** *"The state energy department has approved the construction of a 40-turbine offshore wind array, 5 miles off the Port Whisper coast."*
|
| 17 |
+
- **Day 2:** *"The state has scheduled a public hearing at the harbor next Friday and is offering union-scale jobs in turbine maintenance."*
|
| 18 |
+
- **Day 3 (optional):** *"Protesters blocked the harbor entrance at dawn. Two arrested. The mayor calls for calm."*
|
| 19 |
+
- [ ] Pre-write the **Creative Mode concept** for the closing demo:
|
| 20 |
+
*"A haunted seaside lighthouse where the keeper has vanished. The village is split on whether to investigate or burn it to the ground."*
|
| 21 |
- [ ] Close all other tabs / Slack / notifications
|
| 22 |
- [ ] Mic check — say a sentence, play it back, then start
|
| 23 |
|
|
|
|
| 29 |
|
| 30 |
**You say:**
|
| 31 |
|
| 32 |
+
> "This is Analog Town. It's a shortwave radio that picks up the inside of fictional people's heads. You drop a piece of news into a town — and you tune the dial to hear what each resident is privately thinking about it. Listen."
|
| 33 |
|
| 34 |
**Action:** Slowly drag the frequency slider 1–2 ticks while talking. Let the static crackle change pitch. Don't land on a station yet.
|
| 35 |
|
| 36 |
---
|
| 37 |
|
| 38 |
+
## Scene 2 — Meet the Town (0:15 – 0:35)
|
| 39 |
|
| 40 |
+
**On screen:** Open the **TOWN** dropdown. Pause on it just long enough that the nine options are readable. Pick **Port Whisper**.
|
| 41 |
|
| 42 |
**You say:**
|
| 43 |
|
| 44 |
+
> "We ship nine towns, each with a built-in conflict. A coastal village debating a wind farm, a polar research station picking up a strange signal, a near-future vertical city where a municipal AI just filed for mayor. I'll load Port Whisper."
|
| 45 |
|
| 46 |
+
**Action:** Click **Port Whisper**. Wait for the isometric map to load with the four pixel-art character tokens placed on it. The dossier accordion shows below the map.
|
| 47 |
|
| 48 |
---
|
| 49 |
|
| 50 |
+
## Scene 3 — Day 1 Broadcast (0:35 – 1:05)
|
| 51 |
|
| 52 |
+
**On screen:** The broadcast field has pre-filled with the default scenario. Move to it.
|
| 53 |
|
| 54 |
**You say:**
|
| 55 |
|
| 56 |
+
> "Day 1. A wind farm is approved off the coast. I broadcast it into the town."
|
| 57 |
|
| 58 |
+
**Action:** Press **TRANSMIT**. While the progress bar runs in the status panel:
|
| 59 |
+
|
| 60 |
+
**You say (~10 seconds of cover narration):**
|
| 61 |
+
|
| 62 |
+
> "Behind the scenes, this is Qwen 2.5 7B through the Hugging Face Inference API. Each resident is processed as a typed state transition — not roleplay — so the model produces structured JSON: what the character noticed, which memory got triggered, which value got bruised, and how their emotional state shifted."
|
| 63 |
+
|
| 64 |
+
**On screen:** Simulation completes. The four sprites visibly **tint by emotion** — red on Captain Arthur, amber on Mayor Sarah, blue on Chloe, etc. A pulsing **📢 anomaly badge** appears on at least one sprite. A `DAY 1` pill appears in the timeline strip and a `DAY 1` badge in the corner of the map.
|
| 65 |
+
|
| 66 |
+
**You say:**
|
| 67 |
+
|
| 68 |
+
> "The map just tinted. Red means anger, amber is hope, green is trust, blue is curiosity. That badge — the megaphone — means this character's emotional state shifted by more than 60 percent on at least one axis. Big reaction. Worth tuning in to."
|
| 69 |
|
| 70 |
---
|
| 71 |
|
| 72 |
+
## Scene 4 — Tune the Dial, Hear the Voice (1:05 – 1:35)
|
| 73 |
|
| 74 |
+
**On screen:** Move the cursor to the frequency slider.
|
| 75 |
|
| 76 |
**You say:**
|
| 77 |
|
| 78 |
+
> "Now I tune the dial."
|
| 79 |
+
|
| 80 |
+
**Action:** Slowly drag the dial up toward **Captain Arthur Vance** (around 89.1 MHz). As you approach, the **signal-strength bar climbs and the static audibly fades**. Land on his station. The dossier loads. His monologue appears in the CRT panel.
|
| 81 |
|
| 82 |
+
**Important:** Don't read the monologue aloud yourself. **Wait.** The browser TTS will speak it in a distinct voice over the (now-quiet) static. Stay silent for the ~6 seconds it takes.
|
| 83 |
|
| 84 |
+
**On screen:** The 🔊 voice LED next to the VOICE button flashes green while the voice plays. Static ducks under the voice.
|
| 85 |
|
| 86 |
+
**After it finishes, you say:**
|
| 87 |
|
| 88 |
+
> "Different voice for every character. The static ducks under them. And under every monologue is the structured trace — the emotion deltas, the activated memory, the value conflict — so writers and game masters don't just get prose, they get a mechanical readout of *why* this character reacted this way."
|
| 89 |
|
| 90 |
+
**Action:** Click directly on a different sprite on the map (e.g., **Chloe Chen**, the marine biologist). The dial snaps to her frequency, the dossier swaps, her voice — distinctly different from Arthur's — speaks her monologue.
|
| 91 |
+
|
| 92 |
+
**You say (while her voice plays):**
|
| 93 |
+
|
| 94 |
+
> "Same broadcast. Different mind."
|
| 95 |
|
| 96 |
---
|
| 97 |
|
| 98 |
+
## Scene 5 — Day 2 Drift (1:35 – 2:20)
|
| 99 |
|
| 100 |
+
**On screen:** Move to the broadcast text box. Clear the existing content. Paste Day 2's broadcast.
|
| 101 |
|
| 102 |
**You say:**
|
| 103 |
|
| 104 |
+
> "Now the second day. Same town. Different beat."
|
| 105 |
|
| 106 |
+
**Action:** Press **TRANSMIT**. While it runs:
|
| 107 |
|
| 108 |
+
**You say:**
|
| 109 |
|
| 110 |
+
> "The agents don't reset. Their Day 1 emotional state becomes the seed for Day 2 — the simulator carries the drift forward. And I'm telling the model explicitly: *do not echo what they said yesterday.*"
|
| 111 |
|
| 112 |
+
**On screen:** Simulation completes. The map sprites **re-tint**. Watch for changes — Captain Arthur may shift from red to amber, Mayor Sarah may go greener. A `DAY 2` pill joins the timeline strip; the day badge updates.
|
| 113 |
|
| 114 |
+
**You say:**
|
| 115 |
|
| 116 |
+
> "Watch the map. The town just drifted."
|
| 117 |
|
| 118 |
+
**Action:** Tune back to Captain Arthur. His Day 2 voice comes through with clearly different prose from Day 1. Wait for the voice to finish.
|
| 119 |
|
| 120 |
+
**You say:**
|
| 121 |
|
| 122 |
+
> "Day 1 he said *they're putting iron in my ocean.* Day 2 he heard about jobs and a hearing. Listen to what's softened."
|
| 123 |
|
| 124 |
---
|
| 125 |
|
| 126 |
+
## Scene 6 — Time Travel (2:20 – 2:40)
|
| 127 |
|
| 128 |
+
**On screen:** Hover the timeline strip below the broadcast bar.
|
| 129 |
|
| 130 |
**You say:**
|
| 131 |
|
| 132 |
+
> "And I can time-travel."
|
| 133 |
+
|
| 134 |
+
**Action:** Click the **DAY 1** pill. The map snaps back to Day 1's heatmap, the day badge says DAY 1, Captain Arthur is red again. Tune back to him — his Day 1 voice returns.
|
| 135 |
|
| 136 |
+
**You say:**
|
| 137 |
|
| 138 |
+
> "Day 1's snapshot, intact. Click Day 2 again — back to the present."
|
| 139 |
|
| 140 |
+
**Action:** Click the **DAY 2** pill. Map returns to Day 2 state.
|
| 141 |
|
| 142 |
---
|
| 143 |
|
| 144 |
+
## Scene 7 — Creative Mode (2:40 – 3:10)
|
| 145 |
|
| 146 |
+
**On screen:** Open the **🎨 CREATIVE MODE** accordion above the map.
|
| 147 |
|
| 148 |
**You say:**
|
| 149 |
|
| 150 |
+
> "And you can design your own town. Type a concept."
|
| 151 |
+
|
| 152 |
+
**Action:** Paste into the concept field:
|
| 153 |
+
> *A haunted seaside lighthouse where the keeper has vanished. The village is split on whether to investigate or burn it to the ground.*
|
| 154 |
+
|
| 155 |
+
Set agent count to 4. Click **🪄 GENERATE WITH AI**.
|
| 156 |
+
|
| 157 |
+
**You say (while it generates):**
|
| 158 |
+
|
| 159 |
+
> "The same Qwen model that runs the simulator drafts a complete town: residents, frequencies, fears, hopes, relationships, even a default broadcast event. All in valid JSON. All editable."
|
| 160 |
+
|
| 161 |
+
**On screen:** Generated JSON appears in the editable field below.
|
| 162 |
+
|
| 163 |
+
**Action:** Click **💾 SAVE & LOAD**. The dropdown refreshes; the new town loads. The map shows the new agents.
|
| 164 |
+
|
| 165 |
+
**You say:**
|
| 166 |
|
| 167 |
+
> "Saved, loaded, and ready to broadcast."
|
| 168 |
|
| 169 |
---
|
| 170 |
|
| 171 |
+
## Scene 8 — Close (3:10 – 3:25)
|
| 172 |
|
| 173 |
+
**On screen:** Pull back to a wide shot of the full interface — map with the new town, dial, monologue panel, timeline strip ready to start.
|
| 174 |
|
| 175 |
**You say:**
|
| 176 |
|
| 177 |
+
> "Analog Town. A 7B model, a typed state machine, a radio dial, and a town that remembers. Built small, on purpose. Tune in on Hugging Face Spaces."
|
| 178 |
|
| 179 |
**Action:** Hold on the wide shot for 2 beats. Cut.
|
| 180 |
|
|
|
|
| 182 |
|
| 183 |
## Backup Lines (in case something breaks)
|
| 184 |
|
| 185 |
+
- **If TTS doesn't play on a sprite click:** *"On some browsers the speech engine needs a moment to wake up — there's a TEST button right here that confirms voice is ready."* (Click TEST button to demo voice independently.)
|
| 186 |
+
- **If the model is slow:** *"While that's computing — every state transition is one model call, four characters means four small calls. That's the trade we made: small model, tight schema, no chat."*
|
| 187 |
+
- **If a Day 2 monologue is too similar to Day 1:** *"Notice the model isn't being dramatic — that's deliberate. The system prompt tells it to favor plausible and grounded over interesting."*
|
| 188 |
- **If the static doesn't fade audibly on recording:** *"The static you can't quite hear right now is fading out because the signal strength just climbed — it's a client-side audio effect tied to the dial."*
|
| 189 |
|
| 190 |
---
|
|
|
|
| 195 |
- Don't promise it predicts real people. The safety framing is part of the design.
|
| 196 |
- Don't read JSON fields out loud word-for-word — paraphrase.
|
| 197 |
- Don't apologize for the small model. Frame the smallness as the design choice it is.
|
| 198 |
+
- Don't talk over the TTS voices — let them play. They're the demo.
|
| 199 |
|
| 200 |
---
|
| 201 |
|
| 202 |
## One-Line Elevator Pitch (for thumbnail / caption)
|
| 203 |
|
| 204 |
+
> *"A shortwave radio for fictional minds. Drop news into a town. Tune the dial. Hear each resident, in their own voice, over multiple days."*
|
app.py
CHANGED
|
@@ -5,18 +5,21 @@ A whimsical 'shortwave radio' interface for simulating how different
|
|
| 5 |
fictional personas might internally process a piece of news.
|
| 6 |
"""
|
| 7 |
|
|
|
|
| 8 |
import json
|
| 9 |
import os
|
|
|
|
| 10 |
import traceback
|
| 11 |
from pathlib import Path
|
| 12 |
|
| 13 |
import gradio as gr
|
| 14 |
|
| 15 |
from schemas import Town, BroadcastEvent, AgentState, SimulationResult
|
| 16 |
-
from model_client import ModelClient
|
| 17 |
from simulator import Simulator
|
| 18 |
from export_hub import ExportManager
|
| 19 |
from theme import CUSTOM_CSS
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
# ──────────────────────────────────────────────
|
|
@@ -53,44 +56,85 @@ def load_town(name: str) -> Town:
|
|
| 53 |
return Town(**data)
|
| 54 |
|
| 55 |
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
"""Format the 2D interactive town map with character sprites."""
|
| 58 |
if not town.agents:
|
| 59 |
return "<div style='color: #5a5248; text-align: center; padding: 20px;'>No map data</div>"
|
| 60 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
sprites = []
|
| 62 |
for agent in town.agents:
|
| 63 |
pos = agent.map_pos or {"x": 50, "y": 50}
|
| 64 |
x = pos.get("x", 50)
|
| 65 |
y = pos.get("y", 50)
|
| 66 |
-
|
| 67 |
selected_class = "selected-sprite" if agent.id == selected_agent_id else ""
|
| 68 |
-
|
| 69 |
-
# Resolve avatar image path
|
| 70 |
avatar_file = agent.avatar if agent.avatar else "avatars/default.png"
|
| 71 |
avatar_path = Path(__file__).parent / avatar_file
|
| 72 |
if not avatar_path.exists():
|
| 73 |
avatar_path = Path(__file__).parent / "avatars/default.png"
|
| 74 |
-
|
| 75 |
avatar_url = f"/gradio_api/file={avatar_path.resolve()}"
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
sprites.append(f"""
|
| 78 |
-
<div class="agent-sprite {selected_class}"
|
| 79 |
-
style="left: {x}%; top: {y}%;"
|
| 80 |
onclick="selectAgent('{agent.id}')"
|
| 81 |
title="{agent.name} - {agent.role}">
|
| 82 |
-
<div class="sprite-avatar" style="
|
| 83 |
<div class="sprite-label">{agent.name}</div>
|
|
|
|
| 84 |
</div>
|
| 85 |
""")
|
| 86 |
-
|
| 87 |
sprites_html = "\n".join(sprites)
|
| 88 |
-
|
| 89 |
map_filename = town.map_image if town.map_image else "town_map.png"
|
| 90 |
map_image_path = Path(__file__).parent / map_filename
|
| 91 |
if not map_image_path.exists():
|
| 92 |
map_image_path = Path(__file__).parent / "town_map.png"
|
| 93 |
-
|
| 94 |
return f"""
|
| 95 |
<div class="town-map-wrapper">
|
| 96 |
<img src="/gradio_api/file={map_image_path.resolve()}" class="town-map-bg" alt="{town.name} Map" />
|
|
@@ -249,8 +293,79 @@ def format_action_display(transition) -> str:
|
|
| 249 |
"""
|
| 250 |
|
| 251 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
def tune_frequency(freq_value, sim_result_json):
|
| 253 |
"""Tune to a frequency and return the nearest agent's data."""
|
|
|
|
| 254 |
if not sim_result_json:
|
| 255 |
return (
|
| 256 |
format_frequency_display(freq_value, None, 0),
|
|
@@ -258,6 +373,7 @@ def tune_frequency(freq_value, sim_result_json):
|
|
| 258 |
format_emotion_bars(None),
|
| 259 |
format_action_display(None),
|
| 260 |
"",
|
|
|
|
| 261 |
)
|
| 262 |
|
| 263 |
try:
|
|
@@ -269,6 +385,7 @@ def tune_frequency(freq_value, sim_result_json):
|
|
| 269 |
format_emotion_bars(None),
|
| 270 |
format_action_display(None),
|
| 271 |
"",
|
|
|
|
| 272 |
)
|
| 273 |
|
| 274 |
if not result.transitions:
|
|
@@ -278,6 +395,7 @@ def tune_frequency(freq_value, sim_result_json):
|
|
| 278 |
format_emotion_bars(None),
|
| 279 |
format_action_display(None),
|
| 280 |
"",
|
|
|
|
| 281 |
)
|
| 282 |
|
| 283 |
# Load town to get agent info
|
|
@@ -313,20 +431,27 @@ def tune_frequency(freq_value, sim_result_json):
|
|
| 313 |
format_emotion_bars(None),
|
| 314 |
format_action_display(None),
|
| 315 |
"",
|
|
|
|
| 316 |
)
|
| 317 |
|
| 318 |
# Calculate signal strength (stronger when closer)
|
| 319 |
signal_strength = max(1, int(5 - min_dist * 4))
|
| 320 |
|
| 321 |
-
# Format trace JSON
|
| 322 |
trace_json = json.dumps(nearest_transition.model_dump(), indent=2, default=str)
|
| 323 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 324 |
return (
|
| 325 |
format_frequency_display(nearest_freq, nearest_name, signal_strength),
|
| 326 |
format_monologue(nearest_transition.internal_monologue, nearest_name),
|
| 327 |
format_emotion_bars(nearest_transition.updated_state),
|
| 328 |
format_action_display(nearest_transition),
|
| 329 |
trace_json,
|
|
|
|
| 330 |
)
|
| 331 |
|
| 332 |
|
|
@@ -335,42 +460,86 @@ def tune_frequency(freq_value, sim_result_json):
|
|
| 335 |
# ──────────────────────────────────────────────
|
| 336 |
|
| 337 |
def on_load_town(town_name):
|
| 338 |
-
"""Load a preset town and
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
if not town_name:
|
| 340 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
|
| 342 |
try:
|
| 343 |
town = load_town(town_name)
|
| 344 |
map_html = format_town_map(town)
|
| 345 |
-
profile_html = format_agent_profile_card(None, town)
|
| 346 |
town_json = town.model_dump_json(indent=2)
|
| 347 |
-
|
| 348 |
-
# Pre-fill event from default
|
| 349 |
event_title = town.default_event.title if town.default_event else ""
|
| 350 |
event_content = town.default_event.content if town.default_event else ""
|
| 351 |
|
| 352 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
except Exception as e:
|
| 354 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 355 |
|
| 356 |
|
| 357 |
def on_select_agent(agent_id, town_json, sim_result_json):
|
| 358 |
"""Event handler when an agent is selected via map click."""
|
|
|
|
| 359 |
if not town_json:
|
| 360 |
-
return "", "", 88.0, "", "", "", "", ""
|
| 361 |
-
|
| 362 |
try:
|
| 363 |
town = Town(**json.loads(town_json))
|
| 364 |
agent = next((a for a in town.agents if a.id == agent_id), None)
|
| 365 |
if not agent:
|
| 366 |
-
return "", "", 88.0, "", "", "", "", ""
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
profile_html = format_agent_profile_card(agent, town)
|
| 370 |
-
|
| 371 |
-
# Get simulated shortwave state (monologue, emotional bars, action)
|
| 372 |
receiver_data = tune_frequency(agent.frequency, sim_result_json)
|
| 373 |
-
|
| 374 |
return (
|
| 375 |
map_html,
|
| 376 |
profile_html,
|
|
@@ -380,23 +549,24 @@ def on_select_agent(agent_id, town_json, sim_result_json):
|
|
| 380 |
receiver_data[2],
|
| 381 |
receiver_data[3],
|
| 382 |
receiver_data[4],
|
|
|
|
| 383 |
)
|
| 384 |
except Exception as e:
|
| 385 |
traceback.print_exc()
|
| 386 |
-
return "", f"<div style='color: #ff4444;'>Error: {e}</div>", 88.0, "", "", "", "", ""
|
| 387 |
|
| 388 |
|
| 389 |
-
def on_transmit(town_json, event_title, event_content, progress=gr.Progress()):
|
| 390 |
-
"""Run the simulation
|
| 391 |
if not town_json:
|
| 392 |
-
return "⚠ Load a town first", ""
|
| 393 |
if not event_content:
|
| 394 |
-
return "⚠ Enter a broadcast event", ""
|
| 395 |
|
| 396 |
try:
|
| 397 |
town = Town(**json.loads(town_json))
|
| 398 |
except Exception as e:
|
| 399 |
-
return f"❌ Invalid town data: {e}", ""
|
| 400 |
|
| 401 |
event = BroadcastEvent(
|
| 402 |
title=event_title or "Broadcast Event",
|
|
@@ -404,20 +574,32 @@ def on_transmit(town_json, event_title, event_content, progress=gr.Progress()):
|
|
| 404 |
source="Town Radio",
|
| 405 |
)
|
| 406 |
|
| 407 |
-
# Initialize simulator
|
| 408 |
try:
|
| 409 |
client = ModelClient()
|
| 410 |
simulator = Simulator(model_client=client)
|
| 411 |
except Exception as e:
|
| 412 |
-
return f"❌ Model initialization failed: {e}", ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
|
| 414 |
-
# Run simulation with progress
|
| 415 |
status_lines = []
|
| 416 |
|
| 417 |
def progress_callback(name, status, index, total):
|
| 418 |
-
emoji = {"processing": "⏳", "complete": "✅"}.get(
|
| 419 |
-
status.split(":")[0], "⚠"
|
| 420 |
-
)
|
| 421 |
line = f"{emoji} [{index+1}/{total}] {name}: {status}"
|
| 422 |
status_lines.append(line)
|
| 423 |
progress((index + 1) / total, desc=f"Processing {name}...")
|
|
@@ -426,12 +608,13 @@ def on_transmit(town_json, event_title, event_content, progress=gr.Progress()):
|
|
| 426 |
result = simulator.run_town_simulation(
|
| 427 |
town=town,
|
| 428 |
event=event,
|
|
|
|
| 429 |
progress_callback=progress_callback,
|
|
|
|
| 430 |
)
|
| 431 |
except Exception as e:
|
| 432 |
-
return f"❌ Simulation failed: {e}", ""
|
| 433 |
|
| 434 |
-
# Format status
|
| 435 |
n_success = len(result.transitions)
|
| 436 |
n_total = len(town.agents)
|
| 437 |
|
|
@@ -444,10 +627,88 @@ def on_transmit(town_json, event_title, event_content, progress=gr.Progress()):
|
|
| 444 |
|
| 445 |
status += "\n" + "\n".join(status_lines)
|
| 446 |
|
| 447 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 448 |
result_json = result.model_dump_json(indent=2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
|
| 450 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
|
| 452 |
|
| 453 |
def on_export_local(sim_result_json, town_json):
|
|
@@ -482,6 +743,79 @@ def on_export_hub(sim_result_json, town_json, repo_id):
|
|
| 482 |
return f"❌ Hub upload failed: {e}"
|
| 483 |
|
| 484 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 485 |
# ──────────────────────────────────────────────
|
| 486 |
# Build UI
|
| 487 |
# ──────────────────────────────────────────────
|
|
@@ -591,6 +925,211 @@ window.setAmbientVolume = function(val) {
|
|
| 591 |
}
|
| 592 |
};
|
| 593 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
window.updateAudioVolumeActual = function() {
|
| 595 |
if (!ambientAudio) return;
|
| 596 |
if (ambientMuted) {
|
|
@@ -600,16 +1139,19 @@ window.updateAudioVolumeActual = function() {
|
|
| 600 |
// Calculate target volume based on active signal bars
|
| 601 |
const activeBars = document.querySelectorAll(".signal-bar.active").length;
|
| 602 |
const signalFade = 1.0 - (activeBars / 5.0);
|
| 603 |
-
|
| 604 |
-
|
|
|
|
|
|
|
|
|
|
| 605 |
// Smoothly interpolate currentActualVolume towards targetActualVolume
|
| 606 |
const diff = targetActualVolume - currentActualVolume;
|
| 607 |
if (Math.abs(diff) > 0.01) {
|
| 608 |
-
currentActualVolume += diff * 0.15;
|
| 609 |
} else {
|
| 610 |
currentActualVolume = targetActualVolume;
|
| 611 |
}
|
| 612 |
-
|
| 613 |
// Set audio volume
|
| 614 |
ambientAudio.volume = Math.max(0, Math.min(1, currentActualVolume));
|
| 615 |
};
|
|
@@ -620,6 +1162,78 @@ document.addEventListener("keydown", window.startAmbientOnInteraction);
|
|
| 620 |
|
| 621 |
// Polling interval for smooth fade
|
| 622 |
setInterval(window.updateAudioVolumeActual, 100);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
"""
|
| 624 |
|
| 625 |
AUDIO_PATH = Path(__file__).parent / "static_ambient.wav"
|
|
@@ -638,11 +1252,16 @@ def create_app():
|
|
| 638 |
# ── State ──
|
| 639 |
town_state = gr.State("")
|
| 640 |
sim_result_state = gr.State("")
|
|
|
|
| 641 |
|
| 642 |
# ── Hidden Map Communication Inputs ──
|
| 643 |
selected_agent_id = gr.Textbox(elem_id="selected-agent-id", elem_classes=["hidden-component"], visible=True)
|
| 644 |
select_agent_trigger = gr.Button("Select Agent Trigger", elem_id="select-agent-trigger", elem_classes=["hidden-component"], visible=True)
|
| 645 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
# ── Header ──
|
| 647 |
with gr.Row(elem_id="header-block"):
|
| 648 |
with gr.Column():
|
|
@@ -691,12 +1310,21 @@ def create_app():
|
|
| 691 |
)
|
| 692 |
with gr.Column(scale=2, min_width=180):
|
| 693 |
gr.HTML('<div class="ctrl-label">▸ TRANSMIT</div>')
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 700 |
sim_status = gr.Textbox(
|
| 701 |
show_label=False,
|
| 702 |
container=False,
|
|
@@ -707,12 +1335,68 @@ def create_app():
|
|
| 707 |
elem_id="sim-status",
|
| 708 |
)
|
| 709 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 710 |
# === MAIN STAGE (map+dossier on left | receiver on right) ===
|
| 711 |
with gr.Row(elem_id="main-stage", equal_height=True):
|
| 712 |
|
| 713 |
# ── LEFT: Town Map + Agent Dossier ──
|
| 714 |
with gr.Column(scale=6, min_width=420, elem_id="map-panel"):
|
| 715 |
gr.HTML('<div class="panel-header">🗺 TOWN MAP · CLICK A SIGNAL NODE</div>')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 716 |
|
| 717 |
town_map = gr.HTML(
|
| 718 |
value="<div style='color: #5a5248; text-align: center; padding: 20px;'>Load a town to see the map</div>",
|
|
@@ -748,6 +1432,12 @@ def create_app():
|
|
| 748 |
elem_id="monologue",
|
| 749 |
)
|
| 750 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
with gr.Row(equal_height=True):
|
| 752 |
with gr.Column(scale=1, min_width=160):
|
| 753 |
gr.HTML('<div class="sub-header">🎚 EMOTIONAL STATE</div>')
|
|
@@ -764,13 +1454,20 @@ def create_app():
|
|
| 764 |
|
| 765 |
ambient_control = gr.HTML(
|
| 766 |
value=f"""
|
| 767 |
-
<div class="ambient-control-panel" style="background: #111820; border: 1px solid #2a3040; border-radius: 8px; padding: 8px 12px; margin-top: 10px; font-family: 'IBM Plex Mono', monospace;">
|
| 768 |
<div style="display: flex; align-items: center; gap: 10px;">
|
| 769 |
-
<span style="font-size: 10px; color: #ffb347; letter-spacing: 1.5px; font-weight: bold; white-space: nowrap;">📻 STATIC</span>
|
| 770 |
<button id="ambient-toggle-btn" class="retro-btn" onclick="toggleAmbient()" style="background: #39ff14; border: 1px solid rgba(57, 255, 20, 0.4); color: #0a0e14; padding: 2px 8px; font-family: 'IBM Plex Mono', monospace; font-size: 10px; cursor: pointer; border-radius: 4px; box-shadow: 0 0 10px rgba(57,255,20,0.4); font-weight: bold;">ON</button>
|
| 771 |
<input type="range" id="ambient-volume" min="0" max="1" step="0.05" value="0.15" oninput="setAmbientVolume(this.value)" style="flex-grow: 1; accent-color: #ffb347; cursor: pointer; height: 4px; background: #2a3040; border-radius: 2px; outline: none;" />
|
| 772 |
<span id="ambient-volume-val" style="font-size: 10px; color: #ffb347; width: 32px; text-align: right;">15%</span>
|
| 773 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 774 |
</div>
|
| 775 |
""",
|
| 776 |
elem_id="ambient-control",
|
|
@@ -824,18 +1521,36 @@ def create_app():
|
|
| 824 |
|
| 825 |
# ── Event Handlers ──
|
| 826 |
|
| 827 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 828 |
load_btn.click(
|
| 829 |
fn=on_load_town,
|
| 830 |
inputs=[town_dropdown],
|
| 831 |
-
outputs=
|
| 832 |
)
|
| 833 |
|
| 834 |
-
# Also load on dropdown change
|
| 835 |
town_dropdown.change(
|
| 836 |
fn=on_load_town,
|
| 837 |
inputs=[town_dropdown],
|
| 838 |
-
outputs=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 839 |
)
|
| 840 |
|
| 841 |
# Map Click Event Selector
|
|
@@ -851,21 +1566,48 @@ def create_app():
|
|
| 851 |
emotion_display,
|
| 852 |
action_display,
|
| 853 |
trace_json,
|
|
|
|
| 854 |
],
|
| 855 |
)
|
| 856 |
|
| 857 |
# Transmit
|
| 858 |
transmit_btn.click(
|
| 859 |
fn=on_transmit,
|
| 860 |
-
inputs=[town_state, event_title, event_content],
|
| 861 |
-
outputs=[sim_status, sim_result_state],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 862 |
)
|
| 863 |
|
| 864 |
# Tune frequency
|
| 865 |
freq_slider.change(
|
| 866 |
fn=tune_frequency,
|
| 867 |
inputs=[freq_slider, sim_result_state],
|
| 868 |
-
outputs=[freq_display, monologue_display, emotion_display, action_display, trace_json],
|
| 869 |
)
|
| 870 |
|
| 871 |
# Export local
|
|
@@ -882,10 +1624,9 @@ def create_app():
|
|
| 882 |
outputs=[export_status],
|
| 883 |
)
|
| 884 |
|
| 885 |
-
# Auto-load first town on app start
|
| 886 |
app.load(
|
| 887 |
-
fn=lambda: on_load_town(available_towns[0]
|
| 888 |
-
outputs=
|
| 889 |
)
|
| 890 |
|
| 891 |
return app
|
|
|
|
| 5 |
fictional personas might internally process a piece of news.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
import base64
|
| 9 |
import json
|
| 10 |
import os
|
| 11 |
+
import shutil
|
| 12 |
import traceback
|
| 13 |
from pathlib import Path
|
| 14 |
|
| 15 |
import gradio as gr
|
| 16 |
|
| 17 |
from schemas import Town, BroadcastEvent, AgentState, SimulationResult
|
| 18 |
+
from model_client import ModelClient, synthesize_speech
|
| 19 |
from simulator import Simulator
|
| 20 |
from export_hub import ExportManager
|
| 21 |
from theme import CUSTOM_CSS
|
| 22 |
+
from town_generator import generate_town
|
| 23 |
|
| 24 |
|
| 25 |
# ──────────────────────────────────────────────
|
|
|
|
| 56 |
return Town(**data)
|
| 57 |
|
| 58 |
|
| 59 |
+
_EMOTION_COLORS = {
|
| 60 |
+
"anger": "#ff4444",
|
| 61 |
+
"fear": "#ff8c00",
|
| 62 |
+
"hope": "#ffb347",
|
| 63 |
+
"trust": "#39ff14",
|
| 64 |
+
"curiosity": "#47b3ff",
|
| 65 |
+
"social_energy": "#b347ff",
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
_EMOTION_KEYS = list(_EMOTION_COLORS.keys())
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def _dominant_emotion_color(state) -> str:
|
| 72 |
+
values = {k: getattr(state, k) for k in _EMOTION_KEYS}
|
| 73 |
+
dominant = max(values, key=values.get)
|
| 74 |
+
return _EMOTION_COLORS[dominant]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def format_town_map(town: Town, selected_agent_id: str | None = None, sim_result=None) -> str:
|
| 78 |
"""Format the 2D interactive town map with character sprites."""
|
| 79 |
if not town.agents:
|
| 80 |
return "<div style='color: #5a5248; text-align: center; padding: 20px;'>No map data</div>"
|
| 81 |
|
| 82 |
+
transition_map = {}
|
| 83 |
+
if sim_result is not None:
|
| 84 |
+
for t in sim_result.transitions:
|
| 85 |
+
transition_map[t.agent_id] = t
|
| 86 |
+
|
| 87 |
sprites = []
|
| 88 |
for agent in town.agents:
|
| 89 |
pos = agent.map_pos or {"x": 50, "y": 50}
|
| 90 |
x = pos.get("x", 50)
|
| 91 |
y = pos.get("y", 50)
|
| 92 |
+
|
| 93 |
selected_class = "selected-sprite" if agent.id == selected_agent_id else ""
|
| 94 |
+
|
|
|
|
| 95 |
avatar_file = agent.avatar if agent.avatar else "avatars/default.png"
|
| 96 |
avatar_path = Path(__file__).parent / avatar_file
|
| 97 |
if not avatar_path.exists():
|
| 98 |
avatar_path = Path(__file__).parent / "avatars/default.png"
|
| 99 |
+
|
| 100 |
avatar_url = f"/gradio_api/file={avatar_path.resolve()}"
|
| 101 |
+
|
| 102 |
+
transition = transition_map.get(agent.id)
|
| 103 |
+
if transition is not None:
|
| 104 |
+
tint_color = _dominant_emotion_color(transition.updated_state)
|
| 105 |
+
avatar_style = (
|
| 106 |
+
f"background-image: url('{avatar_url}');"
|
| 107 |
+
f"border-color: {tint_color};"
|
| 108 |
+
f"box-shadow: 0 0 12px {tint_color};"
|
| 109 |
+
f"transition: border-color 0.4s ease, box-shadow 0.4s ease;"
|
| 110 |
+
)
|
| 111 |
+
has_anomaly = any(
|
| 112 |
+
abs(v) > 0.6 for v in transition.emotion_delta.values()
|
| 113 |
+
)
|
| 114 |
+
else:
|
| 115 |
+
avatar_style = f"background-image: url('{avatar_url}');"
|
| 116 |
+
has_anomaly = False
|
| 117 |
+
|
| 118 |
+
anomaly_html = '<div class="anomaly-flag">📢</div>' if has_anomaly else ""
|
| 119 |
+
|
| 120 |
sprites.append(f"""
|
| 121 |
+
<div class="agent-sprite {selected_class}"
|
| 122 |
+
style="left: {x}%; top: {y}%;"
|
| 123 |
onclick="selectAgent('{agent.id}')"
|
| 124 |
title="{agent.name} - {agent.role}">
|
| 125 |
+
<div class="sprite-avatar" style="{avatar_style}"></div>
|
| 126 |
<div class="sprite-label">{agent.name}</div>
|
| 127 |
+
{anomaly_html}
|
| 128 |
</div>
|
| 129 |
""")
|
| 130 |
+
|
| 131 |
sprites_html = "\n".join(sprites)
|
| 132 |
+
|
| 133 |
map_filename = town.map_image if town.map_image else "town_map.png"
|
| 134 |
map_image_path = Path(__file__).parent / map_filename
|
| 135 |
if not map_image_path.exists():
|
| 136 |
map_image_path = Path(__file__).parent / "town_map.png"
|
| 137 |
+
|
| 138 |
return f"""
|
| 139 |
<div class="town-map-wrapper">
|
| 140 |
<img src="/gradio_api/file={map_image_path.resolve()}" class="town-map-bg" alt="{town.name} Map" />
|
|
|
|
| 293 |
"""
|
| 294 |
|
| 295 |
|
| 296 |
+
def format_timeline_strip(timeline_json: str, active_day: int | None = None) -> str:
|
| 297 |
+
"""Return a horizontal row of clickable day pills from a JSON timeline."""
|
| 298 |
+
if not timeline_json:
|
| 299 |
+
return '<div id="timeline-strip"><div class="timeline-empty">Broadcast to begin a day timeline ▸</div></div>'
|
| 300 |
+
|
| 301 |
+
try:
|
| 302 |
+
timeline = json.loads(timeline_json)
|
| 303 |
+
except Exception:
|
| 304 |
+
return '<div id="timeline-strip"><div class="timeline-empty">Broadcast to begin a day timeline ▸</div></div>'
|
| 305 |
+
|
| 306 |
+
if not timeline:
|
| 307 |
+
return '<div id="timeline-strip"><div class="timeline-empty">Broadcast to begin a day timeline ▸</div></div>'
|
| 308 |
+
|
| 309 |
+
if active_day is None:
|
| 310 |
+
active_day = timeline[-1]["day"]
|
| 311 |
+
|
| 312 |
+
pills = []
|
| 313 |
+
for entry in timeline:
|
| 314 |
+
day = entry["day"]
|
| 315 |
+
title = (entry.get("event_title") or "Broadcast")[:30]
|
| 316 |
+
if len(entry.get("event_title") or "") > 30:
|
| 317 |
+
title = title + "…"
|
| 318 |
+
|
| 319 |
+
sim = entry.get("sim_result", {})
|
| 320 |
+
transitions = sim.get("transitions", [])
|
| 321 |
+
anomaly_count = sum(
|
| 322 |
+
1 for t in transitions
|
| 323 |
+
if any(abs(v) > 0.6 for v in (t.get("emotion_delta") or {}).values())
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
active_class = "active" if day == active_day else ""
|
| 327 |
+
anomaly_html = f'<span class="pill-anomaly">⚠ {anomaly_count}</span>' if anomaly_count > 0 else ""
|
| 328 |
+
|
| 329 |
+
pills.append(
|
| 330 |
+
f'<div class="timeline-pill {active_class}" onclick="selectTimelineDay({day})">'
|
| 331 |
+
f'<span class="pill-day">DAY {day}</span>'
|
| 332 |
+
f'<span class="pill-title">{title}</span>'
|
| 333 |
+
f'{anomaly_html}'
|
| 334 |
+
f'</div>'
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
pills_html = "\n".join(pills)
|
| 338 |
+
return f'<div id="timeline-strip">{pills_html}</div>'
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
def format_day_badge(day: int | None) -> str:
|
| 342 |
+
"""Return an absolutely-positioned day badge HTML for the map panel."""
|
| 343 |
+
if day is None:
|
| 344 |
+
return ""
|
| 345 |
+
return f'<div class="day-badge">DAY {day}</div>'
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _build_voice_player(monologue: str | None, agent_name: str | None = None, agent_age: str | None = None) -> str:
|
| 349 |
+
"""Return a hidden div whose data attributes carry the monologue to the browser TTS poller."""
|
| 350 |
+
import html as _html
|
| 351 |
+
if not monologue:
|
| 352 |
+
return "<div id='voice-player-container' data-text='' style='display:none;'></div>"
|
| 353 |
+
clean = monologue.replace("\n", " ").replace("\r", " ").strip()
|
| 354 |
+
safe_text = _html.escape(clean, quote=True)
|
| 355 |
+
safe_name = _html.escape((agent_name or ""), quote=True)
|
| 356 |
+
safe_age = _html.escape((agent_age or ""), quote=True)
|
| 357 |
+
return (
|
| 358 |
+
f'<div id="voice-player-container" '
|
| 359 |
+
f'data-text="{safe_text}" '
|
| 360 |
+
f'data-name="{safe_name}" '
|
| 361 |
+
f'data-age="{safe_age}" '
|
| 362 |
+
f'style="display:none;"></div>'
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
def tune_frequency(freq_value, sim_result_json):
|
| 367 |
"""Tune to a frequency and return the nearest agent's data."""
|
| 368 |
+
_empty_voice = _build_voice_player(None)
|
| 369 |
if not sim_result_json:
|
| 370 |
return (
|
| 371 |
format_frequency_display(freq_value, None, 0),
|
|
|
|
| 373 |
format_emotion_bars(None),
|
| 374 |
format_action_display(None),
|
| 375 |
"",
|
| 376 |
+
_empty_voice,
|
| 377 |
)
|
| 378 |
|
| 379 |
try:
|
|
|
|
| 385 |
format_emotion_bars(None),
|
| 386 |
format_action_display(None),
|
| 387 |
"",
|
| 388 |
+
_empty_voice,
|
| 389 |
)
|
| 390 |
|
| 391 |
if not result.transitions:
|
|
|
|
| 395 |
format_emotion_bars(None),
|
| 396 |
format_action_display(None),
|
| 397 |
"",
|
| 398 |
+
_empty_voice,
|
| 399 |
)
|
| 400 |
|
| 401 |
# Load town to get agent info
|
|
|
|
| 431 |
format_emotion_bars(None),
|
| 432 |
format_action_display(None),
|
| 433 |
"",
|
| 434 |
+
_empty_voice,
|
| 435 |
)
|
| 436 |
|
| 437 |
# Calculate signal strength (stronger when closer)
|
| 438 |
signal_strength = max(1, int(5 - min_dist * 4))
|
| 439 |
|
|
|
|
| 440 |
trace_json = json.dumps(nearest_transition.model_dump(), indent=2, default=str)
|
| 441 |
|
| 442 |
+
agent = agent_freq_map.get(nearest_transition.agent_id)
|
| 443 |
+
nearest_age = agent.age_range if agent else ""
|
| 444 |
+
voice_html = _build_voice_player(
|
| 445 |
+
nearest_transition.internal_monologue, nearest_name, nearest_age
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
return (
|
| 449 |
format_frequency_display(nearest_freq, nearest_name, signal_strength),
|
| 450 |
format_monologue(nearest_transition.internal_monologue, nearest_name),
|
| 451 |
format_emotion_bars(nearest_transition.updated_state),
|
| 452 |
format_action_display(nearest_transition),
|
| 453 |
trace_json,
|
| 454 |
+
voice_html,
|
| 455 |
)
|
| 456 |
|
| 457 |
|
|
|
|
| 460 |
# ──────────────────────────────────────────────
|
| 461 |
|
| 462 |
def on_load_town(town_name):
|
| 463 |
+
"""Load a preset town and reset every town-scoped surface (timeline, receiver, badges)."""
|
| 464 |
+
empty_voice = _build_voice_player(None)
|
| 465 |
+
empty_freq = format_frequency_display(88.0, None, 0)
|
| 466 |
+
empty_monologue = format_monologue(None, None)
|
| 467 |
+
empty_emotions = format_emotion_bars(None)
|
| 468 |
+
empty_actions = format_action_display(None)
|
| 469 |
+
empty_strip = format_timeline_strip("")
|
| 470 |
+
empty_profile = "<div style='color: #5a5248; text-align: center; padding: 20px;'>Select an agent on the map to inspect dossier</div>"
|
| 471 |
+
|
| 472 |
if not town_name:
|
| 473 |
+
return (
|
| 474 |
+
"", empty_profile, "", "", "",
|
| 475 |
+
"", "", empty_strip, "",
|
| 476 |
+
"",
|
| 477 |
+
empty_freq, empty_monologue, empty_emotions, empty_actions, "", empty_voice,
|
| 478 |
+
gr.update(value=88.0),
|
| 479 |
+
)
|
| 480 |
|
| 481 |
try:
|
| 482 |
town = load_town(town_name)
|
| 483 |
map_html = format_town_map(town)
|
|
|
|
| 484 |
town_json = town.model_dump_json(indent=2)
|
|
|
|
|
|
|
| 485 |
event_title = town.default_event.title if town.default_event else ""
|
| 486 |
event_content = town.default_event.content if town.default_event else ""
|
| 487 |
|
| 488 |
+
return (
|
| 489 |
+
map_html,
|
| 490 |
+
empty_profile,
|
| 491 |
+
town_json,
|
| 492 |
+
event_title,
|
| 493 |
+
event_content,
|
| 494 |
+
"",
|
| 495 |
+
"",
|
| 496 |
+
empty_strip,
|
| 497 |
+
"",
|
| 498 |
+
"",
|
| 499 |
+
empty_freq,
|
| 500 |
+
empty_monologue,
|
| 501 |
+
empty_emotions,
|
| 502 |
+
empty_actions,
|
| 503 |
+
"",
|
| 504 |
+
empty_voice,
|
| 505 |
+
gr.update(value=88.0),
|
| 506 |
+
)
|
| 507 |
except Exception as e:
|
| 508 |
+
return (
|
| 509 |
+
f"<div style='color: #ff4444;'>Error loading town: {e}</div>",
|
| 510 |
+
empty_profile, "", "", "",
|
| 511 |
+
"", "", empty_strip, "",
|
| 512 |
+
f"❌ {e}",
|
| 513 |
+
empty_freq, empty_monologue, empty_emotions, empty_actions, "", empty_voice,
|
| 514 |
+
gr.update(value=88.0),
|
| 515 |
+
)
|
| 516 |
|
| 517 |
|
| 518 |
def on_select_agent(agent_id, town_json, sim_result_json):
|
| 519 |
"""Event handler when an agent is selected via map click."""
|
| 520 |
+
_empty = _build_voice_player(None)
|
| 521 |
if not town_json:
|
| 522 |
+
return "", "", 88.0, "", "", "", "", "", _empty
|
| 523 |
+
|
| 524 |
try:
|
| 525 |
town = Town(**json.loads(town_json))
|
| 526 |
agent = next((a for a in town.agents if a.id == agent_id), None)
|
| 527 |
if not agent:
|
| 528 |
+
return "", "", 88.0, "", "", "", "", "", _empty
|
| 529 |
+
|
| 530 |
+
sim_result = None
|
| 531 |
+
if sim_result_json:
|
| 532 |
+
try:
|
| 533 |
+
sim_result = SimulationResult(**json.loads(sim_result_json))
|
| 534 |
+
except Exception:
|
| 535 |
+
sim_result = None
|
| 536 |
+
|
| 537 |
+
map_html = format_town_map(town, agent.id, sim_result)
|
| 538 |
profile_html = format_agent_profile_card(agent, town)
|
| 539 |
+
|
| 540 |
+
# Get simulated shortwave state (monologue, emotional bars, action, voice)
|
| 541 |
receiver_data = tune_frequency(agent.frequency, sim_result_json)
|
| 542 |
+
|
| 543 |
return (
|
| 544 |
map_html,
|
| 545 |
profile_html,
|
|
|
|
| 549 |
receiver_data[2],
|
| 550 |
receiver_data[3],
|
| 551 |
receiver_data[4],
|
| 552 |
+
receiver_data[5],
|
| 553 |
)
|
| 554 |
except Exception as e:
|
| 555 |
traceback.print_exc()
|
| 556 |
+
return "", f"<div style='color: #ff4444;'>Error: {e}</div>", 88.0, "", "", "", "", "", _empty
|
| 557 |
|
| 558 |
|
| 559 |
+
def on_transmit(town_json, event_title, event_content, timeline_json, progress=gr.Progress()):
|
| 560 |
+
"""Run the simulation, chaining from the last day's states if available."""
|
| 561 |
if not town_json:
|
| 562 |
+
return "⚠ Load a town first", "", gr.update(), timeline_json, format_timeline_strip(timeline_json), ""
|
| 563 |
if not event_content:
|
| 564 |
+
return "⚠ Enter a broadcast event", "", gr.update(), timeline_json, format_timeline_strip(timeline_json), ""
|
| 565 |
|
| 566 |
try:
|
| 567 |
town = Town(**json.loads(town_json))
|
| 568 |
except Exception as e:
|
| 569 |
+
return f"❌ Invalid town data: {e}", "", gr.update(), timeline_json, format_timeline_strip(timeline_json), ""
|
| 570 |
|
| 571 |
event = BroadcastEvent(
|
| 572 |
title=event_title or "Broadcast Event",
|
|
|
|
| 574 |
source="Town Radio",
|
| 575 |
)
|
| 576 |
|
|
|
|
| 577 |
try:
|
| 578 |
client = ModelClient()
|
| 579 |
simulator = Simulator(model_client=client)
|
| 580 |
except Exception as e:
|
| 581 |
+
return f"❌ Model initialization failed: {e}", "", gr.update(), timeline_json, format_timeline_strip(timeline_json), ""
|
| 582 |
+
|
| 583 |
+
previous_states = None
|
| 584 |
+
current_timeline = []
|
| 585 |
+
if timeline_json:
|
| 586 |
+
try:
|
| 587 |
+
current_timeline = json.loads(timeline_json)
|
| 588 |
+
if current_timeline:
|
| 589 |
+
last_entry = current_timeline[-1]
|
| 590 |
+
last_result = SimulationResult(**last_entry["sim_result"])
|
| 591 |
+
previous_states = {
|
| 592 |
+
t.agent_id: t.updated_state for t in last_result.transitions
|
| 593 |
+
}
|
| 594 |
+
except Exception:
|
| 595 |
+
current_timeline = []
|
| 596 |
+
|
| 597 |
+
day = len(current_timeline) + 1
|
| 598 |
|
|
|
|
| 599 |
status_lines = []
|
| 600 |
|
| 601 |
def progress_callback(name, status, index, total):
|
| 602 |
+
emoji = {"processing": "⏳", "complete": "✅"}.get(status.split(":")[0], "⚠")
|
|
|
|
|
|
|
| 603 |
line = f"{emoji} [{index+1}/{total}] {name}: {status}"
|
| 604 |
status_lines.append(line)
|
| 605 |
progress((index + 1) / total, desc=f"Processing {name}...")
|
|
|
|
| 608 |
result = simulator.run_town_simulation(
|
| 609 |
town=town,
|
| 610 |
event=event,
|
| 611 |
+
previous_states=previous_states,
|
| 612 |
progress_callback=progress_callback,
|
| 613 |
+
day=day,
|
| 614 |
)
|
| 615 |
except Exception as e:
|
| 616 |
+
return f"❌ Simulation failed: {e}", "", gr.update(), timeline_json, format_timeline_strip(timeline_json), ""
|
| 617 |
|
|
|
|
| 618 |
n_success = len(result.transitions)
|
| 619 |
n_total = len(town.agents)
|
| 620 |
|
|
|
|
| 627 |
|
| 628 |
status += "\n" + "\n".join(status_lines)
|
| 629 |
|
| 630 |
+
new_entry = {
|
| 631 |
+
"day": day,
|
| 632 |
+
"event_title": event_title or "Broadcast Event",
|
| 633 |
+
"event_content": event_content,
|
| 634 |
+
"sim_result": json.loads(result.model_dump_json()),
|
| 635 |
+
}
|
| 636 |
+
current_timeline.append(new_entry)
|
| 637 |
+
new_timeline_json = json.dumps(current_timeline)
|
| 638 |
+
|
| 639 |
result_json = result.model_dump_json(indent=2)
|
| 640 |
+
updated_map = format_town_map(town, None, result)
|
| 641 |
+
timeline_strip = format_timeline_strip(new_timeline_json, active_day=day)
|
| 642 |
+
day_badge = format_day_badge(day)
|
| 643 |
+
|
| 644 |
+
return status, result_json, updated_map, new_timeline_json, timeline_strip, day_badge
|
| 645 |
+
|
| 646 |
+
|
| 647 |
+
def on_reset_timeline(town_json):
|
| 648 |
+
"""Clear the entire timeline and return to Day 1 default state."""
|
| 649 |
+
empty_strip = format_timeline_strip("")
|
| 650 |
+
if not town_json:
|
| 651 |
+
return "", "", empty_strip, ""
|
| 652 |
+
try:
|
| 653 |
+
town = Town(**json.loads(town_json))
|
| 654 |
+
map_html = format_town_map(town, None, None)
|
| 655 |
+
except Exception:
|
| 656 |
+
map_html = ""
|
| 657 |
+
return "", map_html, empty_strip, ""
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def on_select_timeline_day(day_str, town_json, timeline_json):
|
| 661 |
+
"""Restore the map and sim_result to a specific historical day."""
|
| 662 |
+
_empty_voice = _build_voice_player(None)
|
| 663 |
+
empty_returns = (
|
| 664 |
+
"",
|
| 665 |
+
"",
|
| 666 |
+
format_frequency_display(88.0, None, 0),
|
| 667 |
+
format_monologue(None, None),
|
| 668 |
+
format_emotion_bars(None),
|
| 669 |
+
format_action_display(None),
|
| 670 |
+
"",
|
| 671 |
+
_empty_voice,
|
| 672 |
+
format_timeline_strip(timeline_json),
|
| 673 |
+
"",
|
| 674 |
+
)
|
| 675 |
+
|
| 676 |
+
if not day_str or not timeline_json:
|
| 677 |
+
return empty_returns
|
| 678 |
+
|
| 679 |
+
try:
|
| 680 |
+
day = int(day_str)
|
| 681 |
+
timeline = json.loads(timeline_json)
|
| 682 |
+
except Exception:
|
| 683 |
+
return empty_returns
|
| 684 |
+
|
| 685 |
+
entry = next((e for e in timeline if e["day"] == day), None)
|
| 686 |
+
if not entry:
|
| 687 |
+
return empty_returns
|
| 688 |
+
|
| 689 |
+
try:
|
| 690 |
+
sim_result = SimulationResult(**entry["sim_result"])
|
| 691 |
+
town = Town(**json.loads(town_json)) if town_json else None
|
| 692 |
+
except Exception:
|
| 693 |
+
return empty_returns
|
| 694 |
|
| 695 |
+
result_json = sim_result.model_dump_json(indent=2)
|
| 696 |
+
map_html = format_town_map(town, None, sim_result) if town else ""
|
| 697 |
+
strip_html = format_timeline_strip(timeline_json, active_day=day)
|
| 698 |
+
badge_html = format_day_badge(day)
|
| 699 |
+
|
| 700 |
+
return (
|
| 701 |
+
result_json,
|
| 702 |
+
map_html,
|
| 703 |
+
format_frequency_display(88.0, None, 0),
|
| 704 |
+
format_monologue(None, None),
|
| 705 |
+
format_emotion_bars(None),
|
| 706 |
+
format_action_display(None),
|
| 707 |
+
"",
|
| 708 |
+
_empty_voice,
|
| 709 |
+
strip_html,
|
| 710 |
+
badge_html,
|
| 711 |
+
)
|
| 712 |
|
| 713 |
|
| 714 |
def on_export_local(sim_result_json, town_json):
|
|
|
|
| 743 |
return f"❌ Hub upload failed: {e}"
|
| 744 |
|
| 745 |
|
| 746 |
+
# ──────────────────────────────────────────────
|
| 747 |
+
# Creative Mode handlers
|
| 748 |
+
# ──────────────────────────────────────────────
|
| 749 |
+
|
| 750 |
+
def on_generate_creative_town(concept, n_agents, current_json):
|
| 751 |
+
"""Generate a town JSON from concept + count. Returns (json_str, status_markdown)."""
|
| 752 |
+
if not concept or not concept.strip():
|
| 753 |
+
return current_json, "_Status: ⚠ Type a concept first_"
|
| 754 |
+
try:
|
| 755 |
+
town_dict = generate_town(concept.strip(), int(n_agents))
|
| 756 |
+
return json.dumps(town_dict, indent=2), "_Status: ✅ Town generated — review and edit the JSON below, then click Save & Load_"
|
| 757 |
+
except Exception as e:
|
| 758 |
+
return current_json, f"_Status: ❌ Generation failed: {e}_"
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
def on_save_load_creative_town(json_text, map_upload_path):
|
| 762 |
+
"""Validate JSON, optionally copy uploaded map image, write to sample_towns, then trigger full reset."""
|
| 763 |
+
empty_voice = _build_voice_player(None)
|
| 764 |
+
empty_freq = format_frequency_display(88.0, None, 0)
|
| 765 |
+
empty_monologue = format_monologue(None, None)
|
| 766 |
+
empty_emotions = format_emotion_bars(None)
|
| 767 |
+
empty_actions = format_action_display(None)
|
| 768 |
+
empty_strip = format_timeline_strip("")
|
| 769 |
+
empty_profile = "<div style='color: #5a5248; text-align: center; padding: 20px;'>Select an agent on the map to inspect dossier</div>"
|
| 770 |
+
|
| 771 |
+
def _error_return(msg):
|
| 772 |
+
return (
|
| 773 |
+
"", empty_profile, "", "", "",
|
| 774 |
+
"", "", empty_strip, "",
|
| 775 |
+
f"❌ {msg}",
|
| 776 |
+
empty_freq, empty_monologue, empty_emotions, empty_actions, "", empty_voice,
|
| 777 |
+
gr.update(value=88.0),
|
| 778 |
+
gr.update(),
|
| 779 |
+
f"_Status: ❌ {msg}_",
|
| 780 |
+
)
|
| 781 |
+
|
| 782 |
+
if not json_text or not json_text.strip():
|
| 783 |
+
return _error_return("No JSON to save. Generate a town first.")
|
| 784 |
+
|
| 785 |
+
try:
|
| 786 |
+
town_dict = json.loads(json_text)
|
| 787 |
+
town = Town(**town_dict)
|
| 788 |
+
except Exception as e:
|
| 789 |
+
return _error_return(f"Invalid town JSON: {e}")
|
| 790 |
+
|
| 791 |
+
project_root = Path(__file__).parent
|
| 792 |
+
|
| 793 |
+
if map_upload_path:
|
| 794 |
+
dest = project_root / f"{town.id}_map.png"
|
| 795 |
+
shutil.copy(map_upload_path, dest)
|
| 796 |
+
town.map_image = f"{town.id}_map.png"
|
| 797 |
+
else:
|
| 798 |
+
if town.map_image:
|
| 799 |
+
candidate = project_root / town.map_image
|
| 800 |
+
if not candidate.exists():
|
| 801 |
+
town.map_image = "town_map.png"
|
| 802 |
+
else:
|
| 803 |
+
town.map_image = "town_map.png"
|
| 804 |
+
|
| 805 |
+
save_path = SAMPLE_TOWNS_DIR / f"{town.id}.json"
|
| 806 |
+
SAMPLE_TOWNS_DIR.mkdir(parents=True, exist_ok=True)
|
| 807 |
+
save_path.write_text(town.model_dump_json(indent=2))
|
| 808 |
+
|
| 809 |
+
new_choices = get_available_towns()
|
| 810 |
+
|
| 811 |
+
load_tuple = on_load_town(town.id)
|
| 812 |
+
|
| 813 |
+
return load_tuple + (
|
| 814 |
+
gr.update(choices=new_choices, value=town.id),
|
| 815 |
+
f"_Status: ✅ Town '{town.name}' saved and loaded. Press TRANSMIT to run Day 1._",
|
| 816 |
+
)
|
| 817 |
+
|
| 818 |
+
|
| 819 |
# ──────────────────────────────────────────────
|
| 820 |
# Build UI
|
| 821 |
# ──────────────────────────────────────────────
|
|
|
|
| 925 |
}
|
| 926 |
};
|
| 927 |
|
| 928 |
+
let voicePlaying = false;
|
| 929 |
+
let voiceEnabled = true;
|
| 930 |
+
let lastSpokenText = "";
|
| 931 |
+
|
| 932 |
+
window.toggleVoice = function() {
|
| 933 |
+
voiceEnabled = !voiceEnabled;
|
| 934 |
+
const btn = document.getElementById("voice-toggle-btn");
|
| 935 |
+
if (voiceEnabled) {
|
| 936 |
+
if (btn) {
|
| 937 |
+
btn.innerText = "🔊 VOICE ON";
|
| 938 |
+
btn.style.background = "#39ff14";
|
| 939 |
+
btn.style.color = "#0a0e14";
|
| 940 |
+
btn.style.borderColor = "rgba(57, 255, 20, 0.4)";
|
| 941 |
+
btn.style.boxShadow = "0 0 10px rgba(57,255,20,0.4)";
|
| 942 |
+
}
|
| 943 |
+
} else {
|
| 944 |
+
if (window.speechSynthesis) window.speechSynthesis.cancel();
|
| 945 |
+
voicePlaying = false;
|
| 946 |
+
if (btn) {
|
| 947 |
+
btn.innerText = "🔇 VOICE OFF";
|
| 948 |
+
btn.style.background = "#2a3040";
|
| 949 |
+
btn.style.color = "#8a8070";
|
| 950 |
+
btn.style.borderColor = "rgba(138, 128, 112, 0.4)";
|
| 951 |
+
btn.style.boxShadow = "none";
|
| 952 |
+
}
|
| 953 |
+
}
|
| 954 |
+
};
|
| 955 |
+
|
| 956 |
+
window.testVoice = function() {
|
| 957 |
+
console.log("[AnalogTown] testVoice clicked");
|
| 958 |
+
if (!window.speechSynthesis) {
|
| 959 |
+
alert("Your browser does not support speechSynthesis.");
|
| 960 |
+
return;
|
| 961 |
+
}
|
| 962 |
+
voiceEnabled = true;
|
| 963 |
+
try { window.speechSynthesis.resume(); } catch (e) {}
|
| 964 |
+
const u = new SpeechSynthesisUtterance("Receiver online. This is a voice test from Analog Town.");
|
| 965 |
+
u.rate = 0.95;
|
| 966 |
+
u.pitch = 1.0;
|
| 967 |
+
u.volume = 1.0;
|
| 968 |
+
const voices = window.speechSynthesis.getVoices();
|
| 969 |
+
const enVoice = voices.find(v => v.lang && v.lang.toLowerCase().startsWith("en"));
|
| 970 |
+
if (enVoice) { u.voice = enVoice; console.log("[AnalogTown] test using voice:", enVoice.name, enVoice.lang); }
|
| 971 |
+
u.onstart = function() {
|
| 972 |
+
voicePlaying = true;
|
| 973 |
+
const led = document.getElementById("voice-status-led");
|
| 974 |
+
if (led) { led.style.background = "#39ff14"; led.style.boxShadow = "0 0 8px #39ff14"; }
|
| 975 |
+
console.log("[AnalogTown] TEST utterance STARTED");
|
| 976 |
+
};
|
| 977 |
+
u.onend = function() {
|
| 978 |
+
voicePlaying = false;
|
| 979 |
+
const led = document.getElementById("voice-status-led");
|
| 980 |
+
if (led) { led.style.background = "#2a3040"; led.style.boxShadow = "none"; }
|
| 981 |
+
console.log("[AnalogTown] TEST utterance ENDED");
|
| 982 |
+
};
|
| 983 |
+
u.onerror = function(e) {
|
| 984 |
+
console.error("[AnalogTown] TEST utterance ERROR:", e.error || e);
|
| 985 |
+
const led = document.getElementById("voice-status-led");
|
| 986 |
+
if (led) { led.style.background = "#ff4444"; led.style.boxShadow = "0 0 8px #ff4444"; }
|
| 987 |
+
};
|
| 988 |
+
window.speechSynthesis.speak(u);
|
| 989 |
+
console.log("[AnalogTown] TEST speak() invoked, pending:", window.speechSynthesis.pending, "speaking:", window.speechSynthesis.speaking, "paused:", window.speechSynthesis.paused);
|
| 990 |
+
setTimeout(function() {
|
| 991 |
+
console.log("[AnalogTown] 1s after speak — speaking:", window.speechSynthesis.speaking, "paused:", window.speechSynthesis.paused);
|
| 992 |
+
if (window.speechSynthesis.paused) {
|
| 993 |
+
console.log("[AnalogTown] engine paused, attempting resume");
|
| 994 |
+
window.speechSynthesis.resume();
|
| 995 |
+
}
|
| 996 |
+
}, 1000);
|
| 997 |
+
};
|
| 998 |
+
|
| 999 |
+
const FEMALE_NAME_TOKENS = [
|
| 1000 |
+
"sarah","chloe","priya","maple","mei","elena","martha","leah","mara",
|
| 1001 |
+
"joan","lila","mira","idell","rusty","ronnie","tiffany","margaret",
|
| 1002 |
+
"auntie","sister","granny","elder martha","aria","ronnie mae","mae",
|
| 1003 |
+
"elder","mira okonkwo","lila mendes","an"
|
| 1004 |
+
];
|
| 1005 |
+
const MALE_NAME_TOKENS = [
|
| 1006 |
+
"arthur","toby","jesse","buck","hollis","joaquin","daniel","wade","mason",
|
| 1007 |
+
"tuan","kai","engineer daniyal","daniyal","pastor","preacher","ng","martin",
|
| 1008 |
+
"captain arthur","captain","gerald","silas","alistair","marcus","corporal dale",
|
| 1009 |
+
"eli","theo","pastor lin","councilman","architect"
|
| 1010 |
+
];
|
| 1011 |
+
const FEMALE_VOICE_POOL = [
|
| 1012 |
+
"Samantha","Victoria","Karen","Moira","Allison","Susan","Ava","Fiona","Tessa",
|
| 1013 |
+
"Veena","Kate","Serena","Google UK English Female","Google US English",
|
| 1014 |
+
"Microsoft Zira","Microsoft Aria"
|
| 1015 |
+
];
|
| 1016 |
+
const MALE_VOICE_POOL = [
|
| 1017 |
+
"Daniel","Alex","Fred","Tom","Aaron","Lee","Oliver","Rishi","Albert","Bruce",
|
| 1018 |
+
"Junior","Ralph","Google UK English Male","Microsoft David","Microsoft Mark"
|
| 1019 |
+
];
|
| 1020 |
+
|
| 1021 |
+
function _stableHash(s) {
|
| 1022 |
+
let h = 0;
|
| 1023 |
+
for (let i = 0; i < s.length; i++) {
|
| 1024 |
+
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
| 1025 |
+
}
|
| 1026 |
+
return Math.abs(h);
|
| 1027 |
+
}
|
| 1028 |
+
|
| 1029 |
+
function _pickVoiceForAgent(name, ageRange) {
|
| 1030 |
+
const voices = window.speechSynthesis.getVoices().filter(v => v.lang && v.lang.toLowerCase().startsWith("en"));
|
| 1031 |
+
if (voices.length === 0) return { voice: null, pitch: 1.0, rate: 0.95 };
|
| 1032 |
+
|
| 1033 |
+
const lname = (name || "").toLowerCase();
|
| 1034 |
+
let isFemale = FEMALE_NAME_TOKENS.some(t => lname.includes(t));
|
| 1035 |
+
let isMale = MALE_NAME_TOKENS.some(t => lname.includes(t));
|
| 1036 |
+
if (isFemale && isMale) {
|
| 1037 |
+
if (lname.indexOf("captain arthur") >= 0 || lname.indexOf("arthur") >= 0) { isFemale = false; isMale = true; }
|
| 1038 |
+
}
|
| 1039 |
+
if (!isFemale && !isMale) {
|
| 1040 |
+
const h = _stableHash(lname || "x");
|
| 1041 |
+
isFemale = (h % 2) === 0;
|
| 1042 |
+
}
|
| 1043 |
+
|
| 1044 |
+
const pool = isFemale ? FEMALE_VOICE_POOL : MALE_VOICE_POOL;
|
| 1045 |
+
const hash = _stableHash(lname || "x");
|
| 1046 |
+
const idx = hash % pool.length;
|
| 1047 |
+
|
| 1048 |
+
let voice = null;
|
| 1049 |
+
for (let offset = 0; offset < pool.length; offset++) {
|
| 1050 |
+
const candidate = pool[(idx + offset) % pool.length];
|
| 1051 |
+
voice = voices.find(v => v.name === candidate);
|
| 1052 |
+
if (voice) break;
|
| 1053 |
+
voice = voices.find(v => v.name.toLowerCase().includes(candidate.toLowerCase()));
|
| 1054 |
+
if (voice) break;
|
| 1055 |
+
}
|
| 1056 |
+
if (!voice) {
|
| 1057 |
+
const filtered = voices.filter(v => isFemale ? /female|woman|samantha|victoria|karen|moira|tessa|aria/i.test(v.name) : /male|man|daniel|alex|fred|tom|david|mark/i.test(v.name));
|
| 1058 |
+
voice = (filtered.length > 0 ? filtered[hash % filtered.length] : voices[hash % voices.length]);
|
| 1059 |
+
}
|
| 1060 |
+
|
| 1061 |
+
const isElder = /\\b(6[0-9]|7[0-9]|8[0-9]|elder|granny|auntie|pastor|preacher|veteran|retired)\\b/i.test((ageRange || "") + " " + (name || ""));
|
| 1062 |
+
const isYoung = /\\b(1[6-9]|2[0-4]|teen|teenager|young|apprentice)\\b/i.test((ageRange || "") + " " + (name || ""));
|
| 1063 |
+
|
| 1064 |
+
let pitch = 1.0 + ((hash % 9) - 4) * 0.03;
|
| 1065 |
+
if (isElder) pitch -= 0.12;
|
| 1066 |
+
if (isYoung) pitch += 0.08;
|
| 1067 |
+
pitch = Math.max(0.6, Math.min(1.4, pitch));
|
| 1068 |
+
|
| 1069 |
+
const rate = 0.88 + (hash % 5) * 0.03;
|
| 1070 |
+
|
| 1071 |
+
return { voice: voice, pitch: pitch, rate: rate, isFemale: isFemale, isElder: isElder, isYoung: isYoung };
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
window.speakMonologue = function(text, name, ageRange) {
|
| 1075 |
+
console.log("[AnalogTown] speakMonologue called:", { textLen: (text||"").length, name: name, voiceEnabled: voiceEnabled });
|
| 1076 |
+
if (!voiceEnabled) { console.log("[AnalogTown] voice disabled, skipping"); return; }
|
| 1077 |
+
if (!window.speechSynthesis || !window.SpeechSynthesisUtterance) {
|
| 1078 |
+
console.warn("[AnalogTown] Browser SpeechSynthesis not available");
|
| 1079 |
+
return;
|
| 1080 |
+
}
|
| 1081 |
+
if (!text) { console.log("[AnalogTown] empty text, skipping"); return; }
|
| 1082 |
+
if (text === lastSpokenText) { console.log("[AnalogTown] same as last spoken, skipping"); return; }
|
| 1083 |
+
lastSpokenText = text;
|
| 1084 |
+
|
| 1085 |
+
try { window.speechSynthesis.cancel(); } catch (e) {}
|
| 1086 |
+
try { window.speechSynthesis.resume(); } catch (e) {}
|
| 1087 |
+
|
| 1088 |
+
const utter = new SpeechSynthesisUtterance(text);
|
| 1089 |
+
utter.volume = 1.0;
|
| 1090 |
+
|
| 1091 |
+
const choice = _pickVoiceForAgent(name, ageRange);
|
| 1092 |
+
if (choice.voice) {
|
| 1093 |
+
utter.voice = choice.voice;
|
| 1094 |
+
console.log("[AnalogTown] voice for", name, "→", choice.voice.name, "(female=" + choice.isFemale + ", elder=" + choice.isElder + ", young=" + choice.isYoung + ")");
|
| 1095 |
+
}
|
| 1096 |
+
utter.pitch = choice.pitch;
|
| 1097 |
+
utter.rate = choice.rate;
|
| 1098 |
+
console.log("[AnalogTown] pitch=" + utter.pitch.toFixed(2) + " rate=" + utter.rate.toFixed(2));
|
| 1099 |
+
|
| 1100 |
+
utter.onstart = function() {
|
| 1101 |
+
voicePlaying = true;
|
| 1102 |
+
const led = document.getElementById("voice-status-led");
|
| 1103 |
+
if (led) { led.style.background = "#39ff14"; led.style.boxShadow = "0 0 8px #39ff14"; }
|
| 1104 |
+
console.log("[AnalogTown] utterance started");
|
| 1105 |
+
};
|
| 1106 |
+
utter.onend = function() {
|
| 1107 |
+
voicePlaying = false;
|
| 1108 |
+
const led = document.getElementById("voice-status-led");
|
| 1109 |
+
if (led) { led.style.background = "#2a3040"; led.style.boxShadow = "none"; }
|
| 1110 |
+
console.log("[AnalogTown] utterance ended");
|
| 1111 |
+
};
|
| 1112 |
+
utter.onerror = function(e) {
|
| 1113 |
+
voicePlaying = false;
|
| 1114 |
+
const led = document.getElementById("voice-status-led");
|
| 1115 |
+
if (led) { led.style.background = "#ff4444"; led.style.boxShadow = "0 0 8px #ff4444"; }
|
| 1116 |
+
console.error("[AnalogTown] utterance error:", e);
|
| 1117 |
+
};
|
| 1118 |
+
|
| 1119 |
+
setTimeout(function() {
|
| 1120 |
+
window.speechSynthesis.speak(utter);
|
| 1121 |
+
console.log("[AnalogTown] speak() called");
|
| 1122 |
+
}, 80);
|
| 1123 |
+
};
|
| 1124 |
+
|
| 1125 |
+
if (window.speechSynthesis) {
|
| 1126 |
+
window.speechSynthesis.getVoices();
|
| 1127 |
+
window.speechSynthesis.onvoiceschanged = function() {
|
| 1128 |
+
const v = window.speechSynthesis.getVoices();
|
| 1129 |
+
console.log("[AnalogTown] voices loaded:", v.length);
|
| 1130 |
+
};
|
| 1131 |
+
}
|
| 1132 |
+
|
| 1133 |
window.updateAudioVolumeActual = function() {
|
| 1134 |
if (!ambientAudio) return;
|
| 1135 |
if (ambientMuted) {
|
|
|
|
| 1139 |
// Calculate target volume based on active signal bars
|
| 1140 |
const activeBars = document.querySelectorAll(".signal-bar.active").length;
|
| 1141 |
const signalFade = 1.0 - (activeBars / 5.0);
|
| 1142 |
+
const baseTarget = ambientVolume * signalFade;
|
| 1143 |
+
|
| 1144 |
+
// Duck to 15% of user ambient volume while voice is playing
|
| 1145 |
+
targetActualVolume = voicePlaying ? ambientVolume * 0.15 : baseTarget;
|
| 1146 |
+
|
| 1147 |
// Smoothly interpolate currentActualVolume towards targetActualVolume
|
| 1148 |
const diff = targetActualVolume - currentActualVolume;
|
| 1149 |
if (Math.abs(diff) > 0.01) {
|
| 1150 |
+
currentActualVolume += diff * 0.15;
|
| 1151 |
} else {
|
| 1152 |
currentActualVolume = targetActualVolume;
|
| 1153 |
}
|
| 1154 |
+
|
| 1155 |
// Set audio volume
|
| 1156 |
ambientAudio.volume = Math.max(0, Math.min(1, currentActualVolume));
|
| 1157 |
};
|
|
|
|
| 1162 |
|
| 1163 |
// Polling interval for smooth fade
|
| 1164 |
setInterval(window.updateAudioVolumeActual, 100);
|
| 1165 |
+
|
| 1166 |
+
let _voiceContainerWarned = false;
|
| 1167 |
+
let _voicePollTick = 0;
|
| 1168 |
+
setInterval(function() {
|
| 1169 |
+
_voicePollTick++;
|
| 1170 |
+
let c = document.getElementById("voice-player-container");
|
| 1171 |
+
if (!c) {
|
| 1172 |
+
c = document.querySelector("[data-text][data-name]");
|
| 1173 |
+
}
|
| 1174 |
+
if (!c) {
|
| 1175 |
+
if (!_voiceContainerWarned && _voicePollTick % 25 === 0) {
|
| 1176 |
+
console.warn("[AnalogTown] voice-player-container NOT FOUND in DOM after", _voicePollTick * 0.4, "s — sprite clicks won't trigger TTS until it appears");
|
| 1177 |
+
_voiceContainerWarned = true;
|
| 1178 |
+
}
|
| 1179 |
+
return;
|
| 1180 |
+
}
|
| 1181 |
+
if (_voiceContainerWarned) {
|
| 1182 |
+
console.log("[AnalogTown] voice-player-container found:", c);
|
| 1183 |
+
_voiceContainerWarned = false;
|
| 1184 |
+
}
|
| 1185 |
+
const text = c.getAttribute("data-text") || "";
|
| 1186 |
+
const name = c.getAttribute("data-name") || "";
|
| 1187 |
+
const age = c.getAttribute("data-age") || "";
|
| 1188 |
+
if (text && text !== lastSpokenText) {
|
| 1189 |
+
console.log("[AnalogTown] poll detected new data-text:", text.slice(0, 60), "...");
|
| 1190 |
+
window.speakMonologue(text, name, age);
|
| 1191 |
+
}
|
| 1192 |
+
}, 400);
|
| 1193 |
+
|
| 1194 |
+
// Chrome workaround: speech engine auto-pauses after ~15s of inactivity. Keep it warm.
|
| 1195 |
+
setInterval(function() {
|
| 1196 |
+
if (window.speechSynthesis && window.speechSynthesis.speaking && window.speechSynthesis.paused) {
|
| 1197 |
+
window.speechSynthesis.resume();
|
| 1198 |
+
}
|
| 1199 |
+
}, 5000);
|
| 1200 |
+
|
| 1201 |
+
window.selectTimelineDay = function(n) {
|
| 1202 |
+
console.log("selectTimelineDay called with day:", n);
|
| 1203 |
+
const container = document.getElementById("selected-day");
|
| 1204 |
+
if (container) {
|
| 1205 |
+
const input = container.querySelector("input") || container.querySelector("textarea");
|
| 1206 |
+
if (input) {
|
| 1207 |
+
let prototype = Object.getPrototypeOf(input);
|
| 1208 |
+
let descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
|
| 1209 |
+
while (prototype && !descriptor) {
|
| 1210 |
+
prototype = Object.getPrototypeOf(prototype);
|
| 1211 |
+
descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
|
| 1212 |
+
}
|
| 1213 |
+
const setter = descriptor ? descriptor.set : null;
|
| 1214 |
+
if (setter) {
|
| 1215 |
+
setter.call(input, String(n));
|
| 1216 |
+
} else {
|
| 1217 |
+
input.value = String(n);
|
| 1218 |
+
}
|
| 1219 |
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
| 1220 |
+
|
| 1221 |
+
setTimeout(() => {
|
| 1222 |
+
const btn = document.getElementById("select-day-trigger");
|
| 1223 |
+
if (btn) {
|
| 1224 |
+
console.log("Clicking select-day-trigger button");
|
| 1225 |
+
btn.click();
|
| 1226 |
+
} else {
|
| 1227 |
+
console.error("select-day-trigger button not found");
|
| 1228 |
+
}
|
| 1229 |
+
}, 50);
|
| 1230 |
+
} else {
|
| 1231 |
+
console.error("Input/textarea not found inside #selected-day");
|
| 1232 |
+
}
|
| 1233 |
+
} else {
|
| 1234 |
+
console.error("#selected-day container not found");
|
| 1235 |
+
}
|
| 1236 |
+
};
|
| 1237 |
"""
|
| 1238 |
|
| 1239 |
AUDIO_PATH = Path(__file__).parent / "static_ambient.wav"
|
|
|
|
| 1252 |
# ── State ──
|
| 1253 |
town_state = gr.State("")
|
| 1254 |
sim_result_state = gr.State("")
|
| 1255 |
+
timeline_state = gr.State("")
|
| 1256 |
|
| 1257 |
# ── Hidden Map Communication Inputs ──
|
| 1258 |
selected_agent_id = gr.Textbox(elem_id="selected-agent-id", elem_classes=["hidden-component"], visible=True)
|
| 1259 |
select_agent_trigger = gr.Button("Select Agent Trigger", elem_id="select-agent-trigger", elem_classes=["hidden-component"], visible=True)
|
| 1260 |
|
| 1261 |
+
# ── Hidden Timeline Day Bridge ──
|
| 1262 |
+
selected_day = gr.Textbox(elem_id="selected-day", elem_classes=["hidden-component"], visible=True)
|
| 1263 |
+
select_day_trigger = gr.Button("Select Day Trigger", elem_id="select-day-trigger", elem_classes=["hidden-component"], visible=True)
|
| 1264 |
+
|
| 1265 |
# ── Header ──
|
| 1266 |
with gr.Row(elem_id="header-block"):
|
| 1267 |
with gr.Column():
|
|
|
|
| 1310 |
)
|
| 1311 |
with gr.Column(scale=2, min_width=180):
|
| 1312 |
gr.HTML('<div class="ctrl-label">▸ TRANSMIT</div>')
|
| 1313 |
+
with gr.Row(equal_height=True):
|
| 1314 |
+
transmit_btn = gr.Button(
|
| 1315 |
+
"🔊 TRANSMIT",
|
| 1316 |
+
variant="primary",
|
| 1317 |
+
elem_classes=["transmit-btn"],
|
| 1318 |
+
elem_id="transmit-btn",
|
| 1319 |
+
scale=3,
|
| 1320 |
+
)
|
| 1321 |
+
reset_timeline_btn = gr.Button(
|
| 1322 |
+
"🔁 RESET",
|
| 1323 |
+
size="sm",
|
| 1324 |
+
elem_classes=["export-btn"],
|
| 1325 |
+
elem_id="reset-timeline-btn",
|
| 1326 |
+
scale=1,
|
| 1327 |
+
)
|
| 1328 |
sim_status = gr.Textbox(
|
| 1329 |
show_label=False,
|
| 1330 |
container=False,
|
|
|
|
| 1335 |
elem_id="sim-status",
|
| 1336 |
)
|
| 1337 |
|
| 1338 |
+
# === TIMELINE STRIP (below control bar, full width) ===
|
| 1339 |
+
timeline_strip = gr.HTML(
|
| 1340 |
+
value=format_timeline_strip(""),
|
| 1341 |
+
elem_id="timeline-strip-container",
|
| 1342 |
+
)
|
| 1343 |
+
|
| 1344 |
+
# === CREATIVE MODE ACCORDION ===
|
| 1345 |
+
with gr.Accordion("🎨 CREATIVE MODE — Design Your Own Town", open=False, elem_id="creative-mode"):
|
| 1346 |
+
with gr.Row():
|
| 1347 |
+
with gr.Column(scale=4):
|
| 1348 |
+
concept_input = gr.Textbox(
|
| 1349 |
+
label="🎨 TOWN CONCEPT",
|
| 1350 |
+
placeholder="e.g., a haunted seaside lighthouse where the keeper has gone missing and the village is split on whether to investigate or move on",
|
| 1351 |
+
lines=4,
|
| 1352 |
+
elem_id="creative-concept",
|
| 1353 |
+
)
|
| 1354 |
+
with gr.Column(scale=2):
|
| 1355 |
+
agent_count_slider = gr.Slider(
|
| 1356 |
+
label="# AGENTS",
|
| 1357 |
+
minimum=3,
|
| 1358 |
+
maximum=6,
|
| 1359 |
+
step=1,
|
| 1360 |
+
value=4,
|
| 1361 |
+
elem_id="creative-agent-count",
|
| 1362 |
+
)
|
| 1363 |
+
map_upload = gr.File(
|
| 1364 |
+
label="📁 UPLOAD MAP PNG (optional)",
|
| 1365 |
+
file_types=["image"],
|
| 1366 |
+
type="filepath",
|
| 1367 |
+
elem_id="creative-map-upload",
|
| 1368 |
+
)
|
| 1369 |
+
with gr.Row():
|
| 1370 |
+
creative_generate_btn = gr.Button(
|
| 1371 |
+
"🪄 GENERATE WITH AI",
|
| 1372 |
+
variant="secondary",
|
| 1373 |
+
elem_classes=["export-btn"],
|
| 1374 |
+
elem_id="creative-generate-btn",
|
| 1375 |
+
)
|
| 1376 |
+
creative_save_btn = gr.Button(
|
| 1377 |
+
"💾 SAVE & LOAD",
|
| 1378 |
+
variant="primary",
|
| 1379 |
+
elem_classes=["transmit-btn"],
|
| 1380 |
+
elem_id="creative-save-btn",
|
| 1381 |
+
)
|
| 1382 |
+
town_json_code = gr.Code(
|
| 1383 |
+
label="EDITABLE TOWN JSON",
|
| 1384 |
+
language="json",
|
| 1385 |
+
lines=14,
|
| 1386 |
+
elem_id="creative-json",
|
| 1387 |
+
)
|
| 1388 |
+
creative_status = gr.Markdown("_Status: idle_", elem_id="creative-status")
|
| 1389 |
+
|
| 1390 |
# === MAIN STAGE (map+dossier on left | receiver on right) ===
|
| 1391 |
with gr.Row(elem_id="main-stage", equal_height=True):
|
| 1392 |
|
| 1393 |
# ── LEFT: Town Map + Agent Dossier ──
|
| 1394 |
with gr.Column(scale=6, min_width=420, elem_id="map-panel"):
|
| 1395 |
gr.HTML('<div class="panel-header">🗺 TOWN MAP · CLICK A SIGNAL NODE</div>')
|
| 1396 |
+
day_badge = gr.HTML(
|
| 1397 |
+
value="",
|
| 1398 |
+
elem_id="day-badge-container",
|
| 1399 |
+
)
|
| 1400 |
|
| 1401 |
town_map = gr.HTML(
|
| 1402 |
value="<div style='color: #5a5248; text-align: center; padding: 20px;'>Load a town to see the map</div>",
|
|
|
|
| 1432 |
elem_id="monologue",
|
| 1433 |
)
|
| 1434 |
|
| 1435 |
+
voice_player = gr.HTML(
|
| 1436 |
+
value=_build_voice_player(None),
|
| 1437 |
+
elem_id="voice-player",
|
| 1438 |
+
elem_classes=["hidden-component"],
|
| 1439 |
+
)
|
| 1440 |
+
|
| 1441 |
with gr.Row(equal_height=True):
|
| 1442 |
with gr.Column(scale=1, min_width=160):
|
| 1443 |
gr.HTML('<div class="sub-header">🎚 EMOTIONAL STATE</div>')
|
|
|
|
| 1454 |
|
| 1455 |
ambient_control = gr.HTML(
|
| 1456 |
value=f"""
|
| 1457 |
+
<div class="ambient-control-panel" style="background: #111820; border: 1px solid #2a3040; border-radius: 8px; padding: 8px 12px; margin-top: 10px; font-family: 'IBM Plex Mono', monospace; display: flex; flex-direction: column; gap: 8px;">
|
| 1458 |
<div style="display: flex; align-items: center; gap: 10px;">
|
| 1459 |
+
<span style="font-size: 10px; color: #ffb347; letter-spacing: 1.5px; font-weight: bold; white-space: nowrap; min-width: 60px;">📻 STATIC</span>
|
| 1460 |
<button id="ambient-toggle-btn" class="retro-btn" onclick="toggleAmbient()" style="background: #39ff14; border: 1px solid rgba(57, 255, 20, 0.4); color: #0a0e14; padding: 2px 8px; font-family: 'IBM Plex Mono', monospace; font-size: 10px; cursor: pointer; border-radius: 4px; box-shadow: 0 0 10px rgba(57,255,20,0.4); font-weight: bold;">ON</button>
|
| 1461 |
<input type="range" id="ambient-volume" min="0" max="1" step="0.05" value="0.15" oninput="setAmbientVolume(this.value)" style="flex-grow: 1; accent-color: #ffb347; cursor: pointer; height: 4px; background: #2a3040; border-radius: 2px; outline: none;" />
|
| 1462 |
<span id="ambient-volume-val" style="font-size: 10px; color: #ffb347; width: 32px; text-align: right;">15%</span>
|
| 1463 |
</div>
|
| 1464 |
+
<div style="display: flex; align-items: center; gap: 10px;">
|
| 1465 |
+
<span style="font-size: 10px; color: #ffb347; letter-spacing: 1.5px; font-weight: bold; white-space: nowrap; min-width: 60px;">🎙 VOICE</span>
|
| 1466 |
+
<button id="voice-toggle-btn" class="retro-btn" onclick="toggleVoice()" style="background: #39ff14; border: 1px solid rgba(57, 255, 20, 0.4); color: #0a0e14; padding: 2px 8px; font-family: 'IBM Plex Mono', monospace; font-size: 10px; cursor: pointer; border-radius: 4px; box-shadow: 0 0 10px rgba(57,255,20,0.4); font-weight: bold;">🔊 VOICE ON</button>
|
| 1467 |
+
<button id="voice-test-btn" class="retro-btn" onclick="testVoice()" style="background: #2a3040; border: 1px solid rgba(255, 179, 71, 0.4); color: #ffb347; padding: 2px 8px; font-family: 'IBM Plex Mono', monospace; font-size: 10px; cursor: pointer; border-radius: 4px; font-weight: bold;">TEST</button>
|
| 1468 |
+
<span id="voice-status-led" style="display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: #2a3040; transition: background 0.2s, box-shadow 0.2s;"></span>
|
| 1469 |
+
<span style="font-size: 9px; color: #8a8070; flex-grow: 1; text-align: right;">click any sprite to hear</span>
|
| 1470 |
+
</div>
|
| 1471 |
</div>
|
| 1472 |
""",
|
| 1473 |
elem_id="ambient-control",
|
|
|
|
| 1521 |
|
| 1522 |
# ── Event Handlers ──
|
| 1523 |
|
| 1524 |
+
_load_outputs = [
|
| 1525 |
+
town_map, agent_profile, town_state, event_title, event_content,
|
| 1526 |
+
sim_result_state, timeline_state, timeline_strip, day_badge,
|
| 1527 |
+
sim_status,
|
| 1528 |
+
freq_display, monologue_display, emotion_display, action_display, trace_json, voice_player,
|
| 1529 |
+
freq_slider,
|
| 1530 |
+
]
|
| 1531 |
+
|
| 1532 |
load_btn.click(
|
| 1533 |
fn=on_load_town,
|
| 1534 |
inputs=[town_dropdown],
|
| 1535 |
+
outputs=_load_outputs,
|
| 1536 |
)
|
| 1537 |
|
|
|
|
| 1538 |
town_dropdown.change(
|
| 1539 |
fn=on_load_town,
|
| 1540 |
inputs=[town_dropdown],
|
| 1541 |
+
outputs=_load_outputs,
|
| 1542 |
+
)
|
| 1543 |
+
|
| 1544 |
+
creative_generate_btn.click(
|
| 1545 |
+
fn=on_generate_creative_town,
|
| 1546 |
+
inputs=[concept_input, agent_count_slider, town_json_code],
|
| 1547 |
+
outputs=[town_json_code, creative_status],
|
| 1548 |
+
)
|
| 1549 |
+
|
| 1550 |
+
creative_save_btn.click(
|
| 1551 |
+
fn=on_save_load_creative_town,
|
| 1552 |
+
inputs=[town_json_code, map_upload],
|
| 1553 |
+
outputs=_load_outputs + [town_dropdown, creative_status],
|
| 1554 |
)
|
| 1555 |
|
| 1556 |
# Map Click Event Selector
|
|
|
|
| 1566 |
emotion_display,
|
| 1567 |
action_display,
|
| 1568 |
trace_json,
|
| 1569 |
+
voice_player,
|
| 1570 |
],
|
| 1571 |
)
|
| 1572 |
|
| 1573 |
# Transmit
|
| 1574 |
transmit_btn.click(
|
| 1575 |
fn=on_transmit,
|
| 1576 |
+
inputs=[town_state, event_title, event_content, timeline_state],
|
| 1577 |
+
outputs=[sim_status, sim_result_state, town_map, timeline_state, timeline_strip, day_badge],
|
| 1578 |
+
show_progress_on=[sim_status],
|
| 1579 |
+
)
|
| 1580 |
+
|
| 1581 |
+
# Reset timeline
|
| 1582 |
+
reset_timeline_btn.click(
|
| 1583 |
+
fn=on_reset_timeline,
|
| 1584 |
+
inputs=[town_state],
|
| 1585 |
+
outputs=[sim_result_state, town_map, timeline_strip, day_badge],
|
| 1586 |
+
)
|
| 1587 |
+
|
| 1588 |
+
# Timeline day selection bridge
|
| 1589 |
+
select_day_trigger.click(
|
| 1590 |
+
fn=on_select_timeline_day,
|
| 1591 |
+
inputs=[selected_day, town_state, timeline_state],
|
| 1592 |
+
outputs=[
|
| 1593 |
+
sim_result_state,
|
| 1594 |
+
town_map,
|
| 1595 |
+
freq_display,
|
| 1596 |
+
monologue_display,
|
| 1597 |
+
emotion_display,
|
| 1598 |
+
action_display,
|
| 1599 |
+
trace_json,
|
| 1600 |
+
voice_player,
|
| 1601 |
+
timeline_strip,
|
| 1602 |
+
day_badge,
|
| 1603 |
+
],
|
| 1604 |
)
|
| 1605 |
|
| 1606 |
# Tune frequency
|
| 1607 |
freq_slider.change(
|
| 1608 |
fn=tune_frequency,
|
| 1609 |
inputs=[freq_slider, sim_result_state],
|
| 1610 |
+
outputs=[freq_display, monologue_display, emotion_display, action_display, trace_json, voice_player],
|
| 1611 |
)
|
| 1612 |
|
| 1613 |
# Export local
|
|
|
|
| 1624 |
outputs=[export_status],
|
| 1625 |
)
|
| 1626 |
|
|
|
|
| 1627 |
app.load(
|
| 1628 |
+
fn=lambda: on_load_town(available_towns[0] if available_towns else None),
|
| 1629 |
+
outputs=_load_outputs,
|
| 1630 |
)
|
| 1631 |
|
| 1632 |
return app
|
docs/plans/2026-06-12-wave3-timeline-mode.md
ADDED
|
@@ -0,0 +1,912 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Wave 3: Chained Broadcasts / Timeline Mode — Implementation Plan
|
| 2 |
+
|
| 3 |
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
| 4 |
+
|
| 5 |
+
**Goal:** After each TRANSMIT, carry the previous simulation's `updated_state` into the next run so the town drifts across days, and display a clickable horizontal timeline strip that lets the user scrub through history.
|
| 6 |
+
|
| 7 |
+
**Architecture:** Extend `Simulator.run_town_simulation` with an optional `previous_states` dict; add `timeline_state` gr.State (JSON list) in `app.py`; wire `on_transmit` to build/append entries and return strip HTML; add JS bridge (`selectTimelineDay`) mirroring the existing `selectAgent` pattern; add CSS to `theme.py`.
|
| 8 |
+
|
| 9 |
+
**Tech Stack:** Python 3.11, Gradio 4.x, Pydantic v2, browser SpeechSynthesis (untouched), vanilla JS bridge pattern already in place.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## File Map
|
| 14 |
+
|
| 15 |
+
| File | Change |
|
| 16 |
+
|---|---|
|
| 17 |
+
| `simulator.py` | Add `previous_states` optional param to `run_town_simulation` |
|
| 18 |
+
| `app.py` | Add `timeline_state` gr.State; add `format_timeline_strip`, `format_day_badge`, `on_reset_timeline`, `on_select_timeline_day`; rewrite `on_transmit` signature; add hidden bridge components; add `selectTimelineDay` JS; wire new event handlers |
|
| 19 |
+
| `theme.py` | Append CSS for `#timeline-strip`, `.timeline-pill`, `.day-badge`, `.timeline-empty` |
|
| 20 |
+
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
+
### Task 1: Extend `simulator.py` with `previous_states`
|
| 24 |
+
|
| 25 |
+
**Files:**
|
| 26 |
+
- Modify: `/Users/quyetthang/Desktop/Desktop/project/analog_town/simulator.py:154-208`
|
| 27 |
+
|
| 28 |
+
- [ ] **Step 1: Rename the existing `initial_states` param to `previous_states` and make the fallback explicit**
|
| 29 |
+
|
| 30 |
+
Open `simulator.py`. The `run_town_simulation` signature already has `initial_states: dict[str, AgentState] | None = None`. Rename it and guard the per-agent lookup so callers passing nothing get the same default state as today.
|
| 31 |
+
|
| 32 |
+
Replace the full method signature + per-agent state lookup block:
|
| 33 |
+
|
| 34 |
+
```python
|
| 35 |
+
def run_town_simulation(
|
| 36 |
+
self,
|
| 37 |
+
town: Town,
|
| 38 |
+
event: BroadcastEvent,
|
| 39 |
+
previous_states: dict[str, AgentState] | None = None,
|
| 40 |
+
progress_callback=None,
|
| 41 |
+
) -> SimulationResult:
|
| 42 |
+
"""Run simulation for all agents in the town.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
town: The town with agents
|
| 46 |
+
event: The broadcast event
|
| 47 |
+
previous_states: Optional dict of agent_id -> AgentState seeded from prior run
|
| 48 |
+
progress_callback: Optional callback(agent_name, status, index, total)
|
| 49 |
+
|
| 50 |
+
Returns:
|
| 51 |
+
SimulationResult with all transitions (failed agents are skipped)
|
| 52 |
+
"""
|
| 53 |
+
transitions = []
|
| 54 |
+
total = len(town.agents)
|
| 55 |
+
|
| 56 |
+
for i, agent in enumerate(town.agents):
|
| 57 |
+
agent_name = agent.name
|
| 58 |
+
try:
|
| 59 |
+
if progress_callback:
|
| 60 |
+
progress_callback(agent_name, "processing", i, total)
|
| 61 |
+
|
| 62 |
+
state = (
|
| 63 |
+
previous_states.get(agent.id, self._get_initial_state(agent))
|
| 64 |
+
if previous_states
|
| 65 |
+
else self._get_initial_state(agent)
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
transition = self.run_agent_transition(agent, state, event)
|
| 69 |
+
transitions.append(transition)
|
| 70 |
+
|
| 71 |
+
if progress_callback:
|
| 72 |
+
progress_callback(agent_name, "complete", i, total)
|
| 73 |
+
|
| 74 |
+
except Exception as e:
|
| 75 |
+
print(f"⚠ Agent '{agent_name}' failed: {e}")
|
| 76 |
+
traceback.print_exc()
|
| 77 |
+
if progress_callback:
|
| 78 |
+
progress_callback(agent_name, f"failed: {str(e)[:100]}", i, total)
|
| 79 |
+
continue
|
| 80 |
+
|
| 81 |
+
return SimulationResult(
|
| 82 |
+
town_id=town.id,
|
| 83 |
+
event=event,
|
| 84 |
+
transitions=transitions,
|
| 85 |
+
created_at=datetime.now().isoformat(),
|
| 86 |
+
)
|
| 87 |
+
```
|
| 88 |
+
|
| 89 |
+
- [ ] **Step 2: Verify the import chain still works**
|
| 90 |
+
|
| 91 |
+
```bash
|
| 92 |
+
cd /Users/quyetthang/Desktop/Desktop/project/analog_town && python -c "from simulator import Simulator; print('OK')"
|
| 93 |
+
```
|
| 94 |
+
|
| 95 |
+
Expected output: `OK`
|
| 96 |
+
|
| 97 |
+
- [ ] **Step 3: Verify backwards-compatibility with a quick unit call**
|
| 98 |
+
|
| 99 |
+
```bash
|
| 100 |
+
cd /Users/quyetthang/Desktop/Desktop/project/analog_town && python -c "
|
| 101 |
+
from simulator import Simulator
|
| 102 |
+
from schemas import AgentState
|
| 103 |
+
s = Simulator.__new__(Simulator)
|
| 104 |
+
# should accept no previous_states
|
| 105 |
+
import inspect
|
| 106 |
+
sig = inspect.signature(s.run_town_simulation)
|
| 107 |
+
assert 'previous_states' in sig.parameters
|
| 108 |
+
print('param OK:', list(sig.parameters.keys()))
|
| 109 |
+
"
|
| 110 |
+
```
|
| 111 |
+
|
| 112 |
+
Expected: `param OK: ['self', 'town', 'event', 'previous_states', 'progress_callback']`
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
### Task 2: Add CSS to `theme.py`
|
| 117 |
+
|
| 118 |
+
**Files:**
|
| 119 |
+
- Modify: `/Users/quyetthang/Desktop/Desktop/project/analog_town/theme.py` — append before the closing `"""`
|
| 120 |
+
|
| 121 |
+
- [ ] **Step 1: Append timeline CSS**
|
| 122 |
+
|
| 123 |
+
Open `theme.py`. Find the final line `"""` (closing triple-quote of `CUSTOM_CSS`). Insert the following block **before** that closing quote:
|
| 124 |
+
|
| 125 |
+
```css
|
| 126 |
+
|
| 127 |
+
/* ===== WAVE 3: TIMELINE STRIP ===== */
|
| 128 |
+
#timeline-strip {
|
| 129 |
+
width: 100%;
|
| 130 |
+
display: flex;
|
| 131 |
+
flex-direction: row;
|
| 132 |
+
flex-wrap: nowrap;
|
| 133 |
+
overflow-x: auto;
|
| 134 |
+
gap: 8px;
|
| 135 |
+
padding: 8px 12px;
|
| 136 |
+
background: linear-gradient(180deg, #0d1219 0%, #111820 100%);
|
| 137 |
+
border: 1px solid #2a3040;
|
| 138 |
+
border-radius: 10px;
|
| 139 |
+
margin-bottom: 10px;
|
| 140 |
+
box-sizing: border-box;
|
| 141 |
+
scrollbar-width: thin;
|
| 142 |
+
scrollbar-color: #2a3040 #0a0e14;
|
| 143 |
+
min-height: 54px;
|
| 144 |
+
align-items: center;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.timeline-empty {
|
| 148 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 149 |
+
font-size: 11px;
|
| 150 |
+
color: #5a5248;
|
| 151 |
+
letter-spacing: 1px;
|
| 152 |
+
padding: 6px 0;
|
| 153 |
+
flex: 1;
|
| 154 |
+
text-align: center;
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
.timeline-pill {
|
| 158 |
+
position: relative;
|
| 159 |
+
display: flex;
|
| 160 |
+
flex-direction: column;
|
| 161 |
+
align-items: flex-start;
|
| 162 |
+
gap: 2px;
|
| 163 |
+
background: #161d27;
|
| 164 |
+
border: 1px solid #2a3040;
|
| 165 |
+
border-radius: 8px;
|
| 166 |
+
padding: 5px 10px 5px 8px;
|
| 167 |
+
cursor: pointer;
|
| 168 |
+
min-width: 100px;
|
| 169 |
+
max-width: 160px;
|
| 170 |
+
flex-shrink: 0;
|
| 171 |
+
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
| 172 |
+
user-select: none;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.timeline-pill:hover {
|
| 176 |
+
border-color: #ffb34766;
|
| 177 |
+
box-shadow: 0 0 10px rgba(255, 179, 71, 0.12);
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.timeline-pill.active {
|
| 181 |
+
background: rgba(255, 179, 71, 0.07);
|
| 182 |
+
border-color: #ffb347;
|
| 183 |
+
box-shadow: 0 0 14px rgba(255, 179, 71, 0.25);
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.pill-day {
|
| 187 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 188 |
+
font-size: 9px;
|
| 189 |
+
font-weight: 700;
|
| 190 |
+
color: #ffb347;
|
| 191 |
+
letter-spacing: 2px;
|
| 192 |
+
text-transform: uppercase;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.timeline-pill:not(.active) .pill-day {
|
| 196 |
+
color: #8a8070;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.pill-title {
|
| 200 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 201 |
+
font-size: 10px;
|
| 202 |
+
color: #e0d6c8;
|
| 203 |
+
white-space: nowrap;
|
| 204 |
+
overflow: hidden;
|
| 205 |
+
text-overflow: ellipsis;
|
| 206 |
+
max-width: 140px;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.timeline-pill:not(.active) .pill-title {
|
| 210 |
+
color: #5a5248;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.pill-anomaly {
|
| 214 |
+
position: absolute;
|
| 215 |
+
top: 3px;
|
| 216 |
+
right: 4px;
|
| 217 |
+
font-size: 8px;
|
| 218 |
+
color: #ffb347;
|
| 219 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 220 |
+
font-weight: 700;
|
| 221 |
+
line-height: 1;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.day-badge {
|
| 225 |
+
position: absolute;
|
| 226 |
+
top: 10px;
|
| 227 |
+
right: 12px;
|
| 228 |
+
background: #0a0e14;
|
| 229 |
+
border: 1px solid #ffb347;
|
| 230 |
+
border-radius: 6px;
|
| 231 |
+
padding: 3px 10px;
|
| 232 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 233 |
+
font-size: 11px;
|
| 234 |
+
font-weight: 700;
|
| 235 |
+
color: #ffb347;
|
| 236 |
+
letter-spacing: 3px;
|
| 237 |
+
text-transform: uppercase;
|
| 238 |
+
text-shadow: 0 0 10px rgba(255, 179, 71, 0.4);
|
| 239 |
+
box-shadow: 0 0 12px rgba(255, 179, 71, 0.15);
|
| 240 |
+
z-index: 20;
|
| 241 |
+
pointer-events: none;
|
| 242 |
+
}
|
| 243 |
+
```
|
| 244 |
+
|
| 245 |
+
- [ ] **Step 2: Verify CSS import still works**
|
| 246 |
+
|
| 247 |
+
```bash
|
| 248 |
+
cd /Users/quyetthang/Desktop/Desktop/project/analog_town && python -c "from theme import CUSTOM_CSS; assert '#timeline-strip' in CUSTOM_CSS; print('CSS OK')"
|
| 249 |
+
```
|
| 250 |
+
|
| 251 |
+
Expected: `CSS OK`
|
| 252 |
+
|
| 253 |
+
---
|
| 254 |
+
|
| 255 |
+
### Task 3: Add helper functions to `app.py`
|
| 256 |
+
|
| 257 |
+
**Files:**
|
| 258 |
+
- Modify: `/Users/quyetthang/Desktop/Desktop/project/analog_town/app.py` — add two pure helper functions after `format_action_display`
|
| 259 |
+
|
| 260 |
+
- [ ] **Step 1: Add `format_timeline_strip` and `format_day_badge` helpers**
|
| 261 |
+
|
| 262 |
+
Open `app.py`. Find the line that starts `def _build_voice_player(` (currently around line 294). Insert the two new helpers **immediately before** that function:
|
| 263 |
+
|
| 264 |
+
```python
|
| 265 |
+
def format_timeline_strip(timeline_json: str, active_day: int | None = None) -> str:
|
| 266 |
+
"""Return a horizontal row of clickable day pills from a JSON timeline."""
|
| 267 |
+
if not timeline_json:
|
| 268 |
+
return '<div id="timeline-strip"><div class="timeline-empty">Broadcast to begin a day timeline ▸</div></div>'
|
| 269 |
+
|
| 270 |
+
try:
|
| 271 |
+
timeline = json.loads(timeline_json)
|
| 272 |
+
except Exception:
|
| 273 |
+
return '<div id="timeline-strip"><div class="timeline-empty">Broadcast to begin a day timeline ▸</div></div>'
|
| 274 |
+
|
| 275 |
+
if not timeline:
|
| 276 |
+
return '<div id="timeline-strip"><div class="timeline-empty">Broadcast to begin a day timeline ▸</div></div>'
|
| 277 |
+
|
| 278 |
+
if active_day is None:
|
| 279 |
+
active_day = timeline[-1]["day"]
|
| 280 |
+
|
| 281 |
+
pills = []
|
| 282 |
+
for entry in timeline:
|
| 283 |
+
day = entry["day"]
|
| 284 |
+
title = (entry.get("event_title") or "Broadcast")[:30]
|
| 285 |
+
if len(entry.get("event_title") or "") > 30:
|
| 286 |
+
title = title + "…"
|
| 287 |
+
|
| 288 |
+
sim = entry.get("sim_result", {})
|
| 289 |
+
transitions = sim.get("transitions", [])
|
| 290 |
+
anomaly_count = sum(
|
| 291 |
+
1 for t in transitions
|
| 292 |
+
if any(abs(v) > 0.6 for v in (t.get("emotion_delta") or {}).values())
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
active_class = "active" if day == active_day else ""
|
| 296 |
+
anomaly_html = f'<span class="pill-anomaly">⚠ {anomaly_count}</span>' if anomaly_count > 0 else ""
|
| 297 |
+
|
| 298 |
+
pills.append(
|
| 299 |
+
f'<div class="timeline-pill {active_class}" onclick="selectTimelineDay({day})">'
|
| 300 |
+
f'<span class="pill-day">DAY {day}</span>'
|
| 301 |
+
f'<span class="pill-title">{title}</span>'
|
| 302 |
+
f'{anomaly_html}'
|
| 303 |
+
f'</div>'
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
pills_html = "\n".join(pills)
|
| 307 |
+
return f'<div id="timeline-strip">{pills_html}</div>'
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def format_day_badge(day: int | None) -> str:
|
| 311 |
+
"""Return an absolutely-positioned day badge HTML for the map panel."""
|
| 312 |
+
if day is None:
|
| 313 |
+
return ""
|
| 314 |
+
return f'<div class="day-badge">DAY {day}</div>'
|
| 315 |
+
|
| 316 |
+
```
|
| 317 |
+
|
| 318 |
+
- [ ] **Step 2: Verify helpers import cleanly**
|
| 319 |
+
|
| 320 |
+
```bash
|
| 321 |
+
cd /Users/quyetthang/Desktop/Desktop/project/analog_town && python -c "
|
| 322 |
+
from app import format_timeline_strip, format_day_badge
|
| 323 |
+
print(format_day_badge(3))
|
| 324 |
+
print(format_timeline_strip(''))
|
| 325 |
+
"
|
| 326 |
+
```
|
| 327 |
+
|
| 328 |
+
Expected output:
|
| 329 |
+
```
|
| 330 |
+
<div class="day-badge">DAY 3</div>
|
| 331 |
+
<div id="timeline-strip"><div class="timeline-empty">Broadcast to begin a day timeline ▸</div></div>
|
| 332 |
+
```
|
| 333 |
+
|
| 334 |
+
---
|
| 335 |
+
|
| 336 |
+
### Task 4: Update `on_transmit` to chain previous states
|
| 337 |
+
|
| 338 |
+
**Files:**
|
| 339 |
+
- Modify: `/Users/quyetthang/Desktop/Desktop/project/analog_town/app.py:469-527` (the `on_transmit` function)
|
| 340 |
+
|
| 341 |
+
- [ ] **Step 1: Replace `on_transmit` with the chaining version**
|
| 342 |
+
|
| 343 |
+
Find and replace the entire `on_transmit` function (from `def on_transmit(` to the final `return status, result_json, updated_map`) with:
|
| 344 |
+
|
| 345 |
+
```python
|
| 346 |
+
def on_transmit(town_json, event_title, event_content, timeline_json, progress=gr.Progress()):
|
| 347 |
+
"""Run the simulation, chaining from the last day's states if available."""
|
| 348 |
+
if not town_json:
|
| 349 |
+
return "⚠ Load a town first", "", gr.update(), timeline_json, format_timeline_strip(timeline_json), ""
|
| 350 |
+
if not event_content:
|
| 351 |
+
return "⚠ Enter a broadcast event", "", gr.update(), timeline_json, format_timeline_strip(timeline_json), ""
|
| 352 |
+
|
| 353 |
+
try:
|
| 354 |
+
town = Town(**json.loads(town_json))
|
| 355 |
+
except Exception as e:
|
| 356 |
+
return f"❌ Invalid town data: {e}", "", gr.update(), timeline_json, format_timeline_strip(timeline_json), ""
|
| 357 |
+
|
| 358 |
+
event = BroadcastEvent(
|
| 359 |
+
title=event_title or "Broadcast Event",
|
| 360 |
+
content=event_content,
|
| 361 |
+
source="Town Radio",
|
| 362 |
+
)
|
| 363 |
+
|
| 364 |
+
try:
|
| 365 |
+
client = ModelClient()
|
| 366 |
+
simulator = Simulator(model_client=client)
|
| 367 |
+
except Exception as e:
|
| 368 |
+
return f"❌ Model initialization failed: {e}", "", gr.update(), timeline_json, format_timeline_strip(timeline_json), ""
|
| 369 |
+
|
| 370 |
+
previous_states = None
|
| 371 |
+
current_timeline = []
|
| 372 |
+
if timeline_json:
|
| 373 |
+
try:
|
| 374 |
+
current_timeline = json.loads(timeline_json)
|
| 375 |
+
if current_timeline:
|
| 376 |
+
last_entry = current_timeline[-1]
|
| 377 |
+
last_result = SimulationResult(**last_entry["sim_result"])
|
| 378 |
+
previous_states = {
|
| 379 |
+
t.agent_id: t.updated_state for t in last_result.transitions
|
| 380 |
+
}
|
| 381 |
+
except Exception:
|
| 382 |
+
current_timeline = []
|
| 383 |
+
|
| 384 |
+
day = len(current_timeline) + 1
|
| 385 |
+
|
| 386 |
+
status_lines = []
|
| 387 |
+
|
| 388 |
+
def progress_callback(name, status, index, total):
|
| 389 |
+
emoji = {"processing": "⏳", "complete": "✅"}.get(status.split(":")[0], "⚠")
|
| 390 |
+
line = f"{emoji} [{index+1}/{total}] {name}: {status}"
|
| 391 |
+
status_lines.append(line)
|
| 392 |
+
progress((index + 1) / total, desc=f"Processing {name}...")
|
| 393 |
+
|
| 394 |
+
try:
|
| 395 |
+
result = simulator.run_town_simulation(
|
| 396 |
+
town=town,
|
| 397 |
+
event=event,
|
| 398 |
+
previous_states=previous_states,
|
| 399 |
+
progress_callback=progress_callback,
|
| 400 |
+
)
|
| 401 |
+
except Exception as e:
|
| 402 |
+
return f"❌ Simulation failed: {e}", "", gr.update(), timeline_json, format_timeline_strip(timeline_json), ""
|
| 403 |
+
|
| 404 |
+
n_success = len(result.transitions)
|
| 405 |
+
n_total = len(town.agents)
|
| 406 |
+
|
| 407 |
+
if n_success == n_total:
|
| 408 |
+
status = f"✅ Signal acquired — {n_success}/{n_total} transmissions intercepted"
|
| 409 |
+
elif n_success > 0:
|
| 410 |
+
status = f"⚠ Partial signal — {n_success}/{n_total} transmissions intercepted"
|
| 411 |
+
else:
|
| 412 |
+
status = "❌ No signal — all transmissions failed"
|
| 413 |
+
|
| 414 |
+
status += "\n" + "\n".join(status_lines)
|
| 415 |
+
|
| 416 |
+
new_entry = {
|
| 417 |
+
"day": day,
|
| 418 |
+
"event_title": event_title or "Broadcast Event",
|
| 419 |
+
"event_content": event_content,
|
| 420 |
+
"sim_result": json.loads(result.model_dump_json()),
|
| 421 |
+
}
|
| 422 |
+
current_timeline.append(new_entry)
|
| 423 |
+
new_timeline_json = json.dumps(current_timeline)
|
| 424 |
+
|
| 425 |
+
result_json = result.model_dump_json(indent=2)
|
| 426 |
+
updated_map = format_town_map(town, None, result)
|
| 427 |
+
timeline_strip = format_timeline_strip(new_timeline_json, active_day=day)
|
| 428 |
+
day_badge = format_day_badge(day)
|
| 429 |
+
|
| 430 |
+
return status, result_json, updated_map, new_timeline_json, timeline_strip, day_badge
|
| 431 |
+
```
|
| 432 |
+
|
| 433 |
+
- [ ] **Step 2: Verify the function signature compiles**
|
| 434 |
+
|
| 435 |
+
```bash
|
| 436 |
+
cd /Users/quyetthang/Desktop/Desktop/project/analog_town && python -c "
|
| 437 |
+
import inspect, app
|
| 438 |
+
sig = inspect.signature(app.on_transmit)
|
| 439 |
+
print(list(sig.parameters.keys()))
|
| 440 |
+
"
|
| 441 |
+
```
|
| 442 |
+
|
| 443 |
+
Expected: `['town_json', 'event_title', 'event_content', 'timeline_json', 'progress']`
|
| 444 |
+
|
| 445 |
+
---
|
| 446 |
+
|
| 447 |
+
### Task 5: Add `on_reset_timeline` and `on_select_timeline_day` handlers
|
| 448 |
+
|
| 449 |
+
**Files:**
|
| 450 |
+
- Modify: `/Users/quyetthang/Desktop/Desktop/project/analog_town/app.py` — add after `on_transmit`
|
| 451 |
+
|
| 452 |
+
- [ ] **Step 1: Add the two new handlers immediately after `on_transmit`**
|
| 453 |
+
|
| 454 |
+
Find the line `def on_export_local(` and insert the following two functions **before** it:
|
| 455 |
+
|
| 456 |
+
```python
|
| 457 |
+
def on_reset_timeline(town_json):
|
| 458 |
+
"""Clear the entire timeline and return to Day 1 default state."""
|
| 459 |
+
empty_strip = format_timeline_strip("")
|
| 460 |
+
if not town_json:
|
| 461 |
+
return "", "", empty_strip, ""
|
| 462 |
+
try:
|
| 463 |
+
town = Town(**json.loads(town_json))
|
| 464 |
+
map_html = format_town_map(town, None, None)
|
| 465 |
+
except Exception:
|
| 466 |
+
map_html = ""
|
| 467 |
+
return "", map_html, empty_strip, ""
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def on_select_timeline_day(day_str, town_json, timeline_json):
|
| 471 |
+
"""Restore the map and sim_result to a specific historical day."""
|
| 472 |
+
_empty_voice = _build_voice_player(None)
|
| 473 |
+
empty_returns = (
|
| 474 |
+
"", # sim_result_state
|
| 475 |
+
"", # town_map (will stay current if we can't parse)
|
| 476 |
+
format_frequency_display(88.0, None, 0),
|
| 477 |
+
format_monologue(None, None),
|
| 478 |
+
format_emotion_bars(None),
|
| 479 |
+
format_action_display(None),
|
| 480 |
+
"", # trace_json
|
| 481 |
+
_empty_voice,
|
| 482 |
+
format_timeline_strip(timeline_json),
|
| 483 |
+
"", # day_badge
|
| 484 |
+
)
|
| 485 |
+
|
| 486 |
+
if not day_str or not timeline_json:
|
| 487 |
+
return empty_returns
|
| 488 |
+
|
| 489 |
+
try:
|
| 490 |
+
day = int(day_str)
|
| 491 |
+
timeline = json.loads(timeline_json)
|
| 492 |
+
except Exception:
|
| 493 |
+
return empty_returns
|
| 494 |
+
|
| 495 |
+
entry = next((e for e in timeline if e["day"] == day), None)
|
| 496 |
+
if not entry:
|
| 497 |
+
return empty_returns
|
| 498 |
+
|
| 499 |
+
try:
|
| 500 |
+
sim_result = SimulationResult(**entry["sim_result"])
|
| 501 |
+
town = Town(**json.loads(town_json)) if town_json else None
|
| 502 |
+
except Exception:
|
| 503 |
+
return empty_returns
|
| 504 |
+
|
| 505 |
+
result_json = sim_result.model_dump_json(indent=2)
|
| 506 |
+
map_html = format_town_map(town, None, sim_result) if town else ""
|
| 507 |
+
strip_html = format_timeline_strip(timeline_json, active_day=day)
|
| 508 |
+
badge_html = format_day_badge(day)
|
| 509 |
+
|
| 510 |
+
return (
|
| 511 |
+
result_json,
|
| 512 |
+
map_html,
|
| 513 |
+
format_frequency_display(88.0, None, 0),
|
| 514 |
+
format_monologue(None, None),
|
| 515 |
+
format_emotion_bars(None),
|
| 516 |
+
format_action_display(None),
|
| 517 |
+
"",
|
| 518 |
+
_empty_voice,
|
| 519 |
+
strip_html,
|
| 520 |
+
badge_html,
|
| 521 |
+
)
|
| 522 |
+
```
|
| 523 |
+
|
| 524 |
+
- [ ] **Step 2: Verify both handlers are importable**
|
| 525 |
+
|
| 526 |
+
```bash
|
| 527 |
+
cd /Users/quyetthang/Desktop/Desktop/project/analog_town && python -c "
|
| 528 |
+
from app import on_reset_timeline, on_select_timeline_day; print('OK')
|
| 529 |
+
"
|
| 530 |
+
```
|
| 531 |
+
|
| 532 |
+
Expected: `OK`
|
| 533 |
+
|
| 534 |
+
---
|
| 535 |
+
|
| 536 |
+
### Task 6: Add new UI components and JS bridge to `create_app`
|
| 537 |
+
|
| 538 |
+
**Files:**
|
| 539 |
+
- Modify: `/Users/quyetthang/Desktop/Desktop/project/analog_town/app.py` — inside `create_app`
|
| 540 |
+
|
| 541 |
+
This task has multiple sub-steps. Read all of them before editing.
|
| 542 |
+
|
| 543 |
+
- [ ] **Step 1: Add `timeline_state` gr.State near the other state declarations**
|
| 544 |
+
|
| 545 |
+
Find the block:
|
| 546 |
+
```python
|
| 547 |
+
# ── State ──
|
| 548 |
+
town_state = gr.State("")
|
| 549 |
+
sim_result_state = gr.State("")
|
| 550 |
+
```
|
| 551 |
+
|
| 552 |
+
Replace with:
|
| 553 |
+
```python
|
| 554 |
+
# ── State ──
|
| 555 |
+
town_state = gr.State("")
|
| 556 |
+
sim_result_state = gr.State("")
|
| 557 |
+
timeline_state = gr.State("")
|
| 558 |
+
```
|
| 559 |
+
|
| 560 |
+
- [ ] **Step 2: Add hidden day-bridge components after the existing agent-bridge components**
|
| 561 |
+
|
| 562 |
+
Find:
|
| 563 |
+
```python
|
| 564 |
+
# ── Hidden Map Communication Inputs ──
|
| 565 |
+
selected_agent_id = gr.Textbox(elem_id="selected-agent-id", elem_classes=["hidden-component"], visible=True)
|
| 566 |
+
select_agent_trigger = gr.Button("Select Agent Trigger", elem_id="select-agent-trigger", elem_classes=["hidden-component"], visible=True)
|
| 567 |
+
```
|
| 568 |
+
|
| 569 |
+
Replace with:
|
| 570 |
+
```python
|
| 571 |
+
# ── Hidden Map Communication Inputs ──
|
| 572 |
+
selected_agent_id = gr.Textbox(elem_id="selected-agent-id", elem_classes=["hidden-component"], visible=True)
|
| 573 |
+
select_agent_trigger = gr.Button("Select Agent Trigger", elem_id="select-agent-trigger", elem_classes=["hidden-component"], visible=True)
|
| 574 |
+
|
| 575 |
+
# ── Hidden Timeline Day Bridge ──
|
| 576 |
+
selected_day = gr.Textbox(elem_id="selected-day", elem_classes=["hidden-component"], visible=True)
|
| 577 |
+
select_day_trigger = gr.Button("Select Day Trigger", elem_id="select-day-trigger", elem_classes=["hidden-component"], visible=True)
|
| 578 |
+
```
|
| 579 |
+
|
| 580 |
+
- [ ] **Step 3: Add RESET button next to TRANSMIT**
|
| 581 |
+
|
| 582 |
+
Find the transmit column block (starts with `with gr.Column(scale=2, min_width=180):`). Replace just the contents of that column to insert the reset button:
|
| 583 |
+
|
| 584 |
+
Find:
|
| 585 |
+
```python
|
| 586 |
+
with gr.Column(scale=2, min_width=180):
|
| 587 |
+
gr.HTML('<div class="ctrl-label">▸ TRANSMIT</div>')
|
| 588 |
+
transmit_btn = gr.Button(
|
| 589 |
+
"🔊 TRANSMIT",
|
| 590 |
+
variant="primary",
|
| 591 |
+
elem_classes=["transmit-btn"],
|
| 592 |
+
elem_id="transmit-btn",
|
| 593 |
+
)
|
| 594 |
+
sim_status = gr.Textbox(
|
| 595 |
+
show_label=False,
|
| 596 |
+
container=False,
|
| 597 |
+
lines=2,
|
| 598 |
+
max_lines=2,
|
| 599 |
+
interactive=False,
|
| 600 |
+
placeholder="Awaiting broadcast...",
|
| 601 |
+
elem_id="sim-status",
|
| 602 |
+
)
|
| 603 |
+
```
|
| 604 |
+
|
| 605 |
+
Replace with:
|
| 606 |
+
```python
|
| 607 |
+
with gr.Column(scale=2, min_width=180):
|
| 608 |
+
gr.HTML('<div class="ctrl-label">▸ TRANSMIT</div>')
|
| 609 |
+
with gr.Row(equal_height=True):
|
| 610 |
+
transmit_btn = gr.Button(
|
| 611 |
+
"🔊 TRANSMIT",
|
| 612 |
+
variant="primary",
|
| 613 |
+
elem_classes=["transmit-btn"],
|
| 614 |
+
elem_id="transmit-btn",
|
| 615 |
+
scale=3,
|
| 616 |
+
)
|
| 617 |
+
reset_timeline_btn = gr.Button(
|
| 618 |
+
"🔁 RESET",
|
| 619 |
+
size="sm",
|
| 620 |
+
elem_classes=["export-btn"],
|
| 621 |
+
elem_id="reset-timeline-btn",
|
| 622 |
+
scale=1,
|
| 623 |
+
)
|
| 624 |
+
sim_status = gr.Textbox(
|
| 625 |
+
show_label=False,
|
| 626 |
+
container=False,
|
| 627 |
+
lines=2,
|
| 628 |
+
max_lines=2,
|
| 629 |
+
interactive=False,
|
| 630 |
+
placeholder="Awaiting broadcast...",
|
| 631 |
+
elem_id="sim-status",
|
| 632 |
+
)
|
| 633 |
+
```
|
| 634 |
+
|
| 635 |
+
- [ ] **Step 4: Add timeline strip and day badge components**
|
| 636 |
+
|
| 637 |
+
After the `#control-bar` Group closes (the line `# === MAIN STAGE (map+dossier on left | receiver on right) ===`), insert the timeline strip row:
|
| 638 |
+
|
| 639 |
+
Find:
|
| 640 |
+
```python
|
| 641 |
+
# === MAIN STAGE (map+dossier on left | receiver on right) ===
|
| 642 |
+
with gr.Row(elem_id="main-stage", equal_height=True):
|
| 643 |
+
```
|
| 644 |
+
|
| 645 |
+
Replace with:
|
| 646 |
+
```python
|
| 647 |
+
# === TIMELINE STRIP (below control bar, full width) ===
|
| 648 |
+
timeline_strip = gr.HTML(
|
| 649 |
+
value=format_timeline_strip(""),
|
| 650 |
+
elem_id="timeline-strip-container",
|
| 651 |
+
)
|
| 652 |
+
|
| 653 |
+
# === MAIN STAGE (map+dossier on left | receiver on right) ===
|
| 654 |
+
with gr.Row(elem_id="main-stage", equal_height=True):
|
| 655 |
+
```
|
| 656 |
+
|
| 657 |
+
Then, inside `#map-panel`, after the `gr.HTML('<div class="panel-header">...')` line, add the day badge:
|
| 658 |
+
|
| 659 |
+
Find:
|
| 660 |
+
```python
|
| 661 |
+
with gr.Column(scale=6, min_width=420, elem_id="map-panel"):
|
| 662 |
+
gr.HTML('<div class="panel-header">🗺 TOWN MAP · CLICK A SIGNAL NODE</div>')
|
| 663 |
+
|
| 664 |
+
town_map = gr.HTML(
|
| 665 |
+
```
|
| 666 |
+
|
| 667 |
+
Replace with:
|
| 668 |
+
```python
|
| 669 |
+
with gr.Column(scale=6, min_width=420, elem_id="map-panel"):
|
| 670 |
+
gr.HTML('<div class="panel-header">🗺 TOWN MAP · CLICK A SIGNAL NODE</div>')
|
| 671 |
+
day_badge = gr.HTML(
|
| 672 |
+
value="",
|
| 673 |
+
elem_id="day-badge-container",
|
| 674 |
+
)
|
| 675 |
+
|
| 676 |
+
town_map = gr.HTML(
|
| 677 |
+
```
|
| 678 |
+
|
| 679 |
+
- [ ] **Step 5: Verify the UI structure compiles without error**
|
| 680 |
+
|
| 681 |
+
```bash
|
| 682 |
+
cd /Users/quyetthang/Desktop/Desktop/project/analog_town && python -c "from app import create_app; create_app(); print('OK')"
|
| 683 |
+
```
|
| 684 |
+
|
| 685 |
+
Expected: `OK` (no traceback)
|
| 686 |
+
|
| 687 |
+
---
|
| 688 |
+
|
| 689 |
+
### Task 7: Wire all event handlers in `create_app`
|
| 690 |
+
|
| 691 |
+
**Files:**
|
| 692 |
+
- Modify: `/Users/quyetthang/Desktop/Desktop/project/analog_town/app.py` — the `# ── Event Handlers ──` section
|
| 693 |
+
|
| 694 |
+
- [ ] **Step 1: Update the transmit_btn.click binding**
|
| 695 |
+
|
| 696 |
+
Find:
|
| 697 |
+
```python
|
| 698 |
+
# Transmit
|
| 699 |
+
transmit_btn.click(
|
| 700 |
+
fn=on_transmit,
|
| 701 |
+
inputs=[town_state, event_title, event_content],
|
| 702 |
+
outputs=[sim_status, sim_result_state, town_map],
|
| 703 |
+
)
|
| 704 |
+
```
|
| 705 |
+
|
| 706 |
+
Replace with:
|
| 707 |
+
```python
|
| 708 |
+
# Transmit
|
| 709 |
+
transmit_btn.click(
|
| 710 |
+
fn=on_transmit,
|
| 711 |
+
inputs=[town_state, event_title, event_content, timeline_state],
|
| 712 |
+
outputs=[sim_status, sim_result_state, town_map, timeline_state, timeline_strip, day_badge],
|
| 713 |
+
)
|
| 714 |
+
```
|
| 715 |
+
|
| 716 |
+
- [ ] **Step 2: Add reset_timeline_btn.click binding**
|
| 717 |
+
|
| 718 |
+
After the transmit binding, add:
|
| 719 |
+
|
| 720 |
+
```python
|
| 721 |
+
# Reset timeline
|
| 722 |
+
reset_timeline_btn.click(
|
| 723 |
+
fn=on_reset_timeline,
|
| 724 |
+
inputs=[town_state],
|
| 725 |
+
outputs=[sim_result_state, town_map, timeline_strip, day_badge],
|
| 726 |
+
)
|
| 727 |
+
```
|
| 728 |
+
|
| 729 |
+
Wait — `on_reset_timeline` returns 4 values: `("", map_html, empty_strip, "")`. The outputs map is:
|
| 730 |
+
- `""` → `sim_result_state`
|
| 731 |
+
- `map_html` → `town_map`
|
| 732 |
+
- `empty_strip` → `timeline_strip`
|
| 733 |
+
- `""` → `day_badge`
|
| 734 |
+
|
| 735 |
+
That is correct as written above.
|
| 736 |
+
|
| 737 |
+
- [ ] **Step 3: Add select_day_trigger.click binding**
|
| 738 |
+
|
| 739 |
+
After the reset binding, add:
|
| 740 |
+
|
| 741 |
+
```python
|
| 742 |
+
# Timeline day selection bridge
|
| 743 |
+
select_day_trigger.click(
|
| 744 |
+
fn=on_select_timeline_day,
|
| 745 |
+
inputs=[selected_day, town_state, timeline_state],
|
| 746 |
+
outputs=[
|
| 747 |
+
sim_result_state,
|
| 748 |
+
town_map,
|
| 749 |
+
freq_display,
|
| 750 |
+
monologue_display,
|
| 751 |
+
emotion_display,
|
| 752 |
+
action_display,
|
| 753 |
+
trace_json,
|
| 754 |
+
voice_player,
|
| 755 |
+
timeline_strip,
|
| 756 |
+
day_badge,
|
| 757 |
+
],
|
| 758 |
+
)
|
| 759 |
+
```
|
| 760 |
+
|
| 761 |
+
- [ ] **Step 4: Verify full create_app compiles**
|
| 762 |
+
|
| 763 |
+
```bash
|
| 764 |
+
cd /Users/quyetthang/Desktop/Desktop/project/analog_town && python -c "from app import create_app; create_app(); print('OK')"
|
| 765 |
+
```
|
| 766 |
+
|
| 767 |
+
Expected: `OK`
|
| 768 |
+
|
| 769 |
+
---
|
| 770 |
+
|
| 771 |
+
### Task 8: Add `selectTimelineDay` JS to `CUSTOM_JS_TEMPLATE`
|
| 772 |
+
|
| 773 |
+
**Files:**
|
| 774 |
+
- Modify: `/Users/quyetthang/Desktop/Desktop/project/analog_town/app.py` — `CUSTOM_JS_TEMPLATE` string
|
| 775 |
+
|
| 776 |
+
- [ ] **Step 1: Append the timeline JS bridge at the end of `CUSTOM_JS_TEMPLATE`**
|
| 777 |
+
|
| 778 |
+
Find the last few lines of `CUSTOM_JS_TEMPLATE` (before the closing `"""`):
|
| 779 |
+
|
| 780 |
+
```javascript
|
| 781 |
+
// Chrome workaround: speech engine auto-pauses after ~15s of inactivity. Keep it warm.
|
| 782 |
+
setInterval(function() {
|
| 783 |
+
if (window.speechSynthesis && window.speechSynthesis.speaking && window.speechSynthesis.paused) {
|
| 784 |
+
window.speechSynthesis.resume();
|
| 785 |
+
}
|
| 786 |
+
}, 5000);
|
| 787 |
+
```
|
| 788 |
+
|
| 789 |
+
Insert **after** those lines (before the `"""`):
|
| 790 |
+
|
| 791 |
+
```javascript
|
| 792 |
+
|
| 793 |
+
window.selectTimelineDay = function(n) {
|
| 794 |
+
console.log("selectTimelineDay called with day:", n);
|
| 795 |
+
const container = document.getElementById("selected-day");
|
| 796 |
+
if (container) {
|
| 797 |
+
const input = container.querySelector("input") || container.querySelector("textarea");
|
| 798 |
+
if (input) {
|
| 799 |
+
let prototype = Object.getPrototypeOf(input);
|
| 800 |
+
let descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
|
| 801 |
+
while (prototype && !descriptor) {
|
| 802 |
+
prototype = Object.getPrototypeOf(prototype);
|
| 803 |
+
descriptor = Object.getOwnPropertyDescriptor(prototype, "value");
|
| 804 |
+
}
|
| 805 |
+
const setter = descriptor ? descriptor.set : null;
|
| 806 |
+
if (setter) {
|
| 807 |
+
setter.call(input, String(n));
|
| 808 |
+
} else {
|
| 809 |
+
input.value = String(n);
|
| 810 |
+
}
|
| 811 |
+
input.dispatchEvent(new Event("input", { bubbles: true }));
|
| 812 |
+
|
| 813 |
+
setTimeout(() => {
|
| 814 |
+
const btn = document.getElementById("select-day-trigger");
|
| 815 |
+
if (btn) {
|
| 816 |
+
console.log("Clicking select-day-trigger button");
|
| 817 |
+
btn.click();
|
| 818 |
+
} else {
|
| 819 |
+
console.error("select-day-trigger button not found");
|
| 820 |
+
}
|
| 821 |
+
}, 50);
|
| 822 |
+
} else {
|
| 823 |
+
console.error("Input/textarea not found inside #selected-day");
|
| 824 |
+
}
|
| 825 |
+
} else {
|
| 826 |
+
console.error("#selected-day container not found");
|
| 827 |
+
}
|
| 828 |
+
};
|
| 829 |
+
```
|
| 830 |
+
|
| 831 |
+
- [ ] **Step 2: Verify JS template still contains the selectAgent function**
|
| 832 |
+
|
| 833 |
+
```bash
|
| 834 |
+
cd /Users/quyetthang/Desktop/Desktop/project/analog_town && python -c "
|
| 835 |
+
from app import CUSTOM_JS
|
| 836 |
+
assert 'selectAgent' in CUSTOM_JS
|
| 837 |
+
assert 'selectTimelineDay' in CUSTOM_JS
|
| 838 |
+
assert 'select-day-trigger' in CUSTOM_JS
|
| 839 |
+
print('JS OK')
|
| 840 |
+
"
|
| 841 |
+
```
|
| 842 |
+
|
| 843 |
+
Expected: `JS OK`
|
| 844 |
+
|
| 845 |
+
---
|
| 846 |
+
|
| 847 |
+
### Task 9: Acceptance checks
|
| 848 |
+
|
| 849 |
+
- [ ] **Step 1: Verify import**
|
| 850 |
+
|
| 851 |
+
```bash
|
| 852 |
+
cd /Users/quyetthang/Desktop/Desktop/project/analog_town && python -c "from app import create_app; create_app(); print('OK')"
|
| 853 |
+
```
|
| 854 |
+
|
| 855 |
+
Expected: `OK`
|
| 856 |
+
|
| 857 |
+
- [ ] **Step 2: Start server, wait, check HTTP 200**
|
| 858 |
+
|
| 859 |
+
```bash
|
| 860 |
+
pkill -f "python app.py" 2>/dev/null; sleep 1
|
| 861 |
+
cd /Users/quyetthang/Desktop/Desktop/project/analog_town && python app.py > /tmp/analog_app.log 2>&1 &
|
| 862 |
+
sleep 8
|
| 863 |
+
curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:7860
|
| 864 |
+
```
|
| 865 |
+
|
| 866 |
+
Expected: `200`
|
| 867 |
+
|
| 868 |
+
- [ ] **Step 3: Check server logs for errors**
|
| 869 |
+
|
| 870 |
+
```bash
|
| 871 |
+
tail -25 /tmp/analog_app.log
|
| 872 |
+
```
|
| 873 |
+
|
| 874 |
+
Expected: No Python tracebacks; should see Gradio startup messages.
|
| 875 |
+
|
| 876 |
+
- [ ] **Step 4: Kill server**
|
| 877 |
+
|
| 878 |
+
```bash
|
| 879 |
+
pkill -f "python app.py"
|
| 880 |
+
```
|
| 881 |
+
|
| 882 |
+
---
|
| 883 |
+
|
| 884 |
+
## Self-Review Checklist
|
| 885 |
+
|
| 886 |
+
**Spec coverage:**
|
| 887 |
+
|
| 888 |
+
| Spec requirement | Task covering it |
|
| 889 |
+
|---|---|
|
| 890 |
+
| `previous_states` param in `run_town_simulation` | Task 1 |
|
| 891 |
+
| `timeline_state` gr.State (JSON list) | Task 6 Step 1 |
|
| 892 |
+
| `on_transmit` reads timeline, seeds states, appends entry, returns 6 values | Task 4 |
|
| 893 |
+
| `format_timeline_strip` helper | Task 3 |
|
| 894 |
+
| `format_day_badge` helper | Task 3 |
|
| 895 |
+
| RESET button + `on_reset_timeline` | Task 5 + Task 6 Step 3 + Task 7 Step 2 |
|
| 896 |
+
| `on_select_timeline_day` handler | Task 5 |
|
| 897 |
+
| `selected_day` + `select_day_trigger` hidden bridge components | Task 6 Step 2 |
|
| 898 |
+
| `selectTimelineDay` JS function | Task 8 |
|
| 899 |
+
| `select_day_trigger.click` wiring | Task 7 Step 3 |
|
| 900 |
+
| `timeline_strip` HTML component in layout | Task 6 Step 4 |
|
| 901 |
+
| `day_badge` HTML component in map panel | Task 6 Step 4 |
|
| 902 |
+
| CSS for all new elements | Task 2 |
|
| 903 |
+
| Backwards-compatibility (old callers unchanged) | Task 1 |
|
| 904 |
+
| Wave 1/2 not broken (format_town_map, voice_player untouched) | No changes to those functions |
|
| 905 |
+
|
| 906 |
+
**Type/signature consistency check:**
|
| 907 |
+
|
| 908 |
+
- `on_transmit` returns 6-tuple: `(sim_status_str, result_json_str, map_html_str, timeline_json_str, strip_html_str, badge_html_str)` — outputs in Task 7 Step 1 list exactly 6 components matching that order. ✓
|
| 909 |
+
- `on_reset_timeline` returns 4-tuple: `("", map_html, empty_strip, "")` — outputs in Task 7 Step 2 list exactly 4 components. ✓
|
| 910 |
+
- `on_select_timeline_day` returns 10-tuple — outputs in Task 7 Step 3 list exactly 10 components. ✓
|
| 911 |
+
- `format_timeline_strip(timeline_json, active_day=None)` called consistently throughout. ✓
|
| 912 |
+
- `previous_states` param name in Task 1 matches usage in Task 4. ✓
|
model_client.py
CHANGED
|
@@ -1,12 +1,40 @@
|
|
| 1 |
"""Model client for Analog Town using Hugging Face Inference API."""
|
| 2 |
|
|
|
|
| 3 |
import json
|
|
|
|
| 4 |
import os
|
| 5 |
import re
|
| 6 |
from dotenv import load_dotenv
|
| 7 |
from huggingface_hub import InferenceClient
|
| 8 |
from prompts import REPAIR_PROMPT
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
load_dotenv()
|
| 11 |
|
| 12 |
# Model fallback chain (ensuring all models are under 32B parameters for hackathon rules)
|
|
|
|
| 1 |
"""Model client for Analog Town using Hugging Face Inference API."""
|
| 2 |
|
| 3 |
+
import io
|
| 4 |
import json
|
| 5 |
+
import logging
|
| 6 |
import os
|
| 7 |
import re
|
| 8 |
from dotenv import load_dotenv
|
| 9 |
from huggingface_hub import InferenceClient
|
| 10 |
from prompts import REPAIR_PROMPT
|
| 11 |
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
TTS_MODELS = [
|
| 15 |
+
"microsoft/speecht5_tts",
|
| 16 |
+
"facebook/mms-tts-eng",
|
| 17 |
+
]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def synthesize_speech(text: str, token: str | None = None) -> bytes | None:
|
| 21 |
+
"""Call HF Inference TTS and return raw WAV bytes, or None on failure."""
|
| 22 |
+
_token = token or os.getenv("HF_TOKEN")
|
| 23 |
+
if not _token:
|
| 24 |
+
return None
|
| 25 |
+
client = InferenceClient(token=_token)
|
| 26 |
+
for model in TTS_MODELS:
|
| 27 |
+
try:
|
| 28 |
+
result = client.text_to_speech(text, model=model)
|
| 29 |
+
if hasattr(result, "read"):
|
| 30 |
+
return result.read()
|
| 31 |
+
if isinstance(result, (bytes, bytearray)):
|
| 32 |
+
return bytes(result)
|
| 33 |
+
except Exception as exc:
|
| 34 |
+
logger.warning("TTS model %s failed: %s", model, exc)
|
| 35 |
+
continue
|
| 36 |
+
return None
|
| 37 |
+
|
| 38 |
load_dotenv()
|
| 39 |
|
| 40 |
# Model fallback chain (ensuring all models are under 32B parameters for hackathon rules)
|
prompts.py
CHANGED
|
@@ -92,6 +92,68 @@ Required schema:
|
|
| 92 |
{schema}"""
|
| 93 |
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
PERSONA_GENERATION_PROMPT = """Create a fictional persona for the Analog Town simulator.
|
| 96 |
|
| 97 |
The persona should be a resident of a small town. Return valid JSON matching this structure:
|
|
|
|
| 92 |
{schema}"""
|
| 93 |
|
| 94 |
|
| 95 |
+
TOWN_GENERATION_PROMPT = """You are a world-builder for Analog Town, a shortwave social simulator.
|
| 96 |
+
|
| 97 |
+
Generate a complete fictional town with exactly {n_agents} residents based on the concept below.
|
| 98 |
+
|
| 99 |
+
Return STRICT JSON ONLY — no markdown, no code fences, no commentary. The JSON must exactly match the schema shown.
|
| 100 |
+
|
| 101 |
+
CONCEPT: {concept}
|
| 102 |
+
|
| 103 |
+
REQUIRED JSON STRUCTURE:
|
| 104 |
+
{{
|
| 105 |
+
"id": "<snake_case_slug_no_spaces>",
|
| 106 |
+
"name": "<Town Name>",
|
| 107 |
+
"description": "<1-2 sentence town description>",
|
| 108 |
+
"map_image": "town_map.png",
|
| 109 |
+
"agents": [
|
| 110 |
+
{{
|
| 111 |
+
"id": "<snake_case_agent_id>",
|
| 112 |
+
"name": "<Full Name>",
|
| 113 |
+
"frequency": <float between 87.0 and 108.0>,
|
| 114 |
+
"role": "<their role in this town>",
|
| 115 |
+
"age_range": "<XX-XX>",
|
| 116 |
+
"public_description": "<one sentence public description>",
|
| 117 |
+
"private_history": ["<sentence 1>", "<sentence 2>", "<sentence 3>"],
|
| 118 |
+
"core_values": ["<value1>", "<value2>", "<value3>"],
|
| 119 |
+
"fears": ["<fear1>", "<fear2>"],
|
| 120 |
+
"hopes": ["<hope1>", "<hope2>"],
|
| 121 |
+
"relationships": {{
|
| 122 |
+
"<other_agent_id>": "<one sentence describing this agent's view of the other>"
|
| 123 |
+
}},
|
| 124 |
+
"speaking_style": "<description of how they talk>",
|
| 125 |
+
"forbidden_assumptions": ["<what not to assume about this agent>"],
|
| 126 |
+
"map_pos": {{"x": <int 10-90>, "y": <int 10-90>}},
|
| 127 |
+
"avatar": "<one of: avatars/elder_man.png, avatars/elder_woman.png, avatars/middle_man.png, avatars/middle_woman.png, avatars/young_man.png, avatars/young_woman.png, avatars/soldier_man.png, avatars/default.png>"
|
| 128 |
+
}}
|
| 129 |
+
],
|
| 130 |
+
"default_event": {{
|
| 131 |
+
"title": "<short event title>",
|
| 132 |
+
"content": "<2-3 sentence description of the central conflict or event in this town>",
|
| 133 |
+
"source": "<source name, e.g. town radio, local newspaper>",
|
| 134 |
+
"location": "<location name>",
|
| 135 |
+
"affected_groups": ["<group1>", "<group2>"],
|
| 136 |
+
"tone": "<neutral, tense, hopeful, alarming, or somber>"
|
| 137 |
+
}}
|
| 138 |
+
}}
|
| 139 |
+
|
| 140 |
+
RULES:
|
| 141 |
+
- Generate exactly {n_agents} agents.
|
| 142 |
+
- Each agent id must be unique snake_case (e.g., "old_miller", "young_petra").
|
| 143 |
+
- Frequencies must be spread across 87.0-108.0 with at least 2.0 MHz between adjacent stations.
|
| 144 |
+
- Each agent's relationships dict must reference at least 2 other agents in this town by their id.
|
| 145 |
+
- private_history must have 2-3 sentences.
|
| 146 |
+
- core_values must have exactly 3 items.
|
| 147 |
+
- fears must have exactly 2 items.
|
| 148 |
+
- hopes must have exactly 2 items.
|
| 149 |
+
- forbidden_assumptions must have 1-2 items.
|
| 150 |
+
- map_pos x and y must be integers between 10 and 90, spread so agents don't overlap.
|
| 151 |
+
- avatar must be one of the listed paths exactly.
|
| 152 |
+
- The town id must be a snake_case slug with no spaces or special characters.
|
| 153 |
+
- Do not repeat the concept verbatim — build a coherent fictional world around it.
|
| 154 |
+
- Return valid JSON only. No markdown. No explanation."""
|
| 155 |
+
|
| 156 |
+
|
| 157 |
PERSONA_GENERATION_PROMPT = """Create a fictional persona for the Analog Town simulator.
|
| 158 |
|
| 159 |
The persona should be a resident of a small town. Return valid JSON matching this structure:
|
sample_towns/custom_happy_sanctuary_town.json
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"id": "custom_happy_sanctuary_town",
|
| 3 |
+
"name": "Harmony Grove",
|
| 4 |
+
"description": "Harmony Grove is a small, close-knit community that values peace and unity above all else.",
|
| 5 |
+
"map_image": "custom_happy_sanctuary_town_map.png",
|
| 6 |
+
"agents": [
|
| 7 |
+
{
|
| 8 |
+
"id": "elder_miller",
|
| 9 |
+
"name": "Elder Miller",
|
| 10 |
+
"frequency": 87.0,
|
| 11 |
+
"role": "Community Elder",
|
| 12 |
+
"age_range": "65-75",
|
| 13 |
+
"public_description": "Elder Miller is a respected member of the community, known for his wisdom and kindness.",
|
| 14 |
+
"private_history": [
|
| 15 |
+
"He has lived in Harmony Grove for over 50 years and has seen the town grow and change.",
|
| 16 |
+
"He lost his wife to illness last year and is now more focused on helping others.",
|
| 17 |
+
"He enjoys telling stories about the old days to the younger generations."
|
| 18 |
+
],
|
| 19 |
+
"core_values": [
|
| 20 |
+
"Peace",
|
| 21 |
+
"Unity",
|
| 22 |
+
"Community"
|
| 23 |
+
],
|
| 24 |
+
"fears": [
|
| 25 |
+
"Conflict",
|
| 26 |
+
"Isolation"
|
| 27 |
+
],
|
| 28 |
+
"hopes": [
|
| 29 |
+
"Prosperity",
|
| 30 |
+
"Harmony"
|
| 31 |
+
],
|
| 32 |
+
"relationships": {
|
| 33 |
+
"young_petra": "Elder Miller looks up to Petra for her energy and enthusiasm.",
|
| 34 |
+
"middle_woman": "He values the middle woman's practical advice and support."
|
| 35 |
+
},
|
| 36 |
+
"speaking_style": "Elderly and wise, with a soft, soothing voice.",
|
| 37 |
+
"forbidden_assumptions": [
|
| 38 |
+
"He is not interested in modern technology or changes."
|
| 39 |
+
],
|
| 40 |
+
"map_pos": {
|
| 41 |
+
"x": 20,
|
| 42 |
+
"y": 70
|
| 43 |
+
},
|
| 44 |
+
"avatar": "avatars/elder_man.png"
|
| 45 |
+
},
|
| 46 |
+
{
|
| 47 |
+
"id": "young_petra",
|
| 48 |
+
"name": "Young Petra",
|
| 49 |
+
"frequency": 91.2,
|
| 50 |
+
"role": "Youth Leader",
|
| 51 |
+
"age_range": "18-25",
|
| 52 |
+
"public_description": "Petra is a vibrant and energetic leader of the youth group in Harmony Grove.",
|
| 53 |
+
"private_history": [
|
| 54 |
+
"She moved to Harmony Grove from the city a year ago and has quickly become a beloved figure.",
|
| 55 |
+
"She dreams of starting her own business and making Harmony Grove a hub for innovation.",
|
| 56 |
+
"She is working on a community garden project to bring people together."
|
| 57 |
+
],
|
| 58 |
+
"core_values": [
|
| 59 |
+
"Innovation",
|
| 60 |
+
"Community",
|
| 61 |
+
"Progress"
|
| 62 |
+
],
|
| 63 |
+
"fears": [
|
| 64 |
+
"Stagnation",
|
| 65 |
+
"Discord"
|
| 66 |
+
],
|
| 67 |
+
"hopes": [
|
| 68 |
+
"Prosperity",
|
| 69 |
+
"Unity"
|
| 70 |
+
],
|
| 71 |
+
"relationships": {
|
| 72 |
+
"elder_miller": "Petra respects Elder Miller's wisdom and seeks his guidance.",
|
| 73 |
+
"middle_woman": "She admires the middle woman's practicality and wants to learn from her."
|
| 74 |
+
},
|
| 75 |
+
"speaking_style": "Energetic and enthusiastic, with a clear and confident voice.",
|
| 76 |
+
"forbidden_assumptions": [
|
| 77 |
+
"She is not interested in traditional values or community norms."
|
| 78 |
+
],
|
| 79 |
+
"map_pos": {
|
| 80 |
+
"x": 50,
|
| 81 |
+
"y": 40
|
| 82 |
+
},
|
| 83 |
+
"avatar": "avatars/young_woman.png"
|
| 84 |
+
},
|
| 85 |
+
{
|
| 86 |
+
"id": "middle_woman",
|
| 87 |
+
"name": "Middle Woman",
|
| 88 |
+
"frequency": 95.4,
|
| 89 |
+
"role": "Community Organizer",
|
| 90 |
+
"age_range": "45-55",
|
| 91 |
+
"public_description": "Middle Woman is a dedicated organizer who ensures that community events run smoothly.",
|
| 92 |
+
"private_history": [
|
| 93 |
+
"She has been organizing events in Harmony Grove for over 20 years.",
|
| 94 |
+
"She lost her husband to a tragic accident last year and is now more focused on helping others.",
|
| 95 |
+
"She enjoys planning gatherings that bring people together."
|
| 96 |
+
],
|
| 97 |
+
"core_values": [
|
| 98 |
+
"Community",
|
| 99 |
+
"Unity",
|
| 100 |
+
"Support"
|
| 101 |
+
],
|
| 102 |
+
"fears": [
|
| 103 |
+
"Isolation",
|
| 104 |
+
"Conflict"
|
| 105 |
+
],
|
| 106 |
+
"hopes": [
|
| 107 |
+
"Prosperity",
|
| 108 |
+
"Harmony"
|
| 109 |
+
],
|
| 110 |
+
"relationships": {
|
| 111 |
+
"elder_miller": "Middle Woman values Elder Miller's wisdom and guidance.",
|
| 112 |
+
"young_petra": "She sees Petra as a source of energy and enthusiasm."
|
| 113 |
+
},
|
| 114 |
+
"speaking_style": "Warm and reassuring, with a gentle and comforting tone.",
|
| 115 |
+
"forbidden_assumptions": [
|
| 116 |
+
"She is not interested in new ideas or changes."
|
| 117 |
+
],
|
| 118 |
+
"map_pos": {
|
| 119 |
+
"x": 80,
|
| 120 |
+
"y": 60
|
| 121 |
+
},
|
| 122 |
+
"avatar": "avatars/middle_woman.png"
|
| 123 |
+
},
|
| 124 |
+
{
|
| 125 |
+
"id": "soldier_man",
|
| 126 |
+
"name": "Soldier Man",
|
| 127 |
+
"frequency": 99.6,
|
| 128 |
+
"role": "Veteran",
|
| 129 |
+
"age_range": "55-65",
|
| 130 |
+
"public_description": "Soldier Man is a retired veteran who now volunteers at the local community center.",
|
| 131 |
+
"private_history": [
|
| 132 |
+
"He served in the military for 20 years and has seen his fair share of conflict.",
|
| 133 |
+
"He is grateful for the peace he now enjoys in Harmony Grove.",
|
| 134 |
+
"He enjoys sharing his experiences with the younger generation."
|
| 135 |
+
],
|
| 136 |
+
"core_values": [
|
| 137 |
+
"Patriotism",
|
| 138 |
+
"Community",
|
| 139 |
+
"Service"
|
| 140 |
+
],
|
| 141 |
+
"fears": [
|
| 142 |
+
"War",
|
| 143 |
+
"Conflict"
|
| 144 |
+
],
|
| 145 |
+
"hopes": [
|
| 146 |
+
"Peace",
|
| 147 |
+
"Harmony"
|
| 148 |
+
],
|
| 149 |
+
"relationships": {
|
| 150 |
+
"elder_miller": "Soldier Man respects Elder Miller's wisdom and seeks his guidance.",
|
| 151 |
+
"young_petra": "He admires Petra's energy and enthusiasm."
|
| 152 |
+
},
|
| 153 |
+
"speaking_style": "Strong and resolute, with a deep and commanding voice.",
|
| 154 |
+
"forbidden_assumptions": [
|
| 155 |
+
"He is not interested in peace or community."
|
| 156 |
+
],
|
| 157 |
+
"map_pos": {
|
| 158 |
+
"x": 30,
|
| 159 |
+
"y": 30
|
| 160 |
+
},
|
| 161 |
+
"avatar": "avatars/soldier_man.png"
|
| 162 |
+
},
|
| 163 |
+
{
|
| 164 |
+
"id": "middle_man",
|
| 165 |
+
"name": "Middle Man",
|
| 166 |
+
"frequency": 103.8,
|
| 167 |
+
"role": "Local Business Owner",
|
| 168 |
+
"age_range": "45-55",
|
| 169 |
+
"public_description": "Middle Man owns the general store in Harmony Grove and is a beloved figure in the community.",
|
| 170 |
+
"private_history": [
|
| 171 |
+
"He has owned the store for 15 years and has seen the town grow and change.",
|
| 172 |
+
"He is proud of his community and wants to ensure its prosperity.",
|
| 173 |
+
"He enjoys helping his customers and making their lives easier."
|
| 174 |
+
],
|
| 175 |
+
"core_values": [
|
| 176 |
+
"Community",
|
| 177 |
+
"Prosperity",
|
| 178 |
+
"Support"
|
| 179 |
+
],
|
| 180 |
+
"fears": [
|
| 181 |
+
"Economic downturn",
|
| 182 |
+
"Conflict"
|
| 183 |
+
],
|
| 184 |
+
"hopes": [
|
| 185 |
+
"Prosperity",
|
| 186 |
+
"Harmony"
|
| 187 |
+
],
|
| 188 |
+
"relationships": {
|
| 189 |
+
"elder_miller": "Middle Man values Elder Miller's wisdom and guidance.",
|
| 190 |
+
"young_petra": "He sees Petra as a source of energy and enthusiasm."
|
| 191 |
+
},
|
| 192 |
+
"speaking_style": "Warm and friendly, with a reassuring and comforting tone.",
|
| 193 |
+
"forbidden_assumptions": [
|
| 194 |
+
"He is not interested in new ideas or changes."
|
| 195 |
+
],
|
| 196 |
+
"map_pos": {
|
| 197 |
+
"x": 60,
|
| 198 |
+
"y": 80
|
| 199 |
+
},
|
| 200 |
+
"avatar": "avatars/middle_man.png"
|
| 201 |
+
},
|
| 202 |
+
{
|
| 203 |
+
"id": "young_man",
|
| 204 |
+
"name": "Young Man",
|
| 205 |
+
"frequency": 108.0,
|
| 206 |
+
"role": "Tech Innovator",
|
| 207 |
+
"age_range": "25-35",
|
| 208 |
+
"public_description": "Young Man is a tech innovator who is working on a project to improve communication in Harmony Grove.",
|
| 209 |
+
"private_history": [
|
| 210 |
+
"He moved to Harmony Grove a year ago to pursue his passion for technology.",
|
| 211 |
+
"He dreams of creating a platform that connects people and improves their lives.",
|
| 212 |
+
"He is working on a community garden project to bring people together."
|
| 213 |
+
],
|
| 214 |
+
"core_values": [
|
| 215 |
+
"Innovation",
|
| 216 |
+
"Community",
|
| 217 |
+
"Progress"
|
| 218 |
+
],
|
| 219 |
+
"fears": [
|
| 220 |
+
"Stagnation",
|
| 221 |
+
"Discord"
|
| 222 |
+
],
|
| 223 |
+
"hopes": [
|
| 224 |
+
"Prosperity",
|
| 225 |
+
"Unity"
|
| 226 |
+
],
|
| 227 |
+
"relationships": {
|
| 228 |
+
"elder_miller": "Young Man respects Elder Miller's wisdom and seeks his guidance.",
|
| 229 |
+
"middle_woman": "He admires the middle woman's practicality and wants to learn from her."
|
| 230 |
+
},
|
| 231 |
+
"speaking_style": "Energetic and enthusiastic, with a clear and confident voice.",
|
| 232 |
+
"forbidden_assumptions": [
|
| 233 |
+
"He is not interested in traditional values or community norms."
|
| 234 |
+
],
|
| 235 |
+
"map_pos": {
|
| 236 |
+
"x": 90,
|
| 237 |
+
"y": 50
|
| 238 |
+
},
|
| 239 |
+
"avatar": "avatars/young_man.png"
|
| 240 |
+
}
|
| 241 |
+
],
|
| 242 |
+
"default_event": {
|
| 243 |
+
"title": "Community Garden Opening",
|
| 244 |
+
"content": "The community garden project, led by Young Petra and supported by Middle Woman, is finally opening to the public. Elder Miller and Soldier Man will be there to celebrate.",
|
| 245 |
+
"source": "Harmony Grove Community Bulletin",
|
| 246 |
+
"location": "Harmony Grove Community Garden",
|
| 247 |
+
"affected_groups": [
|
| 248 |
+
"Youth",
|
| 249 |
+
"Community Organizers",
|
| 250 |
+
"Veterans",
|
| 251 |
+
"Business Owners"
|
| 252 |
+
],
|
| 253 |
+
"tone": "Hopeful"
|
| 254 |
+
}
|
| 255 |
+
}
|
simulator.py
CHANGED
|
@@ -115,6 +115,7 @@ class Simulator:
|
|
| 115 |
agent: AgentProfile,
|
| 116 |
state: AgentState,
|
| 117 |
event: BroadcastEvent,
|
|
|
|
| 118 |
) -> StateTransition:
|
| 119 |
"""Run a single agent through the state transition.
|
| 120 |
|
|
@@ -122,6 +123,7 @@ class Simulator:
|
|
| 122 |
agent: The agent's profile
|
| 123 |
state: The agent's current state
|
| 124 |
event: The broadcast event
|
|
|
|
| 125 |
|
| 126 |
Returns:
|
| 127 |
StateTransition with updated state and monologue
|
|
@@ -129,18 +131,34 @@ class Simulator:
|
|
| 129 |
Raises:
|
| 130 |
RuntimeError: If both generation and repair fail
|
| 131 |
"""
|
| 132 |
-
# Build the prompt
|
| 133 |
user_prompt = STATE_TRANSITION_PROMPT.format(
|
| 134 |
agent_profile=agent.model_dump_json(indent=2),
|
| 135 |
previous_state=state.model_dump_json(indent=2),
|
| 136 |
broadcast_event=event.model_dump_json(indent=2),
|
| 137 |
)
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
# Validate and fix
|
| 146 |
is_valid, error_msg = self._validate_transition(transition_data, agent)
|
|
@@ -155,15 +173,16 @@ class Simulator:
|
|
| 155 |
self,
|
| 156 |
town: Town,
|
| 157 |
event: BroadcastEvent,
|
| 158 |
-
|
| 159 |
progress_callback=None,
|
|
|
|
| 160 |
) -> SimulationResult:
|
| 161 |
"""Run simulation for all agents in the town.
|
| 162 |
|
| 163 |
Args:
|
| 164 |
town: The town with agents
|
| 165 |
event: The broadcast event
|
| 166 |
-
|
| 167 |
progress_callback: Optional callback(agent_name, status, index, total)
|
| 168 |
|
| 169 |
Returns:
|
|
@@ -178,15 +197,13 @@ class Simulator:
|
|
| 178 |
if progress_callback:
|
| 179 |
progress_callback(agent_name, "processing", i, total)
|
| 180 |
|
| 181 |
-
# Get or create initial state
|
| 182 |
state = (
|
| 183 |
-
|
| 184 |
-
if
|
| 185 |
else self._get_initial_state(agent)
|
| 186 |
)
|
| 187 |
|
| 188 |
-
|
| 189 |
-
transition = self.run_agent_transition(agent, state, event)
|
| 190 |
transitions.append(transition)
|
| 191 |
|
| 192 |
if progress_callback:
|
|
|
|
| 115 |
agent: AgentProfile,
|
| 116 |
state: AgentState,
|
| 117 |
event: BroadcastEvent,
|
| 118 |
+
day: int = 1,
|
| 119 |
) -> StateTransition:
|
| 120 |
"""Run a single agent through the state transition.
|
| 121 |
|
|
|
|
| 123 |
agent: The agent's profile
|
| 124 |
state: The agent's current state
|
| 125 |
event: The broadcast event
|
| 126 |
+
day: 1-indexed broadcast day, used to instruct the model to vary follow-up beats
|
| 127 |
|
| 128 |
Returns:
|
| 129 |
StateTransition with updated state and monologue
|
|
|
|
| 131 |
Raises:
|
| 132 |
RuntimeError: If both generation and repair fail
|
| 133 |
"""
|
|
|
|
| 134 |
user_prompt = STATE_TRANSITION_PROMPT.format(
|
| 135 |
agent_profile=agent.model_dump_json(indent=2),
|
| 136 |
previous_state=state.model_dump_json(indent=2),
|
| 137 |
broadcast_event=event.model_dump_json(indent=2),
|
| 138 |
)
|
| 139 |
|
| 140 |
+
if day > 1:
|
| 141 |
+
user_prompt = (
|
| 142 |
+
f"DAY {day} OF THIS SCENARIO. "
|
| 143 |
+
f"The agent has already lived through {day - 1} previous broadcast(s). "
|
| 144 |
+
"The fields current_belief, active_memory, and unresolved_tension in 'Previous State' "
|
| 145 |
+
"capture where the agent ENDED LAST TIME — they are CONTEXT, not your script. "
|
| 146 |
+
"Write a FRESH internal_monologue that is clearly different in wording from any prior beat: "
|
| 147 |
+
"the agent has had time to process, talk to others, sleep on it, or harden their view. "
|
| 148 |
+
"Make at least one emotion_delta non-zero. Do NOT echo any sentence verbatim from current_belief.\n\n"
|
| 149 |
+
+ user_prompt
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
original_temp = getattr(self.model_client, "temperature", 0.3)
|
| 153 |
+
try:
|
| 154 |
+
if day > 1:
|
| 155 |
+
self.model_client.temperature = min(0.85, original_temp + 0.25)
|
| 156 |
+
transition_data = self.model_client.generate_json(
|
| 157 |
+
system_prompt=SYSTEM_PROMPT,
|
| 158 |
+
user_prompt=user_prompt,
|
| 159 |
+
)
|
| 160 |
+
finally:
|
| 161 |
+
self.model_client.temperature = original_temp
|
| 162 |
|
| 163 |
# Validate and fix
|
| 164 |
is_valid, error_msg = self._validate_transition(transition_data, agent)
|
|
|
|
| 173 |
self,
|
| 174 |
town: Town,
|
| 175 |
event: BroadcastEvent,
|
| 176 |
+
previous_states: dict[str, AgentState] | None = None,
|
| 177 |
progress_callback=None,
|
| 178 |
+
day: int = 1,
|
| 179 |
) -> SimulationResult:
|
| 180 |
"""Run simulation for all agents in the town.
|
| 181 |
|
| 182 |
Args:
|
| 183 |
town: The town with agents
|
| 184 |
event: The broadcast event
|
| 185 |
+
previous_states: Optional dict of agent_id -> AgentState seeded from prior run
|
| 186 |
progress_callback: Optional callback(agent_name, status, index, total)
|
| 187 |
|
| 188 |
Returns:
|
|
|
|
| 197 |
if progress_callback:
|
| 198 |
progress_callback(agent_name, "processing", i, total)
|
| 199 |
|
|
|
|
| 200 |
state = (
|
| 201 |
+
previous_states.get(agent.id, self._get_initial_state(agent))
|
| 202 |
+
if previous_states
|
| 203 |
else self._get_initial_state(agent)
|
| 204 |
)
|
| 205 |
|
| 206 |
+
transition = self.run_agent_transition(agent, state, event, day=day)
|
|
|
|
| 207 |
transitions.append(transition)
|
| 208 |
|
| 209 |
if progress_callback:
|
theme.py
CHANGED
|
@@ -550,6 +550,38 @@ input[type="range"]::-webkit-slider-thumb:hover {
|
|
| 550 |
image-rendering: pixelated;
|
| 551 |
image-rendering: crisp-edges;
|
| 552 |
animation: pulse-radar 2s infinite;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
}
|
| 554 |
|
| 555 |
.agent-sprite.selected-sprite .sprite-avatar {
|
|
@@ -831,15 +863,22 @@ input[type="range"]::-webkit-slider-thumb:hover {
|
|
| 831 |
@media (min-width: 1100px) {
|
| 832 |
#map-panel,
|
| 833 |
#receiver-panel {
|
| 834 |
-
min-height:
|
| 835 |
-
max-height: calc(100vh - 200px);
|
| 836 |
}
|
| 837 |
-
/*
|
| 838 |
#town-map,
|
| 839 |
#town-map .town-map-wrapper,
|
| 840 |
#town-map .town-map-bg {
|
| 841 |
-
max-height:
|
| 842 |
-
min-height:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 843 |
}
|
| 844 |
}
|
| 845 |
|
|
@@ -858,4 +897,153 @@ input[type="range"]::-webkit-slider-thumb:hover {
|
|
| 858 |
max-height: 320px !important;
|
| 859 |
}
|
| 860 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 861 |
"""
|
|
|
|
| 550 |
image-rendering: pixelated;
|
| 551 |
image-rendering: crisp-edges;
|
| 552 |
animation: pulse-radar 2s infinite;
|
| 553 |
+
transition: border-color 0.4s ease, box-shadow 0.4s ease, background-color 0.4s ease;
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
.anomaly-flag {
|
| 557 |
+
position: absolute;
|
| 558 |
+
top: -10px;
|
| 559 |
+
right: -10px;
|
| 560 |
+
width: 16px;
|
| 561 |
+
height: 16px;
|
| 562 |
+
background: #0a0e14;
|
| 563 |
+
border: 1.5px solid #ffb347;
|
| 564 |
+
border-radius: 50%;
|
| 565 |
+
display: flex;
|
| 566 |
+
align-items: center;
|
| 567 |
+
justify-content: center;
|
| 568 |
+
font-size: 9px;
|
| 569 |
+
line-height: 1;
|
| 570 |
+
z-index: 15;
|
| 571 |
+
box-shadow: 0 0 8px rgba(255, 179, 71, 0.7);
|
| 572 |
+
animation: anomaly-pulse 1.4s ease-in-out infinite;
|
| 573 |
+
pointer-events: none;
|
| 574 |
+
}
|
| 575 |
+
|
| 576 |
+
@keyframes anomaly-pulse {
|
| 577 |
+
0%, 100% {
|
| 578 |
+
transform: scale(1);
|
| 579 |
+
box-shadow: 0 0 6px rgba(255, 179, 71, 0.5);
|
| 580 |
+
}
|
| 581 |
+
50% {
|
| 582 |
+
transform: scale(1.18);
|
| 583 |
+
box-shadow: 0 0 14px rgba(255, 179, 71, 0.9);
|
| 584 |
+
}
|
| 585 |
}
|
| 586 |
|
| 587 |
.agent-sprite.selected-sprite .sprite-avatar {
|
|
|
|
| 863 |
@media (min-width: 1100px) {
|
| 864 |
#map-panel,
|
| 865 |
#receiver-panel {
|
| 866 |
+
min-height: 620px;
|
|
|
|
| 867 |
}
|
| 868 |
+
/* Cap the map so the dossier always shows below it */
|
| 869 |
#town-map,
|
| 870 |
#town-map .town-map-wrapper,
|
| 871 |
#town-map .town-map-bg {
|
| 872 |
+
max-height: 360px !important;
|
| 873 |
+
min-height: 260px !important;
|
| 874 |
+
}
|
| 875 |
+
/* Dossier always visible with a guaranteed strip below the map */
|
| 876 |
+
#dossier-accordion {
|
| 877 |
+
min-height: 220px !important;
|
| 878 |
+
}
|
| 879 |
+
#agent-profile {
|
| 880 |
+
max-height: 220px !important;
|
| 881 |
+
min-height: 160px !important;
|
| 882 |
}
|
| 883 |
}
|
| 884 |
|
|
|
|
| 897 |
max-height: 320px !important;
|
| 898 |
}
|
| 899 |
}
|
| 900 |
+
|
| 901 |
+
/* ===== WAVE 3: TIMELINE STRIP ===== */
|
| 902 |
+
#timeline-strip {
|
| 903 |
+
width: 100%;
|
| 904 |
+
display: flex;
|
| 905 |
+
flex-direction: row;
|
| 906 |
+
flex-wrap: nowrap;
|
| 907 |
+
overflow-x: auto;
|
| 908 |
+
gap: 8px;
|
| 909 |
+
padding: 8px 12px;
|
| 910 |
+
background: linear-gradient(180deg, #0d1219 0%, #111820 100%);
|
| 911 |
+
border: 1px solid #2a3040;
|
| 912 |
+
border-radius: 10px;
|
| 913 |
+
margin-bottom: 10px;
|
| 914 |
+
box-sizing: border-box;
|
| 915 |
+
scrollbar-width: thin;
|
| 916 |
+
scrollbar-color: #2a3040 #0a0e14;
|
| 917 |
+
min-height: 54px;
|
| 918 |
+
align-items: center;
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
.timeline-empty {
|
| 922 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 923 |
+
font-size: 11px;
|
| 924 |
+
color: #5a5248;
|
| 925 |
+
letter-spacing: 1px;
|
| 926 |
+
padding: 6px 0;
|
| 927 |
+
flex: 1;
|
| 928 |
+
text-align: center;
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
.timeline-pill {
|
| 932 |
+
position: relative;
|
| 933 |
+
display: flex;
|
| 934 |
+
flex-direction: column;
|
| 935 |
+
align-items: flex-start;
|
| 936 |
+
gap: 2px;
|
| 937 |
+
background: #161d27;
|
| 938 |
+
border: 1px solid #2a3040;
|
| 939 |
+
border-radius: 8px;
|
| 940 |
+
padding: 5px 10px 5px 8px;
|
| 941 |
+
cursor: pointer;
|
| 942 |
+
min-width: 100px;
|
| 943 |
+
max-width: 160px;
|
| 944 |
+
flex-shrink: 0;
|
| 945 |
+
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
| 946 |
+
user-select: none;
|
| 947 |
+
}
|
| 948 |
+
|
| 949 |
+
.timeline-pill:hover {
|
| 950 |
+
border-color: #ffb34766;
|
| 951 |
+
box-shadow: 0 0 10px rgba(255, 179, 71, 0.12);
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
.timeline-pill.active {
|
| 955 |
+
background: rgba(255, 179, 71, 0.07);
|
| 956 |
+
border-color: #ffb347;
|
| 957 |
+
box-shadow: 0 0 14px rgba(255, 179, 71, 0.25);
|
| 958 |
+
}
|
| 959 |
+
|
| 960 |
+
.pill-day {
|
| 961 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 962 |
+
font-size: 9px;
|
| 963 |
+
font-weight: 700;
|
| 964 |
+
color: #ffb347;
|
| 965 |
+
letter-spacing: 2px;
|
| 966 |
+
text-transform: uppercase;
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
.timeline-pill:not(.active) .pill-day {
|
| 970 |
+
color: #8a8070;
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
.pill-title {
|
| 974 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 975 |
+
font-size: 10px;
|
| 976 |
+
color: #e0d6c8;
|
| 977 |
+
white-space: nowrap;
|
| 978 |
+
overflow: hidden;
|
| 979 |
+
text-overflow: ellipsis;
|
| 980 |
+
max-width: 140px;
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
.timeline-pill:not(.active) .pill-title {
|
| 984 |
+
color: #5a5248;
|
| 985 |
+
}
|
| 986 |
+
|
| 987 |
+
.pill-anomaly {
|
| 988 |
+
position: absolute;
|
| 989 |
+
top: 3px;
|
| 990 |
+
right: 4px;
|
| 991 |
+
font-size: 8px;
|
| 992 |
+
color: #ffb347;
|
| 993 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 994 |
+
font-weight: 700;
|
| 995 |
+
line-height: 1;
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
.day-badge {
|
| 999 |
+
position: absolute;
|
| 1000 |
+
top: 10px;
|
| 1001 |
+
right: 12px;
|
| 1002 |
+
background: #0a0e14;
|
| 1003 |
+
border: 1px solid #ffb347;
|
| 1004 |
+
border-radius: 6px;
|
| 1005 |
+
padding: 3px 10px;
|
| 1006 |
+
font-family: 'IBM Plex Mono', monospace;
|
| 1007 |
+
font-size: 11px;
|
| 1008 |
+
font-weight: 700;
|
| 1009 |
+
color: #ffb347;
|
| 1010 |
+
letter-spacing: 3px;
|
| 1011 |
+
text-transform: uppercase;
|
| 1012 |
+
text-shadow: 0 0 10px rgba(255, 179, 71, 0.4);
|
| 1013 |
+
box-shadow: 0 0 12px rgba(255, 179, 71, 0.15);
|
| 1014 |
+
z-index: 20;
|
| 1015 |
+
pointer-events: none;
|
| 1016 |
+
}
|
| 1017 |
+
|
| 1018 |
+
/* ===== CREATIVE MODE ===== */
|
| 1019 |
+
#creative-mode {
|
| 1020 |
+
background: linear-gradient(180deg, #111820 0%, #0d1219 100%) !important;
|
| 1021 |
+
border: 1px solid rgba(255, 179, 71, 0.35) !important;
|
| 1022 |
+
border-radius: 10px !important;
|
| 1023 |
+
margin-bottom: 10px !important;
|
| 1024 |
+
box-shadow: 0 0 18px rgba(255, 179, 71, 0.06) !important;
|
| 1025 |
+
}
|
| 1026 |
+
|
| 1027 |
+
#creative-mode > .label-wrap {
|
| 1028 |
+
font-family: 'IBM Plex Mono', monospace !important;
|
| 1029 |
+
font-size: 11px !important;
|
| 1030 |
+
color: #ffb347 !important;
|
| 1031 |
+
letter-spacing: 2px !important;
|
| 1032 |
+
text-transform: uppercase !important;
|
| 1033 |
+
}
|
| 1034 |
+
|
| 1035 |
+
#creative-json {
|
| 1036 |
+
font-family: 'IBM Plex Mono', monospace !important;
|
| 1037 |
+
font-size: 11px !important;
|
| 1038 |
+
background: #0a0e14 !important;
|
| 1039 |
+
border: 1px solid #2a3040 !important;
|
| 1040 |
+
border-radius: 6px !important;
|
| 1041 |
+
}
|
| 1042 |
+
|
| 1043 |
+
#creative-status {
|
| 1044 |
+
font-family: 'IBM Plex Mono', monospace !important;
|
| 1045 |
+
font-size: 11px !important;
|
| 1046 |
+
color: #8a8070 !important;
|
| 1047 |
+
margin-top: 4px !important;
|
| 1048 |
+
}
|
| 1049 |
"""
|
town_generator.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Town generator: produces a fully-validated Town dict from a concept string."""
|
| 2 |
+
|
| 3 |
+
from prompts import SYSTEM_PROMPT, TOWN_GENERATION_PROMPT
|
| 4 |
+
from schemas import Town
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def generate_town(concept: str, n_agents: int, model_client=None) -> dict:
|
| 8 |
+
"""Generate a town JSON from a concept string. Returns a Pydantic-validated dict.
|
| 9 |
+
Post-processes to enforce frequency spread, clamp map_pos, and namespace the id."""
|
| 10 |
+
if model_client is None:
|
| 11 |
+
from model_client import ModelClient
|
| 12 |
+
model_client = ModelClient(max_tokens=4000)
|
| 13 |
+
|
| 14 |
+
user_prompt = TOWN_GENERATION_PROMPT.format(concept=concept, n_agents=n_agents)
|
| 15 |
+
town_dict = model_client.generate_json(SYSTEM_PROMPT, user_prompt)
|
| 16 |
+
|
| 17 |
+
town = Town(**town_dict)
|
| 18 |
+
|
| 19 |
+
if len(town.agents) < n_agents:
|
| 20 |
+
raise RuntimeError(
|
| 21 |
+
f"Model returned {len(town.agents)} agents but {n_agents} were requested."
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
sorted_agents = sorted(town.agents, key=lambda a: a.frequency)
|
| 25 |
+
if n_agents > 1:
|
| 26 |
+
step = (108.0 - 87.0) / (n_agents - 1)
|
| 27 |
+
else:
|
| 28 |
+
step = 0.0
|
| 29 |
+
for i, agent in enumerate(sorted_agents):
|
| 30 |
+
agent.frequency = round(87.0 + i * step, 1)
|
| 31 |
+
|
| 32 |
+
for agent in town.agents:
|
| 33 |
+
pos = agent.map_pos or {"x": 50, "y": 50}
|
| 34 |
+
pos["x"] = max(10, min(90, int(pos.get("x", 50))))
|
| 35 |
+
pos["y"] = max(10, min(90, int(pos.get("y", 50))))
|
| 36 |
+
agent.map_pos = pos
|
| 37 |
+
|
| 38 |
+
if not town.id.startswith("custom_"):
|
| 39 |
+
town.id = f"custom_{town.id}"
|
| 40 |
+
|
| 41 |
+
if not town.map_image:
|
| 42 |
+
town.map_image = "town_map.png"
|
| 43 |
+
|
| 44 |
+
town = Town(**town.model_dump())
|
| 45 |
+
return town.model_dump()
|