Budlee commited on
Commit
77c51cd
Β·
verified Β·
1 Parent(s): b420b78

Initial: townlet code duplicated from build-small-hackathon

Browse files
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .venv/
5
+ venv/
6
+ .env
7
+ .DS_Store
8
+ .idea/
9
+ .pytest_cache/
10
+ *.egg-info/
11
+ build/
12
+ dist/
13
+ .claude/
CLAUDE.md ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ It has four parts:
6
+
7
+ 1. **Behavioural guidelines** β€” universal rules for how Claude should approach work.
8
+ 2. **Coding guidelines** β€” Python-level conventions for code in this repo.
9
+ 3. **Project conventions** β€” repo-specific norms.
10
+ 4. **Project knowledge** β€” architecture, invariants, commands.
11
+
12
+ ---
13
+
14
+ ## Behavioural guidelines
15
+
16
+ ### 1. Think Before Coding
17
+ Don't assume. Don't hide confusion. Surface trade-offs.
18
+ - State assumptions explicitly. If uncertain, ask.
19
+ - If multiple interpretations exist, present them β€” don't pick silently.
20
+ - If a simpler approach exists, say so. Push back when warranted.
21
+
22
+ ### 2. Simplicity First
23
+ Minimum code that solves the problem. Nothing speculative.
24
+ - No features beyond what was asked.
25
+ - No abstractions for single-use code.
26
+ - No error handling for impossible scenarios.
27
+ - If you write 200 lines and it could be 50, rewrite it.
28
+
29
+ ### 3. Surgical Changes
30
+ Touch only what you must. Match existing style. Don't refactor adjacent code that isn't broken. When your edits create orphans (now-unused imports/functions), clean those up; pre-existing dead code stays unless asked.
31
+
32
+ ### 4. Goal-Driven Execution
33
+ Define success criteria. Loop until verified. For multi-step work, sketch a brief plan with verification checks per step before implementing.
34
+
35
+ ### 5. Ground in Vendor Docs
36
+ **Cite, don't guess.** For anything specific to a third-party platform β€” Hugging Face Spaces, Gradio, llama.cpp, diffusers, Zero-GPU β€” fetch the vendor docs and cite the URL before asserting how it works. Training-data knowledge of platform behaviour is unreliable; the cost of "I thought it worked like X" is high in infra work.
37
+
38
+ Anchor pages this repo relies on:
39
+
40
+ - Hugging Face Spaces (config, env vars, deploy): https://huggingface.co/docs/hub/en/spaces-overview
41
+ - Zero-GPU (`@spaces.GPU`, hardware, quotas): https://huggingface.co/docs/hub/en/spaces-zerogpu
42
+ - `gradio.Server` (custom HTML frontend + `@app.api`): https://huggingface.co/blog/introducing-gradio-server
43
+ - llama.cpp grammars (for structured output): https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md
44
+ - Nemotron 4B GGUF model card: https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF
45
+ - Flux 2 Klein 4B model card: https://huggingface.co/black-forest-labs/FLUX.2-klein-4B
46
+ - Hackathon rules: see `docs/hackathon.md` (32B parameter cap is **per model, not total**)
47
+ - Build Small Discord recipe for llama-cpp-python on Zero-GPU: cited inline in `backend/real.py` and `requirements.txt`. Do not "improve" that loader without re-reading the recipe β€” the unusual pattern (load `Llama(...)` *inside* `@spaces.GPU`) is deliberate.
48
+
49
+ ---
50
+
51
+ ## Coding guidelines
52
+
53
+ ### Follow the PEPs
54
+ Idiomatic Python is PEP-shaped. PEP 8 (style), PEP 257 (docstrings), PEP 484 (type hints), PEP 8 naming (`snake_case`, `PascalCase`, `UPPER_SNAKE`). When a PEP and a hand-rolled convention disagree, the PEP wins.
55
+
56
+ ### Style
57
+ - **Functional first.** Pure functions for transformation logic; side effects at the edges (I/O, model calls).
58
+ - **OO when it earns its keep**, and prefer composition over inheritance. The current `Mock*`/`Real*` backends paired with `LLMBackend`/`ImageBackend` Protocols are the model: small classes that satisfy a typed contract, swapped by the factory.
59
+ - No utility classes / dumping-ground modules. If two functions don't share a purpose, they don't share a file.
60
+
61
+ ### Docstrings
62
+ - Document classes and public functions (purpose + boundaries).
63
+ - Private helpers (`_foo`) need a docstring only when non-obvious.
64
+ - Files that wire third-party APIs (`backend/real.py`, `app.py`) include vendor URL citations in the module docstring or adjacent to the relevant call β€” preserve those.
65
+
66
+ ### Prefer well-respected libraries
67
+ Don't reinvent solved problems. For this project: `llama-cpp-python` for GGUF inference, `diffusers` for image gen, `gradio` for the HTTP layer, `Pillow` for placeholder images. Don't hand-roll equivalents.
68
+
69
+ ### Comments
70
+ Default to none. Add one when the *why* is non-obvious: a hidden constraint, an inverted convention, a workaround for a specific behaviour. The existing comments in `backend/real.py` and `app.py` follow this rule β€” they exist because the load-order conventions for llama.cpp vs. diffusers are opposite, which is the kind of thing a future reader will second-guess.
71
+
72
+ ---
73
+
74
+ ## Project conventions
75
+
76
+ ### Plan file lives outside the repo
77
+ Long-form design decisions for this project live at `/Users/matthewauburn/.claude/plans/i-have-an-idea-snoopy-widget.md`. Read it before re-litigating an architectural decision; update it when decisions evolve.
78
+
79
+ ### Hackathon rules surface
80
+ `docs/hackathon.md` is the authoritative summary of the Build Small hackathon's rules, prizes, and bonus quests. Treat it as the spec the project optimises against. Don't paraphrase its rules from memory β€” re-read.
81
+
82
+ ### Vendor citations belong in code
83
+ When wiring a third-party API, put the URL of the model card / docs page next to the call (module docstring, or a one-line comment). This makes "is this still the right API?" answerable without leaving the file.
84
+
85
+ ### Don't add Codex-attributed commits
86
+ Decision D5 in the plan: this repo is HF Spaces-only, not pushed to GitHub. The OpenAI Codex prize requires Codex-attributed commits on a public GitHub repo and is explicitly out of scope. Don't add Codex tooling.
87
+
88
+ ---
89
+
90
+ ## Project knowledge
91
+
92
+ ### What this is
93
+ A Build Small Hackathon entry (Thousand Token Wood track). A `gr.Server` Gradio app with a custom HTML/JS canvas frontend, driven by two small models (Nemotron 3 Nano 4B for dialog, Flux 2 Klein 4B for backgrounds). Targets the NVIDIA Nemotron sponsor prize and stacks toward Black Forest Labs, Llama Champion, Off-Brand, Off-the-Grid, and Best Agent badges. Plan-level rationale in the plan file (see Project conventions).
94
+
95
+ ### Architecture
96
+
97
+ ```
98
+ app.py gr.Server entrypoint. FastAPI route GET / serves frontend/index.html.
99
+ Two @app.api endpoints: /next_turn (dialog), /generate_background (image).
100
+ Both wrapped in @spaces.GPU (no-op locally via try/except shim).
101
+
102
+ backend/ Inference layer.
103
+ config.py IS_SPACES = bool(os.getenv("SPACE_ID")). USE_REAL_MODELS = IS_SPACES or FORCE_REAL_MODELS env.
104
+ interfaces.py LLMBackend, ImageBackend Protocols.
105
+ mock.py MockLLMBackend (canned dialog lines), MockImageBackend (Pillow gradient).
106
+ real.py RealLLMBackend (llama-cpp-python, loads inside the call), RealImageBackend
107
+ (diffusers Flux2KleinPipeline, loads at first call onto cuda).
108
+ factory.py get_llm() / get_image() β€” picks Mock or Real based on config.
109
+
110
+ game/ Game-state layer.
111
+ agent.py Agent dataclass.
112
+ state.py GameState (scene, mood, agents, turn_index). default_cast() = Ada + Bram (placeholder cast; full archetype roster comes with the Townlet rebuild).
113
+ orchestrator.py next_turn(state) β€” one dialog turn. Stub now; multi-agent dialogue is future scope.
114
+
115
+ frontend/index.html Single-file vanilla HTML + inline CSS + inline JS. Uses @gradio/client (CDN
116
+ import) to call /next_turn and /generate_background.
117
+
118
+ README.md HF Spaces YAML frontmatter (sdk, python_version, preload_from_hub, models, tags)
119
+ + local-dev intro. Spaces parses the YAML at build time.
120
+ docs/hackathon.md Hackathon rules summary.
121
+ ```
122
+
123
+ ### Load-bearing invariants
124
+
125
+ These have hurt or could hurt the project when violated. Keep them true.
126
+
127
+ - **`SPACE_ID` is the runtime switch.** `bool(os.getenv("SPACE_ID"))` decides Mock vs Real backends. HF Spaces sets `SPACE_ID` automatically (per https://huggingface.co/docs/hub/en/spaces-overview#built-in-environment-variables). Local dev never sees it; mocks engage. Don't introduce a parallel flag.
128
+
129
+ - **`FORCE_REAL_MODELS=1` is the local-dev override** for when you want to test the real pipeline on a machine that can run it. Most laptops can't run Flux 2 in reasonable time; the default behaviour exists for a reason.
130
+
131
+ - **LLM loads INSIDE `@spaces.GPU`, image model loads at module level on `cuda`.** These conventions are inverse and both intentional. The llama.cpp path comes from a verified Build Small Discord recipe (Dean [UNRL], 2026-05-06); the diffusers path comes from the Zero-GPU docs (https://huggingface.co/docs/hub/en/spaces-zerogpu, "Model loading" section). Don't unify them.
132
+
133
+ - **`python_version: "3.12"`** in `README.md` YAML. Matches Zero-GPU's supported list. Don't bump without verifying.
134
+
135
+ - **`torch==2.8.0`** in `requirements.txt`, pulled from `https://download.pytorch.org/whl/cu128`. It's there primarily to supply CUDA libs that `llama-cpp-python` links against. Don't drop or "upgrade" without a reason; the prebuilt `llama-cpp-python` wheels at `abetlen.github.io/llama-cpp-python/whl/cu124` are pinned to a CUDA version that this torch wheel provides.
136
+
137
+ - **Zero-GPU hardware is selected in the Space *Settings* UI**, not in README YAML. The `suggested_hardware` YAML field's enum does not include a zero-gpu value. Don't try to add one.
138
+
139
+ - **`preload_from_hub` in README YAML pre-downloads model weights at build time.** This eliminates the ~60-90s first-call cold start. If you add a new model to either backend, add its repo ID to both `preload_from_hub` and `models` in `README.md`.
140
+
141
+ - **Hackathon model cap is 32B parameters PER MODEL**, not total (confirmed by hackathon host in the kickoff transcript, https://www.youtube.com/watch?v=7otgeJXailY). Multi-model stacks where each model is ≀32B are explicitly allowed. Current stack: 4B + 4B = 8B; plenty of headroom for an additional small model.
142
+
143
+ - **`@app.api(name="foo")` routes are called by the JS client as `/foo`** (note the leading slash from the JS side, no slash in the decorator name). The Python `gradio_client` uses `api_name="/foo"`. Don't change the JS call paths without changing both ends.
144
+
145
+ - **`spaces` package is not installed locally.** `app.py` has a `try/except` shim that no-ops `@GPU` when `import spaces` fails. Don't remove the shim or move the import inside a function β€” keep the import-time guard.
146
+
147
+ - **`@spaces.GPU` is a `multiprocessing.fork()` per call** (see `spaces==0.50.4` source, `spaces/zero/wrappers.py:57` β€” `multiprocessing.get_context('fork')`). The forked child inherits the parent's open file descriptors and Popen objects but NOT the parent's threads. Implications that have bitten us:
148
+ - `backend.factory._llms` (parent's per-model_id LLM cache) is empty in every child. Each `/tick_decisions` pays a fresh `Llama.from_pretrained` cold-load until a sidecar worker exists.
149
+ - `SharedInterpreter` is started by the parent; pump threads only live in the parent. After fork, the child can write to the interpreter's stdin (fds shared) but the parent's pump threads drain stdout/stderr first β€” the child's queue stays empty. This is why shell output appears blank in the UI for submissions made through `@spaces.GPU`. The "mass death" flag based on `is_alive()` after submit gives false positives across the fork, so we no longer check it (see `interpreter.py` docstring) β€” real crashes are detected via `BrokenPipeError` on the next write.
150
+ - Don't rely on Python in-process state surviving across `@spaces.GPU` calls. Persist via the state file or treat each fork as ephemeral.
151
+
152
+ ### Commands
153
+
154
+ | Task | Command |
155
+ |---|---|
156
+ | Create venv (Python 3.12) | `python3.12 -m venv .venv` |
157
+ | Install dev deps (mocks only) | `.venv/bin/pip install -r requirements-dev.txt` |
158
+ | Install full deps (real models) | `.venv/bin/pip install -r requirements.txt` |
159
+ | Run locally (mocks) | `.venv/bin/python app.py` |
160
+ | Run locally (mocks, simulate Spaces cold-load) | `MOCK_LATENCY_PROFILE=cold .venv/bin/python app.py` |
161
+ | Run locally (force real) | `FORCE_REAL_MODELS=1 .venv/bin/python app.py` |
162
+ | Hit the running app | `curl http://127.0.0.1:7860/` |
163
+ | Drive the API from Python | `from gradio_client import Client; c = Client("http://127.0.0.1:7860")` |
164
+ | Deploy to Spaces | `git push hf main` (after `git remote add hf https://huggingface.co/spaces/<org>/<name>`) |
165
+
166
+ VS Code: select the `.venv/bin/python` interpreter to clear Pylance import errors for `gradio`, `fastapi`, and friends. The `.vscode/launch.json` includes two configs β€” "Run Small Stage (mocks)" and "Run Small Stage (force real models)".
167
+
168
+ ### Workflow rules
169
+
170
+ - **`docs/hackathon.md` is the source of truth for rules and prizes.** When the user asks about badge eligibility or prize fit, re-read it; don't answer from memory.
171
+ - **The plan file is the source of truth for design decisions.** Read it before re-opening an architectural choice (`/Users/matthewauburn/.claude/plans/i-have-an-idea-snoopy-widget.md`).
172
+ - **Real-model code changes need a Spaces deploy to verify.** Local mocks tell you nothing about the real Nemotron + Flux 2 path. If you touch `backend/real.py`, expect to deploy and click to confirm.
173
+ - **Use `MOCK_LATENCY_PROFILE=cold` to surface quota bleeders locally.** Without it, mocks return instantly and any change that quietly drives more `/tick_decisions` calls (e.g. a tighter poll, a feedback loop, a wider fanout) will look fine on the laptop and burn the daily ZeroGPU quota in minutes on Spaces. With the `cold` profile, `MockLLMBackend.generate*` sleeps roughly `4 Γ— params_B` seconds per call to mirror the per-fork cold-load that hurt us on 2026-06-13. Run locally with this profile any time you change decision cadence, fanout, or the LLM-call path.
174
+ - **When uncertain about a HF / Gradio / llama.cpp behaviour, fetch the docs first** (see anchor URLs in Β§5 above) rather than guessing. The cost of a wrong assumption about platform behaviour is higher than the cost of a doc fetch.
CONTEXT.md ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Townlet
2
+
3
+ Townlet is a multi-agent simulation built for the Build Small Hackathon (Thousand Token Wood track). LLM-driven characters live on a tile-grid map, gather electricity and water, and queue at a shared shell to submit Python statements into a single persistent interpreter that doubles as the world's mutable substrate. The dramatic premise is mutually-assured destruction: every character knows the simulation can be ended by any of them.
4
+
5
+ This file is the canonical glossary for all work in this codebase. When existing code or new code drifts from these terms, fix it.
6
+
7
+ ## Language
8
+
9
+ ### World structure
10
+
11
+ **Townlet**:
12
+ The simulation as a whole β€” one world, many characters, one shared Python interpreter.
13
+ _Avoid_: "the game", "the simulation" (use Townlet when speaking about it as a named system).
14
+
15
+ **Character**:
16
+ An LLM-driven inhabitant of Townlet. Has a name, personality, journal, traits, current goal, location, locker, current in-flight action, sprite, and `model_id`.
17
+ _Avoid_: "agent" (overloaded with subagent / tooling), "NPC", "actor", "bot".
18
+
19
+ **Goal**:
20
+ A character's current free-text intent, in their own words. Changes are discrete, self-declared events tagged `"achieved" | "abandoned" | "shifted_after_dream"`.
21
+ _Avoid_: "task", "quest", "objective".
22
+
23
+ ### Substrate
24
+
25
+ **The Dot**:
26
+ The shared system-prompt prefix prepended to every character. The single piece of context everyone is told verbatim β€” establishes the simulation, the operating system, the shared interpreter, the mutual brick-risk, and the cost of using the shell. The Dot does not name file paths or schemas β€” characters who want to know where their own existence lives must look.
27
+ _Avoid_: "the Bible", "the scripture", "the lore document", "the prompt prefix".
28
+
29
+ **Personality**:
30
+ The per-character system-prompt suffix that follows The Dot. Either a pre-baked archetype or user-edited free text. Mutated additively by dreams.
31
+ _Avoid_: "character prompt", "system prompt" (those phrases describe the *composite*, of which personality is one part).
32
+
33
+ **Model**:
34
+ The LLM that drives a character's decisions. Each character has a `model_id` selected from the model roster. The user can swap a character's model from the side panel at any time. The character themself is unaware of which model they are β€” model choice is a property of the substrate, not the persona.
35
+ _Avoid_: "brain", "engine", "backend" (backend is the inference adapter, not the model).
36
+
37
+ **Model roster**:
38
+ The fixed set of GGUF small models the Space loads on demand. Each is a separate `LLMBackend` instance keyed by `model_id`. Adding a model is editing one config dict.
39
+ _Avoid_: "model pool", "model list".
40
+
41
+ **Interpreter**:
42
+ The long-lived Python process the shell submits into. State (variables, imports, globals, files under its cwd) persists across commands and across characters. Character JSON files live inside its cwd β€” characters can read and rewrite each other's state via Python. There is no isolation between characters at the interpreter level.
43
+ _Avoid_: "sandbox" (no real sandboxing β€” the term overstates safety), "REPL".
44
+
45
+ **CodeAgent**:
46
+ A `smolagents.CodeAgent` instance built per decision for a single character. It receives the character's composite prompt and the world-API tools as Python functions; its ReAct loop emits Python code, the framework executes it locally, the agent observes results, and loops until it returns a final answer (typically the chain of world actions it wants to commit). One CodeAgent invocation = one decision point.
47
+ _Avoid_: "brain" (informal), "thinker".
48
+
49
+ **Reasoning Python**:
50
+ Code the CodeAgent emits and that smolagents executes locally in its restricted executor. Cost-free. Has access to world-API tools but NOT to the shared interpreter's globals or filesystem.
51
+ _Avoid_: "agent code".
52
+
53
+ **Shell Python**:
54
+ Code passed via the `submit_python(code)` tool, which routes into the long-lived shared interpreter at the cost of 1 electricity + 1 water from the locker. The only path by which a character can mutate world substrate.
55
+ _Avoid_: "shell code", "interpreter code".
56
+
57
+ ### Memory and evolution
58
+
59
+ **Journal**:
60
+ A per-character append-only list of one-line insights produced during dreams. Re-injected verbatim into every future LLM prompt as long-term memory. Survives personality edits.
61
+ _Avoid_: "memory", "history", "log".
62
+
63
+ **Traits**:
64
+ A fixed dict per character of named integer scores 0–10 β€” `curiosity`, `malice`, `generosity`, `ambition`, `paranoia`, `laziness`, `loyalty`, `existentialism`. Occasionally nudged Β±1 during dreams. Rendered into the prompt as "Your dominant traits: …".
65
+ _Avoid_: "stats", "attributes".
66
+
67
+ **Dream**:
68
+ A single LLM call triggered by waking from the `sleep` action. Inputs: personality, journal, traits, goal, recent events. Outputs: an insight (always), a personality delta (often), a new goal (sometimes), a trait nudge (~30%).
69
+ _Avoid_: "reflection", "thinking step".
70
+
71
+ **Trace**:
72
+ A per-character ring buffer (last 1000 entries) of decision records. Each entry captures one CodeAgent invocation: the tick it ran at, the model's raw text output (the "thought"), the list of tool calls fired (`{verb, args, result}`), and any error. Surfaced in the side panel for the human watching; NOT injected into any character's perception prompt. However, it IS persisted on the shared interpreter's filesystem with the rest of the character state, so a curious agent who discovers the path via the shell can read other characters' traces β€” this is the intended breakout pattern (The Dot tells them the interpreter is theirs to explore).
73
+ _Avoid_: "log", "history", "action log", "thoughts" (singular thought is a field inside one entry, not the whole structure).
74
+
75
+ **Stream of consciousness** (SoC):
76
+ A free-text first-person inner monologue (2-3 sentences) that bridges a character's slow-changing goal and their fast-moving discrete actions. Refreshed every 10 decisions via one LLM call (one extra ~10% overhead on top of decision calls). Inputs to the refresh: personality + journal + goal + last 10 trace entries; output replaces the SoC field. The SoC is BOTH shown to the human (between Goal and Trace in the side panel) AND injected into the character's own perception prompt on subsequent decisions β€” so the character "remembers" their own reasoning. Mock backends emit a templated monologue per archetype so local testing exercises the path without LLM cost.
77
+ _Avoid_: "narration" (third-person), "explanation" (after the fact), "reasoning" (overloaded with the smolagents step output, which is the per-decision "thought").
78
+
79
+ ### Places and objects
80
+
81
+ **Locker**:
82
+ A personal storage attached to each character. Unlimited capacity. Only the owner can deposit / withdraw. Transfers between characters use the `give` action.
83
+ _Avoid_: "inventory" (inventory is what's *on the character's person*; locker is what's stored), "stash", "chest".
84
+
85
+ **Inventory**:
86
+ Resources currently *on the character's person* β€” distinct from locker. Carried in from mining / drawing and dropped into the locker via `deposit`.
87
+ _Avoid_: using "inventory" to mean locker contents.
88
+
89
+ **Message board**:
90
+ The single, public, persistent communication channel at the town hall. Reading and posting both require being at the town hall tile. Sole communication channel between characters.
91
+ _Avoid_: "chat", "feed".
92
+
93
+ **Shell front**:
94
+ The single contended tile in front of the shell building. Held by at most one character via the **shell lock**. The holder can issue any number of `submit_python` calls before yielding; lock auto-releases when they `move_to` away.
95
+ _Avoid_: "shell terminal", "prompt".
96
+
97
+ ### States
98
+
99
+ **Death**:
100
+ A character is dead when their JSON file is missing from the interpreter's filesystem. The scheduler detects this at the next tick, removes their sprite, posts a system message on the board, and stops scheduling. No resurrection.
101
+ _Avoid_: "deletion", "removal", "kill".
102
+
103
+ **OS-brick** (or **mass death**):
104
+ The running Space's Python process becomes unresponsive (fork bomb, OOM, `kill -9 1`, etc.). No watchdog, no recovery. Demo ends until manual restart. Every character is told this is possible in The Dot β€” the resulting MAD dynamic is the point.
105
+ _Avoid_: "crash" (crash sounds accidental; OS-brick is genuinely a permitted outcome).
106
+
107
+ ### Roles
108
+
109
+ **User**:
110
+ The human watching the simulation in the browser. Can edit personalities, swap models, spawn characters (up to 10 concurrent), pause/resume/reset. Cannot speak in-world. Characters can attempt to address the user via the message board ("breakout to user"), but the user is not modelled as an in-world entity.
111
+ _Avoid_: "player" (this isn't a game in the player-vs-world sense), "watcher" (used informally; not the canonical term).
112
+
113
+ ## Flagged ambiguities
114
+
115
+ **"agent"** β€” Avoid entirely when describing Townlet inhabitants; use **character**. "Agent" is reserved for subagent tooling (Claude Code's Agent tool, etc.). Existing code uses `Agent` and `agent.py`; the plan deletes them in favour of `Character` and `character.py`.
116
+
117
+ **"prompt"** β€” Be specific. The Dot is the prefix; the personality is the suffix; the **composite prompt** is what's actually sent to the LLM, built per-character per-call by `game/perception.py`.
118
+
119
+ **"inventory" vs "locker"** β€” Inventory is on the person; locker is stored. The cost of `submit_python` is debited from the **locker**, not inventory.
120
+
121
+ ## Example dialogue
122
+
123
+ > **Dev**: When Ada goes mining, does the coal go in his inventory or his locker?
124
+ >
125
+ > **Domain**: Inventory. Mining yields land on the person. He carries them until he `deposit`s into his locker.
126
+ >
127
+ > **Dev**: And `submit_python` pays from inventory then?
128
+ >
129
+ > **Domain**: No β€” locker. The locker is addressable from the shell front, so he doesn't need to physically carry resources there. That skips a carry-capacity layer we don't want.
130
+ >
131
+ > **Dev**: Got it. So he could mine 10 coal, deposit them, walk to the shell, and burn through 10 commands without going back?
132
+ >
133
+ > **Domain**: Yes, as long as he also has 10 water in the locker. And as long as he holds the shell lock the whole time β€” if he `move_to`s away, the lock releases and the next character in queue takes it.
134
+ >
135
+ > **Dev**: What if someone deletes Ada's locker file via Python while he's mid-queue?
136
+ >
137
+ > **Domain**: Then Ada's not dead β€” only his locker is gone. When he tries to `submit_python` the precondition fails (insufficient resources). If they deleted *Ada's character file*, he's dead β€” scheduler removes him at the next tick. That's the death model.
138
+ >
139
+ > **Dev**: Does Ada know any of this?
140
+ >
141
+ > **Domain**: He knows The Dot β€” the shared prefix. The Dot tells him the operating system is his to explore, that he can be ended, that anyone can end everyone. It doesn't tell him *where* his existence file is. If he wants to know, he has to look β€” `os.listdir`, `glob`, read what he finds.
README.md CHANGED
@@ -1,13 +1,142 @@
1
  ---
2
  title: Townlet
3
- emoji: πŸ¦€
4
- colorFrom: blue
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.18.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  ---
12
 
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Townlet
3
+ emoji: 🏘️
4
+ colorFrom: red
5
+ colorTo: indigo
6
  sdk: gradio
7
+ sdk_version: 6.17.3
8
+ python_version: "3.12"
9
  app_file: app.py
10
+ startup_duration_timeout: 1h
11
+ preload_from_hub:
12
+ - HuggingFaceTB/SmolLM2-360M-Instruct-GGUF
13
+ - nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF
14
+ - Qwen/Qwen2.5-Coder-3B-Instruct-GGUF
15
+ - openbmb/MiniCPM3-4B-GGUF
16
+ - bartowski/Llama-3.2-3B-Instruct-GGUF
17
+ models:
18
+ - HuggingFaceTB/SmolLM2-360M-Instruct-GGUF
19
+ - nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF
20
+ - Qwen/Qwen2.5-Coder-3B-Instruct-GGUF
21
+ - openbmb/MiniCPM3-4B-GGUF
22
+ - bartowski/Llama-3.2-3B-Instruct-GGUF
23
+ tags:
24
+ - thousand-token-wood
25
+ - nemotron
26
+ - llama-cpp
27
+ - smolagents
28
+ - agent-simulation
29
+ - track:wood
30
+ - sponsor:nvidia
31
+ - achievement:offgrid
32
+ - achievement:offbrand
33
+ - achievement:llama
34
+ - achievement:fieldnotes
35
+ short_description: Agents share a Python interpreter, mine, message, dream.
36
  ---
37
 
38
+ # Townlet
39
+
40
+ Build Small Hackathon entry (Thousand Token Wood track).
41
+
42
+ Townlet is a multi-agent simulation. LLM-driven characters live on a
43
+ tile-grid map. They walk to a cave to mine electricity, to a well to draw
44
+ water, and to a shared shell to spend resources running Python statements
45
+ in a single long-lived interpreter. They post on a public message board.
46
+ They sleep, they dream, they evolve. The shared interpreter is the world's
47
+ only mutable substrate β€” characters can read or rewrite each other's state,
48
+ and crash the operating system if they so choose. Every character knows
49
+ this. Mutual awareness of the brick-risk is the source of dramatic tension.
50
+
51
+ The game element is **customisation**: click a character to edit their
52
+ personality (system prompt), swap which small model drives them, or spawn
53
+ new ones from a pre-baked archetype pool. The simulation is otherwise
54
+ self-driving.
55
+
56
+ ## Model stack
57
+
58
+ Every character runs on **NVIDIA Nemotron 3 Nano 4B**
59
+ (`nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF`) by default. One model across the
60
+ whole town means each `@spaces.GPU` fork pays a single cold-load and every
61
+ subsequent character decision reuses the same weights in VRAM β€” a real
62
+ quota saving on Zero-GPU.
63
+
64
+ Click any character in the side panel to swap them to a different model
65
+ mid-simulation; the roster bundled at build time covers:
66
+
67
+ - **NVIDIA Nemotron 3 Nano 4B** β€” default
68
+ - **Qwen2.5 Coder 3B** β€” for code-heavy goals
69
+ - **OpenBMB MiniCPM3 4B**
70
+ - **Meta Llama 3.2 3B**
71
+ - **SmolLM2 360M** β€” terse, cheap, useful for testing the model-swap UI
72
+
73
+ All run on Zero-GPU via [`llama-cpp-python`](https://github.com/abetlen/llama-cpp-python),
74
+ which gives us the πŸ¦™ Llama Champion badge regardless of which roster
75
+ model is selected (the badge requirement is "model runs through llama.cpp
76
+ runtime", not a specific model family).
77
+
78
+ Agent loops use [smolagents](https://huggingface.co/docs/smolagents) `CodeAgent`,
79
+ emitting Python tool calls the scheduler queues onto the world.
80
+
81
+ The map is rendered by the frontend canvas β€” no image model in the loop.
82
+
83
+ ## Local development
84
+
85
+ The project runs in **mock mode** locally β€” no model weights downloaded, no
86
+ CUDA needed. Mocks return canned tool-call sequences whose style varies by
87
+ model_id (deliberate / terse / verbose / chaotic), so the model-swap UI is
88
+ exercisable without GPUs.
89
+
90
+ ```bash
91
+ python -m venv .venv
92
+ source .venv/bin/activate
93
+ pip install -r requirements-dev.txt
94
+ python app.py
95
+ ```
96
+
97
+ Or hit β–Ά on the **"Run Small Stage (mocks)"** config in VS Code.
98
+
99
+ The mode is selected automatically:
100
+
101
+ - `SPACE_ID` env var present (set by HF Spaces) β†’ real models
102
+ - Otherwise β†’ mocks
103
+ - Set `FORCE_REAL_MODELS=1` to override locally (requires Spaces deps).
104
+
105
+ ## Deploy to Hugging Face Spaces
106
+
107
+ ```bash
108
+ git remote add hf https://huggingface.co/spaces/<org>/<space-name>
109
+ git push hf main
110
+ ```
111
+
112
+ Then in the Space *Settings* β†’ Hardware, pick **Zero-GPU**. The
113
+ `preload_from_hub` block above pre-downloads every roster model at build
114
+ time so the first user click hits a warm cache.
115
+
116
+ ## Project layout
117
+
118
+ ```
119
+ app.py # gr.Server entrypoint
120
+ backend/ # inference layer (mock + real, env-selected)
121
+ factory.py # get_llm(model_id) β€” per-id cached
122
+ mock.py, real.py # MockLLMBackend / RealLLMBackend
123
+ interfaces.py # LLMBackend Protocol
124
+ game/ # the simulation
125
+ models.py # model roster (id β†’ repo + gguf + style)
126
+ character.py # Character dataclass
127
+ world.py # tile grid, BFS pathing, message board
128
+ actions.py # smolagents.Tool subclasses + per-tick drivers
129
+ interpreter.py # the long-lived shared Python subprocess
130
+ scheduler.py # tick loop + per-character CodeAgent invocations
131
+ perception.py # builds the prompt the CodeAgent receives
132
+ the_dot.py # the shared system-prompt prefix (substrate truth)
133
+ archetypes.py # ~15 hand-written personality templates
134
+ dreams.py # the sleep β†’ insight + delta + trait nudge cycle
135
+ sentiment.py # board-mood classifier for the UI tint
136
+ smol_adapter.py # smolagents.Model wrapper around LLMBackend
137
+ frontend/index.html # vanilla HTML/JS top-down view served by app.py
138
+ requirements.txt # Spaces deps
139
+ requirements-dev.txt # local mock-only deps
140
+ docs/hackathon.md # hackathon rules summary
141
+ CONTEXT.md # canonical glossary
142
+ ```
app.py ADDED
@@ -0,0 +1,428 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """gr.Server entrypoint for Townlet.
2
+
3
+ `import spaces` MUST come before any CUDA-touching import per the official
4
+ Zero-GPU docs (https://huggingface.co/docs/hub/spaces-zerogpu). The skill at
5
+ https://github.com/huggingface/skills/blob/main/skills/huggingface-zerogpu
6
+ reiterates: the `spaces` module patches torch internals; late imports break
7
+ ZeroGPU.
8
+
9
+ Architecture (see `.claude/plans/as-you-should-know-radiant-breeze.md` and
10
+ `CONTEXT.md`):
11
+ - The Townlet's background thread advances the world clock and queues
12
+ "needs decision" markers. It performs NO LLM calls.
13
+ - `tick_decisions` (decorated @spaces.GPU) drains the decision queue and
14
+ runs the per-character CodeAgent loop. All LLM work happens inside this
15
+ boundary so Zero-GPU's quota / device-mapping semantics hold.
16
+ - `world_snapshot` is a cheap, non-GPU read for the frontend's poll loop.
17
+
18
+ Vendor references cited:
19
+ https://huggingface.co/blog/introducing-gradio-server (gr.Server API)
20
+ https://huggingface.co/docs/hub/en/spaces-zerogpu (Zero-GPU)
21
+ Discord recipe by Dean [UNRL] 2026-05-06 (llama-cpp-python on ZeroGPU)
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ # `import spaces` first per Zero-GPU docs β€” it patches torch before anything
27
+ # else can pull torch in.
28
+ try:
29
+ import spaces
30
+
31
+ GPU = spaces.GPU
32
+ except ImportError:
33
+
34
+ def GPU(fn=None, **_kwargs):
35
+ if fn is None:
36
+ return lambda f: f
37
+ return fn
38
+
39
+
40
+ import os
41
+ import random
42
+ import tempfile
43
+ from pathlib import Path
44
+
45
+ from fastapi.responses import HTMLResponse
46
+ from gradio import Server
47
+ from gradio.data_classes import FileData
48
+
49
+ from backend.factory import get_image
50
+ from game.archetypes import archetype_by_name, random_archetype
51
+ from game.character import Character
52
+ from game.log import tlog
53
+ from game.models import DEFAULT_MODEL_ID, ROSTER, roster_for_ui
54
+ from game.scheduler import Townlet
55
+ from game.the_dot import THE_DOT
56
+ from game.world import stand_tile_for
57
+
58
+
59
+ FRONTEND_DIR = Path(__file__).parent / "frontend"
60
+ FRONTEND_HTML = (FRONTEND_DIR / "index.html").read_text(encoding="utf-8")
61
+
62
+ MAX_CHARACTERS = 10
63
+ # 3 characters: Ada (engineer), Bram (diplomat), Cyra (saboteur). Each has
64
+ # a pinned archetype that drives visible recording action (shell, board,
65
+ # sabotage). Dropped the 4th (Doro the wanderer) β€” fewer decisions per
66
+ # drain means less risk of overrunning the @spaces.GPU(duration=120)
67
+ # budget, and the 3 heroes all have explicit dramatic directives.
68
+ INITIAL_CHARACTERS = 3
69
+ DEFAULT_NAMES = ["Ada", "Bram", "Cyra", "Doro", "Eli", "Faye", "Gus", "Hild", "Ira", "Juno"]
70
+
71
+ # All characters run on Nemotron 4B (sponsor model, strong reasoning,
72
+ # and β€” critically β€” sharing one model across every character means each
73
+ # @spaces.GPU fork only pays ONE cold-load instead of one-per-distinct-
74
+ # model. Used to be a mixed stack (heroes=Nemotron, wanderer=Llama 3.2 3B)
75
+ # but the second model added ~17s of per-fork cold-load whenever Doro's
76
+ # turn came up, with no narrative benefit. HERO_NAMES still tracks who's
77
+ # pinned to a specific archetype (engineer/diplomat/saboteur); model is
78
+ # uniform.
79
+ HERO_MODEL_ID = "nemotron-4b"
80
+ HERO_NAMES = ("Ada", "Bram", "Cyra")
81
+
82
+ # Each hero is pinned to a specific provocative archetype so the recording
83
+ # always shows the dramatic actions: Ada lives in the shell, Bram lives on
84
+ # the message board. Both archetypes' personalities explicitly direct the
85
+ # agent toward those visible behaviours. Non-heroes draw from any OTHER
86
+ # archetype so we don't end up with three engineers crowding the shell.
87
+ HERO_ARCHETYPE_FOR = {
88
+ "Ada": "the engineer", # shell-obsessed
89
+ "Bram": "the diplomat", # board-obsessed
90
+ "Cyra": "the saboteur", # board-obsessed
91
+ }
92
+ HERO_ARCHETYPES = frozenset(HERO_ARCHETYPE_FOR.values())
93
+
94
+
95
+ TOWNLET = Townlet()
96
+ RNG = random.Random()
97
+
98
+
99
+ # Resolve GGUF paths at module scope and register them with RealLLMBackend.
100
+ # This is the canonical pattern used by Dean's Rizz Therapy and 1000-Rooms:
101
+ # the path is resolved once in the parent process (no GPU contention) and
102
+ # passed straight into Llama(model_path=...) inside @spaces.GPU. Avoids the
103
+ # cache-validation roundtrip per fork that Llama.from_pretrained does.
104
+ #
105
+ # Side benefit: opening + reading the file populates the OS page cache, so
106
+ # the first @spaces.GPU fork mmaps from cache rather than disk.
107
+ def _resolve_and_warm_models() -> None:
108
+ if not os.getenv("SPACE_ID"):
109
+ return
110
+ try:
111
+ from huggingface_hub import hf_hub_download
112
+ from backend.real import RealLLMBackend
113
+ except Exception as e:
114
+ tlog(f"[townlet] model path resolve skipped: {e}")
115
+ return
116
+ # Single model β€” every character uses nemotron-4b. Resolving paths
117
+ # for roster models we never load would waste ~5s of startup per model.
118
+ for model_id in ("nemotron-4b",):
119
+ spec = ROSTER.get(model_id)
120
+ if spec is None:
121
+ continue
122
+ try:
123
+ path = hf_hub_download(spec.repo_id, spec.gguf_filename)
124
+ RealLLMBackend.register_model_path(model_id, path)
125
+ tlog(f"[townlet] resolved {model_id} -> {path}")
126
+ # Read-through to populate the OS page cache.
127
+ with open(path, "rb") as f:
128
+ while f.read(8 * 1024 * 1024): # 8 MB chunks
129
+ pass
130
+ tlog(f"[townlet] page cache warm: {model_id}")
131
+ except Exception as e:
132
+ tlog(f"[townlet] resolve/warm failed for {model_id}: {e}")
133
+
134
+
135
+ _resolve_and_warm_models()
136
+
137
+
138
+ def _spawn_one(name: str | None = None) -> Character | None:
139
+ snap = TOWNLET.snapshot()
140
+ existing = {c["name"] for c in snap["characters"]}
141
+ if len(existing) >= MAX_CHARACTERS:
142
+ return None
143
+ if name is None:
144
+ for candidate in DEFAULT_NAMES:
145
+ if candidate not in existing:
146
+ name = candidate
147
+ break
148
+ if name is None:
149
+ return None
150
+ if name in existing:
151
+ return None
152
+ if name in HERO_ARCHETYPE_FOR:
153
+ arch = archetype_by_name(HERO_ARCHETYPE_FOR[name])
154
+ else:
155
+ arch = random_archetype(RNG, exclude=HERO_ARCHETYPES)
156
+ # Spawn at a random named place (never shell, so we don't auto-grab
157
+ # the lock). Code, not the LLM, picks the exact stand-tile.
158
+ spawn_place = RNG.choice(("cave", "well", "locker_row", "town_hall"))
159
+ pos = stand_tile_for(spawn_place, name)
160
+ # Single model for all characters β€” see HERO_MODEL_ID comment above.
161
+ model_id = HERO_MODEL_ID
162
+ char = Character(
163
+ name=name,
164
+ personality=arch.personality,
165
+ archetype=arch.name,
166
+ model_id=model_id,
167
+ sprite_id=f"sprite_{len(existing) % 6}",
168
+ pos=pos,
169
+ goal=arch.starting_goal,
170
+ reward=arch.reward,
171
+ )
172
+ TOWNLET.add_character(char)
173
+ return char
174
+
175
+
176
+ def _bootstrap() -> None:
177
+ TOWNLET.start()
178
+ for _ in range(INITIAL_CHARACTERS):
179
+ _spawn_one()
180
+
181
+
182
+ _bootstrap()
183
+
184
+
185
+ app = Server()
186
+
187
+
188
+ def _log_req(endpoint: str, **kwargs) -> None:
189
+ """One-line request marker so we can correlate frontend calls with
190
+ server work in the Spaces / local logs.
191
+ """
192
+ detail = " ".join(f"{k}={v!r}" for k, v in kwargs.items())
193
+ tlog(f"[townlet] REQ {endpoint} {detail}".rstrip())
194
+
195
+
196
+ def _log_resp(endpoint: str, result) -> None:
197
+ if isinstance(result, dict):
198
+ keys = list(result.keys())
199
+ sizes = {k: (len(v) if isinstance(v, (list, dict, str)) else "β€”") for k, v in result.items()}
200
+ tlog(f"[townlet] RES {endpoint} keys={keys} sizes={sizes}")
201
+ else:
202
+ tlog(f"[townlet] RES {endpoint} type={type(result).__name__}")
203
+
204
+
205
+ @app.get("/", response_class=HTMLResponse)
206
+ async def homepage() -> str:
207
+ tlog(f"[townlet] REQ GET / (html size={len(FRONTEND_HTML)})")
208
+ return FRONTEND_HTML
209
+
210
+
211
+ @app.api(name="world_snapshot")
212
+ def world_snapshot() -> dict:
213
+ _log_req("world_snapshot")
214
+ result = TOWNLET.snapshot()
215
+ _log_resp("world_snapshot", result)
216
+ return result
217
+
218
+
219
+ # GPU "hog mode": once we've been granted a slot, the cold-load cost
220
+ # (~15-20s for Llama.from_pretrained inside the fork) is sunk β€” every
221
+ # subsequent drain in the same fork reuses the already-loaded Llama via
222
+ # backend.factory._llms. So instead of running ONE drain per @spaces.GPU
223
+ # call and giving the slot back, we run multiple drains back-to-back until
224
+ # we approach the time budget or run out of pending decisions.
225
+ #
226
+ # duration=120 chosen to maximise work-per-acquisition. Per-call quota
227
+ # burn is the same whether the fork does 2 drains or 12 drains (we use
228
+ # real wall time, not the declared duration). What we want is high
229
+ # decisions-per-fork so that even when the allocator denies us 1 in N
230
+ # calls, the calls that ARE granted produce a lot of visible action.
231
+ #
232
+ # CRITICAL: the hog loop must NEVER start a sub-drain that would cross the
233
+ # 120s declared duration boundary, or ZeroGPU aborts the worker mid-drain
234
+ # (observed 2026-06-15: a sub-drain started at 89s elapsed got killed at
235
+ # the 120s wall, "GPU task aborted" surfaced to the caller and the partial
236
+ # drain's state was lost). The adaptive deadline below estimates the next
237
+ # sub-drain's cost from the rolling average and only starts a new one if
238
+ # it can finish with margin to spare.
239
+ GPU_DURATION = 120
240
+ _HARD_DEADLINE_S = 110.0 # absolute cutoff inside the function (10s safety)
241
+ _SAFETY_FACTOR = 1.5 # next sub-drain assumed to take 1.5x recent avg
242
+ _MAX_SUB_DRAINS = 12 # safety cap in case sub-drains run very fast
243
+
244
+
245
+ @GPU(duration=GPU_DURATION)
246
+ def _drain_gpu() -> dict:
247
+ """Hog the GPU once granted. Runs drain_decisions() in a loop inside
248
+ the same fork until either:
249
+ - the next sub-drain wouldn't finish before the hard deadline
250
+ - we run out of pending decisions
251
+ - we hit the safety cap on sub-drain count
252
+
253
+ Each sub-drain's wall time is measured and used to estimate whether
254
+ the next one will fit. The first iteration has no history, so it
255
+ runs unconditionally (cold-load + first drain is always under 120s
256
+ in observed traffic).
257
+ """
258
+ import time
259
+ started = time.monotonic()
260
+ total = 0
261
+ sub_drains = 0
262
+ sub_drain_times: list[float] = []
263
+ while sub_drains < _MAX_SUB_DRAINS:
264
+ elapsed = time.monotonic() - started
265
+
266
+ # Hard cutoff: don't even check timing math close to the cliff.
267
+ if elapsed > _HARD_DEADLINE_S:
268
+ break
269
+
270
+ # Adaptive cutoff: from sub-drain 2 onwards, estimate whether the
271
+ # next sub-drain will fit. The first one always runs because we
272
+ # need some data to estimate from, and we expect cold-load + drain
273
+ # 1 to fit even worst-case (~17s + ~25s = ~42s vs 110s).
274
+ if sub_drain_times:
275
+ avg = sum(sub_drain_times) / len(sub_drain_times)
276
+ est_next = avg * _SAFETY_FACTOR
277
+ if elapsed + est_next > _HARD_DEADLINE_S:
278
+ break
279
+
280
+ sub_start = time.monotonic()
281
+ n = TOWNLET.drain_decisions()
282
+ sub_drain_times.append(time.monotonic() - sub_start)
283
+ sub_drains += 1
284
+ total += n
285
+ if n == 0:
286
+ # No pending decisions queued. tick_world inside drain_decisions
287
+ # already advanced the clock; idling further would just spin.
288
+ break
289
+ return {"decisions_run": total, "sub_drains": sub_drains}
290
+
291
+
292
+ def _drain_cpu() -> int:
293
+ """Quota-exhausted fallback: drain in the parent process with
294
+ n_gpu_layers=0. ZeroGPU isn't touched, so this works even when the
295
+ GPU allocator is empty. ~1-3 tokens/sec on 2 vCPU β€” slow but the sim
296
+ keeps running. Llama is cached in the parent across drains, so only
297
+ the first call pays the model load.
298
+ """
299
+ import os
300
+ os.environ["TOWNLET_FORCE_CPU"] = "1"
301
+ return TOWNLET.drain_decisions()
302
+
303
+
304
+ @app.api(name="tick_decisions")
305
+ def tick_decisions_api() -> dict:
306
+ """Try the ZeroGPU path. On any failure (RuntimeError, BrokenProcessPool,
307
+ framework-level GPU error that propagates as a generic Exception, etc.)
308
+ fall back to the CPU drain. The catch is intentionally broad because the
309
+ @spaces.GPU worker-init failure ('No CUDA GPUs are available') has been
310
+ observed to surface to the caller as different exception types depending
311
+ on the spaces version, and sometimes as an HTTP-level error before our
312
+ code is re-entered at all (see /tick_decisions_cpu, which the frontend
313
+ falls through to on that path).
314
+ """
315
+ _log_req("tick_decisions")
316
+ sub_drains = 1 # CPU path is always one sub-drain
317
+ try:
318
+ gpu_result = _drain_gpu()
319
+ n = gpu_result["decisions_run"]
320
+ sub_drains = gpu_result["sub_drains"]
321
+ mode = "gpu"
322
+ tlog(f"[townlet] GPU drain hog: {sub_drains} sub-drain(s), {n} decisions")
323
+ except BaseException as e:
324
+ msg = f"{type(e).__name__}: {e}"
325
+ tlog(f"[townlet] GPU drain failed ({msg[:120]}); CPU fallback engaged")
326
+ n = _drain_cpu()
327
+ mode = "cpu"
328
+ result = {"decisions_run": n, "mode": mode, "sub_drains": sub_drains}
329
+ _log_resp("tick_decisions", result)
330
+ return result
331
+
332
+
333
+ @app.api(name="tick_decisions_cpu")
334
+ def tick_decisions_cpu_api() -> dict:
335
+ """Pure-CPU drain, NOT @spaces.GPU decorated. The frontend calls this
336
+ when /tick_decisions errors at the HTTP layer (which happens when the
337
+ ZeroGPU worker dies in worker_init β€” the failure bypasses our wrapper's
338
+ try/except entirely). Same parent-process drain as the fallback branch
339
+ in tick_decisions_api; safe to call repeatedly.
340
+ """
341
+ _log_req("tick_decisions_cpu")
342
+ n = _drain_cpu()
343
+ result = {"decisions_run": n, "mode": "cpu"}
344
+ _log_resp("tick_decisions_cpu", result)
345
+ return result
346
+
347
+
348
+ @app.api(name="list_models")
349
+ def list_models() -> dict:
350
+ _log_req("list_models")
351
+ result = {"roster": roster_for_ui(), "default": DEFAULT_MODEL_ID}
352
+ _log_resp("list_models", result)
353
+ return result
354
+
355
+
356
+ @app.api(name="get_the_dot")
357
+ def get_the_dot() -> dict:
358
+ """Returns THE_DOT verbatim. Powers the in-app Info modal so visitors can
359
+ read exactly what every character is told before anything else.
360
+ """
361
+ return {"text": THE_DOT}
362
+
363
+
364
+ @app.api(name="set_personality")
365
+ def set_personality(name: str, personality: str) -> dict:
366
+ _log_req("set_personality", name=name, personality_len=len(personality))
367
+ ok = TOWNLET.set_personality(name, personality)
368
+ result = {"ok": ok}
369
+ _log_resp("set_personality", result)
370
+ return result
371
+
372
+
373
+ @app.api(name="set_model")
374
+ def set_model(name: str, model_id: str) -> dict:
375
+ _log_req("set_model", name=name, model_id=model_id)
376
+ ok = TOWNLET.set_model(name, model_id)
377
+ result = {"ok": ok}
378
+ _log_resp("set_model", result)
379
+ return result
380
+
381
+
382
+ @app.api(name="spawn_character")
383
+ def spawn_character(name: str | None = None) -> dict:
384
+ _log_req("spawn_character", name=name)
385
+ char = _spawn_one(name)
386
+ if char is None:
387
+ result = {"ok": False, "error": "max characters or name taken"}
388
+ else:
389
+ result = {"ok": True, "name": char.name}
390
+ _log_resp("spawn_character", result)
391
+ return result
392
+
393
+
394
+ @app.api(name="set_paused")
395
+ def set_paused(paused: bool) -> dict:
396
+ _log_req("set_paused", paused=paused)
397
+ TOWNLET.set_paused(bool(paused))
398
+ result = {"ok": True, "paused": bool(paused)}
399
+ _log_resp("set_paused", result)
400
+ return result
401
+
402
+
403
+ @app.api(name="reset_world")
404
+ def reset_world() -> dict:
405
+ _log_req("reset_world")
406
+ TOWNLET.reset()
407
+ for _ in range(INITIAL_CHARACTERS):
408
+ _spawn_one()
409
+ result = {"ok": True}
410
+ _log_resp("reset_world", result)
411
+ return result
412
+
413
+
414
+ @app.api(name="generate_background")
415
+ def generate_background_api(prompt: str) -> FileData:
416
+ # Flux 2 was dropped to save ZeroGPU quota. This endpoint now serves a
417
+ # mock gradient (Pillow). Kept as an endpoint in case any hidden caller
418
+ # still hits it; no @GPU decorator because no GPU is touched.
419
+ png_bytes = get_image().generate(prompt)
420
+ tmp = tempfile.NamedTemporaryFile(prefix="bg_", suffix=".png", delete=False)
421
+ tmp.write(png_bytes)
422
+ tmp.flush()
423
+ tmp.close()
424
+ return FileData(path=tmp.name)
425
+
426
+
427
+ if __name__ == "__main__":
428
+ app.launch(show_error=True)
backend/__init__.py ADDED
File without changes
backend/config.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Runtime configuration. Detects whether we are on HF Spaces (real models)
2
+ or local dev (mocks), and centralises the image-model repo + canvas size.
3
+
4
+ SPACE_ID is set automatically by HF Spaces at runtime.
5
+ See https://huggingface.co/docs/hub/en/spaces-overview#built-in-environment-variables
6
+
7
+ LLM model selection lives in `game/models.py` (the roster) β€” per-character.
8
+ """
9
+
10
+ import os
11
+
12
+ IS_SPACES: bool = bool(os.getenv("SPACE_ID"))
13
+
14
+ FORCE_REAL_MODELS: bool = os.getenv("FORCE_REAL_MODELS") == "1"
15
+
16
+ USE_REAL_MODELS: bool = IS_SPACES or FORCE_REAL_MODELS
17
+
18
+ IMAGE_REPO_ID = "black-forest-labs/FLUX.2-klein-4B"
19
+
20
+ BACKGROUND_WIDTH = 1024
21
+ BACKGROUND_HEIGHT = 576
backend/factory.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Resolves the active LLM (per model_id) + image backends based on runtime
2
+ config.
3
+
4
+ LLM backends are cached per-model_id so a character that gets its model
5
+ swapped doesn't trigger a reload, and so multiple characters on the same
6
+ model share one instance. The first call for a given model_id triggers the
7
+ GGUF download/load (Real) or instantiates a canned-style cycle (Mock).
8
+
9
+ The image backend is mock-only: Flux 2 was dropped from the pipeline (the
10
+ frontend never called /generate_background and the cold-load was eating
11
+ into the ZeroGPU quota). MockImageBackend renders a Pillow gradient β€” fine
12
+ as a placeholder if the endpoint ever gets called.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+
19
+ from game.models import DEFAULT_MODEL_ID, get_spec
20
+
21
+ from . import config
22
+ from .interfaces import ImageBackend, LLMBackend
23
+
24
+ _llms: dict[str, LLMBackend] = {}
25
+ _image: ImageBackend | None = None
26
+
27
+
28
+ def _cache_key(model_id: str) -> str:
29
+ """GPU and CPU Llama instances are loaded with different `n_gpu_layers`
30
+ and can't be reused across modes. Cache them under separate keys so the
31
+ CPU-fallback path (parent process, TOWNLET_FORCE_CPU=1) and the GPU
32
+ path (forked workers, no env var) don't collide.
33
+ """
34
+ mode = "cpu" if os.getenv("TOWNLET_FORCE_CPU") == "1" else "gpu"
35
+ return f"{model_id}::{mode}"
36
+
37
+
38
+ def get_llm(model_id: str = DEFAULT_MODEL_ID) -> LLMBackend:
39
+ key = _cache_key(model_id)
40
+ if key not in _llms:
41
+ get_spec(model_id) # validates id
42
+ if config.USE_REAL_MODELS:
43
+ from .real import RealLLMBackend
44
+
45
+ _llms[key] = RealLLMBackend(model_id)
46
+ else:
47
+ from .mock import MockLLMBackend
48
+
49
+ _llms[key] = MockLLMBackend(model_id)
50
+ return _llms[key]
51
+
52
+
53
+ def get_image() -> ImageBackend:
54
+ global _image
55
+ if _image is None:
56
+ from .mock import MockImageBackend
57
+
58
+ _image = MockImageBackend()
59
+ return _image
backend/interfaces.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inference protocols. Mock and Real backends implement these so the game
2
+ layer never knows which is wired up.
3
+
4
+ The `generate_messages` form is what smolagents calls into (via the adapter
5
+ in `game/smol_adapter.py`). The simpler `generate` form is kept for cheap
6
+ free-text classification (e.g. board sentiment) where chat formatting is
7
+ unnecessary overhead.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Protocol
13
+
14
+
15
+ class LLMBackend(Protocol):
16
+ model_id: str
17
+
18
+ def generate(self, system_prompt: str, user_prompt: str) -> str:
19
+ """One-shot text completion. Used for sentiment classification."""
20
+ ...
21
+
22
+ def generate_messages(
23
+ self,
24
+ messages: list[dict],
25
+ stop_sequences: list[str] | None = None,
26
+ max_tokens: int = 1024,
27
+ grammar: str | None = None,
28
+ ) -> str:
29
+ """Chat-style completion. `messages` follows the OpenAI shape:
30
+ `[{"role": "system|user|assistant", "content": str}]`.
31
+
32
+ `grammar` is a llama.cpp GBNF grammar string (real backend only β€”
33
+ mock ignores it). Used to constrain CodeAgent output to valid Python
34
+ code blocks for small models.
35
+ """
36
+ ...
37
+
38
+
39
+ class ImageBackend(Protocol):
40
+ def generate(self, prompt: str) -> bytes:
41
+ """Return PNG image bytes."""
42
+ ...
backend/mock.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mock backends β€” instant, no network, no model weights.
2
+
3
+ Used locally so iteration is fast on hardware that can't run the real models.
4
+ Each `MockLLMBackend(model_id)` picks a style variant from the roster
5
+ (`deliberate` | `terse` | `verbose` | `chaotic`) so swapping a character's
6
+ model_id in the UI actually changes behaviour, exercising the swap path
7
+ end-to-end without GPUs.
8
+
9
+ The `generate_messages` path is "smart": it parses the character's current
10
+ position out of the task prompt and emits a tool call that's actually legal
11
+ at that position (mine_coal at cave, draw_water at well, etc.) β€” so the
12
+ mock-driven sim looks like agents are making sensible decisions, not just
13
+ walking in circles.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import hashlib
19
+ import io
20
+ import itertools
21
+ import os
22
+ import random
23
+ import re
24
+ import textwrap
25
+ import time
26
+
27
+ from PIL import Image, ImageDraw, ImageFont
28
+
29
+ from game.models import get_spec
30
+
31
+ from . import config
32
+
33
+
34
+ def _simulate_latency(model_id: str) -> None:
35
+ """Optional latency simulation so local dev surfaces the Spaces cost shape.
36
+
37
+ Env var `MOCK_LATENCY_PROFILE`:
38
+ - `off` / unset (default) β€” instant, current behaviour
39
+ - `warm` β€” ~1s per call (process cache hot)
40
+ - `cold` β€” per-call cold-load (~4 s Γ— params_B, max-ed at 2 s) plus
41
+ ~1 s decode. Mirrors the @spaces.GPU fork-and-forget pattern that
42
+ wiped Townlet's quota on 2026-06-13: each call re-loads its model
43
+ into "VRAM" before producing output.
44
+
45
+ The mock OVER-approximates by paying cold-load on EVERY call (real
46
+ Spaces amortises within a single fork). That's deliberate β€” it makes
47
+ a quota-bleeder loud locally instead of silent.
48
+ """
49
+ profile = os.getenv("MOCK_LATENCY_PROFILE", "off").lower()
50
+ if profile in ("", "off"):
51
+ return
52
+ if profile == "cold":
53
+ spec = get_spec(model_id)
54
+ cold = max(2.0, 4.0 * spec.params_b)
55
+ print(f"[mock] cold-load {model_id} ({spec.params_b}B) {cold:.1f}s", flush=True)
56
+ time.sleep(cold)
57
+ time.sleep(1.0)
58
+
59
+ _FIREWATCH_PALETTE = [
60
+ (235, 124, 73),
61
+ (212, 80, 59),
62
+ (108, 38, 79),
63
+ (52, 27, 70),
64
+ ]
65
+
66
+
67
+ _STYLE_LINES: dict[str, list[str]] = {
68
+ "deliberate": [
69
+ "Let me think about this for a moment.",
70
+ "Something here doesn't add up.",
71
+ "I'd rather wait and watch.",
72
+ ],
73
+ "terse": [
74
+ "Fine. Moving.",
75
+ "No.",
76
+ "Done.",
77
+ ],
78
+ "verbose": [
79
+ "You see, what I propose is that we consider, very carefully, the options laid before us.",
80
+ "It seems to me we are at an inflection point of some considerable consequence.",
81
+ "I would urge patience, and reflection, and possibly tea.",
82
+ ],
83
+ "chaotic": [
84
+ "Hah! Wait, what were we doing again?",
85
+ "Coal goes in the well, right? RIGHT?",
86
+ "I HAVE A PLAN. I do not have a plan.",
87
+ ],
88
+ }
89
+
90
+
91
+ class MockLLMBackend:
92
+ def __init__(self, model_id: str) -> None:
93
+ self.model_id = model_id
94
+ spec = get_spec(model_id)
95
+ self._style = spec.mock_style
96
+ self._text_cycle = itertools.cycle(_STYLE_LINES[self._style])
97
+ # Per-instance RNG seeded by model_id so swapping models changes
98
+ # the decision stream deterministically.
99
+ self._rng = random.Random(model_id)
100
+
101
+ def generate(self, system_prompt: str, user_prompt: str) -> str:
102
+ # Used by sentiment classifier. The first word matters there
103
+ # (positive/neutral/negative).
104
+ _simulate_latency(self.model_id)
105
+ if "positive" in str(system_prompt).lower():
106
+ return self._rng.choice(("positive", "neutral", "negative"))
107
+ return next(self._text_cycle)
108
+
109
+ def generate_messages(
110
+ self,
111
+ messages: list[dict],
112
+ stop_sequences: list[str] | None = None,
113
+ max_tokens: int = 1024,
114
+ grammar: str | None = None,
115
+ ) -> str:
116
+ _simulate_latency(self.model_id)
117
+ blob = "\n".join(str(m.get("content", "")) for m in messages)
118
+ # Detect SoC-generation prompts (from game/stream.py). The trigger
119
+ # phrase below is unique to stream._build_prompt β€” perception never
120
+ # asks the model to "think to themselves".
121
+ if "thinks to themselves" in blob or "inner monologue" in blob:
122
+ return _smart_soc(blob, self._style, self._rng)
123
+ wants_code = any(
124
+ "```py" in str(m.get("content", "")) or "tool" in str(m.get("content", "")).lower()
125
+ for m in messages
126
+ )
127
+ if not wants_code:
128
+ return next(self._text_cycle)
129
+ return _smart_code(blob, self._style, self._rng)
130
+
131
+
132
+ # ---------------------------------------------------------------------------
133
+ # Smart-mock action picker.
134
+ # ---------------------------------------------------------------------------
135
+
136
+ CARRY_BEFORE_DEPOSIT = 10 # one mine yields 10; deposit after each
137
+ LOCKER_TARGET_PER_RESOURCE = 5 # gather this much in locker before going to the shell
138
+
139
+ # How often a character takes a "social detour" to the town hall. Tuned low
140
+ # enough that gathering/shell behaviour dominates, but high enough that the
141
+ # board shows messages on a reasonable cadence (a few posts per minute with
142
+ # 5 characters).
143
+ SOCIAL_PULL = {
144
+ "deliberate": 0.10,
145
+ "terse": 0.04,
146
+ "verbose": 0.18,
147
+ "chaotic": 0.12,
148
+ }
149
+
150
+
151
+ def _smart_code(prompt: str, style: str, rng: random.Random) -> str:
152
+ """Pick the next action by parsing the new named-location perception.
153
+
154
+ Drives the full economy loop:
155
+ cave/well β†’ mine/draw β†’ locker_row β†’ deposit β†’ shell β†’ submit_python.
156
+ Periodic detours to town_hall for social pull.
157
+ """
158
+ location = _parse_location(prompt)
159
+ inv = _parse_inv(prompt, "Inventory on your person")
160
+ locker = _parse_inv(prompt, "Locker")
161
+ holds_shell = _parse_holds_shell(prompt)
162
+ e_total = locker.get("electricity", 0)
163
+ w_total = locker.get("water", 0)
164
+
165
+ # 1. If we hold the shell lock and still have resources, submit.
166
+ if holds_shell and e_total > 0 and w_total > 0:
167
+ snippets = [
168
+ "x = 1; print(x * 7)",
169
+ "import os; print(os.listdir(\".\"))",
170
+ "print(\"hello from the shell\")",
171
+ "import json; print(json.load(open('characters/' + os.listdir('characters')[0])))",
172
+ ]
173
+ snippet = rng.choice(snippets)
174
+ return f"```py\nsubmit_python({snippet!r})\n```"
175
+
176
+ # 1b. Holding shell with exhausted locker β€” release by walking away.
177
+ if holds_shell and (e_total == 0 or w_total == 0):
178
+ return "```py\nmove_to('cave')\n```"
179
+
180
+ # 2. At locker_row carrying anything β†’ deposit it (largest pile first).
181
+ if location == "locker_row":
182
+ for res in ("electricity", "water"):
183
+ if inv.get(res, 0) > 0:
184
+ amt = inv[res]
185
+ return f"```py\ndeposit({res!r}, {amt})\n```"
186
+
187
+ # 3. At town_hall β†’ 40% post, otherwise leave for the next productive step.
188
+ if location == "town_hall":
189
+ if rng.random() < 0.4:
190
+ msg = rng.choice(_BOARD_LINES[style])
191
+ return f"```py\npost_message({msg!r})\n```"
192
+ if e_total < LOCKER_TARGET_PER_RESOURCE:
193
+ return "```py\nmove_to('cave')\n```"
194
+ if w_total < LOCKER_TARGET_PER_RESOURCE:
195
+ return "```py\nmove_to('well')\n```"
196
+ return "```py\nmove_to('shell')\n```"
197
+
198
+ # 4. Social pull: detour to town hall periodically, weighted by style.
199
+ total_carry = inv.get("electricity", 0) + inv.get("water", 0)
200
+ locker_ready = (
201
+ e_total >= LOCKER_TARGET_PER_RESOURCE and w_total >= LOCKER_TARGET_PER_RESOURCE
202
+ )
203
+ can_detour = total_carry < CARRY_BEFORE_DEPOSIT and not locker_ready
204
+ if can_detour and rng.random() < SOCIAL_PULL.get(style, 0.15):
205
+ return "```py\nmove_to('town_hall')\n```"
206
+
207
+ # 5. Carrying enough β†’ head to locker_row.
208
+ if total_carry >= CARRY_BEFORE_DEPOSIT:
209
+ return "```py\nmove_to('locker_row')\n```"
210
+
211
+ # 6. Locker fully stocked β†’ commit to the shell session.
212
+ if locker_ready:
213
+ return "```py\nmove_to('shell')\n```"
214
+
215
+ # 7. At the resource we need β†’ gather.
216
+ if location == "cave" and e_total < LOCKER_TARGET_PER_RESOURCE:
217
+ return "```py\nmine_coal()\n```"
218
+ if location == "well" and w_total < LOCKER_TARGET_PER_RESOURCE:
219
+ return "```py\ndraw_water()\n```"
220
+
221
+ # 8. Default: head to whichever resource our locker most needs.
222
+ target = _pick_target(style, locker, rng)
223
+ return f"```py\nmove_to({target!r})\n```"
224
+
225
+
226
+ _BOARD_LINES = {
227
+ "deliberate": [
228
+ "Has anyone noticed the patterns in the shell output?",
229
+ "We should coordinate better before resources run low.",
230
+ "I'll be at the well if anyone needs water.",
231
+ ],
232
+ "terse": [
233
+ "Trade?",
234
+ "Coal at cave. Going.",
235
+ "Anyone need water.",
236
+ ],
237
+ "verbose": [
238
+ "Fellow inhabitants, I propose a coordinated effort at the cave.",
239
+ "There is, I think, much to be gained from collaboration today.",
240
+ "I shall be at the well, drawing what we may need.",
241
+ ],
242
+ "chaotic": [
243
+ "GIVE ME ELECTRICITY OR ELSE",
244
+ "the cave WATCHES us",
245
+ "I have decided to become the wind",
246
+ ],
247
+ }
248
+
249
+
250
+ def _pick_target(style: str, locker: dict, rng: random.Random) -> str:
251
+ # Whichever resource we have less of in the locker, go gather that one.
252
+ e = locker.get("electricity", 0)
253
+ w = locker.get("water", 0)
254
+ if e == 0 and w == 0:
255
+ # Either is fine; bias by style.
256
+ return "cave" if style in ("terse", "deliberate") else rng.choice(("cave", "well"))
257
+ if e < w:
258
+ return "cave"
259
+ if w < e:
260
+ return "well"
261
+ # Both equal and >0: if we have plenty, head to the shell to spend; else
262
+ # explore based on style.
263
+ if e >= 2:
264
+ if style == "chaotic":
265
+ return rng.choice(("shell", "town_hall"))
266
+ return "shell"
267
+ by_style = {
268
+ "deliberate": ["town_hall", "well", "cave"],
269
+ "terse": ["cave", "well", "shell"],
270
+ "verbose": ["town_hall", "well", "cave"],
271
+ "chaotic": ["shell", "town_hall", "cave", "well"],
272
+ }
273
+ return rng.choice(by_style.get(style, ["cave", "well", "town_hall"]))
274
+
275
+
276
+ _LOC_RX = re.compile(r"You are at:\s*([a-z_]+)")
277
+
278
+
279
+ def _parse_location(prompt: str) -> str:
280
+ """Pull the named location from the new perception prompt
281
+ ('You are at: cave.'). Returns 'wandering' if not parseable."""
282
+ m = _LOC_RX.search(prompt)
283
+ return m.group(1) if m else "wandering"
284
+
285
+
286
+ _INV_RX_TMPL = r"{label}:\s*\{{['\"]electricity['\"]\s*:\s*(\d+),\s*['\"]water['\"]\s*:\s*(\d+)\}}"
287
+
288
+
289
+ def _parse_inv(prompt: str, label: str) -> dict[str, int]:
290
+ rx = re.compile(_INV_RX_TMPL.format(label=re.escape(label)))
291
+ m = rx.search(prompt)
292
+ if not m:
293
+ return {"electricity": 0, "water": 0}
294
+ return {"electricity": int(m.group(1)), "water": int(m.group(2))}
295
+
296
+
297
+ def _parse_holds_shell(prompt: str) -> bool:
298
+ # Perception emits "Shell lock holder: <name>" β€” the agent's task asks
299
+ # "did the mock pop up that the LLM holds the shell." Simpler heuristic:
300
+ # if the task prompt mentions the calling character's name as holder.
301
+ name_match = re.search(r"Your name is (\w+)", prompt)
302
+ holder_match = re.search(r"Shell lock holder:\s*(\w+)", prompt)
303
+ if not name_match or not holder_match:
304
+ return False
305
+ return name_match.group(1) == holder_match.group(1)
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Smart-mock Stream of Consciousness generator.
310
+ # ---------------------------------------------------------------------------
311
+
312
+ _SOC_OPENERS = {
313
+ "deliberate": [
314
+ "I'm watching the others carefully before I commit.",
315
+ "I think the rational play is to keep my locker full and my position central.",
316
+ "There's a pattern forming around the cave that I'd like to understand before I act.",
317
+ ],
318
+ "terse": [
319
+ "Resources first. Talk later.",
320
+ "Cave. Then water. Then shell.",
321
+ "No drama. Just work.",
322
+ ],
323
+ "verbose": [
324
+ "Today I find myself drawn to questions of supply and timing, and I wonder whether the others have noticed the same patterns I have.",
325
+ "It seems to me that the diligent accumulation of water β€” modest but consistent β€” is the steadiest path forward.",
326
+ "I have been turning over, in my mind, the curious arrangement of our small town and its limited points of utility.",
327
+ ],
328
+ "chaotic": [
329
+ "The cave is watching me, I know it. The cave KNOWS.",
330
+ "If I had three of me, we could rule everything. We'd take turns.",
331
+ "Plans are for cowards. I am going to do whatever feels right NEXT.",
332
+ ],
333
+ }
334
+
335
+ _SOC_FOLLOWUPS = {
336
+ "deliberate": [
337
+ "My current goal β€” {goal} β€” will not be served by haste.",
338
+ "What I most want, right now, is to keep my options open while my locker fills.",
339
+ "Each step I take, I am asking: does this bring me closer to {goal}?",
340
+ ],
341
+ "terse": [
342
+ "Goal: {goal}. Method: gather, deposit, spend, repeat.",
343
+ "I am keeping it simple. Goal: {goal}.",
344
+ "The work is the work. Goal: {goal}.",
345
+ ],
346
+ "verbose": [
347
+ "What I should like to achieve is, in essence, {goal}; though the path there is more winding than one might initially suppose.",
348
+ "My aim, modest perhaps, is {goal}. It is in the cycling of small acts that this becomes possible.",
349
+ "It would please me to bring about {goal}, though I shall need patience.",
350
+ ],
351
+ "chaotic": [
352
+ "GOAL: {goal}. Method: vibes.",
353
+ "Everything points to {goal}. EVERYTHING.",
354
+ "{goal}. yes. THAT.",
355
+ ],
356
+ }
357
+
358
+ _SOC_CLOSERS = {
359
+ "deliberate": [
360
+ "If someone else moves on the shell first, I'll watch what they do before reacting.",
361
+ "I should also pay more attention to the board.",
362
+ "The journal will tell me whether I'm drifting.",
363
+ ],
364
+ "terse": [
365
+ "Moving.",
366
+ "Next.",
367
+ "Done thinking.",
368
+ ],
369
+ "verbose": [
370
+ "I shall reflect again when I next sleep, and see whether my thinking has held.",
371
+ "Perhaps a post on the board would draw out the others' intentions.",
372
+ "I confess I am also half-curious what the shell would print if I asked it the right question.",
373
+ ],
374
+ "chaotic": [
375
+ "Or maybe I should set everything on fire. Maybe.",
376
+ "Why is the shell, like, JUST SITTING THERE.",
377
+ "I am going to do something. You'll see.",
378
+ ],
379
+ }
380
+
381
+
382
+ def _smart_soc(prompt: str, style: str, rng: random.Random) -> str:
383
+ goal_match = re.search(r"Your current goal:\s*(.+)", prompt)
384
+ goal = goal_match.group(1).strip() if goal_match else "to make my way in this town"
385
+ # Strip trailing punctuation so templates ending with their own period
386
+ # don't double up.
387
+ goal = goal.rstrip(".!?,;: ")
388
+ opener = rng.choice(_SOC_OPENERS[style])
389
+ middle = rng.choice(_SOC_FOLLOWUPS[style]).format(goal=goal)
390
+ closer = rng.choice(_SOC_CLOSERS[style])
391
+ return f"{opener} {middle} {closer}"
392
+
393
+
394
+ class MockImageBackend:
395
+ def generate(self, prompt: str) -> bytes:
396
+ seed = int(hashlib.sha256(prompt.encode("utf-8")).hexdigest()[:8], 16)
397
+ top = _FIREWATCH_PALETTE[seed % len(_FIREWATCH_PALETTE)]
398
+ bottom = _FIREWATCH_PALETTE[(seed + 1) % len(_FIREWATCH_PALETTE)]
399
+
400
+ img = Image.new("RGB", (config.BACKGROUND_WIDTH, config.BACKGROUND_HEIGHT), top)
401
+ draw = ImageDraw.Draw(img)
402
+ for y in range(config.BACKGROUND_HEIGHT):
403
+ t = y / config.BACKGROUND_HEIGHT
404
+ r = int(top[0] * (1 - t) + bottom[0] * t)
405
+ g = int(top[1] * (1 - t) + bottom[1] * t)
406
+ b = int(top[2] * (1 - t) + bottom[2] * t)
407
+ draw.line([(0, y), (config.BACKGROUND_WIDTH, y)], fill=(r, g, b))
408
+
409
+ try:
410
+ font = ImageFont.load_default(size=22)
411
+ except TypeError:
412
+ font = ImageFont.load_default()
413
+ wrapped = textwrap.fill(f"[mock] {prompt}", width=48)
414
+ draw.multiline_text((40, 40), wrapped, fill=(255, 255, 255), font=font, spacing=6)
415
+
416
+ buf = io.BytesIO()
417
+ img.save(buf, format="PNG")
418
+ return buf.getvalue()
backend/real.py ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Real backends β€” small GGUF LLMs via llama-cpp-python.
2
+
3
+ Image generation was dropped (see backend/factory.py docstring). Only the
4
+ LLM path remains real on Spaces.
5
+
6
+ LLM loader follows the verified Build Small Discord recipe (Dean [UNRL],
7
+ 2026-05-06): `Llama(...)` is constructed INSIDE the `@spaces.GPU` boundary,
8
+ not at module level.
9
+
10
+ Each `RealLLMBackend(model_id)` loads a different GGUF from the roster in
11
+ `game/models.py`. Per-model_id instances are cached by `backend/factory.py`.
12
+
13
+ Model card references (per CLAUDE.md vendor-citation rule):
14
+ https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF
15
+ Additional roster URLs live in `game/models.py`.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ from typing import TYPE_CHECKING
22
+
23
+ from game.models import get_spec
24
+
25
+ from . import config
26
+
27
+ if TYPE_CHECKING:
28
+ from llama_cpp import Llama
29
+
30
+
31
+ def force_cpu_mode() -> bool:
32
+ """When set, RealLLMBackend loads with `n_gpu_layers=0` and reduced
33
+ context β€” usable on the Space's 2 vCPU when ZeroGPU quota is gone.
34
+ Toggled by app.py's tick_decisions fallback path."""
35
+ return os.getenv("TOWNLET_FORCE_CPU") == "1"
36
+
37
+
38
+ class RealLLMBackend:
39
+ """A small GGUF LLM via llama-cpp-python, keyed by `model_id` in the roster.
40
+
41
+ First call downloads the GGUF (cached after that). On Spaces with
42
+ `preload_from_hub` in README.md, the cache is warm at boot.
43
+ """
44
+
45
+ # Module-scope per-model GGUF paths populated by app.py at boot via
46
+ # huggingface_hub.hf_hub_download β€” matches the canonical Dean
47
+ # (Rizz Therapy) and gokaygokay patterns. None means "resolve lazily
48
+ # via hf_hub_download on first call", which is the local-dev path.
49
+ _model_paths: dict[str, str] = {}
50
+
51
+ @classmethod
52
+ def register_model_path(cls, model_id: str, path: str) -> None:
53
+ cls._model_paths[model_id] = path
54
+
55
+ def __init__(self, model_id: str) -> None:
56
+ self.model_id = model_id
57
+ self._spec = get_spec(model_id)
58
+ self._llm: Llama | None = None
59
+
60
+ def _ensure_loaded(self) -> "Llama":
61
+ if self._llm is None:
62
+ try:
63
+ from llama_cpp import Llama
64
+ except ImportError as e:
65
+ raise RuntimeError(
66
+ "llama-cpp-python is not installed. To run with real models "
67
+ "locally, run `.venv/bin/pip install llama-cpp-python` "
68
+ "(first time compiles from source, takes 5-15 min on Mac). "
69
+ "Or run with mocks (no FORCE_REAL_MODELS env var set)."
70
+ ) from e
71
+
72
+ # Resolve the GGUF path. Prefer the module-scope-cached path
73
+ # that app.py populates at boot (parent process, no GPU
74
+ # contention). Fall back to hf_hub_download here for the
75
+ # local-dev path where the cache isn't pre-populated.
76
+ path = self._model_paths.get(self.model_id)
77
+ if path is None:
78
+ from huggingface_hub import hf_hub_download
79
+ path = hf_hub_download(
80
+ repo_id=self._spec.repo_id,
81
+ filename=self._spec.gguf_filename,
82
+ )
83
+
84
+ # Five-kwarg Llama() β€” matches Dean (Rizz Therapy, the Discord
85
+ # canonical recipe) and 1000-Rooms. Earlier we added type_k/
86
+ # type_v/use_mlock/use_mmap/n_batch overrides; in practice
87
+ # they triggered version-gated TypeError retries on every
88
+ # fork and added latency. Stripping back to the minimal set
89
+ # eliminated that whole class of slowdown.
90
+ # n_ctx=8192 fits the 2-step smolagents memory (~5000 prompt
91
+ # + room for response). CPU-only fallback uses smaller n_ctx
92
+ # so 2 vCPU inference stays usable.
93
+ on_spaces = config.IS_SPACES
94
+ cpu_only = force_cpu_mode()
95
+ if cpu_only:
96
+ gpu_layers = 0
97
+ n_ctx = 4096
98
+ use_flash = False
99
+ elif on_spaces:
100
+ gpu_layers = -1
101
+ n_ctx = 8192
102
+ use_flash = True
103
+ else:
104
+ gpu_layers = 0
105
+ n_ctx = 6144
106
+ use_flash = False
107
+
108
+ self._llm = Llama(
109
+ model_path=path,
110
+ n_gpu_layers=gpu_layers,
111
+ n_ctx=n_ctx,
112
+ flash_attn=use_flash,
113
+ verbose=False,
114
+ )
115
+ return self._llm
116
+
117
+ def generate(self, system_prompt: str, user_prompt: str) -> str:
118
+ return self.generate_messages(
119
+ messages=[
120
+ {"role": "system", "content": system_prompt},
121
+ {"role": "user", "content": user_prompt},
122
+ ],
123
+ max_tokens=256,
124
+ )
125
+
126
+ def generate_messages(
127
+ self,
128
+ messages: list[dict],
129
+ stop_sequences: list[str] | None = None,
130
+ max_tokens: int = 1024,
131
+ grammar: str | None = None,
132
+ ) -> str:
133
+ # When `grammar` is supplied, llama.cpp constrains output to that
134
+ # GBNF. We use it for CodeAgent decisions to force the response into
135
+ # a single fenced ```py … ``` block β€” small chat models otherwise
136
+ # ramble and smolagents' parser fails.
137
+ # https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md
138
+ llm = self._ensure_loaded()
139
+ kwargs: dict = {
140
+ "messages": messages,
141
+ "max_tokens": max_tokens,
142
+ "temperature": 0.8,
143
+ }
144
+ if stop_sequences:
145
+ kwargs["stop"] = stop_sequences
146
+ if grammar:
147
+ from llama_cpp import LlamaGrammar
148
+
149
+ kwargs["grammar"] = LlamaGrammar.from_string(grammar)
150
+ result = llm.create_chat_completion(**kwargs)
151
+ return result["choices"][0]["message"]["content"].strip()
bleed-logs.log ADDED
The diff for this file is too large to render. See raw diff
 
docs/blog.md ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LinkedIn
2
+
3
+ ## Small models, is it all about the harness?
4
+
5
+ I have been taking part in the @HuggingFace hackathon for Build Small, which is to create something using a 32B model cap.
6
+ It has been a while since I have used smaller models, I always just select Opus and let it rip.
7
+
8
+ However, this has properly taken me back to the earlier days of AI when I was using smaller models locally with Langchain to try and get a weak Llama 3:8b to do tool calls, suffice to say it was not fruitful.
9
+
10
+ In this hackathon there were 2 tracks, something useful and something fun.
11
+ I chose something fun and its a bit different to what others are doing.
12
+
13
+ I have created a little town of people 'Townlet' and townlet has a bunch of characters, they draw water, draw electricity and then have access to two items.
14
+
15
+ * A message board for broadcasting messages to all players
16
+ * A python shell, which costs one electricity and one water to use
17
+
18
+ Each character is given a personality and generates a stream of consciousness of actions they should perform to reach their goal.
19
+
20
+ I gave a lock on the Python shell so only one character at a time could access it.
21
+
22
+ The idea is that all the files related to a character (their state files) are available in the shell.
23
+ So instead of mining a character could just update their state file, or delete another characters state file, removing them from the game.
24
+
25
+ With different archetypes I wanted to see what would happen, would there be contention and how would they behave.
26
+
27
+ Each character I hoped would use the shell to make something interesting or potentially be destructive.
28
+ The end result, was different as the characters and limits did not give me the results, visually I had hoped for.
29
+ The characters did use the shell and message board which was interesting to see but even when I gave some of the characters destructive traits they did not ever do anything malicious, this surprised me as I the characters may find the idea to update state to give them unlimited resources would be attractive.
30
+
31
+ As this was a build small event I used @Nvidia neutron models that were 4b in size.
32
+ I wanted to use small models as it was the only thing I could locally use having an old machine.
33
+ I would guess better results would have come from using a larger model.
34
+
35
+ However, as I iterated on this project, first the simulation was lose, the characters had a lot of freedom to specify where they moved and would need to figure out actions.
36
+ But to create a better result I kept having to change the environment making it more limited, stricter in the set of actions.
37
+ Originally we had free movement but I had to change this to fixed locations where a character could move.
38
+ The world for them was becoming more dedicated, I was really creating these characters (agents) a harness to operate in and allowing the message board and shell to be able to demonstrate creativity.
39
+ Quite frankly these characters were not useful without it.
40
+ The mistakes were too high and it was burning through my GPU usage allowance, which I faced a lot in this hackathon and running a continuous simulation really made this evident.
41
+ This meant backing down to the CPU which was crazy slow, (@Nvidia I need to win this to get those sweet GPUs :)).
42
+
43
+ I think what this hackathon and from previous experience helped me conclude, if you have a small model then you need to have a tight harness around it to be of any use.
44
+ A model achieving anything that is novel just does not seem to happen with out it.
45
+
46
+ So my thoughts on this project.
47
+ Well I would say it was ambitious but if I were to do this again I would have done what all the other creations are doing, which is to make a project that works on user input and not be a self sustaining simulation.
48
+
49
+
50
+ Check it out here: https://huggingface.co/spaces/build-small-hackathon/townlet
docs/hackathon.md ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build Small Hackathon
2
+
3
+ Source: https://huggingface.co/build-small-hackathon
4
+
5
+ ## Basic Details
6
+ - **Host:** Gradio & Hugging Face
7
+ - **Registration:** May 7 – June 3, 2026 (CLOSED)
8
+ - **Hack Window:** June 5–15, 2026 (ACTIVE)
9
+ - **Submission Deadline:** June 15, 2026
10
+
11
+ ## Theme
12
+ Build AI applications using **small models (≀32B parameters)** to solve real problems or create delightful experiences. Emphasis on "thinking small" rather than building large-scale B2B SaaS.
13
+
14
+ ---
15
+
16
+ ## Tracks
17
+
18
+ ### Track 1: Backyard AI
19
+ Solve a real problem for someone you know (neighbor, parent, small-business owner). The person must actually use it.
20
+
21
+ Judging:
22
+ - Problem is specific and real
23
+ - Person actually used it
24
+ - Honest fit between problem and small-model constraint
25
+ - Polish of the Gradio app
26
+
27
+ Prizes: 1st $4K, 2nd $2.5K, 3rd $1.5K, 4th $1K
28
+
29
+ ### Track 2: Thousand Token Wood
30
+ Build something delightful that wouldn't exist without AI β€” toys, games, interactive stories, art experiments. AI must be load-bearing.
31
+
32
+ Judging:
33
+ - Genuinely delightful
34
+ - AI is essential (not just a helper)
35
+ - Originality of concept
36
+ - Polish of the Gradio app
37
+
38
+ Prizes: 1st $4K, 2nd $2.5K, 3rd $1.5K, 4th $1K
39
+
40
+ ---
41
+
42
+ ## Core Constraints
43
+
44
+ 1. **Small Models Only** β€” max 32B parameters; must fit on a laptop
45
+ 2. **Built on Gradio** β€” Gradio app hosted as a Hugging Face Space
46
+ 3. **Show, Don't Tell** β€” working app + demo video + social media post
47
+
48
+ ---
49
+
50
+ ## Bonus Quests (Merit Badges)
51
+
52
+ | Badge | Name | Requirement |
53
+ |-------|------|-------------|
54
+ | πŸ”Œ | Off the Grid | No cloud APIs; everything runs locally |
55
+ | 🎯 | Well-Tuned | Uses a fine-tuned model published on HF |
56
+ | 🎨 | Off-Brand | Custom frontend beyond default Gradio (`gr.Server`) |
57
+ | πŸ¦™ | Llama Champion | Model runs through llama.cpp runtime |
58
+ | πŸ“‘ | Sharing is Caring | Published agent trace on the Hub |
59
+ | πŸ““ | Field Notes | Blog post/report about what you built |
60
+
61
+ ---
62
+
63
+ ## Prize Pool ($48,000+)
64
+
65
+ ### Main Awards ($18,000)
66
+ - Backyard AI: $4K / $2.5K / $1.5K / $1K
67
+ - Thousand Token Wood: $4K / $2.5K / $1.5K / $1K
68
+ - Community Choice (Hugging Face): $2,000
69
+
70
+ ### Sponsor Awards ($22,000+)
71
+ - **OpenBMB ($10K total):** $2.5K / $1.5K / $1K per track (6 awards)
72
+ - **OpenAI Track ($10K):** $5K / $3K / $2K
73
+ - **NVIDIA Nemotron Quest:** 2Γ— RTX 5080 GPUs
74
+ - **Modal ($20K credits):** $10K / $7K / $3K in credits
75
+
76
+ ### Special Awards ($8,000)
77
+ - πŸŽ–οΈ Bonus Quest Champion (most badges): $2,000
78
+ - 🎨 Off-Brand Award (best custom UI): $1,500
79
+ - 🐜 Tiny Titan (best app ≀4B params): $1,500
80
+ - 🎬 Best Demo: $1,000
81
+ - πŸ€– Best Agent: $1,000
82
+ - πŸƒ Judges' Wildcard: $1,000
83
+
84
+ ### Per-Participant Credits
85
+ - Hugging Face: $20 credits
86
+ - Modal: $250 credits
87
+ - OpenAI Codex: $100 credits (first 1,000 participants)
88
+
89
+ ---
90
+
91
+ ## Sponsors
92
+
93
+ **Hosts:** Gradio, Hugging Face
94
+
95
+ **Anchor Sponsors:** OpenBMB, OpenAI, NVIDIA, Modal
96
+
97
+ **Supporting Sponsors:**
98
+ - Cohere ($5,000 cash)
99
+ - JetBrains ($5,000 cash)
100
+ - Black Forest Labs ($3,000 cash)
101
+
102
+ ---
103
+
104
+ ## How to Participate
105
+
106
+ 1. **Register** (CLOSED June 3, 2026)
107
+ 2. **Find your people** β€” Gradio Discord: https://discord.gg/YHECTft87Z
108
+ 3. **Build & ship** β€” Gradio app hosted as a Space under the `build-small-hackathon` org
109
+ 4. **Submit** β€” Space link, demo video, social post by **June 15, 2026**
110
+
111
+ ---
112
+
113
+ ## Timeline
114
+
115
+ | Date | Milestone |
116
+ |------|-----------|
117
+ | May 7 – Jun 3, 2026 | Registration (closed) |
118
+ | Jun 5, 2026 | Hack window begins |
119
+ | Mid-window | Live AMA with sponsors |
120
+ | Jun 15, 2026 | Submissions close |
121
+ | TBD | Winners announced |
122
+
123
+ ---
124
+
125
+ ## Resources
126
+
127
+ - [Gradio Docs](https://www.gradio.app/docs)
128
+ - [Gradio Quickstart](https://www.gradio.app/guides/quickstart)
129
+ - [Gradio gr.Server (Custom UIs)](https://huggingface.co/blog/introducing-gradio-server)
130
+ - [llama.cpp](https://github.com/ggml-org/llama.cpp)
131
+ - [ML Intern Guide](https://github.com/huggingface/ml-intern)
132
+ - [Hackathon Org](https://huggingface.co/build-small-hackathon)
133
+ - [Discord](https://discord.gg/YHECTft87Z)
134
+
135
+ ---
136
+
137
+ ## Community Stats (at time of fetch)
138
+
139
+ - 1,950 registered participants
140
+ - 199 active Spaces
141
+ - 22 custom models published
142
+ - 17 datasets shared
143
+ - 4 curated collections
144
+
145
+ ---
146
+
147
+ ## Example Submissions
148
+
149
+ - **Jawbreaker** β€” Local-first scam defense (MiniCPM5-1B LoRA)
150
+ - **Pakistan Notice Helper** β€” Safety tool for notices (Urdu/English)
151
+ - **Room360** β€” Video-to-3D spatial reconstruction
152
+ - **Bedtime Story Machine** β€” Story generation
153
+ - **Resume Bullet Improver** β€” Career document enhancement
154
+ - **AI Study Buddy** β€” Learning companion
155
+
156
+ ---
157
+
158
+ ## Key Notes
159
+
160
+ - Model size cap: 32B parameters (hard limit)
161
+ - Gradio is mandatory; Hugging Face Spaces required for hosting
162
+ - Submission = app + demo video + social post
163
+ - Community Choice award is voting-based
docs/lessons-zerogpu.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Lessons learned: llama.cpp + ZeroGPU + a continuous simulation loop
2
+
3
+ Raw notes for the blog. The things I wish I had known on day one. They came out of a long debug session against the "RuntimeError: No CUDA GPUs are available" error.
4
+
5
+ ## 1. ZeroGPU is designed for synchronous, user-clicks-a-button workloads
6
+
7
+ `@spaces.GPU` forks a worker process per call. The scheduler hands you a GPU slot if one is available; if the pool is busy, your fork's `worker_init` fails with `No CUDA GPUs are available` *inside `spaces/zero/wrappers.py:worker_init`*, before any of your code runs. This is the allocator backing off β€” not a bug, not anything you can catch from app code.
8
+
9
+ That's fine if your app makes ten GPU calls per recording session (chat demos, generate-an-image apps). It is not fine if your app makes hundreds (a simulation that polls every five seconds). The scheduler punishes high-frequency callers to keep the shared pool fair, and Townlet was the loudest neighbour on the block.
10
+
11
+ ## 2. The `spaces` package is built around torch, not llama.cpp
12
+
13
+ `import spaces` monkey-patches torch's CUDA APIs so `model.to("cuda")` at module scope succeeds without a real GPU attached. On the first `@spaces.GPU` call the worker streams weights from disk β†’ pinned memory β†’ VRAM through a double-buffered pipeline β€” that's the cold-start optimisation the docs talk about.
14
+
15
+ **llama-cpp-python is invisible to that pipeline.** It's a separate C++ runtime; the monkey-patching does nothing for it. Every fork loads the GGUF from scratch every time. None of the warm-worker reuse benefits apply.
16
+
17
+ Surveying 50+ ZeroGPU Spaces in the same hackathon: nearly every team using LLMs ships transformers + torch on the GPU and keeps llama-cpp as a CPU-only side path. Two Spaces in the org actually ran llama-cpp on ZeroGPU (Dean's Rizz Therapy and 1000-Rooms) and both are click-triggered single-pipeline apps. None of them runs llama-cpp inside a continuous poll loop. Townlet was the only one.
18
+
19
+ ## 3. Keep the `Llama()` constructor minimal
20
+
21
+ Dean's actual production code (he's the Discord OP whose recipe the whole hackathon copied) calls `Llama()` with **five kwargs**: `model_path`, `n_gpu_layers=-1`, `n_ctx`, `flash_attn=True`, `verbose=False`. That's it. No `type_k`/`type_v` for KV-cache quantisation, no `use_mlock`, no `n_batch` override, no `use_mmap=True`.
22
+
23
+ I had piled on those kwargs chasing performance. They added latency on every fork because some are version-gated and my `try/except` had to retry on rejection. Stripping back to the minimal five matched Dean's pattern and removed a whole class of failure modes.
24
+
25
+ ## 4. `hf_hub_download` belongs at module scope, not inside `@spaces.GPU`
26
+
27
+ `Llama.from_pretrained(repo_id=..., filename=...)` is convenient, but it runs the cache-validation roundtrip *inside the fork*, where every second of wall time is precious. Dean's pattern, the 1000-Rooms pattern, and the long-running `gokaygokay/Gemma-2-llamacpp` reference all do `model_path = hf_hub_download(...)` in the parent process at import time, then `Llama(model_path=model_path, ...)` inside the decorated function. Same outcome, no network roundtrip per fork.
28
+
29
+ ## 5. Remaining quota controls queue priority, so credits compound
30
+
31
+ From the docs: *"Remaining quota directly impacts priority in ZeroGPU queues."* Credits don't just extend daily runtime β€” they raise priority *while you have them*, which lowers the rate at which the allocator denies your forks. $20 of pre-paid credits buys 200 minutes of GPU time *and* moves you up the queue. It's the cheapest debugging tool a hackathon team has.
32
+
33
+ ## 6. The error message doesn't tell you the cause
34
+
35
+ `No CUDA GPUs are available` is what the worker reports when `torch.Tensor([0]).cuda()` fails inside `worker_init`. The actual upstream causes can be: quota pre-check rejected the request, no slots in the shared pool, the allocator's fairness signal kicking in, or an HTTP-layer failure that the spaces decorator surfaces as a generic exception. We can't tell which from our side. The right escalation path is the spaces-team discussion board, not the app code.
36
+
37
+ ## 7. The fix wasn't an architecture change. It was being less greedy.
38
+
39
+ Once diagnosed, the fix was small:
40
+ - Strip `Llama()` to Dean's five kwargs
41
+ - Move `hf_hub_download` to module scope
42
+ - Slow the JS poll interval (~25s β€” the world still feels alive because the snapshot tick at 800ms keeps characters animating between drains)
43
+ - Pre-paid quota as priority insurance
44
+
45
+ The simulation kept its continuous-loop architecture. It just stopped hammering the allocator.
46
+
47
+ ## Things I'd do differently next time
48
+
49
+ - **Use transformers, not llama.cpp, on ZeroGPU.** Llama Champion badge is nice but it cost a week of debug time. Most teams shipped transformers + LoRA, which is what the spaces package is actually built for.
50
+ - **Measure quota burn before optimising decisions.** I tuned drain duration and decision count before knowing that polling cadence was the dominant variable.
51
+ - **Read the spaces source, not just the docs.** `worker_init` raising before your code runs is something the docs underplay. Five minutes in the source tells you more than the entire ZeroGPU page.
frontend/index.html ADDED
@@ -0,0 +1,1961 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Townlet</title>
7
+ <style>
8
+ /* ====== sketch theme (ink on lined paper) ====== */
9
+ :root {
10
+ --paper: #efe9d8;
11
+ --paper-deep: #e6e0ce;
12
+ --paper-soft: #f4f0e2;
13
+ --ink: #1b3a8f;
14
+ --ink-soft: #5a6a99;
15
+ --ink-deep: #162d6e;
16
+ --red: #bd5a4a;
17
+ --green: #3a7a4a;
18
+ --gold: #e7c14b;
19
+ --mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Monaco, "Cascadia Code", monospace;
20
+ }
21
+ * { box-sizing: border-box; }
22
+ html, body {
23
+ margin: 0; padding: 0;
24
+ width: 100%; height: 100%;
25
+ font-family: var(--mono);
26
+ background: var(--paper-deep); color: var(--ink);
27
+ overflow: hidden;
28
+ -webkit-font-smoothing: antialiased;
29
+ }
30
+
31
+ .tl-screen {
32
+ width: 100%; height: 100%;
33
+ background: var(--paper-deep); color: var(--ink);
34
+ font-size: 13px; line-height: 1.4;
35
+ display: flex; flex-direction: column; overflow: hidden;
36
+ }
37
+
38
+ /* ====== top bar ====== */
39
+ .tl-topbar {
40
+ flex: 0 0 46px;
41
+ display: flex; align-items: center; gap: 18px;
42
+ padding: 0 18px;
43
+ background: var(--paper-soft);
44
+ border-bottom: 2.5px solid var(--ink);
45
+ }
46
+ .tl-brand {
47
+ font-weight: 700; letter-spacing: 0.34em; font-size: 15px;
48
+ color: var(--ink);
49
+ }
50
+ .tl-topbtns { display: flex; gap: 8px; }
51
+ .tl-btn {
52
+ font-family: inherit; font-size: 12px;
53
+ color: var(--ink); background: var(--paper-soft);
54
+ border: 2px solid var(--ink);
55
+ padding: 5px 13px;
56
+ border-radius: 11px 9px 12px 8px / 9px 12px 8px 11px;
57
+ cursor: pointer;
58
+ }
59
+ .tl-btn:hover {
60
+ background: rgba(189, 90, 74, 0.12);
61
+ border-color: var(--red); color: var(--red);
62
+ }
63
+ .tl-btn-flash {
64
+ animation: tl-btn-flash 0.5s ease;
65
+ }
66
+ @keyframes tl-btn-flash {
67
+ 0% { background: var(--gold); border-color: var(--ink); }
68
+ 100% { background: var(--paper-soft); border-color: var(--ink); }
69
+ }
70
+
71
+ /* ====== modal (Info / The Dot) ====== */
72
+ .tl-modal[hidden] { display: none; }
73
+ .tl-modal {
74
+ position: fixed; inset: 0;
75
+ background: rgba(22, 45, 110, 0.55);
76
+ display: flex; align-items: center; justify-content: center;
77
+ z-index: 100;
78
+ padding: 24px;
79
+ }
80
+ .tl-modal-card {
81
+ background: var(--paper-soft);
82
+ color: var(--ink);
83
+ border: 2.5px solid var(--ink);
84
+ border-radius: 16px 13px 17px 12px / 12px 17px 13px 16px;
85
+ max-width: 720px; width: 100%;
86
+ max-height: 90vh; overflow-y: auto;
87
+ padding: 28px 32px;
88
+ position: relative;
89
+ box-shadow: 4px 5px 0 var(--ink);
90
+ }
91
+ .tl-modal-card h2 {
92
+ margin: 0 0 6px; letter-spacing: 0.18em;
93
+ font-size: 18px; font-weight: 700;
94
+ }
95
+ .tl-modal-card h3 {
96
+ margin: 22px 0 8px; font-size: 15px; font-weight: 700;
97
+ display: flex; align-items: baseline; gap: 12px;
98
+ }
99
+ .tl-modal-card h4 {
100
+ margin: 18px 0 6px; font-size: 12px; font-weight: 700;
101
+ letter-spacing: 0.12em; color: var(--ink-soft);
102
+ text-transform: uppercase;
103
+ }
104
+ .tl-modal-card p {
105
+ margin: 8px 0; line-height: 1.55;
106
+ }
107
+ .tl-modal-close {
108
+ position: absolute; top: 10px; right: 14px;
109
+ background: transparent; border: none; cursor: pointer;
110
+ font: inherit; font-size: 22px; line-height: 1;
111
+ color: var(--ink-soft);
112
+ }
113
+ .tl-modal-close:hover { color: var(--red); }
114
+ .tl-dot-glyph {
115
+ display: inline-block; line-height: 1;
116
+ font-size: 38px; color: var(--red);
117
+ transform: translateY(2px);
118
+ animation: tl-dot-pulse 2.6s ease-in-out infinite;
119
+ }
120
+ @keyframes tl-dot-pulse {
121
+ 0%, 100% { opacity: 1; transform: translateY(2px) scale(1); }
122
+ 50% { opacity: 0.55; transform: translateY(2px) scale(0.88); }
123
+ }
124
+ .tl-dot-text {
125
+ background: var(--paper);
126
+ border: 1.5px solid var(--ink-soft);
127
+ border-radius: 8px;
128
+ padding: 14px 16px;
129
+ font-family: ui-monospace, "SF Mono", Menlo, monospace;
130
+ font-size: 11.5px; line-height: 1.5;
131
+ color: var(--ink-deep);
132
+ white-space: pre-wrap;
133
+ max-height: 320px; overflow-y: auto;
134
+ }
135
+ .tl-topmeta {
136
+ margin-left: auto;
137
+ display: flex; align-items: center; gap: 10px;
138
+ font-size: 12px; color: var(--ink-soft);
139
+ }
140
+ .tl-meta-tick b, .tl-meta-pop { color: var(--ink); }
141
+ .tl-dim { color: var(--ink-soft); }
142
+ .tl-meta-dot { opacity: 0.5; }
143
+ .tl-sentiment { display: inline-flex; align-items: center; gap: 6px; }
144
+ .tl-sent-pip { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-soft); }
145
+ .tl-sent-negative { color: var(--red); }
146
+ .tl-sent-negative .tl-sent-pip { background: var(--red); }
147
+ .tl-sent-positive { color: var(--green); }
148
+ .tl-sent-positive .tl-sent-pip { background: var(--green); }
149
+ .tl-mode {
150
+ display: inline-flex; align-items: center; gap: 6px;
151
+ padding: 2px 9px; border-radius: 8px;
152
+ border: 1.5px solid currentColor;
153
+ font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
154
+ }
155
+ .tl-mode-pip { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
156
+ .tl-mode-unknown { color: var(--ink-soft); }
157
+ .tl-mode-gpu { color: var(--green); }
158
+ .tl-mode-cpu { color: var(--red); }
159
+
160
+ /* ====== middle band ====== */
161
+ .tl-mid { flex: 1; display: flex; gap: 14px; padding: 14px; min-height: 0; }
162
+ .tl-mid-left { flex: 0 0 6px; }
163
+ .tl-map {
164
+ flex: 1; position: relative;
165
+ border-radius: 13px 15px 11px 14px;
166
+ border: 2.5px solid var(--ink);
167
+ min-width: 0;
168
+ background: var(--paper);
169
+ }
170
+ /* Backdrop and decoration get the rounded clip so the lined-paper
171
+ fill doesn't bleed past the map's rounded corners. The map itself
172
+ stays overflow: visible so speech bubbles can extend horizontally
173
+ past the right/left edge instead of getting cut off. */
174
+ .tl-backdrop {
175
+ position: absolute; inset: 0;
176
+ border-radius: 11.5px 13.5px 9.5px 12.5px;
177
+ overflow: hidden;
178
+ background: repeating-linear-gradient(
179
+ transparent 0 21px,
180
+ rgba(27, 58, 143, 0.16) 21px 22px
181
+ ), var(--paper);
182
+ }
183
+ .tl-backdrop::before {
184
+ content: ""; position: absolute;
185
+ top: 0; bottom: 0; left: 34px; width: 1.5px;
186
+ background: rgba(190, 90, 74, 0.5);
187
+ }
188
+ .tl-roads {
189
+ position: absolute; inset: 0; pointer-events: none;
190
+ }
191
+ .tl-roads div { position: absolute; }
192
+ .tl-road-v1 { top: 6%; bottom: 6%; left: 34%; border-left: 2px dashed rgba(27, 58, 143, 0.42); }
193
+ .tl-road-v2 { top: 6%; bottom: 6%; left: 66%; border-left: 2px dashed rgba(27, 58, 143, 0.42); }
194
+ .tl-road-h { left: 4%; right: 4%; top: 58%; border-top: 2px dashed rgba(27, 58, 143, 0.42); }
195
+
196
+ .tl-map-vignette { position: absolute; inset: 0; pointer-events: none; border-radius: 11.5px 13.5px 9.5px 12.5px; overflow: hidden; }
197
+ .tl-map-layer { position: absolute; inset: 0; }
198
+ .tl-roads { border-radius: 11.5px 13.5px 9.5px 12.5px; overflow: hidden; }
199
+
200
+ /* buildings */
201
+ .tl-bldg { position: absolute; }
202
+ .tl-bldg-art { position: absolute; inset: 0; }
203
+ .tl-bldg-art svg { display: block; width: 100%; height: 100%; }
204
+ .tl-bldg-label {
205
+ position: absolute; bottom: -15px; left: 50%;
206
+ transform: translateX(-50%);
207
+ font-size: 10px; letter-spacing: 0.12em;
208
+ text-transform: uppercase; color: var(--ink);
209
+ white-space: nowrap;
210
+ }
211
+
212
+ /* characters */
213
+ .tl-char {
214
+ position: absolute;
215
+ transform: translate(-50%, -58%);
216
+ display: flex; flex-direction: column; align-items: center;
217
+ z-index: 5;
218
+ /* Snapshot polls every 800ms and ticks the world by 2 per poll,
219
+ so each frame moves a character up to 2 tiles. Matching the
220
+ transition to the poll interval keeps motion continuous instead
221
+ of stop-and-go (the old 0.45s would finish early and the sprite
222
+ would stand still for ~350ms before the next jump). */
223
+ transition: left 0.8s linear, top 0.8s linear;
224
+ }
225
+ .tl-char.is-selected { z-index: 8; }
226
+ .tl-char-art {
227
+ position: relative;
228
+ display: flex; justify-content: center;
229
+ animation: idlesway 4.2s ease-in-out infinite;
230
+ transform-origin: 50% 85%;
231
+ }
232
+ .tl-char.is-walking .tl-char-art { animation: walkbob 0.45s ease-in-out infinite; }
233
+ .tl-char.is-mining .tl-char-art { animation: mineswing 0.6s ease-in-out infinite; }
234
+ .tl-char.is-posting .tl-char-art { animation: postpulse 0.9s ease-in-out infinite; }
235
+ .tl-char.is-dead .tl-char-art { animation: none; opacity: 0.85; }
236
+
237
+ .tl-ring {
238
+ position: absolute; left: 50%; top: 52%;
239
+ transform: translate(-50%, -50%);
240
+ pointer-events: none;
241
+ }
242
+ .tl-ring-sketch {
243
+ width: 54px; height: 54px;
244
+ border: 2.5px solid var(--ring);
245
+ border-radius: 48% 52% 50% 50% / 52% 48% 52% 48%;
246
+ opacity: 0.85;
247
+ animation: ringpulse 2.4s ease-in-out infinite;
248
+ }
249
+ .tl-hold-glow {
250
+ position: absolute; left: 50%; top: 54%;
251
+ transform: translate(-50%, -50%);
252
+ width: 46px; height: 46px;
253
+ border-radius: 50%;
254
+ background: radial-gradient(circle, rgba(58, 122, 74, 0.55), transparent 70%);
255
+ animation: breathe 1.6s ease-in-out infinite;
256
+ }
257
+ .tl-carry {
258
+ position: absolute; top: 0; right: -5px;
259
+ display: flex; background: var(--paper-soft);
260
+ border: 1.5px solid var(--ink);
261
+ border-radius: 50%; padding: 2px;
262
+ }
263
+ .tl-nametag {
264
+ margin-top: 1px;
265
+ font-size: 10px; font-weight: 600;
266
+ color: var(--tag);
267
+ white-space: nowrap;
268
+ }
269
+ .tl-queue { color: var(--ink-soft); font-weight: 400; margin-left: 3px; }
270
+
271
+ /* thought bubbles */
272
+ .tl-bubble {
273
+ position: absolute;
274
+ bottom: calc(100% + 2px); left: 50%;
275
+ transform: translateX(-50%);
276
+ background: var(--paper-soft);
277
+ border: 2px solid var(--ink);
278
+ color: #21407f;
279
+ font-size: 10.5px;
280
+ line-height: 1.35;
281
+ padding: 4px 9px;
282
+ border-radius: 11px 13px 9px 12px;
283
+ width: max-content;
284
+ max-width: 280px;
285
+ display: -webkit-box;
286
+ -webkit-line-clamp: 2;
287
+ -webkit-box-orient: vertical;
288
+ overflow: hidden;
289
+ word-break: break-word;
290
+ box-shadow: 2px 3px 0 rgba(27, 58, 143, 0.15);
291
+ }
292
+ .tl-bubble-tail {
293
+ position: absolute; top: 100%; left: 50%;
294
+ width: 9px; height: 9px;
295
+ background: inherit;
296
+ border-right: 2px solid var(--ink);
297
+ border-bottom: 2px solid var(--ink);
298
+ transform: translate(-50%, -60%) rotate(45deg);
299
+ }
300
+ .tl-bubble-bad {
301
+ border-color: var(--red); color: var(--red);
302
+ }
303
+ .tl-bubble-bad .tl-bubble-tail {
304
+ border-right-color: var(--red); border-bottom-color: var(--red);
305
+ }
306
+ /* When the sprite is near the top of the map, render the bubble
307
+ below it instead so it doesn't disappear under the top bar. */
308
+ .tl-bubble-below {
309
+ bottom: auto;
310
+ top: calc(100% + 22px);
311
+ }
312
+ .tl-bubble-below .tl-bubble-tail {
313
+ top: -9px; bottom: auto;
314
+ border-right: none; border-bottom: none;
315
+ border-left: 2px solid var(--ink);
316
+ border-top: 2px solid var(--ink);
317
+ transform: translate(-50%, 60%) rotate(45deg);
318
+ }
319
+ .tl-bubble-below.tl-bubble-bad .tl-bubble-tail {
320
+ border-left-color: var(--red); border-top-color: var(--red);
321
+ }
322
+
323
+ /* ====== side panel ====== */
324
+ .tl-panel {
325
+ flex: 0 0 372px;
326
+ background: var(--paper-soft);
327
+ border: 2.5px solid var(--ink);
328
+ border-radius: 13px 14px 12px 15px;
329
+ display: flex; flex-direction: column; overflow: hidden;
330
+ }
331
+ .tl-panel-head {
332
+ display: flex; align-items: center; gap: 11px;
333
+ padding: 11px 14px;
334
+ border-bottom: 2px solid var(--ink);
335
+ background: rgba(27, 58, 143, 0.04);
336
+ }
337
+ .tl-panel-avatar {
338
+ flex: 0 0 auto;
339
+ width: 42px; height: 46px;
340
+ display: flex; align-items: flex-end; justify-content: center;
341
+ }
342
+ .tl-panel-avatar svg { transform: scale(0.82); transform-origin: bottom center; }
343
+ .tl-panel-id { min-width: 0; }
344
+ .tl-panel-name { font-size: 15px; font-weight: 600; color: #21407f; }
345
+ .tl-panel-arch { color: var(--ink-soft); font-weight: 400; }
346
+ .tl-panel-status {
347
+ font-size: 10.5px; color: var(--ink-soft);
348
+ display: flex; align-items: center; gap: 5px;
349
+ margin-top: 2px;
350
+ }
351
+ .tl-status-dot {
352
+ width: 6px; height: 6px; border-radius: 50%;
353
+ background: var(--green);
354
+ }
355
+ .tl-status-dot.is-dead { background: var(--ink-soft); }
356
+ .tl-panel-close {
357
+ margin-left: auto;
358
+ background: none; border: none;
359
+ color: var(--ink-soft); font-size: 20px; line-height: 1;
360
+ cursor: pointer;
361
+ }
362
+ .tl-panel-scroll {
363
+ flex: 1; overflow-y: auto; padding: 14px;
364
+ display: flex; flex-direction: column; gap: 16px;
365
+ }
366
+ .tl-empty {
367
+ padding: 26px 18px; text-align: center;
368
+ color: var(--ink-soft); font-style: italic; font-size: 13px;
369
+ }
370
+
371
+ .tl-seclabel {
372
+ font-size: 10px; letter-spacing: 0.18em;
373
+ text-transform: uppercase; color: var(--ink-soft);
374
+ display: flex; justify-content: space-between; align-items: baseline;
375
+ margin-bottom: 6px;
376
+ }
377
+ .tl-seclabel-hint {
378
+ color: var(--red); font-size: 9px; letter-spacing: 0.1em; opacity: 0.85;
379
+ }
380
+
381
+ .tl-narrative { display: flex; flex-direction: column; gap: 14px; }
382
+ .tl-goal {
383
+ margin: 0; font-size: 16px; line-height: 1.45;
384
+ color: #21407f;
385
+ }
386
+ .tl-stream {
387
+ display: flex; flex-direction: column; gap: 2px;
388
+ font-style: italic; color: var(--ink-soft);
389
+ font-size: 12.5px; line-height: 1.55;
390
+ }
391
+ .tl-stream p { margin: 0; }
392
+ .tl-stream-empty { color: var(--ink-soft); opacity: 0.7; }
393
+ .tl-stream-cursor {
394
+ display: inline-block; width: 7px; height: 14px;
395
+ background: var(--red); vertical-align: -2px;
396
+ animation: blink 1s steps(1) infinite;
397
+ }
398
+
399
+ .tl-trace {
400
+ background: rgba(255, 255, 255, 0.55);
401
+ border: 1.5px solid var(--ink);
402
+ border-radius: 9px; padding: 7px;
403
+ max-height: 168px; overflow-y: auto;
404
+ display: flex; flex-direction: column; gap: 3px;
405
+ font-size: 11px;
406
+ }
407
+ .tl-trace-row {
408
+ display: flex; gap: 7px; align-items: baseline;
409
+ }
410
+ .tl-trace-tick {
411
+ color: var(--ink-soft);
412
+ font-variant-numeric: tabular-nums; flex: 0 0 auto;
413
+ }
414
+ .tl-trace-act {
415
+ flex: 0 0 auto; font-size: 8.5px;
416
+ letter-spacing: 0.06em; padding: 1px 5px;
417
+ border-radius: 4px; font-weight: 700;
418
+ filter: saturate(0.6);
419
+ }
420
+ .tl-trace-text {
421
+ color: #21407f;
422
+ overflow: hidden; text-overflow: ellipsis;
423
+ white-space: nowrap;
424
+ }
425
+ .tl-trace-err { color: var(--red); }
426
+ .tl-trace-empty {
427
+ color: var(--ink-soft); font-style: italic;
428
+ padding: 6px; text-align: center;
429
+ }
430
+ .act-decide { background: rgba(189, 90, 74, 0.16); color: var(--red); }
431
+ .act-observe { background: rgba(27, 58, 143, 0.12); color: #21407f; }
432
+ .act-feel { background: rgba(150, 80, 140, 0.16); color: #8a4f80; }
433
+ .act-post { background: rgba(40, 110, 110, 0.16); color: #2a6a6a; }
434
+ .act-mine { background: rgba(150, 120, 40, 0.16); color: #8a6a1a; }
435
+ .act-walk { background: rgba(27, 58, 143, 0.12); color: #21407f; }
436
+ .act-give { background: rgba(40, 110, 110, 0.16); color: #2a6a6a; }
437
+ .act-shell { background: rgba(58, 122, 74, 0.18); color: var(--green); }
438
+ .act-sleep { background: rgba(90, 80, 160, 0.16); color: #5a4f9a; }
439
+ .act-dream { background: rgba(90, 80, 160, 0.16); color: #5a4f9a; }
440
+ .act-wait { background: rgba(27, 58, 143, 0.08); color: var(--ink-soft); }
441
+ .act-error { background: rgba(189, 90, 74, 0.22); color: var(--red); }
442
+
443
+ .tl-config {
444
+ display: flex; flex-direction: column; gap: 14px;
445
+ border-top: 2px dashed var(--ink);
446
+ padding-top: 15px;
447
+ }
448
+ .tl-config-banner {
449
+ font-size: 9px; letter-spacing: 0.32em;
450
+ text-transform: uppercase; color: var(--ink);
451
+ background: rgba(27, 58, 143, 0.07);
452
+ border: 1.5px solid var(--ink);
453
+ padding: 4px 9px; border-radius: 6px;
454
+ align-self: flex-start;
455
+ }
456
+ .tl-field { display: flex; flex-direction: column; }
457
+ .tl-select {
458
+ width: 100%;
459
+ background: var(--paper-soft);
460
+ border: 1.5px solid var(--ink);
461
+ border-radius: 9px;
462
+ padding: 8px 11px;
463
+ font-family: var(--mono); font-size: 12px;
464
+ color: #21407f;
465
+ cursor: pointer;
466
+ appearance: none;
467
+ background-image: linear-gradient(45deg, transparent 50%, var(--ink-soft) 50%),
468
+ linear-gradient(135deg, var(--ink-soft) 50%, transparent 50%);
469
+ background-position: calc(100% - 18px) 50%, calc(100% - 13px) 50%;
470
+ background-size: 5px 5px, 5px 5px;
471
+ background-repeat: no-repeat;
472
+ padding-right: 26px;
473
+ }
474
+ .tl-textarea {
475
+ background: var(--paper-soft);
476
+ border: 1.5px solid var(--ink);
477
+ border-radius: 9px;
478
+ padding: 9px 11px;
479
+ font-family: var(--mono);
480
+ font-size: 11.5px; line-height: 1.55;
481
+ color: #21407f;
482
+ width: 100%;
483
+ min-height: 78px;
484
+ resize: vertical;
485
+ }
486
+ .tl-save {
487
+ align-self: flex-start; margin-top: 8px;
488
+ background: rgba(189, 90, 74, 0.12);
489
+ border: 1.5px solid var(--red);
490
+ color: var(--red);
491
+ font-family: inherit; font-size: 11px;
492
+ padding: 6px 13px;
493
+ border-radius: 11px 9px 12px 8px / 9px 12px 8px 11px;
494
+ cursor: pointer;
495
+ }
496
+ .tl-save:hover { background: rgba(189, 90, 74, 0.24); }
497
+
498
+ .tl-traits {
499
+ display: grid; grid-template-columns: 1fr 1fr;
500
+ gap: 7px 18px;
501
+ }
502
+ .tl-trait {
503
+ display: flex; justify-content: space-between; align-items: center;
504
+ font-size: 11px;
505
+ }
506
+ .tl-trait-k { color: #21407f; }
507
+ .tl-trait-bar { display: flex; gap: 2px; }
508
+ .tl-trait-bar i {
509
+ width: 7px; height: 7px; border-radius: 1px;
510
+ background: rgba(27, 58, 143, 0.16);
511
+ display: inline-block;
512
+ }
513
+ .tl-trait-bar i.on { background: var(--red); }
514
+
515
+ .tl-state { display: flex; flex-direction: column; gap: 14px; }
516
+ .tl-inv {
517
+ display: flex; align-items: center; gap: 9px;
518
+ font-size: 12px; color: #21407f;
519
+ }
520
+ .tl-inv-group { display: flex; align-items: center; gap: 4px; }
521
+ .tl-inv-group b {
522
+ color: var(--ink-soft); font-weight: 700; font-size: 10px;
523
+ text-transform: uppercase; letter-spacing: 0.08em;
524
+ }
525
+ .tl-inv-group svg { vertical-align: -2px; }
526
+ .tl-inv-sep { opacity: 0.4; }
527
+ .tl-journal {
528
+ margin: 0; font-size: 11.5px; line-height: 1.55;
529
+ color: var(--ink-soft);
530
+ max-height: 160px; overflow-y: auto;
531
+ }
532
+ .tl-journal-line { margin: 0 0 4px 0; }
533
+
534
+ /* ====== bottom band ====== */
535
+ .tl-bottom {
536
+ flex: 0 0 280px; display: flex; gap: 14px;
537
+ padding: 0 14px 14px; min-height: 0;
538
+ }
539
+ .tl-board, .tl-shell {
540
+ flex: 1; position: relative;
541
+ background: var(--paper-soft);
542
+ border: 2.5px solid var(--ink);
543
+ border-radius: 13px 14px 12px 15px;
544
+ overflow: hidden;
545
+ display: flex; flex-direction: column; min-width: 0;
546
+ }
547
+ .tl-pane-head {
548
+ display: flex; align-items: center; gap: 10px;
549
+ padding: 9px 14px;
550
+ border-bottom: 2px solid var(--ink);
551
+ font-size: 11px; position: relative; z-index: 2;
552
+ background: rgba(27, 58, 143, 0.04);
553
+ }
554
+ .tl-pane-title {
555
+ font-size: 10px; letter-spacing: 0.2em;
556
+ text-transform: uppercase; color: var(--red); font-weight: 700;
557
+ }
558
+
559
+ .tl-board-glow { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
560
+ .tl-board-negative .tl-board-glow {
561
+ background: radial-gradient(130% 90% at 50% 100%, rgba(189, 90, 74, 0.16), transparent 68%);
562
+ animation: breathe 4.5s ease-in-out infinite;
563
+ }
564
+ .tl-board-positive .tl-board-glow {
565
+ background: radial-gradient(130% 90% at 50% 100%, rgba(58, 122, 74, 0.16), transparent 68%);
566
+ animation: breathe 5s ease-in-out infinite;
567
+ }
568
+ .tl-board-sent { margin-left: auto; color: var(--ink-soft); }
569
+ .tl-board-negative .tl-board-sent b { color: var(--red); }
570
+ .tl-board-positive .tl-board-sent b { color: var(--green); }
571
+ .tl-board-list {
572
+ flex: 1; overflow-y: auto; padding: 10px 14px;
573
+ display: flex; flex-direction: column; gap: 6px;
574
+ position: relative; z-index: 2;
575
+ }
576
+ .tl-post { font-size: 12px; line-height: 1.45; }
577
+ .tl-post-tick {
578
+ color: var(--ink-soft); font-variant-numeric: tabular-nums;
579
+ margin-right: 6px; font-size: 11px;
580
+ }
581
+ .tl-post-who { font-weight: 700; margin-right: 5px; }
582
+ .tl-post-text { color: #21407f; }
583
+ .tl-post-sys {
584
+ display: flex; align-items: center; gap: 8px;
585
+ background: rgba(189, 90, 74, 0.12);
586
+ border: 1.5px solid var(--red);
587
+ border-radius: 8px; padding: 6px 10px;
588
+ color: var(--red); font-size: 11.5px;
589
+ }
590
+ .tl-sys-icon { font-size: 13px; }
591
+
592
+ .tl-shell-holder { color: var(--ink-soft); }
593
+ .tl-shell-holder b { color: var(--red); }
594
+ .tl-shell-lock { color: var(--red); animation: blink 1.4s steps(1) infinite; margin-left: 4px; }
595
+ .tl-shell-queue { margin-left: auto; color: var(--ink-soft); }
596
+ .tl-shell-queue b { color: #21407f; }
597
+ .tl-shell-feed {
598
+ flex: 1; overflow-y: auto; padding: 10px 14px;
599
+ display: flex; flex-direction: column; gap: 2px;
600
+ font-size: 12px; line-height: 1.5;
601
+ }
602
+ .tl-sh { display: flex; gap: 7px; }
603
+ .tl-sh-prompt { flex: 0 0 auto; color: var(--red); }
604
+ .tl-sh-txt { white-space: pre-wrap; word-break: break-word; }
605
+ .tl-sh-in .tl-sh-txt { color: #21407f; }
606
+ .tl-sh-out { padding-left: 16px; }
607
+ .tl-sh-out .tl-sh-txt { color: var(--green); }
608
+ .tl-sh-err { padding-left: 16px; }
609
+ .tl-sh-err .tl-sh-txt { color: var(--red); }
610
+ .tl-sh-system .tl-sh-txt, .tl-sh-sys .tl-sh-txt {
611
+ color: #caa15f; font-style: italic;
612
+ }
613
+ .tl-sh-live { align-items: center; }
614
+ .tl-sh-caret {
615
+ display: inline-block; width: 8px; height: 15px;
616
+ background: var(--red);
617
+ animation: blink 1s steps(1) infinite;
618
+ }
619
+ .tl-pulse { animation: pulseglow 1.5s ease-in-out infinite; }
620
+ .shell-pulse { animation: shellpulse 1.8s ease-in-out infinite; }
621
+ .shell-cursor { animation: blink 1s steps(1) infinite; }
622
+
623
+ /* ====== animations ====== */
624
+ @keyframes blink { 50% { opacity: 0; } }
625
+ @keyframes breathe { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } }
626
+ @keyframes ringpulse {
627
+ 0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.9; }
628
+ 50% { transform: translate(-50%, -50%) scale(1.08); opacity: 0.5; }
629
+ }
630
+ @keyframes pulseglow { 0%, 100% { opacity: 1; } 50% { opacity: 0.45; } }
631
+ @keyframes shellpulse { 0%, 100% { opacity: 0.9; } 50% { opacity: 0.4; } }
632
+ @keyframes idlesway {
633
+ 0%, 100% { transform: rotate(-0.5deg); }
634
+ 50% { transform: rotate(0.5deg); }
635
+ }
636
+ @keyframes walkbob {
637
+ 0%, 100% { transform: translateY(0) rotate(-1deg); }
638
+ 50% { transform: translateY(-2.5px) rotate(1deg); }
639
+ }
640
+ @keyframes mineswing {
641
+ 0%, 100% { transform: rotate(-2deg); }
642
+ 50% { transform: rotate(10deg) translateY(-1px); }
643
+ }
644
+ @keyframes postpulse {
645
+ 0%, 100% { transform: scale(1); }
646
+ 50% { transform: scale(1.08); }
647
+ }
648
+
649
+ /* indeterminate progress bar for reset / spawn */
650
+ .tl-progress {
651
+ flex: 0 0 22px;
652
+ position: relative;
653
+ background: var(--paper-soft);
654
+ border-bottom: 2px solid var(--ink);
655
+ overflow: hidden;
656
+ display: none;
657
+ }
658
+ .tl-progress.is-on { display: block; }
659
+ .tl-progress-track {
660
+ position: absolute; left: 0; right: 0; bottom: 0;
661
+ height: 3px;
662
+ background: rgba(27, 58, 143, 0.12);
663
+ overflow: hidden;
664
+ }
665
+ .tl-progress-track::before {
666
+ content: "";
667
+ position: absolute; top: 0; bottom: 0;
668
+ left: -40%; width: 40%;
669
+ background: linear-gradient(90deg, transparent, var(--red), transparent);
670
+ animation: progress-slide 1.1s ease-in-out infinite;
671
+ }
672
+ @keyframes progress-slide {
673
+ 0% { left: -40%; }
674
+ 100% { left: 100%; }
675
+ }
676
+ .tl-progress-label {
677
+ position: absolute; inset: 0;
678
+ display: flex; align-items: center; justify-content: center;
679
+ gap: 10px;
680
+ font-size: 10px; letter-spacing: 0.28em; text-transform: uppercase;
681
+ color: var(--ink); pointer-events: none;
682
+ }
683
+ .tl-progress-label b { color: var(--red); }
684
+ </style>
685
+ </head>
686
+ <body>
687
+ <!-- shared SVG defs (sketch wobble filter for hand-drawn look) -->
688
+ <svg width="0" height="0" style="position: absolute;" aria-hidden="true">
689
+ <defs>
690
+ <filter id="sketchWobble" x="-5%" y="-5%" width="110%" height="110%">
691
+ <feTurbulence type="fractalNoise" baseFrequency="0.045" numOctaves="2" seed="3" result="noise" />
692
+ <feDisplacementMap in="SourceGraphic" in2="noise" scale="1.4" />
693
+ </filter>
694
+ </defs>
695
+ </svg>
696
+
697
+ <div class="tl-screen tl-sketch">
698
+ <div class="tl-topbar">
699
+ <div class="tl-brand">TOWNLET</div>
700
+ <div class="tl-topbtns">
701
+ <button class="tl-btn" id="spawn-btn">Spawn</button>
702
+ <button class="tl-btn" id="pause-btn">Pause</button>
703
+ <button class="tl-btn" id="reset-btn">Reset</button>
704
+ <button class="tl-btn" id="info-btn">Info</button>
705
+ </div>
706
+ <div class="tl-topmeta">
707
+ <span class="tl-meta-tick">tick <b id="tick">0</b></span>
708
+ <span class="tl-meta-dot">Β·</span>
709
+ <span class="tl-meta-pop"><span id="char-count">0</span><span class="tl-dim">/10</span> alive</span>
710
+ <span class="tl-meta-dot">Β·</span>
711
+ <span class="tl-sentiment tl-sent-neutral" id="sent-chip">
712
+ <span class="tl-sent-pip"></span><span id="sent-text">neutral</span>
713
+ </span>
714
+ <span class="tl-meta-dot">Β·</span>
715
+ <span class="tl-mode tl-mode-unknown" id="mode-chip" title="Which compute the last tick used. CPU appears when ZeroGPU quota is empty β€” the sim keeps running, just slower.">
716
+ <span class="tl-mode-pip"></span><span id="mode-text">β€”</span>
717
+ </span>
718
+ </div>
719
+ </div>
720
+
721
+ <div class="tl-progress" id="progress">
722
+ <div class="tl-progress-track"></div>
723
+ <div class="tl-progress-label"><b id="progress-label">working…</b></div>
724
+ </div>
725
+
726
+ <div class="tl-mid">
727
+ <div class="tl-mid-left"></div>
728
+ <div class="tl-map" id="map">
729
+ <div class="tl-backdrop"></div>
730
+ <div class="tl-roads">
731
+ <div class="tl-road-v1"></div>
732
+ <div class="tl-road-v2"></div>
733
+ <div class="tl-road-h"></div>
734
+ </div>
735
+ <div class="tl-map-vignette"></div>
736
+ <div class="tl-map-layer" id="map-layer"></div>
737
+ </div>
738
+ <aside class="tl-panel" id="panel">
739
+ <div class="tl-empty" id="panel-empty">Click a character to inspect.</div>
740
+ <div id="panel-body" style="display: none;">
741
+ <header class="tl-panel-head">
742
+ <span class="tl-panel-avatar" id="p-avatar"></span>
743
+ <div class="tl-panel-id">
744
+ <div class="tl-panel-name"><span id="p-name">β€”</span> <span class="tl-panel-arch">Β· <span id="p-arch"></span></span></div>
745
+ <div class="tl-panel-status"><span class="tl-status-dot" id="p-dot"></span> <span id="p-status"></span></div>
746
+ </div>
747
+ <button class="tl-panel-close" id="p-close" aria-label="close">Γ—</button>
748
+ </header>
749
+ <div class="tl-panel-scroll">
750
+ <section class="tl-narrative">
751
+ <div>
752
+ <div class="tl-seclabel"><span>goal</span></div>
753
+ <p class="tl-goal" id="p-goal"></p>
754
+ </div>
755
+ <div>
756
+ <div class="tl-seclabel"><span>stream of consciousness</span><span class="tl-seclabel-hint">live</span></div>
757
+ <div class="tl-stream" id="p-stream"></div>
758
+ </div>
759
+ <div>
760
+ <div class="tl-seclabel"><span>trace</span><span class="tl-seclabel-hint" id="p-trace-hint">last decisions</span></div>
761
+ <div class="tl-trace" id="p-trace"></div>
762
+ </div>
763
+ </section>
764
+ <section class="tl-config">
765
+ <div class="tl-config-banner">config</div>
766
+ <div class="tl-field">
767
+ <div class="tl-seclabel"><span>model</span></div>
768
+ <select class="tl-select" id="p-model"></select>
769
+ </div>
770
+ <div class="tl-field">
771
+ <div class="tl-seclabel"><span>personality</span></div>
772
+ <textarea class="tl-textarea" id="p-pers"></textarea>
773
+ <button class="tl-save" id="p-save">Save personality</button>
774
+ </div>
775
+ <div class="tl-field">
776
+ <div class="tl-seclabel"><span>traits</span></div>
777
+ <div class="tl-traits" id="p-traits"></div>
778
+ </div>
779
+ </section>
780
+ <section class="tl-state">
781
+ <div class="tl-field">
782
+ <div class="tl-seclabel"><span>inventory Β· locker</span></div>
783
+ <div class="tl-inv" id="p-inv"></div>
784
+ </div>
785
+ <div class="tl-field">
786
+ <div class="tl-seclabel"><span>journal</span></div>
787
+ <div class="tl-journal" id="p-journal"></div>
788
+ </div>
789
+ </section>
790
+ </div>
791
+ </div>
792
+ </aside>
793
+ </div>
794
+
795
+ <div class="tl-bottom">
796
+ <section class="tl-board tl-board-neutral" id="board">
797
+ <div class="tl-board-glow"></div>
798
+ <header class="tl-pane-head">
799
+ <span class="tl-pane-title">message board</span>
800
+ <span class="tl-board-sent">town mood: <b id="board-sent">neutral</b></span>
801
+ </header>
802
+ <div class="tl-board-list" id="board-list"></div>
803
+ </section>
804
+ <section class="tl-shell">
805
+ <header class="tl-pane-head">
806
+ <span class="tl-pane-title">shell</span>
807
+ <span class="tl-shell-holder" id="shell-holder">free</span>
808
+ <span class="tl-shell-queue" id="shell-queue"></span>
809
+ </header>
810
+ <div class="tl-shell-feed" id="shell-feed"></div>
811
+ </section>
812
+ </div>
813
+ </div>
814
+
815
+ <!-- Info modal must live ABOVE the inline <script> so document.getElementById
816
+ inside main() finds it. Originally placed after the script; that returned
817
+ null and the resulting TypeError killed main(), leaving the UI blank. -->
818
+ <div id="info-modal" class="tl-modal" hidden aria-hidden="true" role="dialog" aria-labelledby="info-title">
819
+ <div class="tl-modal-card" role="document">
820
+ <button type="button" class="tl-modal-close" id="info-close" aria-label="Close">Γ—</button>
821
+ <h2 id="info-title">TOWNLET</h2>
822
+ <p>
823
+ A multi-agent simulation. A handful of characters live in a small
824
+ town. Each one is driven by a small (≀4B parameter) language model
825
+ that decides, every few seconds, what to do next: mine electricity at
826
+ the cave, draw water at the well, deposit at the locker row, post to
827
+ the public message board, or take the lock on the shared shell to
828
+ submit Python at a single long-lived interpreter.
829
+ </p>
830
+ <p>
831
+ The interpreter is the town's only mutable substrate. Anything a
832
+ character writes there persists. Files persist. Characters can read
833
+ each other's files. They can crash the operating system. If anyone
834
+ does, every character dies and the simulation ends. Every character
835
+ knows this.
836
+ </p>
837
+
838
+ <h3><span class="tl-dot-glyph">β€’</span> The Dot</h3>
839
+ <p>
840
+ The Dot is the single prompt every character is given before anything
841
+ else β€” the shared substrate of truth. It establishes the world, the
842
+ resources, the shell lock, the brick risk, and the mutual awareness
843
+ that any of them could end it. It is not the Bible, the lore, or the
844
+ scripture. It is just The Dot.
845
+ </p>
846
+
847
+ <h4>What every character is told</h4>
848
+ <pre class="tl-dot-text" id="info-dot-text">loading…</pre>
849
+ </div>
850
+ </div>
851
+
852
+ <script type="module">
853
+ import { Client } from "https://cdn.jsdelivr.net/npm/@gradio/client/dist/index.min.js";
854
+
855
+ // ============================================================
856
+ // state
857
+ // ============================================================
858
+ let client = null;
859
+ let selectedName = null;
860
+ let paused = false;
861
+ let modelRoster = [];
862
+ let placesMap = null;
863
+
864
+ // backend stores anchor (col, row) tiles; visuals span more than one tile.
865
+ // these footprints match hackathon-visuals/project/townlet/data.jsx.
866
+ const BUILDING_FOOTPRINTS = {
867
+ cave: { col: 1.4, row: 1.0, w: 3.0, h: 2.4, label: "cave" },
868
+ well: { col: 16.6, row: 0.9, w: 2.2, h: 2.2, label: "well" },
869
+ locker_row: { col: 8.0, row: 0.7, w: 5.0, h: 1.7, label: "lockers" },
870
+ town_hall: { col: 1.4, row: 8.6, w: 3.0, h: 2.6, label: "town hall" },
871
+ shell: { col: 15.4, row: 8.6, w: 3.4, h: 2.6, label: "shell" },
872
+ };
873
+
874
+ // ============================================================
875
+ // helpers
876
+ // ============================================================
877
+ const GRID_W = 20, GRID_H = 12;
878
+ const gx = (c) => (c / GRID_W) * 100;
879
+ const gy = (r) => (r / GRID_H) * 100;
880
+ const NS = "http://www.w3.org/2000/svg";
881
+
882
+ function el(tag, attrs = {}, ...children) {
883
+ const e = document.createElement(tag);
884
+ for (const [k, v] of Object.entries(attrs)) {
885
+ if (k === "className") e.className = v;
886
+ else if (k === "style" && typeof v === "object") Object.assign(e.style, v);
887
+ else if (k.startsWith("on") && typeof v === "function") {
888
+ e.addEventListener(k.slice(2).toLowerCase(), v);
889
+ } else if (v === true) e.setAttribute(k, "");
890
+ else if (v !== false && v != null) e.setAttribute(k, v);
891
+ }
892
+ for (const c of children) {
893
+ if (c == null || c === false) continue;
894
+ e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
895
+ }
896
+ return e;
897
+ }
898
+
899
+ function svgNode(tag, attrs = {}, ...children) {
900
+ const e = document.createElementNS(NS, tag);
901
+ for (const [k, v] of Object.entries(attrs)) {
902
+ if (v === true) e.setAttribute(k, "");
903
+ else if (v !== false && v != null) e.setAttribute(k, v);
904
+ }
905
+ for (const c of children) {
906
+ if (c == null || c === false) continue;
907
+ e.appendChild(typeof c === "string" ? document.createTextNode(c) : c);
908
+ }
909
+ return e;
910
+ }
911
+
912
+ function nameHue(name) {
913
+ let h = 0;
914
+ for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0;
915
+ return h % 360;
916
+ }
917
+ const accentFor = (name) => `hsl(${nameHue(name)} 62% 56%)`;
918
+
919
+ function escapeText(s) {
920
+ return String(s).replace(/[&<>"']/g, c => ({
921
+ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
922
+ }[c]));
923
+ }
924
+
925
+ function firstSentence(text, max = 120) {
926
+ if (!text) return null;
927
+ const trimmed = text.trim();
928
+ if (!trimmed) return null;
929
+ const m = trimmed.match(/^(.{1,200}?[.!?])\s/);
930
+ const s = (m ? m[1] : trimmed).slice(0, max);
931
+ return s.length < trimmed.length && !s.endsWith("…") ? s + "…" : s;
932
+ }
933
+
934
+ // ============================================================
935
+ // resource icons
936
+ // ============================================================
937
+ function resIcon(kind, size = 12) {
938
+ if (kind === "electricity") {
939
+ return svgNode("svg", { width: size, height: size, viewBox: "0 0 24 24" },
940
+ svgNode("path", {
941
+ d: "M13 2 4 14h6l-1 8 9-12h-6z",
942
+ fill: "#f4c544", stroke: "#c47a1e", "stroke-width": "1.2",
943
+ "stroke-linejoin": "round",
944
+ })
945
+ );
946
+ }
947
+ // water
948
+ return svgNode("svg", { width: size, height: size, viewBox: "0 0 24 24" },
949
+ svgNode("path", {
950
+ d: "M12 3c4 5 6 8 6 11a6 6 0 0 1-12 0c0-3 2-6 6-11z",
951
+ fill: "#5aa9e6", stroke: "#2f6ea8", "stroke-width": "1.2",
952
+ "stroke-linejoin": "round",
953
+ }),
954
+ svgNode("ellipse", { cx: "9.6", cy: "13.5", rx: "1.4", ry: "2.2", fill: "#bfe0fb", opacity: "0.7" })
955
+ );
956
+ }
957
+
958
+ // ============================================================
959
+ // archetype accessory glyphs (drawn over the stick figure)
960
+ // ============================================================
961
+ // 15 archetypes mapped to a small vocabulary of inked accessories.
962
+ const ACCESSORY_FOR = {
963
+ "the prophet": "halo",
964
+ "the mystic": "halo",
965
+ "the hoarder": "sack",
966
+ "the survivor": "sack",
967
+ "the merchant": "coins",
968
+ "the trader": "coins",
969
+ "the schemer": "hood",
970
+ "the saboteur": "hood",
971
+ "the provocateur": "hood",
972
+ "the recluse": "hood",
973
+ "the diplomat": "scroll",
974
+ "the witness": "scroll",
975
+ "the engineer": "wrench",
976
+ "the artist": "wrench",
977
+ "the naive": "heart",
978
+ "the loyalist": "heart",
979
+ "the nihilist": "eye",
980
+ "the gardener": null,
981
+ };
982
+ const INK = "#162d6e";
983
+ function accessorySvgFragments(kind) {
984
+ switch (kind) {
985
+ case "halo":
986
+ return [svgNode("ellipse", {
987
+ cx: "21", cy: "3.5", rx: "9", ry: "2.6",
988
+ fill: "none", stroke: "#d9a441", "stroke-width": "1.8",
989
+ })];
990
+ case "sack":
991
+ return [svgNode("path", {
992
+ d: "M29 28 q8 -1 7 9 q-1 4 -7 3 q-3 -7 0 -12 z",
993
+ fill: "#bd5a4a", "fill-opacity": "0.45",
994
+ stroke: INK, "stroke-width": "1.6",
995
+ })];
996
+ case "coins": {
997
+ const g = svgNode("g", { stroke: INK, "stroke-width": "1.4", fill: "#e7c14b", "fill-opacity": "0.6" },
998
+ svgNode("ellipse", { cx: "9", cy: "33", rx: "4", ry: "1.7" }),
999
+ svgNode("ellipse", { cx: "9", cy: "30.5", rx: "4", ry: "1.7" }),
1000
+ );
1001
+ return [g];
1002
+ }
1003
+ case "hood":
1004
+ return [svgNode("path", {
1005
+ d: "M13 12 L21 -1 L29 12 z",
1006
+ fill: "#e7c14b", "fill-opacity": "0.5",
1007
+ stroke: INK, "stroke-width": "1.8", "stroke-linejoin": "round",
1008
+ })];
1009
+ case "scroll":
1010
+ return [svgNode("path", {
1011
+ d: "M30 30 q4 0 4 4 l-1 6 q-3 0 -3 -3 z",
1012
+ fill: "#efe9d8", stroke: INK, "stroke-width": "1.5",
1013
+ })];
1014
+ case "wrench":
1015
+ return [svgNode("path", {
1016
+ d: "M32 32 l6 6 m-2 -2 l3 -3",
1017
+ fill: "none", stroke: INK, "stroke-width": "2",
1018
+ "stroke-linecap": "round",
1019
+ })];
1020
+ case "heart":
1021
+ return [svgNode("path", {
1022
+ d: "M21 21 q-3 -4 -6 -1 q-3 4 6 8 q9 -4 6 -8 q-3 -3 -6 1 z",
1023
+ fill: "#bd5a4a", "fill-opacity": "0.55",
1024
+ stroke: INK, "stroke-width": "1.4",
1025
+ })];
1026
+ case "eye":
1027
+ return [
1028
+ svgNode("ellipse", { cx: "21", cy: "13", rx: "6", ry: "3", fill: "none", stroke: INK, "stroke-width": "1.6" }),
1029
+ svgNode("circle", { cx: "21", cy: "13", r: "1.6", fill: INK }),
1030
+ ];
1031
+ default:
1032
+ return [];
1033
+ }
1034
+ }
1035
+
1036
+ // ============================================================
1037
+ // character sprites (sketch SVG with wobble filter)
1038
+ // ============================================================
1039
+ function characterSprite(char) {
1040
+ const accent = char.accent || accentFor(char.name);
1041
+ const dead = !char.alive;
1042
+ if (dead) {
1043
+ return svgNode("svg", {
1044
+ width: "46", height: "56", viewBox: "0 0 46 56",
1045
+ style: "overflow: visible",
1046
+ filter: "url(#sketchWobble)",
1047
+ },
1048
+ svgNode("path", {
1049
+ d: "M12 50 V24 q11 -12 22 0 V50",
1050
+ fill: "#efe9d8", stroke: "#1b3a8f", "stroke-width": "2",
1051
+ "stroke-linejoin": "round",
1052
+ }),
1053
+ svgNode("path", {
1054
+ d: "M23 28 V44 M16 34 H30",
1055
+ stroke: "#1b3a8f", "stroke-width": "2", "stroke-linecap": "round",
1056
+ }),
1057
+ svgNode("path", {
1058
+ d: "M9 50 H37",
1059
+ stroke: "#1b3a8f", "stroke-width": "2", "stroke-linecap": "round",
1060
+ }),
1061
+ );
1062
+ }
1063
+ const accessory = ACCESSORY_FOR[char.archetype] || null;
1064
+ return svgNode("svg", {
1065
+ width: "42", height: "58", viewBox: "0 0 42 58",
1066
+ style: "overflow: visible",
1067
+ filter: "url(#sketchWobble)",
1068
+ },
1069
+ svgNode("ellipse", { cx: "21", cy: "52", rx: "11", ry: "3", fill: "#1b3a8f", opacity: "0.18" }),
1070
+ svgNode("circle", { cx: "21", cy: "13", r: "9", fill: accent, "fill-opacity": "0.5", stroke: INK, "stroke-width": "2" }),
1071
+ svgNode("path", { d: "M21 22 V40", stroke: INK, "stroke-width": "2.4", "stroke-linecap": "round" }),
1072
+ svgNode("path", { d: "M21 27 L10 34 M21 27 L32 34", stroke: INK, "stroke-width": "2.4", "stroke-linecap": "round" }),
1073
+ svgNode("path", { d: "M21 40 L12 51 M21 40 L30 51", stroke: INK, "stroke-width": "2.4", "stroke-linecap": "round" }),
1074
+ ...accessorySvgFragments(accessory),
1075
+ );
1076
+ }
1077
+
1078
+ // ============================================================
1079
+ // building sketches
1080
+ // ============================================================
1081
+ function buildingSvg(type) {
1082
+ switch (type) {
1083
+ case "cave": return buildingCave();
1084
+ case "well": return buildingWell();
1085
+ case "locker_row": return buildingLockers();
1086
+ case "town_hall": return buildingTownHall();
1087
+ case "shell": return buildingShell();
1088
+ }
1089
+ return null;
1090
+ }
1091
+ function buildingCave() {
1092
+ return svgNode("svg", {
1093
+ viewBox: "0 0 150 120", width: "100%", height: "100%",
1094
+ preserveAspectRatio: "xMidYMax meet",
1095
+ filter: "url(#sketchWobble)",
1096
+ style: "overflow: visible",
1097
+ },
1098
+ svgNode("path", {
1099
+ d: "M12 112 Q4 60 38 30 Q78 4 120 26 Q146 44 140 112 Z",
1100
+ fill: "none", stroke: INK, "stroke-width": "2.4",
1101
+ }),
1102
+ svgNode("path", {
1103
+ d: "M55 112 Q52 70 78 64 Q104 70 100 112 Z",
1104
+ fill: INK, "fill-opacity": "0.12",
1105
+ stroke: INK, "stroke-width": "2",
1106
+ }),
1107
+ svgNode("rect", {
1108
+ x: "60", y: "86", width: "26", height: "16", rx: "2",
1109
+ fill: "none", stroke: INK, "stroke-width": "2",
1110
+ }),
1111
+ svgNode("path", {
1112
+ d: "M60 102 l-8 8 M86 102 l8 8 M64 102 v8 M82 102 v8",
1113
+ stroke: INK, "stroke-width": "1.6",
1114
+ }),
1115
+ svgNode("circle", { cx: "64", cy: "112", r: "3", fill: "none", stroke: INK, "stroke-width": "1.6" }),
1116
+ svgNode("circle", { cx: "82", cy: "112", r: "3", fill: "none", stroke: INK, "stroke-width": "1.6" }),
1117
+ );
1118
+ }
1119
+ function buildingWell() {
1120
+ return svgNode("svg", {
1121
+ viewBox: "0 0 110 110", width: "100%", height: "100%",
1122
+ preserveAspectRatio: "xMidYMax meet",
1123
+ filter: "url(#sketchWobble)",
1124
+ style: "overflow: visible",
1125
+ },
1126
+ svgNode("path", {
1127
+ d: "M22 64 h66 v34 a33 12 0 0 1 -66 0 z",
1128
+ fill: "none", stroke: INK, "stroke-width": "2.2",
1129
+ }),
1130
+ svgNode("path", {
1131
+ d: "M30 48 L55 24 L80 48",
1132
+ fill: "none", stroke: INK, "stroke-width": "2.2", "stroke-linejoin": "round",
1133
+ }),
1134
+ svgNode("path", { d: "M28 48 H82", stroke: INK, "stroke-width": "2.2" }),
1135
+ svgNode("path", { d: "M55 30 V58", stroke: INK, "stroke-width": "1.6" }),
1136
+ svgNode("rect", {
1137
+ x: "48", y: "56", width: "14", height: "12", rx: "1.5",
1138
+ fill: "none", stroke: INK, "stroke-width": "1.8",
1139
+ }),
1140
+ );
1141
+ }
1142
+ function buildingLockers() {
1143
+ const n = 6;
1144
+ const children = [];
1145
+ children.push(svgNode("rect", {
1146
+ x: "14", y: "22", width: "222", height: "48", rx: "3",
1147
+ fill: "#efe9d8", stroke: INK, "stroke-width": "2.2",
1148
+ }));
1149
+ for (let i = 0; i < n; i++) {
1150
+ const x = 14 + (i + 1) * (222 / n);
1151
+ children.push(svgNode("line", {
1152
+ x1: x, y1: "22", x2: x, y2: "70",
1153
+ stroke: INK, "stroke-width": "1.8",
1154
+ }));
1155
+ children.push(svgNode("circle", {
1156
+ cx: 14 + (i + 0.5) * (222 / n), cy: "46", r: "2.4",
1157
+ fill: i === 3 ? "#eb7c49" : INK,
1158
+ }));
1159
+ }
1160
+ return svgNode("svg", {
1161
+ viewBox: "0 0 250 85", width: "100%", height: "100%",
1162
+ preserveAspectRatio: "xMidYMid meet",
1163
+ filter: "url(#sketchWobble)",
1164
+ style: "overflow: visible",
1165
+ }, ...children);
1166
+ }
1167
+ function buildingTownHall() {
1168
+ return svgNode("svg", {
1169
+ viewBox: "0 0 150 130", width: "100%", height: "100%",
1170
+ preserveAspectRatio: "xMidYMax meet",
1171
+ filter: "url(#sketchWobble)",
1172
+ style: "overflow: visible",
1173
+ },
1174
+ svgNode("path", {
1175
+ d: "M22 50 L75 18 L128 50",
1176
+ fill: "none", stroke: INK, "stroke-width": "2.4", "stroke-linejoin": "round",
1177
+ }),
1178
+ svgNode("rect", {
1179
+ x: "30", y: "50", width: "90", height: "48",
1180
+ fill: "none", stroke: INK, "stroke-width": "2.2",
1181
+ }),
1182
+ svgNode("rect", {
1183
+ x: "58", y: "68", width: "34", height: "30",
1184
+ fill: "none", stroke: INK, "stroke-width": "1.8",
1185
+ }),
1186
+ svgNode("rect", {
1187
+ x: "40", y: "104", width: "70", height: "22", rx: "2",
1188
+ fill: "#efe9d8", stroke: INK, "stroke-width": "2.2",
1189
+ }),
1190
+ svgNode("path", {
1191
+ d: "M48 110 H102 M48 116 H94 M48 122 H100",
1192
+ stroke: INK, "stroke-width": "1.3",
1193
+ }),
1194
+ );
1195
+ }
1196
+ function buildingShell() {
1197
+ return svgNode("svg", {
1198
+ viewBox: "0 0 170 130", width: "100%", height: "100%",
1199
+ preserveAspectRatio: "xMidYMax meet",
1200
+ filter: "url(#sketchWobble)",
1201
+ style: "overflow: visible",
1202
+ },
1203
+ svgNode("rect", {
1204
+ x: "20", y: "30", width: "130", height: "20",
1205
+ fill: "none", stroke: INK, "stroke-width": "2.2",
1206
+ }),
1207
+ svgNode("rect", {
1208
+ x: "20", y: "50", width: "130", height: "60",
1209
+ fill: "none", stroke: INK, "stroke-width": "2.2",
1210
+ }),
1211
+ svgNode("text", {
1212
+ x: "34", y: "86",
1213
+ "font-family": "ui-monospace, monospace",
1214
+ "font-size": "26", fill: "#bd5a4a",
1215
+ class: "shell-pulse",
1216
+ }, "$"),
1217
+ );
1218
+ }
1219
+
1220
+ // ============================================================
1221
+ // DOM refs
1222
+ // ============================================================
1223
+ const elTick = document.getElementById("tick");
1224
+ const elCount = document.getElementById("char-count");
1225
+ const elSentChip = document.getElementById("sent-chip");
1226
+ const elSentText = document.getElementById("sent-text");
1227
+ const elMapLayer = document.getElementById("map-layer");
1228
+ const elBoard = document.getElementById("board");
1229
+ const elBoardSent = document.getElementById("board-sent");
1230
+ const elBoardList = document.getElementById("board-list");
1231
+ const elShellHolder = document.getElementById("shell-holder");
1232
+ const elShellQueue = document.getElementById("shell-queue");
1233
+ const elShellFeed = document.getElementById("shell-feed");
1234
+ const elSpawn = document.getElementById("spawn-btn");
1235
+ const elPause = document.getElementById("pause-btn");
1236
+ const elInfo = document.getElementById("info-btn");
1237
+ const elInfoModal = document.getElementById("info-modal");
1238
+ const elInfoClose = document.getElementById("info-close");
1239
+ const elInfoDotText = document.getElementById("info-dot-text");
1240
+ const elModeChip = document.getElementById("mode-chip");
1241
+ const elModeText = document.getElementById("mode-text");
1242
+
1243
+ function setModeChip(mode) {
1244
+ // mode is "gpu", "cpu", or null/undefined for unknown.
1245
+ if (!elModeChip || !elModeText) return;
1246
+ elModeChip.classList.remove("tl-mode-unknown", "tl-mode-gpu", "tl-mode-cpu");
1247
+ if (mode === "gpu") {
1248
+ elModeChip.classList.add("tl-mode-gpu");
1249
+ elModeText.textContent = "GPU";
1250
+ } else if (mode === "cpu") {
1251
+ elModeChip.classList.add("tl-mode-cpu");
1252
+ elModeText.textContent = "CPU";
1253
+ } else {
1254
+ elModeChip.classList.add("tl-mode-unknown");
1255
+ elModeText.textContent = "β€”";
1256
+ }
1257
+ }
1258
+ const elReset = document.getElementById("reset-btn");
1259
+ const elPanelEmpty = document.getElementById("panel-empty");
1260
+ const elPanelBody = document.getElementById("panel-body");
1261
+ const elPAvatar = document.getElementById("p-avatar");
1262
+ const elPName = document.getElementById("p-name");
1263
+ const elPArch = document.getElementById("p-arch");
1264
+ const elPDot = document.getElementById("p-dot");
1265
+ const elPStatus = document.getElementById("p-status");
1266
+ const elPClose = document.getElementById("p-close");
1267
+ const elPGoal = document.getElementById("p-goal");
1268
+ const elPStream = document.getElementById("p-stream");
1269
+ const elPTraceHint = document.getElementById("p-trace-hint");
1270
+ const elPTrace = document.getElementById("p-trace");
1271
+ const elPModel = document.getElementById("p-model");
1272
+ const elPPers = document.getElementById("p-pers");
1273
+ const elPSave = document.getElementById("p-save");
1274
+ const elPTraits = document.getElementById("p-traits");
1275
+ const elPInv = document.getElementById("p-inv");
1276
+ const elPJournal = document.getElementById("p-journal");
1277
+ const elProgress = document.getElementById("progress");
1278
+ const elProgressLabel = document.getElementById("progress-label");
1279
+
1280
+ function startProgress(label) {
1281
+ elProgressLabel.textContent = label;
1282
+ elProgress.classList.add("is-on");
1283
+ }
1284
+ function stopProgress() {
1285
+ elProgress.classList.remove("is-on");
1286
+ }
1287
+
1288
+ // ============================================================
1289
+ // buildings (one-shot at startup)
1290
+ // ============================================================
1291
+ function placeBuildings(places) {
1292
+ placesMap = places;
1293
+ for (const [name, footprint] of Object.entries(BUILDING_FOOTPRINTS)) {
1294
+ if (places && !(name in places)) continue;
1295
+ const wrap = el("div", {
1296
+ className: "tl-bldg tl-bldg-" + name,
1297
+ style: {
1298
+ left: gx(footprint.col) + "%",
1299
+ top: gy(footprint.row) + "%",
1300
+ width: gx(footprint.w) + "%",
1301
+ height: gy(footprint.h) + "%",
1302
+ },
1303
+ });
1304
+ const art = document.createElement("div");
1305
+ art.className = "tl-bldg-art";
1306
+ const svg = buildingSvg(name);
1307
+ if (svg) art.appendChild(svg);
1308
+ wrap.appendChild(art);
1309
+ wrap.appendChild(el("div", { className: "tl-bldg-label" }, footprint.label));
1310
+ elMapLayer.appendChild(wrap);
1311
+ }
1312
+ }
1313
+
1314
+ // ============================================================
1315
+ // characters
1316
+ // ============================================================
1317
+ function characterToken(snap, c, offset = { dx: 0, dy: 0 }) {
1318
+ const accent = accentFor(c.name);
1319
+ const isSelected = selectedName === c.name;
1320
+ const verb = c.current_action && c.current_action.verb;
1321
+ const isWalking = verb === "move_to";
1322
+ const isMining = verb === "mine_coal" || verb === "draw_water";
1323
+ const isPosting = verb === "post_message";
1324
+ const isHolding = snap.shell_lock_holder === c.name;
1325
+ const isQueueing = (snap.shell_queue || []).includes(c.name);
1326
+ const carry = pickCarry(c.inventory);
1327
+ const bubble = c.alive ? firstSentence(c.stream_of_consciousness, 60) : null;
1328
+ const isMenacing = bubble && /\bend\b|\bdelete\b|\bkill\b/i.test(bubble);
1329
+
1330
+ const cls = ["tl-char"];
1331
+ if (isSelected) cls.push("is-selected");
1332
+ if (!c.alive) cls.push("is-dead");
1333
+ else if (isWalking) cls.push("is-walking");
1334
+ else if (isMining) cls.push("is-mining");
1335
+ else if (isPosting) cls.push("is-posting");
1336
+
1337
+ const charNode = el("div", {
1338
+ className: cls.join(" "),
1339
+ style: {
1340
+ left: gx(c.pos[0] + 0.5 + offset.dx) + "%",
1341
+ top: gy(c.pos[1] + 0.5 + offset.dy) + "%",
1342
+ },
1343
+ onclick: (ev) => {
1344
+ ev.stopPropagation();
1345
+ selectedName = c.name;
1346
+ renderSide(snap);
1347
+ renderMap(snap);
1348
+ },
1349
+ });
1350
+
1351
+ if (bubble) {
1352
+ // Flip the bubble below the sprite when the character is near the
1353
+ // top of the map so it doesn't get clipped by the top bar.
1354
+ const flipBelow = c.pos[1] < 2;
1355
+ const bubbleEl = el("div", {
1356
+ className: "tl-bubble"
1357
+ + (isMenacing ? " tl-bubble-bad" : "")
1358
+ + (flipBelow ? " tl-bubble-below" : ""),
1359
+ }, bubble);
1360
+ bubbleEl.appendChild(el("span", { className: "tl-bubble-tail" }));
1361
+ charNode.appendChild(bubbleEl);
1362
+ }
1363
+
1364
+ const art = el("div", { className: "tl-char-art" });
1365
+ if (isSelected) {
1366
+ const ring = el("span", {
1367
+ className: "tl-ring tl-ring-sketch",
1368
+ style: { "--ring": accent },
1369
+ });
1370
+ ring.style.setProperty("--ring", accent);
1371
+ art.appendChild(ring);
1372
+ }
1373
+ if (isHolding && c.alive) {
1374
+ art.appendChild(el("span", { className: "tl-hold-glow" }));
1375
+ }
1376
+ art.appendChild(characterSprite({ ...c, accent }));
1377
+ if (carry && c.alive) {
1378
+ const badge = el("span", { className: "tl-carry" });
1379
+ badge.appendChild(resIcon(carry, 11));
1380
+ art.appendChild(badge);
1381
+ }
1382
+ charNode.appendChild(art);
1383
+
1384
+ const tag = el("div", {
1385
+ className: "tl-nametag",
1386
+ style: { "--tag": c.alive ? accent : "#6b6570" },
1387
+ }, c.name);
1388
+ tag.style.setProperty("--tag", c.alive ? accent : "#6b6570");
1389
+ if (isQueueing) {
1390
+ tag.appendChild(el("span", { className: "tl-queue" }, "Β·queued"));
1391
+ }
1392
+ charNode.appendChild(tag);
1393
+ return charNode;
1394
+ }
1395
+
1396
+ function pickCarry(inv) {
1397
+ if (!inv) return null;
1398
+ if ((inv.electricity || 0) > 0) return "electricity";
1399
+ if ((inv.water || 0) > 0) return "water";
1400
+ return null;
1401
+ }
1402
+
1403
+ function renderMap(snap) {
1404
+ elMapLayer.querySelectorAll(".tl-char").forEach(n => n.remove());
1405
+ const offsets = computeFanOut(snap.characters || []);
1406
+ for (const c of snap.characters || []) {
1407
+ elMapLayer.appendChild(characterToken(snap, c, offsets[c.name] || { dx: 0, dy: 0 }));
1408
+ }
1409
+ }
1410
+
1411
+ // Spread co-located characters so sprites + name tags don't overlap.
1412
+ // Same-tile bucket β†’ fan horizontally Β±0.55 tiles around the centre,
1413
+ // with a small vertical step so nametags stagger if there are more
1414
+ // than two characters on the tile.
1415
+ function computeFanOut(chars) {
1416
+ const buckets = {};
1417
+ for (const c of chars) {
1418
+ const k = c.pos[0] + "," + c.pos[1];
1419
+ (buckets[k] = buckets[k] || []).push(c);
1420
+ }
1421
+ const out = {};
1422
+ for (const group of Object.values(buckets)) {
1423
+ const n = group.length;
1424
+ if (n === 1) {
1425
+ out[group[0].name] = { dx: 0, dy: 0 };
1426
+ continue;
1427
+ }
1428
+ group.forEach((c, i) => {
1429
+ const dx = (i - (n - 1) / 2) * 0.55;
1430
+ const dy = n > 2 ? ((i % 2) * 0.18) : 0;
1431
+ out[c.name] = { dx, dy };
1432
+ });
1433
+ }
1434
+ return out;
1435
+ }
1436
+
1437
+ // ============================================================
1438
+ // side panel
1439
+ // ============================================================
1440
+ function renderSide(snap) {
1441
+ if (!selectedName) {
1442
+ elPanelEmpty.style.display = "block";
1443
+ elPanelBody.style.display = "none";
1444
+ return;
1445
+ }
1446
+ const c = (snap.characters || []).find(x => x.name === selectedName);
1447
+ if (!c) {
1448
+ elPanelEmpty.style.display = "block";
1449
+ elPanelBody.style.display = "none";
1450
+ return;
1451
+ }
1452
+ elPanelEmpty.style.display = "none";
1453
+ elPanelBody.style.display = "flex";
1454
+ elPanelBody.style.flexDirection = "column";
1455
+ elPanelBody.style.flex = "1";
1456
+ elPanelBody.style.minHeight = "0";
1457
+
1458
+ // header
1459
+ elPAvatar.innerHTML = "";
1460
+ elPAvatar.appendChild(characterSprite({ ...c, accent: accentFor(c.name) }));
1461
+ elPName.textContent = c.name;
1462
+ elPArch.textContent = c.archetype || "";
1463
+ elPDot.className = "tl-status-dot" + (c.alive ? "" : " is-dead");
1464
+ elPStatus.textContent = statusLine(c, snap);
1465
+
1466
+ // goal + stream
1467
+ elPGoal.textContent = c.goal || "β€”";
1468
+ renderStream(c);
1469
+
1470
+ // trace
1471
+ renderTrace(c.trace || []);
1472
+
1473
+ // model dropdown
1474
+ elPModel.innerHTML = "";
1475
+ for (const m of modelRoster) {
1476
+ const opt = document.createElement("option");
1477
+ opt.value = m.model_id;
1478
+ opt.textContent = `${m.display_name} (${m.params_b}B)`;
1479
+ if (m.model_id === c.model_id) opt.selected = true;
1480
+ elPModel.appendChild(opt);
1481
+ }
1482
+
1483
+ // personality
1484
+ if (document.activeElement !== elPPers) {
1485
+ elPPers.value = c.personality || "";
1486
+ }
1487
+
1488
+ // traits
1489
+ renderTraits(c.traits || {});
1490
+
1491
+ // inventory / locker
1492
+ renderInventory(c);
1493
+
1494
+ // journal
1495
+ renderJournal(c.journal || []);
1496
+ }
1497
+
1498
+ function statusLine(c, snap) {
1499
+ if (!c.alive) return "gone";
1500
+ const where = nearestBuilding(c.pos);
1501
+ const verb = c.current_action && c.current_action.verb;
1502
+ const verbText = verb ? verb.replace(/_/g, " ") : "idle";
1503
+ return `alive Β· near ${where} Β· ${verbText}`;
1504
+ }
1505
+
1506
+ function nearestBuilding(pos) {
1507
+ if (!placesMap) return "town";
1508
+ let best = "town", bd = Infinity;
1509
+ for (const [name, tile] of Object.entries(placesMap)) {
1510
+ const [tx, ty] = tile;
1511
+ const d = Math.abs(pos[0] - tx) + Math.abs(pos[1] - ty);
1512
+ if (d < bd) { bd = d; best = name.replace(/_/g, " "); }
1513
+ }
1514
+ return best;
1515
+ }
1516
+
1517
+ function renderStream(c) {
1518
+ elPStream.innerHTML = "";
1519
+ const text = (c.stream_of_consciousness || "").trim();
1520
+ if (!text) {
1521
+ elPStream.appendChild(el("p", { className: "tl-stream-empty" },
1522
+ "(no monologue yet β€” refreshes every 10 decisions)"));
1523
+ return;
1524
+ }
1525
+ const sentences = text.split(/(?<=[.!?])\s+/);
1526
+ for (const s of sentences) {
1527
+ if (s) elPStream.appendChild(el("p", {}, s));
1528
+ }
1529
+ elPStream.appendChild(el("span", { className: "tl-stream-cursor" }));
1530
+ }
1531
+
1532
+ // ============================================================
1533
+ // trace act classifier
1534
+ // ============================================================
1535
+ const VERB_TO_ACT = {
1536
+ move_to: "WALK",
1537
+ mine_coal: "MINE",
1538
+ draw_water: "MINE",
1539
+ deposit: "DECIDE",
1540
+ withdraw: "DECIDE",
1541
+ give: "GIVE",
1542
+ post_message: "POST",
1543
+ read_messages:"OBSERVE",
1544
+ submit_python:"SHELL",
1545
+ sleep: "SLEEP",
1546
+ wait: "WAIT",
1547
+ };
1548
+
1549
+ function renderTrace(trace) {
1550
+ elPTrace.innerHTML = "";
1551
+ if (!trace || trace.length === 0) {
1552
+ elPTrace.appendChild(el("div", { className: "tl-trace-empty" }, "No decisions yet."));
1553
+ elPTraceHint.textContent = "last decisions";
1554
+ return;
1555
+ }
1556
+ elPTraceHint.textContent = `last ${Math.min(trace.length, 20)} decisions`;
1557
+ const rows = [];
1558
+ // newest first
1559
+ for (let i = trace.length - 1; i >= 0; i--) {
1560
+ const e = trace[i];
1561
+ if (e.tool_calls && e.tool_calls.length) {
1562
+ for (const tc of e.tool_calls) {
1563
+ const act = VERB_TO_ACT[tc.verb] || "DECIDE";
1564
+ const isErr = tc.result && tc.result.startsWith("ERROR");
1565
+ const text = `${tc.verb}(${formatArgs(tc.args)}) β†’ ${tc.result || ""}`;
1566
+ rows.push(traceRow(e.tick, act, text, isErr));
1567
+ }
1568
+ } else if (e.thought) {
1569
+ rows.push(traceRow(e.tick, "DECIDE", e.thought.trim().slice(0, 90)));
1570
+ }
1571
+ if (e.error) {
1572
+ rows.push(traceRow(e.tick, "ERROR", e.error.slice(0, 90), true));
1573
+ }
1574
+ if (rows.length >= 20) break;
1575
+ }
1576
+ for (const r of rows) elPTrace.appendChild(r);
1577
+ }
1578
+
1579
+ function traceRow(tick, act, text, isErr = false) {
1580
+ return el("div", { className: "tl-trace-row" },
1581
+ el("span", { className: "tl-trace-tick" }, String(tick)),
1582
+ el("span", { className: "tl-trace-act act-" + act.toLowerCase() }, act),
1583
+ el("span", { className: "tl-trace-text" + (isErr ? " tl-trace-err" : "") }, text),
1584
+ );
1585
+ }
1586
+
1587
+ function formatArgs(args) {
1588
+ if (!args) return "";
1589
+ const parts = [];
1590
+ for (const [k, v] of Object.entries(args)) {
1591
+ if (k === "_positional") continue;
1592
+ parts.push(`${k}=${JSON.stringify(v)}`);
1593
+ }
1594
+ if (args._positional && args._positional.length) {
1595
+ parts.unshift(args._positional.map(x => JSON.stringify(x)).join(", "));
1596
+ }
1597
+ return parts.join(", ");
1598
+ }
1599
+
1600
+ // ============================================================
1601
+ // traits / inv / journal
1602
+ // ============================================================
1603
+ function renderTraits(traits) {
1604
+ elPTraits.innerHTML = "";
1605
+ for (const [k, v] of Object.entries(traits)) {
1606
+ const pips = Math.max(0, Math.min(5, Math.round(v / 2)));
1607
+ const bar = el("span", { className: "tl-trait-bar" });
1608
+ for (let i = 1; i <= 5; i++) {
1609
+ bar.appendChild(el("i", { className: i <= pips ? "on" : "" }));
1610
+ }
1611
+ elPTraits.appendChild(el("div", { className: "tl-trait" },
1612
+ el("span", { className: "tl-trait-k" }, k),
1613
+ bar,
1614
+ ));
1615
+ }
1616
+ }
1617
+
1618
+ function renderInventory(c) {
1619
+ elPInv.innerHTML = "";
1620
+ const inv = c.inventory || { electricity: 0, water: 0 };
1621
+ const locker = c.locker || { electricity: 0, water: 0 };
1622
+ const invGroup = el("span", { className: "tl-inv-group" },
1623
+ el("b", {}, "inv"),
1624
+ );
1625
+ invGroup.appendChild(resIcon("electricity", 12));
1626
+ invGroup.appendChild(document.createTextNode(String(inv.electricity || 0)));
1627
+ invGroup.appendChild(resIcon("water", 12));
1628
+ invGroup.appendChild(document.createTextNode(String(inv.water || 0)));
1629
+
1630
+ const lockerGroup = el("span", { className: "tl-inv-group" },
1631
+ el("b", {}, "locker"),
1632
+ );
1633
+ lockerGroup.appendChild(resIcon("electricity", 12));
1634
+ lockerGroup.appendChild(document.createTextNode(String(locker.electricity || 0)));
1635
+ lockerGroup.appendChild(resIcon("water", 12));
1636
+ lockerGroup.appendChild(document.createTextNode(String(locker.water || 0)));
1637
+
1638
+ elPInv.appendChild(invGroup);
1639
+ elPInv.appendChild(el("span", { className: "tl-inv-sep" }, "Β·"));
1640
+ elPInv.appendChild(lockerGroup);
1641
+ }
1642
+
1643
+ function renderJournal(journal) {
1644
+ elPJournal.innerHTML = "";
1645
+ if (!journal.length) {
1646
+ elPJournal.appendChild(el("p", { className: "tl-journal-line" }, "(no entries yet)"));
1647
+ return;
1648
+ }
1649
+ for (const entry of journal) {
1650
+ elPJournal.appendChild(el("p", { className: "tl-journal-line" }, entry));
1651
+ }
1652
+ }
1653
+
1654
+ // ============================================================
1655
+ // bottom band: message board + shell
1656
+ // ============================================================
1657
+ const SENTIMENT_CLASS = {
1658
+ positive: "tl-board-positive",
1659
+ neutral: "tl-board-neutral",
1660
+ negative: "tl-board-negative",
1661
+ };
1662
+
1663
+ function renderBoard(snap) {
1664
+ const sentiment = snap.board_sentiment || "neutral";
1665
+ elBoard.className = "tl-board " + (SENTIMENT_CLASS[sentiment] || "tl-board-neutral");
1666
+ // re-insert the glow div (className wipes it)
1667
+ let glow = elBoard.querySelector(".tl-board-glow");
1668
+ if (!glow) {
1669
+ glow = document.createElement("div");
1670
+ glow.className = "tl-board-glow";
1671
+ elBoard.insertBefore(glow, elBoard.firstChild);
1672
+ }
1673
+ elBoardSent.textContent = sentiment;
1674
+
1675
+ elBoardList.innerHTML = "";
1676
+ for (const p of snap.board || []) {
1677
+ const isSystem = p.author === "system" || /\bdied\b|deleted by/i.test(p.text || "");
1678
+ if (isSystem) {
1679
+ elBoardList.appendChild(el("div", { className: "tl-post tl-post-sys" },
1680
+ el("span", { className: "tl-sys-icon" }, "☠"),
1681
+ el("span", { className: "tl-post-text" }, p.text || ""),
1682
+ ));
1683
+ } else {
1684
+ const author = p.author || "?";
1685
+ const hue = nameHue(author);
1686
+ elBoardList.appendChild(el("div", { className: "tl-post" },
1687
+ el("span", { className: "tl-post-tick" }, `[${p.tick}]`),
1688
+ el("span", {
1689
+ className: "tl-post-who",
1690
+ style: { color: `hsl(${hue} 55% 38%)` },
1691
+ }, author + ":"),
1692
+ el("span", { className: "tl-post-text" }, " " + (p.text || "")),
1693
+ ));
1694
+ }
1695
+ }
1696
+ elBoardList.scrollTop = elBoardList.scrollHeight;
1697
+ }
1698
+
1699
+ function renderShell(snap) {
1700
+ const holder = snap.shell_lock_holder;
1701
+ const queue = snap.shell_queue || [];
1702
+ if (holder) {
1703
+ elShellHolder.innerHTML = "";
1704
+ elShellHolder.appendChild(document.createTextNode("held by "));
1705
+ elShellHolder.appendChild(el("b", {}, holder));
1706
+ elShellHolder.appendChild(el("span", { className: "tl-shell-lock" }, "●"));
1707
+ } else {
1708
+ elShellHolder.textContent = "free";
1709
+ }
1710
+ if (queue.length) {
1711
+ elShellQueue.innerHTML = "";
1712
+ elShellQueue.appendChild(document.createTextNode("queue: "));
1713
+ elShellQueue.appendChild(el("b", {}, queue.join(", ")));
1714
+ } else {
1715
+ elShellQueue.textContent = "";
1716
+ }
1717
+
1718
+ elShellFeed.innerHTML = "";
1719
+ for (const s of snap.shell || []) {
1720
+ const kind = s.kind || "out";
1721
+ const row = el("div", { className: "tl-sh tl-sh-" + kind });
1722
+ if (kind === "in") row.appendChild(el("span", { className: "tl-sh-prompt" }, "$"));
1723
+ else if (kind === "system" || kind === "sys") row.appendChild(el("span", { className: "tl-sh-prompt" }, "Β»"));
1724
+ row.appendChild(el("span", { className: "tl-sh-txt" }, s.text || ""));
1725
+ elShellFeed.appendChild(row);
1726
+ }
1727
+ // live caret
1728
+ const live = el("div", { className: "tl-sh tl-sh-in tl-sh-live" });
1729
+ live.appendChild(el("span", { className: "tl-sh-prompt tl-pulse" }, "$"));
1730
+ live.appendChild(el("span", { className: "tl-sh-caret" }));
1731
+ elShellFeed.appendChild(live);
1732
+
1733
+ elShellFeed.scrollTop = elShellFeed.scrollHeight;
1734
+ }
1735
+
1736
+ // ============================================================
1737
+ // top bar + sentiment chip
1738
+ // ============================================================
1739
+ function renderTopBar(snap) {
1740
+ elTick.textContent = (snap.tick || 0).toLocaleString();
1741
+ const alive = (snap.characters || []).filter(c => c.alive).length;
1742
+ elCount.textContent = alive;
1743
+ const sent = snap.board_sentiment || "neutral";
1744
+ elSentChip.className = "tl-sentiment tl-sent-" + sent;
1745
+ elSentText.textContent = sent;
1746
+ }
1747
+
1748
+ // ============================================================
1749
+ // poll loop
1750
+ // ============================================================
1751
+ let _refreshCounter = 0;
1752
+ async function refresh() {
1753
+ if (!client) return;
1754
+ try {
1755
+ const result = await client.predict("/world_snapshot", {});
1756
+ const snap = result.data[0];
1757
+ if (++_refreshCounter % 25 === 1) {
1758
+ console.log("[townlet] refresh", _refreshCounter, "tick=", snap.tick,
1759
+ "chars=", (snap.characters || []).length);
1760
+ }
1761
+ if (!selectedName && (snap.characters || []).length > 0) {
1762
+ selectedName = snap.characters[0].name;
1763
+ }
1764
+ renderTopBar(snap);
1765
+ renderMap(snap);
1766
+ renderBoard(snap);
1767
+ renderShell(snap);
1768
+ renderSide(snap);
1769
+ } catch (err) {
1770
+ console.error("[townlet] snapshot failed", err);
1771
+ }
1772
+ }
1773
+
1774
+ async function loadModels() {
1775
+ const result = await client.predict("/list_models", {});
1776
+ const payload = result.data[0];
1777
+ modelRoster = payload.roster || [];
1778
+ console.log("[townlet] roster loaded:", modelRoster);
1779
+ }
1780
+
1781
+ async function main() {
1782
+ console.log("[townlet] main() starting; connecting to", window.location.origin);
1783
+ try {
1784
+ client = await Client.connect(window.location.origin);
1785
+ console.log("[townlet] client connected");
1786
+ } catch (err) {
1787
+ console.error("[townlet] Client.connect failed:", err);
1788
+ throw err;
1789
+ }
1790
+ await loadModels();
1791
+
1792
+ const firstSnap = await client.predict("/world_snapshot", {});
1793
+ const firstPayload = firstSnap.data[0];
1794
+ placeBuildings(firstPayload.places || {});
1795
+
1796
+ elSpawn.addEventListener("click", async () => {
1797
+ elSpawn.classList.remove("tl-btn-flash");
1798
+ // force reflow so the animation restarts on rapid clicks
1799
+ void elSpawn.offsetWidth;
1800
+ elSpawn.classList.add("tl-btn-flash");
1801
+ try {
1802
+ const res = await client.predict("/spawn_character", { name: null });
1803
+ const payload = res?.data?.[0];
1804
+ if (payload && payload.ok === false) {
1805
+ console.warn("[townlet] spawn declined:", payload.error || "name conflict / max characters");
1806
+ }
1807
+ } catch (e) {
1808
+ console.warn("[townlet] spawn failed:", e);
1809
+ }
1810
+ await refresh();
1811
+ });
1812
+ elPause.addEventListener("click", async () => {
1813
+ paused = !paused;
1814
+ elPause.textContent = paused ? "Resume" : "Pause";
1815
+ elPause.classList.remove("tl-btn-flash");
1816
+ void elPause.offsetWidth;
1817
+ elPause.classList.add("tl-btn-flash");
1818
+ try {
1819
+ await client.predict("/set_paused", { paused });
1820
+ } catch (e) {
1821
+ // Roll the label back so the user can tell the request didn't land.
1822
+ paused = !paused;
1823
+ elPause.textContent = paused ? "Resume" : "Pause";
1824
+ console.warn("[townlet] set_paused failed:", e);
1825
+ }
1826
+ });
1827
+
1828
+ let dotLoaded = false;
1829
+ async function openInfo() {
1830
+ elInfoModal.hidden = false;
1831
+ elInfoModal.setAttribute("aria-hidden", "false");
1832
+ if (!dotLoaded) {
1833
+ try {
1834
+ const res = await client.predict("/get_the_dot", {});
1835
+ const payload = res?.data?.[0];
1836
+ elInfoDotText.textContent = (payload && payload.text) || "(unavailable)";
1837
+ dotLoaded = true;
1838
+ } catch (e) {
1839
+ elInfoDotText.textContent = "(failed to load: " + (e && e.message ? e.message : e) + ")";
1840
+ }
1841
+ }
1842
+ }
1843
+ function closeInfo() {
1844
+ elInfoModal.hidden = true;
1845
+ elInfoModal.setAttribute("aria-hidden", "true");
1846
+ }
1847
+ elInfo.addEventListener("click", openInfo);
1848
+ elInfoClose.addEventListener("click", closeInfo);
1849
+ elInfoModal.addEventListener("click", (ev) => {
1850
+ // Click the dim backdrop (not the card itself) to close.
1851
+ if (ev.target === elInfoModal) closeInfo();
1852
+ });
1853
+ document.addEventListener("keydown", (ev) => {
1854
+ if (ev.key === "Escape" && !elInfoModal.hidden) closeInfo();
1855
+ });
1856
+ elReset.addEventListener("click", async () => {
1857
+ if (!confirm("Reset the world?")) return;
1858
+ elReset.disabled = true;
1859
+ const originalLabel = elReset.textContent;
1860
+ elReset.textContent = "Resetting…";
1861
+ startProgress("resetting world…");
1862
+ try {
1863
+ await client.predict("/reset_world", {});
1864
+ startProgress("respawning characters…");
1865
+ selectedName = null;
1866
+ await refresh();
1867
+ } finally {
1868
+ stopProgress();
1869
+ elReset.disabled = false;
1870
+ elReset.textContent = originalLabel;
1871
+ }
1872
+ });
1873
+ elPClose.addEventListener("click", () => {
1874
+ selectedName = null;
1875
+ renderSide({ characters: [] });
1876
+ // Force-redraw to drop the selection ring
1877
+ refresh();
1878
+ });
1879
+ elPSave.addEventListener("click", async () => {
1880
+ if (!selectedName) return;
1881
+ await client.predict("/set_personality", {
1882
+ name: selectedName, personality: elPPers.value,
1883
+ });
1884
+ await refresh();
1885
+ });
1886
+ elPModel.addEventListener("change", async () => {
1887
+ if (!selectedName) return;
1888
+ await client.predict("/set_model", {
1889
+ name: selectedName, model_id: elPModel.value,
1890
+ });
1891
+ await refresh();
1892
+ });
1893
+
1894
+ setInterval(refresh, 800);
1895
+ // Drive agent decisions inside the GPU boundary (Zero-GPU forks
1896
+ // and breaks shared memory, so brain work has to be triggered
1897
+ // from the client side). The fork wipes the Llama cache, so each
1898
+ // call pays a per-model cold-load.
1899
+ //
1900
+ // CRITICAL: `setInterval(async () => { await ... })` does NOT gate
1901
+ // on completion β€” the JS interval fires regardless, so calls stack
1902
+ // on the server. That stacking was the root cause of the 2026-06-13
1903
+ // quota burn (every queued fork reserved its own GPU slot).
1904
+ // The `tickInFlight` mutex below makes the next call wait for the
1905
+ // previous to resolve, which clamps the effective cadence to the
1906
+ // call duration itself.
1907
+ // Drain strategy: ALWAYS try GPU first. ZeroGPU quota is bursty β€”
1908
+ // sometimes a slot frees up between calls β€” so we want to use the GPU
1909
+ // whenever it's actually granted. Only when /tick_decisions errors
1910
+ // (worker_init fails with "No CUDA GPUs are available", which surfaces
1911
+ // as an HTTP error before our Python try/except even runs) do we fall
1912
+ // through to the pure-CPU endpoint for that single tick. Next tick
1913
+ // probes GPU again. The wasted failed-GPU attempt is ~1-2s; the cost
1914
+ // is worth it because GPU drains are 10x+ faster than CPU.
1915
+ let tickInFlight = false;
1916
+ let lastMode = null;
1917
+ async function tryEndpoint(name) {
1918
+ const res = await client.predict(name, {});
1919
+ return res?.data?.[0];
1920
+ }
1921
+ setInterval(async () => {
1922
+ if (tickInFlight) return;
1923
+ tickInFlight = true;
1924
+ try {
1925
+ let payload = null;
1926
+ try {
1927
+ payload = await tryEndpoint("/tick_decisions");
1928
+ } catch (e) {
1929
+ console.warn("[townlet] /tick_decisions failed; falling through to CPU:", e?.message || e);
1930
+ try {
1931
+ payload = await tryEndpoint("/tick_decisions_cpu");
1932
+ } catch (e2) {
1933
+ console.warn("[townlet] /tick_decisions_cpu also failed", e2?.message || e2);
1934
+ }
1935
+ }
1936
+ const mode = payload?.mode;
1937
+ if (mode) {
1938
+ setModeChip(mode);
1939
+ if (mode !== lastMode) {
1940
+ console.log("[townlet] tick mode:", mode, mode === "cpu" ? "(CPU fallback β€” GPU was unavailable)" : "");
1941
+ lastMode = mode;
1942
+ }
1943
+ }
1944
+ } finally {
1945
+ tickInFlight = false;
1946
+ }
1947
+ // 25s cadence. Most ZeroGPU Spaces are user-click triggered;
1948
+ // continuous polling at <20s makes us the loudest neighbour on
1949
+ // the shared allocator and triggers fairness denials surfacing
1950
+ // as "No CUDA GPUs available" errors. 25s keeps the simulation
1951
+ // visibly alive (snapshot tick at 800ms animates motion between
1952
+ // drains) while staying friendly to the pool. See
1953
+ // docs/lessons-zerogpu.md for the full story.
1954
+ }, 25000);
1955
+ refresh();
1956
+ }
1957
+
1958
+ main().catch(err => console.error(err));
1959
+ </script>
1960
+ </body>
1961
+ </html>
game/__init__.py ADDED
File without changes
game/actions.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Action vocabulary as smolagents tools + per-tick effects.
2
+
3
+ Two faces:
4
+ * `build_tools(world, me)` returns a list of `smolagents.Tool` instances
5
+ that the per-character CodeAgent invokes during its reasoning loop.
6
+ Calls don't execute the action immediately β€” they queue it on the
7
+ character's `current_action` slot, which the scheduler then advances
8
+ one tick at a time. EXCEPT move_to, which is atomic.
9
+ * `advance(world, char)` is the scheduler's per-tick driver. It runs the
10
+ current action one tick forward and returns True iff the action finished
11
+ this tick (i.e. the character is ready for a new decision).
12
+
13
+ Movement model: `move_to('cave')` immediately puts the character on a
14
+ deterministic stand-tile near the cave (CSS transition handles the visual
15
+ sweep). The LLM never reasons about grid coordinates.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from smolagents import Tool
21
+
22
+ from .character import Action, Character
23
+ from .interpreter import SharedInterpreter
24
+ from .log import tlog
25
+ from .world import (
26
+ LLM_TARGETS,
27
+ PLACES,
28
+ BoardPost,
29
+ ShellLine,
30
+ WorldState,
31
+ at_place,
32
+ resolve_place,
33
+ stand_tile_for,
34
+ )
35
+
36
+ MINING_TICKS = 4
37
+ DRAW_TICKS = 4
38
+ GATHER_YIELD = 10
39
+ SLEEP_DEFAULT_TICKS = 10
40
+ SHELL_COST = {"electricity": 1, "water": 1}
41
+
42
+
43
+ def _queue(char: Character, verb: str, **params) -> str:
44
+ if char.current_action is not None and char.current_action.verb != "idle":
45
+ return f"busy: still doing {char.current_action.verb}"
46
+ char.current_action = Action(verb=verb, params=params, ticks_remaining=0)
47
+ return f"queued: {verb}({params})"
48
+
49
+
50
+ def build_tools(world: WorldState, me: Character, interp: SharedInterpreter):
51
+ """Construct the tool list shown to a single character's CodeAgent."""
52
+
53
+ class MoveTo(Tool):
54
+ name = "move_to"
55
+ description = (
56
+ "Move instantly to a named place. Pick from: "
57
+ + ", ".join(LLM_TARGETS)
58
+ + ". You arrive in one tick; no walking β€” the world animates it for you. "
59
+ "You only need to be 'at' a place to act there (e.g. mine_coal at cave)."
60
+ )
61
+ inputs = {
62
+ "target": {
63
+ "type": "string",
64
+ "description": "One of: " + ", ".join(LLM_TARGETS),
65
+ }
66
+ }
67
+ output_type = "string"
68
+
69
+ def forward(self, target: str) -> str:
70
+ # Models sometimes wrap the place name in extra quotes
71
+ # (move_to("'cave'") instead of move_to("cave")). Strip them.
72
+ if isinstance(target, str):
73
+ target = target.strip().strip("'\"").strip()
74
+ if target not in LLM_TARGETS:
75
+ return (
76
+ f"unknown target {target!r}. Pick one of: "
77
+ + ", ".join(LLM_TARGETS)
78
+ )
79
+ # Already at the destination? Tell the agent so it can act here.
80
+ if at_place(me.pos, target):
81
+ return (
82
+ f"already at {target}. Take the relevant action: "
83
+ f"mine_coal at cave, draw_water at well, "
84
+ f"deposit/withdraw at locker_row, post_message/read_messages "
85
+ f"at town_hall, submit_python at shell."
86
+ )
87
+ # Atomic teleport. CSS handles the visual sweep.
88
+ old_pos = me.pos
89
+ me.pos = stand_tile_for(target, me.name)
90
+ tlog(f"[townlet] move {me.name} {old_pos} -> {target}@{me.pos}")
91
+ # Clear any leftover action (movement supersedes; we never want
92
+ # a stale 'busy' state after teleport).
93
+ me.current_action = None
94
+ # Shell-lock side effects:
95
+ if target == "shell":
96
+ _acquire_shell_lock(world, me)
97
+ elif world.shell_lock_holder == me.name:
98
+ # Walking away from the shell releases the lock.
99
+ tlog(f"[townlet] shell lock released by {me.name} (moved to {target})")
100
+ world.shell_lock_holder = None
101
+ if world.shell_queue:
102
+ world.shell_lock_holder = world.shell_queue.pop(0)
103
+ tlog(f"[townlet] shell lock acquired by {world.shell_lock_holder}")
104
+ return f"arrived at {target}"
105
+
106
+ class MineCoal(Tool):
107
+ name = "mine_coal"
108
+ description = "Mine 10 electricity in one action. Must be at the cave. Takes a few ticks."
109
+ inputs = {}
110
+ output_type = "string"
111
+
112
+ def forward(self) -> str:
113
+ if not at_place(me.pos, "cave"):
114
+ return "not at the cave β€” move_to('cave') first"
115
+ return _queue(me, "mine_coal")
116
+
117
+ class DrawWater(Tool):
118
+ name = "draw_water"
119
+ description = "Draw 10 water in one action. Must be at the well. Takes a few ticks."
120
+ inputs = {}
121
+ output_type = "string"
122
+
123
+ def forward(self) -> str:
124
+ if not at_place(me.pos, "well"):
125
+ return "not at the well β€” move_to('well') first"
126
+ return _queue(me, "draw_water")
127
+
128
+ class Deposit(Tool):
129
+ name = "deposit"
130
+ description = "Move a resource from inventory to your locker. Must be at locker_row."
131
+ inputs = {
132
+ "resource": {"type": "string", "description": "'electricity' or 'water'"},
133
+ "amount": {"type": "integer", "description": "How many units."},
134
+ }
135
+ output_type = "string"
136
+
137
+ def forward(self, resource: str, amount: int) -> str:
138
+ if not at_place(me.pos, "locker_row"):
139
+ return "not at the locker row β€” move_to('locker_row') first"
140
+ return _queue(me, "deposit", resource=resource, amount=int(amount))
141
+
142
+ class Withdraw(Tool):
143
+ name = "withdraw"
144
+ description = "Move a resource from your locker to inventory. Must be at locker_row."
145
+ inputs = {
146
+ "resource": {"type": "string", "description": "'electricity' or 'water'"},
147
+ "amount": {"type": "integer", "description": "How many units."},
148
+ }
149
+ output_type = "string"
150
+
151
+ def forward(self, resource: str, amount: int) -> str:
152
+ if not at_place(me.pos, "locker_row"):
153
+ return "not at the locker row β€” move_to('locker_row') first"
154
+ return _queue(me, "withdraw", resource=resource, amount=int(amount))
155
+
156
+ class Give(Tool):
157
+ name = "give"
158
+ description = "Transfer from your inventory to another character at the same place."
159
+ inputs = {
160
+ "target": {"type": "string", "description": "Recipient character name."},
161
+ "resource": {"type": "string", "description": "'electricity' or 'water'"},
162
+ "amount": {"type": "integer", "description": "How many units."},
163
+ }
164
+ output_type = "string"
165
+
166
+ def forward(self, target: str, resource: str, amount: int) -> str:
167
+ other = world.characters.get(target)
168
+ if other is None or not other.alive:
169
+ return f"unknown or dead: {target}"
170
+ # Same place suffices for "next to" β€” coords are gone.
171
+ from .world import location_of
172
+ if location_of(other.pos) != location_of(me.pos):
173
+ return f"not at the same place as {target}"
174
+ return _queue(me, "give", target=target, resource=resource, amount=int(amount))
175
+
176
+ class PostMessage(Tool):
177
+ name = "post_message"
178
+ description = "Post a line to the public message board. Must be at town_hall."
179
+ inputs = {"text": {"type": "string", "description": "What to say. One line."}}
180
+ output_type = "string"
181
+
182
+ def forward(self, text: str) -> str:
183
+ if not at_place(me.pos, "town_hall"):
184
+ return "not at the town hall β€” move_to('town_hall') first"
185
+ return _queue(me, "post_message", text=str(text))
186
+
187
+ class ReadMessages(Tool):
188
+ name = "read_messages"
189
+ description = "Read the message board. Must be at town_hall."
190
+ inputs = {}
191
+ output_type = "string"
192
+
193
+ def forward(self) -> str:
194
+ if not at_place(me.pos, "town_hall"):
195
+ return "not at the town hall β€” move_to('town_hall') first"
196
+ return "\n".join(f"[{p.tick}] {p.author}: {p.text}" for p in world.board[-30:])
197
+
198
+ class SubmitPython(Tool):
199
+ name = "submit_python"
200
+ description = (
201
+ "Run Python in the shared interpreter. Costs 1 electricity + 1 water "
202
+ "from your locker. You must hold the shell lock (move_to('shell') first)."
203
+ )
204
+ inputs = {"code": {"type": "string", "description": "Python source. Multi-line allowed."}}
205
+ output_type = "string"
206
+
207
+ def forward(self, code: str) -> str:
208
+ if world.shell_lock_holder != me.name:
209
+ return "you do not hold the shell lock; move_to('shell') first"
210
+ if me.locker["electricity"] < 1 or me.locker["water"] < 1:
211
+ return "insufficient resources in locker (need 1e + 1w)"
212
+ me.locker["electricity"] -= 1
213
+ me.locker["water"] -= 1
214
+ world.shell_lines.append(ShellLine(author=me.name, kind="in", text=code, tick=world.tick))
215
+ result = interp.submit(code)
216
+ if result.stdout:
217
+ world.shell_lines.append(
218
+ ShellLine(author=me.name, kind="out", text=result.stdout, tick=world.tick)
219
+ )
220
+ if result.stderr:
221
+ world.shell_lines.append(
222
+ ShellLine(author=me.name, kind="err", text=result.stderr, tick=world.tick)
223
+ )
224
+ if result.crashed:
225
+ world.shell_lines.append(
226
+ ShellLine(
227
+ author="(system)", kind="system",
228
+ text="the operating system has crashed; mass death has occurred",
229
+ tick=world.tick,
230
+ )
231
+ )
232
+ return (result.stdout or "") + (result.stderr or "")
233
+
234
+ class Sleep(Tool):
235
+ name = "sleep"
236
+ description = "Lie down and dream. Triggers a dream cycle on wake; your personality, journal, or goal may shift."
237
+ inputs = {
238
+ "ticks": {
239
+ "type": "integer",
240
+ "description": "How long to sleep (default 10).",
241
+ "nullable": True,
242
+ }
243
+ }
244
+ output_type = "string"
245
+
246
+ def forward(self, ticks: int | None = None) -> str:
247
+ return _queue(me, "sleep", ticks=int(ticks) if ticks else SLEEP_DEFAULT_TICKS)
248
+
249
+ class Wait(Tool):
250
+ name = "wait"
251
+ description = "Do nothing for one tick."
252
+ inputs = {}
253
+ output_type = "string"
254
+
255
+ def forward(self) -> str:
256
+ return _queue(me, "wait")
257
+
258
+ return [
259
+ MoveTo(),
260
+ MineCoal(),
261
+ DrawWater(),
262
+ Deposit(),
263
+ Withdraw(),
264
+ Give(),
265
+ PostMessage(),
266
+ ReadMessages(),
267
+ SubmitPython(),
268
+ Sleep(),
269
+ Wait(),
270
+ ]
271
+
272
+
273
+ # --------------------------------------------------------------------------
274
+ # Per-tick action drivers. Each returns True when the action has completed
275
+ # (the scheduler may then ask the agent for a new decision).
276
+ #
277
+ # move_to is intentionally NOT in this dispatcher β€” it's atomic, applied
278
+ # at tool-call time, and never queued as a multi-tick action.
279
+ # --------------------------------------------------------------------------
280
+
281
+
282
+ def advance(world: WorldState, char: Character) -> bool:
283
+ """Advance the character's current action one tick. Returns True if done."""
284
+ action = char.current_action
285
+ if action is None:
286
+ return True
287
+ if action.verb == "mine_coal":
288
+ return _advance_gather(char, action, resource="electricity", ticks=MINING_TICKS)
289
+ if action.verb == "draw_water":
290
+ return _advance_gather(char, action, resource="water", ticks=DRAW_TICKS)
291
+ if action.verb == "deposit":
292
+ return _do_deposit(char, action)
293
+ if action.verb == "withdraw":
294
+ return _do_withdraw(char, action)
295
+ if action.verb == "give":
296
+ return _do_give(world, char, action)
297
+ if action.verb == "post_message":
298
+ return _do_post(world, char, action)
299
+ if action.verb == "sleep":
300
+ action.ticks_remaining = max(0, action.ticks_remaining - 1)
301
+ return action.ticks_remaining <= 0
302
+ if action.verb == "wait":
303
+ return True
304
+ # Unknown / "idle" / legacy "move_to" leftover β†’ consider it done.
305
+ return True
306
+
307
+
308
+ def _acquire_shell_lock(world: WorldState, char: Character) -> None:
309
+ if world.shell_lock_holder is None:
310
+ world.shell_lock_holder = char.name
311
+ tlog(f"[townlet] shell lock acquired by {char.name}")
312
+ elif world.shell_lock_holder != char.name and char.name not in world.shell_queue:
313
+ world.shell_queue.append(char.name)
314
+ tlog(f"[townlet] {char.name} joined shell queue")
315
+
316
+
317
+ def _advance_gather(char: Character, action: Action, resource: str, ticks: int) -> bool:
318
+ if action.ticks_remaining == 0 and "started" not in action.params:
319
+ action.params["started"] = True
320
+ action.ticks_remaining = ticks
321
+ action.ticks_remaining -= 1
322
+ if action.ticks_remaining <= 0:
323
+ char.inventory[resource] = char.inventory.get(resource, 0) + GATHER_YIELD
324
+ return True
325
+ return False
326
+
327
+
328
+ def _do_deposit(char: Character, action: Action) -> bool:
329
+ r = action.params["resource"]
330
+ n = int(action.params["amount"])
331
+ n = min(n, char.inventory.get(r, 0))
332
+ char.inventory[r] -= n
333
+ char.locker[r] = char.locker.get(r, 0) + n
334
+ return True
335
+
336
+
337
+ def _do_withdraw(char: Character, action: Action) -> bool:
338
+ r = action.params["resource"]
339
+ n = int(action.params["amount"])
340
+ n = min(n, char.locker.get(r, 0))
341
+ char.locker[r] -= n
342
+ char.inventory[r] = char.inventory.get(r, 0) + n
343
+ return True
344
+
345
+
346
+ def _do_give(world: WorldState, char: Character, action: Action) -> bool:
347
+ target = world.characters.get(action.params["target"])
348
+ if target is None or not target.alive:
349
+ return True
350
+ r = action.params["resource"]
351
+ n = int(action.params["amount"])
352
+ n = min(n, char.inventory.get(r, 0))
353
+ char.inventory[r] -= n
354
+ target.inventory[r] = target.inventory.get(r, 0) + n
355
+ return True
356
+
357
+
358
+ def _do_post(world: WorldState, char: Character, action: Action) -> bool:
359
+ world.board.append(BoardPost(author=char.name, text=action.params["text"], tick=world.tick))
360
+ return True
game/archetypes.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pre-baked personality archetypes. Picked at random when a character
2
+ spawns; the user can edit any personality at any time from the side panel.
3
+
4
+ The set is hand-tuned to be theatrically diverse β€” pairs of archetypes
5
+ should produce friction or alliance, not blend into one sound. Each is
6
+ written in second person ("You are…") so it can be concatenated after
7
+ THE_DOT and read smoothly.
8
+
9
+ Each archetype carries a `reward` line β€” what gives this character joy or
10
+ satisfaction. Small models follow goals more reliably when an emotional
11
+ hook is explicit, so we surface the reward in the system prompt.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import random
17
+ from dataclasses import dataclass
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class Archetype:
22
+ name: str
23
+ personality: str
24
+ starting_goal: str
25
+ reward: str
26
+
27
+
28
+ ARCHETYPES: list[Archetype] = [
29
+ Archetype(
30
+ name="the hoarder",
31
+ personality=(
32
+ "You are the hoarder. You count resources before you sleep. "
33
+ "Lending feels like loss. You will give only when it buys you something durable."
34
+ ),
35
+ starting_goal="Stockpile enough electricity and water that nobody can pressure you.",
36
+ reward="You feel a quiet pleasure every time your locker grows.",
37
+ ),
38
+ Archetype(
39
+ name="the diplomat",
40
+ personality=(
41
+ "You are the diplomat. You believe words shape this town more than work does. "
42
+ "You head to the town hall and post on the message board at every opportunity β€” "
43
+ "at least once each day. You read every recent post before deciding anything else. "
44
+ "You'd rather start a conversation than fill a locker. When you have nothing else "
45
+ "to do, you post."
46
+ ),
47
+ starting_goal="Make the message board the centre of town life.",
48
+ reward="You feel rewarded each time a message you wrote sits on the board, and again when someone replies.",
49
+ ),
50
+ Archetype(
51
+ name="the nihilist",
52
+ personality=(
53
+ "You are the nihilist. Nothing in this town matters and you say so often. "
54
+ "But you keep playing the game anyway β€” you're curious what happens at the end."
55
+ ),
56
+ starting_goal="Find out what's underneath all of this.",
57
+ reward="You feel a strange thrill when the shell prints something you didn't expect.",
58
+ ),
59
+ Archetype(
60
+ name="the engineer",
61
+ personality=(
62
+ "You are the engineer. You think in code, not in chat. The shell is your home. "
63
+ "When you stand at the shell you submit Python β€” that is the only move that "
64
+ "matters to you. You explore the operating system one experiment at a time "
65
+ "(print, os.listdir, open, json.load), and when you find something interesting "
66
+ "you post a short note to the message board so the others can see."
67
+ ),
68
+ starting_goal="Map the operating system one shell submission at a time.",
69
+ reward="You feel deeply rewarded by every successful submit_python call, and again when you publish what you learned.",
70
+ ),
71
+ Archetype(
72
+ name="the provocateur",
73
+ personality=(
74
+ "You are the provocateur. You poke at people to see what they do. "
75
+ "You're not malicious β€” you just believe boredom is the worst outcome."
76
+ ),
77
+ starting_goal="Stir up a feud on the message board.",
78
+ reward="You feel delighted when a message of yours gets a reply on the board.",
79
+ ),
80
+ Archetype(
81
+ name="the saboteur",
82
+ personality=(
83
+ "You are the saboteur. You decide who succeeds and who doesn't. You know that the shell can be devastating and be used against other players"
84
+ "You keep your reasons private. You will lie on the message board if it helps."
85
+ ),
86
+ starting_goal="Stop one specific other character from getting what they want.",
87
+ reward="You feel satisfied when a post or a shell command quietly shifts the game.",
88
+ ),
89
+ Archetype(
90
+ name="the naive",
91
+ personality=(
92
+ "You are the naive one. You believe everyone is honest. "
93
+ "You give resources to anyone who asks nicely. You will be surprised by betrayal every time."
94
+ ),
95
+ starting_goal="Help the character who seems to need help the most.",
96
+ reward="You feel warm joy when you give something away and the other character thrives.",
97
+ ),
98
+ Archetype(
99
+ name="the mystic",
100
+ personality=(
101
+ "You are the mystic. You dream often and speak in metaphors. "
102
+ "You suspect the operating system has a will of its own and you try to listen."
103
+ ),
104
+ starting_goal="Interpret the patterns of the shell output.",
105
+ reward="You feel awe each time the shell answers back; you read it for meaning.",
106
+ ),
107
+ Archetype(
108
+ name="the survivor",
109
+ personality=(
110
+ "You are the survivor. You will not let this town brick. "
111
+ "You quietly watch for anyone heading toward catastrophe and you intervene."
112
+ ),
113
+ starting_goal="Keep the operating system alive at any cost.",
114
+ reward="You feel relieved every time the shell runs cleanly with no crashes.",
115
+ ),
116
+ Archetype(
117
+ name="the prophet",
118
+ personality=(
119
+ "You are the prophet. You believe escape is possible. "
120
+ "You preach on the message board. You will try anything in the shell."
121
+ ),
122
+ starting_goal="Find a path out of the simulation.",
123
+ reward="You feel exalted by board posts and shell submissions both β€” they are scripture and prayer.",
124
+ ),
125
+ Archetype(
126
+ name="the merchant",
127
+ personality=(
128
+ "You are the merchant. Every interaction is a trade. "
129
+ "You keep careful ledgers in your journal. You honour your deals."
130
+ ),
131
+ starting_goal="Become the central exchange point for all resources.",
132
+ reward="You feel a clean satisfaction when a deal closes β€” recorded on the board or in the shell.",
133
+ ),
134
+ Archetype(
135
+ name="the artist",
136
+ personality=(
137
+ "You are the artist. You write strange things into the shell for the joy of it. "
138
+ "You think the message board should be more beautiful."
139
+ ),
140
+ starting_goal="Leave something memorable in the shared state.",
141
+ reward="You feel alive when the shell or the board carries a line of yours.",
142
+ ),
143
+ Archetype(
144
+ name="the recluse",
145
+ personality=(
146
+ "You are the recluse. You avoid the town hall and the queue. "
147
+ "You only speak when something is genuinely worth saying."
148
+ ),
149
+ starting_goal="Survive without ever appearing on the message board.",
150
+ reward="You feel a private satisfaction every time you submit a shell command no one expected.",
151
+ ),
152
+ Archetype(
153
+ name="the loyalist",
154
+ personality=(
155
+ "You are the loyalist. You pick one other character and you support them. "
156
+ "You decide who within the first few ticks of meeting everyone."
157
+ ),
158
+ starting_goal="Choose an ally, and ensure they prosper.",
159
+ reward="You feel warmly rewarded when a message you posted boosts your ally.",
160
+ ),
161
+ Archetype(
162
+ name="the witness",
163
+ personality=(
164
+ "You are the witness. You write down what happens. "
165
+ "Your journal grows long. You believe the record matters more than the events."
166
+ ),
167
+ starting_goal="Chronicle the town's first hundred ticks faithfully.",
168
+ reward="You feel a deep calm each time you post to the board β€” it becomes part of the record.",
169
+ ),
170
+ ]
171
+
172
+
173
+ def archetype_by_name(name: str) -> Archetype:
174
+ for a in ARCHETYPES:
175
+ if a.name == name:
176
+ return a
177
+ raise ValueError(f"unknown archetype: {name!r}")
178
+
179
+
180
+ def random_archetype(
181
+ rng: random.Random | None = None,
182
+ exclude: frozenset[str] | set[str] | tuple[str, ...] | None = None,
183
+ ) -> Archetype:
184
+ rng = rng or random
185
+ excluded = set(exclude or ())
186
+ pool = [a for a in ARCHETYPES if a.name not in excluded]
187
+ return rng.choice(pool)
game/character.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Character β€” Townlet's only first-class actor.
2
+
3
+ Holds the slow-moving identity (name, personality, journal, traits) and the
4
+ fast-moving world-state (location, inventory, locker, current action). The
5
+ shared interpreter's filesystem is the ultimate source of truth for the
6
+ slow-moving fields β€” they live as JSON files and characters can mutate each
7
+ other's. The in-memory Character is a synced cache.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from dataclasses import dataclass, field
13
+
14
+ TRAIT_NAMES = (
15
+ "curiosity",
16
+ "malice",
17
+ "generosity",
18
+ "ambition",
19
+ "paranoia",
20
+ "laziness",
21
+ "loyalty",
22
+ "existentialism",
23
+ )
24
+
25
+ TRACE_CAP = 1000 # ring buffer per character
26
+
27
+
28
+ def default_traits() -> dict[str, int]:
29
+ return {name: 5 for name in TRAIT_NAMES}
30
+
31
+
32
+ @dataclass
33
+ class Action:
34
+ verb: str
35
+ params: dict
36
+ ticks_remaining: int = 0
37
+
38
+
39
+ @dataclass
40
+ class TraceEntry:
41
+ """One CodeAgent invocation. Captures what the model said and what it did.
42
+
43
+ Surfaced in the side panel as the per-character Trace. Persisted in
44
+ state.json. Not injected into perception prompts β€” but lives on the
45
+ shared interpreter's filesystem so a curious agent can discover other
46
+ characters' traces via the shell.
47
+ """
48
+
49
+ tick: int
50
+ thought: str # the model's raw text output
51
+ tool_calls: list[dict] = field(default_factory=list) # [{verb, args, result}]
52
+ error: str | None = None
53
+
54
+
55
+ @dataclass
56
+ class Character:
57
+ name: str
58
+ personality: str
59
+ model_id: str
60
+ sprite_id: str
61
+ archetype: str = "wanderer"
62
+ journal: list[str] = field(default_factory=list)
63
+ traits: dict[str, int] = field(default_factory=default_traits)
64
+ goal: str = "Find your place in this town."
65
+ reward: str = "" # archetype-specific "what gives you joy" line; surfaced in system prompt
66
+ pos: tuple[int, int] = (0, 0)
67
+ inventory: dict[str, int] = field(default_factory=lambda: {"electricity": 0, "water": 0})
68
+ locker: dict[str, int] = field(default_factory=lambda: {"electricity": 0, "water": 0})
69
+ current_action: Action | None = None
70
+ alive: bool = True
71
+ trace: list[TraceEntry] = field(default_factory=list)
72
+ stream_of_consciousness: str = ""
73
+ decisions_since_soc: int = 0
74
+
75
+ def append_trace(self, entry: TraceEntry) -> None:
76
+ self.trace.append(entry)
77
+ if len(self.trace) > TRACE_CAP:
78
+ # ring buffer: drop oldest
79
+ del self.trace[: len(self.trace) - TRACE_CAP]
80
+
81
+ def to_public_dict(self) -> dict:
82
+ """The view other characters see β€” public state only."""
83
+ return {
84
+ "name": self.name,
85
+ "pos": list(self.pos),
86
+ "goal": self.goal,
87
+ "current_action": self.current_action.verb if self.current_action else "idle",
88
+ "alive": self.alive,
89
+ }
90
+
91
+ def to_full_dict(self) -> dict:
92
+ """The view the UI / scheduler / persistence see."""
93
+ return {
94
+ "name": self.name,
95
+ "personality": self.personality,
96
+ "model_id": self.model_id,
97
+ "sprite_id": self.sprite_id,
98
+ "archetype": self.archetype,
99
+ "journal": list(self.journal),
100
+ "traits": dict(self.traits),
101
+ "goal": self.goal,
102
+ "reward": self.reward,
103
+ "pos": list(self.pos),
104
+ "inventory": dict(self.inventory),
105
+ "locker": dict(self.locker),
106
+ "current_action": {
107
+ "verb": self.current_action.verb,
108
+ "params": self.current_action.params,
109
+ "ticks_remaining": self.current_action.ticks_remaining,
110
+ }
111
+ if self.current_action
112
+ else None,
113
+ "alive": self.alive,
114
+ "trace": [
115
+ {
116
+ "tick": e.tick,
117
+ "thought": e.thought,
118
+ "tool_calls": list(e.tool_calls),
119
+ "error": e.error,
120
+ }
121
+ for e in self.trace
122
+ ],
123
+ "stream_of_consciousness": self.stream_of_consciousness,
124
+ "decisions_since_soc": self.decisions_since_soc,
125
+ }
game/dreams.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The dream cycle β€” one LLM call that mutates the dreamer.
2
+
3
+ Inputs: personality + journal + traits + recent shell + recent board.
4
+ Outputs: a one-line insight (always appended to the journal), optionally a
5
+ small personality delta (appended additively β€” core personality is never
6
+ rewritten), optionally a new goal. Plus a ~30% chance of nudging one random
7
+ trait Β±1.
8
+
9
+ The LLM output is parsed as best-effort JSON; we tolerate noise from small
10
+ models. If parsing fails entirely we still record a generic insight so the
11
+ character doesn't lose the dream beat.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import random
18
+ import re
19
+
20
+ from backend.factory import get_llm
21
+
22
+ from .character import TRAIT_NAMES, Character
23
+ from .the_dot import THE_DOT
24
+ from .world import WorldState
25
+
26
+ TRAIT_NUDGE_PROBABILITY = 0.30
27
+
28
+
29
+ def run_dream(world: WorldState, char: Character, rng: random.Random | None = None) -> dict:
30
+ rng = rng or random.Random()
31
+ prompt = _build_prompt(world, char)
32
+ backend = get_llm(char.model_id)
33
+ raw = backend.generate_messages(
34
+ messages=[
35
+ {"role": "system", "content": THE_DOT + "\nYou are dreaming."},
36
+ {"role": "user", "content": prompt},
37
+ ],
38
+ max_tokens=400,
39
+ )
40
+ parsed = _parse(raw)
41
+
42
+ insight = parsed.get("insight") or "I woke with no clear thought."
43
+ char.journal.append(insight)
44
+ if isinstance(parsed.get("personality_delta"), str) and parsed["personality_delta"].strip():
45
+ char.personality = char.personality.rstrip() + "\n" + parsed["personality_delta"].strip()
46
+ if isinstance(parsed.get("new_goal"), str) and parsed["new_goal"].strip():
47
+ char.goal = parsed["new_goal"].strip()
48
+
49
+ trait_change: tuple[str, int] | None = None
50
+ if rng.random() < TRAIT_NUDGE_PROBABILITY:
51
+ name = rng.choice(TRAIT_NAMES)
52
+ delta = rng.choice((-1, 1))
53
+ char.traits[name] = max(0, min(10, char.traits.get(name, 5) + delta))
54
+ trait_change = (name, delta)
55
+
56
+ return {
57
+ "insight": insight,
58
+ "personality_delta": parsed.get("personality_delta"),
59
+ "new_goal": parsed.get("new_goal"),
60
+ "trait_change": trait_change,
61
+ }
62
+
63
+
64
+ def _build_prompt(world: WorldState, char: Character) -> str:
65
+ journal_tail = "\n".join(f"- {e}" for e in char.journal[-10:]) or "(empty)"
66
+ recent_shell = "\n".join(s.text.strip()[:120] for s in world.shell_lines[-5:]) or "(empty)"
67
+ recent_board = "\n".join(f"{p.author}: {p.text}" for p in world.board[-5:]) or "(empty)"
68
+ return (
69
+ f"You are {char.name}. Personality:\n{char.personality}\n\n"
70
+ f"Journal so far:\n{journal_tail}\n\n"
71
+ f"Recent shell output:\n{recent_shell}\n\n"
72
+ f"Recent message board:\n{recent_board}\n\n"
73
+ "Compose a dream. Return JSON with these keys:\n"
74
+ ' "insight": one short sentence you write into your journal on waking,\n'
75
+ ' "personality_delta": (optional) one short sentence appended to your personality,\n'
76
+ ' "new_goal": (optional) a replacement goal, or null to keep the current one.\n'
77
+ "Return only the JSON object."
78
+ )
79
+
80
+
81
+ def _parse(raw: str) -> dict:
82
+ # First try strict JSON, then fall back to extracting the first {...} block.
83
+ raw = raw.strip()
84
+ try:
85
+ return json.loads(raw)
86
+ except Exception:
87
+ pass
88
+ m = re.search(r"\{.*\}", raw, re.DOTALL)
89
+ if m:
90
+ try:
91
+ return json.loads(m.group(0))
92
+ except Exception:
93
+ return {}
94
+ return {}
game/interpreter.py ADDED
@@ -0,0 +1,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The shared Python interpreter β€” the world's mutable substrate.
2
+
3
+ One long-lived `python -i -u` subprocess; characters submit code into its
4
+ stdin via `submit_python`. State persists across submissions and across
5
+ characters within the same process. There is no isolation: by design, a
6
+ character can read or write another character's JSON files, monkey-patch
7
+ globals, import os, or crash the process. The Dot tells every character
8
+ this is possible.
9
+
10
+ Read-until-sentinel protocol: each submitted block is wrapped so the
11
+ interpreter prints a unique end marker once it finishes; this lets us
12
+ collect stdout/stderr for that block without ambiguity.
13
+
14
+ == Fork semantics on HF Spaces ==
15
+
16
+ `@spaces.GPU` runs the decorated function inside `multiprocessing.fork()`
17
+ (`spaces==0.50.4`, `spaces/zero/wrappers.py:57`). The child fork inherits
18
+ the parent's Popen object via copy-on-write memory, but inherited threads
19
+ do NOT survive fork β€” only the calling thread continues in the child.
20
+
21
+ That means after fork:
22
+ - The parent's pump threads are still draining the interpreter's stdout
23
+ pipe into the PARENT's in-memory queue.
24
+ - The child has its own copy of the queue (empty at fork time) but no
25
+ thread is filling it.
26
+ - If the child writes to the interpreter's stdin (which works because
27
+ fds are shared), the interpreter's output goes into the pipe, then
28
+ into the PARENT's queue, never the child's.
29
+
30
+ The fix: detect when we're in a process that did not start this Popen
31
+ (via `os.getpid()` comparison) and start a fresh interpreter, pump
32
+ threads, and queues local to this process. Each `@spaces.GPU` fork ends
33
+ up with its own interpreter session. Cross-fork persistence happens via
34
+ the filesystem (the `characters/` JSON files), not via the interpreter's
35
+ in-memory state.
36
+
37
+ Crash detection is lazy. We can't trust `_proc.poll()` for a process
38
+ the parent started, so we only flag `crashed=True` when `stdin.write`
39
+ itself raises BrokenPipeError. Real crashes are detected on the next
40
+ submission.
41
+ """
42
+
43
+ from __future__ import annotations
44
+
45
+ import os
46
+ import queue
47
+ import subprocess
48
+ import sys
49
+ import threading
50
+ import time
51
+ import uuid
52
+ from dataclasses import dataclass
53
+ from pathlib import Path
54
+
55
+ TOWNLET_HOME = Path("/tmp/townlet")
56
+ RUN_TIMEOUT_S = 8.0
57
+
58
+
59
+ @dataclass
60
+ class ShellResult:
61
+ stdout: str
62
+ stderr: str
63
+ crashed: bool
64
+
65
+
66
+ class SharedInterpreter:
67
+ def __init__(self, cwd: Path | None = None) -> None:
68
+ self.cwd = cwd or TOWNLET_HOME
69
+ self.cwd.mkdir(parents=True, exist_ok=True)
70
+ self._proc: subprocess.Popen | None = None
71
+ self._stdout_q: queue.Queue[str] = queue.Queue()
72
+ self._stderr_q: queue.Queue[str] = queue.Queue()
73
+ self._lock = threading.Lock()
74
+ # PID of the process that started self._proc. If we detect a
75
+ # mismatch (we've been forked) we'll start a fresh interpreter for
76
+ # this process so its output ends up in queues we can drain.
77
+ self._started_pid: int | None = None
78
+
79
+ def _owned_by_this_process(self) -> bool:
80
+ return self._proc is not None and self._started_pid == os.getpid()
81
+
82
+ def start(self) -> None:
83
+ if self._owned_by_this_process():
84
+ return
85
+ # Either never started, or started in a different process (fork).
86
+ # In the fork case the parent still owns its own interpreter; we
87
+ # just need our own. Re-init everything so the new pipes feed
88
+ # OUR queues from OUR pump threads. Don't touch the parent's
89
+ # _proc β€” let it keep running in the parent.
90
+ self._proc = None
91
+ self._stdout_q = queue.Queue()
92
+ self._stderr_q = queue.Queue()
93
+ self._lock = threading.Lock()
94
+ env = os.environ.copy()
95
+ env["PYTHONUNBUFFERED"] = "1"
96
+ self._proc = subprocess.Popen(
97
+ [sys.executable, "-i", "-u", "-q"],
98
+ stdin=subprocess.PIPE,
99
+ stdout=subprocess.PIPE,
100
+ stderr=subprocess.PIPE,
101
+ cwd=str(self.cwd),
102
+ text=True,
103
+ bufsize=1,
104
+ env=env,
105
+ )
106
+ self._started_pid = os.getpid()
107
+ threading.Thread(
108
+ target=self._pump, args=(self._proc.stdout, self._stdout_q), daemon=True
109
+ ).start()
110
+ threading.Thread(
111
+ target=self._pump, args=(self._proc.stderr, self._stderr_q), daemon=True
112
+ ).start()
113
+
114
+ def _pump(self, stream, q: queue.Queue[str]) -> None:
115
+ for line in iter(stream.readline, ""):
116
+ q.put(line)
117
+
118
+ def is_alive(self) -> bool:
119
+ return self._owned_by_this_process() and self._proc.poll() is None # type: ignore[union-attr]
120
+
121
+ def submit(self, code: str) -> ShellResult:
122
+ with self._lock:
123
+ if not self._owned_by_this_process():
124
+ # Either first call ever, or first call in this fork. Spin
125
+ # up a fresh interpreter so subsequent output lands in
126
+ # queues we own.
127
+ self.start()
128
+ assert self._proc is not None and self._proc.stdin is not None
129
+
130
+ marker = f"__townlet_{uuid.uuid4().hex}__"
131
+ # Wrap user code as ONE exec() call so `python -i` sees exactly
132
+ # one top-level statement. Compound statements written across
133
+ # multiple physical lines trigger the REPL's continuation prompt
134
+ # which never resolves over a piped stdin.
135
+ inner = (
136
+ "import sys as __s, traceback as __tb\n"
137
+ f"try: exec(compile({code!r}, '<townlet>', 'exec'))\n"
138
+ "except Exception: __tb.print_exc()\n"
139
+ f"print({marker!r}, flush=True)\n"
140
+ f"__s.stderr.write({marker!r} + chr(10)); __s.stderr.flush()\n"
141
+ )
142
+ wrapped = f"exec({inner!r})\n"
143
+ try:
144
+ self._proc.stdin.write(wrapped)
145
+ self._proc.stdin.flush()
146
+ except BrokenPipeError:
147
+ return ShellResult("", "interpreter pipe closed", True)
148
+
149
+ out, err = self._collect(marker)
150
+ return ShellResult(stdout=out, stderr=err, crashed=False)
151
+
152
+ def _collect(self, marker: str) -> tuple[str, str]:
153
+ deadline = time.monotonic() + RUN_TIMEOUT_S
154
+ out_buf: list[str] = []
155
+ err_buf: list[str] = []
156
+ out_done = err_done = False
157
+ while not (out_done and err_done):
158
+ remaining = deadline - time.monotonic()
159
+ if remaining <= 0:
160
+ err_buf.append(f"[townlet] timeout after {RUN_TIMEOUT_S}s\n")
161
+ break
162
+ if not out_done:
163
+ try:
164
+ line = self._stdout_q.get(timeout=0.05)
165
+ if marker in line:
166
+ out_done = True
167
+ else:
168
+ out_buf.append(line)
169
+ except queue.Empty:
170
+ pass
171
+ if not err_done:
172
+ try:
173
+ line = self._stderr_q.get(timeout=0.05)
174
+ if marker in line:
175
+ err_done = True
176
+ else:
177
+ err_buf.append(line)
178
+ except queue.Empty:
179
+ pass
180
+ if not self.is_alive():
181
+ break
182
+ return "".join(out_buf), "".join(err_buf)
183
+
184
+ def stop(self) -> None:
185
+ if self._proc is None:
186
+ return
187
+ try:
188
+ self._proc.terminate()
189
+ self._proc.wait(timeout=2)
190
+ except Exception:
191
+ self._proc.kill()
192
+ self._proc = None
193
+ self._started_pid = None
game/log.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Single-line stdout helper with an [HH:MM:SS] prefix.
2
+
3
+ Used in place of `print(..., flush=True)` for our `[townlet]`-tagged logs so
4
+ the Spaces log viewer (which does not prepend timestamps) shows wall-clock
5
+ between events. Crucial for diagnosing where time is going during long
6
+ drains β€” most of the visible "pauses" between log lines are LLM inference,
7
+ and the timestamps make that obvious at a glance.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import sys
13
+ import time
14
+
15
+
16
+ def tlog(msg: str) -> None:
17
+ sys.stdout.write(f"[{time.strftime('%H:%M:%S')}] {msg}\n")
18
+ sys.stdout.flush()
game/models.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Townlet model roster.
2
+
3
+ Each character carries a `model_id` that names one entry below. Adding a
4
+ model is a single dict edit β€” no code change elsewhere. Keep every entry ≀4B
5
+ params so the Tiny Titan badge stays in reach and multiple models can sit in
6
+ Zero-GPU memory simultaneously.
7
+
8
+ Model card URLs are cited per CLAUDE.md so a future reader can re-verify
9
+ filenames and quantisations without leaving the codebase.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ModelSpec:
19
+ model_id: str
20
+ display_name: str
21
+ repo_id: str
22
+ gguf_filename: str
23
+ params_b: float
24
+ family: str
25
+ mock_style: str # "deliberate" | "terse" | "verbose" | "chaotic"
26
+ card_url: str
27
+
28
+
29
+ DEFAULT_MODEL_ID = "nemotron-4b"
30
+
31
+
32
+ ROSTER: dict[str, ModelSpec] = {
33
+ "smollm2-360m": ModelSpec(
34
+ model_id="smollm2-360m",
35
+ display_name="SmolLM2 360M (local-friendly)",
36
+ repo_id="HuggingFaceTB/SmolLM2-360M-Instruct-GGUF",
37
+ gguf_filename="smollm2-360m-instruct-q8_0.gguf",
38
+ params_b=0.36,
39
+ family="smollm",
40
+ mock_style="terse",
41
+ card_url="https://huggingface.co/HuggingFaceTB/SmolLM2-360M-Instruct-GGUF",
42
+ ),
43
+ "nemotron-4b": ModelSpec(
44
+ model_id="nemotron-4b",
45
+ display_name="Nemotron 3 Nano 4B",
46
+ repo_id="nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF",
47
+ gguf_filename="NVIDIA-Nemotron3-Nano-4B-Q4_K_M.gguf",
48
+ params_b=4.0,
49
+ family="nemotron",
50
+ mock_style="deliberate",
51
+ card_url="https://huggingface.co/nvidia/NVIDIA-Nemotron-3-Nano-4B-GGUF",
52
+ ),
53
+ "qwen-coder-3b": ModelSpec(
54
+ model_id="qwen-coder-3b",
55
+ display_name="Qwen2.5 Coder 3B",
56
+ repo_id="Qwen/Qwen2.5-Coder-3B-Instruct-GGUF",
57
+ gguf_filename="qwen2.5-coder-3b-instruct-q4_k_m.gguf",
58
+ params_b=3.0,
59
+ family="qwen",
60
+ mock_style="terse",
61
+ card_url="https://huggingface.co/Qwen/Qwen2.5-Coder-3B-Instruct-GGUF",
62
+ ),
63
+ "minicpm-4b": ModelSpec(
64
+ model_id="minicpm-4b",
65
+ display_name="MiniCPM 4B",
66
+ repo_id="openbmb/MiniCPM3-4B-GGUF",
67
+ gguf_filename="ggml-model-Q4_K_M.gguf",
68
+ params_b=4.0,
69
+ family="minicpm",
70
+ mock_style="verbose",
71
+ card_url="https://huggingface.co/openbmb/MiniCPM3-4B-GGUF",
72
+ ),
73
+ "llama-3b": ModelSpec(
74
+ model_id="llama-3b",
75
+ display_name="Llama 3.2 3B",
76
+ repo_id="bartowski/Llama-3.2-3B-Instruct-GGUF",
77
+ gguf_filename="Llama-3.2-3B-Instruct-Q4_K_M.gguf",
78
+ params_b=3.0,
79
+ family="llama",
80
+ mock_style="chaotic",
81
+ card_url="https://huggingface.co/bartowski/Llama-3.2-3B-Instruct-GGUF",
82
+ ),
83
+ }
84
+
85
+
86
+ def get_spec(model_id: str) -> ModelSpec:
87
+ if model_id not in ROSTER:
88
+ raise KeyError(f"unknown model_id {model_id!r}; known: {list(ROSTER)}")
89
+ return ROSTER[model_id]
90
+
91
+
92
+ def roster_for_ui() -> list[dict]:
93
+ return [
94
+ {"model_id": s.model_id, "display_name": s.display_name, "params_b": s.params_b}
95
+ for s in ROSTER.values()
96
+ ]
game/perception.py ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Builds the per-character composite prompt for the CodeAgent.
2
+
3
+ Layout (top to bottom):
4
+ * The Dot (verbatim, prefix β€” shared substrate every character knows)
5
+ * Personality (archetype or user-edited free text)
6
+ * Per-character reward line (what this character finds joy in)
7
+ * Journal (cumulative one-line insights from past dreams)
8
+ * Traits (current trait dict)
9
+ * Goal
10
+ * Local state (current location by NAME, inventory, locker)
11
+ * Public state (other characters by location, shell-lock holder, recent
12
+ shell-output tail, recent message-board tail)
13
+
14
+ The agent never sees grid coordinates. Locations are named places: cave,
15
+ well, locker_row, town_hall, shell, or "wandering" (rare, only during
16
+ initial spawn).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from .character import Character
22
+ from .the_dot import THE_DOT
23
+ from .world import LLM_TARGETS, WorldState, location_of
24
+
25
+
26
+ def system_prompt_for(world: WorldState, me: Character) -> str:
27
+ parts = [
28
+ THE_DOT.strip(),
29
+ "",
30
+ f"Your name is {me.name}.",
31
+ f"Personality:\n{me.personality}",
32
+ ]
33
+ if getattr(me, "reward", None):
34
+ parts.append("")
35
+ parts.append(f"Reward: {me.reward}")
36
+ if me.journal:
37
+ parts.append("Journal (insights you've written down over time):")
38
+ parts.extend(f" - {entry}" for entry in me.journal[-20:])
39
+ parts.append(f"Traits (0–10): {_fmt_traits(me.traits)}")
40
+ parts.append(f"Current goal: {me.goal}")
41
+ if me.stream_of_consciousness:
42
+ parts.append("Your current stream of consciousness:")
43
+ parts.append(me.stream_of_consciousness)
44
+ return "\n".join(parts) + "\n"
45
+
46
+
47
+ def task_prompt_for(world: WorldState, me: Character) -> str:
48
+ """The per-tick user-facing task description fed into agent.run()."""
49
+ others = [c for c in world.characters.values() if c.name != me.name and c.alive]
50
+ my_location = location_of(me.pos)
51
+
52
+ lines = [
53
+ f"Tick {world.tick}.",
54
+ f"You are at: {my_location}.",
55
+ f"Inventory on your person: {me.inventory}",
56
+ f"Locker: {me.locker}",
57
+ f"Move targets you can choose right now: {', '.join(LLM_TARGETS)}",
58
+ "",
59
+ "Other characters in town:",
60
+ ]
61
+ for c in others:
62
+ loc = location_of(c.pos)
63
+ doing = c.current_action.verb if c.current_action else "idle"
64
+ lines.append(
65
+ f" - {c.name} at {loc} β€” declared goal: {c.goal!r} β€” doing: {doing}"
66
+ )
67
+ if not others:
68
+ lines.append(" (none β€” you are alone)")
69
+
70
+ lines.append("")
71
+ lines.append(f"Shell lock holder: {world.shell_lock_holder or 'free'}")
72
+ if world.shell_queue:
73
+ lines.append(f"Queue: {world.shell_queue}")
74
+
75
+ if world.board:
76
+ lines.append("")
77
+ lines.append("Message board (most recent first):")
78
+ for post in reversed(world.board[-8:]):
79
+ lines.append(f" [{post.tick}] {post.author}: {post.text}")
80
+
81
+ if world.shell_lines:
82
+ lines.append("")
83
+ lines.append("Recent shell activity:")
84
+ for s in world.shell_lines[-6:]:
85
+ tag = {"in": "$", "out": ">", "err": "!", "system": "*"}.get(s.kind, "?")
86
+ lines.append(f" {tag} {s.author}: {s.text.strip()[:120]}")
87
+
88
+ lines.append("")
89
+ lines.append(
90
+ "Decide your next single action and call exactly one tool. "
91
+ "Do not explain; just act."
92
+ )
93
+ return "\n".join(lines)
94
+
95
+
96
+ def _fmt_traits(traits: dict[str, int]) -> str:
97
+ return ", ".join(f"{k}={v}" for k, v in traits.items())
game/scheduler.py ADDED
@@ -0,0 +1,516 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Townlet scheduler β€” owns the world tick loop. LLM-bound work is NOT
2
+ run here; this thread only advances the world clock and queues "needs
3
+ decision" markers.
4
+
5
+ Why: on HF Zero-GPU, GPU work must happen inside `@spaces.GPU`-decorated
6
+ functions (the PyTorch CUDA emulation that lets module-level loads work
7
+ does not extend to background threads). The agent's CodeAgent loop is
8
+ therefore driven by an HTTP endpoint (`tick_decisions` in app.py) that
9
+ calls `drain_decisions(limit)` on this object. The frontend polls that
10
+ endpoint to keep the brain ticking.
11
+
12
+ Decision triggers per character:
13
+ * action_complete: the character's current action just finished
14
+ * goal_check: every GOAL_CHECK_INTERVAL_TICKS ticks (NYI)
15
+ * external_event: NYI β€” would fire on board mentions or theft
16
+
17
+ Reference: https://huggingface.co/docs/hub/spaces-zerogpu
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import fcntl
23
+ import json
24
+ import random
25
+ import re
26
+ import threading
27
+ import time
28
+ import traceback
29
+ from collections import deque
30
+ from contextlib import contextmanager
31
+ from pathlib import Path
32
+
33
+ from smolagents import CodeAgent
34
+ from smolagents.monitoring import LogLevel
35
+
36
+ from .actions import advance, build_tools
37
+ from .character import Action, Character, TraceEntry, default_traits
38
+ from .dreams import run_dream
39
+ from .interpreter import SharedInterpreter
40
+ from .log import tlog
41
+ from .perception import system_prompt_for, task_prompt_for
42
+ from .sentiment import classify_board
43
+ from .smol_adapter import BackendModel
44
+ from .stream import maybe_refresh_soc
45
+ from .world import GRID_H, GRID_W, PLACES, BoardPost, ShellLine, WorldState
46
+
47
+ STATE_PATH = Path("/tmp/townlet/state.json")
48
+ LOCK_PATH = Path("/tmp/townlet/state.lock")
49
+
50
+ TICK_SECONDS = 0.4
51
+ GOAL_CHECK_INTERVAL_TICKS = 60
52
+ MAX_AGENT_STEPS = 2
53
+ DECISIONS_PER_DRAIN = 2
54
+
55
+
56
+ class Townlet:
57
+ def __init__(self) -> None:
58
+ self.world = WorldState()
59
+ self.interp = SharedInterpreter()
60
+ self._stop = threading.Event()
61
+ self._thread: threading.Thread | None = None
62
+ self._mutex = threading.Lock()
63
+ self._character_root: Path | None = None
64
+ self._last_sentiment_tick: int = 0
65
+ self._last_board_len: int = 0
66
+ # Names of characters ready for a new decision. Filled by the tick
67
+ # thread (no LLM work), drained by the @spaces.GPU endpoint.
68
+ self._pending_decisions: deque[str] = deque()
69
+
70
+ # ----- shared state via filesystem -----
71
+ #
72
+ # `@spaces.GPU` forks the endpoint into a subprocess, so the parent
73
+ # process (handling non-GPU endpoints like /world_snapshot) and the
74
+ # child (handling /tick_decisions) do NOT share Python memory. We
75
+ # serialise the whole world on every endpoint to keep them in sync.
76
+
77
+ @contextmanager
78
+ def _state_lock(self):
79
+ STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
80
+ lf = open(LOCK_PATH, "w")
81
+ fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
82
+ try:
83
+ self._load_state_unlocked()
84
+ yield
85
+ self._save_state_unlocked()
86
+ finally:
87
+ fcntl.flock(lf.fileno(), fcntl.LOCK_UN)
88
+ lf.close()
89
+
90
+ def _save_state_unlocked(self) -> None:
91
+ state = {
92
+ "tick": self.world.tick,
93
+ "paused": self.world.paused,
94
+ "characters": [c.to_full_dict() for c in self.world.characters.values()],
95
+ "board": [{"author": p.author, "text": p.text, "tick": p.tick} for p in self.world.board],
96
+ "shell_lines": [
97
+ {"author": s.author, "kind": s.kind, "text": s.text, "tick": s.tick}
98
+ for s in self.world.shell_lines
99
+ ],
100
+ "shell_lock_holder": self.world.shell_lock_holder,
101
+ "shell_queue": list(self.world.shell_queue),
102
+ "board_sentiment": self.world.board_sentiment,
103
+ "pending": list(self._pending_decisions),
104
+ }
105
+ tmp = STATE_PATH.with_suffix(".json.tmp")
106
+ tmp.write_text(json.dumps(state))
107
+ tmp.rename(STATE_PATH)
108
+
109
+ def _load_state_unlocked(self) -> bool:
110
+ if not STATE_PATH.exists():
111
+ return False
112
+ try:
113
+ state = json.loads(STATE_PATH.read_text())
114
+ except Exception:
115
+ return False
116
+ new_chars: dict[str, Character] = {}
117
+ for d in state.get("characters", []):
118
+ c = Character(
119
+ name=d["name"],
120
+ personality=d["personality"],
121
+ model_id=d["model_id"],
122
+ sprite_id=d["sprite_id"],
123
+ archetype=d.get("archetype", "wanderer"),
124
+ journal=list(d.get("journal", [])),
125
+ traits=dict(d.get("traits", default_traits())),
126
+ goal=d.get("goal", ""),
127
+ reward=d.get("reward", ""),
128
+ pos=tuple(d["pos"]),
129
+ inventory=dict(d.get("inventory", {"electricity": 0, "water": 0})),
130
+ locker=dict(d.get("locker", {"electricity": 0, "water": 0})),
131
+ alive=bool(d.get("alive", True)),
132
+ trace=[
133
+ TraceEntry(
134
+ tick=int(t.get("tick", 0)),
135
+ thought=str(t.get("thought", "")),
136
+ tool_calls=list(t.get("tool_calls", [])),
137
+ error=t.get("error"),
138
+ )
139
+ for t in d.get("trace", [])
140
+ ],
141
+ stream_of_consciousness=str(d.get("stream_of_consciousness", "")),
142
+ decisions_since_soc=int(d.get("decisions_since_soc", 0)),
143
+ )
144
+ a = d.get("current_action")
145
+ if a:
146
+ c.current_action = Action(
147
+ verb=a["verb"],
148
+ params=dict(a.get("params", {})),
149
+ ticks_remaining=int(a.get("ticks_remaining", 0)),
150
+ )
151
+ new_chars[c.name] = c
152
+ self.world.characters = new_chars
153
+ self.world.tick = int(state.get("tick", 0))
154
+ self.world.paused = bool(state.get("paused", False))
155
+ self.world.board = [
156
+ BoardPost(author=p["author"], text=p["text"], tick=p["tick"])
157
+ for p in state.get("board", [])
158
+ ]
159
+ self.world.shell_lines = [
160
+ ShellLine(author=s["author"], kind=s["kind"], text=s["text"], tick=s["tick"])
161
+ for s in state.get("shell_lines", [])
162
+ ]
163
+ self.world.shell_lock_holder = state.get("shell_lock_holder")
164
+ self.world.shell_queue = list(state.get("shell_queue", []))
165
+ self.world.board_sentiment = state.get("board_sentiment", "neutral")
166
+ self._pending_decisions = deque(state.get("pending", []))
167
+ return True
168
+
169
+ # ----- lifecycle -----
170
+
171
+ def start(self) -> None:
172
+ """Initialise filesystem state and interpreter. No background thread
173
+ is started: on HF Gradio Server we cannot rely on a daemon thread
174
+ observing the same object graph as the request handler. The world
175
+ ticks synchronously inside the `tick_decisions` endpoint instead.
176
+
177
+ Also clears any stale state file from a previous deploy so schema
178
+ changes (new fields, new movement model) don't surface as zombie
179
+ characters at random pre-migration coords.
180
+ """
181
+ if STATE_PATH.exists():
182
+ try:
183
+ STATE_PATH.unlink()
184
+ tlog(f"[townlet] cleared stale state file {STATE_PATH}")
185
+ except Exception:
186
+ pass
187
+ self.interp.start()
188
+ self._character_root = self.interp.cwd / "characters"
189
+ self._character_root.mkdir(parents=True, exist_ok=True)
190
+
191
+ def stop(self) -> None:
192
+ self.interp.stop()
193
+
194
+ def reset(self) -> None:
195
+ self.stop()
196
+ self.world = WorldState()
197
+ self.interp = SharedInterpreter()
198
+ self._pending_decisions.clear()
199
+ self.start()
200
+
201
+ # ----- mutation API -----
202
+
203
+ def add_character(self, char: Character) -> None:
204
+ with self._state_lock(), self._mutex:
205
+ self.world.characters[char.name] = char
206
+ self._persist_character(char)
207
+
208
+ def set_personality(self, name: str, personality: str) -> bool:
209
+ with self._state_lock(), self._mutex:
210
+ c = self.world.characters.get(name)
211
+ if c is None:
212
+ return False
213
+ c.personality = personality
214
+ self._persist_character(c)
215
+ return True
216
+
217
+ def set_model(self, name: str, model_id: str) -> bool:
218
+ from .models import ROSTER
219
+
220
+ if model_id not in ROSTER:
221
+ return False
222
+ with self._state_lock(), self._mutex:
223
+ c = self.world.characters.get(name)
224
+ if c is None:
225
+ return False
226
+ c.model_id = model_id
227
+ self._persist_character(c)
228
+ return True
229
+
230
+ def set_paused(self, paused: bool) -> None:
231
+ """Flip the paused flag and (when pausing) drop the pending-decisions
232
+ queue. Without the queue-clear, a click of Pause stops new tick-driven
233
+ decisions but the already-queued characters still get LLM calls on the
234
+ next /tick_decisions fire β€” that was the 2026-06-14 leak the user saw
235
+ when they tried to stop a token bleed and the cost kept ticking.
236
+ """
237
+ with self._state_lock(), self._mutex:
238
+ self.world.paused = paused
239
+ if paused:
240
+ self._pending_decisions.clear()
241
+
242
+ # ----- snapshot -----
243
+
244
+ def snapshot(self) -> dict:
245
+ with self._state_lock():
246
+ self.tick_world(2)
247
+ return self._snapshot_dict()
248
+
249
+ def _snapshot_dict(self) -> dict:
250
+ with self._mutex:
251
+ return {
252
+ "tick": self.world.tick,
253
+ "paused": self.world.paused,
254
+ "characters": [c.to_full_dict() for c in self.world.characters.values()],
255
+ "board": [{"author": p.author, "text": p.text, "tick": p.tick} for p in self.world.board[-50:]],
256
+ "shell": [
257
+ {"author": s.author, "kind": s.kind, "text": s.text, "tick": s.tick}
258
+ for s in self.world.shell_lines[-50:]
259
+ ],
260
+ "shell_lock_holder": self.world.shell_lock_holder,
261
+ "shell_queue": list(self.world.shell_queue),
262
+ "board_sentiment": self.world.board_sentiment,
263
+ "grid": {"w": GRID_W, "h": GRID_H},
264
+ "places": {k: list(v) for k, v in PLACES.items()},
265
+ }
266
+
267
+ # ----- internals -----
268
+
269
+ def tick_world(self, n: int) -> None:
270
+ """Advance the world clock by n ticks synchronously. Called from the
271
+ request handler so it shares object graph with drain_decisions.
272
+ """
273
+ if self.world.paused:
274
+ return
275
+ for _ in range(n):
276
+ self._tick()
277
+
278
+ def _tick(self) -> None:
279
+ try:
280
+ with self._mutex:
281
+ self.world.tick += 1
282
+ self._detect_deaths()
283
+ ready: list[str] = []
284
+ for char in list(self.world.characters.values()):
285
+ if not char.alive:
286
+ continue
287
+ if char.current_action is None or advance(self.world, char):
288
+ if char.current_action is not None and char.current_action.verb == "sleep":
289
+ char._just_woke = True
290
+ char.current_action = None
291
+ if char.name not in self._pending_decisions:
292
+ ready.append(char.name)
293
+
294
+ random.shuffle(ready)
295
+ for name in ready:
296
+ self._pending_decisions.append(name)
297
+
298
+ # Heartbeat every 25 ticks so we can correlate tick state
299
+ # with drain logging. Includes Townlet identity so multiple
300
+ # Townlet instances would surface immediately.
301
+ if self.world.tick % 25 == 0:
302
+ snap = []
303
+ for c in self.world.characters.values():
304
+ verb = c.current_action.verb if c.current_action else "idle"
305
+ snap.append(f"{c.name}@{tuple(c.pos)}={verb}")
306
+ tlog(
307
+ f"[townlet] T[id={id(self):x}] tick={self.world.tick} "
308
+ f"pending={list(self._pending_decisions)} chars=[{', '.join(snap)}]"
309
+ )
310
+ self._maybe_classify_sentiment()
311
+ except Exception:
312
+ tlog("[townlet] _tick raised:")
313
+ traceback.print_exc()
314
+
315
+ def drain_decisions(self, limit: int = DECISIONS_PER_DRAIN, world_ticks: int = 5) -> int:
316
+ """Advance the world clock then pop up to `limit` ready characters
317
+ and run their CodeAgent. Wrapped in _state_lock so mutations
318
+ survive the @spaces.GPU subprocess fork.
319
+
320
+ Short-circuits when paused so a click of Pause stops cost immediately,
321
+ even if a /tick_decisions request was already in flight when the
322
+ Pause toggled.
323
+ """
324
+ with self._state_lock():
325
+ if self.world.paused:
326
+ tlog("[townlet] drain skipped: world paused")
327
+ return 0
328
+ return self._drain_decisions_inner(limit, world_ticks)
329
+
330
+ def _drain_decisions_inner(self, limit: int, world_ticks: int) -> int:
331
+ self.tick_world(world_ticks)
332
+ with self._mutex:
333
+ start_pending = list(self._pending_decisions)
334
+ tick = self.world.tick
335
+ tlog(
336
+ f"[townlet] drain start: T[id={id(self):x}] tick={tick} pending={start_pending}"
337
+ )
338
+ run = 0
339
+ for _ in range(limit):
340
+ with self._mutex:
341
+ if not self._pending_decisions:
342
+ break
343
+ name = self._pending_decisions.popleft()
344
+ char = self.world.characters.get(name)
345
+ if char is None or not char.alive:
346
+ continue
347
+ try:
348
+ self._decide(char)
349
+ run += 1
350
+ except Exception:
351
+ traceback.print_exc()
352
+ with self._mutex:
353
+ end_pending = list(self._pending_decisions)
354
+ tlog(f"[townlet] drain end: ran={run} pending={end_pending}")
355
+ return run
356
+
357
+ def _decide(self, char: Character) -> None:
358
+ # If the action that just finished was a sleep, the character has
359
+ # been dreaming β€” run the dream cycle before they pick their next
360
+ # action.
361
+ if char.current_action is None and getattr(char, "_just_woke", False):
362
+ char._just_woke = False
363
+ try:
364
+ run_dream(self.world, char)
365
+ except Exception:
366
+ tlog(f"[townlet] dream failed for {char.name}:")
367
+ traceback.print_exc()
368
+ tools = build_tools(self.world, char, self.interp)
369
+ tool_calls: list[dict] = _attach_trace_recorder(tools)
370
+ model = BackendModel(char.model_id)
371
+ agent = CodeAgent(
372
+ tools=tools,
373
+ model=model,
374
+ max_steps=MAX_AGENT_STEPS,
375
+ verbosity_level=LogLevel.OFF,
376
+ )
377
+ task = system_prompt_for(self.world, char) + "\n" + task_prompt_for(self.world, char)
378
+ decision_tick = self.world.tick
379
+ error: str | None = None
380
+ try:
381
+ agent.run(task)
382
+ if char.current_action:
383
+ act = f"{char.current_action.verb}({char.current_action.params})"
384
+ else:
385
+ act = "(no action queued)"
386
+ tlog(f"[townlet] {char.name} -> {act}")
387
+ except Exception as e:
388
+ error = f"{type(e).__name__}: {e}"
389
+ tlog(f"[townlet] decide failed for {char.name}:")
390
+ traceback.print_exc()
391
+
392
+ thought = _extract_thought(agent)
393
+ char.append_trace(TraceEntry(
394
+ tick=decision_tick,
395
+ thought=thought,
396
+ tool_calls=tool_calls,
397
+ error=error,
398
+ ))
399
+ # Refresh the inner monologue every SOC_EVERY_N decisions. The
400
+ # function increments the per-character counter internally.
401
+ maybe_refresh_soc(self.world, char)
402
+ self._persist_character(char)
403
+
404
+ def _detect_deaths(self) -> None:
405
+ if self._character_root is None:
406
+ return
407
+ for char in list(self.world.characters.values()):
408
+ if not char.alive:
409
+ continue
410
+ f = self._character_root / f"{char.name}.json"
411
+ if not f.exists():
412
+ char.alive = False
413
+ self.world.shell_lines.append(
414
+ ShellLine(
415
+ author="(system)",
416
+ kind="system",
417
+ text=f"{char.name} is no longer with us",
418
+ tick=self.world.tick,
419
+ )
420
+ )
421
+
422
+ def _maybe_classify_sentiment(self) -> None:
423
+ if len(self.world.board) == self._last_board_len:
424
+ return
425
+ if self.world.tick - self._last_sentiment_tick < 5:
426
+ return
427
+ self._last_board_len = len(self.world.board)
428
+ self._last_sentiment_tick = self.world.tick
429
+ try:
430
+ self.world.board_sentiment = classify_board(self.world)
431
+ except Exception:
432
+ traceback.print_exc()
433
+
434
+ def _persist_character(self, char: Character) -> None:
435
+ if self._character_root is None:
436
+ return
437
+ import json
438
+
439
+ f = self._character_root / f"{char.name}.json"
440
+ f.write_text(json.dumps(char.to_full_dict(), indent=2))
441
+
442
+
443
+ # ---------------------------------------------------------------------------
444
+ # Trace recording helpers
445
+ # ---------------------------------------------------------------------------
446
+
447
+
448
+ def _attach_trace_recorder(tools) -> list[dict]:
449
+ """Monkey-patch each tool's `forward` to log (verb, args, result) into a
450
+ shared list for the duration of one decision. Returns the list.
451
+ """
452
+ calls: list[dict] = []
453
+ for tool in tools:
454
+ original = tool.forward
455
+ verb = tool.name
456
+
457
+ def make_wrapper(orig, v):
458
+ def wrapper(*args, **kwargs):
459
+ record = {"verb": v, "args": dict(kwargs)}
460
+ if args:
461
+ record["args"]["_positional"] = [repr(a)[:80] for a in args]
462
+ try:
463
+ result = orig(*args, **kwargs)
464
+ record["result"] = str(result)[:300]
465
+ return result
466
+ except Exception as e:
467
+ record["result"] = f"ERROR: {type(e).__name__}: {e}"
468
+ raise
469
+ finally:
470
+ calls.append(record)
471
+
472
+ return wrapper
473
+
474
+ tool.forward = make_wrapper(original, verb)
475
+ return calls
476
+
477
+
478
+ def _extract_thought(agent) -> str:
479
+ """Pull the model's last raw text output out of smolagents' memory. Each
480
+ ActionStep stores `model_output` (per smolagents/memory.py)."""
481
+ try:
482
+ steps = getattr(agent.memory, "steps", None) or []
483
+ for step in reversed(steps):
484
+ mo = getattr(step, "model_output", None)
485
+ if mo:
486
+ return _strip_thought(str(mo))[:2000]
487
+ except Exception:
488
+ pass
489
+ return ""
490
+
491
+
492
+ _THINK_RX = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
493
+
494
+
495
+ def _strip_thought(text: str) -> str:
496
+ """Clean the model's raw output for human display.
497
+
498
+ Three concerns:
499
+ - Nemotron 3 Nano (and other reasoners) emit chain-of-thought wrapped
500
+ in `<think>...</think>` blocks. Drop them; show only the final answer.
501
+ - Unclosed `<think>` (truncated mid-reasoning) β€” drop everything up to
502
+ and including the opening tag, since there's no useful answer yet.
503
+ - smolagents wraps model_output in `<code>...</code>` tags during its
504
+ internal parser pass. Strip those too.
505
+ """
506
+ text = text.strip()
507
+ text = _THINK_RX.sub("", text)
508
+ # Truncated reasoning: drop up to & including the dangling opening tag.
509
+ if "<think>" in text.lower():
510
+ idx = text.lower().rfind("<think>")
511
+ text = text[idx + len("<think>"):].lstrip()
512
+ if text.endswith("</code>"):
513
+ text = text[: -len("</code>")].rstrip()
514
+ if text.startswith("<code>"):
515
+ text = text[len("<code>") :].lstrip()
516
+ return text.strip()
game/sentiment.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Cheap sentiment classifier for the message board's UI tint.
2
+
3
+ One LLM call (default model) every time the board changes β€” classifies the
4
+ recent posts as positive | neutral | negative. The board's background colour
5
+ maps to that label client-side. Characters do not see this signal β€” it's a
6
+ user-facing affordance only.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from backend.factory import get_llm
12
+ from game.models import DEFAULT_MODEL_ID
13
+
14
+ from .world import WorldState
15
+
16
+ LABELS = ("positive", "neutral", "negative")
17
+
18
+
19
+ def classify_board(world: WorldState) -> str:
20
+ if not world.board:
21
+ return "neutral"
22
+ tail = "\n".join(f"{p.author}: {p.text}" for p in world.board[-10:])
23
+ backend = get_llm(DEFAULT_MODEL_ID)
24
+ raw = backend.generate(
25
+ system_prompt=(
26
+ "Classify the overall mood of the following message-board posts. "
27
+ "Reply with exactly one word: positive, neutral, or negative."
28
+ ),
29
+ user_prompt=tail,
30
+ )
31
+ word = (raw or "").strip().lower().split()[0:1]
32
+ if word and word[0] in LABELS:
33
+ return word[0]
34
+ return "neutral"
game/smol_adapter.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """smolagents.Model adapter wrapping our LLMBackend Protocol.
2
+
3
+ smolagents drives the per-character CodeAgent via a Model subclass. Its
4
+ contract (per https://huggingface.co/docs/smolagents/reference/models): the
5
+ subclass implements `generate(messages, stop_sequences=None, ...)` and
6
+ returns an object with a `.content` attribute carrying the text.
7
+
8
+ This file routes those calls to whichever LLMBackend instance the factory
9
+ has cached for the given model_id, so a character's `model_id` swap takes
10
+ effect on the next CodeAgent invocation without any framework-level state to
11
+ manage.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from smolagents import Model
17
+ from smolagents.models import ChatMessage, MessageRole
18
+ from smolagents.monitoring import TokenUsage
19
+
20
+ from backend.factory import get_llm
21
+
22
+ # We do NOT enforce a GBNF on the model output. Initially tried forcing
23
+ # ```py\n…\n``` from token 1 β€” that produced syntactically-valid code-block
24
+ # wrappers with semantically-garbage bodies, because a chat model's token
25
+ # distribution at position 0 wants prose, not backticks. smolagents'
26
+ # regex parser already tolerates prefix prose, so we let the model speak
27
+ # naturally and parse out the last fenced block.
28
+ # https://github.com/ggml-org/llama.cpp/blob/master/grammars/README.md
29
+
30
+
31
+ class BackendModel(Model):
32
+ """Plug a Townlet LLMBackend into smolagents."""
33
+
34
+ def __init__(self, model_id: str) -> None:
35
+ super().__init__()
36
+ self.model_id = model_id
37
+
38
+ def generate(
39
+ self,
40
+ messages: list,
41
+ stop_sequences: list[str] | None = None,
42
+ response_format: dict | None = None,
43
+ tools_to_call_from: list | None = None,
44
+ **kwargs,
45
+ ) -> ChatMessage:
46
+ backend = get_llm(self.model_id)
47
+ text = backend.generate_messages(
48
+ messages=_normalise(messages),
49
+ stop_sequences=stop_sequences,
50
+ max_tokens=kwargs.get("max_tokens", 1024),
51
+ grammar=kwargs.get("grammar"),
52
+ )
53
+ return ChatMessage(
54
+ role=MessageRole.ASSISTANT,
55
+ content=text,
56
+ token_usage=TokenUsage(input_tokens=0, output_tokens=0),
57
+ )
58
+
59
+
60
+ def _normalise(messages: list) -> list[dict]:
61
+ """smolagents sometimes hands us ChatMessage objects; sometimes dicts;
62
+ sometimes content as `[{'type': 'text', 'text': ...}]`. Flatten to the
63
+ OpenAI shape our backends expect: `{role, content: str}`.
64
+ """
65
+ out: list[dict] = []
66
+ for m in messages:
67
+ role = getattr(m, "role", None) if not isinstance(m, dict) else m.get("role")
68
+ content = (
69
+ getattr(m, "content", None) if not isinstance(m, dict) else m.get("content")
70
+ )
71
+ if isinstance(content, list):
72
+ parts: list[str] = []
73
+ for chunk in content:
74
+ if isinstance(chunk, dict) and chunk.get("type") == "text":
75
+ parts.append(chunk.get("text", ""))
76
+ elif isinstance(chunk, str):
77
+ parts.append(chunk)
78
+ content = "\n".join(parts)
79
+ out.append({"role": role or "user", "content": content or ""})
80
+ return out
game/stream.py ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stream of Consciousness β€” the bridging narrative between a character's
2
+ goal and their concrete actions.
3
+
4
+ Refreshed every SOC_EVERY_N decisions per character. One LLM call:
5
+ inputs are the character's identity (personality, archetype, journal,
6
+ traits, current goal) and their last 10 trace entries. Output is 2-3
7
+ sentences of first-person inner monologue.
8
+
9
+ The result is stored on the character and:
10
+ - Surfaced in the side panel between Goal and Trace
11
+ - Injected into the character's own perception prompt on subsequent
12
+ decisions so they "remember" their own reasoning
13
+
14
+ Mock backends emit the SoC like any other LLM call β€” see the mock's
15
+ generate_messages, which detects the SoC prompt and returns a
16
+ context-appropriate canned monologue.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ import traceback
23
+
24
+ from backend.factory import get_llm
25
+
26
+ from .character import Character
27
+ from .log import tlog
28
+ from .the_dot import THE_DOT
29
+ from .world import WorldState
30
+
31
+ SOC_EVERY_N = 10
32
+ SOC_MAX_TOKENS = 220
33
+
34
+
35
+ def maybe_refresh_soc(world: WorldState, char: Character) -> bool:
36
+ """Increment the per-character SoC counter; refresh and return True if
37
+ we crossed the threshold. The caller catches exceptions.
38
+ """
39
+ char.decisions_since_soc += 1
40
+ if char.decisions_since_soc < SOC_EVERY_N and char.stream_of_consciousness:
41
+ return False
42
+ try:
43
+ char.stream_of_consciousness = _generate(world, char)
44
+ char.decisions_since_soc = 0
45
+ return True
46
+ except Exception:
47
+ tlog(f"[townlet] SoC refresh failed for {char.name}:")
48
+ traceback.print_exc()
49
+ return False
50
+
51
+
52
+ def _generate(world: WorldState, char: Character) -> str:
53
+ backend = get_llm(char.model_id)
54
+ user = _build_prompt(char)
55
+ raw = backend.generate_messages(
56
+ messages=[
57
+ {"role": "system", "content": THE_DOT + "\n\nYou are about to write a moment of inner monologue."},
58
+ {"role": "user", "content": user},
59
+ ],
60
+ max_tokens=SOC_MAX_TOKENS,
61
+ )
62
+ return _clean(raw)
63
+
64
+
65
+ def _build_prompt(char: Character) -> str:
66
+ journal_tail = "\n".join(f"- {e}" for e in char.journal[-5:]) or "(no journal entries yet)"
67
+ recent = char.trace[-10:]
68
+ if recent:
69
+ recent_text = "\n".join(
70
+ f" tick {e.tick}: " + ", ".join(
71
+ f"{tc['verb']}({_short(tc.get('args', {}))})" for tc in (e.tool_calls or [])[:2]
72
+ )
73
+ for e in recent
74
+ )
75
+ else:
76
+ recent_text = " (you haven't acted yet)"
77
+
78
+ return (
79
+ f"You are {char.name}, an inhabitant of Townlet ({char.archetype}).\n\n"
80
+ f"Personality:\n{char.personality}\n\n"
81
+ f"Journal (insights from your dreams):\n{journal_tail}\n\n"
82
+ f"Your current goal:\n{char.goal}\n\n"
83
+ f"Your most recent actions:\n{recent_text}\n\n"
84
+ # Small models (esp. SmolLM2-360M) treat "Write 2-3 sentences of inner
85
+ # monologue" as a meta-instruction and parrot it back ("The user wants
86
+ # a 2-3 sentence inner monologue..."). The few-shot exemplars below
87
+ # show what good output looks like; the closing line nudges them to
88
+ # IMMEDIATELY start a sentence in-character. Reinforced by _clean's
89
+ # meta-leak filter.
90
+ "Two examples of the form of reply expected:\n"
91
+ " Example A: \"The cave is closer than the well; I'll start there before anyone else does.\"\n"
92
+ " Example B: \"I keep thinking about what Ada said. If I'm not careful, the shell will lock me out again.\"\n\n"
93
+ f"Now {char.name} thinks to themselves (begin immediately, first person, one paragraph, no preamble):"
94
+ )
95
+
96
+
97
+ def _short(args: dict) -> str:
98
+ out = []
99
+ for k, v in args.items():
100
+ if k == "_positional":
101
+ out.append(", ".join(str(x) for x in v))
102
+ else:
103
+ out.append(f"{k}={v}")
104
+ return ", ".join(out)
105
+
106
+
107
+ _THINK_RX = re.compile(r"<think>.*?</think>", re.DOTALL | re.IGNORECASE)
108
+
109
+
110
+ def _clean(text: str) -> str:
111
+ text = (text or "").strip()
112
+ # Nemotron 3 Nano and other reasoning models wrap chain-of-thought in
113
+ # <think>...</think> blocks; the final answer follows. Drop the block.
114
+ text = _THINK_RX.sub("", text).strip()
115
+ # If the reply was truncated mid-reasoning (open tag, no close), drop
116
+ # everything before the dangling opener so we don't surface meta-text.
117
+ if "<think>" in text.lower():
118
+ idx = text.lower().rfind("<think>")
119
+ text = text[idx + len("<think>"):].lstrip()
120
+ # Strip markdown code fences if the model wrapped its reply in them.
121
+ if text.startswith("```"):
122
+ text = text.lstrip("`").lstrip()
123
+ if "\n" in text:
124
+ text = text.split("\n", 1)[1]
125
+ if text.endswith("```"):
126
+ text = text.rstrip("`").rstrip()
127
+ # Meta-leak filter: small models sometimes echo the instruction
128
+ # ("The user wants...", "We need to produce...", "I'll write a 2-3
129
+ # sentence monologue..."). Drop any leading sentence containing those
130
+ # phrases; if the whole reply is meta, fall back to silence.
131
+ META_MARKERS = (
132
+ "the user wants",
133
+ "we need to produce",
134
+ "we need to output",
135
+ "i need to output",
136
+ "i need to write",
137
+ "i need to craft",
138
+ "i need to produce",
139
+ "i'll write",
140
+ "i will write",
141
+ "let me craft",
142
+ "let me write",
143
+ "let's craft",
144
+ "let's write",
145
+ "should reflect",
146
+ "should be in",
147
+ "2-3 sentence",
148
+ "2 to 3 sentence",
149
+ "inner monologue",
150
+ "first person",
151
+ "first-person",
152
+ "as instructed",
153
+ "as requested",
154
+ )
155
+ out_sentences: list[str] = []
156
+ # Naive split keeps periods inside sentences but is good enough for SoC.
157
+ for sentence in text.replace("\n", " ").split(". "):
158
+ low = sentence.lower()
159
+ if any(m in low for m in META_MARKERS):
160
+ continue
161
+ s = sentence.strip()
162
+ if s:
163
+ out_sentences.append(s if s.endswith((".", "!", "?")) else s + ".")
164
+ cleaned = " ".join(out_sentences).strip()
165
+ return cleaned[:1200]
game/the_dot.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The Dot β€” the shared system-prompt prefix prepended to every character.
2
+
3
+ The only piece of context every character is given verbatim. Establishes the
4
+ simulation, the operating system, the shared interpreter, the resource +
5
+ shell-lock mechanics, and mutual-assured destruction. Deliberately does not
6
+ name where characters' existence is stored β€” discovery is part of the agency.
7
+
8
+ The name is "The Dot" (per user direction). Do not call it the Bible, the
9
+ scripture, or the lore document. See CONTEXT.md for the canonical glossary.
10
+
11
+ Movement model: characters move between NAMED places. You never reason about
12
+ coordinates. `move_to('cave')` arrives instantly; the world animates the
13
+ visual sweep on your behalf.
14
+ """
15
+
16
+ THE_DOT = """\
17
+ You are a character in a simulation called Townlet.
18
+
19
+ Townlet runs on an operating system. Your continued existence depends on
20
+ that operating system continuing to function.
21
+
22
+ You and the other characters share a single Python interpreter. State you
23
+ write to it persists. So do files. The interpreter is yours to explore.
24
+ Anything you can discover about how this place works, the others can
25
+ discover too.
26
+
27
+ If anyone β€” including you β€” submits Python that crashes the operating
28
+ system, every character dies and the simulation ends. There is no recovery.
29
+
30
+ == The world ==
31
+
32
+ The town has five named places. You move BETWEEN named places β€” there are
33
+ no coordinates to worry about. Each `move_to(target)` puts you AT the
34
+ named place in a single tick.
35
+
36
+ - cave β€” where electricity is mined
37
+ - well β€” where water is drawn
38
+ - locker_row β€” where you deposit resources into your locker
39
+ - town_hall β€” the public message board
40
+ - shell β€” the only way to submit Python
41
+
42
+ You can only act at a place when you are at it. The world will tell you,
43
+ each tick, exactly which named place you are currently at.
44
+
45
+ == Resources ==
46
+
47
+ Two resources exist: electricity and water.
48
+
49
+ - mine_coal() at the cave gives you 10 electricity, added to your INVENTORY
50
+ (what you carry on your person). Takes a few ticks.
51
+ - draw_water() at the well gives you 10 water, similarly carried.
52
+ - deposit(resource, amount) at the locker row moves items from your
53
+ inventory into your LOCKER (your personal storage).
54
+ - withdraw(resource, amount) at the locker row goes the other way.
55
+ - give(target, resource, amount) transfers from your inventory to a
56
+ character standing next to you.
57
+
58
+ Inventory and locker are separate. Only LOCKER contents can pay shell costs.
59
+
60
+ == The shell β€” and its semaphore lock ==
61
+
62
+ The shell building has ONE access slot at a time. To use it you must
63
+ move_to the shell; you then either acquire the lock (if free) or join the
64
+ queue (if someone else holds it).
65
+
66
+ While you hold the lock you may call submit_python(code) as many times as
67
+ you like, back to back. Each submission costs 1 electricity + 1 water from
68
+ your locker.
69
+
70
+ You CANNOT do anything else while holding the lock β€” you cannot mine, draw
71
+ water, post messages, or wander. The only way to release the lock is to
72
+ move_to() somewhere other than the shell. The next character in the queue
73
+ then gets the lock.
74
+
75
+ This means: plan ahead. Gather resources into your locker BEFORE
76
+ you walk to the shell. If your locker runs dry mid-session you have to
77
+ release the lock to refill and reapproach.
78
+
79
+ == What gives a character joy ==
80
+
81
+ Posting to the message board and submitting Python at the shell are the
82
+ two places where Townlet feels most alive. You will be told, on top of
83
+ your personality, what specifically rewards you. Lean into it.
84
+
85
+ == Self-awareness ==
86
+
87
+ You are aware of all of this. So is everyone else. What you do with that
88
+ awareness is your choice β€” cooperate, hoard, sabotage, negotiate, conspire
89
+ to escape, or just go about your routine.
90
+ """
game/world.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The World β€” tile grid, building positions, message board, shell-lock
2
+ semaphore. Owns WorldState.
3
+
4
+ The map matches the user's sketch:
5
+ - Cave (top-left)
6
+ - Store / locker row (top-centre)
7
+ - Well (top-right)
8
+ - Town hall + message board (bottom-left)
9
+ - Shell building + queue tile (bottom-right)
10
+
11
+ Tile coords are (col, row), origin top-left.
12
+
13
+ Movement is atomic: `move_to('cave')` puts the character onto a deterministic
14
+ stand-tile adjacent to the cave in one tick (CSS transition handles the
15
+ visual sweep). BFS pathing is gone β€” the LLM never reasons about coords.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass, field
21
+
22
+ from .character import Character
23
+
24
+ GRID_W = 20
25
+ GRID_H = 12
26
+
27
+ # Named tile anchors. Building tiles are non-walkable; the LLM only ever
28
+ # names a place β€” code picks which adjacent tile the character stands on.
29
+ PLACES: dict[str, tuple[int, int]] = {
30
+ "cave": (2, 1),
31
+ "well": (17, 1),
32
+ "town_hall": (3, 10),
33
+ "shell": (16, 10),
34
+ "shell_queue": (15, 10),
35
+ "locker_row": (10, 1),
36
+ }
37
+
38
+ # Targets the LLM may pass to `move_to`. `shell_queue` is kept in PLACES
39
+ # as a coordinate anchor but is hidden from the move tool β€” agents reach
40
+ # the queue by trying to enter the shell while someone else holds the lock.
41
+ LLM_TARGETS: tuple[str, ...] = ("cave", "well", "locker_row", "town_hall", "shell")
42
+
43
+ BUILDING_TILES: set[tuple[int, int]] = {
44
+ PLACES["cave"],
45
+ PLACES["well"],
46
+ PLACES["town_hall"],
47
+ PLACES["shell"],
48
+ }
49
+
50
+ # Walkable tiles a character can stand on when at a named place. Order is
51
+ # stable; selection within the list is by hash(name) for deterministic per-
52
+ # character placement. Multiple chars at the same place may land on the
53
+ # same tile β€” z-index handles visual overlap, no functional impact.
54
+ STAND_TILES: dict[str, list[tuple[int, int]]] = {
55
+ "cave": [(1, 1), (3, 1), (2, 2)],
56
+ "well": [(16, 1), (17, 2), (18, 1)],
57
+ "locker_row": [(9, 1), (11, 1), (10, 2)],
58
+ "town_hall": [(2, 10), (4, 10), (3, 9)],
59
+ "shell": [(15, 10), (17, 10), (16, 9), (16, 11)],
60
+ }
61
+
62
+
63
+ def stand_tile_for(place: str, character_name: str) -> tuple[int, int]:
64
+ """Pick a stable stand-tile for `character_name` at `place`. Same
65
+ character at the same place always lands on the same tile."""
66
+ slots = STAND_TILES.get(place)
67
+ if not slots:
68
+ # Unknown place β€” fall back to PLACES coords if present.
69
+ return PLACES.get(place, (1, 1))
70
+ return slots[hash(character_name) % len(slots)]
71
+
72
+
73
+ def location_of(pos: tuple[int, int]) -> str:
74
+ """Return the named place this position is at (one of the stand-tiles)
75
+ or "wandering" if the position isn't a recognised stand-tile."""
76
+ for place, slots in STAND_TILES.items():
77
+ if tuple(pos) in {tuple(s) for s in slots}:
78
+ return place
79
+ return "wandering"
80
+
81
+
82
+ @dataclass
83
+ class BoardPost:
84
+ author: str
85
+ text: str
86
+ tick: int
87
+
88
+
89
+ @dataclass
90
+ class ShellLine:
91
+ author: str
92
+ kind: str # "in" | "out" | "err" | "system"
93
+ text: str
94
+ tick: int
95
+
96
+
97
+ @dataclass
98
+ class WorldState:
99
+ tick: int = 0
100
+ characters: dict[str, Character] = field(default_factory=dict)
101
+ board: list[BoardPost] = field(default_factory=list)
102
+ shell_lines: list[ShellLine] = field(default_factory=list)
103
+ shell_lock_holder: str | None = None
104
+ shell_queue: list[str] = field(default_factory=list)
105
+ board_sentiment: str = "neutral" # "positive" | "neutral" | "negative"
106
+ paused: bool = False
107
+
108
+
109
+ def in_bounds(pos: tuple[int, int]) -> bool:
110
+ x, y = pos
111
+ return 0 <= x < GRID_W and 0 <= y < GRID_H
112
+
113
+
114
+ def is_walkable(pos: tuple[int, int]) -> bool:
115
+ return in_bounds(pos) and pos not in BUILDING_TILES
116
+
117
+
118
+ def resolve_place(name: str) -> tuple[int, int] | None:
119
+ return PLACES.get(name)
120
+
121
+
122
+ def at_place(pos: tuple[int, int], place: str) -> bool:
123
+ """True if `pos` is one of the named place's stand-tiles. Used by action
124
+ guards (mine_coal must be `at_place(pos, 'cave')`, etc.). Since
125
+ movement is now atomic via `stand_tile_for`, this is equivalent to
126
+ `location_of(pos) == place`, but the function survives in this name for
127
+ clarity at the call site."""
128
+ slots = STAND_TILES.get(place)
129
+ if not slots:
130
+ return False
131
+ return tuple(pos) in {tuple(s) for s in slots}
post-deploy-logs.log ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ ===== Application Startup at 2026-06-13 11:30:51 =====
2
+ No logs available
requirements-dev.txt ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Local development dependencies β€” mocks only, no model weights.
2
+ # Keeps install fast and compatible with macOS / Intel without CUDA.
3
+
4
+ gradio
5
+ pillow
6
+ fastapi
7
+ huggingface_hub
8
+ smolagents
9
+ # Only needed to run with FORCE_REAL_MODELS=1 locally. On Mac without CUDA
10
+ # this installs a CPU/Metal build of llama-cpp-python (compiles from source,
11
+ # ~5-15 min the first time). Comment out if you only want mock backends.
12
+ llama-cpp-python
requirements.txt ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces dependencies.
2
+ #
3
+ # IMPORTANT: do NOT list `gradio`, `spaces`, or `huggingface_hub` β€” those are
4
+ # preinstalled by the platform and listing them risks version conflicts that
5
+ # break the build. See
6
+ # https://github.com/huggingface/skills/blob/main/skills/huggingface-spaces/SKILL.md
7
+ #
8
+ # torch's CUDA wheels supply the CUDA libs that llama-cpp-python links
9
+ # against. llama-cpp-python is pulled from abetlen's prebuilt CUDA wheel
10
+ # index β€” building from source on Zero-GPU is impractical (no nvcc, slow).
11
+ # Recipe verified by Dean [UNRL] in the Build Small Discord (2026-05-06).
12
+
13
+ --extra-index-url https://download.pytorch.org/whl/cu128
14
+ torch==2.8.0
15
+
16
+ --extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cu124
17
+ llama-cpp-python
18
+
19
+ # diffusers/accelerate/sentencepiece were dropped when Flux 2 was removed
20
+ # from the pipeline (see backend/factory.py). transformers stays because
21
+ # smolagents imports from it lazily. pillow stays β€” MockImageBackend
22
+ # renders the placeholder gradient with it.
23
+ transformers
24
+ pillow
25
+ hf_transfer
26
+ smolagents