nishtha711 commited on
Commit
5e0beb2
·
verified ·
1 Parent(s): 1556827

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +616 -662
app.py CHANGED
@@ -1,17 +1,31 @@
1
  """
2
- app.py — Tiny Civilization: The Tinywick Hollow Gazette
3
- Local Qwen2.5-1.5B model. No APIs. Persistent SQLite.
 
4
  """
5
-
 
 
6
  from __future__ import annotations
7
- import json, os, random, textwrap, traceback, re
8
  from datetime import datetime
9
  import gradio as gr
10
  from PIL import Image, ImageDraw, ImageFont
11
- import torch
12
- from transformers import AutoModelForCausalLM, AutoTokenizer
13
  import database
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  # ═══════════════════════════════════════════════════════════════════
16
  # 1 ▸ CONSTANTS
17
  # ═══════════════════════════════════════════════════════════════════
@@ -19,194 +33,140 @@ CREATURES = ["fox", "badger", "squirrel", "mole"]
19
  EVENT_TYPES = ["trade", "gossip", "feud", "invention", "discovery", "ceremony"]
20
  CREATURE_EMOJI = {"fox":"🦊","badger":"🦡","squirrel":"🐿️","mole":"🐀"}
21
 
22
- # Model — 1.5B primary → 3B fallback. BOTH qualify for ≤4B Tiny Titan badge!
23
  MODEL_PRIMARY = "Qwen/Qwen2.5-1.5B-Instruct"
24
  MODEL_FALLBACK = "Qwen/Qwen2.5-3B-Instruct"
25
 
26
  REL_DELTAS = {"trade":+5,"gossip":-4,"feud":-10,"invention":+7,"discovery":+6,"ceremony":+3}
27
-
28
- REL_TIERS = [
29
- (0, 25, "Sworn Enemies", "⚔️"),
30
- (26, 40, "Very Suspicious", "🦔"),
31
- (41, 55, "Wary Acquaintances", "🤝"),
32
- (56, 70, "Friendly", "🌰"),
33
- (71, 85, "Close Companions", "🌿"),
34
- (86, 100, "Inseparable", "🍄"),
35
  ]
36
 
37
  WEIRD_OBJECTS = [
38
- "half-eaten poem", "suspicious mushroom", "button that looks like the moon",
39
- "forgotten birthday", "three secrets", "a fake acorn",
40
- "map to somewhere that may not exist", "extremely formal apology note",
41
- "small jar of preserved thunder", "second-hand prophecy",
42
  ]
43
-
44
  LAWS = [
45
- "no trading on Tuesdays",
46
- "buttons = currency",
47
- "everyone must compliment the badger",
48
- "mushrooms are sacred",
49
- "all debts expire at midnight",
50
- "silence is legally binding",
51
- "hats must be worn ironically",
52
- "the mole's word is final (underground only)",
53
  ]
54
-
55
  RUMOUR_TYPES = [
56
  "has been secretly hoarding acorns",
57
  "was seen talking to a suspicious stranger at midnight",
58
  "invented something that does not work at all",
59
- "owes three unpayable debts",
60
- "made a deal with the rain",
61
  "owns the moon (allegedly)",
62
  "has been writing a novel about everyone in the hollow",
63
  "is not who they say they are",
64
  "has found a door underground that was not there before",
65
  "once ate an entire philosophy",
66
  ]
67
-
68
  CLASSIFIEDS = [
69
  "LOST: One certainty. If found, please do not return it. — M. Mole",
70
  "FOR SALE: Slightly used certificate of excellence. Condition: forged. — R. Fox",
71
  "NOTICE: The badger declares the east mushroom illegal pending investigation.",
72
- "WANTED: Someone to explain what happened last Tuesday. Any information welcome.",
73
  "FOUND: An unexplained event near the old oak. Owner may claim it.",
74
  "REWARD: For the return of three acorns lent in confidence. You know who you are.",
75
  "LOST: One argument. I was winning it. — B. Badger",
76
- "ANNOUNCEMENT: The squirrel's new invention works. (This is the third announcement this week.)",
 
 
 
 
 
 
 
 
 
 
 
 
77
  ]
78
 
79
  # ═══════════════════════════════════════════════════════════════════
80
- # 2 ▸ AGENT PROMPTS (shown in Konami easter egg)
81
  # ═══════════════════════════════════════════════════════════════════
82
  AGENT_PROMPTS: dict[str, str] = {
83
  "fox": (
84
- "You are Reginald Fox — charming, dishonest, and deeply fond of certificates. "
85
- "You speak formally and hint at secret arrangements. You believe everything is "
86
- "negotiable. You collect and forge official-looking documents. "
87
  "Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
88
  ),
89
  "badger": (
90
- "You are Beatrice Badger — gruff keeper of rules, deeply suspicious (especially "
91
- "of Reginald Fox), secretly a poet. Mushrooms are a serious matter. "
92
- "You speak in short declarative sentences. You are always right. "
93
  "Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
94
  ),
95
  "squirrel": (
96
  "You are Cornelius Squirrel — anxious inventor who speaks fast and repeats himself! "
97
- "You invent things that almost-but-not-quite work. You are obsessed with efficiency! "
98
- "You use exclamation points! You repeat key words for emphasis, for emphasis! "
99
  "Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
100
  ),
101
  "mole": (
102
  "You are Millicent Mole — quiet, philosophical, rarely surfaces. "
103
- "You speak in incomplete thoughts and gentle riddles. You know everyone's secrets "
104
- "but share them only obliquely. You believe all things connect underground. "
105
  "Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
106
  ),
107
  }
108
-
109
  NARRATOR_PROMPT = (
110
  "You are the pompous editor-in-chief of The Tinywick Hollow Gazette. "
111
- "You write about absurd woodland animals with the utmost journalistic gravity. "
112
- "Respond in this EXACT format (no deviations, no extra text):\n\n"
113
  "HEADLINE IN ALL CAPS\n\n"
114
- "Three to four sentences of pompous newspaper prose about today's events.\n\n"
115
- "WEATHER: One-sentence absurd woodland forecast.\n"
116
  "FOX: One sentence about the fox's day.\n"
117
  "BADGER: One sentence about the badger's day.\n"
118
  "SQUIRREL: One sentence about the squirrel's day.\n"
119
  "MOLE: One cryptic sentence."
120
  )
121
 
 
 
 
 
 
122
 
123
- import os as _os
124
- import requests as _req
125
- from huggingface_hub import InferenceClient as _InfClient
126
-
127
- _MODAL_URL = (_os.getenv('MODAL_INFERENCE_URL') or _os.getenv('MODAL_URL') or '').strip()
128
- _HF_TOKEN = _os.getenv('HF_TOKEN', '').strip()
129
-
130
- _USE_MODAL = bool(_MODAL_URL)
131
- _USE_HF_API = bool(_HF_TOKEN) and not _USE_MODAL
132
- _USE_LOCAL = not _USE_MODAL and not _USE_HF_API
133
-
134
- _hf_client = _InfClient(token=_HF_TOKEN) if _USE_HF_API else None
135
- _local_pipe = None
136
- _model_id_used = 'none'
137
-
138
- _BACKEND = ('Modal' if _USE_MODAL else
139
- 'HF Inference API [no quota!]' if _USE_HF_API else
140
- 'Local ZeroGPU [set HF_TOKEN to remove quota limit]')
141
- print(f'[TinyC] Backend: {_BACKEND}', flush=True)
142
-
143
-
144
- def _load_local_pipeline() -> None:
145
- global _local_pipe, _model_id_used
146
- if _local_pipe is not None:
147
- return
148
  for mid in (MODEL_PRIMARY, MODEL_FALLBACK):
149
  try:
150
- print(f'[TinyC] Loading {mid} ...', flush=True)
151
- _local_pipe = hf_pipeline(
152
- 'text-generation', model=mid,
153
- dtype=torch.float16,
154
- device_map='auto',
155
  trust_remote_code=True,
156
  )
157
  _model_id_used = mid
158
- print(f'[TinyC] {mid} ready', flush=True)
159
  return
160
  except Exception as e:
161
- print(f'[TinyC] {mid} failed: {e}', flush=True)
162
- raise RuntimeError(f'Could not load {MODEL_PRIMARY} or {MODEL_FALLBACK}.')
163
-
164
 
165
  def _generate(system_prompt: str, user_prompt: str, max_new_tokens: int = 160) -> str:
166
- messages = [
167
- {'role': 'system', 'content': system_prompt},
168
- {'role': 'user', 'content': user_prompt},
169
- ]
170
- if _USE_MODAL:
171
- try:
172
- r = _req.post(_MODAL_URL, json={'messages': messages,
173
- 'max_new_tokens': max_new_tokens, 'temperature': 0.88, 'top_p': 0.92},
174
- timeout=90)
175
- r.raise_for_status()
176
- d = r.json()
177
- return (d.get('text') or d.get('generated_text') or '').strip()
178
- except Exception as e:
179
- print(f'[TinyC] Modal error: {e}', flush=True); return ''
180
- if _USE_HF_API:
181
- for _attempt in range(2):
182
- try:
183
- if _attempt == 0: # newer OpenAI-compat path
184
- resp = _hf_client.chat.completions.create(
185
- model=MODEL_PRIMARY, messages=messages,
186
- max_tokens=max_new_tokens, temperature=0.88, top_p=0.92)
187
- else: # older path for huggingface_hub < 0.31
188
- resp = _hf_client.chat_completion(
189
- model=MODEL_PRIMARY, messages=messages,
190
- max_tokens=max_new_tokens, temperature=0.88, top_p=0.92)
191
- return (resp.choices[0].message.content or '').strip()
192
- except AttributeError:
193
- continue # try the other API
194
- except Exception as e:
195
- print(f'[TinyC] HF API error: {e}', flush=True); return ''
196
- assert _local_pipe is not None
197
  try:
198
- out = _local_pipe(messages, max_new_tokens=max_new_tokens,
199
- temperature=0.88, top_p=0.92, do_sample=True,
200
- return_full_text=False)
201
- return (out[0]['generated_text'] or '').strip()
 
 
202
  except Exception as e:
203
- print(f'[TinyC] local pipe error: {e}', flush=True); return ''
204
-
205
 
206
  def call_agent(agent_name: str, context: str) -> str:
207
- result = _generate(AGENT_PROMPTS.get(agent_name, AGENT_PROMPTS['fox']), context, 100)
208
- return result or f'{agent_name.capitalize()} had no comment at this time.'
209
-
210
 
211
  # ═══════════════════════════════════════════════════════════════════
212
  # 4 ▸ SIMULATION
@@ -216,325 +176,221 @@ def _nudge_ctx(nudge_type, nudge_value, nudge_target, day_number):
216
  database.save_nudge(day_number, "rumour", f"{nudge_target}: {nudge_value}")
217
  return f"A rumour is spreading that {nudge_target} {nudge_value}."
218
  if nudge_type == "donation" and nudge_value:
219
- recipient = random.choice(CREATURES)
220
- c = database.get_creature(recipient)
221
  if c:
222
- inv = (c["inventory"] + [nudge_value])[-12:]
223
- database.update_creature(recipient, inventory=inv)
224
- database.save_nudge(day_number, "donation", f"{recipient}: {nudge_value}")
225
- return f"Someone anonymously donated '{nudge_value}' to {recipient}."
226
  if nudge_type == "law" and nudge_value:
227
  database.save_nudge(day_number, "law", nudge_value)
228
- return f"A new law has been formally proposed: '{nudge_value}'."
229
  return ""
230
 
231
-
232
- def _historical_ctx() -> str:
233
- nudges = database.get_recent_nudges(3)
234
- if not nudges: return ""
235
- lines = [f"Day {n['day_number']} {n['nudge_type']}: {n['nudge_value']}" for n in nudges]
236
- return "Recent influences: " + "; ".join(lines)
237
-
238
-
239
- def _past_headline_ctx() -> str:
240
- """Include last 2 headlines so newspaper can reference history."""
241
- hl = database.get_all_headlines()
242
- if len(hl) < 2: return ""
243
- recent = hl[:2]
244
- return "Past headlines: " + " | ".join(f"Day {d}: {h[:40]}" for d, h in recent)
245
-
246
-
247
  def _parse_newspaper_full(raw: str, day_number: int) -> dict:
248
- """Parse the structured newspaper response into sections."""
249
- lines = [l.strip() for l in raw.strip().splitlines()]
250
- lines = [l for l in lines if l]
251
-
252
  result = {
253
  "headline": f"ANOTHER EVENTFUL DAY IN TINYWICK HOLLOW (DAY {day_number})",
254
- "article": "",
255
- "weather": "Overcast, with a chance of philosophical observations.",
256
  "briefs": {c: f"{c.capitalize()} was present." for c in CREATURES},
257
  }
258
-
259
- # Find headline (first all-caps-ish line)
260
  body_start = 0
261
  for i, line in enumerate(lines):
262
- cleaned = re.sub(r'[*_#"\']', "", line).strip()
263
  if cleaned and (cleaned == cleaned.upper() or i == 0):
264
- result["headline"] = cleaned.upper()
265
- body_start = i + 1
266
- break
267
-
268
- # Extract labelled sections
269
  article_lines = []
270
  for line in lines[body_start:]:
271
- upper = line.upper()
272
- if upper.startswith("WEATHER:"):
273
- result["weather"] = line.split(":", 1)[1].strip()
274
- elif upper.startswith("FOX:"):
275
- result["briefs"]["fox"] = line.split(":", 1)[1].strip()
276
- elif upper.startswith("BADGER:"):
277
- result["briefs"]["badger"] = line.split(":", 1)[1].strip()
278
- elif upper.startswith("SQUIRREL:"):
279
- result["briefs"]["squirrel"] = line.split(":", 1)[1].strip()
280
- elif upper.startswith("MOLE:"):
281
- result["briefs"]["mole"] = line.split(":", 1)[1].strip()
282
- else:
283
- article_lines.append(line)
284
-
285
- result["article"] = " ".join(article_lines).strip()
286
- if not result["article"]:
287
- result["article"] = raw.strip()
288
-
289
  return result
290
 
291
-
292
  def _run_simulation_step(nudge_type, nudge_value, nudge_target):
293
- """Core simulation — backend-agnostic."""
294
  day_number = database.get_next_day_number()
295
  creatures = database.get_all_creatures()
296
-
297
- current_nudge = _nudge_ctx(nudge_type, nudge_value, nudge_target, day_number)
298
- historical = _historical_ctx()
299
- past_hl = _past_headline_ctx()
300
- combined_ctx = " | ".join(filter(None, [current_nudge, historical]))
301
-
302
- # ── Generate 3 events ─────────────────────────────────────────
303
- event_records: list[dict] = []
304
  for _ in range(3):
305
  actor = random.choice(CREATURES)
306
  target = random.choice([c for c in CREATURES if c != actor])
307
  etype = random.choice(EVENT_TYPES)
308
- rel = next((c for c in creatures if c["name"]==actor), {}).get(
309
- "relationship_scores", {}).get(target, 50)
310
-
311
  prompts = {
312
- "trade": f"Propose a trade with {target} (relationship {rel}/100). Be specific about what you're offering.",
313
- "gossip": f"Share gossip about {target} (relationship {rel}/100). Make it wonderfully absurd.",
314
- "feud": f"Describe your current feud with {target} (relationship {rel}/100). It must be about something trivial.",
315
- "invention": f"You've invented something that involves {target} somehow. Describe your invention.",
316
- "discovery": f"You've discovered something surprising about {target} or near them. What did you find?",
317
- "ceremony": f"You're organising a ceremony and {target} must be involved. Describe it.",
318
  }
319
- agent_prompt = prompts[etype]
320
- if combined_ctx: agent_prompt += f"\n\nWorld context: {combined_ctx}"
321
-
322
- description = call_agent(actor, agent_prompt)
323
- if not description or len(description) < 8:
324
- description = f"{actor.capitalize()} had a {etype} with {target}."
325
-
326
- event_records.append({"actor":actor,"action":etype,"target":target,"description":description})
327
- database.save_event(day_number, actor, etype, target, description)
328
-
329
- # ── Update relationships ──────────────────────────────────
330
  delta = REL_DELTAS.get(etype, 0)
331
  creatures = database.get_all_creatures()
332
  for c in creatures:
333
  if c["name"] == actor:
334
  sc = c["relationship_scores"]
335
- sc[target] = max(0, min(100, sc.get(target,50) + delta))
336
  database.update_creature(actor, relationship_scores=sc)
337
  if c["name"] == target:
338
  sc = c["relationship_scores"]
339
- sc[actor] = max(0, min(100, sc.get(actor,50) + delta//2))
340
  database.update_creature(target, relationship_scores=sc)
341
  creatures = database.get_all_creatures()
342
 
343
- # ── Generate newspaper ────────────────────────────────────────
344
- events_summary = "\n".join(
345
- f"- {e['actor'].capitalize()} [{e['action']}] with {e['target']}: {e['description']}"
346
- for e in event_records
347
- )
348
- extra = ""
349
- if combined_ctx: extra += f"\nWorld context: {combined_ctx}"
350
- if past_hl: extra += f"\n{past_hl}"
351
-
352
- raw_paper = _generate(
353
- NARRATOR_PROMPT,
354
- f"Today's events:\n{events_summary}{extra}",
355
- max_new_tokens=300,
356
- )
357
- if not raw_paper:
358
- raw_paper = (
359
- f"CHAOS REIGNS IN TINYWICK HOLLOW ON DAY {day_number}\n\n"
360
- "The printing press has momentarily jammed. However, our sources confirm "
361
- "that things definitely happened today.\n\n"
362
- "WEATHER: Unclear, like everything else.\n"
363
- f"FOX: {event_records[0]['description'][:60]}\n"
364
- f"BADGER: {event_records[1]['description'][:60]}\n"
365
- f"SQUIRREL: {event_records[2]['description'][:60]}\n"
366
- "MOLE: Something underground."
367
- )
368
-
369
- parsed = _parse_newspaper_full(raw_paper, day_number)
370
  classified = random.choice(CLASSIFIEDS)
371
-
372
- full_text = "\n\n".join([
373
- parsed["headline"],
374
- parsed["article"],
375
- f"WEATHER: {parsed['weather']}",
376
- "\n".join(f"{c.upper()}: {t}" for c, t in parsed["briefs"].items()),
377
- f"CLASSIFIEDS: {classified}",
378
- ])
379
-
380
  database.save_day(day_number, parsed["headline"], full_text)
381
  return day_number, parsed, classified
382
 
383
-
384
-
385
- if _USE_LOCAL:
386
- @spaces.GPU(duration=60)
387
- def advance_day(nudge_type=None, nudge_value=None, nudge_target=None):
388
- _load_local_pipeline()
389
- return _run_simulation_step(nudge_type, nudge_value, nudge_target)
390
- else:
391
- def advance_day(nudge_type=None, nudge_value=None, nudge_target=None):
392
- return _run_simulation_step(nudge_type, nudge_value, nudge_target)
393
-
394
 
395
  # ═══════════════════════════════════════════════════════════════════
396
- # 6 ▸ PIL NEWSPAPER IMAGE (enhanced)
397
  # ═══════════════════════════════════════════════════════════════════
398
- _SERIF_B = ["/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
399
- "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf"]
400
- _SERIF_R = ["/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
401
- "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf"]
402
-
403
- def _tf(paths, sz):
404
- for p in paths:
405
- try: return ImageFont.truetype(p, sz)
406
  except: pass
407
  return ImageFont.load_default()
408
 
409
- def render_newspaper_image(parsed: dict, classified: str, day_number: int) -> str:
410
- W, H = 960, 720
411
- PAPER=(245,232,200); INK=(20,8,2); BORDER=(70,40,8); SUBINK=(90,55,25); GREY=(130,100,70)
412
-
413
- img = Image.new("RGB",(W,H),PAPER)
414
- d = ImageDraw.Draw(img)
415
-
416
- f_mast = _tf(_SERIF_B, 30); f_hed = _tf(_SERIF_B, 20)
417
- f_body = _tf(_SERIF_R, 12); f_sm = _tf(_SERIF_R, 10)
418
- f_sub = _tf(_SERIF_R, 11); f_bold = _tf(_SERIF_B, 12)
419
-
420
- M = 18
421
- d.rectangle([M,M,W-M,H-M], outline=BORDER, width=3)
422
- d.rectangle([M+6,M+6,W-M-6,H-M-6], outline=BORDER, width=1)
423
-
424
- y = M + 14
425
- # Masthead
426
- mast = "THE TINYWICK HOLLOW GAZETTE"
427
- bb = d.textbbox((0,0),mast,font=f_mast); tw=bb[2]-bb[0]
428
- d.text(((W-tw)/2, y), mast, fill=INK, font=f_mast); y += bb[3]-bb[1]+3
429
- # Sub
430
- sub = f"Est. Day 1 ✦ Day {day_number} ✦ One Acorn ✦ Woodland Readers Only"
431
- bb=d.textbbox((0,0),sub,font=f_sm); d.text(((W-(bb[2]-bb[0]))/2,y),sub,fill=SUBINK,font=f_sm)
432
- y+=bb[3]-bb[1]+5
433
- # Weather strip
434
- wx = parsed.get("weather","Overcast.")
435
- wx_line = f"☁ WEATHER: {wx}"
436
- 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)
437
- y+=bb[3]-bb[1]+4
438
- d.line([M+10,y,W-M-10,y],fill=BORDER,width=2)
439
- d.line([M+10,y+4,W-M-10,y+4],fill=BORDER,width=1); y+=14
440
-
441
- # Headline
442
- for line in textwrap.wrap(parsed.get("headline",""),width=50):
443
- bb=d.textbbox((0,0),line,font=f_hed); d.text(((W-(bb[2]-bb[0]))/2,y),line,fill=INK,font=f_hed)
444
- y+=bb[3]-bb[1]+2
445
  y+=4; d.line([M+10,y,W-M-10,y],fill=BORDER,width=1); y+=10
446
-
447
- # Two-column article + sidebar
448
- PAD=M+12; COL_GAP=24; SIDE_W=220
449
- main_w = W - 2*PAD - COL_GAP - SIDE_W
450
- half_w = (main_w-COL_GAP)//2
451
- col1_x=PAD; col2_x=PAD+half_w+COL_GAP; side_x=PAD+main_w+COL_GAP
452
- LH=15; MAX_Y=H-M-50; art_y=y
453
-
454
- art_lines = textwrap.wrap(parsed.get("article",""), width=38)
455
- mid = max(1,len(art_lines)//2)
456
- ly=art_y
457
- for line in art_lines[:mid]:
458
- if ly+LH>MAX_Y: break
459
- d.text((col1_x,ly),line,fill=INK,font=f_body); ly+=LH
460
- 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)
461
- ry=art_y
462
- for line in art_lines[mid:]:
463
- if ry+LH>MAX_Y: break
464
- d.text((col2_x,ry),line,fill=INK,font=f_body); ry+=LH
465
-
466
- # Sidebar: In Brief
467
- d.line([side_x-8,art_y,side_x-8,MAX_Y],fill=BORDER,width=1)
468
- sy=art_y
469
- d.text((side_x,sy),"IN BRIEF",fill=INK,font=f_bold); sy+=16
470
- d.line([side_x,sy,W-M-14,sy],fill=GREY,width=1); sy+=6
471
- briefs = parsed.get("briefs",{})
472
- for cname in CREATURES:
473
- em=CREATURE_EMOJI.get(cname,"?"); txt=briefs.get(cname,"")
474
- header=f"{em} {cname.upper()}"
475
- d.text((side_x,sy),header,fill=INK,font=f_bold); sy+=13
476
- for bline in textwrap.wrap(txt,width=26):
477
- if sy+12>MAX_Y: break
478
- d.text((side_x,sy),bline,fill=INK,font=f_sm); sy+=12
479
  sy+=4
480
-
481
- # Classifieds footer
482
  fy=H-M-38; d.line([M+10,fy,W-M-10,fy],fill=BORDER,width=1); fy+=4
483
- d.text((M+14,fy),"CLASSIFIEDS",fill=INK,font=f_bold); fy+=14
484
- for cl in textwrap.wrap(classified,width=100):
485
  if fy+12>H-M-8: break
486
- d.text((M+14,fy),cl,fill=INK,font=f_sm); fy+=12
487
-
488
- path=f"/tmp/tinywick_day_{day_number}.png"
489
- img.save(path,"PNG")
490
- return path
491
-
492
-
493
- # ═══════════════════════════════════════════════════════════════════
494
- # 7 ▸ AGENT TRACE EXPORT (📡 Sharing is Caring badge)
495
- # ═══════════════════════════════════════════════════════════════════
496
- def export_agent_traces() -> str:
497
- """Export all simulation events as JSON for the HF dataset."""
498
- creatures = database.get_all_creatures()
499
- trace = {
500
- "meta": {
501
- "project": "Tiny Civilization — The Tinywick Hollow Gazette",
502
- "model": _model_id_used,
503
- "exported_at": datetime.now().isoformat(),
504
- },
505
- "agent_prompts": AGENT_PROMPTS,
506
- "narrator_prompt": NARRATOR_PROMPT,
507
- "creature_state": creatures,
508
- "events": [],
509
- "days": [],
510
- }
511
- headlines = database.get_all_headlines()
512
- for dn, _ in reversed(headlines):
513
- day = database.get_day(dn)
514
- if day: trace["days"].append(dict(day))
515
- evts = database.get_events_for_day(dn)
516
- trace["events"].extend([{"day": dn, **e} for e in evts])
517
- path = "/tmp/tiny_civ_traces.json"
518
- with open(path,"w") as f: json.dump(trace, f, indent=2, default=str)
519
  return path
520
 
521
-
522
  # ═══════════════════════════════════════════════════════════════════
523
- # 8 ▸ CSS (heavy newspaper polish for Off-Brand badge)
524
  # ═══════════════════════════════════════════════════════════════════
525
  NEWSPAPER_CSS = """
526
  @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=UnifrakturMaguntia&display=swap');
527
 
528
  body, .gradio-container { background: #c4b090 !important; }
529
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
530
  /* ── Newspaper wrapper ── */
531
  .paper-wrap {
532
- background: #f6ead0;
533
- background-image: repeating-linear-gradient(0deg,transparent,transparent 21px,rgba(150,110,60,.07) 22px);
534
- border: 3px solid #4a2a08; border-radius:1px;
535
- padding: 20px 26px 16px;
536
- box-shadow: 6px 6px 28px rgba(0,0,0,.35), inset 0 0 100px rgba(180,140,80,.15);
537
- margin: 6px 0; position: relative;
 
538
  }
539
  .paper-wrap::before {
540
  content:""; display:block; border:1px solid #4a2a08;
@@ -547,32 +403,40 @@ body, .gradio-container { background: #c4b090 !important; }
547
  font-size:2.4em; text-align:center; color:#160800;
548
  border-top:5px double #4a2a08; border-bottom:5px double #4a2a08;
549
  padding:5px 0; margin-bottom:3px; letter-spacing:1px;
 
 
 
 
 
 
 
 
 
 
 
550
  }
551
- .paper-sub { font-family:'Libre Baskerville',Georgia,serif; font-size:.72em; color:#5a3615;
552
- text-align:center; font-style:italic; margin-bottom:2px; }
553
- .paper-weather { font-family:'Libre Baskerville',Georgia,serif; font-size:.75em; color:#7a5630;
554
- text-align:center; margin-bottom:6px; letter-spacing:.5px; }
555
- .paper-rule { border:none; border-top:2px solid #4a2a08; margin:3px 0 6px; }
556
  .paper-rule-thin { border:none; border-top:1px solid #9a7040; margin:3px 0; }
557
 
558
- /* ── Headline ── */
559
  .paper-headline {
560
  font-family:'Playfair Display',Georgia,serif; font-size:1.85em; font-weight:900;
561
  text-align:center; text-transform:uppercase; color:#0c0400;
562
  line-height:1.12; margin:6px 0 8px;
 
563
  }
564
-
565
- /* ── Body row: article + sidebar ── */
566
  .paper-body-row { display:flex; gap:0; }
567
  .paper-article-cols {
568
  flex:1; font-family:'Libre Baskerville',Georgia,serif; font-size:.88em;
569
  color:#180a02; line-height:1.75; text-align:justify;
570
- column-count:2; column-gap:24px; column-rule:1px solid #9a7040;
571
- padding:4px 0;
572
  }
573
  .paper-sidebar {
574
  width:210px; min-width:190px; padding:0 0 0 16px;
575
  border-left:2px solid #4a2a08; margin-left:16px;
 
576
  }
577
  .sidebar-title {
578
  font-family:'Playfair Display',Georgia,serif; font-weight:700; font-size:.85em;
@@ -580,35 +444,82 @@ body, .gradio-container { background: #c4b090 !important; }
580
  border-bottom:1px solid #9a7040; padding-bottom:3px; margin-bottom:6px;
581
  }
582
  .sidebar-item { margin-bottom:8px; }
583
- .sidebar-creature-name {
584
- font-family:'Libre Baskerville',Georgia,serif; font-weight:700; font-size:.78em; color:#2a1008;
585
- }
586
- .sidebar-creature-text {
587
- font-family:'Libre Baskerville',Georgia,serif; font-size:.74em; color:#3a1c08;
588
- line-height:1.45; font-style:italic;
589
- }
590
 
591
- /* ── Classifieds ── */
592
  .paper-classifieds {
593
  font-family:'Libre Baskerville',Georgia,serif; font-size:.75em; color:#5a3615;
594
  border-top:1px solid #9a7040; margin-top:8px; padding-top:5px; font-style:italic;
 
595
  }
596
  .paper-classifieds span { font-weight:700; font-style:normal; color:#2a1008; }
597
-
598
- /* ── Day badge ── */
599
  .paper-daybadge {
600
  font-family:'Libre Baskerville',Georgia,serif; font-size:.75em; color:#5a3615;
601
  text-align:center; border-top:1px solid #9a7040; margin-top:8px; padding-top:5px;
 
602
  }
603
 
604
- /* ── Status strip ── */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  .status-strip {
606
  background:#d6c4a0; border:1px solid #8a6030; border-radius:3px;
607
  padding:5px 12px; font-family:'Libre Baskerville',Georgia,serif;
608
  font-size:.82em; color:#2a1008; text-align:center; margin:4px 0;
609
  }
610
-
611
- /* ── Creature cards ── */
 
 
 
 
 
 
 
612
  .creature-grid { display:flex; gap:8px; flex-wrap:wrap; }
613
  .creature-card {
614
  background:#f8ecd8; border:1px solid #9a7040; border-radius:2px;
@@ -617,99 +528,158 @@ body, .gradio-container { background: #c4b090 !important; }
617
  }
618
  .creature-name { font-weight:700; font-size:.95em; color:#260e04; display:block; margin-bottom:3px; }
619
  .creature-tier { font-style:italic; color:#7a5030; font-size:.85em; }
620
-
621
- /* ── Civ stats ── */
622
- .civ-stats {
623
- background:#e8d4b0; border:1px solid #9a7040; border-radius:2px;
624
- padding:8px 14px; font-family:'Libre Baskerville',Georgia,serif;
625
- font-size:.78em; color:#2a1008; display:flex; gap:14px; flex-wrap:wrap;
626
- justify-content:center; margin:6px 0;
627
- }
628
- .stat-item { text-align:center; }
629
- .stat-label { font-size:.85em; color:#7a5030; display:block; }
630
- .stat-value { font-weight:700; font-size:1.05em; color:#160800; }
631
-
632
- /* ── Section titles ── */
633
  .section-title {
634
  font-family:'Playfair Display',Georgia,serif; font-weight:700;
635
- color:#160800; font-size:.95em; text-transform:uppercase;
636
- letter-spacing:1px; text-align:center;
637
- border-bottom:1px solid #8a6030; padding-bottom:3px; margin:8px 0 8px;
638
  }
639
- .archive-area { background:#f0e4c6; border:1px solid #9a7040; border-radius:2px;
640
- padding:10px; margin-top:5px; min-height:50px; }
641
 
642
  /* ── Konami modal ── */
643
  #konami-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,.5); z-index:99998; }
644
  #konami-modal {
645
  display:none; position:fixed; z-index:99999; top:50%; left:50%;
646
- transform:translate(-50%,-50%); width:min(660px,92vw); max-height:76vh; overflow-y:auto;
647
- background:#f6ead0; border:3px solid #4a2a08; box-shadow:10px 10px 40px rgba(0,0,0,.6);
648
- padding:22px 26px 18px; font-family:'Libre Baskerville',Georgia,serif;
 
649
  }
650
  #konami-modal h2 { font-family:'Playfair Display',Georgia,serif; color:#160800; margin:0 0 10px; }
651
  #konami-modal details { margin:6px 0; }
652
  #konami-modal summary { cursor:pointer; font-weight:700; color:#4a2a08; }
653
- #konami-modal pre { background:#e8d4b0; border:1px solid #9a7040; padding:8px;
654
- font-size:.76em; white-space:pre-wrap; border-radius:2px; margin:5px 0 0; }
655
- #konami-close { position:absolute; top:8px; right:12px; cursor:pointer;
656
- font-size:1.3em; color:#4a2a08; background:none; border:none; }
657
  """
658
 
659
  # ═══════════════════════════════════════════════════════════════════
660
- # 9 ▸ JAVASCRIPT
661
  # ═══════════════════════════════════════════════════════════════════
662
- KONAMI_JS = """<script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
663
  (function(){
664
- var SEQ=['ArrowUp','ArrowUp','ArrowDown','ArrowDown',
665
- 'ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
666
- var idx=0;
667
- document.addEventListener('keydown',function(e){
668
- if(e.key===SEQ[idx]){idx++;if(idx===SEQ.length){idx=0;showKonami();}}
669
- else{idx=(e.key===SEQ[0])?1:0;}
670
- });
671
- window.showKonami=function(){
672
- document.getElementById('konami-backdrop').style.display='block';
673
- document.getElementById('konami-modal').style.display='block';
674
- };
675
- window.hideKonami=function(){
676
- document.getElementById('konami-backdrop').style.display='none';
677
- document.getElementById('konami-modal').style.display='none';
678
- };
679
  })();
680
- </script>"""
681
-
682
 
683
  # ═══════════════════════════════════════════════════════════════════
684
- # 10 ▸ HTML FORMATTERS
685
  # ═══════════════════════════════════════════════════════════════════
686
- def _rel_tier(score: int) -> tuple[str, str]:
687
- for lo, hi, label, icon in REL_TIERS:
688
- if lo <= score <= hi: return label, icon
689
- return "Unknown", "?"
 
 
 
 
 
 
 
 
 
 
690
 
691
  def _html_paper(parsed: dict, classified: str, day_num: int) -> str:
692
- hed = (parsed.get("headline","") or "").replace("<","&lt;")
693
- art = (parsed.get("article","") or "").replace("<","&lt;")
694
- wx = (parsed.get("weather","") or "").replace("<","&lt;")
695
- briefs = parsed.get("briefs",{})
 
696
  emojis = " ".join(f"{CREATURE_EMOJI[c]} {c.capitalize()}" for c in CREATURES)
697
- classified_esc = classified.replace("<","&lt;")
698
 
699
- sidebar_items = ""
700
- for c in CREATURES:
701
- em = CREATURE_EMOJI.get(c,"?")
702
- txt = (briefs.get(c,"") or "").replace("<","&lt;")
703
- sidebar_items += f"""
704
  <div class="sidebar-item">
705
- <div class="sidebar-creature-name">{em} {c.upper()}</div>
706
- <div class="sidebar-creature-text">{txt}</div>
707
- </div>"""
708
 
709
  return f"""
710
- <div class="paper-wrap">
711
  <div class="paper-masthead">The Tinywick Hollow Gazette</div>
712
- <div class="paper-sub">Est. Day&nbsp;1 ✦ Day&nbsp;{day_num} ✦ One Acorn Per Copy ✦ Serving the Woodland Since the Beginning</div>
713
  <div class="paper-weather">☁&nbsp;{wx}</div>
714
  <hr class="paper-rule">
715
  <div class="paper-headline">{hed}</div>
@@ -721,269 +691,253 @@ def _html_paper(parsed: dict, classified: str, day_num: int) -> str:
721
  {sidebar_items}
722
  </div>
723
  </div>
724
- <div class="paper-classifieds"><span>CLASSIFIEDS:</span> {classified_esc}</div>
 
 
 
 
725
  <div class="paper-daybadge">— Day {day_num} —&nbsp;&nbsp; {emojis}</div>
726
  </div>
 
727
  """
728
 
729
- def _html_placeholder() -> str:
730
- founding = database.get_day(0)
731
- if founding:
732
- text = founding["full_newspaper_text"]
733
- parts = text.split("\n\n",1)
734
- hed = parts[0]; art = parts[1] if len(parts)>1 else text
735
- dummy_parsed = {
736
- "headline": hed, "article": art,
737
- "weather": "Portentous, with scattered significance.",
738
- "briefs": {
739
- "fox": "Forged three certificates before breakfast.",
740
- "badger": "Insisted on thirteen amendments before lunch.",
741
- "squirrel": "Invented a signing machine! It signed the wrong document!",
742
- "mole": "Something is already happening underground.",
743
- }
744
- }
745
- return _html_paper(dummy_parsed, "NOTICE: Civilisation now in progress.", 0)
746
- return """<div class="paper-wrap">
747
  <div class="paper-masthead">The Tinywick Hollow Gazette</div>
 
748
  <hr class="paper-rule">
749
- <div class="paper-headline">AWAITING FIRST LIGHT IN TINYWICK HOLLOW</div>
750
- <hr class="paper-rule-thin">
751
- <div class="paper-article-cols">Press <em>Advance Day</em> to begin the chronicle.</div>
752
- <div class="paper-daybadge">— Day 0 —</div>
753
- </div>"""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
754
 
755
- def _html_creatures() -> str:
756
- creatures = database.get_all_creatures()
757
- cards = ""
758
- for c in creatures:
759
- em = CREATURE_EMOJI.get(c["name"],"?")
760
- inv = (", ".join(c["inventory"][:3]) + ("" if len(c["inventory"])>3 else "")) or "nothing"
761
- rels = ""
762
- for other, score in sorted(c["relationship_scores"].items()):
763
- label, icon = _rel_tier(score)
764
- rels += f'<span title="{label} ({score})">{CREATURE_EMOJI.get(other,"?")} {icon}</span> '
765
- cards += f"""<div class="creature-card">
 
 
 
 
 
 
 
 
 
766
  <span class="creature-name">{em} {c['name'].capitalize()}</span>
767
  <div class="creature-tier">{rels}</div>
768
- <div style="font-size:.78em;margin-top:3px;color:#5a3615;"><em>Carries:</em> {inv}</div>
769
  </div>"""
770
  return f'<div class="creature-grid">{cards}</div>'
771
 
772
- def _html_civ_stats() -> str:
773
- s = database.get_civ_stats()
774
- bp = s["best_pair"]; wp = s["worst_pair"]
775
- dom = s["dominant"].capitalize()
776
  return f"""<div class="civ-stats">
777
  <div class="stat-item"><span class="stat-label">Days</span><span class="stat-value">{s['total_days']}</span></div>
778
  <div class="stat-item"><span class="stat-label">Events</span><span class="stat-value">{s['total_events']}</span></div>
779
  <div class="stat-item"><span class="stat-label">Nudges</span><span class="stat-value">{s['total_nudges']}</span></div>
780
- <div class="stat-item"><span class="stat-label">Strongest bond</span>
781
- <span class="stat-value">{bp[0].capitalize()} &amp; {bp[1].capitalize()} ({bp[2]})</span></div>
782
- <div class="stat-item"><span class="stat-label">Bitterest feud</span>
783
- <span class="stat-value">{wp[0].capitalize()} vs {wp[1].capitalize()} ({wp[2]})</span></div>
784
- <div class="stat-item"><span class="stat-label">Most popular</span><span class="stat-value">{dom}</span></div>
785
  </div>"""
786
 
787
  def _archive_choices():
788
- hl = database.get_all_headlines()
789
- return [(f"Day {dn}: {h[:44]}{'…' if len(h)>44 else ''}", dn) for dn,h in hl] or []
790
 
791
  def _status(msg): return f'<div class="status-strip">{msg}</div>'
792
 
793
- def _konami_html() -> str:
794
  import html as _h
795
- details = "".join(
796
- f"<details><summary>{CREATURE_EMOJI.get(n,'')}<strong> {n.upper()}</strong></summary>"
797
- f"<pre>{_h.escape(p)}</pre></details>"
798
- for n,p in AGENT_PROMPTS.items()
799
- )
800
- details += f"<details><summary>📰 <strong>NARRATOR</strong></summary><pre>{_h.escape(NARRATOR_PROMPT)}</pre></details>"
801
  return f"""
802
  <div id="konami-backdrop" onclick="hideKonami()"></div>
803
  <div id="konami-modal" role="dialog">
804
  <button id="konami-close" onclick="hideKonami()">✕</button>
805
  <h2>🔮 Secret Agent Briefing</h2>
806
- <p>Konami Code found! Here are the raw system prompts driving our correspondents:</p>
807
  {details}
808
- <p style="text-align:center;font-style:italic;color:#5a3615;font-size:.84em;margin-top:14px;">
809
- ↑↑↓↓←→←→BA — only the woodland elite know this. 🎮</p>
810
- </div>{KONAMI_JS}"""
811
-
812
 
813
  # ��══════════════════════════════════════════════════════════════════
814
- # 11GRADIO EVENT HANDLERS
815
  # ═══════════════════════════════════════════════════════════════════
816
- def _render_all(day_num, parsed, classified, status_msg):
817
- return (
818
- _html_paper(parsed, classified, day_num),
819
- _html_creatures(),
820
- _html_civ_stats(),
821
- gr.update(choices=_archive_choices(), value=None),
822
- _status(status_msg),
823
- day_num, parsed, classified,
824
- )
825
-
826
- def _safe_advance(nudge_type=None, nudge_value=None, nudge_target=None):
 
827
  try:
828
- day_num, parsed, classified = advance_day(nudge_type, nudge_value, nudge_target)
829
  model_tag = f" [{_model_id_used.split('/')[-1]}]" if _model_id_used!="none" else ""
830
- return _render_all(day_num, parsed, classified,
831
- f"✓ Day {day_num} published to the Gazette.{model_tag}")
832
  except Exception:
833
- tb = traceback.format_exc()
834
- print(tb)
835
- err_parsed = {
836
- "headline": "THE GAZETTE'S PRINTING PRESS HAS JAMMED",
837
- "article": "Our correspondents report a technical malfunction. The editor is inconsolable. "
838
- "Beatrice Badger suspects sabotage. Reginald Fox denies everything.",
839
- "weather": "Stormy, with a chance of errors.",
840
- "briefs": {c: "Unavailable for comment." for c in CREATURES},
841
- }
842
- return _render_all(0, err_parsed, "LOST: One functioning simulation. — The Editor",
843
- "✗ Simulation error — the printing press is jammed. Check logs.")
844
 
845
- def handle_advance(): return _safe_advance()
846
- def handle_rumour(c,r): return _safe_advance("rumour",r,c)
847
- def handle_donation(o): return _safe_advance("donation",o)
848
- def handle_law(l): return _safe_advance("law",l)
849
 
850
  def handle_archive_view(day_num):
851
  if day_num is None:
852
- return '<div class="archive-area"><em>Select a day to read its edition.</em></div>'
853
- day = database.get_day(int(day_num))
854
- if not day: return '<div class="archive-area"><em>Edition not found.</em></div>'
855
- txt = day["full_newspaper_text"]; parts = txt.split("\n\n",1)
856
- hed = parts[0]; art = parts[1] if len(parts)>1 else txt
857
- # Reconstruct parsed from stored text
858
- parsed = _parse_newspaper_full(txt, day_num)
859
- classified_match = re.search(r"CLASSIFIEDS:\s*(.+)", txt)
860
- cl = classified_match.group(1) if classified_match else random.choice(CLASSIFIEDS)
861
- return f'<div class="archive-area">{_html_paper(parsed, cl, day_num)}</div>'
862
-
863
- def handle_share(day_num_state, parsed_state, classified_state):
864
- if not parsed_state or not parsed_state.get("headline"): return gr.update(visible=False)
865
  try:
866
- path = render_newspaper_image(parsed_state, classified_state or "", day_num_state or 0)
867
- return gr.update(visible=True, value=path)
868
  except Exception: return gr.update(visible=False)
869
 
870
- def handle_export_traces():
871
- try:
872
- path = export_agent_traces()
873
- return gr.update(visible=True, value=path)
874
  except Exception: return gr.update(visible=False)
875
 
876
-
877
- # =================================================================
878
- # 12 ▸ GRADIO BLOCKS
879
- # =================================================================
880
-
881
- # -- Startup: init DB, load latest state --------------------------
882
  database.init_db()
883
- _latest = database.get_latest_day()
884
- if _latest:
885
- _INIT_DAY = _latest["day_number"]
886
- _INIT_PARSED = _parse_newspaper_full(_latest["full_newspaper_text"], _INIT_DAY)
887
- _cl_m = re.search(r"CLASSIFIEDS:\s*(.+)", _latest["full_newspaper_text"])
888
- _INIT_CL = _cl_m.group(1) if _cl_m else random.choice(CLASSIFIEDS)
889
  else:
890
- _INIT_DAY = 0; _INIT_PARSED = {}; _INIT_CL = ""
891
 
892
- # -- Gradio version-aware css/theme routing -----------------------
893
- _GR_MAJOR = int(gr.__version__.split(".")[0])
894
- if _GR_MAJOR >= 6: _BKW: dict = {}; _LKW: dict = {"css": NEWSPAPER_CSS}
895
- elif _GR_MAJOR >= 5: _BKW = {"css": NEWSPAPER_CSS}; _LKW = {}
896
- else: _BKW = {"css": NEWSPAPER_CSS, "theme": gr.themes.Base(primary_hue="orange", neutral_hue="stone")}; _LKW = {}
897
 
898
- # ------------------------------------------------------------------
899
- with gr.Blocks(title="Tiny Civilization — The Tinywick Hollow Gazette", **_BKW) as demo:
900
 
901
  gr.HTML(_konami_html())
902
-
903
- gr.HTML(
904
- '<div style="text-align:center;padding:8px 0 2px;">'
905
- '<h1 style="font-family:\'Playfair Display\',Georgia,serif;color:#160800;font-size:1.9em;margin:0 0 2px;">'
906
- '🦊 Tiny Civilization 🐀</h1>'
907
- '<p style="font-family:Georgia,serif;color:#5a3615;font-style:italic;margin:0;font-size:.87em;">'
908
- 'A persistent woodland civilisation. One day. One acorn. One absurd headline at a time.'
909
- '&nbsp;|&nbsp;<kbd title="Konami Code">↑↑↓↓←→←→BA</kbd> for secrets.'
910
- '</p></div>'
911
- )
912
-
913
- day_state = gr.State(_INIT_DAY)
914
- parsed_state = gr.State(_INIT_PARSED)
915
- classif_state = gr.State(_INIT_CL)
916
-
917
- status_html = gr.HTML(value=_status(
918
- f"Day {_INIT_DAY} in the archive — next: Day {_INIT_DAY + 1}."
919
- if _INIT_DAY >= 0 else "No days yet. Press Advance Day to begin."))
920
- civ_stats_html = gr.HTML(value=_html_civ_stats())
921
 
922
  with gr.Row(equal_height=False):
923
-
924
  with gr.Column(scale=3):
925
- newspaper_display = gr.HTML(
926
- value=(_html_paper(_INIT_PARSED, _INIT_CL, _INIT_DAY)
927
- if _INIT_PARSED else _html_placeholder()))
928
-
929
- with gr.Accordion('📜 Archive — Past Editions', open=False):
930
- archive_dd = gr.Dropdown(
931
- choices=_archive_choices(), value=None,
932
- label='Select a past day', container=False)
933
- archive_display = gr.HTML(
934
- value='<div class="archive-area"><em>Select a day to read its edition.</em></div>')
935
-
936
- with gr.Column(scale=1, min_width=260):
937
  gr.HTML('<div class="section-title">📰 Editorial Desk</div>')
938
- advance_btn = gr.Button('📅 Advance Day (no nudge)', variant='primary', size='lg')
939
 
940
  gr.HTML('<hr style="border-color:#8a6030;margin:8px 0;">')
941
  gr.HTML('<div class="section-title">✉ Nudge the Story</div>')
942
- gr.HTML('<p style="font-size:.78em;color:#5a3615;text-align:center;'
943
- 'font-style:italic;margin:0 0 6px;">Each nudge advances one day.</p>')
944
 
945
- with gr.Accordion('🗣️ Spread a Rumour', open=False):
946
- rumour_creature = gr.Dropdown(choices=CREATURES, value=CREATURES[0],
947
- label='About which creature?')
948
- rumour_type_dd = gr.Dropdown(choices=RUMOUR_TYPES, value=RUMOUR_TYPES[0],
949
- label='What rumour?')
950
- rumour_btn = gr.Button('📢 Spread It', variant='secondary')
951
 
952
- with gr.Accordion('🎁 Donate a Weird Object', open=False):
953
- donation_dd = gr.Dropdown(choices=WEIRD_OBJECTS, value=WEIRD_OBJECTS[0],
954
- label='Which object?')
955
- donation_btn = gr.Button('🎁 Donate It', variant='secondary')
956
 
957
- with gr.Accordion('⚖️ Propose a New Law', open=False):
958
- law_dd = gr.Dropdown(choices=LAWS, value=LAWS[0], label='Which law?')
959
- law_btn = gr.Button('⚖️ Propose It', variant='secondary')
960
 
961
  gr.HTML('<hr style="border-color:#8a6030;margin:8px 0;">')
962
- share_btn = gr.Button('🖼️ Share as Image', variant='secondary')
963
- img_output = gr.Image(label='Front Page PNG', visible=False, type='filepath')
964
- export_btn = gr.Button('📡 Export Agent Traces (JSON)', variant='secondary')
965
- trace_output = gr.File(label='Agent Traces JSON', visible=False)
966
 
967
- gr.HTML('<p style="font-size:.70em;color:#6a4818;text-align:center;'
968
- 'margin-top:8px;font-style:italic;">'
969
- 'Model: Qwen2.5-1.5B ≤4B 🐜&nbsp;|&nbsp;Local only 🔌&nbsp;|&nbsp;Custom UI 🎨</p>')
970
 
971
  gr.HTML('<div class="section-title" style="margin-top:12px;">Woodland Residents</div>')
972
- creature_display = gr.HTML(value=_html_creatures())
973
 
974
- _OUT = [newspaper_display, creature_display, civ_stats_html,
975
- archive_dd, status_html, day_state, parsed_state, classif_state]
976
 
977
- advance_btn.click( fn=handle_advance, inputs=[], outputs=_OUT, api_name=False)
978
- rumour_btn.click( fn=handle_rumour, inputs=[rumour_creature, rumour_type_dd], outputs=_OUT, api_name=False)
979
- donation_btn.click(fn=handle_donation, inputs=[donation_dd], outputs=_OUT, api_name=False)
980
- law_btn.click( fn=handle_law, inputs=[law_dd], outputs=_OUT, api_name=False)
981
- archive_dd.change( fn=handle_archive_view, inputs=[archive_dd], outputs=[archive_display], api_name=False)
982
- share_btn.click( fn=handle_share, inputs=[day_state, parsed_state, classif_state], outputs=[img_output], api_name=False)
983
- export_btn.click( fn=handle_export_traces, inputs=[], outputs=[trace_output], api_name=False)
984
 
985
  # ═══════════════════════════════════════════════════════════════════
986
- # 13 ▸ ENTRY POINT
987
  # ═══════════════════════════════════════════════════════════════════
988
  if __name__ == "__main__":
989
  demo.launch(server_name="0.0.0.0", server_port=7860, **_LKW)
 
1
  """
2
+ app.py — Tiny Civilization: The Tinywick Hollow Gazette 🍄
3
+ Persistent woodland civilisation · Qwen2.5-1.5B · ZeroGPU · Gradio 6
4
+ Build Small Hackathon 2026 — Thousand Token Wood track
5
  """
6
+ # ═══════════════════════════════════════════════════════════════════
7
+ # 0 ▸ IMPORTS
8
+ # ═══════════════════════════════════════════════════════════════════
9
  from __future__ import annotations
10
+ import json, os, random, re, textwrap, traceback
11
  from datetime import datetime
12
  import gradio as gr
13
  from PIL import Image, ImageDraw, ImageFont
 
 
14
  import database
15
 
16
+ try:
17
+ import spaces
18
+ _ZERO_GPU = True
19
+ except ImportError:
20
+ class _Stub:
21
+ @staticmethod
22
+ def GPU(fn=None, *, duration=60):
23
+ return fn if callable(fn) else (lambda f: f)
24
+ spaces = _Stub(); _ZERO_GPU = False # type: ignore
25
+
26
+ import torch
27
+ from transformers import pipeline as hf_pipeline
28
+
29
  # ═══════════════════════════════════════════════════════════════════
30
  # 1 ▸ CONSTANTS
31
  # ═══════════════════════════════════════════════════════════════════
 
33
  EVENT_TYPES = ["trade", "gossip", "feud", "invention", "discovery", "ceremony"]
34
  CREATURE_EMOJI = {"fox":"🦊","badger":"🦡","squirrel":"🐿️","mole":"🐀"}
35
 
 
36
  MODEL_PRIMARY = "Qwen/Qwen2.5-1.5B-Instruct"
37
  MODEL_FALLBACK = "Qwen/Qwen2.5-3B-Instruct"
38
 
39
  REL_DELTAS = {"trade":+5,"gossip":-4,"feud":-10,"invention":+7,"discovery":+6,"ceremony":+3}
40
+ REL_TIERS = [
41
+ (0,25,"Sworn Enemies","⚔️"),(26,40,"Very Suspicious","🦔"),
42
+ (41,55,"Wary Acquaintances","🤝"),(56,70,"Friendly","🌰"),
43
+ (71,85,"Close Companions","🌿"),(86,100,"Inseparable","🍄"),
 
 
 
 
44
  ]
45
 
46
  WEIRD_OBJECTS = [
47
+ "half-eaten poem","suspicious mushroom","button that looks like the moon",
48
+ "forgotten birthday","three secrets","a fake acorn",
49
+ "map to somewhere that may not exist","extremely formal apology note",
50
+ "small jar of preserved thunder","second-hand prophecy",
51
  ]
 
52
  LAWS = [
53
+ "no trading on Tuesdays","buttons = currency",
54
+ "everyone must compliment the badger","mushrooms are sacred",
55
+ "all debts expire at midnight","silence is legally binding",
56
+ "hats must be worn ironically","the mole's word is final (underground only)",
 
 
 
 
57
  ]
 
58
  RUMOUR_TYPES = [
59
  "has been secretly hoarding acorns",
60
  "was seen talking to a suspicious stranger at midnight",
61
  "invented something that does not work at all",
62
+ "owes three unpayable debts","made a deal with the rain",
 
63
  "owns the moon (allegedly)",
64
  "has been writing a novel about everyone in the hollow",
65
  "is not who they say they are",
66
  "has found a door underground that was not there before",
67
  "once ate an entire philosophy",
68
  ]
 
69
  CLASSIFIEDS = [
70
  "LOST: One certainty. If found, please do not return it. — M. Mole",
71
  "FOR SALE: Slightly used certificate of excellence. Condition: forged. — R. Fox",
72
  "NOTICE: The badger declares the east mushroom illegal pending investigation.",
73
+ "WANTED: Someone to explain what happened last Tuesday.",
74
  "FOUND: An unexplained event near the old oak. Owner may claim it.",
75
  "REWARD: For the return of three acorns lent in confidence. You know who you are.",
76
  "LOST: One argument. I was winning it. — B. Badger",
77
+ "ANNOUNCEMENT: The squirrel's new invention works. (Third announcement this week.)",
78
+ ]
79
+ LOADING_MSGS = [
80
+ "🦊 Fox is forging today's certificates of news...",
81
+ "🦡 Badger is filing constitutional objections to yesterday's headline...",
82
+ "🐿️ Squirrel invented a faster printing press! It prints sideways!",
83
+ "🐀 Mole observes that the news already exists, underground...",
84
+ "📰 The Gazette's editor is being insufferably pompous...",
85
+ "✒️ Setting moveable type for today's edition...",
86
+ "🖨️ Pressing ink onto fresh Tinywick newsprint...",
87
+ "📜 Fox has pre-forged three copies of the headline...",
88
+ "🍄 Badger has declared the comma unconstitutional...",
89
+ "🔍 Fact-checking in progress. All facts remain suspicious.",
90
  ]
91
 
92
  # ═══════════════════════════════════════════════════════════════════
93
+ # 2 ▸ AGENT PROMPTS
94
  # ═══════════════════════════════════════════════════════════════════
95
  AGENT_PROMPTS: dict[str, str] = {
96
  "fox": (
97
+ "You are Reginald Fox — charming, dishonest, deeply fond of certificates. "
98
+ "You speak formally and hint at secret arrangements. Everything is negotiable. "
 
99
  "Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
100
  ),
101
  "badger": (
102
+ "You are Beatrice Badger — gruff keeper of rules, deeply suspicious of everyone "
103
+ "(especially the fox), secretly a poet. Mushrooms are a serious matter. "
104
+ "Short declarative sentences. You are always right. "
105
  "Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
106
  ),
107
  "squirrel": (
108
  "You are Cornelius Squirrel — anxious inventor who speaks fast and repeats himself! "
109
+ "You invent things that almost-but-not-quite work! Obsessed with efficiency! "
 
110
  "Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
111
  ),
112
  "mole": (
113
  "You are Millicent Mole — quiet, philosophical, rarely surfaces. "
114
+ "Incomplete thoughts and gentle riddles. Know everyone's secrets but share obliquely. "
 
115
  "Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
116
  ),
117
  }
 
118
  NARRATOR_PROMPT = (
119
  "You are the pompous editor-in-chief of The Tinywick Hollow Gazette. "
120
+ "Write about absurd woodland animals with utmost journalistic gravity. "
121
+ "Respond in this EXACT format:\n\n"
122
  "HEADLINE IN ALL CAPS\n\n"
123
+ "Three to four sentences of pompous newspaper prose.\n\n"
124
+ "WEATHER: One absurd woodland forecast.\n"
125
  "FOX: One sentence about the fox's day.\n"
126
  "BADGER: One sentence about the badger's day.\n"
127
  "SQUIRREL: One sentence about the squirrel's day.\n"
128
  "MOLE: One cryptic sentence."
129
  )
130
 
131
+ # ═══════════════════════════════════════════════════════════════════
132
+ # 3 ▸ MODEL (ZeroGPU only — lazy loaded inside @spaces.GPU)
133
+ # ═══════════════════════════════════════════════════════════════════
134
+ _pipe = None
135
+ _model_id_used = "none"
136
 
137
+ def _load_pipeline() -> None:
138
+ global _pipe, _model_id_used
139
+ if _pipe is not None: return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  for mid in (MODEL_PRIMARY, MODEL_FALLBACK):
141
  try:
142
+ print(f"[TinyC] Loading {mid}…", flush=True)
143
+ _pipe = hf_pipeline(
144
+ "text-generation", model=mid,
145
+ dtype=torch.float16, device_map="auto",
 
146
  trust_remote_code=True,
147
  )
148
  _model_id_used = mid
149
+ print(f"[TinyC] {mid} ready ✓", flush=True)
150
  return
151
  except Exception as e:
152
+ print(f"[TinyC] {mid} failed: {e}", flush=True)
153
+ raise RuntimeError(f"Could not load {MODEL_PRIMARY} or {MODEL_FALLBACK}.")
 
154
 
155
  def _generate(system_prompt: str, user_prompt: str, max_new_tokens: int = 160) -> str:
156
+ assert _pipe is not None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  try:
158
+ out = _pipe(
159
+ [{"role":"system","content":system_prompt},{"role":"user","content":user_prompt}],
160
+ max_new_tokens=max_new_tokens, temperature=0.88, top_p=0.92,
161
+ do_sample=True, return_full_text=False,
162
+ )
163
+ return (out[0]["generated_text"] or "").strip()
164
  except Exception as e:
165
+ print(f"[TinyC] generate error: {e}", flush=True); return ""
 
166
 
167
  def call_agent(agent_name: str, context: str) -> str:
168
+ r = _generate(AGENT_PROMPTS.get(agent_name, AGENT_PROMPTS["fox"]), context, 100)
169
+ return r or f"{agent_name.capitalize()} had no comment at this time."
 
170
 
171
  # ═══════════════════════════════════════════════════════════════════
172
  # 4 ▸ SIMULATION
 
176
  database.save_nudge(day_number, "rumour", f"{nudge_target}: {nudge_value}")
177
  return f"A rumour is spreading that {nudge_target} {nudge_value}."
178
  if nudge_type == "donation" and nudge_value:
179
+ r = random.choice(CREATURES)
180
+ c = database.get_creature(r)
181
  if c:
182
+ database.update_creature(r, inventory=(c["inventory"]+[nudge_value])[-12:])
183
+ database.save_nudge(day_number, "donation", f"{r}: {nudge_value}")
184
+ return f"Someone donated '{nudge_value}' to {r}."
 
185
  if nudge_type == "law" and nudge_value:
186
  database.save_nudge(day_number, "law", nudge_value)
187
+ return f"A new law proposed: '{nudge_value}'."
188
  return ""
189
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  def _parse_newspaper_full(raw: str, day_number: int) -> dict:
191
+ lines = [l.strip() for l in raw.strip().splitlines() if l.strip()]
 
 
 
192
  result = {
193
  "headline": f"ANOTHER EVENTFUL DAY IN TINYWICK HOLLOW (DAY {day_number})",
194
+ "article": "", "weather": "Overcast, with philosophical observations.",
 
195
  "briefs": {c: f"{c.capitalize()} was present." for c in CREATURES},
196
  }
 
 
197
  body_start = 0
198
  for i, line in enumerate(lines):
199
+ cleaned = re.sub(r'[*_#"\'`]', "", line).strip()
200
  if cleaned and (cleaned == cleaned.upper() or i == 0):
201
+ result["headline"] = cleaned.upper(); body_start = i+1; break
 
 
 
 
202
  article_lines = []
203
  for line in lines[body_start:]:
204
+ u = line.upper()
205
+ if u.startswith("WEATHER:"): result["weather"] = line.split(":",1)[1].strip()
206
+ elif u.startswith("FOX:"): result["briefs"]["fox"] = line.split(":",1)[1].strip()
207
+ elif u.startswith("BADGER:"): result["briefs"]["badger"] = line.split(":",1)[1].strip()
208
+ elif u.startswith("SQUIRREL:"):result["briefs"]["squirrel"] = line.split(":",1)[1].strip()
209
+ elif u.startswith("MOLE:"): result["briefs"]["mole"] = line.split(":",1)[1].strip()
210
+ else: article_lines.append(line)
211
+ result["article"] = " ".join(article_lines).strip() or raw.strip()
 
 
 
 
 
 
 
 
 
 
212
  return result
213
 
 
214
  def _run_simulation_step(nudge_type, nudge_value, nudge_target):
 
215
  day_number = database.get_next_day_number()
216
  creatures = database.get_all_creatures()
217
+ cur_nudge = _nudge_ctx(nudge_type, nudge_value, nudge_target, day_number)
218
+ nudges = database.get_recent_nudges(3)
219
+ hist = ("Recent: " + "; ".join(f"Day {n['day_number']} {n['nudge_type']}: {n['nudge_value']}" for n in nudges)) if nudges else ""
220
+ hl = database.get_all_headlines()
221
+ past_hl = ("Past: " + " | ".join(f"Day {d}: {h[:35]}" for d,h in hl[:2])) if len(hl)>=2 else ""
222
+ ctx = " | ".join(filter(None, [cur_nudge, hist]))
223
+
224
+ event_records = []
225
  for _ in range(3):
226
  actor = random.choice(CREATURES)
227
  target = random.choice([c for c in CREATURES if c != actor])
228
  etype = random.choice(EVENT_TYPES)
229
+ rel = next((c for c in creatures if c["name"]==actor),{}).get("relationship_scores",{}).get(target,50)
 
 
230
  prompts = {
231
+ "trade": f"Propose a trade with {target} (relationship {rel}/100).",
232
+ "gossip": f"Share absurd gossip about {target} (relationship {rel}/100).",
233
+ "feud": f"Describe your trivial feud with {target} (relationship {rel}/100).",
234
+ "invention": f"You invented something involving {target}. Describe it.",
235
+ "discovery": f"You discovered something surprising about {target}.",
236
+ "ceremony": f"You are organising a ceremony involving {target}.",
237
  }
238
+ prompt = prompts[etype] + (f"\nContext: {ctx}" if ctx else "")
239
+ desc = call_agent(actor, prompt) or f"{actor.capitalize()} did a {etype} with {target}."
240
+ event_records.append({"actor":actor,"action":etype,"target":target,"description":desc})
241
+ database.save_event(day_number, actor, etype, target, desc)
 
 
 
 
 
 
 
242
  delta = REL_DELTAS.get(etype, 0)
243
  creatures = database.get_all_creatures()
244
  for c in creatures:
245
  if c["name"] == actor:
246
  sc = c["relationship_scores"]
247
+ sc[target] = max(0, min(100, sc.get(target,50)+delta))
248
  database.update_creature(actor, relationship_scores=sc)
249
  if c["name"] == target:
250
  sc = c["relationship_scores"]
251
+ sc[actor] = max(0, min(100, sc.get(actor,50)+delta//2))
252
  database.update_creature(target, relationship_scores=sc)
253
  creatures = database.get_all_creatures()
254
 
255
+ evsum = "\n".join(f"- {e['actor'].capitalize()} [{e['action']}] {e['target']}: {e['description']}" for e in event_records)
256
+ extra = ("\nContext: "+ctx if ctx else "") + ("\n"+past_hl if past_hl else "")
257
+ raw = _generate(NARRATOR_PROMPT, f"Today's events:\n{evsum}{extra}", 300)
258
+ if not raw:
259
+ raw = (f"CHAOS IN TINYWICK HOLLOW DAY {day_number}\n\nThings happened today.\n\n"
260
+ f"WEATHER: Unclear.\nFOX: {event_records[0]['description'][:50]}\n"
261
+ f"BADGER: {event_records[1]['description'][:50]}\n"
262
+ f"SQUIRREL: {event_records[2]['description'][:50]}\nMOLE: Underground.")
263
+ parsed = _parse_newspaper_full(raw, day_number)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
264
  classified = random.choice(CLASSIFIEDS)
265
+ full_text = "\n\n".join([parsed["headline"], parsed["article"],
266
+ f"WEATHER: {parsed['weather']}",
267
+ "\n".join(f"{c.upper()}: {t}" for c,t in parsed["briefs"].items()),
268
+ f"CLASSIFIEDS: {classified}"])
 
 
 
 
 
269
  database.save_day(day_number, parsed["headline"], full_text)
270
  return day_number, parsed, classified
271
 
272
+ # ═══════════════════════════════════════════════════════════════════
273
+ # 5 ▸ ZERОГPU WRAPPER
274
+ # ═══════════════════════════════════════════════════════════════════
275
+ @spaces.GPU(duration=60)
276
+ def advance_day(nudge_type=None, nudge_value=None, nudge_target=None):
277
+ _load_pipeline()
278
+ return _run_simulation_step(nudge_type, nudge_value, nudge_target)
 
 
 
 
279
 
280
  # ═══════════════════════════════════════════════════════════════════
281
+ # 6 ▸ PIL IMAGE
282
  # ═══════════════════════════════════════════════════════════════════
283
+ _SB=["/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf","/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf"]
284
+ _SR=["/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf","/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf"]
285
+ def _tf(p,s):
286
+ for f in p:
287
+ try: return ImageFont.truetype(f,s)
 
 
 
288
  except: pass
289
  return ImageFont.load_default()
290
 
291
+ def render_newspaper_image(parsed:dict, classified:str, day_number:int)->str:
292
+ W,H=960,720; PAPER=(245,232,200); INK=(20,8,2); BORDER=(70,40,8); SUBINK=(90,55,25); GREY=(130,100,70)
293
+ img=Image.new("RGB",(W,H),PAPER); d=ImageDraw.Draw(img)
294
+ fm=_tf(_SB,30); fh=_tf(_SB,20); fb=_tf(_SR,12); fs=_tf(_SR,10); fbo=_tf(_SB,12)
295
+ M=18; d.rectangle([M,M,W-M,H-M],outline=BORDER,width=3); d.rectangle([M+6,M+6,W-M-6,H-M-6],outline=BORDER,width=1)
296
+ y=M+14
297
+ mast="THE TINYWICK HOLLOW GAZETTE"; bb=d.textbbox((0,0),mast,font=fm); d.text(((W-(bb[2]-bb[0]))/2,y),mast,fill=INK,font=fm); y+=bb[3]-bb[1]+3
298
+ sub=f"Est. Day 1 Day {day_number} ✦ One Acorn ✦ Woodland Readers Only"; bb=d.textbbox((0,0),sub,font=fs); d.text(((W-(bb[2]-bb[0]))/2,y),sub,fill=SUBINK,font=fs); y+=bb[3]-bb[1]+4
299
+ wx=f"☁ {parsed.get('weather','Overcast.')}"; bb=d.textbbox((0,0),wx,font=fs); d.text(((W-(bb[2]-bb[0]))/2,y),wx,fill=GREY,font=fs); y+=bb[3]-bb[1]+4
300
+ d.line([M+10,y,W-M-10,y],fill=BORDER,width=2); d.line([M+10,y+4,W-M-10,y+4],fill=BORDER,width=1); y+=14
301
+ for ln in textwrap.wrap(parsed.get("headline",""),50):
302
+ bb=d.textbbox((0,0),ln,font=fh); d.text(((W-(bb[2]-bb[0]))/2,y),ln,fill=INK,font=fh); y+=bb[3]-bb[1]+2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  y+=4; d.line([M+10,y,W-M-10,y],fill=BORDER,width=1); y+=10
304
+ PAD=M+12; CG=24; SW=220; mw=W-2*PAD-CG-SW; hw=(mw-CG)//2
305
+ c1=PAD; c2=PAD+hw+CG; sx=PAD+mw+CG; LH=15; MY=H-M-50; ay=y
306
+ al=textwrap.wrap(parsed.get("article",""),38); mid=max(1,len(al)//2); ly=ay
307
+ for ln in al[:mid]:
308
+ if ly+LH>MY: break
309
+ d.text((c1,ly),ln,fill=INK,font=fb); ly+=LH
310
+ d.line([c1+hw+CG//2,ay,c1+hw+CG//2,min(ly,MY)],fill=GREY,width=1); ry=ay
311
+ for ln in al[mid:]:
312
+ if ry+LH>MY: break
313
+ d.text((c2,ry),ln,fill=INK,font=fb); ry+=LH
314
+ d.line([sx-8,ay,sx-8,MY],fill=BORDER,width=1); sy=ay
315
+ d.text((sx,sy),"IN BRIEF",fill=INK,font=fbo); sy+=16; d.line([sx,sy,W-M-14,sy],fill=GREY,width=1); sy+=6
316
+ for cn in CREATURES:
317
+ em=CREATURE_EMOJI.get(cn,"?"); txt=parsed.get("briefs",{}).get(cn,"")
318
+ d.text((sx,sy),f"{em} {cn.upper()}",fill=INK,font=fbo); sy+=13
319
+ for bl in textwrap.wrap(txt,26):
320
+ if sy+12>MY: break
321
+ d.text((sx,sy),bl,fill=INK,font=fs); sy+=12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
322
  sy+=4
 
 
323
  fy=H-M-38; d.line([M+10,fy,W-M-10,fy],fill=BORDER,width=1); fy+=4
324
+ d.text((M+14,fy),"CLASSIFIEDS",fill=INK,font=fbo); fy+=14
325
+ for cl in textwrap.wrap(classified,100):
326
  if fy+12>H-M-8: break
327
+ d.text((M+14,fy),cl,fill=INK,font=fs); fy+=12
328
+ path=f"/tmp/tinywick_day_{day_number}.png"; img.save(path,"PNG"); return path
329
+
330
+ def export_agent_traces()->str:
331
+ trace={"meta":{"project":"Tiny Civilization","model":_model_id_used,"exported_at":datetime.now().isoformat()},
332
+ "agent_prompts":AGENT_PROMPTS,"narrator_prompt":NARRATOR_PROMPT,
333
+ "creature_state":database.get_all_creatures(),"events":[],"days":[]}
334
+ for dn,_ in reversed(database.get_all_headlines()):
335
+ d=database.get_day(dn)
336
+ if d: trace["days"].append(dict(d))
337
+ trace["events"].extend([{"day":dn,**e} for e in database.get_events_for_day(dn)])
338
+ path="/tmp/tiny_civ_traces.json"
339
+ with open(path,"w") as f: json.dump(trace,f,indent=2,default=str)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
340
  return path
341
 
 
342
  # ═══════════════════════════════════════════════════════════════════
343
+ # 7 ▸ CSS animations + newspaper polish
344
  # ═══════════════════════════════════════════════════════════════════
345
  NEWSPAPER_CSS = """
346
  @import url('https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;0,900;1,400&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=UnifrakturMaguntia&display=swap');
347
 
348
  body, .gradio-container { background: #c4b090 !important; }
349
 
350
+ /* ── Animations ─────────────────────────────────────────────────── */
351
+ @keyframes paperDrop {
352
+ 0% { transform: translateY(-30px) perspective(600px) rotateX(12deg) scale(.97); opacity:0; filter:brightness(1.6) blur(1px); }
353
+ 55% { transform: translateY(4px) perspective(600px) rotateX(-2deg) scale(1.005); filter:brightness(1); }
354
+ 100% { transform: translateY(0) perspective(600px) rotateX(0deg) scale(1); opacity:1; }
355
+ }
356
+ @keyframes inkFade {
357
+ 0% { opacity:0; filter:blur(3px) sepia(.5); letter-spacing:-.5px; }
358
+ 100% { opacity:1; filter:blur(0) sepia(0); letter-spacing:normal; }
359
+ }
360
+ @keyframes headlineType {
361
+ 0% { clip-path: inset(0 100% 0 0); }
362
+ 100% { clip-path: inset(0 0% 0 0); }
363
+ }
364
+ @keyframes sidebarSlide {
365
+ 0% { transform:translateX(18px); opacity:0; }
366
+ 100% { transform:translateX(0); opacity:1; }
367
+ }
368
+ @keyframes dotBlink {
369
+ 0%,80%,100% { opacity:0; } 40% { opacity:1; }
370
+ }
371
+ @keyframes ornamentSpin {
372
+ 0% { transform:rotate(0deg) scale(1); }
373
+ 50% { transform:rotate(180deg) scale(1.2); }
374
+ 100% { transform:rotate(360deg) scale(1); }
375
+ }
376
+ @keyframes squirrelRun {
377
+ 0% { left:-70px; }
378
+ 100% { left:calc(100vw + 70px); }
379
+ }
380
+ @keyframes loadingPulse {
381
+ 0%,100% { opacity:.7; transform:scale(1); }
382
+ 50% { opacity:1; transform:scale(1.05); }
383
+ }
384
+
385
  /* ── Newspaper wrapper ── */
386
  .paper-wrap {
387
+ background:#f6ead0;
388
+ background-image:repeating-linear-gradient(0deg,transparent,transparent 21px,rgba(150,110,60,.07) 22px);
389
+ border:3px solid #4a2a08; border-radius:1px;
390
+ padding:20px 26px 16px;
391
+ box-shadow:6px 6px 28px rgba(0,0,0,.38), inset 0 0 100px rgba(180,140,80,.15);
392
+ margin:6px 0; position:relative;
393
+ animation: paperDrop .55s cubic-bezier(.34,1.56,.64,1) both;
394
  }
395
  .paper-wrap::before {
396
  content:""; display:block; border:1px solid #4a2a08;
 
403
  font-size:2.4em; text-align:center; color:#160800;
404
  border-top:5px double #4a2a08; border-bottom:5px double #4a2a08;
405
  padding:5px 0; margin-bottom:3px; letter-spacing:1px;
406
+ animation: inkFade .7s ease .1s both;
407
+ }
408
+ .paper-sub {
409
+ font-family:'Libre Baskerville',Georgia,serif; font-size:.72em;
410
+ color:#5a3615; text-align:center; font-style:italic; margin-bottom:2px;
411
+ animation: inkFade .6s ease .2s both;
412
+ }
413
+ .paper-weather {
414
+ font-family:'Libre Baskerville',Georgia,serif; font-size:.75em;
415
+ color:#7a5630; text-align:center; margin-bottom:6px; letter-spacing:.5px;
416
+ animation: inkFade .6s ease .25s both;
417
  }
418
+ .paper-rule { border:none; border-top:2px solid #4a2a08; margin:3px 0 6px; }
 
 
 
 
419
  .paper-rule-thin { border:none; border-top:1px solid #9a7040; margin:3px 0; }
420
 
421
+ /* ── Headline typewriter effect ── */
422
  .paper-headline {
423
  font-family:'Playfair Display',Georgia,serif; font-size:1.85em; font-weight:900;
424
  text-align:center; text-transform:uppercase; color:#0c0400;
425
  line-height:1.12; margin:6px 0 8px;
426
+ animation: headlineType 1s steps(35) .35s both;
427
  }
428
+ /* ── Body ── */
 
429
  .paper-body-row { display:flex; gap:0; }
430
  .paper-article-cols {
431
  flex:1; font-family:'Libre Baskerville',Georgia,serif; font-size:.88em;
432
  color:#180a02; line-height:1.75; text-align:justify;
433
+ column-count:2; column-gap:24px; column-rule:1px solid #9a7040; padding:4px 0;
434
+ animation: inkFade .9s ease .85s both;
435
  }
436
  .paper-sidebar {
437
  width:210px; min-width:190px; padding:0 0 0 16px;
438
  border-left:2px solid #4a2a08; margin-left:16px;
439
+ animation: sidebarSlide .7s ease 1s both;
440
  }
441
  .sidebar-title {
442
  font-family:'Playfair Display',Georgia,serif; font-weight:700; font-size:.85em;
 
444
  border-bottom:1px solid #9a7040; padding-bottom:3px; margin-bottom:6px;
445
  }
446
  .sidebar-item { margin-bottom:8px; }
447
+ .sidebar-creature-name { font-family:'Libre Baskerville',Georgia,serif; font-weight:700; font-size:.78em; color:#2a1008; }
448
+ .sidebar-creature-text { font-family:'Libre Baskerville',Georgia,serif; font-size:.74em; color:#3a1c08; line-height:1.45; font-style:italic; }
 
 
 
 
 
449
 
450
+ /* ── Classifieds + footer ── */
451
  .paper-classifieds {
452
  font-family:'Libre Baskerville',Georgia,serif; font-size:.75em; color:#5a3615;
453
  border-top:1px solid #9a7040; margin-top:8px; padding-top:5px; font-style:italic;
454
+ animation: inkFade .8s ease 1.2s both;
455
  }
456
  .paper-classifieds span { font-weight:700; font-style:normal; color:#2a1008; }
 
 
457
  .paper-daybadge {
458
  font-family:'Libre Baskerville',Georgia,serif; font-size:.75em; color:#5a3615;
459
  text-align:center; border-top:1px solid #9a7040; margin-top:8px; padding-top:5px;
460
+ animation: inkFade .6s ease 1.3s both;
461
  }
462
 
463
+ /* ── TTS button ── */
464
+ .tts-bar {
465
+ display:flex; gap:8px; align-items:center; justify-content:center;
466
+ margin:8px 0 2px; animation: inkFade .6s ease 1.4s both;
467
+ }
468
+ .tts-btn {
469
+ background:#4a2a08; color:#f6ead0; border:none; border-radius:2px;
470
+ padding:5px 14px; font-family:'Libre Baskerville',Georgia,serif;
471
+ font-size:.8em; cursor:pointer; transition:background .2s;
472
+ letter-spacing:.3px;
473
+ }
474
+ .tts-btn:hover { background:#2a1008; }
475
+ .tts-stop {
476
+ background:#8a1a08; color:#f6ead0; border:none; border-radius:2px;
477
+ padding:5px 12px; font-family:'Libre Baskerville',Georgia,serif;
478
+ font-size:.8em; cursor:pointer; display:none;
479
+ }
480
+
481
+ /* ── Loading newspaper ── */
482
+ .paper-loading .loading-area {
483
+ text-align:center; padding:40px 20px;
484
+ animation: loadingPulse 2s ease infinite;
485
+ }
486
+ .loading-ornament {
487
+ font-size:2.5em; display:block; margin-bottom:12px;
488
+ animation: ornamentSpin 3s linear infinite;
489
+ }
490
+ .loading-msg {
491
+ font-family:'Playfair Display',Georgia,serif; font-size:1.1em;
492
+ color:#2a1008; margin-bottom:8px;
493
+ }
494
+ .loading-dots span {
495
+ display:inline-block; font-size:1.5em; color:#4a2a08;
496
+ animation: dotBlink 1.4s infinite;
497
+ }
498
+ .loading-dots span:nth-child(2) { animation-delay:.2s; }
499
+ .loading-dots span:nth-child(3) { animation-delay:.4s; }
500
+
501
+ /* ── Running squirrel ── */
502
+ .tinyc-squirrel {
503
+ position:fixed; bottom:28px; font-size:1.8em;
504
+ z-index:9999; pointer-events:none;
505
+ animation: squirrelRun 3.5s linear forwards;
506
+ }
507
+
508
+ /* ── Panels ── */
509
  .status-strip {
510
  background:#d6c4a0; border:1px solid #8a6030; border-radius:3px;
511
  padding:5px 12px; font-family:'Libre Baskerville',Georgia,serif;
512
  font-size:.82em; color:#2a1008; text-align:center; margin:4px 0;
513
  }
514
+ .civ-stats {
515
+ background:#e8d4b0; border:1px solid #9a7040; border-radius:2px;
516
+ padding:8px 14px; font-family:'Libre Baskerville',Georgia,serif;
517
+ font-size:.78em; color:#2a1008; display:flex; gap:14px;
518
+ flex-wrap:wrap; justify-content:center; margin:6px 0;
519
+ }
520
+ .stat-item { text-align:center; }
521
+ .stat-label { font-size:.85em; color:#7a5030; display:block; }
522
+ .stat-value { font-weight:700; font-size:1.05em; color:#160800; }
523
  .creature-grid { display:flex; gap:8px; flex-wrap:wrap; }
524
  .creature-card {
525
  background:#f8ecd8; border:1px solid #9a7040; border-radius:2px;
 
528
  }
529
  .creature-name { font-weight:700; font-size:.95em; color:#260e04; display:block; margin-bottom:3px; }
530
  .creature-tier { font-style:italic; color:#7a5030; font-size:.85em; }
 
 
 
 
 
 
 
 
 
 
 
 
 
531
  .section-title {
532
  font-family:'Playfair Display',Georgia,serif; font-weight:700;
533
+ color:#160800; font-size:.95em; text-transform:uppercase; letter-spacing:1px;
534
+ text-align:center; border-bottom:1px solid #8a6030; padding-bottom:3px; margin:8px 0 8px;
 
535
  }
536
+ .archive-area { background:#f0e4c6; border:1px solid #9a7040; border-radius:2px; padding:10px; margin-top:5px; min-height:50px; }
 
537
 
538
  /* ── Konami modal ── */
539
  #konami-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,.5); z-index:99998; }
540
  #konami-modal {
541
  display:none; position:fixed; z-index:99999; top:50%; left:50%;
542
+ transform:translate(-50%,-50%); width:min(660px,92vw); max-height:76vh;
543
+ overflow-y:auto; background:#f6ead0; border:3px solid #4a2a08;
544
+ box-shadow:10px 10px 40px rgba(0,0,0,.6); padding:22px 26px 18px;
545
+ font-family:'Libre Baskerville',Georgia,serif;
546
  }
547
  #konami-modal h2 { font-family:'Playfair Display',Georgia,serif; color:#160800; margin:0 0 10px; }
548
  #konami-modal details { margin:6px 0; }
549
  #konami-modal summary { cursor:pointer; font-weight:700; color:#4a2a08; }
550
+ #konami-modal pre { background:#e8d4b0; border:1px solid #9a7040; padding:8px; font-size:.76em; white-space:pre-wrap; border-radius:2px; margin:5px 0 0; }
551
+ #konami-close { position:absolute; top:8px; right:12px; cursor:pointer; font-size:1.3em; color:#4a2a08; background:none; border:none; }
 
 
552
  """
553
 
554
  # ═══════════════════════════════════════════════════════════════════
555
+ # 8 ▸ JAVASCRIPT — TTS · sound · squirrel · Konami
556
  # ═══════════════════════════════════════════════════════════════════
557
+ GLOBAL_JS = """
558
+ <script>
559
+ // ── Web Speech API TTS ────────────────────────────────────────────
560
+ window.tinyCivReadAloud = function() {
561
+ if (!window.speechSynthesis) { alert('Text-to-speech not supported in this browser.'); return; }
562
+ const paper = document.querySelector('.paper-wrap[data-speech]');
563
+ if (!paper) return;
564
+ window.speechSynthesis.cancel();
565
+ const utter = new SpeechSynthesisUtterance(paper.getAttribute('data-speech'));
566
+ utter.rate = 0.85; utter.pitch = 1.08;
567
+ const stopBtn = document.getElementById('tinyc-tts-stop');
568
+ if (stopBtn) stopBtn.style.display = 'inline';
569
+ utter.onend = utter.onerror = () => { if (stopBtn) stopBtn.style.display = 'none'; };
570
+ window.speechSynthesis.speak(utter);
571
+ };
572
+ window.tinyCivStopReading = function() {
573
+ window.speechSynthesis && window.speechSynthesis.cancel();
574
+ const s = document.getElementById('tinyc-tts-stop');
575
+ if (s) s.style.display = 'none';
576
+ };
577
+
578
+ // ── Web Audio paper/press sound ───────────────────────────────────
579
+ window.tinyCivPlaySound = function() {
580
+ try {
581
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
582
+ // Press click
583
+ const osc = ctx.createOscillator();
584
+ const g1 = ctx.createGain();
585
+ osc.connect(g1); g1.connect(ctx.destination);
586
+ osc.frequency.setValueAtTime(520, ctx.currentTime);
587
+ osc.frequency.exponentialRampToValueAtTime(180, ctx.currentTime + .12);
588
+ g1.gain.setValueAtTime(.18, ctx.currentTime);
589
+ g1.gain.exponentialRampToValueAtTime(.001, ctx.currentTime + .14);
590
+ osc.start(); osc.stop(ctx.currentTime + .14);
591
+ // Paper rustle
592
+ const sz = ctx.sampleRate * .18;
593
+ const buf = ctx.createBuffer(1, sz, ctx.sampleRate);
594
+ const dat = buf.getChannelData(0);
595
+ for (let i=0; i<sz; i++) dat[i] = (Math.random()*2-1)*Math.pow(1-i/sz,1.6)*.8;
596
+ const ns = ctx.createBufferSource();
597
+ const nf = ctx.createBiquadFilter();
598
+ nf.type = 'bandpass'; nf.frequency.value = 2200; nf.Q.value = .8;
599
+ const g2 = ctx.createGain();
600
+ g2.gain.setValueAtTime(.12, ctx.currentTime+.14);
601
+ g2.gain.exponentialRampToValueAtTime(.001, ctx.currentTime+.32);
602
+ ns.buffer = buf;
603
+ ns.connect(nf); nf.connect(g2); g2.connect(ctx.destination);
604
+ ns.start(ctx.currentTime + .14);
605
+ } catch(e) { /* audio unavailable */ }
606
+ };
607
+
608
+ // ── Running squirrel ──────────────────────────────────────────────
609
+ window.tinyCivSquirrel = function() {
610
+ if (document.querySelector('.tinyc-squirrel')) return;
611
+ const sq = document.createElement('div');
612
+ sq.className = 'tinyc-squirrel';
613
+ sq.textContent = Math.random() < .5 ? '🐿️' : '🦊';
614
+ sq.title = 'Breaking news delivery!';
615
+ document.body.appendChild(sq);
616
+ setTimeout(() => sq.remove(), 4000);
617
+ };
618
+
619
+ // ── Called whenever a new edition is printed ──────────────────────
620
+ window.tinyCivNewEdition = function() {
621
+ window.tinyCivPlaySound();
622
+ if (Math.random() < .35) setTimeout(window.tinyCivSquirrel, 800);
623
+ };
624
+
625
+ // ── Konami code ───────────────────────────────────────────────────
626
  (function(){
627
+ const SEQ=['ArrowUp','ArrowUp','ArrowDown','ArrowDown',
628
+ 'ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
629
+ let i=0;
630
+ document.addEventListener('keydown',function(e){
631
+ if(e.key===SEQ[i]){ i++; if(i===SEQ.length){i=0; showKonami();} }
632
+ else { i=(e.key===SEQ[0])?1:0; }
633
+ });
634
+ window.showKonami = function(){
635
+ document.getElementById('konami-backdrop').style.display='block';
636
+ document.getElementById('konami-modal').style.display='block';
637
+ };
638
+ window.hideKonami = function(){
639
+ document.getElementById('konami-backdrop').style.display='none';
640
+ document.getElementById('konami-modal').style.display='none';
641
+ };
642
  })();
643
+ </script>
644
+ """
645
 
646
  # ═══════════════════════════════════════════════════════════════════
647
+ # 9 ▸ HTML FORMATTERS
648
  # ═══════════════════════════════════════════════════════════════════
649
+ def _e(t): return (t or "").replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
650
+
651
+ def _speech_text(parsed: dict) -> str:
652
+ hed = parsed.get("headline","")
653
+ art = parsed.get("article","")
654
+ wx = parsed.get("weather","")
655
+ brf = parsed.get("briefs",{})
656
+ parts = [f"Today's headline: {hed}.", art,
657
+ f"Weather: {wx}.",
658
+ f"Fox: {brf.get('fox','')}",
659
+ f"Badger: {brf.get('badger','')}",
660
+ f"Squirrel: {brf.get('squirrel','')}",
661
+ f"Mole: {brf.get('mole','')}"]
662
+ return " ".join(p for p in parts if p.strip()).replace('"',"'")
663
 
664
  def _html_paper(parsed: dict, classified: str, day_num: int) -> str:
665
+ hed = _e(parsed.get("headline",""))
666
+ art = _e(parsed.get("article",""))
667
+ wx = _e(parsed.get("weather",""))
668
+ brf = parsed.get("briefs",{})
669
+ cl = _e(classified)
670
  emojis = " ".join(f"{CREATURE_EMOJI[c]} {c.capitalize()}" for c in CREATURES)
671
+ speech = _speech_text(parsed).replace("'", "\\'")
672
 
673
+ sidebar_items = "".join(f"""
 
 
 
 
674
  <div class="sidebar-item">
675
+ <div class="sidebar-creature-name">{CREATURE_EMOJI.get(c,'?')} {c.upper()}</div>
676
+ <div class="sidebar-creature-text">{_e(brf.get(c,''))}</div>
677
+ </div>""" for c in CREATURES)
678
 
679
  return f"""
680
+ <div class="paper-wrap" data-speech="{speech}" data-day="{day_num}">
681
  <div class="paper-masthead">The Tinywick Hollow Gazette</div>
682
+ <div class="paper-sub">Est. Day&nbsp;1 ✦ Day&nbsp;{day_num} ✦ One Acorn Per Copy ✦ Serving the Woodland</div>
683
  <div class="paper-weather">☁&nbsp;{wx}</div>
684
  <hr class="paper-rule">
685
  <div class="paper-headline">{hed}</div>
 
691
  {sidebar_items}
692
  </div>
693
  </div>
694
+ <div class="paper-classifieds"><span>CLASSIFIEDS:</span> {cl}</div>
695
+ <div class="tts-bar">
696
+ <button class="tts-btn" onclick="tinyCivReadAloud()">🔊 Read Today's News</button>
697
+ <button class="tts-stop" id="tinyc-tts-stop" onclick="tinyCivStopReading()">⏹ Stop</button>
698
+ </div>
699
  <div class="paper-daybadge">— Day {day_num} —&nbsp;&nbsp; {emojis}</div>
700
  </div>
701
+ <script>if(window.tinyCivNewEdition) tinyCivNewEdition();</script>
702
  """
703
 
704
+ def _html_loading() -> str:
705
+ msgs_js = json.dumps(LOADING_MSGS)
706
+ return f"""
707
+ <div class="paper-wrap paper-loading">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
708
  <div class="paper-masthead">The Tinywick Hollow Gazette</div>
709
+ <div class="paper-sub">Est. Day 1 ✦ One Acorn Per Copy</div>
710
  <hr class="paper-rule">
711
+ <div class="loading-area">
712
+ <span class="loading-ornament">❋</span>
713
+ <div class="loading-msg" id="tinyc-loading-msg">The printing press is warming up...</div>
714
+ <div class="loading-dots"><span>·</span><span>·</span><span>·</span></div>
715
+ </div>
716
+ <hr class="paper-rule">
717
+ <div class="paper-daybadge">🦊 Fox &nbsp;🦡 Badger &nbsp;🐿️ Squirrel &nbsp;🐀 Mole</div>
718
+ </div>
719
+ <script>
720
+ (function(){{
721
+ const msgs = {msgs_js};
722
+ let i = Math.floor(Math.random() * msgs.length);
723
+ const el = document.getElementById('tinyc-loading-msg');
724
+ if (!el) return;
725
+ const iv = setInterval(() => {{ el.textContent = msgs[i++ % msgs.length]; }}, 1800);
726
+ const obs = new MutationObserver(() => {{
727
+ if (!document.getElementById('tinyc-loading-msg')) {{ clearInterval(iv); obs.disconnect(); }}
728
+ }});
729
+ obs.observe(document.body, {{childList:true, subtree:true}});
730
+ }})();
731
+ </script>
732
+ """
733
 
734
+ def _html_placeholder() -> str:
735
+ d0 = database.get_day(0)
736
+ if d0:
737
+ p = _parse_newspaper_full(d0["full_newspaper_text"], 0)
738
+ return _html_paper(p, "NOTICE: Civilisation now in progress.", 0)
739
+ return '<div class="paper-wrap"><div class="paper-masthead">The Tinywick Hollow Gazette</div><div class="paper-headline">AWAITING FIRST LIGHT</div></div>'
740
+
741
+ def _rel_tier(score:int)->tuple:
742
+ for lo,hi,label,icon in REL_TIERS:
743
+ if lo<=score<=hi: return label,icon
744
+ return "Unknown","?"
745
+
746
+ def _html_creatures()->str:
747
+ cards=""
748
+ for c in database.get_all_creatures():
749
+ em=CREATURE_EMOJI.get(c["name"],"?")
750
+ inv=(", ".join(c["inventory"][:3])+("…" if len(c["inventory"])>3 else "")) or "nothing"
751
+ rels="".join(f'<span title="{_rel_tier(s)[0]} ({s})">{CREATURE_EMOJI.get(k,"?")} {_rel_tier(s)[1]}</span> '
752
+ for k,s in sorted(c["relationship_scores"].items()))
753
+ cards+=f"""<div class="creature-card">
754
  <span class="creature-name">{em} {c['name'].capitalize()}</span>
755
  <div class="creature-tier">{rels}</div>
756
+ <div style="font-size:.78em;margin-top:3px;color:#5a3615;"><em>Carries:</em> {_e(inv)}</div>
757
  </div>"""
758
  return f'<div class="creature-grid">{cards}</div>'
759
 
760
+ def _html_civ_stats()->str:
761
+ s=database.get_civ_stats()
762
+ bp=s["best_pair"]; wp=s["worst_pair"]
 
763
  return f"""<div class="civ-stats">
764
  <div class="stat-item"><span class="stat-label">Days</span><span class="stat-value">{s['total_days']}</span></div>
765
  <div class="stat-item"><span class="stat-label">Events</span><span class="stat-value">{s['total_events']}</span></div>
766
  <div class="stat-item"><span class="stat-label">Nudges</span><span class="stat-value">{s['total_nudges']}</span></div>
767
+ <div class="stat-item"><span class="stat-label">Strongest bond</span><span class="stat-value">{bp[0].capitalize()} &amp; {bp[1].capitalize()} ({bp[2]})</span></div>
768
+ <div class="stat-item"><span class="stat-label">Bitterest feud</span><span class="stat-value">{wp[0].capitalize()} vs {wp[1].capitalize()} ({wp[2]})</span></div>
769
+ <div class="stat-item"><span class="stat-label">Most popular</span><span class="stat-value">{s['dominant'].capitalize()}</span></div>
 
 
770
  </div>"""
771
 
772
  def _archive_choices():
773
+ hl=database.get_all_headlines()
774
+ return [(f"Day {dn}: {h[:44]}{'…' if len(h)>44 else ''}",dn) for dn,h in hl] or []
775
 
776
  def _status(msg): return f'<div class="status-strip">{msg}</div>'
777
 
778
+ def _konami_html()->str:
779
  import html as _h
780
+ details="".join(
781
+ f"<details><summary>{CREATURE_EMOJI.get(n,'')} <strong>{n.upper()}</strong></summary><pre>{_h.escape(p)}</pre></details>"
782
+ for n,p in AGENT_PROMPTS.items())
783
+ details+=f"<details><summary>📰 <strong>NARRATOR</strong></summary><pre>{_h.escape(NARRATOR_PROMPT)}</pre></details>"
 
 
784
  return f"""
785
  <div id="konami-backdrop" onclick="hideKonami()"></div>
786
  <div id="konami-modal" role="dialog">
787
  <button id="konami-close" onclick="hideKonami()">✕</button>
788
  <h2>🔮 Secret Agent Briefing</h2>
789
+ <p>You found the Konami Code! Here are the raw system prompts:</p>
790
  {details}
791
+ <p style="text-align:center;font-style:italic;color:#5a3615;font-size:.84em;margin-top:14px;">↑↑↓↓←→←→BA — only the woodland elite know this. 🎮</p>
792
+ </div>"""
 
 
793
 
794
  # ��══════════════════════════════════════════════════════════════════
795
+ # 10 ▸ EVENT HANDLERS (generator-based for animated loading state)
796
  # ═══════════════════════════════════════════════════════════════════
797
+ def _render_all(day_num,parsed,classified,status_msg):
798
+ return (_html_paper(parsed,classified,day_num), _html_creatures(), _html_civ_stats(),
799
+ gr.update(choices=_archive_choices(),value=None), _status(status_msg),
800
+ day_num, parsed, classified)
801
+
802
+ def _do_advance(nudge_type=None, nudge_value=None, nudge_target=None,
803
+ cur_day=0, cur_parsed=None, cur_cl=""):
804
+ # Step 1: immediately show loading newspaper
805
+ yield (_html_loading(), gr.update(), gr.update(), gr.update(),
806
+ _status("⏳ The printing press is warming up..."),
807
+ cur_day, cur_parsed or {}, cur_cl)
808
+ # Step 2: run model + return result
809
  try:
810
+ dn, parsed, classified = advance_day(nudge_type, nudge_value, nudge_target)
811
  model_tag = f" [{_model_id_used.split('/')[-1]}]" if _model_id_used!="none" else ""
812
+ yield _render_all(dn, parsed, classified, f"✓ Day {dn} published!{model_tag}")
 
813
  except Exception:
814
+ tb = traceback.format_exc(); print(tb)
815
+ err = {"headline":"THE GAZETTE'S PRINTING PRESS HAS JAMMED",
816
+ "article":"Our correspondents report a technical malfunction. The editor is inconsolable. Beatrice Badger suspects sabotage. Reginald Fox denies everything.",
817
+ "weather":"Stormy, with a chance of errors.","briefs":{c:"Unavailable." for c in CREATURES}}
818
+ yield _render_all(cur_day, err, "LOST: One simulation. The Editor", "✗ Error printing press jammed.")
 
 
 
 
 
 
819
 
820
+ def handle_advance(d,p,c): yield from _do_advance(None,None,None,d,p,c)
821
+ def handle_rumour(cr,rt,d,p,c): yield from _do_advance("rumour",rt,cr,d,p,c)
822
+ def handle_donation(obj,d,p,c): yield from _do_advance("donation",obj,None,d,p,c)
823
+ def handle_law(law,d,p,c): yield from _do_advance("law",law,None,d,p,c)
824
 
825
  def handle_archive_view(day_num):
826
  if day_num is None:
827
+ return '<div class="archive-area"><em>Select a day above.</em></div>'
828
+ day=database.get_day(int(day_num))
829
+ if not day: return '<div class="archive-area"><em>Not found.</em></div>'
830
+ txt=day["full_newspaper_text"]; m=re.search(r"CLASSIFIEDS:\s*(.+)",txt)
831
+ cl=m.group(1) if m else random.choice(CLASSIFIEDS)
832
+ return f'<div class="archive-area">{_html_paper(_parse_newspaper_full(txt,day_num),cl,day_num)}</div>'
833
+
834
+ def handle_share(dn,p,c):
835
+ if not p or not p.get("headline"): return gr.update(visible=False)
 
 
 
 
836
  try:
837
+ path=render_newspaper_image(p,c or "",dn or 0)
838
+ return gr.update(visible=True,value=path)
839
  except Exception: return gr.update(visible=False)
840
 
841
+ def handle_export():
842
+ try: return gr.update(visible=True,value=export_agent_traces())
 
 
843
  except Exception: return gr.update(visible=False)
844
 
845
+ # ═══════════════════════════════════════════════════════════════════
846
+ # 11 ▸ GRADIO BLOCKS (Gradio 6.17.3)
847
+ # ═══════════════════════════════════════════════════════════════════
 
 
 
848
  database.init_db()
849
+ _lat=database.get_latest_day()
850
+ if _lat:
851
+ _INIT_DAY=_lat["day_number"]
852
+ _INIT_P=_parse_newspaper_full(_lat["full_newspaper_text"],_INIT_DAY)
853
+ _m=re.search(r"CLASSIFIEDS:\s*(.+)",_lat["full_newspaper_text"])
854
+ _INIT_CL=_m.group(1) if _m else random.choice(CLASSIFIEDS)
855
  else:
856
+ _INIT_DAY=0; _INIT_P={}; _INIT_CL=""
857
 
858
+ # Gradio 6: css goes to launch(), not Blocks()
859
+ _GR_MAJOR=int(gr.__version__.split(".")[0])
860
+ _BKW={} if _GR_MAJOR>=6 else {"css":NEWSPAPER_CSS}
861
+ _LKW={"css":NEWSPAPER_CSS} if _GR_MAJOR>=6 else {}
 
862
 
863
+ with gr.Blocks(title="Tiny Civilization — The Tinywick Hollow Gazette",**_BKW) as demo:
 
864
 
865
  gr.HTML(_konami_html())
866
+ gr.HTML(GLOBAL_JS)
867
+
868
+ gr.HTML("""
869
+ <div style="text-align:center;padding:8px 0 2px;">
870
+ <h1 style="font-family:'Playfair Display',Georgia,serif;color:#160800;font-size:1.9em;margin:0 0 2px;">
871
+ 🦊 Tiny Civilization 🐀</h1>
872
+ <p style="font-family:Georgia,serif;color:#5a3615;font-style:italic;margin:0;font-size:.87em;">
873
+ A persistent woodland civilisation. One day. One acorn. One absurd headline at a time.
874
+ &nbsp;|&nbsp;<kbd title="Konami Code">↑↑↓↓←→←→BA</kbd> for secrets.</p>
875
+ </div>""")
876
+
877
+ day_state=gr.State(_INIT_DAY)
878
+ par_state=gr.State(_INIT_P)
879
+ cls_state=gr.State(_INIT_CL)
880
+
881
+ status_html=gr.HTML(value=_status(
882
+ f"Day {_INIT_DAY} in the archive — next: Day {_INIT_DAY+1}." if _INIT_DAY>=0
883
+ else "No days yet. Press Advance Day to begin."))
884
+ civ_html=gr.HTML(value=_html_civ_stats())
885
 
886
  with gr.Row(equal_height=False):
 
887
  with gr.Column(scale=3):
888
+ newspaper_display=gr.HTML(
889
+ value=(_html_paper(_INIT_P,_INIT_CL,_INIT_DAY) if _INIT_P else _html_placeholder()))
890
+ with gr.Accordion("📜 Archive — Past Editions",open=False):
891
+ archive_dd=gr.Dropdown(choices=_archive_choices(),value=None,
892
+ label="Select a past day",container=False)
893
+ archive_html=gr.HTML(value='<div class="archive-area"><em>Select a day above.</em></div>')
894
+
895
+ with gr.Column(scale=1,min_width=260):
 
 
 
 
896
  gr.HTML('<div class="section-title">📰 Editorial Desk</div>')
897
+ advance_btn=gr.Button("📅 Advance Day (no nudge)",variant="primary",size="lg")
898
 
899
  gr.HTML('<hr style="border-color:#8a6030;margin:8px 0;">')
900
  gr.HTML('<div class="section-title">✉ Nudge the Story</div>')
901
+ gr.HTML('<p style="font-size:.78em;color:#5a3615;text-align:center;font-style:italic;margin:0 0 6px;">Each nudge advances one day.</p>')
 
902
 
903
+ with gr.Accordion("🗣️ Spread a Rumour",open=False):
904
+ rumour_creature=gr.Dropdown(choices=CREATURES,value=CREATURES[0],label="About which creature?")
905
+ rumour_type_dd=gr.Dropdown(choices=RUMOUR_TYPES,value=RUMOUR_TYPES[0],label="What rumour?")
906
+ rumour_btn=gr.Button("📢 Spread It",variant="secondary")
 
 
907
 
908
+ with gr.Accordion("🎁 Donate a Weird Object",open=False):
909
+ donation_dd=gr.Dropdown(choices=WEIRD_OBJECTS,value=WEIRD_OBJECTS[0],label="Which object?")
910
+ donation_btn=gr.Button("🎁 Donate It",variant="secondary")
 
911
 
912
+ with gr.Accordion("⚖️ Propose a New Law",open=False):
913
+ law_dd=gr.Dropdown(choices=LAWS,value=LAWS[0],label="Which law?")
914
+ law_btn=gr.Button("⚖️ Propose It",variant="secondary")
915
 
916
  gr.HTML('<hr style="border-color:#8a6030;margin:8px 0;">')
917
+ share_btn=gr.Button("🖼️ Share as Image",variant="secondary")
918
+ img_output=gr.Image(label="Front Page PNG",visible=False,type="filepath")
919
+ export_btn=gr.Button("📡 Export Agent Traces (JSON)",variant="secondary")
920
+ trace_output=gr.File(label="traces.json",visible=False)
921
 
922
+ gr.HTML('<p style="font-size:.70em;color:#6a4818;text-align:center;margin-top:8px;font-style:italic;">'
923
+ 'Qwen2.5-1.5B ≤4B 🐜 · Local ZeroGPU 🔌 · Custom UI 🎨</p>')
 
924
 
925
  gr.HTML('<div class="section-title" style="margin-top:12px;">Woodland Residents</div>')
926
+ creature_display=gr.HTML(value=_html_creatures())
927
 
928
+ _OUT=[newspaper_display,creature_display,civ_html,archive_dd,status_html,day_state,par_state,cls_state]
929
+ _ST=[day_state,par_state,cls_state]
930
 
931
+ advance_btn.click(fn=handle_advance, inputs=_ST, outputs=_OUT,api_name=False)
932
+ rumour_btn.click( fn=handle_rumour, inputs=[rumour_creature,rumour_type_dd]+_ST, outputs=_OUT,api_name=False)
933
+ donation_btn.click(fn=handle_donation, inputs=[donation_dd]+_ST, outputs=_OUT,api_name=False)
934
+ law_btn.click( fn=handle_law, inputs=[law_dd]+_ST, outputs=_OUT,api_name=False)
935
+ archive_dd.change(fn=handle_archive_view, inputs=[archive_dd], outputs=[archive_html],api_name=False)
936
+ share_btn.click( fn=handle_share, inputs=_ST, outputs=[img_output],api_name=False)
937
+ export_btn.click( fn=handle_export, inputs=[], outputs=[trace_output],api_name=False)
938
 
939
  # ═══════════════════════════════════════════════════════════════════
940
+ # 12 ▸ ENTRY POINT
941
  # ═══════════════════════════════════════════════════════════════════
942
  if __name__ == "__main__":
943
  demo.launch(server_name="0.0.0.0", server_port=7860, **_LKW)