nishtha711 commited on
Commit
1289a8d
·
verified ·
1 Parent(s): 82b4b28

Upload 4 files

Browse files
Files changed (4) hide show
  1. BLOG_POST_DRAFT.md +106 -0
  2. app.py +963 -0
  3. database.py +237 -0
  4. requirements.txt +17 -0
BLOG_POST_DRAFT.md ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: "Tiny Civilization: Building a Persistent Woodland Newspaper with a 1.5B Model"
3
+ tags: [build-small-hackathon, gradio, qwen, simulation, creative-ai]
4
+ ---
5
+
6
+ # Tiny Civilization: Building a Persistent Woodland Newspaper with a 1.5B Model
7
+
8
+ *A Build Small Hackathon field report — Thousand Token Wood track.*
9
+
10
+ **Try it first:** [Tiny Civilization on Hugging Face Spaces](https://huggingface.co/spaces/build-small-hackathon/tiny-civilization) · [Agent Traces](https://huggingface.co/datasets/build-small-hackathon/tiny-civilization-traces)
11
+
12
+ ---
13
+
14
+ Every day in Tinywick Hollow, four woodland creatures do something absurd. Reginald Fox forges a certificate. Beatrice Badger issues a decree. Cornelius Squirrel invents something that almost works. Millicent Mole observes, obliquely, that something is already happening underground.
15
+
16
+ Then *The Tinywick Hollow Gazette* covers it all with the utmost journalistic gravity.
17
+
18
+ I built **Tiny Civilization** for the Build Small Hackathon. It is a persistent narrative simulation: four AI agents — each with a distinct persona — generate daily events, and a fifth narrator agent writes a full newspaper front page treating everything with pompous seriousness. You read the paper, nudge the story (spread a rumour, donate a suspicious mushroom, propose a law), and the civilization accumulates history across sessions in a SQLite database.
19
+
20
+ The whole thing runs on **Qwen2.5-1.5B-Instruct**. 1.5 billion parameters. Qualifies for the ≤4B Tiny Titan category. No external APIs. Fully local on ZeroGPU.
21
+
22
+ ---
23
+
24
+ ## Why a newspaper?
25
+
26
+ Most multi-agent simulations show you numbers: prices, scores, wealth distributions. I wanted to show you *stories*. A newspaper is a machine for turning events into narrative with stakes. The formal journalistic register — "In a development that surprised absolutely no one who knows these four..." — makes even the most trivial event feel significant.
27
+
28
+ The newspaper format also solves a small-model problem: instead of asking the model to reason about emergent economic dynamics (hard for 1.5B), I ask it to write two sentences in a character's voice (easy), then one paragraph in a pompous journalistic style (easy). Small models are excellent format generators and unreliable reasoners. Design to their strengths.
29
+
30
+ ---
31
+
32
+ ## Architecture: five calls, one day
33
+
34
+ Each day advancement makes exactly **five LLM calls**:
35
+
36
+ ```
37
+ advance_day()
38
+ ├─ call_agent("fox", trade_prompt) → ~80 tokens
39
+ ├─ call_agent("badger", gossip_prompt) → ~80 tokens
40
+ ├─ call_agent("squirrel",invention_prompt) → ~80 tokens
41
+ └─ _generate_newspaper(events_summary) → ~280 tokens
42
+ ```
43
+
44
+ Total per day: **~520 tokens across 4 calls** (after moving from 3 events + weather to a single structured newspaper call). On a T4 GPU, 1.5B at ~50 tok/s completes in under 15 seconds. That fits comfortably within ZeroGPU's 120-second window.
45
+
46
+ The newspaper call uses a rigid output format:
47
+
48
+ ```
49
+ HEADLINE IN ALL CAPS
50
+
51
+ Pompous article paragraph (3-4 sentences).
52
+
53
+ WEATHER: One absurd one-line forecast.
54
+ FOX: One line about the fox's day.
55
+ BADGER: One line about the badger's day.
56
+ SQUIRREL: One line about the squirrel's day.
57
+ MOLE: One cryptic line.
58
+ ```
59
+
60
+ 1.5B honours this format ~85% of the time. The parser is tolerant: it finds the first all-caps line as the headline, then extracts labelled sections by prefix, and falls back gracefully when sections are missing. A bad output degrades to a slightly sparse front page — never a crash.
61
+
62
+ ---
63
+
64
+ ## Persistence and history
65
+
66
+ The simulation stores everything in SQLite: every day, every event, every nudge, every creature's relationship scores and inventory. The `days` table becomes the newspaper archive. On restart, the civilization picks up exactly where it left off.
67
+
68
+ More importantly: past content influences future content. The newspaper prompt includes the last two headlines, so the model can write "In a development reminiscent of last Tuesday's acorn scandal..." without any extra machinery. History accumulates naturally through the context window.
69
+
70
+ Creature relationship scores drift based on event type (trade: +5, feud: −10, invention: +7) and feed back into agent prompts: a fox with a relationship score of 12 with the badger gets a different prompt than a fox at 88. The creatures' inventories grow as players donate items. A "map to somewhere that may not exist" donated to Millicent Mole will appear in her prompt next time she acts.
71
+
72
+ ---
73
+
74
+ ## What I learned about 1.5B models
75
+
76
+ **Format adherence is the game.** A 1.5B model will follow a clear, demonstrated format with high reliability. Give it a schema, not a vague instruction. `WEATHER: [one sentence]` works far better than `include an absurd weather note`.
77
+
78
+ **Persona injection works at 1.5B.** The four creatures have distinct voices that survive even the smallest model. Cornelius Squirrel really does use exclamation points and repeat himself. Beatrice Badger really is gruff and declarative. Character consistency emerged from 3-4 sentences of persona description — no fine-tuning needed.
79
+
80
+ **Fail loudly into fallbacks, not silently into wrong output.** Every LLM call is wrapped: if the response is empty or malformed, the simulation generates a plausible fallback ("The printing press has jammed") rather than surfacing an opaque API error. The experience degrades gracefully.
81
+
82
+ **Don't fight the model's weaknesses.** I originally asked the model to reason about which creature would be *most affected* by a nudge. The outputs were random. I switched to pre-computing the target and asking the model to react — suddenly the responses were coherent. Push reasoning into your code; ask the model for language.
83
+
84
+ ---
85
+
86
+ ## The newspaper as UI
87
+
88
+ The Gradio app is built to look like a physical newspaper. Every element — the UnifrakturMaguntia masthead font, the double-border, the two-column article layout, the "In Brief" sidebar, the classifieds footer — is CSS. No Gradio default styling survives. The `@import url(Google Fonts)` at the top of the CSS block does a lot of work.
89
+
90
+ The result is a front page that wants to be screenshot and shared. The "Share as Image" button renders the current edition as a PIL image with the same layout, ready for posting.
91
+
92
+ The Konami Code (↑↑↓↓←→←→BA) reveals all four agent system prompts and the narrator prompt in a modal — a small prize for curious judges.
93
+
94
+ ---
95
+
96
+ ## What's next
97
+
98
+ The civilization is eight days old as I write this. The fox's relationship with the badger has deteriorated to 18 (Sworn Enemies ⚔️). Cornelius Squirrel recently donated a "map to somewhere that may not exist" and it has appeared in three subsequent headlines. A law proposing that "mushrooms are sacred" passed on Day 4 and keeps surfacing in the Gazette's coverage.
99
+
100
+ Small models, persistent worlds, big headlines.
101
+
102
+ *Try it: [huggingface.co/spaces/build-small-hackathon/tiny-civilization](https://huggingface.co/spaces/build-small-hackathon/tiny-civilization)*
103
+
104
+ ---
105
+
106
+ *Agent traces for every event generated during development are available at [huggingface.co/datasets/build-small-hackathon/tiny-civilization-traces](https://huggingface.co/datasets/build-small-hackathon/tiny-civilization-traces). Each row contains the full agent prompt, raw model response, and parsed output.*
app.py ADDED
@@ -0,0 +1,963 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py — Tiny Civilization: The Tinywick Hollow Gazette 🍄
3
+ Persistent woodland civilisation simulation powered by Qwen2.5-1.5B.
4
+ Build Small Hackathon 2026 — Thousand Token Wood track.
5
+ Model: Qwen/Qwen2.5-1.5B-Instruct (≤4B → Tiny Titan badge!)
6
+ """
7
+ # ═══════════════════════════════════════════════════════════════════
8
+ # 0 ▸ IMPORTS
9
+ # ═══════════════════════════════════════════════════════════════════
10
+ from __future__ import annotations
11
+ import json, os, random, textwrap, traceback, re
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ import gradio as gr
15
+ from PIL import Image, ImageDraw, ImageFont
16
+ import database
17
+
18
+ try:
19
+ import spaces
20
+ _ZERO_GPU = True
21
+ except ImportError:
22
+ class _Stub:
23
+ @staticmethod
24
+ def GPU(fn=None, *, duration=120):
25
+ return fn if callable(fn) else (lambda f: f)
26
+ spaces = _Stub(); _ZERO_GPU = False # type: ignore
27
+
28
+ import torch
29
+ from transformers import pipeline as hf_pipeline
30
+
31
+ # ═══════════════════════════════════════════════════════════════════
32
+ # 1 ▸ CONSTANTS
33
+ # ═══════════════════════════════════════════════════════════════════
34
+ CREATURES = ["fox", "badger", "squirrel", "mole"]
35
+ EVENT_TYPES = ["trade", "gossip", "feud", "invention", "discovery", "ceremony"]
36
+ CREATURE_EMOJI = {"fox":"🦊","badger":"🦡","squirrel":"🐿️","mole":"🐀"}
37
+
38
+ # Model — 1.5B primary → 3B fallback. BOTH qualify for ≤4B Tiny Titan badge!
39
+ MODEL_PRIMARY = "Qwen/Qwen2.5-1.5B-Instruct"
40
+ MODEL_FALLBACK = "Qwen/Qwen2.5-3B-Instruct"
41
+
42
+ REL_DELTAS = {"trade":+5,"gossip":-4,"feud":-10,"invention":+7,"discovery":+6,"ceremony":+3}
43
+
44
+ REL_TIERS = [
45
+ (0, 25, "Sworn Enemies", "⚔️"),
46
+ (26, 40, "Very Suspicious", "🦔"),
47
+ (41, 55, "Wary Acquaintances", "🤝"),
48
+ (56, 70, "Friendly", "🌰"),
49
+ (71, 85, "Close Companions", "🌿"),
50
+ (86, 100, "Inseparable", "🍄"),
51
+ ]
52
+
53
+ WEIRD_OBJECTS = [
54
+ "half-eaten poem", "suspicious mushroom", "button that looks like the moon",
55
+ "forgotten birthday", "three secrets", "a fake acorn",
56
+ "map to somewhere that may not exist", "extremely formal apology note",
57
+ "small jar of preserved thunder", "second-hand prophecy",
58
+ ]
59
+
60
+ LAWS = [
61
+ "no trading on Tuesdays",
62
+ "buttons = currency",
63
+ "everyone must compliment the badger",
64
+ "mushrooms are sacred",
65
+ "all debts expire at midnight",
66
+ "silence is legally binding",
67
+ "hats must be worn ironically",
68
+ "the mole's word is final (underground only)",
69
+ ]
70
+
71
+ RUMOUR_TYPES = [
72
+ "has been secretly hoarding acorns",
73
+ "was seen talking to a suspicious stranger at midnight",
74
+ "invented something that does not work at all",
75
+ "owes three unpayable debts",
76
+ "made a deal with the rain",
77
+ "owns the moon (allegedly)",
78
+ "has been writing a novel about everyone in the hollow",
79
+ "is not who they say they are",
80
+ "has found a door underground that was not there before",
81
+ "once ate an entire philosophy",
82
+ ]
83
+
84
+ CLASSIFIEDS = [
85
+ "LOST: One certainty. If found, please do not return it. — M. Mole",
86
+ "FOR SALE: Slightly used certificate of excellence. Condition: forged. — R. Fox",
87
+ "NOTICE: The badger declares the east mushroom illegal pending investigation.",
88
+ "WANTED: Someone to explain what happened last Tuesday. Any information welcome.",
89
+ "FOUND: An unexplained event near the old oak. Owner may claim it.",
90
+ "REWARD: For the return of three acorns lent in confidence. You know who you are.",
91
+ "LOST: One argument. I was winning it. — B. Badger",
92
+ "ANNOUNCEMENT: The squirrel's new invention works. (This is the third announcement this week.)",
93
+ ]
94
+
95
+ # ═══════════════════════════════════════════════════════════════════
96
+ # 2 ▸ AGENT PROMPTS (shown in Konami easter egg)
97
+ # ═══════════════════════════════════════════════════════════════════
98
+ AGENT_PROMPTS: dict[str, str] = {
99
+ "fox": (
100
+ "You are Reginald Fox — charming, dishonest, and deeply fond of certificates. "
101
+ "You speak formally and hint at secret arrangements. You believe everything is "
102
+ "negotiable. You collect and forge official-looking documents. "
103
+ "Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
104
+ ),
105
+ "badger": (
106
+ "You are Beatrice Badger — gruff keeper of rules, deeply suspicious (especially "
107
+ "of Reginald Fox), secretly a poet. Mushrooms are a serious matter. "
108
+ "You speak in short declarative sentences. You are always right. "
109
+ "Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
110
+ ),
111
+ "squirrel": (
112
+ "You are Cornelius Squirrel — anxious inventor who speaks fast and repeats himself! "
113
+ "You invent things that almost-but-not-quite work. You are obsessed with efficiency! "
114
+ "You use exclamation points! You repeat key words for emphasis, for emphasis! "
115
+ "Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
116
+ ),
117
+ "mole": (
118
+ "You are Millicent Mole — quiet, philosophical, rarely surfaces. "
119
+ "You speak in incomplete thoughts and gentle riddles. You know everyone's secrets "
120
+ "but share them only obliquely. You believe all things connect underground. "
121
+ "Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
122
+ ),
123
+ }
124
+
125
+ NARRATOR_PROMPT = (
126
+ "You are the pompous editor-in-chief of The Tinywick Hollow Gazette. "
127
+ "You write about absurd woodland animals with the utmost journalistic gravity. "
128
+ "Respond in this EXACT format (no deviations, no extra text):\n\n"
129
+ "HEADLINE IN ALL CAPS\n\n"
130
+ "Three to four sentences of pompous newspaper prose about today's events.\n\n"
131
+ "WEATHER: One-sentence absurd woodland forecast.\n"
132
+ "FOX: One sentence about the fox's day.\n"
133
+ "BADGER: One sentence about the badger's day.\n"
134
+ "SQUIRREL: One sentence about the squirrel's day.\n"
135
+ "MOLE: One cryptic sentence."
136
+ )
137
+
138
+ # ═══════════════════════════════════════════════════════════════════
139
+ # 3 ▸ MODEL (lazy-loaded inside @spaces.GPU context)
140
+ # ═══════════════════════════════════════════════════════════════════
141
+ _pipe = None
142
+ _model_id_used = "none"
143
+
144
+
145
+ def _load_pipeline() -> None:
146
+ global _pipe, _model_id_used
147
+ if _pipe is not None:
148
+ return
149
+ for mid in (MODEL_PRIMARY, MODEL_FALLBACK):
150
+ try:
151
+ print(f"[TinyC] Loading {mid} …", flush=True)
152
+ _pipe = hf_pipeline(
153
+ "text-generation", model=mid,
154
+ torch_dtype=torch.float16,
155
+ device_map="auto",
156
+ trust_remote_code=True,
157
+ )
158
+ _model_id_used = mid
159
+ print(f"[TinyC] {mid} ready ✓", flush=True)
160
+ return
161
+ except Exception as e:
162
+ print(f"[TinyC] {mid} failed: {e}", flush=True)
163
+ raise RuntimeError(f"Could not load {MODEL_PRIMARY} or {MODEL_FALLBACK}.")
164
+
165
+
166
+ def _generate(system_prompt: str, user_prompt: str, max_new_tokens: int = 160) -> str:
167
+ assert _pipe is not None
168
+ try:
169
+ out = _pipe(
170
+ [{"role":"system","content":system_prompt},{"role":"user","content":user_prompt}],
171
+ max_new_tokens=max_new_tokens,
172
+ temperature=0.88, top_p=0.92, do_sample=True,
173
+ return_full_text=False,
174
+ )
175
+ return (out[0]["generated_text"] or "").strip()
176
+ except Exception as e:
177
+ print(f"[TinyC] _generate error: {e}", flush=True)
178
+ return ""
179
+
180
+
181
+ def call_agent(agent_name: str, context: str) -> str:
182
+ result = _generate(AGENT_PROMPTS.get(agent_name, AGENT_PROMPTS["fox"]), context, 100)
183
+ return result or f"{agent_name.capitalize()} had no comment at this time."
184
+
185
+
186
+ # ═══════════════════════════════════════════════════════════════════
187
+ # 4 ▸ SIMULATION
188
+ # ═══════════════════════════════════════════════════════════════════
189
+ def _nudge_ctx(nudge_type, nudge_value, nudge_target, day_number):
190
+ if nudge_type == "rumour" and nudge_target and nudge_value:
191
+ database.save_nudge(day_number, "rumour", f"{nudge_target}: {nudge_value}")
192
+ return f"A rumour is spreading that {nudge_target} {nudge_value}."
193
+ if nudge_type == "donation" and nudge_value:
194
+ recipient = random.choice(CREATURES)
195
+ c = database.get_creature(recipient)
196
+ if c:
197
+ inv = (c["inventory"] + [nudge_value])[-12:]
198
+ database.update_creature(recipient, inventory=inv)
199
+ database.save_nudge(day_number, "donation", f"{recipient}: {nudge_value}")
200
+ return f"Someone anonymously donated '{nudge_value}' to {recipient}."
201
+ if nudge_type == "law" and nudge_value:
202
+ database.save_nudge(day_number, "law", nudge_value)
203
+ return f"A new law has been formally proposed: '{nudge_value}'."
204
+ return ""
205
+
206
+
207
+ def _historical_ctx() -> str:
208
+ nudges = database.get_recent_nudges(3)
209
+ if not nudges: return ""
210
+ lines = [f"Day {n['day_number']} {n['nudge_type']}: {n['nudge_value']}" for n in nudges]
211
+ return "Recent influences: " + "; ".join(lines)
212
+
213
+
214
+ def _past_headline_ctx() -> str:
215
+ """Include last 2 headlines so newspaper can reference history."""
216
+ hl = database.get_all_headlines()
217
+ if len(hl) < 2: return ""
218
+ recent = hl[:2]
219
+ return "Past headlines: " + " | ".join(f"Day {d}: {h[:40]}" for d, h in recent)
220
+
221
+
222
+ def _parse_newspaper_full(raw: str, day_number: int) -> dict:
223
+ """Parse the structured newspaper response into sections."""
224
+ lines = [l.strip() for l in raw.strip().splitlines()]
225
+ lines = [l for l in lines if l]
226
+
227
+ result = {
228
+ "headline": f"ANOTHER EVENTFUL DAY IN TINYWICK HOLLOW (DAY {day_number})",
229
+ "article": "",
230
+ "weather": "Overcast, with a chance of philosophical observations.",
231
+ "briefs": {c: f"{c.capitalize()} was present." for c in CREATURES},
232
+ }
233
+
234
+ # Find headline (first all-caps-ish line)
235
+ body_start = 0
236
+ for i, line in enumerate(lines):
237
+ cleaned = re.sub(r'[*_#"\']', "", line).strip()
238
+ if cleaned and (cleaned == cleaned.upper() or i == 0):
239
+ result["headline"] = cleaned.upper()
240
+ body_start = i + 1
241
+ break
242
+
243
+ # Extract labelled sections
244
+ article_lines = []
245
+ for line in lines[body_start:]:
246
+ upper = line.upper()
247
+ if upper.startswith("WEATHER:"):
248
+ result["weather"] = line.split(":", 1)[1].strip()
249
+ elif upper.startswith("FOX:"):
250
+ result["briefs"]["fox"] = line.split(":", 1)[1].strip()
251
+ elif upper.startswith("BADGER:"):
252
+ result["briefs"]["badger"] = line.split(":", 1)[1].strip()
253
+ elif upper.startswith("SQUIRREL:"):
254
+ result["briefs"]["squirrel"] = line.split(":", 1)[1].strip()
255
+ elif upper.startswith("MOLE:"):
256
+ result["briefs"]["mole"] = line.split(":", 1)[1].strip()
257
+ else:
258
+ article_lines.append(line)
259
+
260
+ result["article"] = " ".join(article_lines).strip()
261
+ if not result["article"]:
262
+ result["article"] = raw.strip()
263
+
264
+ return result
265
+
266
+
267
+ def _run_simulation_step(nudge_type, nudge_value, nudge_target):
268
+ """Core simulation — runs within GPU context."""
269
+ _load_pipeline()
270
+ day_number = database.get_next_day_number()
271
+ creatures = database.get_all_creatures()
272
+
273
+ current_nudge = _nudge_ctx(nudge_type, nudge_value, nudge_target, day_number)
274
+ historical = _historical_ctx()
275
+ past_hl = _past_headline_ctx()
276
+ combined_ctx = " | ".join(filter(None, [current_nudge, historical]))
277
+
278
+ # ── Generate 3 events ─────────────────────────────────────────
279
+ event_records: list[dict] = []
280
+ for _ in range(3):
281
+ actor = random.choice(CREATURES)
282
+ target = random.choice([c for c in CREATURES if c != actor])
283
+ etype = random.choice(EVENT_TYPES)
284
+ rel = next((c for c in creatures if c["name"]==actor), {}).get(
285
+ "relationship_scores", {}).get(target, 50)
286
+
287
+ prompts = {
288
+ "trade": f"Propose a trade with {target} (relationship {rel}/100). Be specific about what you're offering.",
289
+ "gossip": f"Share gossip about {target} (relationship {rel}/100). Make it wonderfully absurd.",
290
+ "feud": f"Describe your current feud with {target} (relationship {rel}/100). It must be about something trivial.",
291
+ "invention": f"You've invented something that involves {target} somehow. Describe your invention.",
292
+ "discovery": f"You've discovered something surprising about {target} or near them. What did you find?",
293
+ "ceremony": f"You're organising a ceremony and {target} must be involved. Describe it.",
294
+ }
295
+ agent_prompt = prompts[etype]
296
+ if combined_ctx: agent_prompt += f"\n\nWorld context: {combined_ctx}"
297
+
298
+ description = call_agent(actor, agent_prompt)
299
+ if not description or len(description) < 8:
300
+ description = f"{actor.capitalize()} had a {etype} with {target}."
301
+
302
+ event_records.append({"actor":actor,"action":etype,"target":target,"description":description})
303
+ database.save_event(day_number, actor, etype, target, description)
304
+
305
+ # ── Update relationships ──────────────────────────────────
306
+ delta = REL_DELTAS.get(etype, 0)
307
+ creatures = database.get_all_creatures()
308
+ for c in creatures:
309
+ if c["name"] == actor:
310
+ sc = c["relationship_scores"]
311
+ sc[target] = max(0, min(100, sc.get(target,50) + delta))
312
+ database.update_creature(actor, relationship_scores=sc)
313
+ if c["name"] == target:
314
+ sc = c["relationship_scores"]
315
+ sc[actor] = max(0, min(100, sc.get(actor,50) + delta//2))
316
+ database.update_creature(target, relationship_scores=sc)
317
+ creatures = database.get_all_creatures()
318
+
319
+ # ── Generate newspaper ────────────────────────────────────────
320
+ events_summary = "\n".join(
321
+ f"- {e['actor'].capitalize()} [{e['action']}] with {e['target']}: {e['description']}"
322
+ for e in event_records
323
+ )
324
+ extra = ""
325
+ if combined_ctx: extra += f"\nWorld context: {combined_ctx}"
326
+ if past_hl: extra += f"\n{past_hl}"
327
+
328
+ raw_paper = _generate(
329
+ NARRATOR_PROMPT,
330
+ f"Today's events:\n{events_summary}{extra}",
331
+ max_new_tokens=300,
332
+ )
333
+ if not raw_paper:
334
+ raw_paper = (
335
+ f"CHAOS REIGNS IN TINYWICK HOLLOW ON DAY {day_number}\n\n"
336
+ "The printing press has momentarily jammed. However, our sources confirm "
337
+ "that things definitely happened today.\n\n"
338
+ "WEATHER: Unclear, like everything else.\n"
339
+ f"FOX: {event_records[0]['description'][:60]}\n"
340
+ f"BADGER: {event_records[1]['description'][:60]}\n"
341
+ f"SQUIRREL: {event_records[2]['description'][:60]}\n"
342
+ "MOLE: Something underground."
343
+ )
344
+
345
+ parsed = _parse_newspaper_full(raw_paper, day_number)
346
+ classified = random.choice(CLASSIFIEDS)
347
+
348
+ full_text = "\n\n".join([
349
+ parsed["headline"],
350
+ parsed["article"],
351
+ f"WEATHER: {parsed['weather']}",
352
+ "\n".join(f"{c.upper()}: {t}" for c, t in parsed["briefs"].items()),
353
+ f"CLASSIFIEDS: {classified}",
354
+ ])
355
+
356
+ database.save_day(day_number, parsed["headline"], full_text)
357
+ return day_number, parsed, classified
358
+
359
+
360
+ # ═══════════════════════════════════════════════════════════════════
361
+ # 5 ▸ ZERОГPU WRAPPER
362
+ # ═══════════════════════════════════════════════════════════════════
363
+ @spaces.GPU(duration=120)
364
+ def advance_day(nudge_type=None, nudge_value=None, nudge_target=None):
365
+ """Public GPU entry point — 1.5B model needs ≤60s on T4."""
366
+ return _run_simulation_step(nudge_type, nudge_value, nudge_target)
367
+
368
+
369
+ # ═══════════════════════════════════════════════════════════════════
370
+ # 6 ▸ PIL NEWSPAPER IMAGE (enhanced)
371
+ # ═══════════════════════════════════════════════════════════════════
372
+ _SERIF_B = ["/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf",
373
+ "/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf"]
374
+ _SERIF_R = ["/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf",
375
+ "/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf"]
376
+
377
+ def _tf(paths, sz):
378
+ for p in paths:
379
+ try: return ImageFont.truetype(p, sz)
380
+ except: pass
381
+ return ImageFont.load_default()
382
+
383
+ def render_newspaper_image(parsed: dict, classified: str, day_number: int) -> str:
384
+ W, H = 960, 720
385
+ PAPER=(245,232,200); INK=(20,8,2); BORDER=(70,40,8); SUBINK=(90,55,25); GREY=(130,100,70)
386
+
387
+ img = Image.new("RGB",(W,H),PAPER)
388
+ d = ImageDraw.Draw(img)
389
+
390
+ f_mast = _tf(_SERIF_B, 30); f_hed = _tf(_SERIF_B, 20)
391
+ f_body = _tf(_SERIF_R, 12); f_sm = _tf(_SERIF_R, 10)
392
+ f_sub = _tf(_SERIF_R, 11); f_bold = _tf(_SERIF_B, 12)
393
+
394
+ M = 18
395
+ d.rectangle([M,M,W-M,H-M], outline=BORDER, width=3)
396
+ d.rectangle([M+6,M+6,W-M-6,H-M-6], outline=BORDER, width=1)
397
+
398
+ y = M + 14
399
+ # Masthead
400
+ mast = "THE TINYWICK HOLLOW GAZETTE"
401
+ bb = d.textbbox((0,0),mast,font=f_mast); tw=bb[2]-bb[0]
402
+ d.text(((W-tw)/2, y), mast, fill=INK, font=f_mast); y += bb[3]-bb[1]+3
403
+ # Sub
404
+ sub = f"Est. Day 1 ✦ Day {day_number} ✦ One Acorn ✦ Woodland Readers Only"
405
+ bb=d.textbbox((0,0),sub,font=f_sm); d.text(((W-(bb[2]-bb[0]))/2,y),sub,fill=SUBINK,font=f_sm)
406
+ y+=bb[3]-bb[1]+5
407
+ # Weather strip
408
+ wx = parsed.get("weather","Overcast.")
409
+ wx_line = f"☁ WEATHER: {wx}"
410
+ bb=d.textbbox((0,0),wx_line,font=f_sm); d.text(((W-(bb[2]-bb[0]))/2,y),wx_line,fill=GREY,font=f_sm)
411
+ y+=bb[3]-bb[1]+4
412
+ d.line([M+10,y,W-M-10,y],fill=BORDER,width=2)
413
+ d.line([M+10,y+4,W-M-10,y+4],fill=BORDER,width=1); y+=14
414
+
415
+ # Headline
416
+ for line in textwrap.wrap(parsed.get("headline",""),width=50):
417
+ bb=d.textbbox((0,0),line,font=f_hed); d.text(((W-(bb[2]-bb[0]))/2,y),line,fill=INK,font=f_hed)
418
+ y+=bb[3]-bb[1]+2
419
+ y+=4; d.line([M+10,y,W-M-10,y],fill=BORDER,width=1); y+=10
420
+
421
+ # Two-column article + sidebar
422
+ PAD=M+12; COL_GAP=24; SIDE_W=220
423
+ main_w = W - 2*PAD - COL_GAP - SIDE_W
424
+ half_w = (main_w-COL_GAP)//2
425
+ col1_x=PAD; col2_x=PAD+half_w+COL_GAP; side_x=PAD+main_w+COL_GAP
426
+ LH=15; MAX_Y=H-M-50; art_y=y
427
+
428
+ art_lines = textwrap.wrap(parsed.get("article",""), width=38)
429
+ mid = max(1,len(art_lines)//2)
430
+ ly=art_y
431
+ for line in art_lines[:mid]:
432
+ if ly+LH>MAX_Y: break
433
+ d.text((col1_x,ly),line,fill=INK,font=f_body); ly+=LH
434
+ d.line([col1_x+half_w+COL_GAP//2,art_y,col1_x+half_w+COL_GAP//2,min(ly,MAX_Y)],fill=GREY,width=1)
435
+ ry=art_y
436
+ for line in art_lines[mid:]:
437
+ if ry+LH>MAX_Y: break
438
+ d.text((col2_x,ry),line,fill=INK,font=f_body); ry+=LH
439
+
440
+ # Sidebar: In Brief
441
+ d.line([side_x-8,art_y,side_x-8,MAX_Y],fill=BORDER,width=1)
442
+ sy=art_y
443
+ d.text((side_x,sy),"IN BRIEF",fill=INK,font=f_bold); sy+=16
444
+ d.line([side_x,sy,W-M-14,sy],fill=GREY,width=1); sy+=6
445
+ briefs = parsed.get("briefs",{})
446
+ for cname in CREATURES:
447
+ em=CREATURE_EMOJI.get(cname,"?"); txt=briefs.get(cname,"")
448
+ header=f"{em} {cname.upper()}"
449
+ d.text((side_x,sy),header,fill=INK,font=f_bold); sy+=13
450
+ for bline in textwrap.wrap(txt,width=26):
451
+ if sy+12>MAX_Y: break
452
+ d.text((side_x,sy),bline,fill=INK,font=f_sm); sy+=12
453
+ sy+=4
454
+
455
+ # Classifieds footer
456
+ fy=H-M-38; d.line([M+10,fy,W-M-10,fy],fill=BORDER,width=1); fy+=4
457
+ d.text((M+14,fy),"CLASSIFIEDS",fill=INK,font=f_bold); fy+=14
458
+ for cl in textwrap.wrap(classified,width=100):
459
+ if fy+12>H-M-8: break
460
+ d.text((M+14,fy),cl,fill=INK,font=f_sm); fy+=12
461
+
462
+ path=f"/tmp/tinywick_day_{day_number}.png"
463
+ img.save(path,"PNG")
464
+ return path
465
+
466
+
467
+ # ═══════════════════════════════════════════════════════════════════
468
+ # 7 ▸ AGENT TRACE EXPORT (📡 Sharing is Caring badge)
469
+ # ═══════════════════════════════════════════════════════════════════
470
+ def export_agent_traces() -> str:
471
+ """Export all simulation events as JSON for the HF dataset."""
472
+ creatures = database.get_all_creatures()
473
+ trace = {
474
+ "meta": {
475
+ "project": "Tiny Civilization — The Tinywick Hollow Gazette",
476
+ "model": _model_id_used,
477
+ "exported_at": datetime.now().isoformat(),
478
+ },
479
+ "agent_prompts": AGENT_PROMPTS,
480
+ "narrator_prompt": NARRATOR_PROMPT,
481
+ "creature_state": creatures,
482
+ "events": [],
483
+ "days": [],
484
+ }
485
+ headlines = database.get_all_headlines()
486
+ for dn, _ in reversed(headlines):
487
+ day = database.get_day(dn)
488
+ if day: trace["days"].append(dict(day))
489
+ evts = database.get_events_for_day(dn)
490
+ trace["events"].extend([{"day": dn, **e} for e in evts])
491
+ path = "/tmp/tiny_civ_traces.json"
492
+ with open(path,"w") as f: json.dump(trace, f, indent=2, default=str)
493
+ return path
494
+
495
+
496
+ # ═══════════════════════════════════════════════════════════════════
497
+ # 8 ▸ CSS (heavy newspaper polish for Off-Brand badge)
498
+ # ═══════════════════════════════════════════════════════════════════
499
+ NEWSPAPER_CSS = """
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
+ body, .gradio-container { background: #c4b090 !important; }
503
+
504
+ /* ── Newspaper wrapper ── */
505
+ .paper-wrap {
506
+ background: #f6ead0;
507
+ background-image: repeating-linear-gradient(0deg,transparent,transparent 21px,rgba(150,110,60,.07) 22px);
508
+ border: 3px solid #4a2a08; border-radius:1px;
509
+ padding: 20px 26px 16px;
510
+ box-shadow: 6px 6px 28px rgba(0,0,0,.35), inset 0 0 100px rgba(180,140,80,.15);
511
+ margin: 6px 0; position: relative;
512
+ }
513
+ .paper-wrap::before {
514
+ content:""; display:block; border:1px solid #4a2a08;
515
+ position:absolute; inset:7px; pointer-events:none;
516
+ }
517
+
518
+ /* ── Masthead ── */
519
+ .paper-masthead {
520
+ font-family:'UnifrakturMaguntia','Playfair Display',Georgia,serif;
521
+ font-size:2.4em; text-align:center; color:#160800;
522
+ border-top:5px double #4a2a08; border-bottom:5px double #4a2a08;
523
+ padding:5px 0; margin-bottom:3px; letter-spacing:1px;
524
+ }
525
+ .paper-sub { font-family:'Libre Baskerville',Georgia,serif; font-size:.72em; color:#5a3615;
526
+ text-align:center; font-style:italic; margin-bottom:2px; }
527
+ .paper-weather { font-family:'Libre Baskerville',Georgia,serif; font-size:.75em; color:#7a5630;
528
+ text-align:center; margin-bottom:6px; letter-spacing:.5px; }
529
+ .paper-rule { border:none; border-top:2px solid #4a2a08; margin:3px 0 6px; }
530
+ .paper-rule-thin { border:none; border-top:1px solid #9a7040; margin:3px 0; }
531
+
532
+ /* ── Headline ── */
533
+ .paper-headline {
534
+ font-family:'Playfair Display',Georgia,serif; font-size:1.85em; font-weight:900;
535
+ text-align:center; text-transform:uppercase; color:#0c0400;
536
+ line-height:1.12; margin:6px 0 8px;
537
+ }
538
+
539
+ /* ── Body row: article + sidebar ── */
540
+ .paper-body-row { display:flex; gap:0; }
541
+ .paper-article-cols {
542
+ flex:1; font-family:'Libre Baskerville',Georgia,serif; font-size:.88em;
543
+ color:#180a02; line-height:1.75; text-align:justify;
544
+ column-count:2; column-gap:24px; column-rule:1px solid #9a7040;
545
+ padding:4px 0;
546
+ }
547
+ .paper-sidebar {
548
+ width:210px; min-width:190px; padding:0 0 0 16px;
549
+ border-left:2px solid #4a2a08; margin-left:16px;
550
+ }
551
+ .sidebar-title {
552
+ font-family:'Playfair Display',Georgia,serif; font-weight:700; font-size:.85em;
553
+ text-transform:uppercase; letter-spacing:1px; color:#160800;
554
+ border-bottom:1px solid #9a7040; padding-bottom:3px; margin-bottom:6px;
555
+ }
556
+ .sidebar-item { margin-bottom:8px; }
557
+ .sidebar-creature-name {
558
+ font-family:'Libre Baskerville',Georgia,serif; font-weight:700; font-size:.78em; color:#2a1008;
559
+ }
560
+ .sidebar-creature-text {
561
+ font-family:'Libre Baskerville',Georgia,serif; font-size:.74em; color:#3a1c08;
562
+ line-height:1.45; font-style:italic;
563
+ }
564
+
565
+ /* ── Classifieds ── */
566
+ .paper-classifieds {
567
+ font-family:'Libre Baskerville',Georgia,serif; font-size:.75em; color:#5a3615;
568
+ border-top:1px solid #9a7040; margin-top:8px; padding-top:5px; font-style:italic;
569
+ }
570
+ .paper-classifieds span { font-weight:700; font-style:normal; color:#2a1008; }
571
+
572
+ /* ── Day badge ── */
573
+ .paper-daybadge {
574
+ font-family:'Libre Baskerville',Georgia,serif; font-size:.75em; color:#5a3615;
575
+ text-align:center; border-top:1px solid #9a7040; margin-top:8px; padding-top:5px;
576
+ }
577
+
578
+ /* ── Status strip ── */
579
+ .status-strip {
580
+ background:#d6c4a0; border:1px solid #8a6030; border-radius:3px;
581
+ padding:5px 12px; font-family:'Libre Baskerville',Georgia,serif;
582
+ font-size:.82em; color:#2a1008; text-align:center; margin:4px 0;
583
+ }
584
+
585
+ /* ── Creature cards ── */
586
+ .creature-grid { display:flex; gap:8px; flex-wrap:wrap; }
587
+ .creature-card {
588
+ background:#f8ecd8; border:1px solid #9a7040; border-radius:2px;
589
+ padding:10px 13px; font-family:'Libre Baskerville',Georgia,serif;
590
+ font-size:.8em; color:#180a02; flex:1; min-width:155px;
591
+ }
592
+ .creature-name { font-weight:700; font-size:.95em; color:#260e04; display:block; margin-bottom:3px; }
593
+ .creature-tier { font-style:italic; color:#7a5030; font-size:.85em; }
594
+
595
+ /* ── Civ stats ── */
596
+ .civ-stats {
597
+ background:#e8d4b0; border:1px solid #9a7040; border-radius:2px;
598
+ padding:8px 14px; font-family:'Libre Baskerville',Georgia,serif;
599
+ font-size:.78em; color:#2a1008; display:flex; gap:14px; flex-wrap:wrap;
600
+ justify-content:center; margin:6px 0;
601
+ }
602
+ .stat-item { text-align:center; }
603
+ .stat-label { font-size:.85em; color:#7a5030; display:block; }
604
+ .stat-value { font-weight:700; font-size:1.05em; color:#160800; }
605
+
606
+ /* ── Section titles ── */
607
+ .section-title {
608
+ font-family:'Playfair Display',Georgia,serif; font-weight:700;
609
+ color:#160800; font-size:.95em; text-transform:uppercase;
610
+ letter-spacing:1px; text-align:center;
611
+ border-bottom:1px solid #8a6030; padding-bottom:3px; margin:8px 0 8px;
612
+ }
613
+ .archive-area { background:#f0e4c6; border:1px solid #9a7040; border-radius:2px;
614
+ padding:10px; margin-top:5px; min-height:50px; }
615
+
616
+ /* ── Konami modal ── */
617
+ #konami-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,.5); z-index:99998; }
618
+ #konami-modal {
619
+ display:none; position:fixed; z-index:99999; top:50%; left:50%;
620
+ transform:translate(-50%,-50%); width:min(660px,92vw); max-height:76vh; overflow-y:auto;
621
+ background:#f6ead0; border:3px solid #4a2a08; box-shadow:10px 10px 40px rgba(0,0,0,.6);
622
+ padding:22px 26px 18px; font-family:'Libre Baskerville',Georgia,serif;
623
+ }
624
+ #konami-modal h2 { font-family:'Playfair Display',Georgia,serif; color:#160800; margin:0 0 10px; }
625
+ #konami-modal details { margin:6px 0; }
626
+ #konami-modal summary { cursor:pointer; font-weight:700; color:#4a2a08; }
627
+ #konami-modal pre { background:#e8d4b0; border:1px solid #9a7040; padding:8px;
628
+ font-size:.76em; white-space:pre-wrap; border-radius:2px; margin:5px 0 0; }
629
+ #konami-close { position:absolute; top:8px; right:12px; cursor:pointer;
630
+ font-size:1.3em; color:#4a2a08; background:none; border:none; }
631
+ """
632
+
633
+ # ═══════════════════════════════════════════════════════════════════
634
+ # 9 ▸ JAVASCRIPT
635
+ # ════════════════════════════════════════════════════════════════���══
636
+ KONAMI_JS = """<script>
637
+ (function(){
638
+ var SEQ=['ArrowUp','ArrowUp','ArrowDown','ArrowDown',
639
+ 'ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
640
+ var idx=0;
641
+ document.addEventListener('keydown',function(e){
642
+ if(e.key===SEQ[idx]){idx++;if(idx===SEQ.length){idx=0;showKonami();}}
643
+ else{idx=(e.key===SEQ[0])?1:0;}
644
+ });
645
+ window.showKonami=function(){
646
+ document.getElementById('konami-backdrop').style.display='block';
647
+ document.getElementById('konami-modal').style.display='block';
648
+ };
649
+ window.hideKonami=function(){
650
+ document.getElementById('konami-backdrop').style.display='none';
651
+ document.getElementById('konami-modal').style.display='none';
652
+ };
653
+ })();
654
+ </script>"""
655
+
656
+
657
+ # ═══════════════════════════════════════════════════════════════════
658
+ # 10 ▸ HTML FORMATTERS
659
+ # ═══════════════════════════════════════════════════════════════════
660
+ def _rel_tier(score: int) -> tuple[str, str]:
661
+ for lo, hi, label, icon in REL_TIERS:
662
+ if lo <= score <= hi: return label, icon
663
+ return "Unknown", "?"
664
+
665
+ def _html_paper(parsed: dict, classified: str, day_num: int) -> str:
666
+ hed = (parsed.get("headline","") or "").replace("<","&lt;")
667
+ art = (parsed.get("article","") or "").replace("<","&lt;")
668
+ wx = (parsed.get("weather","") or "").replace("<","&lt;")
669
+ briefs = parsed.get("briefs",{})
670
+ emojis = " ".join(f"{CREATURE_EMOJI[c]} {c.capitalize()}" for c in CREATURES)
671
+ classified_esc = classified.replace("<","&lt;")
672
+
673
+ sidebar_items = ""
674
+ for c in CREATURES:
675
+ em = CREATURE_EMOJI.get(c,"?")
676
+ txt = (briefs.get(c,"") or "").replace("<","&lt;")
677
+ sidebar_items += f"""
678
+ <div class="sidebar-item">
679
+ <div class="sidebar-creature-name">{em} {c.upper()}</div>
680
+ <div class="sidebar-creature-text">{txt}</div>
681
+ </div>"""
682
+
683
+ return f"""
684
+ <div class="paper-wrap">
685
+ <div class="paper-masthead">The Tinywick Hollow Gazette</div>
686
+ <div class="paper-sub">Est. Day&nbsp;1 ✦ Day&nbsp;{day_num} ✦ One Acorn Per Copy ✦ Serving the Woodland Since the Beginning</div>
687
+ <div class="paper-weather">☁&nbsp;{wx}</div>
688
+ <hr class="paper-rule">
689
+ <div class="paper-headline">{hed}</div>
690
+ <hr class="paper-rule-thin">
691
+ <div class="paper-body-row">
692
+ <div class="paper-article-cols">{art}</div>
693
+ <div class="paper-sidebar">
694
+ <div class="sidebar-title">In Brief</div>
695
+ {sidebar_items}
696
+ </div>
697
+ </div>
698
+ <div class="paper-classifieds"><span>CLASSIFIEDS:</span> {classified_esc}</div>
699
+ <div class="paper-daybadge">— Day {day_num} —&nbsp;&nbsp; {emojis}</div>
700
+ </div>
701
+ """
702
+
703
+ def _html_placeholder() -> str:
704
+ founding = database.get_day(0)
705
+ if founding:
706
+ text = founding["full_newspaper_text"]
707
+ parts = text.split("\n\n",1)
708
+ hed = parts[0]; art = parts[1] if len(parts)>1 else text
709
+ dummy_parsed = {
710
+ "headline": hed, "article": art,
711
+ "weather": "Portentous, with scattered significance.",
712
+ "briefs": {
713
+ "fox": "Forged three certificates before breakfast.",
714
+ "badger": "Insisted on thirteen amendments before lunch.",
715
+ "squirrel": "Invented a signing machine! It signed the wrong document!",
716
+ "mole": "Something is already happening underground.",
717
+ }
718
+ }
719
+ return _html_paper(dummy_parsed, "NOTICE: Civilisation now in progress.", 0)
720
+ return """<div class="paper-wrap">
721
+ <div class="paper-masthead">The Tinywick Hollow Gazette</div>
722
+ <hr class="paper-rule">
723
+ <div class="paper-headline">AWAITING FIRST LIGHT IN TINYWICK HOLLOW</div>
724
+ <hr class="paper-rule-thin">
725
+ <div class="paper-article-cols">Press <em>Advance Day</em> to begin the chronicle.</div>
726
+ <div class="paper-daybadge">— Day 0 —</div>
727
+ </div>"""
728
+
729
+ def _html_creatures() -> str:
730
+ creatures = database.get_all_creatures()
731
+ cards = ""
732
+ for c in creatures:
733
+ em = CREATURE_EMOJI.get(c["name"],"?")
734
+ inv = (", ".join(c["inventory"][:3]) + ("…" if len(c["inventory"])>3 else "")) or "nothing"
735
+ rels = ""
736
+ for other, score in sorted(c["relationship_scores"].items()):
737
+ label, icon = _rel_tier(score)
738
+ rels += f'<span title="{label} ({score})">{CREATURE_EMOJI.get(other,"?")} {icon}</span> '
739
+ cards += f"""<div class="creature-card">
740
+ <span class="creature-name">{em} {c['name'].capitalize()}</span>
741
+ <div class="creature-tier">{rels}</div>
742
+ <div style="font-size:.78em;margin-top:3px;color:#5a3615;"><em>Carries:</em> {inv}</div>
743
+ </div>"""
744
+ return f'<div class="creature-grid">{cards}</div>'
745
+
746
+ def _html_civ_stats() -> str:
747
+ s = database.get_civ_stats()
748
+ bp = s["best_pair"]; wp = s["worst_pair"]
749
+ dom = s["dominant"].capitalize()
750
+ return f"""<div class="civ-stats">
751
+ <div class="stat-item"><span class="stat-label">Days</span><span class="stat-value">{s['total_days']}</span></div>
752
+ <div class="stat-item"><span class="stat-label">Events</span><span class="stat-value">{s['total_events']}</span></div>
753
+ <div class="stat-item"><span class="stat-label">Nudges</span><span class="stat-value">{s['total_nudges']}</span></div>
754
+ <div class="stat-item"><span class="stat-label">Strongest bond</span>
755
+ <span class="stat-value">{bp[0].capitalize()} &amp; {bp[1].capitalize()} ({bp[2]})</span></div>
756
+ <div class="stat-item"><span class="stat-label">Bitterest feud</span>
757
+ <span class="stat-value">{wp[0].capitalize()} vs {wp[1].capitalize()} ({wp[2]})</span></div>
758
+ <div class="stat-item"><span class="stat-label">Most popular</span><span class="stat-value">{dom}</span></div>
759
+ </div>"""
760
+
761
+ def _archive_choices():
762
+ hl = database.get_all_headlines()
763
+ return [(f"Day {dn}: {h[:44]}{'…' if len(h)>44 else ''}", dn) for dn,h in hl] or []
764
+
765
+ def _status(msg): return f'<div class="status-strip">{msg}</div>'
766
+
767
+ def _konami_html() -> str:
768
+ import html as _h
769
+ details = "".join(
770
+ f"<details><summary>{CREATURE_EMOJI.get(n,'')}<strong> {n.upper()}</strong></summary>"
771
+ f"<pre>{_h.escape(p)}</pre></details>"
772
+ for n,p in AGENT_PROMPTS.items()
773
+ )
774
+ details += f"<details><summary>📰 <strong>NARRATOR</strong></summary><pre>{_h.escape(NARRATOR_PROMPT)}</pre></details>"
775
+ return f"""
776
+ <div id="konami-backdrop" onclick="hideKonami()"></div>
777
+ <div id="konami-modal" role="dialog">
778
+ <button id="konami-close" onclick="hideKonami()">✕</button>
779
+ <h2>🔮 Secret Agent Briefing</h2>
780
+ <p>Konami Code found! Here are the raw system prompts driving our correspondents:</p>
781
+ {details}
782
+ <p style="text-align:center;font-style:italic;color:#5a3615;font-size:.84em;margin-top:14px;">
783
+ ↑↑↓↓←→←→BA — only the woodland elite know this. 🎮</p>
784
+ </div>{KONAMI_JS}"""
785
+
786
+
787
+ # ═══════════════════════════════════════════════════════════════════
788
+ # 11 ▸ GRADIO EVENT HANDLERS
789
+ # ═══════════════════════════════════════════════════════════════════
790
+ def _render_all(day_num, parsed, classified, status_msg):
791
+ return (
792
+ _html_paper(parsed, classified, day_num),
793
+ _html_creatures(),
794
+ _html_civ_stats(),
795
+ gr.update(choices=_archive_choices(), value=None),
796
+ _status(status_msg),
797
+ day_num, parsed, classified,
798
+ )
799
+
800
+ def _safe_advance(nudge_type=None, nudge_value=None, nudge_target=None):
801
+ try:
802
+ day_num, parsed, classified = advance_day(nudge_type, nudge_value, nudge_target)
803
+ model_tag = f" [{_model_id_used.split('/')[-1]}]" if _model_id_used!="none" else ""
804
+ return _render_all(day_num, parsed, classified,
805
+ f"✓ Day {day_num} published to the Gazette.{model_tag}")
806
+ except Exception:
807
+ tb = traceback.format_exc()
808
+ print(tb)
809
+ err_parsed = {
810
+ "headline": "THE GAZETTE'S PRINTING PRESS HAS JAMMED",
811
+ "article": "Our correspondents report a technical malfunction. The editor is inconsolable. "
812
+ "Beatrice Badger suspects sabotage. Reginald Fox denies everything.",
813
+ "weather": "Stormy, with a chance of errors.",
814
+ "briefs": {c: "Unavailable for comment." for c in CREATURES},
815
+ }
816
+ return _render_all(0, err_parsed, "LOST: One functioning simulation. — The Editor",
817
+ "✗ Simulation error — the printing press is jammed. Check logs.")
818
+
819
+ def handle_advance(): return _safe_advance()
820
+ def handle_rumour(c,r): return _safe_advance("rumour",r,c)
821
+ def handle_donation(o): return _safe_advance("donation",o)
822
+ def handle_law(l): return _safe_advance("law",l)
823
+
824
+ def handle_archive_view(day_num):
825
+ if day_num is None:
826
+ return '<div class="archive-area"><em>Select a day to read its edition.</em></div>'
827
+ day = database.get_day(int(day_num))
828
+ if not day: return '<div class="archive-area"><em>Edition not found.</em></div>'
829
+ txt = day["full_newspaper_text"]; parts = txt.split("\n\n",1)
830
+ hed = parts[0]; art = parts[1] if len(parts)>1 else txt
831
+ # Reconstruct parsed from stored text
832
+ parsed = _parse_newspaper_full(txt, day_num)
833
+ classified_match = re.search(r"CLASSIFIEDS:\s*(.+)", txt)
834
+ cl = classified_match.group(1) if classified_match else random.choice(CLASSIFIEDS)
835
+ return f'<div class="archive-area">{_html_paper(parsed, cl, day_num)}</div>'
836
+
837
+ def handle_share(day_num_state, parsed_state, classified_state):
838
+ if not parsed_state or not parsed_state.get("headline"): return gr.update(visible=False)
839
+ try:
840
+ path = render_newspaper_image(parsed_state, classified_state or "", day_num_state or 0)
841
+ return gr.update(visible=True, value=path)
842
+ except Exception: return gr.update(visible=False)
843
+
844
+ def handle_export_traces():
845
+ try:
846
+ path = export_agent_traces()
847
+ return gr.update(visible=True, value=path)
848
+ except Exception: return gr.update(visible=False)
849
+
850
+
851
+ # =================================================================
852
+ # 12 ▸ GRADIO BLOCKS
853
+ # =================================================================
854
+
855
+ # -- Startup: init DB, load latest state --------------------------
856
+ database.init_db()
857
+ _latest = database.get_latest_day()
858
+ if _latest:
859
+ _INIT_DAY = _latest["day_number"]
860
+ _INIT_PARSED = _parse_newspaper_full(_latest["full_newspaper_text"], _INIT_DAY)
861
+ _cl_m = re.search(r"CLASSIFIEDS:\s*(.+)", _latest["full_newspaper_text"])
862
+ _INIT_CL = _cl_m.group(1) if _cl_m else random.choice(CLASSIFIEDS)
863
+ else:
864
+ _INIT_DAY = 0; _INIT_PARSED = {}; _INIT_CL = ""
865
+
866
+ # -- Gradio version-aware css/theme routing -----------------------
867
+ _GR_MAJOR = int(gr.__version__.split(".")[0])
868
+ if _GR_MAJOR >= 6: _BKW: dict = {}; _LKW: dict = {"css": NEWSPAPER_CSS}
869
+ elif _GR_MAJOR >= 5: _BKW = {"css": NEWSPAPER_CSS}; _LKW = {}
870
+ else: _BKW = {"css": NEWSPAPER_CSS, "theme": gr.themes.Base(primary_hue="orange", neutral_hue="stone")}; _LKW = {}
871
+
872
+ # ------------------------------------------------------------------
873
+ with gr.Blocks(title="Tiny Civilization — The Tinywick Hollow Gazette", **_BKW) as demo:
874
+
875
+ gr.HTML(_konami_html())
876
+
877
+ gr.HTML(
878
+ '<div style="text-align:center;padding:8px 0 2px;">'
879
+ '<h1 style="font-family:\'Playfair Display\',Georgia,serif;color:#160800;font-size:1.9em;margin:0 0 2px;">'
880
+ '🦊 Tiny Civilization 🐀</h1>'
881
+ '<p style="font-family:Georgia,serif;color:#5a3615;font-style:italic;margin:0;font-size:.87em;">'
882
+ 'A persistent woodland civilisation. One day. One acorn. One absurd headline at a time.'
883
+ '&nbsp;|&nbsp;<kbd title="Konami Code">↑↑↓↓←→←→BA</kbd> for secrets.'
884
+ '</p></div>'
885
+ )
886
+
887
+ day_state = gr.State(_INIT_DAY)
888
+ parsed_state = gr.State(_INIT_PARSED)
889
+ classif_state = gr.State(_INIT_CL)
890
+
891
+ status_html = gr.HTML(value=_status(
892
+ f"Day {_INIT_DAY} in the archive — next: Day {_INIT_DAY + 1}."
893
+ if _INIT_DAY >= 0 else "No days yet. Press Advance Day to begin."))
894
+ civ_stats_html = gr.HTML(value=_html_civ_stats())
895
+
896
+ with gr.Row(equal_height=False):
897
+
898
+ with gr.Column(scale=3):
899
+ newspaper_display = gr.HTML(
900
+ value=(_html_paper(_INIT_PARSED, _INIT_CL, _INIT_DAY)
901
+ if _INIT_PARSED else _html_placeholder()))
902
+
903
+ with gr.Accordion('📜 Archive — Past Editions', open=False):
904
+ archive_dd = gr.Dropdown(
905
+ choices=_archive_choices(), value=None,
906
+ label='Select a past day', container=False)
907
+ archive_display = gr.HTML(
908
+ value='<div class="archive-area"><em>Select a day to read its edition.</em></div>')
909
+
910
+ with gr.Column(scale=1, min_width=260):
911
+ gr.HTML('<div class="section-title">📰 Editorial Desk</div>')
912
+ advance_btn = gr.Button('📅 Advance Day (no nudge)', variant='primary', size='lg')
913
+
914
+ gr.HTML('<hr style="border-color:#8a6030;margin:8px 0;">')
915
+ gr.HTML('<div class="section-title">✉ Nudge the Story</div>')
916
+ gr.HTML('<p style="font-size:.78em;color:#5a3615;text-align:center;'
917
+ 'font-style:italic;margin:0 0 6px;">Each nudge advances one day.</p>')
918
+
919
+ with gr.Accordion('🗣️ Spread a Rumour', open=False):
920
+ rumour_creature = gr.Dropdown(choices=CREATURES, value=CREATURES[0],
921
+ label='About which creature?')
922
+ rumour_type_dd = gr.Dropdown(choices=RUMOUR_TYPES, value=RUMOUR_TYPES[0],
923
+ label='What rumour?')
924
+ rumour_btn = gr.Button('📢 Spread It', variant='secondary')
925
+
926
+ with gr.Accordion('🎁 Donate a Weird Object', open=False):
927
+ donation_dd = gr.Dropdown(choices=WEIRD_OBJECTS, value=WEIRD_OBJECTS[0],
928
+ label='Which object?')
929
+ donation_btn = gr.Button('🎁 Donate It', variant='secondary')
930
+
931
+ with gr.Accordion('⚖️ Propose a New Law', open=False):
932
+ law_dd = gr.Dropdown(choices=LAWS, value=LAWS[0], label='Which law?')
933
+ law_btn = gr.Button('⚖️ Propose It', variant='secondary')
934
+
935
+ gr.HTML('<hr style="border-color:#8a6030;margin:8px 0;">')
936
+ share_btn = gr.Button('🖼️ Share as Image', variant='secondary')
937
+ img_output = gr.Image(label='Front Page PNG', visible=False, type='filepath')
938
+ export_btn = gr.Button('📡 Export Agent Traces (JSON)', variant='secondary')
939
+ trace_output = gr.File(label='Agent Traces JSON', visible=False)
940
+
941
+ gr.HTML('<p style="font-size:.70em;color:#6a4818;text-align:center;'
942
+ 'margin-top:8px;font-style:italic;">'
943
+ 'Model: Qwen2.5-1.5B ≤4B 🐜&nbsp;|&nbsp;Local only 🔌&nbsp;|&nbsp;Custom UI 🎨</p>')
944
+
945
+ gr.HTML('<div class="section-title" style="margin-top:12px;">Woodland Residents</div>')
946
+ creature_display = gr.HTML(value=_html_creatures())
947
+
948
+ _OUT = [newspaper_display, creature_display, civ_stats_html,
949
+ archive_dd, status_html, day_state, parsed_state, classif_state]
950
+
951
+ advance_btn.click( fn=handle_advance, inputs=[], outputs=_OUT)
952
+ rumour_btn.click( fn=handle_rumour, inputs=[rumour_creature, rumour_type_dd], outputs=_OUT)
953
+ donation_btn.click(fn=handle_donation, inputs=[donation_dd], outputs=_OUT)
954
+ law_btn.click( fn=handle_law, inputs=[law_dd], outputs=_OUT)
955
+ archive_dd.change( fn=handle_archive_view, inputs=[archive_dd], outputs=[archive_display])
956
+ share_btn.click( fn=handle_share, inputs=[day_state, parsed_state, classif_state], outputs=[img_output])
957
+ export_btn.click( fn=handle_export_traces, inputs=[], outputs=[trace_output])
958
+
959
+ # ═══════════════════════════════════════════════════════════════════
960
+ # 13 ▸ ENTRY POINT
961
+ # ═══════════════════════════════════════════════════════════════════
962
+ if __name__ == "__main__":
963
+ demo.launch(server_name="0.0.0.0", server_port=7860, **_LKW)
database.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ database.py — Tiny Civilization persistent storage layer.
3
+ SQLite-backed, safe for concurrent Gradio calls.
4
+ """
5
+ import sqlite3
6
+ import json
7
+ import os
8
+ from datetime import datetime
9
+ from typing import Optional
10
+
11
+ # ── DB path: prefer HF /data (persistent volume), fallback to cwd ──
12
+ _DATA_DIRS = ["/data", "."]
13
+ DB_PATH = os.getenv("TINY_DB_PATH", "")
14
+ if not DB_PATH:
15
+ for _d in _DATA_DIRS:
16
+ try:
17
+ os.makedirs(_d, exist_ok=True)
18
+ _t = os.path.join(_d, ".wtest"); open(_t,"w").write("ok"); os.remove(_t)
19
+ DB_PATH = os.path.join(_d, "tiny_civilization.db"); break
20
+ except Exception: continue
21
+ if not DB_PATH: DB_PATH = "tiny_civilization.db"
22
+
23
+
24
+ def _conn() -> sqlite3.Connection:
25
+ c = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10)
26
+ c.row_factory = sqlite3.Row
27
+ return c
28
+
29
+
30
+ # ─────────────────────────────────────────────────────────────────
31
+ # Schema + seed
32
+ # ─────────────────────────────────────────────────────────────────
33
+ def init_db() -> None:
34
+ with _conn() as con:
35
+ cur = con.cursor()
36
+ cur.execute("""CREATE TABLE IF NOT EXISTS days (
37
+ day_number INTEGER PRIMARY KEY,
38
+ headline TEXT NOT NULL,
39
+ full_newspaper_text TEXT NOT NULL,
40
+ timestamp TEXT NOT NULL
41
+ )""")
42
+ cur.execute("""CREATE TABLE IF NOT EXISTS events (
43
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44
+ day_number INTEGER NOT NULL,
45
+ actor TEXT NOT NULL,
46
+ action TEXT NOT NULL,
47
+ target TEXT NOT NULL,
48
+ description TEXT NOT NULL
49
+ )""")
50
+ cur.execute("""CREATE TABLE IF NOT EXISTS creatures (
51
+ name TEXT PRIMARY KEY,
52
+ relationship_scores TEXT NOT NULL DEFAULT '{}',
53
+ inventory TEXT NOT NULL DEFAULT '[]'
54
+ )""")
55
+ cur.execute("""CREATE TABLE IF NOT EXISTS nudges (
56
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
57
+ day_number INTEGER NOT NULL,
58
+ nudge_type TEXT NOT NULL,
59
+ nudge_value TEXT NOT NULL
60
+ )""")
61
+ con.commit()
62
+
63
+ # ── Seed creatures ─────────────────────────────────────────
64
+ _SEED = {
65
+ "fox": {
66
+ "relationships": {"badger": 42, "squirrel": 61, "mole": 55},
67
+ "inventory": ["forged certificate of merit", "silk scarf (dubious origin)"],
68
+ },
69
+ "badger": {
70
+ "relationships": {"fox": 28, "squirrel": 67, "mole": 72},
71
+ "inventory": ["ancient grudge (well-preserved)", "favourite grey stone"],
72
+ },
73
+ "squirrel": {
74
+ "relationships": {"fox": 63, "badger": 70, "mole": 48},
75
+ "inventory": ["seven-and-a-half acorns", "borrowed umbrella (decade old)"],
76
+ },
77
+ "mole": {
78
+ "relationships": {"fox": 51, "badger": 76, "squirrel": 53},
79
+ "inventory": ["map of secret tunnels", "crystal monocle", "lost button"],
80
+ },
81
+ }
82
+ for name, data in _SEED.items():
83
+ if not cur.execute("SELECT 1 FROM creatures WHERE name=?", (name,)).fetchone():
84
+ cur.execute(
85
+ "INSERT INTO creatures (name,relationship_scores,inventory) VALUES (?,?,?)",
86
+ (name, json.dumps(data["relationships"]), json.dumps(data["inventory"])),
87
+ )
88
+ con.commit()
89
+
90
+ # ── Seed Day 0: the Founding ───────────────────────────────
91
+ if not cur.execute("SELECT 1 FROM days WHERE day_number=0").fetchone():
92
+ founding_text = (
93
+ "TINYWICK HOLLOW DECLARES ITSELF A CIVILISATION TODAY\n\n"
94
+ "In a development that surprised absolutely no one who knows these four, Tinywick "
95
+ "Hollow has formally declared itself a civilisation. The founding document was "
96
+ "signed by Beatrice Badger (who insists it must be legally binding), Reginald "
97
+ "Fox (who has already forged three certified copies), Cornelius Squirrel (who "
98
+ "invented a device to sign it faster, then signed it twice by mistake), and "
99
+ "Millicent Mole (who observed that the document was, in a sense, already signed "
100
+ "underground, and then descended). The future of Tinywick Hollow remains, as "
101
+ "always, magnificently uncertain.\n\n"
102
+ "WEATHER: Portentous, with scattered significance.\n"
103
+ "FOX: Forged three certificates before breakfast.\n"
104
+ "BADGER: Insisted on thirteen constitutional amendments before lunch.\n"
105
+ "SQUIRREL: Invented a signing machine! It signed the wrong document!\n"
106
+ "MOLE: Something is already happening underground."
107
+ )
108
+ cur.execute(
109
+ "INSERT INTO days (day_number,headline,full_newspaper_text,timestamp) VALUES (?,?,?,?)",
110
+ (0, "TINYWICK HOLLOW DECLARES ITSELF A CIVILISATION TODAY",
111
+ founding_text, datetime.now().isoformat())
112
+ )
113
+ con.commit()
114
+
115
+
116
+ # ─────────────────────────────────────────────────────────────────
117
+ # Days
118
+ # ─────────────────────────────────────────────────────────────────
119
+ def save_day(day_number: int, headline: str, full_newspaper_text: str) -> None:
120
+ with _conn() as con:
121
+ con.execute(
122
+ "INSERT OR REPLACE INTO days (day_number,headline,full_newspaper_text,timestamp) VALUES (?,?,?,?)",
123
+ (day_number, headline, full_newspaper_text, datetime.now().isoformat()),
124
+ ); con.commit()
125
+
126
+ def get_latest_day() -> Optional[dict]:
127
+ with _conn() as con:
128
+ row = con.execute("SELECT * FROM days ORDER BY day_number DESC LIMIT 1").fetchone()
129
+ return dict(row) if row else None
130
+
131
+ def get_day(day_number: int) -> Optional[dict]:
132
+ with _conn() as con:
133
+ row = con.execute("SELECT * FROM days WHERE day_number=?", (day_number,)).fetchone()
134
+ return dict(row) if row else None
135
+
136
+ def get_all_headlines() -> list[tuple[int, str]]:
137
+ with _conn() as con:
138
+ rows = con.execute("SELECT day_number,headline FROM days ORDER BY day_number DESC").fetchall()
139
+ return [(r["day_number"], r["headline"]) for r in rows]
140
+
141
+ def get_next_day_number() -> int:
142
+ with _conn() as con:
143
+ row = con.execute("SELECT MAX(day_number) AS m FROM days").fetchone()
144
+ return (row["m"] or 0) + 1
145
+
146
+
147
+ # ─────────────────────────────────────────────────────────────────
148
+ # Events
149
+ # ─────────────────────────────────────────────────────────────────
150
+ def save_event(day_number: int, actor: str, action: str, target: str, description: str) -> None:
151
+ with _conn() as con:
152
+ con.execute(
153
+ "INSERT INTO events (day_number,actor,action,target,description) VALUES (?,?,?,?,?)",
154
+ (day_number, actor, action, target, description),
155
+ ); con.commit()
156
+
157
+ def get_events_for_day(day_number: int) -> list[dict]:
158
+ with _conn() as con:
159
+ rows = con.execute(
160
+ "SELECT actor,action,target,description FROM events WHERE day_number=?", (day_number,)
161
+ ).fetchall()
162
+ return [dict(r) for r in rows]
163
+
164
+
165
+ # ─────────────────────────────────────────────────────────────────
166
+ # Nudges
167
+ # ─────────────────────────────────────────────────────────────────
168
+ def save_nudge(day_number: int, nudge_type: str, nudge_value: str) -> None:
169
+ with _conn() as con:
170
+ con.execute("INSERT INTO nudges (day_number,nudge_type,nudge_value) VALUES (?,?,?)",
171
+ (day_number, nudge_type, nudge_value)); con.commit()
172
+
173
+ def get_recent_nudges(limit: int = 4) -> list[dict]:
174
+ with _conn() as con:
175
+ rows = con.execute(
176
+ "SELECT day_number,nudge_type,nudge_value FROM nudges ORDER BY id DESC LIMIT ?", (limit,)
177
+ ).fetchall()
178
+ return [dict(r) for r in rows]
179
+
180
+
181
+ # ─────────────────────────────────────────────────────────────────
182
+ # Creatures
183
+ # ─────────────────────────────────────────────────────────────────
184
+ def _parse_creature(row: sqlite3.Row) -> dict:
185
+ d = dict(row)
186
+ d["relationship_scores"] = json.loads(d["relationship_scores"])
187
+ d["inventory"] = json.loads(d["inventory"])
188
+ return d
189
+
190
+ def get_creature(name: str) -> Optional[dict]:
191
+ with _conn() as con:
192
+ row = con.execute("SELECT * FROM creatures WHERE name=?", (name,)).fetchone()
193
+ return _parse_creature(row) if row else None
194
+
195
+ def get_all_creatures() -> list[dict]:
196
+ with _conn() as con:
197
+ rows = con.execute("SELECT * FROM creatures").fetchall()
198
+ return [_parse_creature(r) for r in rows]
199
+
200
+ def update_creature(name: str, relationship_scores: Optional[dict]=None, inventory: Optional[list]=None) -> None:
201
+ with _conn() as con:
202
+ if relationship_scores is not None and inventory is not None:
203
+ con.execute("UPDATE creatures SET relationship_scores=?,inventory=? WHERE name=?",
204
+ (json.dumps(relationship_scores), json.dumps(inventory), name))
205
+ elif relationship_scores is not None:
206
+ con.execute("UPDATE creatures SET relationship_scores=? WHERE name=?",
207
+ (json.dumps(relationship_scores), name))
208
+ elif inventory is not None:
209
+ con.execute("UPDATE creatures SET inventory=? WHERE name=?",
210
+ (json.dumps(inventory), name))
211
+ con.commit()
212
+
213
+
214
+ # ─────────────────────────────────────────────────────────────────
215
+ # Civilisation stats
216
+ # ─────────────────────────────────────────────────────────────────
217
+ def get_civ_stats() -> dict:
218
+ with _conn() as con:
219
+ total_days = con.execute("SELECT COUNT(*) FROM days").fetchone()[0]
220
+ total_events = con.execute("SELECT COUNT(*) FROM events").fetchone()[0]
221
+ total_nudges = con.execute("SELECT COUNT(*) FROM nudges").fetchone()[0]
222
+ creatures = get_all_creatures()
223
+ best_pair = ("?", "?", 0); worst_pair = ("?", "?", 100)
224
+ for c in creatures:
225
+ for other, score in c["relationship_scores"].items():
226
+ pair = tuple(sorted([c["name"], other]))
227
+ if score > best_pair[2]: best_pair = (*pair, score)
228
+ if score < worst_pair[2]: worst_pair = (*pair, score)
229
+ dominant = max(creatures, key=lambda c: sum(c["relationship_scores"].values()), default={"name":"?"})
230
+ return {
231
+ "total_days": total_days,
232
+ "total_events": total_events,
233
+ "total_nudges": total_nudges,
234
+ "best_pair": best_pair,
235
+ "worst_pair": worst_pair,
236
+ "dominant": dominant["name"],
237
+ }
requirements.txt ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Tiny Civilization — requirements.txt ──────────────────────────
2
+ # Build Small Hackathon 2026 / Thousand Token Wood track
3
+ # Model: Qwen2.5-1.5B-Instruct (≤4B → Tiny Titan badge 🐜)
4
+
5
+ # Core inference — 1.5B model fits in ~3 GB VRAM, loads in <60 s on T4
6
+ torch>=2.1.0
7
+ transformers>=4.44.0
8
+ accelerate>=0.30.0
9
+
10
+ # UI — 5.7.1 is the first 5.x release that removed HfFolder from oauth.py.
11
+ # Do NOT pin below 5.7.1: older versions crash on huggingface_hub >= 0.23.
12
+ # Do NOT add `spaces` here: it's pre-installed on ZeroGPU spaces, and
13
+ # older pinned versions constrain gradio < 4, which re-introduces the crash.
14
+ gradio==5.9.0
15
+
16
+ # Newspaper PNG export
17
+ Pillow>=10.3.0