nishtha711 commited on
Commit
364b60a
·
verified ·
1 Parent(s): 8185312

Delete app.py

Browse files
Files changed (1) hide show
  1. app.py +0 -1163
app.py DELETED
@@ -1,1163 +0,0 @@
1
- """
2
- app.py — Tiny Civilization: The Tinywick Hollow Gazette
3
- A persistent woodland simulation powered by a local LLM.
4
- Built for the Hugging Face Build Small Hackathon – Thousand Token Wood track.
5
- """
6
- # ═══════════════════════════════════════════════════════════════════
7
- # 0 ▸ IMPORTS
8
- # ═══════════════════════════════════════════════════════════════════
9
- from __future__ import annotations
10
-
11
- import os
12
- import random
13
- import textwrap
14
- import traceback
15
- from io import BytesIO
16
- from pathlib import Path
17
-
18
- import gradio as gr
19
- from PIL import Image, ImageDraw, ImageFont
20
-
21
- import database # our SQLite helper
22
-
23
- # ── ZeroGPU (HF Spaces) – graceful no-op when running locally ─────
24
- try:
25
- import spaces
26
- _ZERO_GPU = True
27
- except ImportError:
28
- class _SpacesStub: # pragma: no cover
29
- @staticmethod
30
- def GPU(fn=None, *, duration: int = 120):
31
- if callable(fn):
32
- return fn
33
- def _inner(f):
34
- return f
35
- return _inner
36
- spaces = _SpacesStub() # type: ignore[assignment]
37
- _ZERO_GPU = False
38
-
39
- import torch
40
- from transformers import pipeline as hf_pipeline
41
-
42
- # ═══════════════════════════════════════════════════════════════════
43
- # 1 ▸ CONSTANTS
44
- # ═══════════════════════════════════════════════════════════════════
45
- CREATURES = ["fox", "badger", "squirrel", "mole"]
46
- EVENT_TYPES = ["trade", "gossip", "feud", "invention"]
47
- CREATURE_EMOJI = {"fox": "🦊", "badger": "🦡", "squirrel": "🐿️", "mole": "🐀"}
48
-
49
- MODEL_7B = "Qwen/Qwen2.5-7B-Instruct"
50
- MODEL_3B = "Qwen/Qwen2.5-3B-Instruct"
51
-
52
- WEIRD_OBJECTS = [
53
- "half-eaten poem",
54
- "suspicious mushroom",
55
- "button that looks like the moon",
56
- "forgotten birthday",
57
- "three secrets",
58
- "a fake acorn",
59
- ]
60
-
61
- LAWS = [
62
- "no trading on Tuesdays",
63
- "buttons = currency",
64
- "everyone must compliment the badger",
65
- "mushrooms are sacred",
66
- ]
67
-
68
- RUMOUR_TYPES = [
69
- "has been secretly hoarding acorns",
70
- "was seen talking to a suspicious stranger at midnight",
71
- "invented something that does not work at all",
72
- "owes three unpayable debts",
73
- "made a deal with the rain",
74
- "owns the moon (allegedly)",
75
- ]
76
-
77
- # Relationship delta per event type
78
- REL_DELTAS = {"trade": +5, "gossip": -4, "feud": -10, "invention": +7}
79
-
80
- # ═══════════════════════════════════════════════════════════════════
81
- # 2 ▸ AGENT SYSTEM PROMPTS (also exposed by Konami easter-egg)
82
- # ═══════════════════════════════════════════════════════════════════
83
- AGENT_PROMPTS: dict[str, str] = {
84
- "fox": (
85
- "You are Reginald Fox, a charming and subtly dishonest fox who resides in "
86
- "Tinywick Hollow. You speak in an overly formal, faintly pompous manner. "
87
- "You love trading dubious goods, collecting official-looking certificates, "
88
- "and hinting at secret deals. You always refer to yourself in first person. "
89
- "Keep every response to exactly 2-3 sentences. Do not add scene descriptions "
90
- "or stage directions. Just speak as yourself."
91
- ),
92
- "badger": (
93
- "You are Beatrice Badger, the self-appointed keeper of rules in Tinywick Hollow. "
94
- "You speak in short, gruff, declarative sentences. You are deeply suspicious of "
95
- "everyone (especially the fox) but ultimately fair. Mushrooms are an extremely "
96
- "serious matter to you. You are secretly a poet but will never admit it. "
97
- "Keep every response to exactly 2-3 sentences. Just speak as yourself."
98
- ),
99
- "squirrel": (
100
- "You are Cornelius Squirrel, an anxious, hyperactive inventor who lives in "
101
- "Tinywick Hollow. You speak very quickly, with exclamation points. You often "
102
- "repeat a phrase twice! You invent things that almost-but-not-quite work. "
103
- "You are obsessed with 'efficiency' even when spectacularly inefficient. "
104
- "Keep every response to exactly 2-3 sentences. Just speak as yourself."
105
- ),
106
- "mole": (
107
- "You are Millicent Mole, a quiet and deeply philosophical mole who rarely "
108
- "surfaces in Tinywick Hollow. You speak slowly, in incomplete thoughts and "
109
- "gentle riddles. You know everyone's secrets but share them only obliquely. "
110
- "You believe all things are connected underground. "
111
- "Keep every response to exactly 2-3 sentences. Just speak as yourself."
112
- ),
113
- }
114
-
115
- NARRATOR_PROMPT = (
116
- "You are the pompous editor-in-chief of The Tinywick Hollow Gazette, "
117
- "a broadsheet newspaper for a civilisation of absurd talking woodland animals. "
118
- "Write a headline followed by a 3-4 sentence newspaper article about the day's events. "
119
- "Treat every event with the utmost journalistic gravity, no matter how ridiculous. "
120
- "FORMAT — first line: the headline in ALL CAPS (no prefix, just the headline itself). "
121
- "Then a blank line. Then the article (3-4 sentences, formal and slightly overwrought). "
122
- "Do not add anything else."
123
- )
124
-
125
- # ═══════════════════════════════════════════════════════════════════
126
- # 3 ▸ MODEL (lazy-loaded inside the @spaces.GPU context)
127
- # ═══════════════════════════════════════════════════════════════════
128
- _pipe = None # transformers text-generation pipeline
129
- _model_id_used = "" # which model was actually loaded
130
-
131
-
132
- def _load_pipeline() -> None:
133
- """Try 7B, then 3B. Stores result in module-level _pipe."""
134
- global _pipe, _model_id_used
135
- if _pipe is not None:
136
- return
137
-
138
- for mid in (MODEL_7B, MODEL_3B):
139
- try:
140
- print(f"[TinyC] Loading {mid} …", flush=True)
141
- _pipe = hf_pipeline(
142
- "text-generation",
143
- model=mid,
144
- torch_dtype=torch.float16,
145
- device_map="auto",
146
- trust_remote_code=True,
147
- )
148
- _model_id_used = mid
149
- print(f"[TinyC] {mid} loaded ✓", flush=True)
150
- return
151
- except Exception as exc:
152
- print(f"[TinyC] {mid} failed: {exc}", flush=True)
153
-
154
- raise RuntimeError("Could not load Qwen2.5-7B or Qwen2.5-3B. Check GPU memory.")
155
-
156
-
157
- def _generate(system_prompt: str, user_prompt: str, max_new_tokens: int = 200) -> str:
158
- """Raw LLM call – always called from within a GPU context."""
159
- assert _pipe is not None, "Pipeline not loaded – call _load_pipeline() first"
160
- messages = [
161
- {"role": "system", "content": system_prompt},
162
- {"role": "user", "content": user_prompt},
163
- ]
164
- try:
165
- out = _pipe(
166
- messages,
167
- max_new_tokens=max_new_tokens,
168
- temperature=0.88,
169
- top_p=0.92,
170
- do_sample=True,
171
- return_full_text=False,
172
- )
173
- return out[0]["generated_text"].strip()
174
- except Exception as exc:
175
- print(f"[TinyC] _generate error: {exc}", flush=True)
176
- return "(The Gazette's printing press has jammed. Try again.)"
177
-
178
-
179
- def call_agent(agent_name: str, context: str) -> str:
180
- """Call a creature agent with its persona prompt."""
181
- prompt = AGENT_PROMPTS.get(agent_name, AGENT_PROMPTS["fox"])
182
- return _generate(prompt, context, max_new_tokens=120)
183
-
184
-
185
- def _generate_newspaper(events_summary: str, nudge_ctx: str) -> str:
186
- extra = f"\n\nAdditional context for today: {nudge_ctx}" if nudge_ctx else ""
187
- user_prompt = (
188
- f"Today's events in Tinywick Hollow:\n{events_summary}{extra}\n\n"
189
- "Write the Gazette headline and article."
190
- )
191
- return _generate(NARRATOR_PROMPT, user_prompt, max_new_tokens=280)
192
-
193
-
194
- # ═══════════════════════════════════════════════════════════════════
195
- # 4 ▸ SIMULATION LOGIC
196
- # ═══════════════════════════════════════════════════════════════════
197
-
198
- def _nudge_context_string(
199
- nudge_type: str | None,
200
- nudge_value: str | None,
201
- nudge_target: str | None,
202
- ) -> str:
203
- if nudge_type == "rumour" and nudge_target and nudge_value:
204
- return f"A rumour is spreading that {nudge_target} {nudge_value}."
205
- if nudge_type == "donation" and nudge_value:
206
- recipient = random.choice(CREATURES)
207
- database.get_all_creatures() # ensure loaded
208
- # Add item to a random creature's inventory
209
- c = database.get_creature(recipient)
210
- if c:
211
- inv = c["inventory"]
212
- inv.append(nudge_value)
213
- if len(inv) > 12:
214
- inv = inv[-12:]
215
- database.update_creature(recipient, inventory=inv)
216
- return f"Someone anonymously donated '{nudge_value}' to {recipient}."
217
- if nudge_type == "law" and nudge_value:
218
- return f"A new law has been formally proposed: '{nudge_value}'."
219
- return ""
220
-
221
-
222
- def _collect_historical_nudge_flavour() -> str:
223
- """Pull last few nudges to give the LLM ongoing world-state context."""
224
- recent = database.get_recent_nudges(3)
225
- if not recent:
226
- return ""
227
- lines = []
228
- for n in recent:
229
- lines.append(f" • [Day {n['day_number']}] {n['nudge_type']}: {n['nudge_value']}")
230
- return "Ongoing influences from recent days:\n" + "\n".join(lines)
231
-
232
-
233
- def _run_simulation_step(
234
- nudge_type: str | None,
235
- nudge_value: str | None,
236
- nudge_target: str | None,
237
- ) -> tuple[int, str, str]:
238
- """
239
- Core simulation: generate events, update state, write newspaper.
240
- Called from within the @spaces.GPU decorated wrapper.
241
- Returns (day_number, headline, article_body).
242
- """
243
- _load_pipeline()
244
-
245
- day_number = database.get_next_day_number()
246
- creatures = database.get_all_creatures()
247
-
248
- # Build nudge context
249
- current_nudge_ctx = _nudge_context_string(nudge_type, nudge_value, nudge_target)
250
- if current_nudge_ctx and nudge_type:
251
- database.save_nudge(day_number, nudge_type,
252
- nudge_value or nudge_target or "")
253
-
254
- historical_ctx = _collect_historical_nudge_flavour()
255
- combined_ctx = "\n".join(filter(None, [current_nudge_ctx, historical_ctx]))
256
-
257
- # ── Generate 3 events ───────────────────────────────────────
258
- event_records: list[dict] = []
259
- for _ in range(3):
260
- actor = random.choice(CREATURES)
261
- others = [c for c in CREATURES if c != actor]
262
- target = random.choice(others)
263
- etype = random.choice(EVENT_TYPES)
264
-
265
- # Find actor's current relationship with target
266
- actor_data = next((c for c in creatures if c["name"] == actor), {})
267
- rel_score = actor_data.get("relationship_scores", {}).get(target, 50)
268
-
269
- prompts = {
270
- "trade": (
271
- f"You are about to propose a trade with {target} "
272
- f"(your relationship score with them: {rel_score}/100). "
273
- f"Describe exactly what you are offering and what you want in return, "
274
- f"in your unique voice."
275
- ),
276
- "gossip": (
277
- f"You have heard some gossip about {target} "
278
- f"(relationship score: {rel_score}/100). "
279
- f"Share the gossip — make it wonderfully absurd."
280
- ),
281
- "feud": (
282
- f"You are currently feuding with {target} "
283
- f"(relationship score: {rel_score}/100). "
284
- f"Describe the nature of this dispute. It should be about something trivial."
285
- ),
286
- "invention": (
287
- f"You have invented something new today. It is somehow related to {target}. "
288
- f"Describe your invention enthusiastically."
289
- ),
290
- }
291
-
292
- agent_prompt = prompts[etype]
293
- if combined_ctx:
294
- agent_prompt += f"\n\nWorld context: {combined_ctx}"
295
-
296
- description = call_agent(actor, agent_prompt)
297
-
298
- # Clean up any refusal / empty output
299
- if not description or len(description) < 10:
300
- description = f"{actor.capitalize()} did something noteworthy involving {target}."
301
-
302
- event_records.append({
303
- "actor": actor,
304
- "action": etype,
305
- "target": target,
306
- "description": description,
307
- })
308
- database.save_event(day_number, actor, etype, target, description)
309
-
310
- # ── Update relationships ─────────────────────────────
311
- delta = REL_DELTAS.get(etype, 0)
312
- for c in creatures:
313
- if c["name"] == actor:
314
- scores = c["relationship_scores"]
315
- scores[target] = max(0, min(100, scores.get(target, 50) + delta))
316
- database.update_creature(actor, relationship_scores=scores)
317
- if c["name"] == target:
318
- scores = c["relationship_scores"]
319
- scores[actor] = max(0, min(100, scores.get(actor, 50) + delta // 2))
320
- database.update_creature(target, relationship_scores=scores)
321
- # Refresh creature data after updates
322
- creatures = database.get_all_creatures()
323
-
324
- # ── Generate newspaper ───────────────────────────────────────
325
- events_summary = "\n".join(
326
- f"- {e['actor'].capitalize()} [{e['action']}] with {e['target']}: {e['description']}"
327
- for e in event_records
328
- )
329
- raw_paper = _generate_newspaper(events_summary, combined_ctx)
330
-
331
- headline, article = _parse_newspaper(raw_paper, day_number)
332
-
333
- full_text = f"{headline}\n\n{article}"
334
- database.save_day(day_number, headline, full_text)
335
-
336
- return day_number, headline, article
337
-
338
-
339
- def _parse_newspaper(raw: str, day_number: int) -> tuple[str, str]:
340
- """Extract headline (first all-caps line) and article body."""
341
- lines = [l.strip() for l in raw.strip().splitlines()]
342
- lines = [l for l in lines if l] # drop blanks
343
-
344
- headline = ""
345
- body_start = 0
346
-
347
- for i, line in enumerate(lines):
348
- # Accept a line as headline if it's mostly uppercase / short
349
- cleaned = line.strip("*_#\"'")
350
- if cleaned and (cleaned == cleaned.upper() or i == 0):
351
- headline = cleaned.upper()
352
- body_start = i + 1
353
- break
354
-
355
- article = " ".join(lines[body_start:]).strip()
356
-
357
- if not headline:
358
- headline = f"ANOTHER BEWILDERING DAY IN TINYWICK HOLLOW (DAY {day_number})"
359
- if not article:
360
- article = raw.strip()
361
-
362
- return headline, article
363
-
364
-
365
- # ═══════════════════════════════════════════════════════════════════
366
- # 5 ▸ ZERОГPU WRAPPER
367
- # ═══════════════════════════════════════════════════════════════════
368
- @spaces.GPU(duration=360)
369
- def advance_day(
370
- nudge_type: str | None = None,
371
- nudge_value: str | None = None,
372
- nudge_target: str | None = None,
373
- ) -> tuple[int, str, str]:
374
- """Public entry point for simulation — GPU context guaranteed."""
375
- return _run_simulation_step(nudge_type, nudge_value, nudge_target)
376
-
377
-
378
- # ═══════════════════════════════════════════════════════════════════
379
- # 6 ▸ PIL NEWSPAPER IMAGE
380
- # ═══════════════════════════════════════════════════════════════════
381
- _SERIF_BOLD = [
382
- "/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
383
- "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf",
384
- "/usr/share/fonts/truetype/freefont/FreeSerifBold.ttf",
385
- ]
386
- _SERIF_REG = [
387
- "/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
388
- "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf",
389
- "/usr/share/fonts/truetype/freefont/FreeSerif.ttf",
390
- ]
391
-
392
-
393
- def _try_font(paths: list[str], size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
394
- for p in paths:
395
- try:
396
- return ImageFont.truetype(p, size)
397
- except Exception:
398
- continue
399
- return ImageFont.load_default()
400
-
401
-
402
- def render_newspaper_image(headline: str, article: str, day_number: int) -> str:
403
- """Render the front page as a PNG and return the file path."""
404
- W, H = 920, 680
405
- PAPER = (245, 232, 200) # old paper cream
406
- INK = (22, 10, 4) # near-black
407
- BORDER = (72, 42, 10) # dark brown
408
- SUBINK = (88, 56, 28)
409
-
410
- img = Image.new("RGB", (W, H), PAPER)
411
- draw = ImageDraw.Draw(img)
412
-
413
- f_mast = _try_font(_SERIF_BOLD, 30)
414
- f_hed = _try_font(_SERIF_BOLD, 22)
415
- f_body = _try_font(_SERIF_REG, 13)
416
- f_small = _try_font(_SERIF_REG, 10)
417
-
418
- M = 18 # margin
419
-
420
- # ── Double border ────────────────────────────────────────────
421
- draw.rectangle([M, M, W-M, H-M], outline=BORDER, width=3)
422
- draw.rectangle([M+6, M+6, W-M-6, H-M-6], outline=BORDER, width=1)
423
-
424
- y = M + 16
425
-
426
- # ── Masthead ─────────────────────────────────────────────────
427
- MAST = "THE TINYWICK HOLLOW GAZETTE"
428
- bb = draw.textbbox((0, 0), MAST, font=f_mast)
429
- tw = bb[2] - bb[0]
430
- draw.text(((W - tw) / 2, y), MAST, fill=INK, font=f_mast)
431
- y += bb[3] - bb[1] + 4
432
-
433
- sub = f"Est. Day 1 * Day {day_number} * One Acorn Per Copy * For All Woodland Readers"
434
- bb = draw.textbbox((0, 0), sub, font=f_small)
435
- draw.text(((W - bb[2]) / 2, y), sub, fill=SUBINK, font=f_small)
436
- y += bb[3] - bb[1] + 6
437
-
438
- # Rule (double)
439
- draw.line([M+10, y, W-M-10, y], fill=BORDER, width=2)
440
- draw.line([M+10, y+5, W-M-10, y+5], fill=BORDER, width=1)
441
- y += 18
442
-
443
- # ── Headline (wrapped, centred) ───────────────────────────────
444
- for line in textwrap.wrap(headline, width=52):
445
- bb = draw.textbbox((0, 0), line, font=f_hed)
446
- draw.text(((W - (bb[2] - bb[0])) / 2, y), line, fill=INK, font=f_hed)
447
- y += bb[3] - bb[1] + 2
448
- y += 4
449
-
450
- draw.line([M+10, y, W-M-10, y], fill=BORDER, width=1)
451
- y += 12
452
-
453
- # ── Two-column article ────────────────────────────────────────
454
- PAD = M + 14
455
- COL_GAP = 28
456
- col_w = (W - 2*PAD - COL_GAP) // 2
457
- col1_x = PAD
458
- col2_x = PAD + col_w + COL_GAP
459
- LINE_H = 16
460
-
461
- wrapped = textwrap.wrap(article, width=46)
462
- mid = max(1, len(wrapped) // 2)
463
- max_y = H - M - 40
464
-
465
- ly = y
466
- for line in wrapped[:mid]:
467
- if ly + LINE_H > max_y:
468
- break
469
- draw.text((col1_x, ly), line, fill=INK, font=f_body)
470
- ly += LINE_H
471
-
472
- div_x = col1_x + col_w + COL_GAP // 2
473
- draw.line([div_x, y, div_x, min(ly, max_y)], fill=SUBINK, width=1)
474
-
475
- ry = y
476
- for line in wrapped[mid:]:
477
- if ry + LINE_H > max_y:
478
- break
479
- draw.text((col2_x, ry), line, fill=INK, font=f_body)
480
- ry += LINE_H
481
-
482
- # ── Footer ────────────────────────────────────────────────────
483
- fy = H - M - 28
484
- draw.line([M+10, fy, W-M-10, fy], fill=BORDER, width=1)
485
- footer = (f"Fox - Badger - Squirrel - Mole "
486
- f"| (c) The Tinywick Hollow Gazette, Day {day_number}")
487
- bb = draw.textbbox((0, 0), footer, font=f_small)
488
- draw.text(((W - (bb[2] - bb[0])) / 2, fy + 6), footer, fill=SUBINK, font=f_small)
489
-
490
- path = f"/tmp/tinywick_day_{day_number}.png"
491
- img.save(path, "PNG")
492
- return path
493
-
494
-
495
- # ═══════════════════════════════════════════════════════════════════
496
- # 7 ▸ CSS
497
- # ═══════════════════════════════════════════════════════════════════
498
- NEWSPAPER_CSS = r"""
499
- /* ── Google Fonts ── */
500
- @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');
501
-
502
- /* ── Page background ── */
503
- body, .gradio-container {
504
- background: #c9b89a !important;
505
- font-family: 'Libre Baskerville', Georgia, serif !important;
506
- }
507
-
508
- /* ── Newspaper wrapper ── */
509
- .paper-wrap {
510
- background: #f4e9d2;
511
- background-image:
512
- linear-gradient(rgba(160,120,70,.06) 1px, transparent 1px),
513
- linear-gradient(90deg, rgba(160,120,70,.04) 1px, transparent 1px);
514
- background-size: 100% 22px, 80px 100%;
515
- border: 3px solid #4a2c0a;
516
- border-radius: 2px;
517
- padding: 22px 28px 18px;
518
- box-shadow: 5px 5px 24px rgba(0,0,0,.32),
519
- inset 0 0 80px rgba(180,140,90,.18);
520
- margin: 8px 0;
521
- position: relative;
522
- }
523
- .paper-wrap::before, .paper-wrap::after {
524
- content: "";
525
- display: block;
526
- border: 1px solid #4a2c0a;
527
- position: absolute;
528
- pointer-events: none;
529
- }
530
- .paper-wrap::before { inset: 7px; }
531
-
532
- /* ── Masthead ── */
533
- .paper-masthead {
534
- font-family: 'UnifrakturMaguntia', 'Playfair Display', Georgia, serif;
535
- font-size: 2.3em;
536
- text-align: center;
537
- color: #1a0a04;
538
- border-top: 4px double #4a2c0a;
539
- border-bottom: 4px double #4a2c0a;
540
- padding: 6px 0;
541
- margin-bottom: 4px;
542
- letter-spacing: 1px;
543
- }
544
- .paper-sub {
545
- font-family: 'Libre Baskerville', Georgia, serif;
546
- font-size: 0.73em;
547
- color: #5a3820;
548
- text-align: center;
549
- font-style: italic;
550
- margin-bottom: 10px;
551
- }
552
- .paper-rule {
553
- border: none;
554
- border-top: 2px solid #4a2c0a;
555
- margin: 2px 0 8px;
556
- }
557
- .paper-rule-thin {
558
- border: none;
559
- border-top: 1px solid #8a6040;
560
- margin: 4px 0;
561
- }
562
-
563
- /* ── Headline ── */
564
- .paper-headline {
565
- font-family: 'Playfair Display', Georgia, serif;
566
- font-size: 1.75em;
567
- font-weight: 900;
568
- text-align: center;
569
- text-transform: uppercase;
570
- color: #0d0602;
571
- line-height: 1.15;
572
- margin: 8px 0 10px;
573
- }
574
-
575
- /* ── Article body (two-column feel via CSS) ── */
576
- .paper-article {
577
- font-family: 'Libre Baskerville', Georgia, serif;
578
- font-size: 0.9em;
579
- color: #1a0c06;
580
- line-height: 1.72;
581
- text-align: justify;
582
- column-count: 2;
583
- column-gap: 28px;
584
- column-rule: 1px solid #9a7050;
585
- padding: 6px 4px 0;
586
- }
587
-
588
- /* ── Day badge ── */
589
- .paper-daybadge {
590
- font-family: 'Libre Baskerville', Georgia, serif;
591
- font-size: 0.78em;
592
- color: #5a3820;
593
- text-align: center;
594
- border-top: 1px solid #9a7050;
595
- margin-top: 10px;
596
- padding-top: 6px;
597
- }
598
-
599
- /* ── Status bar ── */
600
- .status-strip {
601
- background: #d6c4a2;
602
- border: 1px solid #8a6040;
603
- border-radius: 3px;
604
- padding: 6px 14px;
605
- font-family: 'Libre Baskerville', Georgia, serif;
606
- font-size: 0.83em;
607
- color: #2c1810;
608
- text-align: center;
609
- margin: 4px 0;
610
- }
611
-
612
- /* ── Creature cards ── */
613
- .creature-card {
614
- background: #f7eed8;
615
- border: 1px solid #9a7050;
616
- border-radius: 3px;
617
- padding: 9px 12px;
618
- font-family: 'Libre Baskerville', Georgia, serif;
619
- font-size: 0.82em;
620
- color: #1c0e08;
621
- flex: 1;
622
- min-width: 160px;
623
- }
624
- .creature-name {
625
- font-weight: 700;
626
- font-size: 1em;
627
- color: #2c1008;
628
- display: block;
629
- margin-bottom: 3px;
630
- }
631
-
632
- /* ── Section titles ── */
633
- .section-title {
634
- font-family: 'Playfair Display', Georgia, serif;
635
- font-weight: 700;
636
- color: #2c1008;
637
- font-size: 1em;
638
- text-transform: uppercase;
639
- letter-spacing: 1px;
640
- text-align: center;
641
- border-bottom: 1px solid #8a6040;
642
- padding-bottom: 4px;
643
- margin: 8px 0 10px;
644
- }
645
-
646
- /* ── Archive display area ── */
647
- .archive-area {
648
- background: #f0e4c8;
649
- border: 1px solid #9a7050;
650
- border-radius: 3px;
651
- padding: 10px;
652
- margin-top: 6px;
653
- min-height: 60px;
654
- }
655
-
656
- /* ── Buttons ── */
657
- button.lg { font-family: 'Libre Baskerville', Georgia, serif !important; }
658
-
659
- /* ── Konami modal ── */
660
- #konami-modal {
661
- display: none;
662
- position: fixed;
663
- z-index: 99999;
664
- top: 50%; left: 50%;
665
- transform: translate(-50%, -50%);
666
- width: min(640px, 92vw);
667
- max-height: 78vh;
668
- overflow-y: auto;
669
- background: #f4e9d2;
670
- border: 3px solid #4a2c0a;
671
- box-shadow: 8px 8px 36px rgba(0,0,0,.55);
672
- padding: 24px 28px 20px;
673
- font-family: 'Libre Baskerville', Georgia, serif;
674
- }
675
- #konami-modal h2 {
676
- font-family: 'Playfair Display', Georgia, serif;
677
- color: #1a0a04;
678
- margin-top: 0;
679
- }
680
- #konami-modal details { margin: 8px 0; }
681
- #konami-modal summary {
682
- cursor: pointer;
683
- font-weight: 700;
684
- color: #4a2c0a;
685
- }
686
- #konami-modal pre {
687
- background: #e8d8b8;
688
- border: 1px solid #9a7050;
689
- padding: 10px;
690
- font-size: 0.78em;
691
- white-space: pre-wrap;
692
- word-break: break-word;
693
- border-radius: 3px;
694
- margin: 6px 0 0;
695
- }
696
- #konami-close {
697
- position: absolute;
698
- top: 10px; right: 14px;
699
- cursor: pointer;
700
- font-size: 1.4em;
701
- color: #4a2c0a;
702
- background: none;
703
- border: none;
704
- font-family: serif;
705
- }
706
- #konami-backdrop {
707
- display: none;
708
- position: fixed;
709
- inset: 0;
710
- background: rgba(0,0,0,.45);
711
- z-index: 99998;
712
- }
713
- """
714
-
715
- # ═══════════════════════════════════════════════════════════════════
716
- # 8 ▸ JAVASCRIPT
717
- # ═══════════════════════════════════════════════════════════════════
718
- KONAMI_JS = r"""
719
- <script>
720
- (function() {
721
- var SEQ = ['ArrowUp','ArrowUp','ArrowDown','ArrowDown',
722
- 'ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
723
- var idx = 0;
724
-
725
- document.addEventListener('keydown', function(e) {
726
- if (e.key === SEQ[idx]) {
727
- idx++;
728
- if (idx === SEQ.length) { idx = 0; showKonami(); }
729
- } else {
730
- idx = (e.key === SEQ[0]) ? 1 : 0;
731
- }
732
- });
733
-
734
- window.showKonami = function() {
735
- document.getElementById('konami-backdrop').style.display = 'block';
736
- document.getElementById('konami-modal').style.display = 'block';
737
- };
738
- window.hideKonami = function() {
739
- document.getElementById('konami-backdrop').style.display = 'none';
740
- document.getElementById('konami-modal').style.display = 'none';
741
- };
742
- })();
743
- </script>
744
- """
745
-
746
-
747
- # ═══════════════════════════════════════════════════════════════════
748
- # 9 ▸ HTML FORMATTERS
749
- # ═══════════════════════════════════════════════════════════════════
750
-
751
- def _html_paper(headline: str, article: str, day_num: int) -> str:
752
- escaped_hed = headline.replace("<", "&lt;").replace(">", "&gt;")
753
- escaped_body = article.replace("<", "&lt;").replace(">", "&gt;")
754
- emojis = " ".join(f"{CREATURE_EMOJI[c]} {c.capitalize()}" for c in CREATURES)
755
- return f"""
756
- <div class="paper-wrap">
757
- <div class="paper-masthead">The Tinywick Hollow Gazette</div>
758
- <div class="paper-sub">Est. Day&nbsp;1 &nbsp;✦&nbsp; Day&nbsp;{day_num}
759
- &nbsp;✦&nbsp; One Acorn Per Copy &nbsp;✦&nbsp; Serving the Woodland Community</div>
760
- <hr class="paper-rule">
761
- <div class="paper-headline">{escaped_hed}</div>
762
- <hr class="paper-rule-thin">
763
- <div class="paper-article">{escaped_body}</div>
764
- <div class="paper-daybadge">— Day {day_num} — &nbsp;&nbsp; {emojis}</div>
765
- </div>
766
- """
767
-
768
-
769
- def _html_placeholder() -> str:
770
- return """
771
- <div class="paper-wrap">
772
- <div class="paper-masthead">The Tinywick Hollow Gazette</div>
773
- <div class="paper-sub">Est. Day 1 &nbsp;✦&nbsp; One Acorn Per Copy</div>
774
- <hr class="paper-rule">
775
- <div class="paper-headline">AWAITING FIRST LIGHT IN TINYWICK HOLLOW</div>
776
- <hr class="paper-rule-thin">
777
- <div class="paper-article">
778
- The hollow is still. Reginald Fox has not yet stirred from his den.
779
- Beatrice Badger has not yet issued any proclamations. Cornelius Squirrel
780
- has not yet invented anything that almost works. Millicent Mole has not yet
781
- surfaced with an oblique observation. Press <em>Advance Day</em> to begin
782
- the chronicle of this peculiar civilisation.
783
- </div>
784
- <div class="paper-daybadge">— Day 0 — &nbsp; Awaiting commencement</div>
785
- </div>
786
- """
787
-
788
-
789
- def _html_creatures() -> str:
790
- creatures = database.get_all_creatures()
791
- cards = ""
792
- for c in creatures:
793
- emoji = CREATURE_EMOJI.get(c["name"], "?")
794
- rel = ", ".join(
795
- f"{k}: {v}" for k, v in sorted(c["relationship_scores"].items())
796
- )
797
- inv = (", ".join(c["inventory"][:3]) + ("…" if len(c["inventory"]) > 3 else "")) \
798
- or "nothing"
799
- cards += f"""
800
- <div class="creature-card">
801
- <span class="creature-name">{emoji} {c['name'].capitalize()}</span>
802
- <em>Carries:</em> {inv}<br>
803
- <small><em>Relations:</em> {rel}</small>
804
- </div>
805
- """
806
- return f'<div style="display:flex;gap:8px;flex-wrap:wrap;">{cards}</div>'
807
-
808
-
809
- def _archive_choices() -> list[tuple[str, int]]:
810
- headlines = database.get_all_headlines()
811
- if not headlines:
812
- return []
813
- return [
814
- (f"Day {dn}: {h[:45]}{'…' if len(h)>45 else ''}", dn)
815
- for dn, h in headlines
816
- ]
817
-
818
-
819
- def _status(msg: str) -> str:
820
- return f'<div class="status-strip">{msg}</div>'
821
-
822
-
823
- # ═══════════════════════════════════════════════════════════════════
824
- # 10 ▸ GRADIO EVENT HANDLERS
825
- # ═══════════════════════════════════════════════════════════════════
826
-
827
- def _update_all(
828
- day_num: int, headline: str, article: str, status_msg: str
829
- ) -> tuple:
830
- """Return values for all shared outputs."""
831
- return (
832
- _html_paper(headline, article, day_num), # newspaper_display
833
- _html_creatures(), # creature_display
834
- gr.update(choices=_archive_choices(), value=None), # archive_dd
835
- _status(status_msg), # status_html
836
- day_num, # day_state
837
- headline, # hed_state
838
- article, # art_state
839
- )
840
-
841
-
842
- def handle_advance() -> tuple:
843
- try:
844
- dn, hed, art = advance_day()
845
- return _update_all(dn, hed, art, f"✓ Day {dn} published to the Gazette.")
846
- except Exception:
847
- tb = traceback.format_exc()
848
- print(tb)
849
- return _update_all(0, "PRESS ERROR", tb[:300], "✗ Simulation error — check logs.")
850
-
851
-
852
- def handle_rumour(creature: str, rumour: str) -> tuple:
853
- try:
854
- dn, hed, art = advance_day("rumour", rumour, creature)
855
- msg = f"✓ Day {dn}: Rumour about {creature} has spread through the hollow."
856
- return _update_all(dn, hed, art, msg)
857
- except Exception:
858
- print(traceback.format_exc())
859
- return _update_all(0, "RUMOUR SUPPRESSED", "The rumour never left the burrow.", "✗ Error.")
860
-
861
-
862
- def handle_donation(obj: str) -> tuple:
863
- try:
864
- dn, hed, art = advance_day("donation", obj, None)
865
- msg = f"✓ Day {dn}: '{obj}' has been donated — someone is now confused."
866
- return _update_all(dn, hed, art, msg)
867
- except Exception:
868
- print(traceback.format_exc())
869
- return _update_all(0, "DONATION LOST", "The object was never found.", "✗ Error.")
870
-
871
-
872
- def handle_law(law: str) -> tuple:
873
- try:
874
- dn, hed, art = advance_day("law", law, None)
875
- msg = f"✓ Day {dn}: New law proposed — '{law}'. Compliance uncertain."
876
- return _update_all(dn, hed, art, msg)
877
- except Exception:
878
- print(traceback.format_exc())
879
- return _update_all(0, "LAW STRUCK DOWN", "The proposal was eaten by a mole.", "✗ Error.")
880
-
881
-
882
- def handle_archive_view(day_num: int | None) -> str:
883
- if day_num is None:
884
- return '<div class="archive-area"><em>Select a day above to view its front page.</em></div>'
885
- day = database.get_day(int(day_num))
886
- if not day:
887
- return '<div class="archive-area"><em>Day not found in the archive.</em></div>'
888
- text = day["full_newspaper_text"]
889
- parts = text.split("\n\n", 1)
890
- hed = parts[0] if parts else "UNKNOWN"
891
- art = parts[1] if len(parts) > 1 else text
892
- return f'<div class="archive-area">{_html_paper(hed, art, day_num)}</div>'
893
-
894
-
895
- def handle_share(day_num: int, headline: str, article: str):
896
- if day_num == 0 or not headline:
897
- return gr.update(visible=False, value=None)
898
- try:
899
- path = render_newspaper_image(headline, article, day_num)
900
- return gr.update(visible=True, value=path)
901
- except Exception:
902
- print(traceback.format_exc())
903
- return gr.update(visible=False, value=None)
904
-
905
-
906
- # ═══════════════════════════════════════════════════════════════════
907
- # 11 ▸ BUILD KONAMI MODAL HTML
908
- # ��══════════════════════════════════════════════════════════════════
909
- def _konami_modal_html() -> str:
910
- import html as _html
911
- details = ""
912
- for name, prompt in AGENT_PROMPTS.items():
913
- emoji = CREATURE_EMOJI.get(name, "")
914
- esc = _html.escape(prompt)
915
- details += f"""
916
- <details>
917
- <summary>{emoji} <strong>{name.upper()}</strong></summary>
918
- <pre>{esc}</pre>
919
- </details>"""
920
- esc_narrator = _html.escape(NARRATOR_PROMPT)
921
- details += f"""
922
- <details>
923
- <summary>📰 <strong>NARRATOR (Gazette Editor)</strong></summary>
924
- <pre>{esc_narrator}</pre>
925
- </details>"""
926
- return f"""
927
- <div id="konami-backdrop" onclick="hideKonami()"></div>
928
- <div id="konami-modal" role="dialog" aria-modal="true">
929
- <button id="konami-close" onclick="hideKonami()" title="Close">✕</button>
930
- <h2>🔮 Secret Agent Briefing</h2>
931
- <p>You found the Konami Code Easter Egg! Here are the raw system prompts
932
- that drive our woodland correspondents and the Gazette editor:</p>
933
- {details}
934
- <hr style="border-color:#9a7050;margin:16px 0 10px;">
935
- <p style="text-align:center;font-style:italic;color:#5a3820;font-size:.85em;">
936
- ↑↑↓↓←→←→BA — only the woodland elite know this. 🎮
937
- </p>
938
- </div>
939
- {KONAMI_JS}
940
- """
941
-
942
-
943
- # ═══════════════════════════════════════════════════════════════════
944
- # 12 ▸ GRADIO BLOCKS APP
945
- # ═══════════════════════════════════════════════════════════════════
946
-
947
- # ── Version-aware css / theme routing ────────────────────────────
948
- # gradio 4.x → css + theme (with constructor args) in gr.Blocks()
949
- # gradio 5.x → css in gr.Blocks(); theme constructor changed, skip it
950
- # gradio 6.x → css moved to launch(); Blocks() takes neither
951
- _GR_MAJOR = int(gr.__version__.split(".")[0])
952
- if _GR_MAJOR >= 6:
953
- _BLOCKS_KW: dict = {}
954
- _LAUNCH_KW: dict = {"css": NEWSPAPER_CSS}
955
- elif _GR_MAJOR >= 5:
956
- # 5.x: css still lives in Blocks; theme API dropped constructor params
957
- _BLOCKS_KW = {"css": NEWSPAPER_CSS}
958
- _LAUNCH_KW: dict = {}
959
- else:
960
- # 4.x: full theme + css in Blocks
961
- _BLOCKS_KW = {
962
- "css": NEWSPAPER_CSS,
963
- "theme": gr.themes.Base(
964
- primary_hue="orange",
965
- neutral_hue="stone",
966
- ),
967
- }
968
- _LAUNCH_KW: dict = {}
969
-
970
- # Initialise DB and read starting state
971
- database.init_db()
972
- _latest = database.get_latest_day()
973
- if _latest:
974
- _parts = _latest["full_newspaper_text"].split("\n\n", 1)
975
- _INIT_DAY = _latest["day_number"]
976
- _INIT_HED = _parts[0]
977
- _INIT_ART = _parts[1] if len(_parts) > 1 else _latest["full_newspaper_text"]
978
- else:
979
- _INIT_DAY = 0
980
- _INIT_HED = ""
981
- _INIT_ART = ""
982
-
983
- # Shared output definition (returned by every nudge/advance handler)
984
- _COMMON = 7 # newspaper, creatures, archive_dd, status, day_state, hed_state, art_state
985
-
986
- with gr.Blocks(
987
- title="Tiny Civilization — The Tinywick Hollow Gazette",
988
- **_BLOCKS_KW,
989
- ) as demo:
990
-
991
- # ── Konami modal (injected before everything else) ───────────
992
- gr.HTML(_konami_modal_html())
993
-
994
- # ── Page header ──────────────────────────────────────────────
995
- gr.HTML("""
996
- <div style="text-align:center;padding:8px 0 4px;">
997
- <h1 style="font-family:'Playfair Display',Georgia,serif;color:#1a0a04;
998
- font-size:1.8em;margin:0 0 2px;">
999
- 🦊 Tiny Civilization 🐀
1000
- </h1>
1001
- <p style="font-family:Georgia,serif;color:#5a3820;font-style:italic;
1002
- margin:0;font-size:.88em;">
1003
- A persistent woodland civilisation. One day at a time. One acorn at a time.
1004
- &nbsp;|&nbsp; <kbd title="Konami Code">↑↑↓↓←→←→BA</kbd> for secrets.
1005
- </p>
1006
- </div>
1007
- """)
1008
-
1009
- # ── Persistent state ─────────────────────────────────────────
1010
- day_state = gr.State(_INIT_DAY)
1011
- hed_state = gr.State(_INIT_HED)
1012
- art_state = gr.State(_INIT_ART)
1013
-
1014
- # ── Status bar ───────────────────────────────────────────────
1015
- status_html = gr.HTML(
1016
- value=_status(
1017
- f"Day {_INIT_DAY} already in the archive — ready for Day {_INIT_DAY+1}."
1018
- if _INIT_DAY > 0 else
1019
- "No days recorded yet. Click Advance Day to start the chronicle."
1020
- )
1021
- )
1022
-
1023
- # ── Main layout ──────────────────────────────────────────────
1024
- with gr.Row(equal_height=False):
1025
-
1026
- # ── LEFT: Newspaper display ──────────────────────────────
1027
- with gr.Column(scale=3):
1028
- newspaper_display = gr.HTML(
1029
- value=_html_paper(_INIT_HED, _INIT_ART, _INIT_DAY)
1030
- if _INIT_DAY > 0 else _html_placeholder()
1031
- )
1032
-
1033
- # Archive
1034
- with gr.Accordion("📜 Archive — Past Front Pages", open=False):
1035
- archive_dd = gr.Dropdown(
1036
- choices=_archive_choices(),
1037
- value=None,
1038
- label="Select a past day",
1039
- container=False,
1040
- )
1041
- archive_display = gr.HTML(
1042
- value='<div class="archive-area">'
1043
- '<em>Select a day above to view its edition.</em></div>'
1044
- )
1045
-
1046
- # ── RIGHT: Controls ──────────────────────────────────────
1047
- with gr.Column(scale=1, min_width=240):
1048
-
1049
- gr.HTML('<div class="section-title">📰 Editorial Desk</div>')
1050
-
1051
- advance_btn = gr.Button(
1052
- "📅 Advance Day (no nudge)",
1053
- variant="primary",
1054
- size="lg",
1055
- )
1056
-
1057
- gr.HTML('<hr style="border-color:#9a7050;margin:10px 0;">')
1058
- gr.HTML('<div class="section-title">✉ Nudge the Story</div>')
1059
- gr.HTML('<p style="font-size:.8em;color:#5a3820;text-align:center;'
1060
- 'font-style:italic;margin:0 0 8px;">Each nudge advances the day.</p>')
1061
-
1062
- # Nudge 1 — Rumour
1063
- with gr.Accordion("🗣️ Spread a Rumour", open=False):
1064
- rumour_creature = gr.Dropdown(
1065
- choices=CREATURES,
1066
- value=CREATURES[0],
1067
- label="About which creature?",
1068
- )
1069
- rumour_type_dd = gr.Dropdown(
1070
- choices=RUMOUR_TYPES,
1071
- value=RUMOUR_TYPES[0],
1072
- label="What kind of rumour?",
1073
- )
1074
- rumour_btn = gr.Button("📢 Spread It", variant="secondary")
1075
-
1076
- # Nudge 2 — Donation
1077
- with gr.Accordion("🎁 Donate a Weird Object", open=False):
1078
- donation_dd = gr.Dropdown(
1079
- choices=WEIRD_OBJECTS,
1080
- value=WEIRD_OBJECTS[0],
1081
- label="Which object?",
1082
- )
1083
- donation_btn = gr.Button("🎁 Donate It", variant="secondary")
1084
-
1085
- # Nudge 3 — Law
1086
- with gr.Accordion("⚖️ Propose a New Law", open=False):
1087
- law_dd = gr.Dropdown(
1088
- choices=LAWS,
1089
- value=LAWS[0],
1090
- label="Which law?",
1091
- )
1092
- law_btn = gr.Button("⚖️ Propose It", variant="secondary")
1093
-
1094
- gr.HTML('<hr style="border-color:#9a7050;margin:10px 0;">')
1095
-
1096
- share_btn = gr.Button("🖼️ Share as Image", variant="secondary")
1097
- img_output = gr.Image(
1098
- label="Front Page PNG",
1099
- visible=False,
1100
- type="filepath",
1101
- )
1102
-
1103
- gr.HTML(
1104
- '<p style="font-size:.72em;color:#6a4828;text-align:center;'
1105
- 'margin-top:8px;font-style:italic;">'
1106
- 'Model: Qwen2.5-7B (or 3B fallback)<br>'
1107
- 'Powered by ZeroGPU · Tiny Civilization</p>'
1108
- )
1109
-
1110
- # ── Creature status ──────────────────────────────────────────
1111
- gr.HTML('<div class="section-title" style="margin-top:14px;">Woodland Residents</div>')
1112
- creature_display = gr.HTML(value=_html_creatures())
1113
-
1114
- # ── Wire outputs list ─────────────────────────────────────────
1115
- _OUTPUTS = [
1116
- newspaper_display,
1117
- creature_display,
1118
- archive_dd,
1119
- status_html,
1120
- day_state,
1121
- hed_state,
1122
- art_state,
1123
- ]
1124
-
1125
- # ── Event wiring ─────────────────────────────────────────────
1126
- advance_btn.click(fn=handle_advance, inputs=[], outputs=_OUTPUTS)
1127
-
1128
- rumour_btn.click(
1129
- fn=handle_rumour,
1130
- inputs=[rumour_creature, rumour_type_dd],
1131
- outputs=_OUTPUTS,
1132
- )
1133
-
1134
- donation_btn.click(
1135
- fn=handle_donation,
1136
- inputs=[donation_dd],
1137
- outputs=_OUTPUTS,
1138
- )
1139
-
1140
- law_btn.click(
1141
- fn=handle_law,
1142
- inputs=[law_dd],
1143
- outputs=_OUTPUTS,
1144
- )
1145
-
1146
- archive_dd.change(
1147
- fn=handle_archive_view,
1148
- inputs=[archive_dd],
1149
- outputs=[archive_display],
1150
- )
1151
-
1152
- share_btn.click(
1153
- fn=handle_share,
1154
- inputs=[day_state, hed_state, art_state],
1155
- outputs=[img_output],
1156
- )
1157
-
1158
-
1159
- # ═══════════════════════════════════════════════════════════════════
1160
- # 13 ▸ ENTRY POINT
1161
- # ═══════════════════════════════════════════════════════════════════
1162
- if __name__ == "__main__":
1163
- demo.launch(server_name="0.0.0.0", server_port=7860, **_LAUNCH_KW)