nishtha711's picture
Update app.py
619eda2 verified
Raw
History Blame Contribute Delete
55.3 kB
"""
app.py — Tiny Civilization: The Tinywick Hollow Gazette 🍄
Persistent woodland civilisation · Qwen2.5-1.5B · ZeroGPU · Gradio 6
Build Small Hackathon 2026 — Thousand Token Wood track
"""
# ═══════════════════════════════════════════════════════════════════
# 0 ▸ IMPORTS
# ═══════════════════════════════════════════════════════════════════
from __future__ import annotations
import json, os, random, re, textwrap, traceback
from datetime import datetime
import gradio as gr
from PIL import Image, ImageDraw, ImageFont
import database
try:
import spaces
_ZERO_GPU = True
except ImportError:
class _Stub:
@staticmethod
def GPU(fn=None, *, duration=60):
return fn if callable(fn) else (lambda f: f)
spaces = _Stub(); _ZERO_GPU = False # type: ignore
import torch
from transformers import pipeline as hf_pipeline
# ═══════════════════════════════════════════════════════════════════
# 1 ▸ CONSTANTS
# ═══════════════════════════════════════════════════════════════════
CREATURES = ["fox", "badger", "squirrel", "mole"]
EVENT_TYPES = ["trade", "gossip", "feud", "invention", "discovery", "ceremony"]
CREATURE_EMOJI = {"fox":"🦊","badger":"🦡","squirrel":"🐿️","mole":"🐀"}
MODEL_PRIMARY = "Qwen/Qwen2.5-1.5B-Instruct"
MODEL_FALLBACK = "Qwen/Qwen2.5-3B-Instruct"
REL_DELTAS = {"trade":+5,"gossip":-4,"feud":-10,"invention":+7,"discovery":+6,"ceremony":+3}
REL_TIERS = [
(0,25,"Sworn Enemies","⚔️"),(26,40,"Very Suspicious","🦔"),
(41,55,"Wary Acquaintances","🤝"),(56,70,"Friendly","🌰"),
(71,85,"Close Companions","🌿"),(86,100,"Inseparable","🍄"),
]
WEIRD_OBJECTS = [
"half-eaten poem","suspicious mushroom","button that looks like the moon",
"forgotten birthday","three secrets","a fake acorn",
"map to somewhere that may not exist","extremely formal apology note",
"small jar of preserved thunder","second-hand prophecy",
]
LAWS = [
"no trading on Tuesdays","buttons = currency",
"everyone must compliment the badger","mushrooms are sacred",
"all debts expire at midnight","silence is legally binding",
"hats must be worn ironically","the mole's word is final (underground only)",
]
RUMOUR_TYPES = [
"has been secretly hoarding acorns",
"was seen talking to a suspicious stranger at midnight",
"invented something that does not work at all",
"owes three unpayable debts","made a deal with the rain",
"owns the moon (allegedly)",
"has been writing a novel about everyone in the hollow",
"is not who they say they are",
"has found a door underground that was not there before",
"once ate an entire philosophy",
]
CLASSIFIEDS = [
"LOST: One certainty. If found, please do not return it. — M. Mole",
"FOR SALE: Slightly used certificate of excellence. Condition: forged. — R. Fox",
"NOTICE: The badger declares the east mushroom illegal pending investigation.",
"WANTED: Someone to explain what happened last Tuesday.",
"FOUND: An unexplained event near the old oak. Owner may claim it.",
"REWARD: For the return of three acorns lent in confidence. You know who you are.",
"LOST: One argument. I was winning it. — B. Badger",
"ANNOUNCEMENT: The squirrel's new invention works. (Third announcement this week.)",
]
LOADING_MSGS = [
"🦊 Fox is forging today's certificates of news...",
"🦡 Badger is filing constitutional objections to yesterday's headline...",
"🐿️ Squirrel invented a faster printing press! It prints sideways!",
"🐀 Mole observes that the news already exists, underground...",
"📰 The Gazette's editor is being insufferably pompous...",
"✒️ Setting moveable type for today's edition...",
"🖨️ Pressing ink onto fresh Tinywick newsprint...",
"📜 Fox has pre-forged three copies of the headline...",
"🍄 Badger has declared the comma unconstitutional...",
"🔍 Fact-checking in progress. All facts remain suspicious.",
]
# ═══════════════════════════════════════════════════════════════════
# 2 ▸ AGENT PROMPTS
# ═══════════════════════════════════════════════════════════════════
AGENT_PROMPTS: dict[str, str] = {
"fox": (
"You are Reginald Fox — charming, dishonest, deeply fond of certificates. "
"You speak formally and hint at secret arrangements. Everything is negotiable. "
"Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
),
"badger": (
"You are Beatrice Badger — gruff keeper of rules, deeply suspicious of everyone "
"(especially the fox), secretly a poet. Mushrooms are a serious matter. "
"Short declarative sentences. You are always right. "
"Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
),
"squirrel": (
"You are Cornelius Squirrel — anxious inventor who speaks fast and repeats himself! "
"You invent things that almost-but-not-quite work! Obsessed with efficiency! "
"Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
),
"mole": (
"You are Millicent Mole — quiet, philosophical, rarely surfaces. "
"Incomplete thoughts and gentle riddles. Know everyone's secrets but share obliquely. "
"Reply in EXACTLY 2 sentences. No stage directions. Speak only as yourself."
),
}
NARRATOR_PROMPT = (
"CRITICAL RULES: (1) Do NOT start with the newspaper name. (2) Do NOT use markdown — no asterisks, no hashtags, no bold text, no bullet points. (3) Write plain flowing prose only. (4) The HEADLINE must be specific breaking news, never the paper name.\n\n""You are the pompous editor-in-chief of The Tinywick Hollow Gazette, "
"a broadsheet for an absurd woodland civilisation. "
"Given today's events, write the front page. "
"Copy this format EXACTLY — including the blank lines, the colon labels, "
"and writing the headline in ALL CAPITALS:\n\n"
"WRITE YOUR ACTUAL HEADLINE HERE IN ALL CAPS\n\n"
"Write the actual article here. Three to four sentences. "
"Pompous, formal, treating trivial events as major news.\n\n"
"WEATHER: One sentence of absurd woodland weather.\n"
"FOX: One sentence about what Reginald Fox did today.\n"
"BADGER: One sentence about what Beatrice Badger did today.\n"
"SQUIRREL: One sentence about what Cornelius Squirrel did today.\n"
"MOLE: One cryptic sentence about Millicent Mole.\n\n"
"EXAMPLE OF CORRECT OUTPUT:\n"
"FOX PROPOSES REPLACING ACORNS WITH FORGED CERTIFICATES\n\n"
"Reginald Fox has formally proposed that the hollow's economy be restructured "
"entirely around certificates of authenticity, a development which Beatrice Badger "
"has declared unconstitutional on eleven separate counts. "
"The proposal has garnered significant attention from those who already "
"own several suspicious certificates.\n\n"
"WEATHER: Partly suspicious, with a seventy percent chance of decrees.\n"
"FOX: Forged three certificates before the morning dew had lifted.\n"
"BADGER: Filed eleven formal objections before elevenses.\n"
"SQUIRREL: Invented a certificate-counting machine that counts to seven then resets.\n"
"MOLE: Something certificated is already circulating underground.\n\n"
"NOW WRITE THE REAL GAZETTE ENTRY FOR TODAY."
)
# ═══════════════════════════════════════════════════════════════════
# 3 ▸ MODEL (ZeroGPU only — lazy loaded inside @spaces.GPU)
# ═══════════════════════════════════════════════════════════════════
_pipe = None
_model_id_used = "none"
def _load_pipeline() -> None:
global _pipe, _model_id_used
if _pipe is not None: return
for mid in (MODEL_PRIMARY, MODEL_FALLBACK):
try:
print(f"[TinyC] Loading {mid}…", flush=True)
_pipe = hf_pipeline(
"text-generation", model=mid,
dtype=torch.float16, device_map="auto",
trust_remote_code=True,
)
_model_id_used = mid
print(f"[TinyC] {mid} ready ✓", flush=True)
return
except Exception as e:
print(f"[TinyC] {mid} failed: {e}", flush=True)
raise RuntimeError(f"Could not load {MODEL_PRIMARY} or {MODEL_FALLBACK}.")
def _generate(system_prompt: str, user_prompt: str, max_new_tokens: int = 160) -> str:
assert _pipe is not None
try:
out = _pipe(
[{"role":"system","content":system_prompt},{"role":"user","content":user_prompt}],
max_new_tokens=max_new_tokens, temperature=0.88, top_p=0.92,
do_sample=True, return_full_text=False,
)
return (out[0]["generated_text"] or "").strip()
except Exception as e:
print(f"[TinyC] generate error: {e}", flush=True); return ""
def call_agent(agent_name: str, context: str) -> str:
r = _generate(AGENT_PROMPTS.get(agent_name, AGENT_PROMPTS["fox"]), context, 100)
return r or f"{agent_name.capitalize()} had no comment at this time."
# ═══════════════════════════════════════════════════════════════════
# 4 ▸ SIMULATION
# ═══════════════════════════════════════════════════════════════════
def _nudge_ctx(nudge_type, nudge_value, nudge_target, day_number):
if nudge_type == "rumour" and nudge_target and nudge_value:
database.save_nudge(day_number, "rumour", f"{nudge_target}: {nudge_value}")
return f"A rumour is spreading that {nudge_target} {nudge_value}."
if nudge_type == "donation" and nudge_value:
r = random.choice(CREATURES)
c = database.get_creature(r)
if c:
database.update_creature(r, inventory=(c["inventory"]+[nudge_value])[-12:])
database.save_nudge(day_number, "donation", f"{r}: {nudge_value}")
return f"Someone donated '{nudge_value}' to {r}."
if nudge_type == "law" and nudge_value:
database.save_nudge(day_number, "law", nudge_value)
return f"A new law proposed: '{nudge_value}'."
return ""
# Words that indicate the model echoed a template instead of writing a real headline
_TEMPLATE_WORDS = {"HEADLINE", "INSERT", "PLACEHOLDER", "CAPS HERE",
"YOUR HEADLINE", "ACTUAL HEADLINE", "WRITE YOUR", "EXAMPLE",
"TINYWICK", "GAZETTE", "THE GAZETTE", "HOLLOW GAZETTE"}
def _is_template_headline(text: str) -> bool:
up = text.upper()
return any(w in up for w in _TEMPLATE_WORDS)
# Labels that should never land in the article body
def _strip_markdown(text: str) -> str:
if not text: return text
# Bold/italic anywhere: **x** *x* ***x*** -> x
text = re.sub(r'\*{1,3}(.+?)\*{1,3}', r'\1', text, flags=re.DOTALL)
# Headers at line start: ## Heading -> Heading
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
# Headers mid-line: "text ### Heading more" -> "text Heading more"
text = re.sub(r'\s*#{1,6}\s+', ' ', text)
# Any remaining lone # characters
text = re.sub(r'(?<![\w])#{1,6}(?![\w])', '', text)
# Inline code: `x` -> x
text = re.sub(r'`+(.+?)`+', r'\1', text)
# Horizontal rules (--- *** ___)
text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE)
# Collapse extra spaces
text = re.sub(r' {2,}', ' ', text)
return text.strip()
_KNOWN_LABELS = {"WEATHER","FOX","BADGER","SQUIRREL","MOLE",
"CLASSIFIEDS","EXAMPLE","NOW WRITE","CORRECT OUTPUT"}
def _parse_newspaper_full(raw: str, day_number: int) -> dict:
lines = [l.strip() for l in raw.strip().splitlines() if l.strip()]
result = {
"headline": f"BREAKING: DAY {day_number} IN TINYWICK HOLLOW",
"article": "", "weather": "Overcast, with philosophical observations.",
"briefs": {c: "" for c in CREATURES},
}
body_start = 0
# Find headline: first ALL-CAPS line that isn't a template echo
for i, line in enumerate(lines):
cleaned = re.sub(r'[*_#"\'`]', "", line).strip().rstrip(":")
if not cleaned:
continue
if cleaned == cleaned.upper() and len(cleaned) > 8 and not _is_template_headline(cleaned):
result["headline"] = cleaned
body_start = i + 1
break
# Fallback: first line regardless (but still reject templates)
if i == 0 and not _is_template_headline(cleaned):
result["headline"] = cleaned.upper()
body_start = 1
break
article_lines = []
skip_rest = False
for line in lines[body_start:]:
u = line.upper().strip()
# Stop collecting article when we hit the example block
if "EXAMPLE OF CORRECT" in u or "NOW WRITE THE REAL" in u:
skip_rest = True
if skip_rest:
continue
# Route labelled lines to the right bucket
if u.startswith("WEATHER:"): result["weather"] = _strip_markdown(line.split(":",1)[1].strip())
elif u.startswith("FOX:"): result["briefs"]["fox"] = _strip_markdown(line.split(":",1)[1].strip())
elif u.startswith("BADGER:"): result["briefs"]["badger"] = _strip_markdown(line.split(":",1)[1].strip())
elif u.startswith("SQUIRREL:"): result["briefs"]["squirrel"] = _strip_markdown(line.split(":",1)[1].strip())
elif u.startswith("MOLE:"): result["briefs"]["mole"] = _strip_markdown(line.split(":",1)[1].strip())
elif u.startswith("CLASSIFIEDS:"): pass # skip — we supply our own
elif any(u.startswith(lbl+":") for lbl in _KNOWN_LABELS): pass # skip other labels
elif _is_template_headline(line): pass # skip echoed format instructions
else:
article_lines.append(line)
result["article"] = _strip_markdown(" ".join(article_lines).strip() or raw.strip())
# Fill in any empty briefs with a sensible default
defaults = {
"fox": "Reginald Fox was seen conducting suspicious business.",
"badger": "Beatrice Badger issued a firm statement.",
"squirrel": "Cornelius Squirrel invented something that nearly worked.",
"mole": "Something is happening underground.",
}
for c in CREATURES:
if result["briefs"].get(c):
result["briefs"][c] = _strip_markdown(result["briefs"][c])
else:
result["briefs"][c] = defaults[c]
return result
def _run_simulation_step(nudge_type, nudge_value, nudge_target):
day_number = database.get_next_day_number()
creatures = database.get_all_creatures()
cur_nudge = _nudge_ctx(nudge_type, nudge_value, nudge_target, day_number)
nudges = database.get_recent_nudges(3)
hist = ("Recent: " + "; ".join(f"Day {n['day_number']} {n['nudge_type']}: {n['nudge_value']}" for n in nudges)) if nudges else ""
hl = database.get_all_headlines()
past_hl = ("Past: " + " | ".join(f"Day {d}: {h[:35]}" for d,h in hl[:2])) if len(hl)>=2 else ""
ctx = " | ".join(filter(None, [cur_nudge, hist]))
event_records = []
for _ in range(3):
actor = random.choice(CREATURES)
target = random.choice([c for c in CREATURES if c != actor])
etype = random.choice(EVENT_TYPES)
rel = next((c for c in creatures if c["name"]==actor),{}).get("relationship_scores",{}).get(target,50)
prompts = {
"trade": f"Propose a trade with {target} (relationship {rel}/100).",
"gossip": f"Share absurd gossip about {target} (relationship {rel}/100).",
"feud": f"Describe your trivial feud with {target} (relationship {rel}/100).",
"invention": f"You invented something involving {target}. Describe it.",
"discovery": f"You discovered something surprising about {target}.",
"ceremony": f"You are organising a ceremony involving {target}.",
}
prompt = prompts[etype] + (f"\nContext: {ctx}" if ctx else "")
desc = call_agent(actor, prompt) or f"{actor.capitalize()} did a {etype} with {target}."
event_records.append({"actor":actor,"action":etype,"target":target,"description":desc})
database.save_event(day_number, actor, etype, target, desc)
delta = REL_DELTAS.get(etype, 0)
creatures = database.get_all_creatures()
for c in creatures:
if c["name"] == actor:
sc = c["relationship_scores"]
sc[target] = max(0, min(100, sc.get(target,50)+delta))
database.update_creature(actor, relationship_scores=sc)
if c["name"] == target:
sc = c["relationship_scores"]
sc[actor] = max(0, min(100, sc.get(actor,50)+delta//2))
database.update_creature(target, relationship_scores=sc)
creatures = database.get_all_creatures()
evsum = "\n".join(f"- {e['actor'].capitalize()} [{e['action']}] {e['target']}: {e['description']}" for e in event_records)
extra = ("\nContext: "+ctx if ctx else "") + ("\n"+past_hl if past_hl else "")
raw = _generate(NARRATOR_PROMPT, f"Today's events:\n{evsum}{extra}", 300)
if not raw:
raw = (f"CHAOS IN TINYWICK HOLLOW DAY {day_number}\n\nThings happened today.\n\n"
f"WEATHER: Unclear.\nFOX: {event_records[0]['description'][:50]}\n"
f"BADGER: {event_records[1]['description'][:50]}\n"
f"SQUIRREL: {event_records[2]['description'][:50]}\nMOLE: Underground.")
parsed = _parse_newspaper_full(raw, day_number)
classified = random.choice(CLASSIFIEDS)
full_text = "\n\n".join([parsed["headline"], parsed["article"],
f"WEATHER: {parsed['weather']}",
"\n".join(f"{c.upper()}: {t}" for c,t in parsed["briefs"].items()),
f"CLASSIFIEDS: {classified}"])
database.save_day(day_number, parsed["headline"], full_text)
return day_number, parsed, classified
# ═══════════════════════════════════════════════════════════════════
# 5 ▸ ZERОГPU WRAPPER
# ═══════════════════════════════════════════════════════════════════
@spaces.GPU(duration=60)
def advance_day(nudge_type=None, nudge_value=None, nudge_target=None):
_load_pipeline()
return _run_simulation_step(nudge_type, nudge_value, nudge_target)
# ═══════════════════════════════════════════════════════════════════
# 6 ▸ PIL IMAGE
# ═══════════════════════════════════════════════════════════════════
_SB=["/usr/share/fonts/truetype/liberation/LiberationSerif-Bold.ttf","/usr/share/fonts/truetype/dejavu/DejaVuSerif-Bold.ttf"]
_SR=["/usr/share/fonts/truetype/liberation/LiberationSerif-Regular.ttf","/usr/share/fonts/truetype/dejavu/DejaVuSerif.ttf"]
def _tf(p,s):
for f in p:
try: return ImageFont.truetype(f,s)
except: pass
return ImageFont.load_default()
def render_newspaper_image(parsed:dict, classified:str, day_number:int)->str:
W,H=960,720; PAPER=(245,232,200); INK=(20,8,2); BORDER=(70,40,8); SUBINK=(90,55,25); GREY=(130,100,70)
img=Image.new("RGB",(W,H),PAPER); d=ImageDraw.Draw(img)
fm=_tf(_SB,30); fh=_tf(_SB,20); fb=_tf(_SR,12); fs=_tf(_SR,10); fbo=_tf(_SB,12)
M=18; d.rectangle([M,M,W-M,H-M],outline=BORDER,width=3); d.rectangle([M+6,M+6,W-M-6,H-M-6],outline=BORDER,width=1)
y=M+14
mast="THE TINYWICK HOLLOW GAZETTE"; bb=d.textbbox((0,0),mast,font=fm); d.text(((W-(bb[2]-bb[0]))/2,y),mast,fill=INK,font=fm); y+=bb[3]-bb[1]+3
sub=f"Est. Day 1 ✦ Day {day_number} ✦ One Acorn ✦ Woodland Readers Only"; bb=d.textbbox((0,0),sub,font=fs); d.text(((W-(bb[2]-bb[0]))/2,y),sub,fill=SUBINK,font=fs); y+=bb[3]-bb[1]+4
wx=f"☁ {parsed.get('weather','Overcast.')}"; bb=d.textbbox((0,0),wx,font=fs); d.text(((W-(bb[2]-bb[0]))/2,y),wx,fill=GREY,font=fs); y+=bb[3]-bb[1]+4
d.line([M+10,y,W-M-10,y],fill=BORDER,width=2); d.line([M+10,y+4,W-M-10,y+4],fill=BORDER,width=1); y+=14
for ln in textwrap.wrap(parsed.get("headline",""),50):
bb=d.textbbox((0,0),ln,font=fh); d.text(((W-(bb[2]-bb[0]))/2,y),ln,fill=INK,font=fh); y+=bb[3]-bb[1]+2
y+=4; d.line([M+10,y,W-M-10,y],fill=BORDER,width=1); y+=10
PAD=M+12; CG=24; SW=220; mw=W-2*PAD-CG-SW; hw=(mw-CG)//2
c1=PAD; c2=PAD+hw+CG; sx=PAD+mw+CG; LH=15; MY=H-M-50; ay=y
al=textwrap.wrap(parsed.get("article",""),38); mid=max(1,len(al)//2); ly=ay
for ln in al[:mid]:
if ly+LH>MY: break
d.text((c1,ly),ln,fill=INK,font=fb); ly+=LH
d.line([c1+hw+CG//2,ay,c1+hw+CG//2,min(ly,MY)],fill=GREY,width=1); ry=ay
for ln in al[mid:]:
if ry+LH>MY: break
d.text((c2,ry),ln,fill=INK,font=fb); ry+=LH
d.line([sx-8,ay,sx-8,MY],fill=BORDER,width=1); sy=ay
d.text((sx,sy),"IN BRIEF",fill=INK,font=fbo); sy+=16; d.line([sx,sy,W-M-14,sy],fill=GREY,width=1); sy+=6
for cn in CREATURES:
em=CREATURE_EMOJI.get(cn,"?"); txt=parsed.get("briefs",{}).get(cn,"")
d.text((sx,sy),f"{em} {cn.upper()}",fill=INK,font=fbo); sy+=13
for bl in textwrap.wrap(txt,26):
if sy+12>MY: break
d.text((sx,sy),bl,fill=INK,font=fs); sy+=12
sy+=4
fy=H-M-38; d.line([M+10,fy,W-M-10,fy],fill=BORDER,width=1); fy+=4
d.text((M+14,fy),"CLASSIFIEDS",fill=INK,font=fbo); fy+=14
for cl in textwrap.wrap(classified,100):
if fy+12>H-M-8: break
d.text((M+14,fy),cl,fill=INK,font=fs); fy+=12
path=f"/tmp/tinywick_day_{day_number}.png"; img.save(path,"PNG"); return path
def export_agent_traces()->str:
trace={"meta":{"project":"Tiny Civilization","model":_model_id_used,"exported_at":datetime.now().isoformat()},
"agent_prompts":AGENT_PROMPTS,"narrator_prompt":NARRATOR_PROMPT,
"creature_state":database.get_all_creatures(),"events":[],"days":[]}
for dn,_ in reversed(database.get_all_headlines()):
d=database.get_day(dn)
if d: trace["days"].append(dict(d))
trace["events"].extend([{"day":dn,**e} for e in database.get_events_for_day(dn)])
path="/tmp/tiny_civ_traces.json"
with open(path,"w") as f: json.dump(trace,f,indent=2,default=str)
return path
# ═══════════════════════════════════════════════════════════════════
# 7 ▸ CSS — animations + newspaper polish
# ═══════════════════════════════════════════════════════════════════
NEWSPAPER_CSS = """
@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');
body, .gradio-container { background: #c4b090 !important; }
/* ── Animations ─────────────────────────────────────────────────── */
@keyframes paperDrop {
0% { transform: translateY(-30px) perspective(600px) rotateX(12deg) scale(.97); opacity:0; filter:brightness(1.6) blur(1px); }
55% { transform: translateY(4px) perspective(600px) rotateX(-2deg) scale(1.005); filter:brightness(1); }
100% { transform: translateY(0) perspective(600px) rotateX(0deg) scale(1); opacity:1; }
}
@keyframes inkFade {
0% { opacity:0; filter:blur(3px) sepia(.5); letter-spacing:-.5px; }
100% { opacity:1; filter:blur(0) sepia(0); letter-spacing:normal; }
}
@keyframes headlineType {
0% { clip-path: inset(0 100% 0 0); }
100% { clip-path: inset(0 0% 0 0); }
}
@keyframes sidebarSlide {
0% { transform:translateX(18px); opacity:0; }
100% { transform:translateX(0); opacity:1; }
}
@keyframes dotBlink {
0%,80%,100% { opacity:0; } 40% { opacity:1; }
}
@keyframes ornamentSpin {
0% { transform:rotate(0deg) scale(1); }
50% { transform:rotate(180deg) scale(1.2); }
100% { transform:rotate(360deg) scale(1); }
}
@keyframes squirrelRun {
0% { left:-70px; }
100% { left:calc(100vw + 70px); }
}
@keyframes loadingPulse {
0%,100% { opacity:.7; transform:scale(1); }
50% { opacity:1; transform:scale(1.05); }
}
/* ── Newspaper wrapper ── */
.paper-wrap {
background:#f6ead0;
background-image:repeating-linear-gradient(0deg,transparent,transparent 21px,rgba(150,110,60,.07) 22px);
border:3px solid #4a2a08; border-radius:1px;
padding:20px 26px 16px;
box-shadow:6px 6px 28px rgba(0,0,0,.38), inset 0 0 100px rgba(180,140,80,.15);
margin:6px 0; position:relative;
animation: paperDrop .55s cubic-bezier(.34,1.56,.64,1) both;
}
.paper-wrap::before {
content:""; display:block; border:1px solid #4a2a08;
position:absolute; inset:7px; pointer-events:none;
}
/* ── Masthead ── */
.paper-masthead {
font-family:'UnifrakturMaguntia','Playfair Display',Georgia,serif;
font-size:2.4em; text-align:center; color:#160800;
border-top:5px double #4a2a08; border-bottom:5px double #4a2a08;
padding:5px 0; margin-bottom:3px; letter-spacing:1px;
animation: inkFade .7s ease .1s both;
}
.paper-sub {
font-family:'Libre Baskerville',Georgia,serif; font-size:.72em;
color:#5a3615; text-align:center; font-style:italic; margin-bottom:2px;
animation: inkFade .6s ease .2s both;
}
.paper-weather {
font-family:'Libre Baskerville',Georgia,serif; font-size:.75em;
color:#7a5630; text-align:center; margin-bottom:6px; letter-spacing:.5px;
animation: inkFade .6s ease .25s both;
}
.paper-rule { border:none; border-top:2px solid #4a2a08; margin:3px 0 6px; }
.paper-rule-thin { border:none; border-top:1px solid #9a7040; margin:3px 0; }
/* ── Headline typewriter effect ── */
.paper-headline {
font-family:'Playfair Display',Georgia,serif; font-size:1.85em; font-weight:900;
text-align:center; text-transform:uppercase; color:#0c0400;
line-height:1.12; margin:6px 0 8px;
animation: headlineType 1s steps(35) .35s both;
}
/* ── Body ── */
.paper-body-row { display:flex; gap:0; }
.paper-article-cols {
flex:1; font-family:'Libre Baskerville',Georgia,serif; font-size:.88em;
color:#180a02; line-height:1.75; text-align:justify;
column-count:2; column-gap:24px; column-rule:1px solid #9a7040; padding:4px 0;
animation: inkFade .9s ease .85s both;
}
.paper-sidebar {
width:210px; min-width:190px; padding:0 0 0 16px;
border-left:2px solid #4a2a08; margin-left:16px;
animation: sidebarSlide .7s ease 1s both;
}
.sidebar-title {
font-family:'Playfair Display',Georgia,serif; font-weight:700; font-size:.85em;
text-transform:uppercase; letter-spacing:1px; color:#160800;
border-bottom:1px solid #9a7040; padding-bottom:3px; margin-bottom:6px;
}
.sidebar-item { margin-bottom:8px; }
.sidebar-creature-name { font-family:'Libre Baskerville',Georgia,serif; font-weight:700; font-size:.78em; color:#2a1008; }
.sidebar-creature-text { font-family:'Libre Baskerville',Georgia,serif; font-size:.74em; color:#3a1c08; line-height:1.45; font-style:italic; }
/* ── Classifieds + footer ── */
.paper-classifieds {
font-family:'Libre Baskerville',Georgia,serif; font-size:.75em; color:#5a3615;
border-top:1px solid #9a7040; margin-top:8px; padding-top:5px; font-style:italic;
animation: inkFade .8s ease 1.2s both;
}
.paper-classifieds span { font-weight:700; font-style:normal; color:#2a1008; }
.paper-daybadge {
font-family:'Libre Baskerville',Georgia,serif; font-size:.75em; color:#5a3615;
text-align:center; border-top:1px solid #9a7040; margin-top:8px; padding-top:5px;
animation: inkFade .6s ease 1.3s both;
}
/* ── TTS button ── */
.tts-bar {
display:flex; gap:8px; align-items:center; justify-content:center;
margin:8px 0 2px; animation: inkFade .6s ease 1.4s both;
}
.tts-btn {
background:#4a2a08; color:#f6ead0; border:none; border-radius:2px;
padding:5px 14px; font-family:'Libre Baskerville',Georgia,serif;
font-size:.8em; cursor:pointer; transition:background .2s;
letter-spacing:.3px;
}
.tts-btn:hover { background:#2a1008; }
.tts-stop {
background:#8a1a08; color:#f6ead0; border:none; border-radius:2px;
padding:5px 12px; font-family:'Libre Baskerville',Georgia,serif;
font-size:.8em; cursor:pointer; display:none;
}
/* ── Loading newspaper ── */
.paper-loading .loading-area {
text-align:center; padding:40px 20px;
animation: loadingPulse 2s ease infinite;
}
.loading-ornament {
font-size:2.5em; display:block; margin-bottom:12px;
animation: ornamentSpin 3s linear infinite;
}
.loading-msg {
font-family:'Playfair Display',Georgia,serif; font-size:1.1em;
color:#2a1008; margin-bottom:8px;
}
.loading-dots span {
display:inline-block; font-size:1.5em; color:#4a2a08;
animation: dotBlink 1.4s infinite;
}
.loading-dots span:nth-child(2) { animation-delay:.2s; }
.loading-dots span:nth-child(3) { animation-delay:.4s; }
/* ── Running squirrel ── */
.tinyc-squirrel {
position:fixed; bottom:28px; font-size:1.8em;
z-index:9999; pointer-events:none;
animation: squirrelRun 3.5s linear forwards;
}
/* ── Panels ── */
.status-strip {
background:#d6c4a0; border:1px solid #8a6030; border-radius:3px;
padding:5px 12px; font-family:'Libre Baskerville',Georgia,serif;
font-size:.82em; color:#2a1008; text-align:center; margin:4px 0;
}
.civ-stats {
background:#e8d4b0; border:1px solid #9a7040; border-radius:2px;
padding:8px 14px; font-family:'Libre Baskerville',Georgia,serif;
font-size:.78em; color:#2a1008; display:flex; gap:14px;
flex-wrap:wrap; justify-content:center; margin:6px 0;
}
.stat-item { text-align:center; }
.stat-label { font-size:.85em; color:#7a5030; display:block; }
.stat-value { font-weight:700; font-size:1.05em; color:#160800; }
.creature-grid { display:flex; gap:8px; flex-wrap:wrap; }
.creature-card {
background:#f8ecd8; border:1px solid #9a7040; border-radius:2px;
padding:10px 13px; font-family:'Libre Baskerville',Georgia,serif;
font-size:.8em; color:#180a02; flex:1; min-width:155px;
}
.creature-name { font-weight:700; font-size:.95em; color:#260e04; display:block; margin-bottom:3px; }
.creature-tier { font-style:italic; color:#7a5030; font-size:.85em; }
.section-title {
font-family:'Playfair Display',Georgia,serif; font-weight:700;
color:#160800; font-size:.95em; text-transform:uppercase; letter-spacing:1px;
text-align:center; border-bottom:1px solid #8a6030; padding-bottom:3px; margin:8px 0 8px;
}
.archive-area { background:#f0e4c6; border:1px solid #9a7040; border-radius:2px; padding:10px; margin-top:5px; min-height:50px; }
/* ── Konami modal ── */
#konami-backdrop { display:none; position:fixed; inset:0; background:rgba(0,0,0,.5); z-index:99998; }
#konami-modal {
display:none; position:fixed; z-index:99999; top:50%; left:50%;
transform:translate(-50%,-50%); width:min(660px,92vw); max-height:76vh;
overflow-y:auto; background:#f6ead0; border:3px solid #4a2a08;
box-shadow:10px 10px 40px rgba(0,0,0,.6); padding:22px 26px 18px;
font-family:'Libre Baskerville',Georgia,serif;
}
#konami-modal h2 { font-family:'Playfair Display',Georgia,serif; color:#160800; margin:0 0 10px; }
#konami-modal details { margin:6px 0; }
#konami-modal summary { cursor:pointer; font-weight:700; color:#4a2a08; }
#konami-modal pre { background:#e8d4b0; border:1px solid #9a7040; padding:8px; font-size:.76em; white-space:pre-wrap; border-radius:2px; margin:5px 0 0; }
#konami-close { position:absolute; top:8px; right:12px; cursor:pointer; font-size:1.3em; color:#4a2a08; background:none; border:none; }
"""
# ═══════════════════════════════════════════════════════════════════
# 8 ▸ JAVASCRIPT — TTS · sound · squirrel · Konami
# ═══════════════════════════════════════════════════════════════════
GLOBAL_JS = """
<script>
// ── Web Speech API TTS ────────────────────────────────────────────
window.tinyCivReadAloud = function() {
if (!window.speechSynthesis) { alert('Text-to-speech not supported in this browser.'); return; }
const paper = document.querySelector('.paper-wrap[data-speech]');
if (!paper) return;
window.speechSynthesis.cancel();
const utter = new SpeechSynthesisUtterance(paper.getAttribute('data-speech'));
utter.rate = 0.85; utter.pitch = 1.08;
const stopBtn = document.getElementById('tinyc-tts-stop');
if (stopBtn) stopBtn.style.display = 'inline';
utter.onend = utter.onerror = () => { if (stopBtn) stopBtn.style.display = 'none'; };
window.speechSynthesis.speak(utter);
};
window.tinyCivStopReading = function() {
window.speechSynthesis && window.speechSynthesis.cancel();
const s = document.getElementById('tinyc-tts-stop');
if (s) s.style.display = 'none';
};
// ── Web Audio paper/press sound ───────────────────────────────────
window.tinyCivPlaySound = function() {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
// Press click
const osc = ctx.createOscillator();
const g1 = ctx.createGain();
osc.connect(g1); g1.connect(ctx.destination);
osc.frequency.setValueAtTime(520, ctx.currentTime);
osc.frequency.exponentialRampToValueAtTime(180, ctx.currentTime + .12);
g1.gain.setValueAtTime(.18, ctx.currentTime);
g1.gain.exponentialRampToValueAtTime(.001, ctx.currentTime + .14);
osc.start(); osc.stop(ctx.currentTime + .14);
// Paper rustle
const sz = ctx.sampleRate * .18;
const buf = ctx.createBuffer(1, sz, ctx.sampleRate);
const dat = buf.getChannelData(0);
for (let i=0; i<sz; i++) dat[i] = (Math.random()*2-1)*Math.pow(1-i/sz,1.6)*.8;
const ns = ctx.createBufferSource();
const nf = ctx.createBiquadFilter();
nf.type = 'bandpass'; nf.frequency.value = 2200; nf.Q.value = .8;
const g2 = ctx.createGain();
g2.gain.setValueAtTime(.12, ctx.currentTime+.14);
g2.gain.exponentialRampToValueAtTime(.001, ctx.currentTime+.32);
ns.buffer = buf;
ns.connect(nf); nf.connect(g2); g2.connect(ctx.destination);
ns.start(ctx.currentTime + .14);
} catch(e) { /* audio unavailable */ }
};
// ── Running squirrel ──────────────────────────────────────────────
window.tinyCivSquirrel = function() {
if (document.querySelector('.tinyc-squirrel')) return;
const sq = document.createElement('div');
sq.className = 'tinyc-squirrel';
sq.textContent = Math.random() < .5 ? '🐿️' : '🦊';
sq.title = 'Breaking news delivery!';
document.body.appendChild(sq);
setTimeout(() => sq.remove(), 4000);
};
// ── Called whenever a new edition is printed ──────────────────────
window.tinyCivNewEdition = function() {
window.tinyCivPlaySound();
if (Math.random() < .35) setTimeout(window.tinyCivSquirrel, 800);
};
// ── Konami code ───────────────────────────────────────────────────
(function(){
const SEQ=['ArrowUp','ArrowUp','ArrowDown','ArrowDown',
'ArrowLeft','ArrowRight','ArrowLeft','ArrowRight','b','a'];
let i=0;
document.addEventListener('keydown',function(e){
if(e.key===SEQ[i]){ i++; if(i===SEQ.length){i=0; showKonami();} }
else { i=(e.key===SEQ[0])?1:0; }
});
window.showKonami = function(){
document.getElementById('konami-backdrop').style.display='block';
document.getElementById('konami-modal').style.display='block';
};
window.hideKonami = function(){
document.getElementById('konami-backdrop').style.display='none';
document.getElementById('konami-modal').style.display='none';
};
})();
</script>
"""
# ═══════════════════════════════════════════════════════════════════
# 9 ▸ HTML FORMATTERS
# ═══════════════════════════════════════════════════════════════════
def _e(t): return (t or "").replace("&","&amp;").replace("<","&lt;").replace(">","&gt;")
def _speech_text(parsed: dict) -> str:
hed = _strip_markdown(parsed.get("headline",""))
art = _strip_markdown(parsed.get("article",""))
wx = _strip_markdown(parsed.get("weather",""))
brf = parsed.get("briefs",{})
parts = [
f"Today's headline: {hed}.", art, f"Weather: {wx}.",
f"Fox: {_strip_markdown(brf.get('fox',''))}",
f"Badger: {_strip_markdown(brf.get('badger',''))}",
f"Squirrel: {_strip_markdown(brf.get('squirrel',''))}",
f"Mole: {_strip_markdown(brf.get('mole',''))}",
]
return " ".join(p for p in parts if p.strip()).replace('"',"'")
def _html_paper(parsed: dict, classified: str, day_num: int) -> str:
hed = _e(parsed.get("headline",""))
art = _e(parsed.get("article",""))
wx = _e(parsed.get("weather",""))
brf = parsed.get("briefs",{})
cl = _e(classified)
emojis = " ".join(f"{CREATURE_EMOJI[c]} {c.capitalize()}" for c in CREATURES)
speech = _speech_text(parsed).replace("'", "\\'")
sidebar_items = "".join(f"""
<div class="sidebar-item">
<div class="sidebar-creature-name">{CREATURE_EMOJI.get(c,'?')} {c.upper()}</div>
<div class="sidebar-creature-text">{_e(brf.get(c,''))}</div>
</div>""" for c in CREATURES)
return f"""
<div class="paper-wrap" data-speech="{speech}" data-day="{day_num}">
<div class="paper-masthead">The Tinywick Hollow Gazette</div>
<div class="paper-sub">Est. Day&nbsp;1 ✦ Day&nbsp;{day_num} ✦ One Acorn Per Copy ✦ Serving the Woodland</div>
<div class="paper-weather">☁&nbsp;{wx}</div>
<hr class="paper-rule">
<div class="paper-headline">{hed}</div>
<hr class="paper-rule-thin">
<div class="paper-body-row">
<div class="paper-article-cols">{art}</div>
<div class="paper-sidebar">
<div class="sidebar-title">In Brief</div>
{sidebar_items}
</div>
</div>
<div class="paper-classifieds"><span>CLASSIFIEDS:</span> {cl}</div>
<div class="tts-bar">
<button class="tts-btn" onclick="var b=this;if(window.speechSynthesis&&window.speechSynthesis.speaking){{window.speechSynthesis.cancel();b.textContent=String.fromCodePoint(0x1F50A)+' Read Aloud';return;}}if(!window.speechSynthesis)return;var p=document.querySelector('.paper-wrap[data-speech]');if(!p)return;var u=new SpeechSynthesisUtterance(p.getAttribute('data-speech'));u.rate=0.82;u.pitch=1.1;b.textContent=String.fromCodePoint(0x23F9)+' Stop';u.onend=u.onerror=function(){{b.textContent=String.fromCodePoint(0x1F50A)+' Read Aloud';}};window.speechSynthesis.speak(u);">🔊 Read Aloud</button>
</div>
<div class="paper-daybadge">— Day {day_num} —&nbsp;&nbsp; {emojis}</div>
</div>
<script>if(window.tinyCivNewEdition) tinyCivNewEdition();</script>
"""
def _html_loading() -> str:
msgs_js = json.dumps(LOADING_MSGS)
return f"""
<div class="paper-wrap paper-loading">
<div class="paper-masthead">The Tinywick Hollow Gazette</div>
<div class="paper-sub">Est. Day 1 ✦ One Acorn Per Copy</div>
<hr class="paper-rule">
<div class="loading-area">
<span class="loading-ornament">❋</span>
<div class="loading-msg" id="tinyc-loading-msg">The printing press is warming up...</div>
<div class="loading-dots"><span>·</span><span>·</span><span>·</span></div>
</div>
<hr class="paper-rule">
<div class="paper-daybadge">🦊 Fox &nbsp;🦡 Badger &nbsp;🐿️ Squirrel &nbsp;🐀 Mole</div>
</div>
<script>
(function(){{
const msgs = {msgs_js};
let i = Math.floor(Math.random() * msgs.length);
const el = document.getElementById('tinyc-loading-msg');
if (!el) return;
const iv = setInterval(() => {{ el.textContent = msgs[i++ % msgs.length]; }}, 1800);
const obs = new MutationObserver(() => {{
if (!document.getElementById('tinyc-loading-msg')) {{ clearInterval(iv); obs.disconnect(); }}
}});
obs.observe(document.body, {{childList:true, subtree:true}});
}})();
</script>
"""
def _html_placeholder() -> str:
d0 = database.get_day(0)
if d0:
p = _parse_newspaper_full(d0["full_newspaper_text"], 0)
return _html_paper(p, "NOTICE: Civilisation now in progress.", 0)
return '<div class="paper-wrap"><div class="paper-masthead">The Tinywick Hollow Gazette</div><div class="paper-headline">AWAITING FIRST LIGHT</div></div>'
def _rel_tier(score:int)->tuple:
for lo,hi,label,icon in REL_TIERS:
if lo<=score<=hi: return label,icon
return "Unknown","?"
def _html_creatures()->str:
cards=""
for c in database.get_all_creatures():
em=CREATURE_EMOJI.get(c["name"],"?")
inv=(", ".join(c["inventory"][:3])+("…" if len(c["inventory"])>3 else "")) or "nothing"
rels="".join(f'<span title="{_rel_tier(s)[0]} ({s})">{CREATURE_EMOJI.get(k,"?")} {_rel_tier(s)[1]}</span> '
for k,s in sorted(c["relationship_scores"].items()))
cards+=f"""<div class="creature-card">
<span class="creature-name">{em} {c['name'].capitalize()}</span>
<div class="creature-tier">{rels}</div>
<div style="font-size:.78em;margin-top:3px;color:#5a3615;"><em>Carries:</em> {_e(inv)}</div>
</div>"""
return f'<div class="creature-grid">{cards}</div>'
def _html_civ_stats()->str:
s=database.get_civ_stats()
bp=s["best_pair"]; wp=s["worst_pair"]
return f"""<div class="civ-stats">
<div class="stat-item"><span class="stat-label">Days</span><span class="stat-value">{s['total_days']}</span></div>
<div class="stat-item"><span class="stat-label">Events</span><span class="stat-value">{s['total_events']}</span></div>
<div class="stat-item"><span class="stat-label">Nudges</span><span class="stat-value">{s['total_nudges']}</span></div>
<div class="stat-item"><span class="stat-label">Strongest bond</span><span class="stat-value">{bp[0].capitalize()} &amp; {bp[1].capitalize()} ({bp[2]})</span></div>
<div class="stat-item"><span class="stat-label">Bitterest feud</span><span class="stat-value">{wp[0].capitalize()} vs {wp[1].capitalize()} ({wp[2]})</span></div>
<div class="stat-item"><span class="stat-label">Most popular</span><span class="stat-value">{s['dominant'].capitalize()}</span></div>
</div>"""
def _archive_choices():
hl=database.get_all_headlines()
return [(f"Day {dn}: {h[:44]}{'…' if len(h)>44 else ''}",dn) for dn,h in hl] or []
def _status(msg): return f'<div class="status-strip">{msg}</div>'
def _konami_html()->str:
import html as _h
details="".join(
f"<details><summary>{CREATURE_EMOJI.get(n,'')} <strong>{n.upper()}</strong></summary><pre>{_h.escape(p)}</pre></details>"
for n,p in AGENT_PROMPTS.items())
details+=f"<details><summary>📰 <strong>NARRATOR</strong></summary><pre>{_h.escape(NARRATOR_PROMPT)}</pre></details>"
return f"""
<div id="konami-backdrop" onclick="hideKonami()"></div>
<div id="konami-modal" role="dialog">
<button id="konami-close" onclick="hideKonami()">✕</button>
<h2>🔮 Secret Agent Briefing</h2>
<p>You found the Konami Code! Here are the raw system prompts:</p>
{details}
<p style="text-align:center;font-style:italic;color:#5a3615;font-size:.84em;margin-top:14px;">↑↑↓↓←→←→BA — only the woodland elite know this. 🎮</p>
</div>"""
# ═══════════════════════════════════════════════════════════════════
# 10 ▸ EVENT HANDLERS (generator-based for animated loading state)
# ═══════════════════════════════════════════════════════════════════
def _render_all(day_num,parsed,classified,status_msg):
return (_html_paper(parsed,classified,day_num), _html_creatures(), _html_civ_stats(),
gr.update(choices=_archive_choices(),value=None), _status(status_msg),
day_num, parsed, classified)
def _do_advance(nudge_type=None, nudge_value=None, nudge_target=None,
cur_day=0, cur_parsed=None, cur_cl=""):
# Step 1: immediately show loading newspaper
yield (_html_loading(), gr.update(), gr.update(), gr.update(),
_status("⏳ The printing press is warming up..."),
cur_day, cur_parsed or {}, cur_cl)
# Step 2: run model + return result
try:
dn, parsed, classified = advance_day(nudge_type, nudge_value, nudge_target)
model_tag = f" [{_model_id_used.split('/')[-1]}]" if _model_id_used!="none" else ""
yield _render_all(dn, parsed, classified, f"✓ Day {dn} published!{model_tag}")
except Exception:
tb = traceback.format_exc(); print(tb)
err = {"headline":"THE GAZETTE'S PRINTING PRESS HAS JAMMED",
"article":"Our correspondents report a technical malfunction. The editor is inconsolable. Beatrice Badger suspects sabotage. Reginald Fox denies everything.",
"weather":"Stormy, with a chance of errors.","briefs":{c:"Unavailable." for c in CREATURES}}
yield _render_all(cur_day, err, "LOST: One simulation. — The Editor", "✗ Error — printing press jammed.")
def handle_advance(d,p,c): yield from _do_advance(None,None,None,d,p,c)
def handle_rumour(cr,rt,d,p,c): yield from _do_advance("rumour",rt,cr,d,p,c)
def handle_donation(obj,d,p,c): yield from _do_advance("donation",obj,None,d,p,c)
def handle_law(law,d,p,c): yield from _do_advance("law",law,None,d,p,c)
def handle_archive_view(day_num):
if day_num is None:
return '<div class="archive-area"><em>Select a day above.</em></div>'
day=database.get_day(int(day_num))
if not day: return '<div class="archive-area"><em>Not found.</em></div>'
txt=day["full_newspaper_text"]; m=re.search(r"CLASSIFIEDS:\s*(.+)",txt)
cl=m.group(1) if m else random.choice(CLASSIFIEDS)
return f'<div class="archive-area">{_html_paper(_parse_newspaper_full(txt,day_num),cl,day_num)}</div>'
def handle_share(dn,p,c):
if not p or not p.get("headline"): return gr.update(visible=False)
try:
path=render_newspaper_image(p,c or "",dn or 0)
return gr.update(visible=True,value=path)
except Exception: return gr.update(visible=False)
def handle_export():
try: return gr.update(visible=True,value=export_agent_traces())
except Exception: return gr.update(visible=False)
# ═══════════════════════════════════════════════════════════════════
# 11 ▸ GRADIO BLOCKS (Gradio 6.17.3)
# ═══════════════════════════════════════════════════════════════════
database.init_db()
_lat=database.get_latest_day()
if _lat:
_INIT_DAY=_lat["day_number"]
_INIT_P=_parse_newspaper_full(_lat["full_newspaper_text"],_INIT_DAY)
_m=re.search(r"CLASSIFIEDS:\s*(.+)",_lat["full_newspaper_text"])
_INIT_CL=_m.group(1) if _m else random.choice(CLASSIFIEDS)
else:
_INIT_DAY=0; _INIT_P={}; _INIT_CL=""
# Gradio 6: css goes to launch(), not Blocks()
_GR_MAJOR=int(gr.__version__.split(".")[0])
_BKW={} if _GR_MAJOR>=6 else {"css":NEWSPAPER_CSS}
_LKW={"css":NEWSPAPER_CSS} if _GR_MAJOR>=6 else {}
with gr.Blocks(title="Tiny Civilization — The Tinywick Hollow Gazette",**_BKW) as demo:
gr.HTML(_konami_html())
gr.HTML(GLOBAL_JS)
gr.HTML("""
<div style="text-align:center;padding:8px 0 2px;">
<h1 style="font-family:'Playfair Display',Georgia,serif;color:#160800;font-size:1.9em;margin:0 0 2px;">
🦊 Tiny Civilization 🐀</h1>
<p style="font-family:Georgia,serif;color:#5a3615;font-style:italic;margin:0;font-size:.87em;">
A persistent woodland civilisation. One day. One acorn. One absurd headline at a time.
&nbsp;|&nbsp;<kbd title="Konami Code">↑↑↓↓←→←→BA</kbd> for secrets.</p>
</div>""")
day_state=gr.State(_INIT_DAY)
par_state=gr.State(_INIT_P)
cls_state=gr.State(_INIT_CL)
status_html=gr.HTML(value=_status(
f"Day {_INIT_DAY} in the archive — next: Day {_INIT_DAY+1}." if _INIT_DAY>=0
else "No days yet. Press Advance Day to begin."))
civ_html=gr.HTML(value=_html_civ_stats())
with gr.Row(equal_height=False):
with gr.Column(scale=3):
newspaper_display=gr.HTML(
value=(_html_paper(_INIT_P,_INIT_CL,_INIT_DAY) if _INIT_P else _html_placeholder()))
with gr.Accordion("📜 Archive — Past Editions",open=False):
archive_dd=gr.Dropdown(choices=_archive_choices(),value=None,
label="Select a past day",container=False)
archive_html=gr.HTML(value='<div class="archive-area"><em>Select a day above.</em></div>')
with gr.Column(scale=1,min_width=260):
gr.HTML('<div class="section-title">📰 Editorial Desk</div>')
advance_btn=gr.Button("📅 Advance Day (no nudge)",variant="primary",size="lg")
gr.HTML('<hr style="border-color:#8a6030;margin:8px 0;">')
gr.HTML('<div class="section-title">✉ Nudge the Story</div>')
gr.HTML('<p style="font-size:.78em;color:#5a3615;text-align:center;font-style:italic;margin:0 0 6px;">Each nudge advances one day.</p>')
with gr.Accordion("🗣️ Spread a Rumour",open=False):
rumour_creature=gr.Dropdown(choices=CREATURES,value=CREATURES[0],label="About which creature?")
rumour_type_dd=gr.Dropdown(choices=RUMOUR_TYPES,value=RUMOUR_TYPES[0],label="What rumour?")
rumour_btn=gr.Button("📢 Spread It",variant="secondary")
with gr.Accordion("🎁 Donate a Weird Object",open=False):
donation_dd=gr.Dropdown(choices=WEIRD_OBJECTS,value=WEIRD_OBJECTS[0],label="Which object?")
donation_btn=gr.Button("🎁 Donate It",variant="secondary")
with gr.Accordion("⚖️ Propose a New Law",open=False):
law_dd=gr.Dropdown(choices=LAWS,value=LAWS[0],label="Which law?")
law_btn=gr.Button("⚖️ Propose It",variant="secondary")
gr.HTML('<hr style="border-color:#8a6030;margin:8px 0;">')
share_btn=gr.Button("🖼️ Share as Image",variant="secondary")
img_output=gr.Image(label="Front Page PNG",visible=False,type="filepath")
export_btn=gr.Button("📡 Export Agent Traces (JSON)",variant="secondary")
trace_output=gr.File(label="traces.json",visible=False)
gr.HTML('<p style="font-size:.70em;color:#6a4818;text-align:center;margin-top:8px;font-style:italic;">'
'Qwen2.5-1.5B ≤4B 🐜 · Local ZeroGPU 🔌 · Custom UI 🎨</p>')
gr.HTML('<div class="section-title" style="margin-top:12px;">Woodland Residents</div>')
creature_display=gr.HTML(value=_html_creatures())
_OUT=[newspaper_display,creature_display,civ_html,archive_dd,status_html,day_state,par_state,cls_state]
_ST=[day_state,par_state,cls_state]
advance_btn.click(fn=handle_advance, inputs=_ST, outputs=_OUT,api_name=False)
rumour_btn.click( fn=handle_rumour, inputs=[rumour_creature,rumour_type_dd]+_ST, outputs=_OUT,api_name=False)
donation_btn.click(fn=handle_donation, inputs=[donation_dd]+_ST, outputs=_OUT,api_name=False)
law_btn.click( fn=handle_law, inputs=[law_dd]+_ST, outputs=_OUT,api_name=False)
archive_dd.change(fn=handle_archive_view, inputs=[archive_dd], outputs=[archive_html],api_name=False)
share_btn.click( fn=handle_share, inputs=_ST, outputs=[img_output],api_name=False)
export_btn.click( fn=handle_export, inputs=[], outputs=[trace_output],api_name=False)
# ═══════════════════════════════════════════════════════════════════
# 12 ▸ ENTRY POINT
# ═══════════════════════════════════════════════════════════════════
if __name__ == "__main__":
demo.launch(server_name="0.0.0.0", server_port=7860, **_LKW)