thangvip commited on
Commit
ed19734
·
verified ·
1 Parent(s): 3f24f4e

Add Creative Mode + browser TTS + multi-day timeline + emotion heatmap + 3 new towns

Browse files
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 · ~16 MB Space
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 intercept their inner monologues one frequency at a time. That is **Analog Town**: a tabletop-style perspective-rehearsal tool dressed up as a 1970s shortwave receiver.
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 two 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
 
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 second pass through a dedicated `REPAIR_PROMPT` rewrites it cleanly.
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 (`theme.py`) 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".
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 and the corresponding character's dossier and monologue come into focus.
64
 
65
- **The static.** We loop a 15-second analog hiss and low-hum bed (`static_ambient.wav`) underneath everything. A small client-side JavaScript audio manager watches the signal-strength value and **smoothly fades the static out** when you tune into a station and **fades it back in** when you scan between them — all in the browser, no server roundtrip.
66
 
67
- **The map.** Six town presets each ship with a custom 2D isometric pixel-art background:
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 the map as circular pixel-art tokens drawn from a small avatar set (`elder_man.png`, `young_woman.png`, `soldier_man.png`, …) chosen to match the persona's age and role. Clicking a token tunes the receiver to that character's frequency, swaps in their dossier, and starts streaming their latest intercepted thought.
77
 
78
  ---
79
 
80
- ## A Walkthrough: One Broadcast, Six Reactions
81
 
82
- You load **Graybridge** — six residents, one foggy map. You type into the broadcast box:
83
 
84
- > *"The old train station will be converted into a luxury hotel."*
85
 
86
- You hit **Broadcast**. The simulator iterates through every agent, calls the model once per character with their profile and current emotional state, and parses the structured JSON back into the UI. You hear the static swell as the dial idles at 91.3 MHz.
87
 
88
- You scroll the dial up to **98.7 MHz** — *Margaret, the retired stationmaster*. The static fades. Her dossier loads: 67 years old, grew up watching the trains, lost her husband the year the line was shut down. Her monologue scrolls onto the CRT panel:
89
 
90
- > *"They're putting a bar where the ticket window used to be. I knew this was coming. I just didn't think they'd bother pretending it was good news."*
91
 
92
- `activated_memory`: *the night the last train left in 1998*.
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
- You scan up to **102.1 MHz** *Devon, a 24-year-old line cook*. Same broadcast, completely different read:
98
 
99
- > *"Wait, like, a real hotel? Could I get a job there? Could I stop driving 40 minutes for a kitchen shift?"*
100
 
101
- Same event. Same town. Two minds. The dial keeps going.
 
 
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. Both are comfortably under the 32B parameter cap. The fallback chain lives in `model_client.py` and is transparent to the rest of the app.
110
-
111
- 2. **Structured generation beats brute force.** Because every model call is shaped by a Pydantic schema and a strict system prompt, we can run a full town reaction (6 characters) on a small model without losing coherence. We deliberately keep `max_tokens=900` and `temperature=0.3`.
112
-
113
- 3. **Client-side audio.** The radio static fade is computed in the browser from the signal-strength value. Zero server-side audio processing, zero extra latency.
114
-
115
- 4. **Static map assets.** Each town's pixel-art map is a single PNG. Character tokens are static avatars composed on top via CSS positioning. The Gradio app runs comfortably on `cpu-basic` hardware.
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
- 🎙 **Tune in:** https://huggingface.co/spaces/build-small-hackathon/analog-town
140
 
141
- Pick a town. Type a piece of news. Slide the dial. Listen.
 
 
 
 
 
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:** 2 min 30 sec (hard cap 3 min)
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 is 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 — Space cold-starts can take 30s, warm it up first
12
- - [ ] Browser window sized 1440×900, zoom 110%
13
- - [ ] System volume on (so static fade is audible in the recording)
14
- - [ ] Pick the town: **Graybridge** (best demo because the conflict is instantly legible)
15
- - [ ] Pre-write the broadcast text in a notes window so you don't fumble typing on camera:
16
- *"The old train station will be converted into a luxury hotel."*
17
- - [ ] Have a second pre-written broadcast ready as a backup:
18
- *"The town council just approved a 5G tower on the school roof."*
 
 
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 find out what each resident is privately thinking about it."
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 — The Setup (0:15 – 0:35)
37
 
38
- **On screen:** Open the town selector dropdown. Pause on it just long enough that the six options are readable.
39
 
40
  **You say:**
41
 
42
- > "We ship six fictional towns, each with a built-in conflict. There's a mill town arguing about gentrification, a coastal village debating an offshore wind farm, a polar research station that just picked up a strange signal. I'll load Graybridge."
43
 
44
- **Action:** Click **Graybridge**. Wait for the isometric map to load with the pixel-art character tokens placed on it.
45
 
46
  ---
47
 
48
- ## Scene 3 — Meet the Town (0:35 – 0:55)
49
 
50
- **On screen:** The Graybridge isometric map is visible. Six circular character avatars sit on it.
51
 
52
  **You say:**
53
 
54
- > "Graybridge has six residents. Each one is a typed agent with their own values, fears, hopes, and private history. They each broadcast on their own FM frequency."
55
 
56
- **Action:** Hover slowly over two or three avatars. If hovering shows a name/role tooltip, pause on each for ~1.5 seconds. Don't click yet — you're building anticipation.
 
 
 
 
 
 
 
 
 
 
57
 
58
  ---
59
 
60
- ## Scene 4 — The Broadcast (0:55 – 1:15)
61
 
62
- **On screen:** Move to the broadcast input box.
63
 
64
  **You say:**
65
 
66
- > "I'm going to broadcast something into the town. Watch what happens."
 
 
67
 
68
- **Action:** Type (or paste) the broadcast:
69
 
70
- > *The old train station will be converted into a luxury hotel.*
71
 
72
- Click **Broadcast**.
73
 
74
- **You say (while it's computing):**
75
 
76
- > "Behind the scenes, this is running on 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. Then it writes one short internal monologue in their voice."
 
 
 
 
77
 
78
  ---
79
 
80
- ## Scene 5 — Tune the Dial (1:151:50)
81
 
82
- **On screen:** The broadcast finished. The CRT panel is ready to show transitions.
83
 
84
  **You say:**
85
 
86
- > "Now I tune in."
87
 
88
- **Action:** Slowly drag the frequency dial up toward Margaret's station (around 98.7 MHz, whatever the actual frequency is). As you approach, the **signal-strength bar climbs and the static audibly fades**. Stop right on her station.
89
 
90
- **On screen:** Margaret's dossier loads. Her monologue prints onto the CRT panel.
91
 
92
- **You say (read the monologue out loud, or paraphrase if it's long):**
93
 
94
- > "Margaret. 67. Retired stationmaster. She remembers the night the last train left in 1998. Listen to what she heard."
95
 
96
- (Read 1–2 lines of her monologue verbatim from the screen.)
97
 
98
- **Action:** Drag the dial up further to a younger character — e.g. **Devon, the line cook**. The static swells while you scan, then fades again when you land.
99
 
100
- **You say:**
101
 
102
- > "Same broadcast. Different mind."
103
 
104
- (Read 1 line of Devon's monologue verbatim should be visibly more upbeat / opportunistic.)
105
 
106
  ---
107
 
108
- ## Scene 6 — The Map Click (1:50 – 2:05)
109
 
110
- **On screen:** Move the cursor to the isometric map.
111
 
112
  **You say:**
113
 
114
- > "You can also just click someone on the map."
 
 
115
 
116
- **Action:** Click a third character's avatar on the map. The dial snaps to their frequency, the dossier swaps, their monologue appears.
117
 
118
- **You say (one line):**
119
 
120
- > "The radio retunes itself."
121
 
122
  ---
123
 
124
- ## Scene 7 — The Trace (2:052:20)
125
 
126
- **On screen:** Open the **structured trace** panel (the collapsible JSON view, if present in your build).
127
 
128
  **You say:**
129
 
130
- > "Under every monologue is the full structured trace — the emotion deltas, the activated memory, the value conflict, the likely public action. This is what makes Analog Town useful for writers and game masters: you don't just get prose, you get a readable mechanical readout of *why* this character reacted this way."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
131
 
132
- **Action:** Scroll the trace panel briefly so the JSON fields are visible.
133
 
134
  ---
135
 
136
- ## Scene 8 — Close (2:202:30)
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, and a radio dial. Built small, on purpose. Tune in on Hugging Face Spaces."
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 the model is slow:** *"While that's computing every state transition is one call, six characters means six small calls. That's the trade we made: small model, tight schema, no chat."*
151
- - **If a station produces a flat monologue:** *"Notice the model isn't being dramatic that's deliberate. The system prompt tells it to favor plausible and grounded over interesting."*
 
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. Intercept what each resident is privately thinking."*
 
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:352: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:403: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:103: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
- def format_town_map(town: Town, selected_agent_id: str | None = None) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="background-image: url('{avatar_url}');"></div>
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 return map HTML and empty profile card."""
 
 
 
 
 
 
 
 
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 map_html, profile_html, town_json, event_title, event_content
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  except Exception as e:
354
- return f"<div style='color: #ff4444;'>Error loading town: {e}</div>", "", "", "", ""
 
 
 
 
 
 
 
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
- map_html = format_town_map(town, agent.id)
 
 
 
 
 
 
 
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 for all agents."""
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
- # Store result as JSON
 
 
 
 
 
 
 
 
448
  result_json = result.model_dump_json(indent=2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
449
 
450
- return status, result_json
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- targetActualVolume = ambientVolume * signalFade;
604
-
 
 
 
605
  // Smoothly interpolate currentActualVolume towards targetActualVolume
606
  const diff = targetActualVolume - currentActualVolume;
607
  if (Math.abs(diff) > 0.01) {
608
- currentActualVolume += diff * 0.15; // smooth transition
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
- transmit_btn = gr.Button(
695
- "🔊 TRANSMIT",
696
- variant="primary",
697
- elem_classes=["transmit-btn"],
698
- elem_id="transmit-btn",
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 &middot; 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
- # Load town
 
 
 
 
 
 
 
828
  load_btn.click(
829
  fn=on_load_town,
830
  inputs=[town_dropdown],
831
- outputs=[town_map, agent_profile, town_state, event_title, event_content],
832
  )
833
 
834
- # Also load on dropdown change
835
  town_dropdown.change(
836
  fn=on_load_town,
837
  inputs=[town_dropdown],
838
- outputs=[town_map, agent_profile, town_state, event_title, event_content],
 
 
 
 
 
 
 
 
 
 
 
 
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]) if available_towns else ("", "", "", "", ""),
888
- outputs=[town_map, agent_profile, town_state, event_title, event_content],
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 &middot; 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 &middot; 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 &middot; 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
- # Generate JSON from model
140
- transition_data = self.model_client.generate_json(
141
- system_prompt=SYSTEM_PROMPT,
142
- user_prompt=user_prompt,
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
- initial_states: dict[str, AgentState] | None = None,
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
- initial_states: Optional dict of agent_id -> AgentState
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
- initial_states.get(agent.id)
184
- if initial_states
185
  else self._get_initial_state(agent)
186
  )
187
 
188
- # Run transition
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: calc(100vh - 320px);
835
- max-height: calc(100vh - 200px);
836
  }
837
- /* Map shrinks if the column is short, but never disappears */
838
  #town-map,
839
  #town-map .town-map-wrapper,
840
  #town-map .town-map-bg {
841
- max-height: calc(100vh - 620px) !important;
842
- min-height: 280px !important;
 
 
 
 
 
 
 
 
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()