nishtha711 commited on
Commit
34953a7
·
verified ·
1 Parent(s): 271552a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +512 -242
app.py CHANGED
@@ -4,6 +4,7 @@ Persistent woodland civilisation simulation powered by Qwen2.5-1.5B.
4
  Build Small Hackathon 2026 — Thousand Token Wood track.
5
  Model: Qwen/Qwen2.5-1.5B-Instruct (≤4B → Tiny Titan badge!)
6
  """
 
7
  # ═══════════════════════════════════════════════════════════════════
8
  # 0 ▸ IMPORTS
9
  # ═══════════════════════════════════════════════════════════════════
@@ -17,13 +18,17 @@ import database
17
 
18
  try:
19
  import spaces
 
20
  _ZERO_GPU = True
21
  except ImportError:
 
22
  class _Stub:
23
  @staticmethod
24
  def GPU(fn=None, *, duration=120):
25
  return fn if callable(fn) else (lambda f: f)
26
- spaces = _Stub(); _ZERO_GPU = False # type: ignore
 
 
27
 
28
  import torch
29
  from transformers import pipeline as hf_pipeline
@@ -31,30 +36,43 @@ from transformers import pipeline as hf_pipeline
31
  # ═══════════════════════════════════════════════════════════════════
32
  # 1 ▸ CONSTANTS
33
  # ═══════════════════════════════════════════════════════════════════
34
- CREATURES = ["fox", "badger", "squirrel", "mole"]
35
- EVENT_TYPES = ["trade", "gossip", "feud", "invention", "discovery", "ceremony"]
36
- CREATURE_EMOJI = {"fox":"🦊","badger":"🦡","squirrel":"🐿️","mole":"🐀"}
37
 
38
  # Model — 1.5B primary → 3B fallback. BOTH qualify for ≤4B Tiny Titan badge!
39
- MODEL_PRIMARY = "Qwen/Qwen2.5-1.5B-Instruct"
40
  MODEL_FALLBACK = "Qwen/Qwen2.5-3B-Instruct"
41
 
42
- REL_DELTAS = {"trade":+5,"gossip":-4,"feud":-10,"invention":+7,"discovery":+6,"ceremony":+3}
 
 
 
 
 
 
 
43
 
44
  REL_TIERS = [
45
- (0, 25, "Sworn Enemies", "⚔️"),
46
- (26, 40, "Very Suspicious", "🦔"),
47
- (41, 55, "Wary Acquaintances", "🤝"),
48
- (56, 70, "Friendly", "🌰"),
49
- (71, 85, "Close Companions", "🌿"),
50
- (86, 100, "Inseparable", "🍄"),
51
  ]
52
 
53
  WEIRD_OBJECTS = [
54
- "half-eaten poem", "suspicious mushroom", "button that looks like the moon",
55
- "forgotten birthday", "three secrets", "a fake acorn",
56
- "map to somewhere that may not exist", "extremely formal apology note",
57
- "small jar of preserved thunder", "second-hand prophecy",
 
 
 
 
 
 
58
  ]
59
 
60
  LAWS = [
@@ -135,28 +153,50 @@ NARRATOR_PROMPT = (
135
  "MOLE: One cryptic sentence."
136
  )
137
 
138
- # ═══════════════════════════════════════════════════════════════════
139
- # 3 MODEL (lazy-loaded inside @spaces.GPU context)
140
- # ═══════════════════════════════════════════════════════════════════
141
- _pipe = None
 
 
 
 
 
 
 
 
 
 
 
 
142
  _model_id_used = "none"
143
 
 
 
 
 
 
 
 
 
 
144
 
145
- def _load_pipeline() -> None:
146
- global _pipe, _model_id_used
147
- if _pipe is not None:
148
  return
149
  for mid in (MODEL_PRIMARY, MODEL_FALLBACK):
150
  try:
151
- print(f"[TinyC] Loading {mid} ", flush=True)
152
- _pipe = hf_pipeline(
153
- "text-generation", model=mid,
154
- torch_dtype=torch.float16,
 
155
  device_map="auto",
156
  trust_remote_code=True,
157
  )
158
  _model_id_used = mid
159
- print(f"[TinyC] {mid} ready", flush=True)
160
  return
161
  except Exception as e:
162
  print(f"[TinyC] {mid} failed: {e}", flush=True)
@@ -164,22 +204,61 @@ def _load_pipeline() -> None:
164
 
165
 
166
  def _generate(system_prompt: str, user_prompt: str, max_new_tokens: int = 160) -> str:
167
- assert _pipe is not None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
168
  try:
169
- out = _pipe(
170
- [{"role":"system","content":system_prompt},{"role":"user","content":user_prompt}],
171
  max_new_tokens=max_new_tokens,
172
- temperature=0.88, top_p=0.92, do_sample=True,
 
 
173
  return_full_text=False,
174
  )
175
  return (out[0]["generated_text"] or "").strip()
176
  except Exception as e:
177
- print(f"[TinyC] _generate error: {e}", flush=True)
178
  return ""
179
 
180
 
181
  def call_agent(agent_name: str, context: str) -> str:
182
- result = _generate(AGENT_PROMPTS.get(agent_name, AGENT_PROMPTS["fox"]), context, 100)
 
 
183
  return result or f"{agent_name.capitalize()} had no comment at this time."
184
 
185
 
@@ -206,15 +285,19 @@ def _nudge_ctx(nudge_type, nudge_value, nudge_target, day_number):
206
 
207
  def _historical_ctx() -> str:
208
  nudges = database.get_recent_nudges(3)
209
- if not nudges: return ""
210
- lines = [f"Day {n['day_number']} {n['nudge_type']}: {n['nudge_value']}" for n in nudges]
 
 
 
211
  return "Recent influences: " + "; ".join(lines)
212
 
213
 
214
  def _past_headline_ctx() -> str:
215
  """Include last 2 headlines so newspaper can reference history."""
216
  hl = database.get_all_headlines()
217
- if len(hl) < 2: return ""
 
218
  recent = hl[:2]
219
  return "Past headlines: " + " | ".join(f"Day {d}: {h[:40]}" for d, h in recent)
220
 
@@ -265,41 +348,51 @@ def _parse_newspaper_full(raw: str, day_number: int) -> dict:
265
 
266
 
267
  def _run_simulation_step(nudge_type, nudge_value, nudge_target):
268
- """Core simulation — runs within GPU context."""
269
- _load_pipeline()
270
  day_number = database.get_next_day_number()
271
- creatures = database.get_all_creatures()
272
 
273
  current_nudge = _nudge_ctx(nudge_type, nudge_value, nudge_target, day_number)
274
- historical = _historical_ctx()
275
- past_hl = _past_headline_ctx()
276
- combined_ctx = " | ".join(filter(None, [current_nudge, historical]))
277
 
278
  # ── Generate 3 events ─────────────────────────────────────────
279
  event_records: list[dict] = []
280
  for _ in range(3):
281
- actor = random.choice(CREATURES)
282
  target = random.choice([c for c in CREATURES if c != actor])
283
- etype = random.choice(EVENT_TYPES)
284
- rel = next((c for c in creatures if c["name"]==actor), {}).get(
285
- "relationship_scores", {}).get(target, 50)
 
 
 
286
 
287
  prompts = {
288
- "trade": f"Propose a trade with {target} (relationship {rel}/100). Be specific about what you're offering.",
289
- "gossip": f"Share gossip about {target} (relationship {rel}/100). Make it wonderfully absurd.",
290
- "feud": f"Describe your current feud with {target} (relationship {rel}/100). It must be about something trivial.",
291
  "invention": f"You've invented something that involves {target} somehow. Describe your invention.",
292
  "discovery": f"You've discovered something surprising about {target} or near them. What did you find?",
293
- "ceremony": f"You're organising a ceremony and {target} must be involved. Describe it.",
294
  }
295
  agent_prompt = prompts[etype]
296
- if combined_ctx: agent_prompt += f"\n\nWorld context: {combined_ctx}"
 
297
 
298
  description = call_agent(actor, agent_prompt)
299
  if not description or len(description) < 8:
300
  description = f"{actor.capitalize()} had a {etype} with {target}."
301
 
302
- event_records.append({"actor":actor,"action":etype,"target":target,"description":description})
 
 
 
 
 
 
 
303
  database.save_event(day_number, actor, etype, target, description)
304
 
305
  # ── Update relationships ──────────────────────────────────
@@ -308,11 +401,11 @@ def _run_simulation_step(nudge_type, nudge_value, nudge_target):
308
  for c in creatures:
309
  if c["name"] == actor:
310
  sc = c["relationship_scores"]
311
- sc[target] = max(0, min(100, sc.get(target,50) + delta))
312
  database.update_creature(actor, relationship_scores=sc)
313
  if c["name"] == target:
314
  sc = c["relationship_scores"]
315
- sc[actor] = max(0, min(100, sc.get(actor,50) + delta//2))
316
  database.update_creature(target, relationship_scores=sc)
317
  creatures = database.get_all_creatures()
318
 
@@ -322,8 +415,10 @@ def _run_simulation_step(nudge_type, nudge_value, nudge_target):
322
  for e in event_records
323
  )
324
  extra = ""
325
- if combined_ctx: extra += f"\nWorld context: {combined_ctx}"
326
- if past_hl: extra += f"\n{past_hl}"
 
 
327
 
328
  raw_paper = _generate(
329
  NARRATOR_PROMPT,
@@ -345,122 +440,180 @@ def _run_simulation_step(nudge_type, nudge_value, nudge_target):
345
  parsed = _parse_newspaper_full(raw_paper, day_number)
346
  classified = random.choice(CLASSIFIEDS)
347
 
348
- full_text = "\n\n".join([
349
- parsed["headline"],
350
- parsed["article"],
351
- f"WEATHER: {parsed['weather']}",
352
- "\n".join(f"{c.upper()}: {t}" for c, t in parsed["briefs"].items()),
353
- f"CLASSIFIEDS: {classified}",
354
- ])
 
 
355
 
356
  database.save_day(day_number, parsed["headline"], full_text)
357
  return day_number, parsed, classified
358
 
359
 
360
- # ═══════════════════════════════════════════════════════════════════
361
- # 5 ▸ ZERОГPU WRAPPER
362
- # ═══════════════════════════════════════════════════════════════════
363
- @spaces.GPU(duration=120)
364
- def advance_day(nudge_type=None, nudge_value=None, nudge_target=None):
365
- """Public GPU entry point — 1.5B model needs ≤60s on T4."""
366
- return _run_simulation_step(nudge_type, nudge_value, nudge_target)
 
 
 
367
 
368
 
369
  # ═══════════════════════════════════════════════════════════════════
370
  # 6 ▸ PIL NEWSPAPER IMAGE (enhanced)
371
  # ═══════════════════════════════════════════════════════════════════
372
- _SERIF_B = ["/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
373
- "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf"]
374
- _SERIF_R = ["/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
375
- "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf"]
 
 
 
 
 
376
 
377
  def _tf(paths, sz):
378
  for p in paths:
379
- try: return ImageFont.truetype(p, sz)
380
- except: pass
 
 
381
  return ImageFont.load_default()
382
 
 
383
  def render_newspaper_image(parsed: dict, classified: str, day_number: int) -> str:
384
  W, H = 960, 720
385
- PAPER=(245,232,200); INK=(20,8,2); BORDER=(70,40,8); SUBINK=(90,55,25); GREY=(130,100,70)
386
-
387
- img = Image.new("RGB",(W,H),PAPER)
388
- d = ImageDraw.Draw(img)
389
-
390
- f_mast = _tf(_SERIF_B, 30); f_hed = _tf(_SERIF_B, 20)
391
- f_body = _tf(_SERIF_R, 12); f_sm = _tf(_SERIF_R, 10)
392
- f_sub = _tf(_SERIF_R, 11); f_bold = _tf(_SERIF_B, 12)
 
 
 
 
 
 
 
393
 
394
  M = 18
395
- d.rectangle([M,M,W-M,H-M], outline=BORDER, width=3)
396
- d.rectangle([M+6,M+6,W-M-6,H-M-6], outline=BORDER, width=1)
397
 
398
  y = M + 14
399
  # Masthead
400
  mast = "THE TINYWICK HOLLOW GAZETTE"
401
- bb = d.textbbox((0,0),mast,font=f_mast); tw=bb[2]-bb[0]
402
- d.text(((W-tw)/2, y), mast, fill=INK, font=f_mast); y += bb[3]-bb[1]+3
 
 
403
  # Sub
404
  sub = f"Est. Day 1 ✦ Day {day_number} ✦ One Acorn ✦ Woodland Readers Only"
405
- bb=d.textbbox((0,0),sub,font=f_sm); d.text(((W-(bb[2]-bb[0]))/2,y),sub,fill=SUBINK,font=f_sm)
406
- y+=bb[3]-bb[1]+5
 
407
  # Weather strip
408
- wx = parsed.get("weather","Overcast.")
409
  wx_line = f"☁ WEATHER: {wx}"
410
- bb=d.textbbox((0,0),wx_line,font=f_sm); d.text(((W-(bb[2]-bb[0]))/2,y),wx_line,fill=GREY,font=f_sm)
411
- y+=bb[3]-bb[1]+4
412
- d.line([M+10,y,W-M-10,y],fill=BORDER,width=2)
413
- d.line([M+10,y+4,W-M-10,y+4],fill=BORDER,width=1); y+=14
 
 
414
 
415
  # Headline
416
- for line in textwrap.wrap(parsed.get("headline",""),width=50):
417
- bb=d.textbbox((0,0),line,font=f_hed); d.text(((W-(bb[2]-bb[0]))/2,y),line,fill=INK,font=f_hed)
418
- y+=bb[3]-bb[1]+2
419
- y+=4; d.line([M+10,y,W-M-10,y],fill=BORDER,width=1); y+=10
 
 
 
420
 
421
  # Two-column article + sidebar
422
- PAD=M+12; COL_GAP=24; SIDE_W=220
423
- main_w = W - 2*PAD - COL_GAP - SIDE_W
424
- half_w = (main_w-COL_GAP)//2
425
- col1_x=PAD; col2_x=PAD+half_w+COL_GAP; side_x=PAD+main_w+COL_GAP
426
- LH=15; MAX_Y=H-M-50; art_y=y
427
-
428
- art_lines = textwrap.wrap(parsed.get("article",""), width=38)
429
- mid = max(1,len(art_lines)//2)
430
- ly=art_y
 
 
 
 
 
 
431
  for line in art_lines[:mid]:
432
- if ly+LH>MAX_Y: break
433
- d.text((col1_x,ly),line,fill=INK,font=f_body); ly+=LH
434
- d.line([col1_x+half_w+COL_GAP//2,art_y,col1_x+half_w+COL_GAP//2,min(ly,MAX_Y)],fill=GREY,width=1)
435
- ry=art_y
 
 
 
 
 
 
 
 
 
 
 
436
  for line in art_lines[mid:]:
437
- if ry+LH>MAX_Y: break
438
- d.text((col2_x,ry),line,fill=INK,font=f_body); ry+=LH
 
 
439
 
440
  # Sidebar: In Brief
441
- d.line([side_x-8,art_y,side_x-8,MAX_Y],fill=BORDER,width=1)
442
- sy=art_y
443
- d.text((side_x,sy),"IN BRIEF",fill=INK,font=f_bold); sy+=16
444
- d.line([side_x,sy,W-M-14,sy],fill=GREY,width=1); sy+=6
445
- briefs = parsed.get("briefs",{})
 
 
446
  for cname in CREATURES:
447
- em=CREATURE_EMOJI.get(cname,"?"); txt=briefs.get(cname,"")
448
- header=f"{em} {cname.upper()}"
449
- d.text((side_x,sy),header,fill=INK,font=f_bold); sy+=13
450
- for bline in textwrap.wrap(txt,width=26):
451
- if sy+12>MAX_Y: break
452
- d.text((side_x,sy),bline,fill=INK,font=f_sm); sy+=12
453
- sy+=4
 
 
 
 
454
 
455
  # Classifieds footer
456
- fy=H-M-38; d.line([M+10,fy,W-M-10,fy],fill=BORDER,width=1); fy+=4
457
- d.text((M+14,fy),"CLASSIFIEDS",fill=INK,font=f_bold); fy+=14
458
- for cl in textwrap.wrap(classified,width=100):
459
- if fy+12>H-M-8: break
460
- d.text((M+14,fy),cl,fill=INK,font=f_sm); fy+=12
461
-
462
- path=f"/tmp/tinywick_day_{day_number}.png"
463
- img.save(path,"PNG")
 
 
 
 
 
464
  return path
465
 
466
 
@@ -485,11 +638,13 @@ def export_agent_traces() -> str:
485
  headlines = database.get_all_headlines()
486
  for dn, _ in reversed(headlines):
487
  day = database.get_day(dn)
488
- if day: trace["days"].append(dict(day))
 
489
  evts = database.get_events_for_day(dn)
490
  trace["events"].extend([{"day": dn, **e} for e in evts])
491
  path = "/tmp/tiny_civ_traces.json"
492
- with open(path,"w") as f: json.dump(trace, f, indent=2, default=str)
 
493
  return path
494
 
495
 
@@ -659,21 +814,23 @@ KONAMI_JS = """<script>
659
  # ═══════════════════════════════════════════════════════════════════
660
  def _rel_tier(score: int) -> tuple[str, str]:
661
  for lo, hi, label, icon in REL_TIERS:
662
- if lo <= score <= hi: return label, icon
 
663
  return "Unknown", "?"
664
 
 
665
  def _html_paper(parsed: dict, classified: str, day_num: int) -> str:
666
- hed = (parsed.get("headline","") or "").replace("<","&lt;")
667
- art = (parsed.get("article","") or "").replace("<","&lt;")
668
- wx = (parsed.get("weather","") or "").replace("<","&lt;")
669
- briefs = parsed.get("briefs",{})
670
  emojis = " ".join(f"{CREATURE_EMOJI[c]} {c.capitalize()}" for c in CREATURES)
671
- classified_esc = classified.replace("<","&lt;")
672
 
673
  sidebar_items = ""
674
  for c in CREATURES:
675
- em = CREATURE_EMOJI.get(c,"?")
676
- txt = (briefs.get(c,"") or "").replace("<","&lt;")
677
  sidebar_items += f"""
678
  <div class="sidebar-item">
679
  <div class="sidebar-creature-name">{em} {c.upper()}</div>
@@ -700,21 +857,24 @@ def _html_paper(parsed: dict, classified: str, day_num: int) -> str:
700
  </div>
701
  """
702
 
 
703
  def _html_placeholder() -> str:
704
  founding = database.get_day(0)
705
  if founding:
706
- text = founding["full_newspaper_text"]
707
- parts = text.split("\n\n",1)
708
- hed = parts[0]; art = parts[1] if len(parts)>1 else text
 
709
  dummy_parsed = {
710
- "headline": hed, "article": art,
 
711
  "weather": "Portentous, with scattered significance.",
712
  "briefs": {
713
  "fox": "Forged three certificates before breakfast.",
714
  "badger": "Insisted on thirteen amendments before lunch.",
715
  "squirrel": "Invented a signing machine! It signed the wrong document!",
716
  "mole": "Something is already happening underground.",
717
- }
718
  }
719
  return _html_paper(dummy_parsed, "NOTICE: Civilisation now in progress.", 0)
720
  return """<div class="paper-wrap">
@@ -726,31 +886,36 @@ def _html_placeholder() -> str:
726
  <div class="paper-daybadge">— Day 0 —</div>
727
  </div>"""
728
 
 
729
  def _html_creatures() -> str:
730
  creatures = database.get_all_creatures()
731
  cards = ""
732
  for c in creatures:
733
- em = CREATURE_EMOJI.get(c["name"],"?")
734
- inv = (", ".join(c["inventory"][:3]) + ("…" if len(c["inventory"])>3 else "")) or "nothing"
 
 
735
  rels = ""
736
  for other, score in sorted(c["relationship_scores"].items()):
737
  label, icon = _rel_tier(score)
738
- rels += f'<span title="{label} ({score})">{CREATURE_EMOJI.get(other,"?")} {icon}</span> '
739
  cards += f"""<div class="creature-card">
740
- <span class="creature-name">{em} {c['name'].capitalize()}</span>
741
  <div class="creature-tier">{rels}</div>
742
  <div style="font-size:.78em;margin-top:3px;color:#5a3615;"><em>Carries:</em> {inv}</div>
743
  </div>"""
744
  return f'<div class="creature-grid">{cards}</div>'
745
 
 
746
  def _html_civ_stats() -> str:
747
  s = database.get_civ_stats()
748
- bp = s["best_pair"]; wp = s["worst_pair"]
 
749
  dom = s["dominant"].capitalize()
750
  return f"""<div class="civ-stats">
751
- <div class="stat-item"><span class="stat-label">Days</span><span class="stat-value">{s['total_days']}</span></div>
752
- <div class="stat-item"><span class="stat-label">Events</span><span class="stat-value">{s['total_events']}</span></div>
753
- <div class="stat-item"><span class="stat-label">Nudges</span><span class="stat-value">{s['total_nudges']}</span></div>
754
  <div class="stat-item"><span class="stat-label">Strongest bond</span>
755
  <span class="stat-value">{bp[0].capitalize()} &amp; {bp[1].capitalize()} ({bp[2]})</span></div>
756
  <div class="stat-item"><span class="stat-label">Bitterest feud</span>
@@ -758,18 +923,25 @@ def _html_civ_stats() -> str:
758
  <div class="stat-item"><span class="stat-label">Most popular</span><span class="stat-value">{dom}</span></div>
759
  </div>"""
760
 
 
761
  def _archive_choices():
762
  hl = database.get_all_headlines()
763
- return [(f"Day {dn}: {h[:44]}{'…' if len(h)>44 else ''}", dn) for dn,h in hl] or []
 
 
 
 
 
 
764
 
765
- def _status(msg): return f'<div class="status-strip">{msg}</div>'
766
 
767
  def _konami_html() -> str:
768
  import html as _h
 
769
  details = "".join(
770
- f"<details><summary>{CREATURE_EMOJI.get(n,'')}<strong> {n.upper()}</strong></summary>"
771
  f"<pre>{_h.escape(p)}</pre></details>"
772
- for n,p in AGENT_PROMPTS.items()
773
  )
774
  details += f"<details><summary>📰 <strong>NARRATOR</strong></summary><pre>{_h.escape(NARRATOR_PROMPT)}</pre></details>"
775
  return f"""
@@ -794,58 +966,95 @@ def _render_all(day_num, parsed, classified, status_msg):
794
  _html_civ_stats(),
795
  gr.update(choices=_archive_choices(), value=None),
796
  _status(status_msg),
797
- day_num, parsed, classified,
 
 
798
  )
799
 
 
800
  def _safe_advance(nudge_type=None, nudge_value=None, nudge_target=None):
801
  try:
802
  day_num, parsed, classified = advance_day(nudge_type, nudge_value, nudge_target)
803
- model_tag = f" [{_model_id_used.split('/')[-1]}]" if _model_id_used!="none" else ""
804
- return _render_all(day_num, parsed, classified,
805
- f"✓ Day {day_num} published to the Gazette.{model_tag}")
 
 
 
 
 
 
806
  except Exception:
807
  tb = traceback.format_exc()
808
  print(tb)
809
  err_parsed = {
810
  "headline": "THE GAZETTE'S PRINTING PRESS HAS JAMMED",
811
  "article": "Our correspondents report a technical malfunction. The editor is inconsolable. "
812
- "Beatrice Badger suspects sabotage. Reginald Fox denies everything.",
813
  "weather": "Stormy, with a chance of errors.",
814
  "briefs": {c: "Unavailable for comment." for c in CREATURES},
815
  }
816
- return _render_all(0, err_parsed, "LOST: One functioning simulation. — The Editor",
817
- "✗ Simulation error — the printing press is jammed. Check logs.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
818
 
819
- def handle_advance(): return _safe_advance()
820
- def handle_rumour(c,r): return _safe_advance("rumour",r,c)
821
- def handle_donation(o): return _safe_advance("donation",o)
822
- def handle_law(l): return _safe_advance("law",l)
823
 
824
  def handle_archive_view(day_num):
825
  if day_num is None:
826
- return '<div class="archive-area"><em>Select a day to read its edition.</em></div>'
 
 
827
  day = database.get_day(int(day_num))
828
- if not day: return '<div class="archive-area"><em>Edition not found.</em></div>'
829
- txt = day["full_newspaper_text"]; parts = txt.split("\n\n",1)
830
- hed = parts[0]; art = parts[1] if len(parts)>1 else txt
 
 
 
831
  # Reconstruct parsed from stored text
832
  parsed = _parse_newspaper_full(txt, day_num)
833
  classified_match = re.search(r"CLASSIFIEDS:\s*(.+)", txt)
834
  cl = classified_match.group(1) if classified_match else random.choice(CLASSIFIEDS)
835
  return f'<div class="archive-area">{_html_paper(parsed, cl, day_num)}</div>'
836
 
 
837
  def handle_share(day_num_state, parsed_state, classified_state):
838
- if not parsed_state or not parsed_state.get("headline"): return gr.update(visible=False)
 
839
  try:
840
- path = render_newspaper_image(parsed_state, classified_state or "", day_num_state or 0)
 
 
841
  return gr.update(visible=True, value=path)
842
- except Exception: return gr.update(visible=False)
 
 
843
 
844
  def handle_export_traces():
845
  try:
846
  path = export_agent_traces()
847
  return gr.update(visible=True, value=path)
848
- except Exception: return gr.update(visible=False)
 
849
 
850
 
851
  # =================================================================
@@ -856,108 +1065,169 @@ def handle_export_traces():
856
  database.init_db()
857
  _latest = database.get_latest_day()
858
  if _latest:
859
- _INIT_DAY = _latest["day_number"]
860
  _INIT_PARSED = _parse_newspaper_full(_latest["full_newspaper_text"], _INIT_DAY)
861
- _cl_m = re.search(r"CLASSIFIEDS:\s*(.+)", _latest["full_newspaper_text"])
862
- _INIT_CL = _cl_m.group(1) if _cl_m else random.choice(CLASSIFIEDS)
863
  else:
864
- _INIT_DAY = 0; _INIT_PARSED = {}; _INIT_CL = ""
 
 
865
 
866
  # -- Gradio version-aware css/theme routing -----------------------
867
  _GR_MAJOR = int(gr.__version__.split(".")[0])
868
- if _GR_MAJOR >= 6: _BKW: dict = {}; _LKW: dict = {"css": NEWSPAPER_CSS}
869
- elif _GR_MAJOR >= 5: _BKW = {"css": NEWSPAPER_CSS}; _LKW = {}
870
- else: _BKW = {"css": NEWSPAPER_CSS, "theme": gr.themes.Base(primary_hue="orange", neutral_hue="stone")}; _LKW = {}
 
 
 
 
 
 
 
 
 
871
 
872
  # ------------------------------------------------------------------
873
  with gr.Blocks(title="Tiny Civilization — The Tinywick Hollow Gazette", **_BKW) as demo:
874
-
875
  gr.HTML(_konami_html())
876
 
877
  gr.HTML(
878
  '<div style="text-align:center;padding:8px 0 2px;">'
879
- '<h1 style="font-family:\'Playfair Display\',Georgia,serif;color:#160800;font-size:1.9em;margin:0 0 2px;">'
880
- '🦊 Tiny Civilization 🐀</h1>'
881
  '<p style="font-family:Georgia,serif;color:#5a3615;font-style:italic;margin:0;font-size:.87em;">'
882
- 'A persistent woodland civilisation. One day. One acorn. One absurd headline at a time.'
883
  '&nbsp;|&nbsp;<kbd title="Konami Code">↑↑↓↓←→←→BA</kbd> for secrets.'
884
- '</p></div>'
885
  )
886
 
887
- day_state = gr.State(_INIT_DAY)
888
- parsed_state = gr.State(_INIT_PARSED)
889
  classif_state = gr.State(_INIT_CL)
890
 
891
- status_html = gr.HTML(value=_status(
892
- f"Day {_INIT_DAY} in the archive — next: Day {_INIT_DAY + 1}."
893
- if _INIT_DAY >= 0 else "No days yet. Press Advance Day to begin."))
 
 
 
 
894
  civ_stats_html = gr.HTML(value=_html_civ_stats())
895
 
896
  with gr.Row(equal_height=False):
897
-
898
  with gr.Column(scale=3):
899
  newspaper_display = gr.HTML(
900
- value=(_html_paper(_INIT_PARSED, _INIT_CL, _INIT_DAY)
901
- if _INIT_PARSED else _html_placeholder()))
 
 
 
 
902
 
903
- with gr.Accordion('📜 Archive — Past Editions', open=False):
904
  archive_dd = gr.Dropdown(
905
- choices=_archive_choices(), value=None,
906
- label='Select a past day', container=False)
 
 
 
907
  archive_display = gr.HTML(
908
- value='<div class="archive-area"><em>Select a day to read its edition.</em></div>')
 
909
 
910
  with gr.Column(scale=1, min_width=260):
911
  gr.HTML('<div class="section-title">📰 Editorial Desk</div>')
912
- advance_btn = gr.Button('📅 Advance Day (no nudge)', variant='primary', size='lg')
 
 
913
 
914
  gr.HTML('<hr style="border-color:#8a6030;margin:8px 0;">')
915
  gr.HTML('<div class="section-title">✉ Nudge the Story</div>')
916
- gr.HTML('<p style="font-size:.78em;color:#5a3615;text-align:center;'
917
- 'font-style:italic;margin:0 0 6px;">Each nudge advances one day.</p>')
918
-
919
- with gr.Accordion('🗣️ Spread a Rumour', open=False):
920
- rumour_creature = gr.Dropdown(choices=CREATURES, value=CREATURES[0],
921
- label='About which creature?')
922
- rumour_type_dd = gr.Dropdown(choices=RUMOUR_TYPES, value=RUMOUR_TYPES[0],
923
- label='What rumour?')
924
- rumour_btn = gr.Button('📢 Spread It', variant='secondary')
925
-
926
- with gr.Accordion('🎁 Donate a Weird Object', open=False):
927
- donation_dd = gr.Dropdown(choices=WEIRD_OBJECTS, value=WEIRD_OBJECTS[0],
928
- label='Which object?')
929
- donation_btn = gr.Button('🎁 Donate It', variant='secondary')
930
 
931
- with gr.Accordion('⚖Propose a New Law', open=False):
932
- law_dd = gr.Dropdown(choices=LAWS, value=LAWS[0], label='Which law?')
933
- law_btn = gr.Button('⚖️ Propose It', variant='secondary')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
934
 
935
  gr.HTML('<hr style="border-color:#8a6030;margin:8px 0;">')
936
- share_btn = gr.Button('🖼️ Share as Image', variant='secondary')
937
- img_output = gr.Image(label='Front Page PNG', visible=False, type='filepath')
938
- export_btn = gr.Button('📡 Export Agent Traces (JSON)', variant='secondary')
939
- trace_output = gr.File(label='Agent Traces JSON', visible=False)
 
 
940
 
941
- gr.HTML('<p style="font-size:.70em;color:#6a4818;text-align:center;'
942
- 'margin-top:8px;font-style:italic;">'
943
- 'Model: Qwen2.5-1.5B ≤4B 🐜&nbsp;|&nbsp;Local only 🔌&nbsp;|&nbsp;Custom UI 🎨</p>')
 
 
944
 
945
- gr.HTML('<div class="section-title" style="margin-top:12px;">Woodland Residents</div>')
 
 
946
  creature_display = gr.HTML(value=_html_creatures())
947
 
948
- _OUT = [newspaper_display, creature_display, civ_stats_html,
949
- archive_dd, status_html, day_state, parsed_state, classif_state]
950
-
951
- advance_btn.click( fn=handle_advance, inputs=[], outputs=_OUT, api_name=False)
952
- rumour_btn.click( fn=handle_rumour, inputs=[rumour_creature, rumour_type_dd], outputs=_OUT, api_name=False)
953
- donation_btn.click(fn=handle_donation, inputs=[donation_dd], outputs=_OUT, api_name=False)
954
- law_btn.click( fn=handle_law, inputs=[law_dd], outputs=_OUT, api_name=False)
955
- archive_dd.change( fn=handle_archive_view, inputs=[archive_dd], outputs=[archive_display], api_name=False)
956
- share_btn.click( fn=handle_share, inputs=[day_state, parsed_state, classif_state], outputs=[img_output], api_name=False)
957
- export_btn.click( fn=handle_export_traces, inputs=[], outputs=[trace_output], api_name=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
958
 
959
  # ═══════════════════════════════════════════════════════════════════
960
  # 13 ▸ ENTRY POINT
961
  # ═══════════════════════════════════════════════════════════════════
962
  if __name__ == "__main__":
963
- demo.launch(server_name="0.0.0.0", server_port=7860, **_LKW)
 
4
  Build Small Hackathon 2026 — Thousand Token Wood track.
5
  Model: Qwen/Qwen2.5-1.5B-Instruct (≤4B → Tiny Titan badge!)
6
  """
7
+
8
  # ═══════════════════════════════════════════════════════════════════
9
  # 0 ▸ IMPORTS
10
  # ═══════════════════════════════════════════════════════════════════
 
18
 
19
  try:
20
  import spaces
21
+
22
  _ZERO_GPU = True
23
  except ImportError:
24
+
25
  class _Stub:
26
  @staticmethod
27
  def GPU(fn=None, *, duration=120):
28
  return fn if callable(fn) else (lambda f: f)
29
+
30
+ spaces = _Stub()
31
+ _ZERO_GPU = False # type: ignore
32
 
33
  import torch
34
  from transformers import pipeline as hf_pipeline
 
36
  # ═══════════════════════════════════════════════════════════════════
37
  # 1 ▸ CONSTANTS
38
  # ═══════════════════════════════════════════════════════════════════
39
+ CREATURES = ["fox", "badger", "squirrel", "mole"]
40
+ EVENT_TYPES = ["trade", "gossip", "feud", "invention", "discovery", "ceremony"]
41
+ CREATURE_EMOJI = {"fox": "🦊", "badger": "🦡", "squirrel": "🐿️", "mole": "🐀"}
42
 
43
  # Model — 1.5B primary → 3B fallback. BOTH qualify for ≤4B Tiny Titan badge!
44
+ MODEL_PRIMARY = "Qwen/Qwen2.5-1.5B-Instruct"
45
  MODEL_FALLBACK = "Qwen/Qwen2.5-3B-Instruct"
46
 
47
+ REL_DELTAS = {
48
+ "trade": +5,
49
+ "gossip": -4,
50
+ "feud": -10,
51
+ "invention": +7,
52
+ "discovery": +6,
53
+ "ceremony": +3,
54
+ }
55
 
56
  REL_TIERS = [
57
+ (0, 25, "Sworn Enemies", "⚔️"),
58
+ (26, 40, "Very Suspicious", "🦔"),
59
+ (41, 55, "Wary Acquaintances", "🤝"),
60
+ (56, 70, "Friendly", "🌰"),
61
+ (71, 85, "Close Companions", "🌿"),
62
+ (86, 100, "Inseparable", "🍄"),
63
  ]
64
 
65
  WEIRD_OBJECTS = [
66
+ "half-eaten poem",
67
+ "suspicious mushroom",
68
+ "button that looks like the moon",
69
+ "forgotten birthday",
70
+ "three secrets",
71
+ "a fake acorn",
72
+ "map to somewhere that may not exist",
73
+ "extremely formal apology note",
74
+ "small jar of preserved thunder",
75
+ "second-hand prophecy",
76
  ]
77
 
78
  LAWS = [
 
153
  "MOLE: One cryptic sentence."
154
  )
155
 
156
+
157
+ import os as _os
158
+ import requests as _req
159
+ from huggingface_hub import InferenceClient as _InfClient
160
+
161
+ _MODAL_URL = _os.getenv("MODAL_INFERENCE_URL", "").strip()
162
+ _HF_TOKEN = _os.getenv("HF_TOKEN", "").strip()
163
+
164
+ _USE_MODAL = bool(_MODAL_URL)
165
+ _USE_HF_API = bool(_HF_TOKEN) and not _USE_MODAL
166
+ _USE_LOCAL = not _USE_MODAL and not _USE_HF_API
167
+
168
+ _hf_client = (
169
+ _InfClient(provider="hf-inference", api_key=_HF_TOKEN) if _USE_HF_API else None
170
+ )
171
+ _local_pipe = None
172
  _model_id_used = "none"
173
 
174
+ _BACKEND = (
175
+ "Modal"
176
+ if _USE_MODAL
177
+ else "HF Inference API [no quota!]"
178
+ if _USE_HF_API
179
+ else "Local ZeroGPU [set HF_TOKEN to remove quota limit]"
180
+ )
181
+ print(f"[TinyC] Backend: {_BACKEND}", flush=True)
182
+
183
 
184
+ def _load_local_pipeline() -> None:
185
+ global _local_pipe, _model_id_used
186
+ if _local_pipe is not None:
187
  return
188
  for mid in (MODEL_PRIMARY, MODEL_FALLBACK):
189
  try:
190
+ print(f"[TinyC] Loading {mid} ...", flush=True)
191
+ _local_pipe = hf_pipeline(
192
+ "text-generation",
193
+ model=mid,
194
+ dtype=torch.float16,
195
  device_map="auto",
196
  trust_remote_code=True,
197
  )
198
  _model_id_used = mid
199
+ print(f"[TinyC] {mid} ready", flush=True)
200
  return
201
  except Exception as e:
202
  print(f"[TinyC] {mid} failed: {e}", flush=True)
 
204
 
205
 
206
  def _generate(system_prompt: str, user_prompt: str, max_new_tokens: int = 160) -> str:
207
+ messages = [
208
+ {"role": "system", "content": system_prompt},
209
+ {"role": "user", "content": user_prompt},
210
+ ]
211
+ if _USE_MODAL:
212
+ try:
213
+ r = _req.post(
214
+ _MODAL_URL,
215
+ json={
216
+ "messages": messages,
217
+ "max_new_tokens": max_new_tokens,
218
+ "temperature": 0.88,
219
+ "top_p": 0.92,
220
+ },
221
+ timeout=90,
222
+ )
223
+ r.raise_for_status()
224
+ d = r.json()
225
+ return (d.get("text") or d.get("generated_text") or "").strip()
226
+ except Exception as e:
227
+ print(f"[TinyC] Modal error: {e}", flush=True)
228
+ return ""
229
+ if _USE_HF_API:
230
+ try:
231
+ resp = _hf_client.chat.completions.create(
232
+ model=MODEL_PRIMARY,
233
+ messages=messages,
234
+ max_tokens=max_new_tokens,
235
+ temperature=0.88,
236
+ top_p=0.92,
237
+ )
238
+ return (resp.choices[0].message.content or "").strip()
239
+ except Exception as e:
240
+ print(f"[TinyC] HF API error: {e}", flush=True)
241
+ return ""
242
+ assert _local_pipe is not None
243
  try:
244
+ out = _local_pipe(
245
+ messages,
246
  max_new_tokens=max_new_tokens,
247
+ temperature=0.88,
248
+ top_p=0.92,
249
+ do_sample=True,
250
  return_full_text=False,
251
  )
252
  return (out[0]["generated_text"] or "").strip()
253
  except Exception as e:
254
+ print(f"[TinyC] local pipe error: {e}", flush=True)
255
  return ""
256
 
257
 
258
  def call_agent(agent_name: str, context: str) -> str:
259
+ result = _generate(
260
+ AGENT_PROMPTS.get(agent_name, AGENT_PROMPTS["fox"]), context, 100
261
+ )
262
  return result or f"{agent_name.capitalize()} had no comment at this time."
263
 
264
 
 
285
 
286
  def _historical_ctx() -> str:
287
  nudges = database.get_recent_nudges(3)
288
+ if not nudges:
289
+ return ""
290
+ lines = [
291
+ f"Day {n['day_number']} {n['nudge_type']}: {n['nudge_value']}" for n in nudges
292
+ ]
293
  return "Recent influences: " + "; ".join(lines)
294
 
295
 
296
  def _past_headline_ctx() -> str:
297
  """Include last 2 headlines so newspaper can reference history."""
298
  hl = database.get_all_headlines()
299
+ if len(hl) < 2:
300
+ return ""
301
  recent = hl[:2]
302
  return "Past headlines: " + " | ".join(f"Day {d}: {h[:40]}" for d, h in recent)
303
 
 
348
 
349
 
350
  def _run_simulation_step(nudge_type, nudge_value, nudge_target):
351
+ """Core simulation — backend-agnostic."""
 
352
  day_number = database.get_next_day_number()
353
+ creatures = database.get_all_creatures()
354
 
355
  current_nudge = _nudge_ctx(nudge_type, nudge_value, nudge_target, day_number)
356
+ historical = _historical_ctx()
357
+ past_hl = _past_headline_ctx()
358
+ combined_ctx = " | ".join(filter(None, [current_nudge, historical]))
359
 
360
  # ── Generate 3 events ─────────────────────────────────────────
361
  event_records: list[dict] = []
362
  for _ in range(3):
363
+ actor = random.choice(CREATURES)
364
  target = random.choice([c for c in CREATURES if c != actor])
365
+ etype = random.choice(EVENT_TYPES)
366
+ rel = (
367
+ next((c for c in creatures if c["name"] == actor), {})
368
+ .get("relationship_scores", {})
369
+ .get(target, 50)
370
+ )
371
 
372
  prompts = {
373
+ "trade": f"Propose a trade with {target} (relationship {rel}/100). Be specific about what you're offering.",
374
+ "gossip": f"Share gossip about {target} (relationship {rel}/100). Make it wonderfully absurd.",
375
+ "feud": f"Describe your current feud with {target} (relationship {rel}/100). It must be about something trivial.",
376
  "invention": f"You've invented something that involves {target} somehow. Describe your invention.",
377
  "discovery": f"You've discovered something surprising about {target} or near them. What did you find?",
378
+ "ceremony": f"You're organising a ceremony and {target} must be involved. Describe it.",
379
  }
380
  agent_prompt = prompts[etype]
381
+ if combined_ctx:
382
+ agent_prompt += f"\n\nWorld context: {combined_ctx}"
383
 
384
  description = call_agent(actor, agent_prompt)
385
  if not description or len(description) < 8:
386
  description = f"{actor.capitalize()} had a {etype} with {target}."
387
 
388
+ event_records.append(
389
+ {
390
+ "actor": actor,
391
+ "action": etype,
392
+ "target": target,
393
+ "description": description,
394
+ }
395
+ )
396
  database.save_event(day_number, actor, etype, target, description)
397
 
398
  # ── Update relationships ──────────────────────────────────
 
401
  for c in creatures:
402
  if c["name"] == actor:
403
  sc = c["relationship_scores"]
404
+ sc[target] = max(0, min(100, sc.get(target, 50) + delta))
405
  database.update_creature(actor, relationship_scores=sc)
406
  if c["name"] == target:
407
  sc = c["relationship_scores"]
408
+ sc[actor] = max(0, min(100, sc.get(actor, 50) + delta // 2))
409
  database.update_creature(target, relationship_scores=sc)
410
  creatures = database.get_all_creatures()
411
 
 
415
  for e in event_records
416
  )
417
  extra = ""
418
+ if combined_ctx:
419
+ extra += f"\nWorld context: {combined_ctx}"
420
+ if past_hl:
421
+ extra += f"\n{past_hl}"
422
 
423
  raw_paper = _generate(
424
  NARRATOR_PROMPT,
 
440
  parsed = _parse_newspaper_full(raw_paper, day_number)
441
  classified = random.choice(CLASSIFIEDS)
442
 
443
+ full_text = "\n\n".join(
444
+ [
445
+ parsed["headline"],
446
+ parsed["article"],
447
+ f"WEATHER: {parsed['weather']}",
448
+ "\n".join(f"{c.upper()}: {t}" for c, t in parsed["briefs"].items()),
449
+ f"CLASSIFIEDS: {classified}",
450
+ ]
451
+ )
452
 
453
  database.save_day(day_number, parsed["headline"], full_text)
454
  return day_number, parsed, classified
455
 
456
 
457
+ if _USE_LOCAL:
458
+
459
+ @spaces.GPU(duration=60)
460
+ def advance_day(nudge_type=None, nudge_value=None, nudge_target=None):
461
+ _load_local_pipeline()
462
+ return _run_simulation_step(nudge_type, nudge_value, nudge_target)
463
+ else:
464
+
465
+ def advance_day(nudge_type=None, nudge_value=None, nudge_target=None):
466
+ return _run_simulation_step(nudge_type, nudge_value, nudge_target)
467
 
468
 
469
  # ═══════════════════════════════════════════════════════════════════
470
  # 6 ▸ PIL NEWSPAPER IMAGE (enhanced)
471
  # ═══════════════════════════════════════════════════════════════════
472
+ _SERIF_B = [
473
+ "/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
474
+ "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
475
+ ]
476
+ _SERIF_R = [
477
+ "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
478
+ "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
479
+ ]
480
+
481
 
482
  def _tf(paths, sz):
483
  for p in paths:
484
+ try:
485
+ return ImageFont.truetype(p, sz)
486
+ except:
487
+ pass
488
  return ImageFont.load_default()
489
 
490
+
491
  def render_newspaper_image(parsed: dict, classified: str, day_number: int) -> str:
492
  W, H = 960, 720
493
+ PAPER = (245, 232, 200)
494
+ INK = (20, 8, 2)
495
+ BORDER = (70, 40, 8)
496
+ SUBINK = (90, 55, 25)
497
+ GREY = (130, 100, 70)
498
+
499
+ img = Image.new("RGB", (W, H), PAPER)
500
+ d = ImageDraw.Draw(img)
501
+
502
+ f_mast = _tf(_SERIF_B, 30)
503
+ f_hed = _tf(_SERIF_B, 20)
504
+ f_body = _tf(_SERIF_R, 12)
505
+ f_sm = _tf(_SERIF_R, 10)
506
+ f_sub = _tf(_SERIF_R, 11)
507
+ f_bold = _tf(_SERIF_B, 12)
508
 
509
  M = 18
510
+ d.rectangle([M, M, W - M, H - M], outline=BORDER, width=3)
511
+ d.rectangle([M + 6, M + 6, W - M - 6, H - M - 6], outline=BORDER, width=1)
512
 
513
  y = M + 14
514
  # Masthead
515
  mast = "THE TINYWICK HOLLOW GAZETTE"
516
+ bb = d.textbbox((0, 0), mast, font=f_mast)
517
+ tw = bb[2] - bb[0]
518
+ d.text(((W - tw) / 2, y), mast, fill=INK, font=f_mast)
519
+ y += bb[3] - bb[1] + 3
520
  # Sub
521
  sub = f"Est. Day 1 ✦ Day {day_number} ✦ One Acorn ✦ Woodland Readers Only"
522
+ bb = d.textbbox((0, 0), sub, font=f_sm)
523
+ d.text(((W - (bb[2] - bb[0])) / 2, y), sub, fill=SUBINK, font=f_sm)
524
+ y += bb[3] - bb[1] + 5
525
  # Weather strip
526
+ wx = parsed.get("weather", "Overcast.")
527
  wx_line = f"☁ WEATHER: {wx}"
528
+ bb = d.textbbox((0, 0), wx_line, font=f_sm)
529
+ d.text(((W - (bb[2] - bb[0])) / 2, y), wx_line, fill=GREY, font=f_sm)
530
+ y += bb[3] - bb[1] + 4
531
+ d.line([M + 10, y, W - M - 10, y], fill=BORDER, width=2)
532
+ d.line([M + 10, y + 4, W - M - 10, y + 4], fill=BORDER, width=1)
533
+ y += 14
534
 
535
  # Headline
536
+ for line in textwrap.wrap(parsed.get("headline", ""), width=50):
537
+ bb = d.textbbox((0, 0), line, font=f_hed)
538
+ d.text(((W - (bb[2] - bb[0])) / 2, y), line, fill=INK, font=f_hed)
539
+ y += bb[3] - bb[1] + 2
540
+ y += 4
541
+ d.line([M + 10, y, W - M - 10, y], fill=BORDER, width=1)
542
+ y += 10
543
 
544
  # Two-column article + sidebar
545
+ PAD = M + 12
546
+ COL_GAP = 24
547
+ SIDE_W = 220
548
+ main_w = W - 2 * PAD - COL_GAP - SIDE_W
549
+ half_w = (main_w - COL_GAP) // 2
550
+ col1_x = PAD
551
+ col2_x = PAD + half_w + COL_GAP
552
+ side_x = PAD + main_w + COL_GAP
553
+ LH = 15
554
+ MAX_Y = H - M - 50
555
+ art_y = y
556
+
557
+ art_lines = textwrap.wrap(parsed.get("article", ""), width=38)
558
+ mid = max(1, len(art_lines) // 2)
559
+ ly = art_y
560
  for line in art_lines[:mid]:
561
+ if ly + LH > MAX_Y:
562
+ break
563
+ d.text((col1_x, ly), line, fill=INK, font=f_body)
564
+ ly += LH
565
+ d.line(
566
+ [
567
+ col1_x + half_w + COL_GAP // 2,
568
+ art_y,
569
+ col1_x + half_w + COL_GAP // 2,
570
+ min(ly, MAX_Y),
571
+ ],
572
+ fill=GREY,
573
+ width=1,
574
+ )
575
+ ry = art_y
576
  for line in art_lines[mid:]:
577
+ if ry + LH > MAX_Y:
578
+ break
579
+ d.text((col2_x, ry), line, fill=INK, font=f_body)
580
+ ry += LH
581
 
582
  # Sidebar: In Brief
583
+ d.line([side_x - 8, art_y, side_x - 8, MAX_Y], fill=BORDER, width=1)
584
+ sy = art_y
585
+ d.text((side_x, sy), "IN BRIEF", fill=INK, font=f_bold)
586
+ sy += 16
587
+ d.line([side_x, sy, W - M - 14, sy], fill=GREY, width=1)
588
+ sy += 6
589
+ briefs = parsed.get("briefs", {})
590
  for cname in CREATURES:
591
+ em = CREATURE_EMOJI.get(cname, "?")
592
+ txt = briefs.get(cname, "")
593
+ header = f"{em} {cname.upper()}"
594
+ d.text((side_x, sy), header, fill=INK, font=f_bold)
595
+ sy += 13
596
+ for bline in textwrap.wrap(txt, width=26):
597
+ if sy + 12 > MAX_Y:
598
+ break
599
+ d.text((side_x, sy), bline, fill=INK, font=f_sm)
600
+ sy += 12
601
+ sy += 4
602
 
603
  # Classifieds footer
604
+ fy = H - M - 38
605
+ d.line([M + 10, fy, W - M - 10, fy], fill=BORDER, width=1)
606
+ fy += 4
607
+ d.text((M + 14, fy), "CLASSIFIEDS", fill=INK, font=f_bold)
608
+ fy += 14
609
+ for cl in textwrap.wrap(classified, width=100):
610
+ if fy + 12 > H - M - 8:
611
+ break
612
+ d.text((M + 14, fy), cl, fill=INK, font=f_sm)
613
+ fy += 12
614
+
615
+ path = f"/tmp/tinywick_day_{day_number}.png"
616
+ img.save(path, "PNG")
617
  return path
618
 
619
 
 
638
  headlines = database.get_all_headlines()
639
  for dn, _ in reversed(headlines):
640
  day = database.get_day(dn)
641
+ if day:
642
+ trace["days"].append(dict(day))
643
  evts = database.get_events_for_day(dn)
644
  trace["events"].extend([{"day": dn, **e} for e in evts])
645
  path = "/tmp/tiny_civ_traces.json"
646
+ with open(path, "w") as f:
647
+ json.dump(trace, f, indent=2, default=str)
648
  return path
649
 
650
 
 
814
  # ═══════════════════════════════════════════════════════════════════
815
  def _rel_tier(score: int) -> tuple[str, str]:
816
  for lo, hi, label, icon in REL_TIERS:
817
+ if lo <= score <= hi:
818
+ return label, icon
819
  return "Unknown", "?"
820
 
821
+
822
  def _html_paper(parsed: dict, classified: str, day_num: int) -> str:
823
+ hed = (parsed.get("headline", "") or "").replace("<", "&lt;")
824
+ art = (parsed.get("article", "") or "").replace("<", "&lt;")
825
+ wx = (parsed.get("weather", "") or "").replace("<", "&lt;")
826
+ briefs = parsed.get("briefs", {})
827
  emojis = " ".join(f"{CREATURE_EMOJI[c]} {c.capitalize()}" for c in CREATURES)
828
+ classified_esc = classified.replace("<", "&lt;")
829
 
830
  sidebar_items = ""
831
  for c in CREATURES:
832
+ em = CREATURE_EMOJI.get(c, "?")
833
+ txt = (briefs.get(c, "") or "").replace("<", "&lt;")
834
  sidebar_items += f"""
835
  <div class="sidebar-item">
836
  <div class="sidebar-creature-name">{em} {c.upper()}</div>
 
857
  </div>
858
  """
859
 
860
+
861
  def _html_placeholder() -> str:
862
  founding = database.get_day(0)
863
  if founding:
864
+ text = founding["full_newspaper_text"]
865
+ parts = text.split("\n\n", 1)
866
+ hed = parts[0]
867
+ art = parts[1] if len(parts) > 1 else text
868
  dummy_parsed = {
869
+ "headline": hed,
870
+ "article": art,
871
  "weather": "Portentous, with scattered significance.",
872
  "briefs": {
873
  "fox": "Forged three certificates before breakfast.",
874
  "badger": "Insisted on thirteen amendments before lunch.",
875
  "squirrel": "Invented a signing machine! It signed the wrong document!",
876
  "mole": "Something is already happening underground.",
877
+ },
878
  }
879
  return _html_paper(dummy_parsed, "NOTICE: Civilisation now in progress.", 0)
880
  return """<div class="paper-wrap">
 
886
  <div class="paper-daybadge">— Day 0 —</div>
887
  </div>"""
888
 
889
+
890
  def _html_creatures() -> str:
891
  creatures = database.get_all_creatures()
892
  cards = ""
893
  for c in creatures:
894
+ em = CREATURE_EMOJI.get(c["name"], "?")
895
+ inv = (
896
+ ", ".join(c["inventory"][:3]) + ("…" if len(c["inventory"]) > 3 else "")
897
+ ) or "nothing"
898
  rels = ""
899
  for other, score in sorted(c["relationship_scores"].items()):
900
  label, icon = _rel_tier(score)
901
+ rels += f'<span title="{label} ({score})">{CREATURE_EMOJI.get(other, "?")} {icon}</span> '
902
  cards += f"""<div class="creature-card">
903
+ <span class="creature-name">{em} {c["name"].capitalize()}</span>
904
  <div class="creature-tier">{rels}</div>
905
  <div style="font-size:.78em;margin-top:3px;color:#5a3615;"><em>Carries:</em> {inv}</div>
906
  </div>"""
907
  return f'<div class="creature-grid">{cards}</div>'
908
 
909
+
910
  def _html_civ_stats() -> str:
911
  s = database.get_civ_stats()
912
+ bp = s["best_pair"]
913
+ wp = s["worst_pair"]
914
  dom = s["dominant"].capitalize()
915
  return f"""<div class="civ-stats">
916
+ <div class="stat-item"><span class="stat-label">Days</span><span class="stat-value">{s["total_days"]}</span></div>
917
+ <div class="stat-item"><span class="stat-label">Events</span><span class="stat-value">{s["total_events"]}</span></div>
918
+ <div class="stat-item"><span class="stat-label">Nudges</span><span class="stat-value">{s["total_nudges"]}</span></div>
919
  <div class="stat-item"><span class="stat-label">Strongest bond</span>
920
  <span class="stat-value">{bp[0].capitalize()} &amp; {bp[1].capitalize()} ({bp[2]})</span></div>
921
  <div class="stat-item"><span class="stat-label">Bitterest feud</span>
 
923
  <div class="stat-item"><span class="stat-label">Most popular</span><span class="stat-value">{dom}</span></div>
924
  </div>"""
925
 
926
+
927
  def _archive_choices():
928
  hl = database.get_all_headlines()
929
+ return [
930
+ (f"Day {dn}: {h[:44]}{'…' if len(h) > 44 else ''}", dn) for dn, h in hl
931
+ ] or []
932
+
933
+
934
+ def _status(msg):
935
+ return f'<div class="status-strip">{msg}</div>'
936
 
 
937
 
938
  def _konami_html() -> str:
939
  import html as _h
940
+
941
  details = "".join(
942
+ f"<details><summary>{CREATURE_EMOJI.get(n, '')}<strong> {n.upper()}</strong></summary>"
943
  f"<pre>{_h.escape(p)}</pre></details>"
944
+ for n, p in AGENT_PROMPTS.items()
945
  )
946
  details += f"<details><summary>📰 <strong>NARRATOR</strong></summary><pre>{_h.escape(NARRATOR_PROMPT)}</pre></details>"
947
  return f"""
 
966
  _html_civ_stats(),
967
  gr.update(choices=_archive_choices(), value=None),
968
  _status(status_msg),
969
+ day_num,
970
+ parsed,
971
+ classified,
972
  )
973
 
974
+
975
  def _safe_advance(nudge_type=None, nudge_value=None, nudge_target=None):
976
  try:
977
  day_num, parsed, classified = advance_day(nudge_type, nudge_value, nudge_target)
978
+ model_tag = (
979
+ f" [{_model_id_used.split('/')[-1]}]" if _model_id_used != "none" else ""
980
+ )
981
+ return _render_all(
982
+ day_num,
983
+ parsed,
984
+ classified,
985
+ f"✓ Day {day_num} published to the Gazette.{model_tag}",
986
+ )
987
  except Exception:
988
  tb = traceback.format_exc()
989
  print(tb)
990
  err_parsed = {
991
  "headline": "THE GAZETTE'S PRINTING PRESS HAS JAMMED",
992
  "article": "Our correspondents report a technical malfunction. The editor is inconsolable. "
993
+ "Beatrice Badger suspects sabotage. Reginald Fox denies everything.",
994
  "weather": "Stormy, with a chance of errors.",
995
  "briefs": {c: "Unavailable for comment." for c in CREATURES},
996
  }
997
+ return _render_all(
998
+ 0,
999
+ err_parsed,
1000
+ "LOST: One functioning simulation. — The Editor",
1001
+ "✗ Simulation error — the printing press is jammed. Check logs.",
1002
+ )
1003
+
1004
+
1005
+ def handle_advance():
1006
+ return _safe_advance()
1007
+
1008
+
1009
+ def handle_rumour(c, r):
1010
+ return _safe_advance("rumour", r, c)
1011
+
1012
+
1013
+ def handle_donation(o):
1014
+ return _safe_advance("donation", o)
1015
+
1016
+
1017
+ def handle_law(l):
1018
+ return _safe_advance("law", l)
1019
 
 
 
 
 
1020
 
1021
  def handle_archive_view(day_num):
1022
  if day_num is None:
1023
+ return (
1024
+ '<div class="archive-area"><em>Select a day to read its edition.</em></div>'
1025
+ )
1026
  day = database.get_day(int(day_num))
1027
+ if not day:
1028
+ return '<div class="archive-area"><em>Edition not found.</em></div>'
1029
+ txt = day["full_newspaper_text"]
1030
+ parts = txt.split("\n\n", 1)
1031
+ hed = parts[0]
1032
+ art = parts[1] if len(parts) > 1 else txt
1033
  # Reconstruct parsed from stored text
1034
  parsed = _parse_newspaper_full(txt, day_num)
1035
  classified_match = re.search(r"CLASSIFIEDS:\s*(.+)", txt)
1036
  cl = classified_match.group(1) if classified_match else random.choice(CLASSIFIEDS)
1037
  return f'<div class="archive-area">{_html_paper(parsed, cl, day_num)}</div>'
1038
 
1039
+
1040
  def handle_share(day_num_state, parsed_state, classified_state):
1041
+ if not parsed_state or not parsed_state.get("headline"):
1042
+ return gr.update(visible=False)
1043
  try:
1044
+ path = render_newspaper_image(
1045
+ parsed_state, classified_state or "", day_num_state or 0
1046
+ )
1047
  return gr.update(visible=True, value=path)
1048
+ except Exception:
1049
+ return gr.update(visible=False)
1050
+
1051
 
1052
  def handle_export_traces():
1053
  try:
1054
  path = export_agent_traces()
1055
  return gr.update(visible=True, value=path)
1056
+ except Exception:
1057
+ return gr.update(visible=False)
1058
 
1059
 
1060
  # =================================================================
 
1065
  database.init_db()
1066
  _latest = database.get_latest_day()
1067
  if _latest:
1068
+ _INIT_DAY = _latest["day_number"]
1069
  _INIT_PARSED = _parse_newspaper_full(_latest["full_newspaper_text"], _INIT_DAY)
1070
+ _cl_m = re.search(r"CLASSIFIEDS:\s*(.+)", _latest["full_newspaper_text"])
1071
+ _INIT_CL = _cl_m.group(1) if _cl_m else random.choice(CLASSIFIEDS)
1072
  else:
1073
+ _INIT_DAY = 0
1074
+ _INIT_PARSED = {}
1075
+ _INIT_CL = ""
1076
 
1077
  # -- Gradio version-aware css/theme routing -----------------------
1078
  _GR_MAJOR = int(gr.__version__.split(".")[0])
1079
+ if _GR_MAJOR >= 6:
1080
+ _BKW: dict = {}
1081
+ _LKW: dict = {"css": NEWSPAPER_CSS}
1082
+ elif _GR_MAJOR >= 5:
1083
+ _BKW = {"css": NEWSPAPER_CSS}
1084
+ _LKW = {}
1085
+ else:
1086
+ _BKW = {
1087
+ "css": NEWSPAPER_CSS,
1088
+ "theme": gr.themes.Base(primary_hue="orange", neutral_hue="stone"),
1089
+ }
1090
+ _LKW = {}
1091
 
1092
  # ------------------------------------------------------------------
1093
  with gr.Blocks(title="Tiny Civilization — The Tinywick Hollow Gazette", **_BKW) as demo:
 
1094
  gr.HTML(_konami_html())
1095
 
1096
  gr.HTML(
1097
  '<div style="text-align:center;padding:8px 0 2px;">'
1098
+ "<h1 style=\"font-family:'Playfair Display',Georgia,serif;color:#160800;font-size:1.9em;margin:0 0 2px;\">"
1099
+ "🦊 Tiny Civilization 🐀</h1>"
1100
  '<p style="font-family:Georgia,serif;color:#5a3615;font-style:italic;margin:0;font-size:.87em;">'
1101
+ "A persistent woodland civilisation. One day. One acorn. One absurd headline at a time."
1102
  '&nbsp;|&nbsp;<kbd title="Konami Code">↑↑↓↓←→←→BA</kbd> for secrets.'
1103
+ "</p></div>"
1104
  )
1105
 
1106
+ day_state = gr.State(_INIT_DAY)
1107
+ parsed_state = gr.State(_INIT_PARSED)
1108
  classif_state = gr.State(_INIT_CL)
1109
 
1110
+ status_html = gr.HTML(
1111
+ value=_status(
1112
+ f"Day {_INIT_DAY} in the archive next: Day {_INIT_DAY + 1}."
1113
+ if _INIT_DAY >= 0
1114
+ else "No days yet. Press Advance Day to begin."
1115
+ )
1116
+ )
1117
  civ_stats_html = gr.HTML(value=_html_civ_stats())
1118
 
1119
  with gr.Row(equal_height=False):
 
1120
  with gr.Column(scale=3):
1121
  newspaper_display = gr.HTML(
1122
+ value=(
1123
+ _html_paper(_INIT_PARSED, _INIT_CL, _INIT_DAY)
1124
+ if _INIT_PARSED
1125
+ else _html_placeholder()
1126
+ )
1127
+ )
1128
 
1129
+ with gr.Accordion("📜 Archive — Past Editions", open=False):
1130
  archive_dd = gr.Dropdown(
1131
+ choices=_archive_choices(),
1132
+ value=None,
1133
+ label="Select a past day",
1134
+ container=False,
1135
+ )
1136
  archive_display = gr.HTML(
1137
+ value='<div class="archive-area"><em>Select a day to read its edition.</em></div>'
1138
+ )
1139
 
1140
  with gr.Column(scale=1, min_width=260):
1141
  gr.HTML('<div class="section-title">📰 Editorial Desk</div>')
1142
+ advance_btn = gr.Button(
1143
+ "📅 Advance Day (no nudge)", variant="primary", size="lg"
1144
+ )
1145
 
1146
  gr.HTML('<hr style="border-color:#8a6030;margin:8px 0;">')
1147
  gr.HTML('<div class="section-title">✉ Nudge the Story</div>')
1148
+ gr.HTML(
1149
+ '<p style="font-size:.78em;color:#5a3615;text-align:center;'
1150
+ 'font-style:italic;margin:0 0 6px;">Each nudge advances one day.</p>'
1151
+ )
 
 
 
 
 
 
 
 
 
 
1152
 
1153
+ with gr.Accordion("🗣Spread a Rumour", open=False):
1154
+ rumour_creature = gr.Dropdown(
1155
+ choices=CREATURES, value=CREATURES[0], label="About which creature?"
1156
+ )
1157
+ rumour_type_dd = gr.Dropdown(
1158
+ choices=RUMOUR_TYPES, value=RUMOUR_TYPES[0], label="What rumour?"
1159
+ )
1160
+ rumour_btn = gr.Button("📢 Spread It", variant="secondary")
1161
+
1162
+ with gr.Accordion("🎁 Donate a Weird Object", open=False):
1163
+ donation_dd = gr.Dropdown(
1164
+ choices=WEIRD_OBJECTS, value=WEIRD_OBJECTS[0], label="Which object?"
1165
+ )
1166
+ donation_btn = gr.Button("🎁 Donate It", variant="secondary")
1167
+
1168
+ with gr.Accordion("⚖️ Propose a New Law", open=False):
1169
+ law_dd = gr.Dropdown(choices=LAWS, value=LAWS[0], label="Which law?")
1170
+ law_btn = gr.Button("⚖️ Propose It", variant="secondary")
1171
 
1172
  gr.HTML('<hr style="border-color:#8a6030;margin:8px 0;">')
1173
+ share_btn = gr.Button("🖼️ Share as Image", variant="secondary")
1174
+ img_output = gr.Image(
1175
+ label="Front Page PNG", visible=False, type="filepath"
1176
+ )
1177
+ export_btn = gr.Button("📡 Export Agent Traces (JSON)", variant="secondary")
1178
+ trace_output = gr.File(label="Agent Traces JSON", visible=False)
1179
 
1180
+ gr.HTML(
1181
+ '<p style="font-size:.70em;color:#6a4818;text-align:center;'
1182
+ 'margin-top:8px;font-style:italic;">'
1183
+ "Model: Qwen2.5-1.5B ≤4B 🐜&nbsp;|&nbsp;Local only 🔌&nbsp;|&nbsp;Custom UI 🎨</p>"
1184
+ )
1185
 
1186
+ gr.HTML(
1187
+ '<div class="section-title" style="margin-top:12px;">Woodland Residents</div>'
1188
+ )
1189
  creature_display = gr.HTML(value=_html_creatures())
1190
 
1191
+ _OUT = [
1192
+ newspaper_display,
1193
+ creature_display,
1194
+ civ_stats_html,
1195
+ archive_dd,
1196
+ status_html,
1197
+ day_state,
1198
+ parsed_state,
1199
+ classif_state,
1200
+ ]
1201
+
1202
+ advance_btn.click(fn=handle_advance, inputs=[], outputs=_OUT, api_name=False)
1203
+ rumour_btn.click(
1204
+ fn=handle_rumour,
1205
+ inputs=[rumour_creature, rumour_type_dd],
1206
+ outputs=_OUT,
1207
+ api_name=False,
1208
+ )
1209
+ donation_btn.click(
1210
+ fn=handle_donation, inputs=[donation_dd], outputs=_OUT, api_name=False
1211
+ )
1212
+ law_btn.click(fn=handle_law, inputs=[law_dd], outputs=_OUT, api_name=False)
1213
+ archive_dd.change(
1214
+ fn=handle_archive_view,
1215
+ inputs=[archive_dd],
1216
+ outputs=[archive_display],
1217
+ api_name=False,
1218
+ )
1219
+ share_btn.click(
1220
+ fn=handle_share,
1221
+ inputs=[day_state, parsed_state, classif_state],
1222
+ outputs=[img_output],
1223
+ api_name=False,
1224
+ )
1225
+ export_btn.click(
1226
+ fn=handle_export_traces, inputs=[], outputs=[trace_output], api_name=False
1227
+ )
1228
 
1229
  # ═══════════════════════════════════════════════════════════════════
1230
  # 13 ▸ ENTRY POINT
1231
  # ═══════════════════════════════════════════════════════════════════
1232
  if __name__ == "__main__":
1233
+ demo.launch(server_name="0.0.0.0", server_port=7860, **_LKW)