AdmiralTaco Claude Opus 4.8 commited on
Commit
1127d3b
Β·
1 Parent(s): a4cd5c0

UI: lead with the council, lab attribution, story spine, raw-output panel

Browse files

Judge-facing polish for the Build Small Hackathon, reviewer+QA gated.

Tier 1
- Council strip under the banner: five lab-colored chips naming each
creature's model + lab, budget line derived from assert_param_budget so
the on-screen 29.5B <= 32B can never drift from the engine table.
- Reorder: town square + charts moved above a collapsed "Patron's console"
accordion, so the first paint is the living economy, not a 10-input form.
- Lab color-coding: each card gets a lab-keyed top accent (LAB_COLORS in
council.py) + lab-colored thought border; rank crown/leaf badge kept.
- "Watch the saga" promoted to a primary hero button on its own row; Step
relabeled "Step (live council)".
- Banner copy fixed: "(<=4B) model" -> "different lab's small model
(<=32B council)" (Oona is gpt-oss-20B).

Tier 2
- Model-tagged thoughts: traces show [model] per creature.
- Story spine: chapter_markdown rewritten to an HTML stage tracker (one
active pill via background+weight+underline so it reads under the panel's
forced dark-text rule); chapter_md switched Markdown -> HTML, moved to top.
- Mode pill (LIVE vs REPLAY) + "View raw model output" accordion backed by
new game.raw_output_markdown() (fence-breakout neutralized; firewall-safe).
- Short-settle beat: suspicious_win event carries pnl; ticker now reads
"The gambit pays +Np."
- Contrast: opacity-dimmed cream captions replaced with solid color.

Arity: outputs migrated 15 -> 17 (mode_pill, raw_md appended AFTER the 14
frame views so replay.frame_to_views stays 14-wide). _tail() centralizes
the contract; new tests/test_app_arity.py locks every handler/branch.

Tests: 134 passed (was 116). ruff E9,F clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (8) hide show
  1. app.py +158 -52
  2. tasks/demo-shot-list.md +30 -0
  3. tests/test_app_arity.py +118 -0
  4. tests/test_game.py +47 -0
  5. ttw/council.py +24 -0
  6. ttw/game.py +85 -13
  7. ttw/narrate.py +6 -1
  8. ttw/sim.py +3 -2
app.py CHANGED
@@ -1,7 +1,7 @@
1
  """Thousand Token Wood -- a tiny emergent economy of small-model agents.
2
 
3
  Gradio app for the Build Small Hackathon. Five woodland creatures, each driven
4
- by a <=4B model (Qwen2.5-3B) served on Modal, trade goods for pebbles, gossip,
5
  and react to "Wood Legends" (famous market manias reskinned). You poke the
6
  economy and watch bubbles, crashes, and a widening wealth gap emerge.
7
 
@@ -16,6 +16,13 @@ import time
16
 
17
  import gradio as gr
18
 
 
 
 
 
 
 
 
19
  from ttw.game import Game
20
  from ttw.narrator import make_narrator
21
  from ttw.replay import frame_to_views, load_attract
@@ -32,6 +39,62 @@ ATTRACT_FRAMES = load_attract()
32
  CAST = list(seed_world().creatures.keys())
33
 
34
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
35
  def make_policy():
36
  """Pick the turn policy from the environment.
37
 
@@ -77,14 +140,24 @@ def _views(game: Game):
77
  )
78
 
79
 
 
 
 
 
 
 
 
 
 
80
  def init():
81
  # Seed a live game (ready for the judge to Step), but if a recorded run
82
  # exists, paint its opening frame so the Space is rich and alive on load
83
- # instead of an empty turn-0 board while the council cold-starts.
 
84
  game = new_game()
85
  if ATTRACT_FRAMES:
86
- return (game, *frame_to_views(ATTRACT_FRAMES[0]))
87
- return (game, *_views(game))
88
 
89
 
90
  def do_watch(game: Game):
@@ -92,13 +165,15 @@ def do_watch(game: Game):
92
  # place. The live game seeded at load is passed back UNCHANGED in the state
93
  # slot every yield (a gr.State must receive the real value, never gr.update),
94
  # so the judge can Step into their own game the moment the replay ends. Pure
95
- # data: no model, no Modal, no GPU.
 
 
96
  if not ATTRACT_FRAMES:
97
  if game is not None:
98
- yield (game, *_views(game))
99
  return
100
  for i, frame in enumerate(ATTRACT_FRAMES):
101
- yield (game, *frame_to_views(frame))
102
  if i < len(ATTRACT_FRAMES) - 1:
103
  time.sleep(REPLAY_SECONDS)
104
 
@@ -141,18 +216,19 @@ def do_step(game: Game):
141
  # the model call. Gradio streams both, so the UI never appears frozen.
142
  if game is None:
143
  # Unreachable on the Space (demo.load seeds a Game first), but never
144
- # dereference None: yield a no-op frame for every view output.
145
- yield (game, *([gr.update()] * 14))
 
146
  return
147
- yield (game, *_thinking_views(game))
148
  game.step()
149
- yield (game, *_views(game))
150
 
151
 
152
  def do_tempt(game: Game):
153
  if game is not None:
154
  game.tempt_fate()
155
- return (game, *_views(game))
156
 
157
 
158
  def do_move(game: Game, kind: str, target: str, good: str, amount: float, text: str, other: str, tip_truth: str):
@@ -171,7 +247,7 @@ def do_move(game: Game, kind: str, target: str, good: str, amount: float, text:
171
  other=other,
172
  truth=(tip_truth == "true"),
173
  )
174
- return (game, *_views(game))
175
 
176
 
177
  def do_reset():
@@ -208,6 +284,13 @@ CSS = """
208
  }
209
  #ttw-banner p { margin: 0; font-size: 15px; opacity: .96; color: #fbf6e8; }
210
  .gradio-container h3 { font-family: 'Fredoka', system-ui, sans-serif; letter-spacing: .2px; }
 
 
 
 
 
 
 
211
  #legend-panel {background:#fff8e6 !important; border:1px solid #e6c48a; border-radius:12px; padding:10px 16px;}
212
  #ticker-panel {background:#fbf7ec !important; border:1px solid #dcd6c4; border-radius:12px; padding:10px 16px;}
213
  #legend-panel, #legend-panel *, #ticker-panel, #ticker-panel * {color:#2e2519 !important;}
@@ -223,7 +306,7 @@ CSS = """
223
  #investigation-panel, #investigation-panel * {color:#2e2519 !important;}
224
  #relations-panel {background:#eef3ea !important; border:1px solid #b9cdb0; border-radius:12px; padding:10px 16px;}
225
  #relations-panel, #relations-panel * {color:#2e2519 !important;}
226
- #chapter-panel {background:#f5efe0 !important; border:1px solid #ddd0aa; border-radius:12px; padding:8px 14px;}
227
  #chapter-panel, #chapter-panel * {color:#2e2519 !important;}
228
  /* The relationship heat-matrix is a light surface, so it carries the same
229
  id-scoped !important dark-text rule as the other cream panels. The heat-panel
@@ -237,17 +320,42 @@ with gr.Blocks(title="Thousand Token Wood") as demo:
237
  gr.HTML(
238
  "<div id='ttw-banner'>"
239
  "<h1>πŸ„ Thousand Token Wood</h1>"
240
- "<p>A tiny <b>emergent economy</b>. Five woodland creatures, each a small (≀4B) model, "
241
- "trade, gossip, and panic. Poke the wood and watch bubbles, crashes, and a widening "
242
- "wealth gap emerge, all unscripted. <b>Press Step to begin, then Tempt Fate.</b></p>"
 
243
  "</div>"
244
  )
245
 
246
  game_state = gr.State(None)
247
 
248
  with gr.Column(elem_id="ttw-console"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  # Operator strip: the Patron's meters and live heat gauge are always
250
- # visible above the move controls so cost-and-consequence reads at a glance.
251
  with gr.Row():
252
  with gr.Column(scale=1):
253
  patron_md = gr.Markdown(elem_id="patron-panel")
@@ -255,42 +363,16 @@ with gr.Blocks(title="Thousand Token Wood") as demo:
255
  heat_html = gr.HTML(elem_id="heat-panel")
256
  legend_md = gr.Markdown(elem_id="legend-panel")
257
 
258
- gr.Markdown("### The Patron's console")
259
- gr.Markdown(
260
- "_Move money and information from the shadows. Pick a target, set the "
261
- "knobs, fire a move, then press **Step** to watch the wood react._"
262
- )
263
- with gr.Row():
264
- move_target = gr.Dropdown(CAST, value=CAST[0], label="Target creature")
265
- move_other = gr.Dropdown(CAST, value=CAST[1], label="Ally (for Broker alliance)")
266
- move_good = gr.Dropdown(GOODS, value="berries", label="Good (for Short / Corner)")
267
- move_amount = gr.Number(value=50, label="Amount (pebbles)", precision=0)
268
- with gr.Row():
269
- move_text = gr.Textbox(label="Tip (for Whisper)", placeholder="the honey crop will fail...")
270
- tip_truth = gr.Radio(["true", "false"], value="false", label="Tip is...")
271
- with gr.Row():
272
- lend_btn = gr.Button("Lend (-50p, +1 heat)")
273
- tip_btn = gr.Button("Whisper tip (-5p, +2 heat)")
274
- short_btn = gr.Button("Short (-20p, +5 heat)")
275
- bribe_btn = gr.Button("Bribe (-40p, +8 heat)")
276
- alliance_btn = gr.Button("Broker alliance (-15p, +2 heat)")
277
- corner_btn = gr.Button("Fund corner (-60p, +6 heat)")
278
-
279
- with gr.Row():
280
- step_btn = gr.Button("Step", variant="primary")
281
- auto = gr.Checkbox(label=f"Auto-run (every {AUTO_SECONDS:.0f}s)", value=False)
282
- tempt_btn = gr.Button("Tempt Fate (draw a Wood Legend)")
283
- reset_btn = gr.Button("Reset the wood")
284
- if ATTRACT_FRAMES:
285
- watch_btn = gr.Button("β–Ά Watch the saga (recorded run)", variant="secondary")
286
-
287
- # Monitor row: the illustrated town cards beside the live charts.
288
  with gr.Row():
289
  with gr.Column(scale=2):
290
  gr.Markdown("### The town square")
291
  town = gr.HTML(elem_id="town-panel")
292
  with gr.Accordion("What the creatures are thinking", open=True):
293
  traces = gr.Markdown()
 
 
294
  with gr.Column(scale=3):
295
  gr.Markdown("### Prices")
296
  price_plot = gr.LinePlot(x="turn", y="price", color="good", height=240)
@@ -310,11 +392,32 @@ with gr.Blocks(title="Thousand Token Wood") as demo:
310
  with gr.Column(scale=2):
311
  investigation_md = gr.Markdown(elem_id="investigation-panel")
312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  gr.Markdown("### The Patron's ledger")
314
  moveslog_md = gr.Markdown(elem_id="moveslog-panel")
315
 
316
- chapter_md = gr.Markdown(elem_id="chapter-panel")
317
-
318
  gr.Markdown("### The wood's news")
319
  ticker = gr.Markdown(elem_id="ticker-panel")
320
 
@@ -322,11 +425,14 @@ with gr.Blocks(title="Thousand Token Wood") as demo:
322
 
323
  # Order MUST match: game_state first, then _views() in order (town, price,
324
  # gini, journal, traces, legend, patron, investigation, moveslog, relations,
325
- # chapter, heat, relmap, cutscene). 15 outputs = game_state + 14 views.
 
 
326
  outputs = [
327
  game_state, town, price_plot, gini_plot, ticker, traces, legend_md,
328
  patron_md, investigation_md, moveslog_md, relations_md, chapter_md,
329
- heat_html, relmap_html, cutscene
 
330
  ]
331
  move_inputs = [game_state, move_target, move_good, move_amount, move_text, move_other, tip_truth]
332
 
 
1
  """Thousand Token Wood -- a tiny emergent economy of small-model agents.
2
 
3
  Gradio app for the Build Small Hackathon. Five woodland creatures, each driven
4
+ by a different lab's small model served on Modal, trade goods for pebbles, gossip,
5
  and react to "Wood Legends" (famous market manias reskinned). You poke the
6
  economy and watch bubbles, crashes, and a widening wealth gap emerge.
7
 
 
16
 
17
  import gradio as gr
18
 
19
+ from ttw import narrate
20
+ from ttw.council import (
21
+ PARAM_BUDGET_CAP_B,
22
+ assert_param_budget,
23
+ creature_engine_label,
24
+ creature_lab_color,
25
+ )
26
  from ttw.game import Game
27
  from ttw.narrator import make_narrator
28
  from ttw.replay import frame_to_views, load_attract
 
39
  CAST = list(seed_world().creatures.keys())
40
 
41
 
42
+ # A small status pill: the judge always knows whether they are watching the
43
+ # instant recorded saga or a live council turn. Dark self-contained chips (light
44
+ # text on dark) so they read in any Space theme without an id-scoped rule.
45
+ PILL_LIVE = (
46
+ "<span style=\"display:inline-block;padding:3px 11px;border-radius:999px;"
47
+ "background:#0d1f14;border:1px solid #1c3a26;color:#9ef0b0;font-size:12px;"
48
+ "font-family:'IBM Plex Mono',monospace;\">● LIVE - council warm</span>"
49
+ )
50
+ PILL_REPLAY = (
51
+ "<span style=\"display:inline-block;padding:3px 11px;border-radius:999px;"
52
+ "background:#1f1a0d;border:1px solid #3a341c;color:#f0d89e;font-size:12px;"
53
+ "font-family:'IBM Plex Mono',monospace;\">● REPLAY (recorded)</span>"
54
+ )
55
+
56
+
57
+ def build_council_strip() -> str:
58
+ """The Council strip: five lab-colored chips naming each creature's model.
59
+
60
+ Static (built once at import): the single most valuable claim of the project
61
+ is that five labs' small models think side by side, so the UI says it in the
62
+ first glance, above the controls. Each chip carries its lab's accent color,
63
+ the same color the creature's card and thought border use, so lab identity is
64
+ one consistent visual language. The budget line is derived from the param
65
+ table so the number can never drift from the actual council.
66
+ """
67
+ total = assert_param_budget()
68
+ chips = []
69
+ for name in CAST:
70
+ model, lab = creature_engine_label(name)
71
+ color = creature_lab_color(name)
72
+ label = f"{model} Β· {lab}" if model else "small model"
73
+ chips.append(
74
+ "<span style=\"display:inline-flex;align-items:center;gap:6px;"
75
+ f"border-left:4px solid {color};background:#fffdf7;border-radius:8px;"
76
+ "padding:5px 10px;margin:3px;font-size:12px;color:#2e2519;"
77
+ "box-shadow:0 1px 3px rgba(60,45,20,.10);\">"
78
+ f"<span style=\"font-size:16px;\">{narrate.avatar(name)}</span>"
79
+ f"<b>{name}</b> <span style=\"color:#6b5d3e;\">{label}</span></span>"
80
+ )
81
+ budget = (
82
+ "<span style=\"font-size:11.5px;color:#6b5d3e;margin-left:6px;\">"
83
+ f"distinct-engine budget {total:g}B ≀ {PARAM_BUDGET_CAP_B}B</span>"
84
+ )
85
+ return (
86
+ "<div style=\"font-family:'Fredoka',system-ui,sans-serif;font-weight:600;"
87
+ "font-size:13px;color:#2e2519;margin-bottom:4px;\">"
88
+ "The Council Β· five labs, five minds</div>"
89
+ "<div style=\"display:flex;flex-wrap:wrap;align-items:center;\">"
90
+ + "".join(chips) + budget
91
+ + "</div>"
92
+ )
93
+
94
+
95
+ COUNCIL_STRIP_HTML = build_council_strip()
96
+
97
+
98
  def make_policy():
99
  """Pick the turn policy from the environment.
100
 
 
140
  )
141
 
142
 
143
+ def _tail(replay: bool, game: Game):
144
+ """The two trailing outputs every handler must emit: the mode pill and the
145
+ raw-model-output panel. Kept here so every handler stays in lockstep and the
146
+ 15->17 arity contract can never drift in one place but not another."""
147
+ pill = PILL_REPLAY if replay else PILL_LIVE
148
+ raw = game.raw_output_markdown() if game is not None else ""
149
+ return (pill, raw)
150
+
151
+
152
  def init():
153
  # Seed a live game (ready for the judge to Step), but if a recorded run
154
  # exists, paint its opening frame so the Space is rich and alive on load
155
+ # instead of an empty turn-0 board while the council cold-starts. The opening
156
+ # paint of a recorded run is REPLAY; a bare live seed is LIVE.
157
  game = new_game()
158
  if ATTRACT_FRAMES:
159
+ return (game, *frame_to_views(ATTRACT_FRAMES[0]), PILL_REPLAY, "")
160
+ return (game, *_views(game), *_tail(False, game))
161
 
162
 
163
  def do_watch(game: Game):
 
165
  # place. The live game seeded at load is passed back UNCHANGED in the state
166
  # slot every yield (a gr.State must receive the real value, never gr.update),
167
  # so the judge can Step into their own game the moment the replay ends. Pure
168
+ # data: no model, no Modal, no GPU. Every yield carries the REPLAY pill and an
169
+ # empty raw panel (recorded frames store no raw output), supplied here so
170
+ # replay.py's stored frames stay 14-wide and need no migration.
171
  if not ATTRACT_FRAMES:
172
  if game is not None:
173
+ yield (game, *_views(game), *_tail(False, game))
174
  return
175
  for i, frame in enumerate(ATTRACT_FRAMES):
176
+ yield (game, *frame_to_views(frame), PILL_REPLAY, "")
177
  if i < len(ATTRACT_FRAMES) - 1:
178
  time.sleep(REPLAY_SECONDS)
179
 
 
216
  # the model call. Gradio streams both, so the UI never appears frozen.
217
  if game is None:
218
  # Unreachable on the Space (demo.load seeds a Game first), but never
219
+ # dereference None: yield a no-op frame for every view output. The state
220
+ # slot still receives the real (None) value, never gr.update().
221
+ yield (game, *([gr.update()] * 16))
222
  return
223
+ yield (game, *_thinking_views(game), *_tail(False, game))
224
  game.step()
225
+ yield (game, *_views(game), *_tail(False, game))
226
 
227
 
228
  def do_tempt(game: Game):
229
  if game is not None:
230
  game.tempt_fate()
231
+ return (game, *_views(game), *_tail(False, game))
232
 
233
 
234
  def do_move(game: Game, kind: str, target: str, good: str, amount: float, text: str, other: str, tip_truth: str):
 
247
  other=other,
248
  truth=(tip_truth == "true"),
249
  )
250
+ return (game, *_views(game), *_tail(False, game))
251
 
252
 
253
  def do_reset():
 
284
  }
285
  #ttw-banner p { margin: 0; font-size: 15px; opacity: .96; color: #fbf6e8; }
286
  .gradio-container h3 { font-family: 'Fredoka', system-ui, sans-serif; letter-spacing: .2px; }
287
+ /* The Council strip and the raw-output panel are LIGHT surfaces, so they carry
288
+ the same id-scoped !important dark-text rule as the other cream panels to stay
289
+ legible on a dark Space theme. */
290
+ #council-panel {background:#faf6ea !important; border:1px solid #ddd0aa; border-radius:12px; padding:10px 14px; margin-bottom:6px;}
291
+ #council-panel, #council-panel * {color:#2e2519 !important;}
292
+ #raw-panel {background:#f6f1e3 !important; border:1px solid #ddd0aa; border-radius:12px; padding:8px 12px;}
293
+ #raw-panel, #raw-panel * {color:#2e2519 !important;}
294
  #legend-panel {background:#fff8e6 !important; border:1px solid #e6c48a; border-radius:12px; padding:10px 16px;}
295
  #ticker-panel {background:#fbf7ec !important; border:1px solid #dcd6c4; border-radius:12px; padding:10px 16px;}
296
  #legend-panel, #legend-panel *, #ticker-panel, #ticker-panel * {color:#2e2519 !important;}
 
306
  #investigation-panel, #investigation-panel * {color:#2e2519 !important;}
307
  #relations-panel {background:#eef3ea !important; border:1px solid #b9cdb0; border-radius:12px; padding:10px 16px;}
308
  #relations-panel, #relations-panel * {color:#2e2519 !important;}
309
+ #chapter-panel {background:#f5efe0 !important; border:1px solid #ddd0aa; border-radius:12px; padding:8px 14px; margin-bottom:6px;}
310
  #chapter-panel, #chapter-panel * {color:#2e2519 !important;}
311
  /* The relationship heat-matrix is a light surface, so it carries the same
312
  id-scoped !important dark-text rule as the other cream panels. The heat-panel
 
320
  gr.HTML(
321
  "<div id='ttw-banner'>"
322
  "<h1>πŸ„ Thousand Token Wood</h1>"
323
+ "<p>A tiny <b>emergent economy</b>. Five woodland creatures, each a different lab's "
324
+ "small model (≀32B council), trade, gossip, and panic. Poke the wood and watch bubbles, "
325
+ "crashes, and a widening wealth gap emerge, all unscripted. <b>Watch the saga, or press "
326
+ "Step to begin.</b></p>"
327
  "</div>"
328
  )
329
 
330
  game_state = gr.State(None)
331
 
332
  with gr.Column(elem_id="ttw-console"):
333
+ # Lead with the proof: five labs' models, named, before any control.
334
+ gr.HTML(COUNCIL_STRIP_HTML, elem_id="council-panel")
335
+ # The authored arc as a persistent stage tracker, so a muted-video judge
336
+ # can follow the crime story (whispers -> heat -> inquiry -> reckoning).
337
+ chapter_md = gr.HTML(elem_id="chapter-panel")
338
+
339
+ # Prominent action row: the three things a judge actually presses, plus
340
+ # the live/replay status pill.
341
+ with gr.Row():
342
+ step_btn = gr.Button("Step (live council)", variant="primary")
343
+ tempt_btn = gr.Button("Tempt Fate (draw a Wood Legend)")
344
+ reset_btn = gr.Button("Reset the wood")
345
+ auto = gr.Checkbox(label=f"Auto-run (every {AUTO_SECONDS:.0f}s)", value=False)
346
+ mode_pill = gr.HTML(elem_id="mode-pill")
347
+
348
+ # The hero path: a full dramatic run, instantly, with no warm-up. This is
349
+ # the latency mitigation, so it is the most prominent thing on the page.
350
+ if ATTRACT_FRAMES:
351
+ watch_btn = gr.Button("β–Ά Watch the saga (recorded run)", variant="primary")
352
+ gr.Markdown(
353
+ "_A full dramatic run plays instantly, no model warm-up. "
354
+ "Then take the controls and Step into your own._"
355
+ )
356
+
357
  # Operator strip: the Patron's meters and live heat gauge are always
358
+ # visible so cost-and-consequence reads at a glance.
359
  with gr.Row():
360
  with gr.Column(scale=1):
361
  patron_md = gr.Markdown(elem_id="patron-panel")
 
363
  heat_html = gr.HTML(elem_id="heat-panel")
364
  legend_md = gr.Markdown(elem_id="legend-panel")
365
 
366
+ # Monitor row: the illustrated town cards beside the live charts. Moved
367
+ # ABOVE the console so the first paint is the living economy, not a form.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  with gr.Row():
369
  with gr.Column(scale=2):
370
  gr.Markdown("### The town square")
371
  town = gr.HTML(elem_id="town-panel")
372
  with gr.Accordion("What the creatures are thinking", open=True):
373
  traces = gr.Markdown()
374
+ with gr.Accordion("View raw model output (is this scripted?)", open=False):
375
+ raw_md = gr.Markdown(elem_id="raw-panel")
376
  with gr.Column(scale=3):
377
  gr.Markdown("### Prices")
378
  price_plot = gr.LinePlot(x="turn", y="price", color="good", height=240)
 
392
  with gr.Column(scale=2):
393
  investigation_md = gr.Markdown(elem_id="investigation-panel")
394
 
395
+ # The move controls, tucked away so the judge sees the toy before the
396
+ # knobs. Opened when they want to play the Patron.
397
+ with gr.Accordion("The Patron's console - issue power moves", open=False):
398
+ gr.Markdown(
399
+ "_Move money and information from the shadows. Pick a target, set the "
400
+ "knobs, fire a move, then press **Step** to watch the wood react._"
401
+ )
402
+ with gr.Row():
403
+ move_target = gr.Dropdown(CAST, value=CAST[0], label="Target creature")
404
+ move_other = gr.Dropdown(CAST, value=CAST[1], label="Ally (for Broker alliance)")
405
+ move_good = gr.Dropdown(GOODS, value="berries", label="Good (for Short / Corner)")
406
+ move_amount = gr.Number(value=50, label="Amount (pebbles)", precision=0)
407
+ with gr.Row():
408
+ move_text = gr.Textbox(label="Tip (for Whisper)", placeholder="the honey crop will fail...")
409
+ tip_truth = gr.Radio(["true", "false"], value="false", label="Tip is...")
410
+ with gr.Row():
411
+ lend_btn = gr.Button("Lend (-50p, +1 heat)")
412
+ tip_btn = gr.Button("Whisper tip (-5p, +2 heat)")
413
+ short_btn = gr.Button("Short (-20p, +5 heat)")
414
+ bribe_btn = gr.Button("Bribe (-40p, +8 heat)")
415
+ alliance_btn = gr.Button("Broker alliance (-15p, +2 heat)")
416
+ corner_btn = gr.Button("Fund corner (-60p, +6 heat)")
417
+
418
  gr.Markdown("### The Patron's ledger")
419
  moveslog_md = gr.Markdown(elem_id="moveslog-panel")
420
 
 
 
421
  gr.Markdown("### The wood's news")
422
  ticker = gr.Markdown(elem_id="ticker-panel")
423
 
 
425
 
426
  # Order MUST match: game_state first, then _views() in order (town, price,
427
  # gini, journal, traces, legend, patron, investigation, moveslog, relations,
428
+ # chapter, heat, relmap, cutscene), then the two _tail() outputs (mode_pill,
429
+ # raw_md). 17 outputs = game_state + 14 views + 2 tail. The tail is appended
430
+ # AFTER the 14 frame views so replay.py's frame_to_views stays 14-wide.
431
  outputs = [
432
  game_state, town, price_plot, gini_plot, ticker, traces, legend_md,
433
  patron_md, investigation_md, moveslog_md, relations_md, chapter_md,
434
+ heat_html, relmap_html, cutscene,
435
+ mode_pill, raw_md,
436
  ]
437
  move_inputs = [game_state, move_target, move_good, move_amount, move_text, move_other, tip_truth]
438
 
tasks/demo-shot-list.md ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Thousand Token Wood β€” demo video shot-list (~75s)
2
+
3
+ The recorded attract run already tells the whole story, so the easiest high-quality
4
+ demo is: keep-warm the engines, open the Space, hit "β–Ά Watch the saga", and narrate
5
+ over it. Target 60-90s. Money shot in the first 10 seconds.
6
+
7
+ Before filming: run `python scripts/keep_warm.py` so the live council is hot, and
8
+ open the Space fresh so the attract opening frame is already on screen.
9
+
10
+ | Time | On screen | Say (voiceover) |
11
+ |---|---|---|
12
+ | 0:00-0:08 | Space loads β€” the town square is ALREADY alive (recorded opening), five creature cards each tagged with a different lab's model. | "Five woodland creatures. Each one thinks on a different lab's small model β€” OpenAI, NVIDIA, OpenBMB, and my own fine-tuned half-billion." |
13
+ | 0:08-0:18 | Click **β–Ά Watch the saga**. Cards update, thoughts (πŸ’­) change per creature, Wood Street Journal headline animates. | "You're the Patron β€” a shadow financier. You whisper insider tips, short the market, bribe, and broker alliances." |
14
+ | 0:18-0:32 | The legend frame: "The Run on Oona's Hoard"; honey price line dives on the chart; headline "Patron's Sweet Gambit…". | "Whisper a true tip, short the crop, and spring the panic β€” honey crashes exactly as you planned." |
15
+ | 0:32-0:45 | Heat bar slams toward the red; crosses Heron's line (70). Headline turns to the inquiry. | "But every dirty win raises your heat. Cross the Magistrate's line and Heron opens an investigation." |
16
+ | 0:45-0:55 | Verdict lands β€” the fine; purse drops; heat clamps back. Relationship matrix shows grudges/alliances shifting. | "Caught. A hundred-pebble fine β€” and the creatures remember how you treated them, and scheme back." |
17
+ | 0:55-1:05 | Stop the replay; press **Step** once to show it's live β€” a real council turn computes, new thoughts appear. | "And it's all live β€” every thought here is a real small model deciding in real time, not a script." |
18
+ | 1:05-1:15 | Pull back to the full console. | "Thousand Token Wood. Five labs, five minds, one emergent economy β€” running entirely on small models." |
19
+
20
+ ## Tips
21
+ - Record at 1080p+, browser zoomed so the creature cards + their model badges and πŸ’­ thoughts are legible.
22
+ - If a live Step is slow on camera, pre-warm harder or just cut to it; the attract replay carries the demo on its own.
23
+ - The two cleanest headlines in the recording: "Patron's Sweet Gambit: Shorting Honey and Bribing Fenn…" and "Honey Prices Crash; Acorn Trade Tightens the Ties."
24
+ - Do NOT name the TV show that inspired the drama in the video.
25
+
26
+ ## Sponsor framing (for the written submission, not the video)
27
+ Name each creature's model explicitly so the OpenAI / NVIDIA / OpenBMB tracks see
28
+ their model in use: Oona = gpt-oss-20B (OpenAI), Fenn = Nemotron-Mini-4B (NVIDIA),
29
+ Bramble = MiniCPM3-4B (OpenBMB), Mossback + Pip = AdmiralTaco/ttw-trader-0.5B
30
+ (fine-tuned). Distinct-engine budget 29.5B ≀ 32B.
tests/test_app_arity.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Locks the Gradio output-arity contract: every handler must emit exactly
2
+ len(app.outputs) values, in lockstep, and never put a gr.update() in the
3
+ game_state slot (which would corrupt gr.State).
4
+
5
+ This is the recurring bug class on this app (15 -> 17 migration), so it gets a
6
+ dedicated, exhaustive test: init (both branches), do_step (all 3 yields incl.
7
+ the None branch), do_tempt, do_move, do_reset, and do_watch (both branches).
8
+
9
+ Run: TTW_DUMMY=1 python -m pytest tests/test_app_arity.py -q
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
17
+
18
+ # The app must build with no GPU/Modal. Set before importing app (import builds
19
+ # the council strip and the Blocks graph, neither of which launches a server).
20
+ os.environ["TTW_DUMMY"] = "1"
21
+
22
+ import app # noqa: E402
23
+ from ttw.replay import record_frame # noqa: E402
24
+
25
+ N = len(app.outputs)
26
+
27
+
28
+ def _assert_frame(out):
29
+ """A handler frame is the right width and never corrupts the state slot."""
30
+ assert len(out) == N, f"expected {N} outputs, got {len(out)}"
31
+ # Slot 0 is gr.State; it must carry the real value (a Game or None), never a
32
+ # gr.update() payload (a dict), which would silently corrupt the state.
33
+ assert not isinstance(out[0], dict), "game_state slot must never be gr.update()"
34
+
35
+
36
+ def test_outputs_is_seventeen():
37
+ assert N == 17
38
+
39
+
40
+ def test_frame_to_views_plus_tail_equals_outputs():
41
+ # 14 frame views + game_state(1) + tail(2) == outputs. Locks the relationship
42
+ # so widening the frame or the tail without the other is caught.
43
+ g = app.new_game()
44
+ frame = record_frame(g)
45
+ assert app.frame_to_views(frame).__len__() + 1 + 2 == N
46
+
47
+
48
+ def test_init_live_branch_arity(monkeypatch):
49
+ monkeypatch.setattr(app, "ATTRACT_FRAMES", [])
50
+ _assert_frame(app.init())
51
+
52
+
53
+ def test_init_replay_branch_arity(monkeypatch):
54
+ frame = record_frame(app.new_game())
55
+ monkeypatch.setattr(app, "ATTRACT_FRAMES", [frame])
56
+ _assert_frame(app.init())
57
+
58
+
59
+ def test_do_step_all_yields_arity():
60
+ g = app.new_game()
61
+ frames = list(app.do_step(g))
62
+ assert len(frames) == 2 # thinking frame, then result
63
+ for f in frames:
64
+ _assert_frame(f)
65
+
66
+
67
+ def test_do_step_none_branch_arity():
68
+ frames = list(app.do_step(None))
69
+ assert len(frames) == 1
70
+ _assert_frame(frames[0])
71
+ assert frames[0][0] is None # state slot is the real (None) value
72
+
73
+
74
+ def test_do_tempt_arity():
75
+ _assert_frame(app.do_tempt(app.new_game()))
76
+
77
+
78
+ def test_do_move_arity():
79
+ g = app.new_game()
80
+ out = app.do_move(g, "lend", "Pip", "berries", 50, "", "Mossback", "false")
81
+ _assert_frame(out)
82
+
83
+
84
+ def test_do_reset_arity():
85
+ _assert_frame(app.do_reset())
86
+
87
+
88
+ def test_do_watch_replay_arity(monkeypatch):
89
+ # Two synthetic frames; patch out the inter-frame sleep so the test is fast.
90
+ frames = [record_frame(app.new_game()), record_frame(app.new_game())]
91
+ monkeypatch.setattr(app, "ATTRACT_FRAMES", frames)
92
+ monkeypatch.setattr(app.time, "sleep", lambda *a, **k: None)
93
+ yielded = list(app.do_watch(app.new_game()))
94
+ assert len(yielded) == len(frames)
95
+ for f in yielded:
96
+ _assert_frame(f)
97
+ assert f[-2] == app.PILL_REPLAY # mode pill reads REPLAY during the saga
98
+
99
+
100
+ def test_do_watch_empty_frames_falls_back_live(monkeypatch):
101
+ monkeypatch.setattr(app, "ATTRACT_FRAMES", [])
102
+ yielded = list(app.do_watch(app.new_game()))
103
+ assert len(yielded) == 1
104
+ _assert_frame(yielded[0])
105
+ assert yielded[0][-2] == app.PILL_LIVE # the fallback is a live frame
106
+
107
+
108
+ def test_do_watch_empty_frames_and_none_game_yields_nothing(monkeypatch):
109
+ monkeypatch.setattr(app, "ATTRACT_FRAMES", [])
110
+ assert list(app.do_watch(None)) == []
111
+
112
+
113
+ def test_gr_update_count_in_none_branch():
114
+ # The None branch must fill all 16 view slots with gr.update() and keep the
115
+ # state slot real, so the count of dict payloads is exactly 16.
116
+ out = list(app.do_step(None))[0]
117
+ dicts = sum(1 for x in out if isinstance(x, dict))
118
+ assert dicts == N - 1 # every slot but game_state
tests/test_game.py CHANGED
@@ -143,3 +143,50 @@ def test_cutscene_html_never_blank():
143
  assert after
144
  assert "#fff8e6" not in after # the dark surface is never lightened
145
  assert "#161b22" in after # and is the intended dark playbill background
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  assert after
144
  assert "#fff8e6" not in after # the dark surface is never lightened
145
  assert "#161b22" in after # and is the intended dark playbill background
146
+
147
+
148
+ # Judge-facing polish: lab attribution, the story spine, and the raw-output panel.
149
+ def test_creature_lab_color_maps_engine_and_falls_back():
150
+ from ttw.council import creature_lab_color
151
+
152
+ assert creature_lab_color("Fenn") == "#76b900" # NVIDIA green (nemotron)
153
+ assert creature_lab_color("Oona") == "#10a37f" # OpenAI teal (gptoss)
154
+ assert creature_lab_color("Nobody") == "#cdbf9a" # neutral fallback
155
+
156
+
157
+ def test_chapter_markdown_is_html_stepper_with_one_active():
158
+ g = _game()
159
+ spine = g.chapter_markdown()
160
+ # All five chapter short-titles are present as pills.
161
+ for title in ("First Whispers", "The Heat Rises", "Reckoning", "The Long Game"):
162
+ assert title in spine
163
+ # Exactly one pill is the active one (the accent underline appears once).
164
+ assert spine.count("border-bottom:3px solid #2f6b3d") == 1
165
+
166
+
167
+ def test_traces_markdown_tags_the_model():
168
+ g = _game()
169
+ # Thoughts are normally filled from policy.state["raw"]; set them directly to
170
+ # test the model tag rendering without a live council.
171
+ g.thoughts = {"Oona": "hold the line", "Pip": "buy acorns"}
172
+ md = g.traces_markdown()
173
+ assert "[gpt-oss-20B]" in md # Oona's model
174
+ assert "[ttw-trader-0.5B]" in md # Pip's model
175
+
176
+
177
+ def test_raw_output_markdown_empty_state():
178
+ g = _game() # dummy policy exposes no .state
179
+ md = g.raw_output_markdown()
180
+ assert isinstance(md, str)
181
+ assert "No raw model output" in md
182
+
183
+
184
+ def test_raw_output_markdown_tags_and_fence_safe():
185
+ g = _game()
186
+ g.policy.state = {"raw": {"Oona": '{"thought":"x"} ``` # inject'}}
187
+ md = g.raw_output_markdown()
188
+ assert "gpt-oss-20B" in md # tagged by model
189
+ assert "```json" in md # rendered as a fenced block
190
+ # Exactly the open/close fence remain; a stray fence in model output that
191
+ # would break out and inject markup is neutralized.
192
+ assert md.count("```") == 2
ttw/council.py CHANGED
@@ -93,6 +93,30 @@ def creature_engine_label(name: str) -> tuple[str, str]:
93
  return ("", "")
94
  return ENGINE_LABELS.get(eid, ("", ""))
95
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  # Approx params (billions) per DISTINCT engine; summed for the budget assertion.
97
  PARAM_BUDGET_B: dict[str, float] = {
98
  "qwen": 0.5,
 
93
  return ("", "")
94
  return ENGINE_LABELS.get(eid, ("", ""))
95
 
96
+
97
+ # One brand-ish accent color per engine, so a lab's identity is a consistent
98
+ # visual channel across the council strip, the card top-accent, and each
99
+ # creature's thought border. Keyed by engine id (matches CREATURE_ENGINE/
100
+ # ENGINE_LABELS routing) so there is one routing concept, not two.
101
+ LAB_COLORS: dict[str, str] = {
102
+ "qwen": "#d4a017", # our fine-tune: gold
103
+ "minicpm": "#7c5cbf", # OpenBMB: violet
104
+ "nemotron": "#76b900", # NVIDIA: green
105
+ "gptoss": "#10a37f", # OpenAI: teal
106
+ }
107
+
108
+ _NEUTRAL_LAB_COLOR = "#cdbf9a" # the existing neutral card border (unknown/dummy)
109
+
110
+
111
+ def creature_lab_color(name: str) -> str:
112
+ """Accent color for the lab whose engine drives `name`.
113
+
114
+ Falls back to the neutral parchment border for an unknown name (the dummy
115
+ dev path), so callers can always paint a card without a guard.
116
+ """
117
+ eid = CREATURE_ENGINE.get(name)
118
+ return LAB_COLORS.get(eid, _NEUTRAL_LAB_COLOR)
119
+
120
  # Approx params (billions) per DISTINCT engine; summed for the budget assertion.
121
  PARAM_BUDGET_B: dict[str, float] = {
122
  "qwen": 0.5,
ttw/game.py CHANGED
@@ -14,7 +14,7 @@ import pandas as pd
14
 
15
  from . import chapters, moves, narrate, relationships, shocks
16
  from .actions import _extract_json
17
- from .council import creature_engine_label
18
  from .events import EventDeck
19
  from .market import gini
20
  from .narrator import guard_dialogue, guard_headline
@@ -193,8 +193,11 @@ class Game:
193
  worth = round(c.net_worth(self.world.last_price))
194
  mood = narrate.mood_word(c.wellbeing)
195
  badge = "πŸ‘‘" if i == 0 else ("πŸ₯€" if i == len(ranked) - 1 else "")
196
- # accent by rank: gold for richest, faded for poorest
 
 
197
  border = "#d4a017" if i == 0 else ("#b08a8a" if i == len(ranked) - 1 else "#cdbf9a")
 
198
  # Model attribution: each mind is a different lab's small model, and
199
  # the card says which one (the council's whole point). Reasoning: the
200
  # creature's own public thought, proving the agent actually decides.
@@ -209,19 +212,20 @@ class Game:
209
  thought = self.thoughts.get(c.name, "")
210
  thought_html = (
211
  "<div style=\"margin-top:6px;font-size:11.5px;font-style:italic;"
212
- "color:#5b5030;border-top:1px dashed #e3d9bf;padding-top:5px;\">"
213
  f"πŸ’­ {html.escape(thought[:140])}{'…' if len(thought) > 140 else ''}</div>"
214
  if thought
215
  else ""
216
  )
217
  cards.append(
218
  "<div style=\"flex:1 1 170px;min-width:170px;background:#fffdf7;"
219
- f"border:2px solid {border};border-radius:14px;padding:12px 14px;"
 
220
  "box-shadow:0 2px 6px rgba(60,45,20,.10);color:#2e2519;\">"
221
  f"<div style=\"font-size:30px;line-height:1;\">{narrate.avatar(c.name)} "
222
  f"<span style=\"font-size:18px;\">{badge}</span></div>"
223
  f"<div style=\"font-weight:700;font-size:16px;margin-top:4px;\">{c.name}</div>"
224
- f"<div style=\"font-size:12px;opacity:.7;\">grows {c.produces}</div>"
225
  f"{badge_html}"
226
  "<div style=\"margin-top:8px;font-size:13px;\">"
227
  f"πŸͺ™ <b>{c.pebbles}</b> pebbles</div>"
@@ -274,7 +278,7 @@ class Game:
274
  + "<div style=\"position:absolute;right:0;top:0;height:100%;width:2px;"
275
  "background:#9ef0b0;\"></div>"
276
  "</div>"
277
- "<div style=\"margin-top:4px;font-size:11px;color:#9ef0b0;opacity:.8;\">"
278
  f"Heron's line ({threshold})</div>"
279
  + caption
280
  + "</div>"
@@ -369,9 +373,44 @@ class Game:
369
  def traces_markdown(self) -> str:
370
  if not self.thoughts:
371
  return "_Run a turn to hear what the creatures are thinking._"
372
- return "\n\n".join(
373
- f"{narrate.avatar(name)} **{name}:** {thought}" for name, thought in self.thoughts.items()
374
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
375
 
376
  def legend_markdown(self) -> str:
377
  lg = self.current_legend
@@ -388,10 +427,43 @@ class Game:
388
  return "\n\n".join(f"- {line}" for line in self.ticker[-limit:][::-1])
389
 
390
  def chapter_markdown(self) -> str:
391
- """The current authored chapter: name and subtitle, clamped to the arc."""
392
- idx = min(self._chapter_idx, len(chapters.CHAPTERS) - 1)
393
- name, subtitle = chapters.CHAPTERS[idx]
394
- return f"### {name}\n_{subtitle}_"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
395
 
396
  def journal_markdown(self, limit: int = 14) -> str:
397
  """The Wood Street Journal feed: headline + dialogue over the ticker body.
 
14
 
15
  from . import chapters, moves, narrate, relationships, shocks
16
  from .actions import _extract_json
17
+ from .council import creature_engine_label, creature_lab_color
18
  from .events import EventDeck
19
  from .market import gini
20
  from .narrator import guard_dialogue, guard_headline
 
193
  worth = round(c.net_worth(self.world.last_price))
194
  mood = narrate.mood_word(c.wellbeing)
195
  badge = "πŸ‘‘" if i == 0 else ("πŸ₯€" if i == len(ranked) - 1 else "")
196
+ # Two orthogonal visual channels: rank (gold/faded side border, the
197
+ # wealth story) and lab identity (a thicker top accent keyed to the
198
+ # creature's model, so the cards never look like one model in five hats).
199
  border = "#d4a017" if i == 0 else ("#b08a8a" if i == len(ranked) - 1 else "#cdbf9a")
200
+ lab_color = creature_lab_color(c.name)
201
  # Model attribution: each mind is a different lab's small model, and
202
  # the card says which one (the council's whole point). Reasoning: the
203
  # creature's own public thought, proving the agent actually decides.
 
212
  thought = self.thoughts.get(c.name, "")
213
  thought_html = (
214
  "<div style=\"margin-top:6px;font-size:11.5px;font-style:italic;"
215
+ f"color:#5b5030;border-top:1px dashed {lab_color};padding-top:5px;\">"
216
  f"πŸ’­ {html.escape(thought[:140])}{'…' if len(thought) > 140 else ''}</div>"
217
  if thought
218
  else ""
219
  )
220
  cards.append(
221
  "<div style=\"flex:1 1 170px;min-width:170px;background:#fffdf7;"
222
+ f"border:2px solid {border};border-top:4px solid {lab_color};"
223
+ "border-radius:14px;padding:12px 14px;"
224
  "box-shadow:0 2px 6px rgba(60,45,20,.10);color:#2e2519;\">"
225
  f"<div style=\"font-size:30px;line-height:1;\">{narrate.avatar(c.name)} "
226
  f"<span style=\"font-size:18px;\">{badge}</span></div>"
227
  f"<div style=\"font-weight:700;font-size:16px;margin-top:4px;\">{c.name}</div>"
228
+ f"<div style=\"font-size:12px;color:#6b5d3e;\">grows {c.produces}</div>"
229
  f"{badge_html}"
230
  "<div style=\"margin-top:8px;font-size:13px;\">"
231
  f"πŸͺ™ <b>{c.pebbles}</b> pebbles</div>"
 
278
  + "<div style=\"position:absolute;right:0;top:0;height:100%;width:2px;"
279
  "background:#9ef0b0;\"></div>"
280
  "</div>"
281
+ "<div style=\"margin-top:4px;font-size:11px;color:#9ef0b0;\">"
282
  f"Heron's line ({threshold})</div>"
283
  + caption
284
  + "</div>"
 
373
  def traces_markdown(self) -> str:
374
  if not self.thoughts:
375
  return "_Run a turn to hear what the creatures are thinking._"
376
+ lines = []
377
+ for name, thought in self.thoughts.items():
378
+ model, _ = creature_engine_label(name)
379
+ # Tag the model that produced the thought so the contrast between a
380
+ # 20B's reasoning and a 0.5B's terse output reads as five models, not
381
+ # writing variety. Omit the tag on the unknown/dummy path.
382
+ tag = f" `[{model}]`" if model else ""
383
+ lines.append(f"{narrate.avatar(name)} **{name}**{tag}: {thought}")
384
+ return "\n\n".join(lines)
385
+
386
+ def raw_output_markdown(self, cap: int = 1200) -> str:
387
+ """The raw JSON each model emitted this turn, for a "is this scripted?" check.
388
+
389
+ Reads the policy's per-creature raw completions (the same duck-typed
390
+ `.state["raw"]` traces use) and shows them verbatim in fenced blocks,
391
+ tagged by model. This is the falsifiable artifact: a judge can see real,
392
+ slightly-malformed model output, proving the agents genuinely decide.
393
+
394
+ Firewall: this is the model's OWN completion. truth and legend_key are
395
+ never in any prompt (bound server-side), so a model cannot echo them. The
396
+ text is shown in a fenced block and Gradio sanitizes HTML on render; we
397
+ additionally neutralize any stray code fence so odd output can't break out
398
+ and inject markup. Do NOT screen this for the substrings "true"/"false":
399
+ those are ordinary JSON booleans, not the hidden tip-truth flag.
400
+ """
401
+ state = getattr(self.policy, "state", None)
402
+ if not state or not state.get("raw"):
403
+ return "_No raw model output yet. Press **Step (live council)**._"
404
+ blocks = []
405
+ for name, raw in state["raw"].items():
406
+ model, _ = creature_engine_label(name)
407
+ tag = f" [{model}]" if model else ""
408
+ text = (raw or "").strip()
409
+ if len(text) > cap:
410
+ text = text[:cap] + "\n... (truncated)"
411
+ text = text.replace("```", "'''") # cannot break out of the fence
412
+ blocks.append(f"**{narrate.avatar(name)} {name}{tag}**\n```json\n{text}\n```")
413
+ return "\n\n".join(blocks)
414
 
415
  def legend_markdown(self) -> str:
416
  lg = self.current_legend
 
427
  return "\n\n".join(f"- {line}" for line in self.ticker[-limit:][::-1])
428
 
429
  def chapter_markdown(self) -> str:
430
+ """The authored arc as a horizontal stage tracker, current chapter lit.
431
+
432
+ Returns HTML (the panel is a gr.HTML): one pill per chapter, the active
433
+ one filled and bold with an accent underline, past ones solid-muted,
434
+ future ones faint, plus the active subtitle below. Designed to read under
435
+ the panel's forced dark-text rule, so the active state is signaled by
436
+ background, weight, and underline, never by light text (which the
437
+ id-scoped !important rule would override anyway). Lets a muted-video judge
438
+ follow the crime arc (whispers -> heat -> inquiry -> reckoning) at a glance.
439
+ """
440
+ cur = min(self._chapter_idx, len(chapters.CHAPTERS) - 1)
441
+ pills = []
442
+ for i, (name, _sub) in enumerate(chapters.CHAPTERS):
443
+ short = name.split(":", 1)[1].strip() if ":" in name else name
444
+ if i == cur:
445
+ style = (
446
+ "background:#cfe8d2;font-weight:700;"
447
+ "border:1px solid #4f9d52;border-bottom:3px solid #2f6b3d;"
448
+ )
449
+ elif i < cur:
450
+ style = "background:#e6dfcd;border:1px solid #d8ccab;"
451
+ else:
452
+ style = "background:#f3eddc;border:1px dashed #ddd0aa;"
453
+ pills.append(
454
+ "<span style=\"display:inline-block;padding:4px 10px;margin:2px;"
455
+ f"border-radius:10px;font-size:11.5px;color:#2e2519;{style}\">"
456
+ f"{html.escape(short)}</span>"
457
+ )
458
+ connector = "<span style=\"color:#9c8f6a;font-size:11px;\"> β€Ί </span>"
459
+ _name, sub = chapters.CHAPTERS[cur]
460
+ return (
461
+ "<div style=\"display:flex;flex-wrap:wrap;align-items:center;\">"
462
+ + connector.join(pills)
463
+ + "</div>"
464
+ "<div style=\"margin-top:5px;font-size:12px;color:#6b5d3e;font-style:italic;\">"
465
+ f"{html.escape(sub)}</div>"
466
+ )
467
 
468
  def journal_markdown(self, limit: int = 14) -> str:
469
  """The Wood Street Journal feed: headline + dialogue over the ticker body.
ttw/narrate.py CHANGED
@@ -137,7 +137,12 @@ def _narrate_bankruptcy(event: dict) -> str:
137
  def _narrate_suspicious_win(event: dict) -> str:
138
  """One ticker line for a Magistrate-flagged well-timed Patron win."""
139
  if event.get("reason") == "short_before_legend":
140
- return f"πŸ”₯ A suspicious win: the Patron shorted {event.get('good')} right before the legend struck."
 
 
 
 
 
141
  return f"πŸ”₯ A suspicious win: the Patron's {event.get('good')} short crashed on cue."
142
 
143
 
 
137
  def _narrate_suspicious_win(event: dict) -> str:
138
  """One ticker line for a Magistrate-flagged well-timed Patron win."""
139
  if event.get("reason") == "short_before_legend":
140
+ pnl = event.get("pnl")
141
+ gain = f" The gambit pays {pnl:+d}p." if isinstance(pnl, int) else ""
142
+ return (
143
+ f"πŸ”₯ A suspicious win: the Patron shorted {event.get('good')} "
144
+ f"right before the legend struck.{gain}"
145
+ )
146
  return f"πŸ”₯ A suspicious win: the Patron's {event.get('good')} short crashed on cue."
147
 
148
 
ttw/sim.py CHANGED
@@ -337,11 +337,12 @@ def _settle_suspicious_wins(world: WorldState, price_before: dict[str, float]) -
337
  if not short.open or short.good != legend_good:
338
  continue
339
  patron.heat += moves.HEAT_SHORT_BEFORE_LEGEND
340
- patron.realized_pnl += short_unrealized_pnl(short, world.last_price)
 
341
  short.open = False
342
  events.append(
343
  {"type": "suspicious_win", "turn": world.turn, "good": legend_good,
344
- "reason": "short_before_legend"}
345
  )
346
  world.pending_legend = None
347
 
 
337
  if not short.open or short.good != legend_good:
338
  continue
339
  patron.heat += moves.HEAT_SHORT_BEFORE_LEGEND
340
+ pnl = short_unrealized_pnl(short, world.last_price)
341
+ patron.realized_pnl += pnl
342
  short.open = False
343
  events.append(
344
  {"type": "suspicious_win", "turn": world.turn, "good": legend_good,
345
+ "reason": "short_before_legend", "pnl": pnl}
346
  )
347
  world.pending_legend = None
348