Initial: townlet code duplicated from build-small-hackathon
Browse files- .gitignore +13 -0
- CLAUDE.md +174 -0
- CONTEXT.md +141 -0
- README.md +136 -7
- app.py +428 -0
- backend/__init__.py +0 -0
- backend/config.py +21 -0
- backend/factory.py +59 -0
- backend/interfaces.py +42 -0
- backend/mock.py +418 -0
- backend/real.py +151 -0
- bleed-logs.log +0 -0
- docs/blog.md +50 -0
- docs/hackathon.md +163 -0
- docs/lessons-zerogpu.md +51 -0
- frontend/index.html +1961 -0
- game/__init__.py +0 -0
- game/actions.py +360 -0
- game/archetypes.py +187 -0
- game/character.py +125 -0
- game/dreams.py +94 -0
- game/interpreter.py +193 -0
- game/log.py +18 -0
- game/models.py +96 -0
- game/perception.py +97 -0
- game/scheduler.py +516 -0
- game/sentiment.py +34 -0
- game/smol_adapter.py +80 -0
- game/stream.py +165 -0
- game/the_dot.py +90 -0
- game/world.py +131 -0
- post-deploy-logs.log +2 -0
- requirements-dev.txt +12 -0
- requirements.txt +26 -0
.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:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 6.
|
| 8 |
-
python_version:
|
| 9 |
app_file: app.py
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
---
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
| 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
|