nishtha711 commited on
Commit
ebf50cb
·
verified ·
1 Parent(s): 5259491

Upload 5 files

Browse files
Files changed (5) hide show
  1. README.md +256 -8
  2. app.py +1155 -0
  3. database.py +260 -0
  4. packages.txt +2 -0
  5. requirements.txt +19 -0
README.md CHANGED
@@ -1,13 +1,261 @@
1
  ---
2
- title: Tiny Civilization
3
- emoji: 💻
4
- colorFrom: gray
5
- colorTo: pink
6
  sdk: gradio
7
- sdk_version: 6.16.0
8
- python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Tiny Civilization — The Tinywick Hollow Gazette
3
+ emoji: 🦊
4
+ colorFrom: yellow
5
+ colorTo: red
6
  sdk: gradio
7
+ sdk_version: "4.40.0"
 
8
  app_file: app.py
9
+ pinned: true
10
+ license: mit
11
+ short_description: A persistent woodland civilisation sim powered by Qwen2.5.
12
+ tags:
13
+ - simulation
14
+ - creative-writing
15
+ - llm
16
+ - gradio
17
+ - hackathon
18
+ - tiny-model
19
  ---
20
 
21
+ # 🦊 Tiny Civilization The Tinywick Hollow Gazette
22
+
23
+ > *A persistent woodland simulation where four creatures invent absurd civilisation events — and you read about them in the daily newspaper.*
24
+
25
+ Built for the **Hugging Face Build Small Hackathon — Thousand Token Wood** track.
26
+
27
+ ---
28
+
29
+ ## What is this?
30
+
31
+ Every time you click **Advance Day**, four woodland creatures —
32
+ 🦊 **Reginald Fox**, 🦡 **Beatrice Badger**, 🐿️ **Cornelius Squirrel**,
33
+ and 🐀 **Millicent Mole** — generate today's absurd events using a local
34
+ Qwen2.5 language model. Then the *Gazette* editor writes a formal newspaper
35
+ article treating the whole affair with the utmost journalistic gravity.
36
+
37
+ All simulation state is stored in a **SQLite database**, so the civilisation
38
+ persists across sessions (on Spaces with persistent storage attached).
39
+
40
+ ---
41
+
42
+ ## Features
43
+
44
+ | Feature | Detail |
45
+ |---|---|
46
+ | 🗞️ **Daily Newspaper** | Vintage-styled front page with headline + two-column article |
47
+ | 📜 **Archive Dropdown** | Browse every past front page |
48
+ | 🗣️ **Spread a Rumour** | Pick a creature and a rumour type — affects the next day |
49
+ | 🎁 **Donate a Weird Object** | Send a mysterious item into the economy |
50
+ | ⚖️ **Propose a Law** | Shift the legal framework of the Hollow |
51
+ | 🖼️ **Share as Image** | Download the front page as a newspaper-style PNG |
52
+ | 🎮 **Konami Code** | `↑↑↓↓←→←→BA` — reveals all agent system prompts |
53
+ | 🤖 **ZeroGPU** | `@spaces.GPU` decorator for dynamic GPU allocation |
54
+ | 💾 **Persistent SQLite** | `days`, `events`, `creatures`, `nudges` tables |
55
+
56
+ ---
57
+
58
+ ## Project Structure
59
+
60
+ ```
61
+ tiny_civilization/
62
+ ├── app.py # Main Gradio Blocks application
63
+ ├── database.py # SQLite layer (init_db, save_day, update_creature …)
64
+ ├── requirements.txt # Python dependencies
65
+ ├── packages.txt # System packages (fonts)
66
+ └── README.md # This file
67
+ ```
68
+
69
+ ---
70
+
71
+ ## Local Setup
72
+
73
+ ### Prerequisites
74
+ - Python 3.10+
75
+ - ~8 GB VRAM for 7B model (or ~6 GB for 3B fallback)
76
+ - CPU-only works too — just slower
77
+
78
+ ### Install
79
+
80
+ ```bash
81
+ git clone https://huggingface.co/spaces/YOUR_USERNAME/tiny-civilization
82
+ cd tiny-civilization
83
+
84
+ python -m venv .venv
85
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
86
+
87
+ pip install -r requirements.txt
88
+ ```
89
+
90
+ ### Run
91
+
92
+ ```bash
93
+ python app.py
94
+ ```
95
+
96
+ Open `http://localhost:7860` in your browser.
97
+
98
+ > **Note:** On first run the model downloads ~6–15 GB depending on which
99
+ > Qwen2.5 variant loads successfully. Subsequent starts use the local cache.
100
+
101
+ ### Environment variables
102
+
103
+ | Variable | Default | Purpose |
104
+ |---|---|---|
105
+ | `TINY_DB_PATH` | auto-detected | Override SQLite file path |
106
+ | `HF_HOME` | `~/.cache/huggingface` | Model cache directory |
107
+
108
+ ---
109
+
110
+ ## Deploying to Hugging Face Spaces
111
+
112
+ 1. Create a new **Gradio** Space on [huggingface.co/spaces](https://huggingface.co/spaces).
113
+ 2. Enable **ZeroGPU** in the Space hardware settings (T4 or better recommended).
114
+ 3. Optionally attach a **Persistent Storage** volume (Space → Settings → Persistent Storage) so the SQLite database survives restarts. The app writes to `/data/` when available, local directory otherwise.
115
+ 4. Push all files:
116
+
117
+ ```bash
118
+ git remote add space https://huggingface.co/spaces/YOUR_USERNAME/tiny-civilization
119
+ git push space main
120
+ ```
121
+
122
+ 5. The Space will install `requirements.txt` and `packages.txt` automatically.
123
+
124
+ ### `packages.txt` (system fonts for PIL image export)
125
+
126
+ Create a file named `packages.txt` at the repo root with:
127
+
128
+ ```
129
+ fonts-liberation
130
+ fonts-dejavu-core
131
+ ```
132
+
133
+ This installs serif fonts that PIL uses to render the newspaper image.
134
+ Without them the image still generates but uses a smaller bitmap font.
135
+
136
+ ---
137
+
138
+ ## How the Simulation Works
139
+
140
+ ```
141
+ User clicks button
142
+
143
+
144
+ advance_day() ←── @spaces.GPU(duration=360)
145
+
146
+ ├─ get_next_day_number() [SQLite]
147
+ ├─ get_all_creatures() [SQLite]
148
+
149
+ ├─ For each of 3 events:
150
+ │ ├─ pick random actor, target, event_type
151
+ │ ├─ call_agent(actor, situation_prompt) ←── Qwen2.5 inference
152
+ │ ├─ save_event() [SQLite]
153
+ │ └─ update creature relationship_scores [SQLite]
154
+
155
+ ├─ _generate_newspaper(events_summary) ←── Qwen2.5 inference
156
+ ├─ _parse_newspaper() → headline + article
157
+ ├─ save_day() [SQLite]
158
+
159
+ └─ return (day_number, headline, article)
160
+
161
+
162
+ Gradio updates all UI components
163
+ ```
164
+
165
+ ### Relationship System
166
+
167
+ Each creature has a relationship score (0–100) with every other creature.
168
+ Event types shift scores:
169
+
170
+ | Event | Delta (actor→target) |
171
+ |---|---|
172
+ | `trade` | +5 |
173
+ | `invention` | +7 |
174
+ | `gossip` | −4 |
175
+ | `feud` | −10 |
176
+
177
+ The target always receives half the delta in return.
178
+ These scores are included in agent prompts so the model can reflect
179
+ on whether a trade is proposed warmly or suspiciously.
180
+
181
+ ### Nudge Persistence
182
+
183
+ Nudges are stored in the `nudges` table and the last 3 are injected into
184
+ every subsequent day's generation context, creating a chain of cause and
185
+ effect across days.
186
+
187
+ ---
188
+
189
+ ## Model Details
190
+
191
+ | | |
192
+ |---|---|
193
+ | **Primary** | `Qwen/Qwen2.5-7B-Instruct` |
194
+ | **Fallback** | `Qwen/Qwen2.5-3B-Instruct` |
195
+ | **Precision** | `torch.float16` |
196
+ | **Device** | `device_map="auto"` |
197
+ | **Sampling** | `temperature=0.88`, `top_p=0.92` |
198
+ | **Max tokens** | 120 per agent, 280 for newspaper |
199
+
200
+ The app makes **4 inference calls per day** (3 creature events + 1 newspaper).
201
+ Each response is short (2–3 sentences for agents, 4 sentences for the Gazette).
202
+ This keeps total token usage well within the *Thousand Token Wood* track budget.
203
+
204
+ ---
205
+
206
+ ## Easter Egg
207
+
208
+ Enter the **Konami Code** on your keyboard while the app is focused:
209
+
210
+ ```
211
+ ↑ ↑ ↓ ↓ ← → ← → B A
212
+ ```
213
+
214
+ A modal will appear showing the raw system prompts for all four creature
215
+ agents and the Gazette editor — a behind-the-scenes look at the personas
216
+ driving the simulation.
217
+
218
+ ---
219
+
220
+ ## Database Schema
221
+
222
+ ```sql
223
+ CREATE TABLE days (
224
+ day_number INTEGER PRIMARY KEY,
225
+ headline TEXT NOT NULL,
226
+ full_newspaper_text TEXT NOT NULL,
227
+ timestamp TEXT NOT NULL
228
+ );
229
+
230
+ CREATE TABLE events (
231
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
232
+ day_number INTEGER NOT NULL,
233
+ actor TEXT NOT NULL, -- fox | badger | squirrel | mole
234
+ action TEXT NOT NULL, -- trade | gossip | feud | invention
235
+ target TEXT NOT NULL,
236
+ description TEXT NOT NULL
237
+ );
238
+
239
+ CREATE TABLE creatures (
240
+ name TEXT PRIMARY KEY,
241
+ relationship_scores TEXT NOT NULL, -- JSON {"fox": 42, …}
242
+ inventory TEXT NOT NULL -- JSON ["item1", …]
243
+ );
244
+
245
+ CREATE TABLE nudges (
246
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
247
+ day_number INTEGER NOT NULL,
248
+ nudge_type TEXT NOT NULL, -- rumour | donation | law
249
+ nudge_value TEXT NOT NULL
250
+ );
251
+ ```
252
+
253
+ ---
254
+
255
+ ## License
256
+
257
+ MIT — do whatever you like with this absurd woodland bureaucracy.
258
+
259
+ ---
260
+
261
+ *"All things are connected underground." — Millicent Mole*
app.py ADDED
@@ -0,0 +1,1155 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ # ── Gradio 6+ moved css/theme from Blocks() to launch() ──────────
948
+ _GR_MAJOR = int(gr.__version__.split(".")[0])
949
+ if _GR_MAJOR >= 6:
950
+ _BLOCKS_KW: dict = {}
951
+ _LAUNCH_KW: dict = {"css": NEWSPAPER_CSS}
952
+ else:
953
+ _BLOCKS_KW = {
954
+ "css": NEWSPAPER_CSS,
955
+ "theme": gr.themes.Base(
956
+ primary_hue=gr.themes.colors.orange,
957
+ neutral_hue=gr.themes.colors.stone,
958
+ ),
959
+ }
960
+ _LAUNCH_KW = {}
961
+
962
+ # Initialise DB and read starting state
963
+ database.init_db()
964
+ _latest = database.get_latest_day()
965
+ if _latest:
966
+ _parts = _latest["full_newspaper_text"].split("\n\n", 1)
967
+ _INIT_DAY = _latest["day_number"]
968
+ _INIT_HED = _parts[0]
969
+ _INIT_ART = _parts[1] if len(_parts) > 1 else _latest["full_newspaper_text"]
970
+ else:
971
+ _INIT_DAY = 0
972
+ _INIT_HED = ""
973
+ _INIT_ART = ""
974
+
975
+ # Shared output definition (returned by every nudge/advance handler)
976
+ _COMMON = 7 # newspaper, creatures, archive_dd, status, day_state, hed_state, art_state
977
+
978
+ with gr.Blocks(
979
+ title="Tiny Civilization — The Tinywick Hollow Gazette",
980
+ **_BLOCKS_KW,
981
+ ) as demo:
982
+
983
+ # ── Konami modal (injected before everything else) ───────────
984
+ gr.HTML(_konami_modal_html())
985
+
986
+ # ── Page header ──────────────────────────────────────────────
987
+ gr.HTML("""
988
+ <div style="text-align:center;padding:8px 0 4px;">
989
+ <h1 style="font-family:'Playfair Display',Georgia,serif;color:#1a0a04;
990
+ font-size:1.8em;margin:0 0 2px;">
991
+ 🦊 Tiny Civilization 🐀
992
+ </h1>
993
+ <p style="font-family:Georgia,serif;color:#5a3820;font-style:italic;
994
+ margin:0;font-size:.88em;">
995
+ A persistent woodland civilisation. One day at a time. One acorn at a time.
996
+ &nbsp;|&nbsp; <kbd title="Konami Code">↑↑↓↓←→←→BA</kbd> for secrets.
997
+ </p>
998
+ </div>
999
+ """)
1000
+
1001
+ # ── Persistent state ─────────────────────────────────────────
1002
+ day_state = gr.State(_INIT_DAY)
1003
+ hed_state = gr.State(_INIT_HED)
1004
+ art_state = gr.State(_INIT_ART)
1005
+
1006
+ # ── Status bar ───────────────────────────────────────────────
1007
+ status_html = gr.HTML(
1008
+ value=_status(
1009
+ f"Day {_INIT_DAY} already in the archive — ready for Day {_INIT_DAY+1}."
1010
+ if _INIT_DAY > 0 else
1011
+ "No days recorded yet. Click Advance Day to start the chronicle."
1012
+ )
1013
+ )
1014
+
1015
+ # ── Main layout ──────────────────────────────────────────────
1016
+ with gr.Row(equal_height=False):
1017
+
1018
+ # ── LEFT: Newspaper display ──────────────────────────────
1019
+ with gr.Column(scale=3):
1020
+ newspaper_display = gr.HTML(
1021
+ value=_html_paper(_INIT_HED, _INIT_ART, _INIT_DAY)
1022
+ if _INIT_DAY > 0 else _html_placeholder()
1023
+ )
1024
+
1025
+ # Archive
1026
+ with gr.Accordion("📜 Archive — Past Front Pages", open=False):
1027
+ archive_dd = gr.Dropdown(
1028
+ choices=_archive_choices(),
1029
+ value=None,
1030
+ label="Select a past day",
1031
+ container=False,
1032
+ )
1033
+ archive_display = gr.HTML(
1034
+ value='<div class="archive-area">'
1035
+ '<em>Select a day above to view its edition.</em></div>'
1036
+ )
1037
+
1038
+ # ── RIGHT: Controls ──────────────────────────────────────
1039
+ with gr.Column(scale=1, min_width=240):
1040
+
1041
+ gr.HTML('<div class="section-title">📰 Editorial Desk</div>')
1042
+
1043
+ advance_btn = gr.Button(
1044
+ "📅 Advance Day (no nudge)",
1045
+ variant="primary",
1046
+ size="lg",
1047
+ )
1048
+
1049
+ gr.HTML('<hr style="border-color:#9a7050;margin:10px 0;">')
1050
+ gr.HTML('<div class="section-title">✉ Nudge the Story</div>')
1051
+ gr.HTML('<p style="font-size:.8em;color:#5a3820;text-align:center;'
1052
+ 'font-style:italic;margin:0 0 8px;">Each nudge advances the day.</p>')
1053
+
1054
+ # Nudge 1 — Rumour
1055
+ with gr.Accordion("🗣️ Spread a Rumour", open=False):
1056
+ rumour_creature = gr.Dropdown(
1057
+ choices=CREATURES,
1058
+ value=CREATURES[0],
1059
+ label="About which creature?",
1060
+ )
1061
+ rumour_type_dd = gr.Dropdown(
1062
+ choices=RUMOUR_TYPES,
1063
+ value=RUMOUR_TYPES[0],
1064
+ label="What kind of rumour?",
1065
+ )
1066
+ rumour_btn = gr.Button("📢 Spread It", variant="secondary")
1067
+
1068
+ # Nudge 2 — Donation
1069
+ with gr.Accordion("🎁 Donate a Weird Object", open=False):
1070
+ donation_dd = gr.Dropdown(
1071
+ choices=WEIRD_OBJECTS,
1072
+ value=WEIRD_OBJECTS[0],
1073
+ label="Which object?",
1074
+ )
1075
+ donation_btn = gr.Button("🎁 Donate It", variant="secondary")
1076
+
1077
+ # Nudge 3 — Law
1078
+ with gr.Accordion("⚖️ Propose a New Law", open=False):
1079
+ law_dd = gr.Dropdown(
1080
+ choices=LAWS,
1081
+ value=LAWS[0],
1082
+ label="Which law?",
1083
+ )
1084
+ law_btn = gr.Button("⚖️ Propose It", variant="secondary")
1085
+
1086
+ gr.HTML('<hr style="border-color:#9a7050;margin:10px 0;">')
1087
+
1088
+ share_btn = gr.Button("🖼️ Share as Image", variant="secondary")
1089
+ img_output = gr.Image(
1090
+ label="Front Page PNG",
1091
+ visible=False,
1092
+ type="filepath",
1093
+ )
1094
+
1095
+ gr.HTML(
1096
+ '<p style="font-size:.72em;color:#6a4828;text-align:center;'
1097
+ 'margin-top:8px;font-style:italic;">'
1098
+ 'Model: Qwen2.5-7B (or 3B fallback)<br>'
1099
+ 'Powered by ZeroGPU · Tiny Civilization</p>'
1100
+ )
1101
+
1102
+ # ── Creature status ──────────────────────────────────────────
1103
+ gr.HTML('<div class="section-title" style="margin-top:14px;">Woodland Residents</div>')
1104
+ creature_display = gr.HTML(value=_html_creatures())
1105
+
1106
+ # ── Wire outputs list ─────────────────────────────────────────
1107
+ _OUTPUTS = [
1108
+ newspaper_display,
1109
+ creature_display,
1110
+ archive_dd,
1111
+ status_html,
1112
+ day_state,
1113
+ hed_state,
1114
+ art_state,
1115
+ ]
1116
+
1117
+ # ── Event wiring ─────────────────────────────────────────────
1118
+ advance_btn.click(fn=handle_advance, inputs=[], outputs=_OUTPUTS)
1119
+
1120
+ rumour_btn.click(
1121
+ fn=handle_rumour,
1122
+ inputs=[rumour_creature, rumour_type_dd],
1123
+ outputs=_OUTPUTS,
1124
+ )
1125
+
1126
+ donation_btn.click(
1127
+ fn=handle_donation,
1128
+ inputs=[donation_dd],
1129
+ outputs=_OUTPUTS,
1130
+ )
1131
+
1132
+ law_btn.click(
1133
+ fn=handle_law,
1134
+ inputs=[law_dd],
1135
+ outputs=_OUTPUTS,
1136
+ )
1137
+
1138
+ archive_dd.change(
1139
+ fn=handle_archive_view,
1140
+ inputs=[archive_dd],
1141
+ outputs=[archive_display],
1142
+ )
1143
+
1144
+ share_btn.click(
1145
+ fn=handle_share,
1146
+ inputs=[day_state, hed_state, art_state],
1147
+ outputs=[img_output],
1148
+ )
1149
+
1150
+
1151
+ # ═══════════════════════════════════════════════════════════════════
1152
+ # 13 ▸ ENTRY POINT
1153
+ # ══════════════════════════════════════════════��════════════════════
1154
+ if __name__ == "__main__":
1155
+ demo.launch(server_name="0.0.0.0", server_port=7860, **_LAUNCH_KW)
database.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ database.py — Tiny Civilization persistent storage layer.
3
+ All simulation state lives in a SQLite file. Functions are designed to be
4
+ safe for concurrent Gradio calls (each call opens its own short-lived connection).
5
+ """
6
+
7
+ import sqlite3
8
+ import json
9
+ import os
10
+ from datetime import datetime
11
+ from typing import Optional
12
+
13
+ # ──────────────────────────────────────────────────────────────
14
+ # Database path — prefer HF Spaces /data (persistent volume),
15
+ # then fall back gracefully to a local file.
16
+ # ──────────────────────────────────────────────────────────────
17
+ _DATA_DIRS = ["/data", "."]
18
+ DB_PATH = os.getenv("TINY_DB_PATH", "")
19
+ if not DB_PATH:
20
+ for _d in _DATA_DIRS:
21
+ try:
22
+ os.makedirs(_d, exist_ok=True)
23
+ _test = os.path.join(_d, ".write_test")
24
+ with open(_test, "w") as f:
25
+ f.write("ok")
26
+ os.remove(_test)
27
+ DB_PATH = os.path.join(_d, "tiny_civilization.db")
28
+ break
29
+ except Exception:
30
+ continue
31
+ if not DB_PATH:
32
+ DB_PATH = "tiny_civilization.db"
33
+
34
+
35
+ def _conn() -> sqlite3.Connection:
36
+ """Open a SQLite connection with row_factory for dict-style access."""
37
+ c = sqlite3.connect(DB_PATH, check_same_thread=False, timeout=10)
38
+ c.row_factory = sqlite3.Row
39
+ return c
40
+
41
+
42
+ # ──────────────────────────────────────────────────────────────
43
+ # Schema
44
+ # ──────────────────────────────────────────────────────────────
45
+
46
+ def init_db() -> None:
47
+ """Create tables and seed starting creature state if absent."""
48
+ with _conn() as con:
49
+ cur = con.cursor()
50
+
51
+ cur.execute("""
52
+ CREATE TABLE IF NOT EXISTS days (
53
+ day_number INTEGER PRIMARY KEY,
54
+ headline TEXT NOT NULL,
55
+ full_newspaper_text TEXT NOT NULL,
56
+ timestamp TEXT NOT NULL
57
+ )
58
+ """)
59
+
60
+ cur.execute("""
61
+ CREATE TABLE IF NOT EXISTS events (
62
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
63
+ day_number INTEGER NOT NULL,
64
+ actor TEXT NOT NULL,
65
+ action TEXT NOT NULL,
66
+ target TEXT NOT NULL,
67
+ description TEXT NOT NULL
68
+ )
69
+ """)
70
+
71
+ cur.execute("""
72
+ CREATE TABLE IF NOT EXISTS creatures (
73
+ name TEXT PRIMARY KEY,
74
+ relationship_scores TEXT NOT NULL DEFAULT '{}',
75
+ inventory TEXT NOT NULL DEFAULT '[]'
76
+ )
77
+ """)
78
+
79
+ cur.execute("""
80
+ CREATE TABLE IF NOT EXISTS nudges (
81
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
82
+ day_number INTEGER NOT NULL,
83
+ nudge_type TEXT NOT NULL,
84
+ nudge_value TEXT NOT NULL
85
+ )
86
+ """)
87
+
88
+ con.commit()
89
+
90
+ # ── Seed creatures if the table is empty ──────────────
91
+ _SEED = {
92
+ "fox": {
93
+ "relationships": {"badger": 42, "squirrel": 61, "mole": 55},
94
+ "inventory": ["forged certificate of merit", "silk scarf (suspect origin)"],
95
+ },
96
+ "badger": {
97
+ "relationships": {"fox": 28, "squirrel": 67, "mole": 72},
98
+ "inventory": ["ancient grudge (well-preserved)", "favourite grey stone"],
99
+ },
100
+ "squirrel": {
101
+ "relationships": {"fox": 63, "badger": 70, "mole": 48},
102
+ "inventory": ["seven-and-a-half acorns", "borrowed umbrella (decade old)"],
103
+ },
104
+ "mole": {
105
+ "relationships": {"fox": 51, "badger": 76, "squirrel": 53},
106
+ "inventory": ["map of secret tunnels", "crystal monocle", "lost button"],
107
+ },
108
+ }
109
+
110
+ for name, data in _SEED.items():
111
+ exists = cur.execute(
112
+ "SELECT 1 FROM creatures WHERE name = ?", (name,)
113
+ ).fetchone()
114
+ if not exists:
115
+ cur.execute(
116
+ "INSERT INTO creatures (name, relationship_scores, inventory) VALUES (?,?,?)",
117
+ (name, json.dumps(data["relationships"]), json.dumps(data["inventory"])),
118
+ )
119
+
120
+ con.commit()
121
+
122
+
123
+ # ──────────────────────────────────────────────────────────────
124
+ # Days
125
+ # ──────────────────────────────────────────────────────────────
126
+
127
+ def save_day(day_number: int, headline: str, full_newspaper_text: str) -> None:
128
+ with _conn() as con:
129
+ con.execute(
130
+ "INSERT OR REPLACE INTO days (day_number, headline, full_newspaper_text, timestamp) "
131
+ "VALUES (?,?,?,?)",
132
+ (day_number, headline, full_newspaper_text, datetime.now().isoformat()),
133
+ )
134
+ con.commit()
135
+
136
+
137
+ def get_latest_day() -> Optional[dict]:
138
+ with _conn() as con:
139
+ row = con.execute(
140
+ "SELECT * FROM days ORDER BY day_number DESC LIMIT 1"
141
+ ).fetchone()
142
+ return dict(row) if row else None
143
+
144
+
145
+ def get_day(day_number: int) -> Optional[dict]:
146
+ with _conn() as con:
147
+ row = con.execute(
148
+ "SELECT * FROM days WHERE day_number = ?", (day_number,)
149
+ ).fetchone()
150
+ return dict(row) if row else None
151
+
152
+
153
+ def get_all_headlines() -> list[tuple[int, str]]:
154
+ with _conn() as con:
155
+ rows = con.execute(
156
+ "SELECT day_number, headline FROM days ORDER BY day_number DESC"
157
+ ).fetchall()
158
+ return [(r["day_number"], r["headline"]) for r in rows]
159
+
160
+
161
+ def get_next_day_number() -> int:
162
+ with _conn() as con:
163
+ row = con.execute("SELECT MAX(day_number) AS m FROM days").fetchone()
164
+ return (row["m"] or 0) + 1
165
+
166
+
167
+ # ──────────────────────────────────────────────────────────────
168
+ # Events
169
+ # ──────────────────────────────────────────────────────────────
170
+
171
+ def save_event(
172
+ day_number: int, actor: str, action: str, target: str, description: str
173
+ ) -> None:
174
+ with _conn() as con:
175
+ con.execute(
176
+ "INSERT INTO events (day_number, actor, action, target, description) "
177
+ "VALUES (?,?,?,?,?)",
178
+ (day_number, actor, action, target, description),
179
+ )
180
+ con.commit()
181
+
182
+
183
+ def get_events_for_day(day_number: int) -> list[dict]:
184
+ with _conn() as con:
185
+ rows = con.execute(
186
+ "SELECT actor, action, target, description FROM events WHERE day_number = ?",
187
+ (day_number,),
188
+ ).fetchall()
189
+ return [dict(r) for r in rows]
190
+
191
+
192
+ # ──────────────────────────────────────────────────────────────
193
+ # Nudges
194
+ # ──────────────────────────────────────────────────────────────
195
+
196
+ def save_nudge(day_number: int, nudge_type: str, nudge_value: str) -> None:
197
+ with _conn() as con:
198
+ con.execute(
199
+ "INSERT INTO nudges (day_number, nudge_type, nudge_value) VALUES (?,?,?)",
200
+ (day_number, nudge_type, nudge_value),
201
+ )
202
+ con.commit()
203
+
204
+
205
+ def get_recent_nudges(limit: int = 4) -> list[dict]:
206
+ with _conn() as con:
207
+ rows = con.execute(
208
+ "SELECT day_number, nudge_type, nudge_value FROM nudges ORDER BY id DESC LIMIT ?",
209
+ (limit,),
210
+ ).fetchall()
211
+ return [dict(r) for r in rows]
212
+
213
+
214
+ # ──────────────────────────────────────────────────────────────
215
+ # Creatures
216
+ # ──────────────────────────────────────────────────────────────
217
+
218
+ def _parse_creature(row: sqlite3.Row) -> dict:
219
+ d = dict(row)
220
+ d["relationship_scores"] = json.loads(d["relationship_scores"])
221
+ d["inventory"] = json.loads(d["inventory"])
222
+ return d
223
+
224
+
225
+ def get_creature(name: str) -> Optional[dict]:
226
+ with _conn() as con:
227
+ row = con.execute(
228
+ "SELECT * FROM creatures WHERE name = ?", (name,)
229
+ ).fetchone()
230
+ return _parse_creature(row) if row else None
231
+
232
+
233
+ def get_all_creatures() -> list[dict]:
234
+ with _conn() as con:
235
+ rows = con.execute("SELECT * FROM creatures").fetchall()
236
+ return [_parse_creature(r) for r in rows]
237
+
238
+
239
+ def update_creature(
240
+ name: str,
241
+ relationship_scores: Optional[dict] = None,
242
+ inventory: Optional[list] = None,
243
+ ) -> None:
244
+ with _conn() as con:
245
+ if relationship_scores is not None and inventory is not None:
246
+ con.execute(
247
+ "UPDATE creatures SET relationship_scores=?, inventory=? WHERE name=?",
248
+ (json.dumps(relationship_scores), json.dumps(inventory), name),
249
+ )
250
+ elif relationship_scores is not None:
251
+ con.execute(
252
+ "UPDATE creatures SET relationship_scores=? WHERE name=?",
253
+ (json.dumps(relationship_scores), name),
254
+ )
255
+ elif inventory is not None:
256
+ con.execute(
257
+ "UPDATE creatures SET inventory=? WHERE name=?",
258
+ (json.dumps(inventory), name),
259
+ )
260
+ con.commit()
packages.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ fonts-liberation
2
+ fonts-dejavu-core
requirements.txt ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Tiny Civilization — requirements.txt ──────────────────────────
2
+ # Hugging Face Build Small Hackathon / Thousand Token Wood track
3
+
4
+ # Core inference
5
+ torch>=2.1.0
6
+ transformers>=4.44.0
7
+ accelerate>=0.30.0 # device_map="auto" multi-device support
8
+
9
+ # HF Spaces GPU management
10
+ spaces>=0.19.0 # ZeroGPU @spaces.GPU decorator
11
+
12
+ # UI
13
+ gradio>=4.40.0
14
+
15
+ # Image generation (newspaper PNG)
16
+ Pillow>=10.3.0
17
+
18
+ # Utilities (already in most Python envs, listed for explicitness)
19
+ # sqlite3 — stdlib, no install needed