| """ |
| 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 |
| """ |
| |
| |
| |
| 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 |
|
|
| import torch |
| from transformers import pipeline as hf_pipeline |
|
|
| |
| |
| |
| 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.", |
| ] |
|
|
| |
| |
| |
| 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." |
| ) |
|
|
| |
| |
| |
| _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." |
|
|
| |
| |
| |
| 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 "" |
|
|
| |
| _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) |
|
|
| |
| def _strip_markdown(text: str) -> str: |
| if not text: return text |
| |
| text = re.sub(r'\*{1,3}(.+?)\*{1,3}', r'\1', text, flags=re.DOTALL) |
| |
| text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE) |
| |
| text = re.sub(r'\s*#{1,6}\s+', ' ', text) |
| |
| text = re.sub(r'(?<![\w])#{1,6}(?![\w])', '', text) |
| |
| text = re.sub(r'`+(.+?)`+', r'\1', text) |
| |
| text = re.sub(r'^[-*_]{3,}\s*$', '', text, flags=re.MULTILINE) |
| |
| 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 |
|
|
| |
| 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 |
| |
| 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() |
| |
| if "EXAMPLE OF CORRECT" in u or "NOW WRITE THE REAL" in u: |
| skip_rest = True |
| if skip_rest: |
| continue |
| |
| 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 |
| elif any(u.startswith(lbl+":") for lbl in _KNOWN_LABELS): pass |
| elif _is_template_headline(line): pass |
| else: |
| article_lines.append(line) |
|
|
| result["article"] = _strip_markdown(" ".join(article_lines).strip() or raw.strip()) |
|
|
| |
| 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 |
|
|
| |
| |
| |
| @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) |
|
|
| |
| |
| |
| _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 |
|
|
| |
| |
| |
| 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; } |
| """ |
|
|
| |
| |
| |
| 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> |
| """ |
|
|
| |
| |
| |
| def _e(t): return (t or "").replace("&","&").replace("<","<").replace(">",">") |
|
|
| 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 1 ✦ Day {day_num} ✦ One Acorn Per Copy ✦ Serving the Woodland</div> |
| <div class="paper-weather">☁ {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} — {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 🦡 Badger 🐿️ Squirrel 🐀 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()} & {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>""" |
|
|
| |
| |
| |
| 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=""): |
| |
| yield (_html_loading(), gr.update(), gr.update(), gr.update(), |
| _status("⏳ The printing press is warming up..."), |
| cur_day, cur_parsed or {}, cur_cl) |
| |
| 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) |
|
|
| |
| |
| |
| 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="" |
|
|
| |
| _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. |
| | <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) |
|
|
| |
| |
| |
| if __name__ == "__main__": |
| demo.launch(server_name="0.0.0.0", server_port=7860, **_LKW) |