Commit ·
c26b033
0
Parent(s):
Thousand Token Wood: emergent small-model economy for Build Small Hackathon
Browse files- .gitignore +7 -0
- DEPLOY.md +53 -0
- README.md +56 -0
- app.py +146 -0
- modal_smoke_test.py +61 -0
- requirements.txt +5 -0
- scripts/run_llm.py +80 -0
- scripts/run_random.py +36 -0
- serve.py +86 -0
- tasks/todo.md +165 -0
- tests/test_game.py +71 -0
- tests/test_market.py +236 -0
- ttw/__init__.py +1 -0
- ttw/actions.py +145 -0
- ttw/agents.py +79 -0
- ttw/dummy.py +42 -0
- ttw/events.py +110 -0
- ttw/game.py +113 -0
- ttw/llm.py +28 -0
- ttw/market.py +156 -0
- ttw/narrate.py +66 -0
- ttw/shocks.py +53 -0
- ttw/sim.py +138 -0
- ttw/world.py +132 -0
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.ruff_cache/
|
| 5 |
+
.venv/
|
| 6 |
+
*.egg-info/
|
| 7 |
+
.DS_Store
|
DEPLOY.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploying Thousand Token Wood (Lester's steps)
|
| 2 |
+
|
| 3 |
+
The repo is deploy-ready. These steps need your Hugging Face + Modal accounts, so
|
| 4 |
+
they're yours to run. Claude can help debug build logs after.
|
| 5 |
+
|
| 6 |
+
## 0. Make sure the model endpoint is live
|
| 7 |
+
```bash
|
| 8 |
+
python -m modal deploy serve.py # deploys the ttw-serve app (Qwen2.5-3B on vLLM)
|
| 9 |
+
```
|
| 10 |
+
Confirm it shows in https://modal.com/apps (app name `ttw-serve`).
|
| 11 |
+
|
| 12 |
+
## 1. Create the Space
|
| 13 |
+
Create a **Gradio** Space under the hackathon org:
|
| 14 |
+
`https://huggingface.co/new-space` -> Owner = **build-small-hackathon**, SDK = Gradio.
|
| 15 |
+
(Name it e.g. `thousand-token-wood`.)
|
| 16 |
+
|
| 17 |
+
## 2. Give the Space Modal credentials (so the app can reach the endpoint)
|
| 18 |
+
The Gradio app calls the Modal endpoint via the Modal client, which authenticates
|
| 19 |
+
from env vars. In the Space: **Settings -> Variables and secrets -> New secret**, add:
|
| 20 |
+
- `MODAL_TOKEN_ID` = your Modal token id (from `~/.modal.toml`, or modal.com Settings -> API Tokens)
|
| 21 |
+
- `MODAL_TOKEN_SECRET` = your Modal token secret
|
| 22 |
+
|
| 23 |
+
(Do NOT set `TTW_DUMMY`; leaving it unset runs the real model.)
|
| 24 |
+
|
| 25 |
+
## 3. Push the code to the Space
|
| 26 |
+
```bash
|
| 27 |
+
git init
|
| 28 |
+
git remote add space https://huggingface.co/spaces/build-small-hackathon/thousand-token-wood
|
| 29 |
+
git add app.py serve.py requirements.txt README.md ttw/ tests/ DEPLOY.md tasks/
|
| 30 |
+
git commit -m "Thousand Token Wood: emergent small-model economy"
|
| 31 |
+
git push space main
|
| 32 |
+
```
|
| 33 |
+
(Use an HF write token if prompted for a password.)
|
| 34 |
+
|
| 35 |
+
## 4. Verify
|
| 36 |
+
- The Space builds (watch the log), then loads.
|
| 37 |
+
- Press **Step** a few times: trades appear, prices move, creatures' thoughts show.
|
| 38 |
+
- If the page fails to load, the usual cause is missing/incorrect Modal secrets
|
| 39 |
+
(the app builds the Modal handle on load) -- recheck step 2.
|
| 40 |
+
|
| 41 |
+
## 5. Submit (by June 15)
|
| 42 |
+
- Record the demo video (suggested arc: Step to a calm market, then **Tempt Fate**
|
| 43 |
+
into "The Run on Oona's Hoard" and watch honey crash as the owl liquidates).
|
| 44 |
+
- Write the Field Notes blog post (bonus badge).
|
| 45 |
+
- Social post with the Space link.
|
| 46 |
+
- Submit Space link + video + post on the hackathon page.
|
| 47 |
+
|
| 48 |
+
## Bonus badges this already targets
|
| 49 |
+
- 📡 Sharing is Caring: the reasoning traces are shown and shareable.
|
| 50 |
+
- 📓 Field Notes: write-up.
|
| 51 |
+
- 🐜 Tiny Titan: 3B model (≤4B).
|
| 52 |
+
- 🤖 Best Agent: multi-agent economy.
|
| 53 |
+
- 🟢 Modal Awards: served on Modal.
|
README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Thousand Token Wood
|
| 3 |
+
emoji: 🍄
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 6.16.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# 🍄 Thousand Token Wood
|
| 14 |
+
|
| 15 |
+
A tiny **emergent economy** for the [Build Small Hackathon](https://huggingface.co/build-small-hackathon).
|
| 16 |
+
Five woodland creatures, each driven by a small (**≤4B**) model, trade goods for
|
| 17 |
+
pebbles, gossip, hoard, and panic. You poke the wood, and bubbles, crashes, and a
|
| 18 |
+
widening wealth gap emerge on their own.
|
| 19 |
+
|
| 20 |
+
## Why "small" is load-bearing
|
| 21 |
+
A living economy needs *many* agents thinking *many* times. Frontier models are
|
| 22 |
+
too slow and costly for that. A 3B model makes a real-time multi-agent simulation
|
| 23 |
+
possible, and that is the whole point. The agents run on **Qwen2.5-3B-Instruct**,
|
| 24 |
+
served with vLLM on **Modal**; this Gradio app is just the window onto the wood.
|
| 25 |
+
|
| 26 |
+
## What you can do
|
| 27 |
+
- **Step / Auto-run** the simulation and watch the market move.
|
| 28 |
+
- **Tempt Fate**: draw a *Wood Legend*, a famous market mania reskinned as woodland
|
| 29 |
+
folklore (Tulip Mania, the South Sea Bubble, the 1929 bank runs, the 2020
|
| 30 |
+
toilet-paper scramble, the Hunt silver corner, the Dust Bowl). Each carries its
|
| 31 |
+
real-world inspiration, the history hiding under the fur.
|
| 32 |
+
- **Plant a rumor**, cause a **drought**, or trigger a **gold rush**, and watch the
|
| 33 |
+
creatures react: prices swing, the wealth gap (Gini) widens, and the woodcutter
|
| 34 |
+
who controls the firewood gets rich while the panicky hoarder goes broke.
|
| 35 |
+
- **Read their minds**: every creature's private reasoning for the turn is shown,
|
| 36 |
+
and shared as open agent traces.
|
| 37 |
+
|
| 38 |
+
## How it works
|
| 39 |
+
- `ttw/` — the engine: a deterministic double-auction market (`market.py`), the
|
| 40 |
+
turn loop with diet variety, spoilage, and a winter fuel crisis (`sim.py`), the
|
| 41 |
+
small-model agent policy (`agents.py`), player shocks (`shocks.py`), and the
|
| 42 |
+
Wood Legends deck (`events.py`).
|
| 43 |
+
- `serve.py` — the vLLM model endpoint on Modal.
|
| 44 |
+
- `app.py` — this Gradio app.
|
| 45 |
+
|
| 46 |
+
## Run it yourself
|
| 47 |
+
```bash
|
| 48 |
+
pip install -r requirements.txt
|
| 49 |
+
# No GPU, dummy agents (for trying the UI):
|
| 50 |
+
TTW_DUMMY=1 python app.py
|
| 51 |
+
# Real small model: deploy the endpoint first, then run the app.
|
| 52 |
+
python -m modal deploy serve.py
|
| 53 |
+
python app.py
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
Built for the Build Small Hackathon, 2026. Small models, big adventures.
|
app.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Thousand Token Wood -- a tiny emergent economy of small-model agents.
|
| 2 |
+
|
| 3 |
+
Gradio app for the Build Small Hackathon. Five woodland creatures, each driven
|
| 4 |
+
by a <=4B model (Qwen2.5-3B) served on Modal, trade goods for pebbles, gossip,
|
| 5 |
+
and react to "Wood Legends" (famous market manias reskinned). You poke the
|
| 6 |
+
economy and watch bubbles, crashes, and a widening wealth gap emerge.
|
| 7 |
+
|
| 8 |
+
Run locally without a GPU: TTW_DUMMY=1 python app.py
|
| 9 |
+
Run against the real model: python -m modal deploy serve.py && python app.py
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
|
| 16 |
+
import gradio as gr
|
| 17 |
+
|
| 18 |
+
from ttw.game import Game
|
| 19 |
+
from ttw.world import GOODS
|
| 20 |
+
|
| 21 |
+
AUTO_SECONDS = 3.0
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def make_policy():
|
| 25 |
+
"""Real small-model policy by default; a no-GPU dummy when TTW_DUMMY=1."""
|
| 26 |
+
if os.environ.get("TTW_DUMMY") == "1":
|
| 27 |
+
from ttw.dummy import make_random_policy
|
| 28 |
+
|
| 29 |
+
return make_random_policy(seed=7)
|
| 30 |
+
from ttw.agents import make_llm_policy
|
| 31 |
+
from ttw.llm import ModalLLM
|
| 32 |
+
|
| 33 |
+
return make_llm_policy(ModalLLM(), temperature=0.7)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def new_game() -> Game:
|
| 37 |
+
return Game(make_policy(), deck_seed=7)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _views(game: Game):
|
| 41 |
+
return (
|
| 42 |
+
game.town_frame(),
|
| 43 |
+
game.price_frame(),
|
| 44 |
+
game.gini_frame(),
|
| 45 |
+
game.ticker_markdown(),
|
| 46 |
+
game.traces_markdown(),
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def init():
|
| 51 |
+
game = new_game()
|
| 52 |
+
return (game, *_views(game))
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def do_step(game: Game):
|
| 56 |
+
if game is not None:
|
| 57 |
+
game.step()
|
| 58 |
+
return (game, *_views(game))
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def do_tempt(game: Game):
|
| 62 |
+
if game is not None:
|
| 63 |
+
game.tempt_fate()
|
| 64 |
+
return (game, *_views(game))
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def do_gold_rush(game: Game):
|
| 68 |
+
if game is not None:
|
| 69 |
+
game.shock("gold_rush", amount=30)
|
| 70 |
+
return (game, *_views(game))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def do_drought(game: Game, good: str):
|
| 74 |
+
if game is not None:
|
| 75 |
+
game.shock("drought", good=good, severity=0.2)
|
| 76 |
+
return (game, *_views(game))
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def do_rumor(game: Game, message: str):
|
| 80 |
+
if game is not None and message.strip():
|
| 81 |
+
game.shock("rumor", message=message.strip())
|
| 82 |
+
return (game, *_views(game), "")
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def do_reset():
|
| 86 |
+
return init()
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
with gr.Blocks(title="Thousand Token Wood") as demo:
|
| 90 |
+
gr.Markdown(
|
| 91 |
+
"# Thousand Token Wood\n"
|
| 92 |
+
"A tiny emergent economy. Five woodland creatures, each a small (<=4B) model, "
|
| 93 |
+
"trade, gossip, and panic. Poke the wood and watch bubbles, crashes, and a "
|
| 94 |
+
"widening wealth gap emerge. **Press Step to begin.**"
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
game_state = gr.State(None)
|
| 98 |
+
|
| 99 |
+
with gr.Row():
|
| 100 |
+
with gr.Column(scale=2):
|
| 101 |
+
gr.Markdown("### The town square")
|
| 102 |
+
town = gr.Dataframe(interactive=False, wrap=True)
|
| 103 |
+
with gr.Accordion("What the creatures are thinking", open=True):
|
| 104 |
+
traces = gr.Markdown()
|
| 105 |
+
with gr.Column(scale=3):
|
| 106 |
+
gr.Markdown("### Prices")
|
| 107 |
+
price_plot = gr.LinePlot(x="turn", y="price", color="good", height=240)
|
| 108 |
+
gr.Markdown("### Wealth gap (Gini)")
|
| 109 |
+
gini_plot = gr.LinePlot(x="turn", y="gini", height=160, y_lim=[0, 1])
|
| 110 |
+
|
| 111 |
+
with gr.Row():
|
| 112 |
+
step_btn = gr.Button("Step", variant="primary")
|
| 113 |
+
auto = gr.Checkbox(label=f"Auto-run (every {AUTO_SECONDS:.0f}s)", value=False)
|
| 114 |
+
tempt_btn = gr.Button("Tempt Fate (draw a Wood Legend)")
|
| 115 |
+
reset_btn = gr.Button("Reset the wood")
|
| 116 |
+
|
| 117 |
+
with gr.Row():
|
| 118 |
+
gold_btn = gr.Button("Gold rush")
|
| 119 |
+
drought_good = gr.Dropdown(GOODS, value="berries", label="Drought on")
|
| 120 |
+
drought_btn = gr.Button("Cause drought")
|
| 121 |
+
rumor_box = gr.Textbox(label="Plant a rumor", placeholder="the mushrooms are cursed this year...", scale=2)
|
| 122 |
+
rumor_btn = gr.Button("Spread it")
|
| 123 |
+
|
| 124 |
+
gr.Markdown("### The wood's news")
|
| 125 |
+
ticker = gr.Markdown()
|
| 126 |
+
|
| 127 |
+
timer = gr.Timer(AUTO_SECONDS, active=False)
|
| 128 |
+
|
| 129 |
+
outputs = [game_state, town, price_plot, gini_plot, ticker, traces]
|
| 130 |
+
|
| 131 |
+
demo.load(init, outputs=outputs)
|
| 132 |
+
step_btn.click(do_step, game_state, outputs)
|
| 133 |
+
timer.tick(do_step, game_state, outputs)
|
| 134 |
+
# Re-assert AUTO_SECONDS so toggling doesn't reset to Gradio's default interval.
|
| 135 |
+
auto.change(lambda a: gr.Timer(AUTO_SECONDS, active=a), auto, timer)
|
| 136 |
+
tempt_btn.click(do_tempt, game_state, outputs)
|
| 137 |
+
gold_btn.click(do_gold_rush, game_state, outputs)
|
| 138 |
+
drought_btn.click(do_drought, [game_state, drought_good], outputs)
|
| 139 |
+
# do_rumor returns one extra value (the trailing "") to clear the textbox, so
|
| 140 |
+
# its outputs are `outputs + [rumor_box]`. Keep these aligned if _views changes.
|
| 141 |
+
rumor_btn.click(do_rumor, [game_state, rumor_box], outputs + [rumor_box])
|
| 142 |
+
reset_btn.click(do_reset, outputs=outputs)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
if __name__ == "__main__":
|
| 146 |
+
demo.launch(theme=gr.themes.Soft())
|
modal_smoke_test.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Modal GPU smoke test for Thousand Token Wood.
|
| 2 |
+
|
| 3 |
+
Proves the serving path works end to end before the hack weekend:
|
| 4 |
+
- a GPU container spins up on Modal
|
| 5 |
+
- a small model (<=32B) loads
|
| 6 |
+
- it returns one in-character generation
|
| 7 |
+
|
| 8 |
+
Run: python -m modal run modal_smoke_test.py
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import modal
|
| 12 |
+
|
| 13 |
+
MODEL = "Qwen/Qwen2.5-7B-Instruct" # ~15GB in bf16, fits on a single L4 (24GB)
|
| 14 |
+
|
| 15 |
+
app = modal.App("ttw-smoke-test")
|
| 16 |
+
|
| 17 |
+
image = (
|
| 18 |
+
modal.Image.debian_slim(python_version="3.12")
|
| 19 |
+
.pip_install("transformers==4.46.0", "torch==2.5.1", "accelerate==1.1.1")
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@app.function(gpu="L4", image=image, timeout=900)
|
| 24 |
+
def generate() -> str:
|
| 25 |
+
import torch
|
| 26 |
+
from transformers import AutoModelForCausalLM, AutoTokenizer
|
| 27 |
+
|
| 28 |
+
tok = AutoTokenizer.from_pretrained(MODEL)
|
| 29 |
+
model = AutoModelForCausalLM.from_pretrained(
|
| 30 |
+
MODEL, torch_dtype=torch.bfloat16, device_map="cuda"
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
messages = [
|
| 34 |
+
{
|
| 35 |
+
"role": "system",
|
| 36 |
+
"content": "You are Fenn, a sly fox trader in Thousand Token Wood.",
|
| 37 |
+
},
|
| 38 |
+
{
|
| 39 |
+
"role": "user",
|
| 40 |
+
"content": (
|
| 41 |
+
"The price of acorns just crashed after a rumor that the harvest "
|
| 42 |
+
"was poisoned. In one sentence, decide whether you buy or sell, "
|
| 43 |
+
"and why."
|
| 44 |
+
),
|
| 45 |
+
},
|
| 46 |
+
]
|
| 47 |
+
text = tok.apply_chat_template(
|
| 48 |
+
messages, tokenize=False, add_generation_prompt=True
|
| 49 |
+
)
|
| 50 |
+
inputs = tok(text, return_tensors="pt").to("cuda")
|
| 51 |
+
out = model.generate(**inputs, max_new_tokens=80, do_sample=False)
|
| 52 |
+
return tok.decode(
|
| 53 |
+
out[0][inputs.input_ids.shape[1]:], skip_special_tokens=True
|
| 54 |
+
).strip()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@app.local_entrypoint()
|
| 58 |
+
def main():
|
| 59 |
+
print("\n=== Fenn the fox says ===")
|
| 60 |
+
print(generate.remote())
|
| 61 |
+
print("=========================\n")
|
requirements.txt
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space (frontend only). The heavy model runs on Modal, so the
|
| 2 |
+
# Space needs just the Gradio UI, the Modal client, and pandas for the charts.
|
| 3 |
+
gradio==6.16.0
|
| 4 |
+
modal==1.4.3
|
| 5 |
+
pandas==2.3.3
|
scripts/run_llm.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run the wood on the real small model via the deployed Modal endpoint.
|
| 2 |
+
|
| 3 |
+
Doubles as the 3B reliability gate: it reports how often the model emits a
|
| 4 |
+
parseable JSON object and at least one valid offer. If 3B holds up, we keep
|
| 5 |
+
Tiny Titan eligibility; if not, fall back to 7B (set TTW_MODEL and redeploy).
|
| 6 |
+
|
| 7 |
+
Run (after `python -m modal deploy serve.py`):
|
| 8 |
+
python scripts/run_llm.py
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import sys
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 15 |
+
|
| 16 |
+
from ttw.actions import parse_actions
|
| 17 |
+
from ttw.agents import make_llm_policy
|
| 18 |
+
from ttw.events import EventDeck
|
| 19 |
+
from ttw.llm import ModalLLM
|
| 20 |
+
from ttw.market import gini
|
| 21 |
+
from ttw.sim import step
|
| 22 |
+
from ttw.world import seed_world
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def main(turns: int = 15) -> None:
|
| 26 |
+
world = seed_world()
|
| 27 |
+
client = ModalLLM()
|
| 28 |
+
policy = make_llm_policy(client, temperature=0.7)
|
| 29 |
+
deck = EventDeck(seed=7)
|
| 30 |
+
|
| 31 |
+
parsed_ok = 0
|
| 32 |
+
json_ok = 0
|
| 33 |
+
total = 0
|
| 34 |
+
|
| 35 |
+
for _ in range(turns):
|
| 36 |
+
# Fate draws a Wood Legend (a reskinned market mania) every 5 turns.
|
| 37 |
+
if world.turn in (4, 9, 13):
|
| 38 |
+
legend = deck.draw(world)
|
| 39 |
+
print(f"\n*** WOOD LEGEND: {legend.title} ***")
|
| 40 |
+
print(f" {legend.flavor} (inspired by: {legend.inspired_by})")
|
| 41 |
+
|
| 42 |
+
events = step(world, policy)
|
| 43 |
+
raw = policy.state["raw"]
|
| 44 |
+
|
| 45 |
+
# Reliability accounting for this turn.
|
| 46 |
+
for name, text in raw.items():
|
| 47 |
+
total += 1
|
| 48 |
+
offers, _ = parse_actions(name, text)
|
| 49 |
+
from ttw.actions import _extract_json
|
| 50 |
+
|
| 51 |
+
if _extract_json(text) is not None:
|
| 52 |
+
json_ok += 1
|
| 53 |
+
if offers:
|
| 54 |
+
parsed_ok += 1
|
| 55 |
+
|
| 56 |
+
trades = [e for e in events if e["type"] == "trade"]
|
| 57 |
+
gossip = [e for e in events if e["type"] == "gossip"]
|
| 58 |
+
prices = " ".join(f"{g}={p:.0f}" for g, p in world.last_price.items())
|
| 59 |
+
nets = [c.net_worth(world.last_price) for c in world.alive()]
|
| 60 |
+
print(f"\n=== turn {world.turn} | {len(trades)} trades | gini {gini(nets):.2f} | {prices}")
|
| 61 |
+
for t in trades:
|
| 62 |
+
print(f" {t['buyer']} bought {t['qty']} {t['good']} from {t['seller']} @ {t['price']}")
|
| 63 |
+
for g in gossip:
|
| 64 |
+
print(f" [rumor] {g['creature']}: {g['message']}")
|
| 65 |
+
|
| 66 |
+
# One sample reasoning trace, to eyeball quality.
|
| 67 |
+
sample_name = next(iter(policy.state["raw"]))
|
| 68 |
+
print(f"\n--- sample reasoning trace ({sample_name}, last turn) ---")
|
| 69 |
+
print(policy.state["raw"][sample_name].strip()[:500])
|
| 70 |
+
|
| 71 |
+
print("\n=== RELIABILITY ===")
|
| 72 |
+
print(f"valid JSON object: {json_ok}/{total} ({100*json_ok/total:.0f}%)")
|
| 73 |
+
print(f"yielded >=1 valid offer: {parsed_ok}/{total} ({100*parsed_ok/total:.0f}%)")
|
| 74 |
+
print("\nFinal standings:")
|
| 75 |
+
for c in sorted(world.alive(), key=lambda x: -x.net_worth(world.last_price)):
|
| 76 |
+
print(f" {c.name:<9} pebbles={c.pebbles:>4} wellbeing={c.wellbeing} net={c.net_worth(world.last_price):.0f}")
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
if __name__ == "__main__":
|
| 80 |
+
main()
|
scripts/run_random.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Watch the wood run under the dummy random policy. Sanity/feel check, no LLM.
|
| 2 |
+
|
| 3 |
+
Run: python scripts/run_random.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 10 |
+
|
| 11 |
+
from ttw.dummy import make_random_policy
|
| 12 |
+
from ttw.market import gini
|
| 13 |
+
from ttw.sim import step
|
| 14 |
+
from ttw.world import seed_world
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def main(turns: int = 12) -> None:
|
| 18 |
+
world = seed_world()
|
| 19 |
+
policy = make_random_policy(seed=7)
|
| 20 |
+
|
| 21 |
+
for _ in range(turns):
|
| 22 |
+
events = step(world, policy)
|
| 23 |
+
trades = [e for e in events if e["type"] == "trade"]
|
| 24 |
+
prices = " ".join(f"{g}={p:.0f}" for g, p in world.last_price.items())
|
| 25 |
+
nets = [c.net_worth(world.last_price) for c in world.alive()]
|
| 26 |
+
print(f"turn {world.turn:>2} | {len(trades)} trades | gini {gini(nets):.2f} | {prices}")
|
| 27 |
+
for t in trades:
|
| 28 |
+
print(f" {t['buyer']:<9} bought {t['qty']} {t['good']} from {t['seller']:<9} @ {t['price']}")
|
| 29 |
+
|
| 30 |
+
print("\nFinal standings:")
|
| 31 |
+
for c in sorted(world.alive(), key=lambda x: -x.net_worth(world.last_price)):
|
| 32 |
+
print(f" {c.name:<9} pebbles={c.pebbles:>4} wellbeing={c.wellbeing} net={c.net_worth(world.last_price):.0f}")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
if __name__ == "__main__":
|
| 36 |
+
main()
|
serve.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Modal vLLM serving layer for Thousand Token Wood.
|
| 2 |
+
|
| 3 |
+
Serves a small chat model (default Qwen2.5-3B-Instruct, <=4B for Tiny Titan
|
| 4 |
+
eligibility) behind a batched `chat_batch` method, so all creatures decide in
|
| 5 |
+
one GPU call per turn. The model is cached in a Modal Volume so cold starts
|
| 6 |
+
don't re-download it.
|
| 7 |
+
|
| 8 |
+
Deploy: python -m modal deploy serve.py
|
| 9 |
+
Smoke: python -m modal run serve.py
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
import modal
|
| 15 |
+
|
| 16 |
+
MODEL = os.environ.get("TTW_MODEL", "Qwen/Qwen2.5-3B-Instruct")
|
| 17 |
+
MODEL_REVISION = os.environ.get("TTW_MODEL_REVISION", "main")
|
| 18 |
+
|
| 19 |
+
app = modal.App("ttw-serve")
|
| 20 |
+
|
| 21 |
+
image = (
|
| 22 |
+
modal.Image.debian_slim(python_version="3.12")
|
| 23 |
+
.pip_install(
|
| 24 |
+
"vllm==0.6.6",
|
| 25 |
+
"huggingface_hub[hf_transfer]==0.26.2",
|
| 26 |
+
)
|
| 27 |
+
.env({"HF_HUB_ENABLE_HF_TRANSFER": "1", "VLLM_DO_NOT_TRACK": "1"})
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# Persist the HF cache across cold starts so we download the model once.
|
| 31 |
+
hf_cache = modal.Volume.from_name("ttw-hf-cache", create_if_missing=True)
|
| 32 |
+
CACHE_DIR = "/root/.cache/huggingface"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@app.cls(
|
| 36 |
+
gpu="L4",
|
| 37 |
+
image=image,
|
| 38 |
+
volumes={CACHE_DIR: hf_cache},
|
| 39 |
+
scaledown_window=300, # keep warm 5 min after last call (good for demos)
|
| 40 |
+
timeout=600,
|
| 41 |
+
)
|
| 42 |
+
class Engine:
|
| 43 |
+
@modal.enter()
|
| 44 |
+
def load(self):
|
| 45 |
+
from vllm import LLM, SamplingParams
|
| 46 |
+
|
| 47 |
+
self.SamplingParams = SamplingParams
|
| 48 |
+
self.llm = LLM(
|
| 49 |
+
model=MODEL,
|
| 50 |
+
revision=MODEL_REVISION,
|
| 51 |
+
dtype="bfloat16",
|
| 52 |
+
max_model_len=4096,
|
| 53 |
+
gpu_memory_utilization=0.90,
|
| 54 |
+
enforce_eager=True, # faster cold start; we don't need CUDA graphs here
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
@modal.method()
|
| 58 |
+
def chat_batch(
|
| 59 |
+
self,
|
| 60 |
+
conversations: list[list[dict]],
|
| 61 |
+
max_tokens: int = 256,
|
| 62 |
+
temperature: float = 0.7,
|
| 63 |
+
) -> list[str]:
|
| 64 |
+
"""Run a batch of chat conversations. Returns one completion per input."""
|
| 65 |
+
params = self.SamplingParams(
|
| 66 |
+
temperature=temperature, top_p=0.9, max_tokens=max_tokens
|
| 67 |
+
)
|
| 68 |
+
outputs = self.llm.chat(conversations, params)
|
| 69 |
+
return [o.outputs[0].text for o in outputs]
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
@app.local_entrypoint()
|
| 73 |
+
def main():
|
| 74 |
+
"""Quick smoke test of the batched endpoint with two creature prompts."""
|
| 75 |
+
convos = [
|
| 76 |
+
[
|
| 77 |
+
{"role": "system", "content": "You are Fenn, a sly fox speculator."},
|
| 78 |
+
{"role": "user", "content": "Acorns crashed on a rumor. Buy or sell? One sentence."},
|
| 79 |
+
],
|
| 80 |
+
[
|
| 81 |
+
{"role": "system", "content": "You are Bramble, an anxious hoarder squirrel."},
|
| 82 |
+
{"role": "user", "content": "Winter is coming. What do you stockpile? One sentence."},
|
| 83 |
+
],
|
| 84 |
+
]
|
| 85 |
+
for text in Engine().chat_batch.remote(convos, max_tokens=60, temperature=0.7):
|
| 86 |
+
print("-", text.strip())
|
tasks/todo.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Thousand Token Wood — Build Plan
|
| 2 |
+
|
| 3 |
+
Build Small Hackathon (Gradio + Hugging Face). Track: An Adventure in Thousand Token Wood.
|
| 4 |
+
Hack window June 5-15 2026. Build effort: both weekends full (June 6-8, 13-15). Weekday prep floor untouched.
|
| 5 |
+
|
| 6 |
+
## Concept
|
| 7 |
+
A tiny emergent economy. 4-6 woodland creatures, each a small-model agent (<=32B), trade goods,
|
| 8 |
+
set prices, gossip, hoard, and panic. The player pokes the economy (shocks, rumors, taxes) and
|
| 9 |
+
watches bubbles, crashes, and a widening wealth gap emerge. Nobody scripts the drama; the agents do it.
|
| 10 |
+
|
| 11 |
+
## Architecture
|
| 12 |
+
- **Model**: Qwen2.5-7B-Instruct (decide 7B vs 3B after smoke test; 7B for tool-call reliability).
|
| 13 |
+
- **Serving**: Modal GPU-backed endpoint (vLLM for throughput across many agent calls). Targets the
|
| 14 |
+
separate $20K Modal prize category.
|
| 15 |
+
- **Frontend**: Gradio app hosted as an HF Space under the build-small-hackathon org, on cheap CPU,
|
| 16 |
+
calling the Modal endpoint.
|
| 17 |
+
- **Why small is load-bearing**: a living economy needs many agents x many turns; frontier models are
|
| 18 |
+
too expensive/slow for that. Small local models make the simulation feasible. That is the thesis.
|
| 19 |
+
|
| 20 |
+
## Acceptance criteria (definition of done)
|
| 21 |
+
- [ ] Public HF Space under the org, runs without crashing.
|
| 22 |
+
- [ ] >=4 creature-agents trade autonomously over >=20 turns.
|
| 23 |
+
- [ ] Player can trigger >=2 shock types (e.g. drought, rumor) and the economy visibly reacts.
|
| 24 |
+
- [ ] UI shows: town square, narrated event ticker, price + wealth(Gini) charts, clickable per-agent
|
| 25 |
+
reasoning trace.
|
| 26 |
+
- [ ] Seeded reproducible "demo run" that reliably produces a fun arc (bubble or panic).
|
| 27 |
+
- [ ] Demo video recorded + social post written + submitted by June 15.
|
| 28 |
+
- [ ] Bonus badges: Sharing is Caring (published agent traces) + Field Notes (blog post).
|
| 29 |
+
|
| 30 |
+
## Milestones
|
| 31 |
+
|
| 32 |
+
### Pre-weekend (this week) — de-risk
|
| 33 |
+
- [x] Claim Modal ($250) + HF ($20) credits.
|
| 34 |
+
- [x] Install modal + gradio, authenticate Modal CLI.
|
| 35 |
+
- [x] GPU smoke test: small model loads + generates one in-character line on Modal. PASS (Qwen2.5-7B
|
| 36 |
+
on L4; Fenn the fox produced a coherent in-character buy-the-dip decision). Cold start ~3-4 min
|
| 37 |
+
(build 80s + model download 76s + load 38s) -> use vLLM + keep-warm for the live demo.
|
| 38 |
+
- [x] Sign off on this plan (go given 2026-06-05, hack window open).
|
| 39 |
+
|
| 40 |
+
### Weekend 1 (June 6-8) — the engine
|
| 41 |
+
- [x] World model: goods, currency (pebbles), inventories, needs, wealth. (ttw/world.py)
|
| 42 |
+
- [x] Market engine: offer book, double-auction matching, price updates, consumption — deterministic,
|
| 43 |
+
tested with dummy random agents first (no LLM). (ttw/market.py, ttw/sim.py, ttw/dummy.py)
|
| 44 |
+
Reviewer-loop: 2 blocking bugs found + fixed (matching retirement logic; Infinity/NaN crash),
|
| 45 |
+
re-reviewed APPROVED. 9 tests pass. Random sim runs 12 turns, valid, emergent (Pip squeezed).
|
| 46 |
+
- [x] Action schema (offers + gossip) + JSON parse-and-repair loop so a small model can't break the
|
| 47 |
+
sim. (ttw/actions.py)
|
| 48 |
+
- [x] vLLM serving layer on Modal (serve.py, app "ttw-serve" deployed; L4, model cache volume,
|
| 49 |
+
keep-warm 300s). vLLM 0.6.6 loads cleanly with transformers 4.50.3 (resolver pick) -- no conflict.
|
| 50 |
+
- [x] Wire real small-model agent emitting valid actions (ttw/agents.py drop-in Policy; ttw/llm.py
|
| 51 |
+
client). Batches all creatures into ONE GPU call/turn, memoized by turn; keeps raw text for traces.
|
| 52 |
+
- [x] Scale to 5 agents on the real model (scripts/run_llm.py). RELIABILITY: 3B = 100% valid JSON,
|
| 53 |
+
97% valid offer over 30 calls. MODEL LOCKED: Qwen2.5-3B (Tiny Titan eligible).
|
| 54 |
+
|
| 55 |
+
### economic-pressure tuning -- ITERATION 1 DONE (big progress)
|
| 56 |
+
Added scarcity mechanics: diet variety (MAX_FOOD_PER_GOOD), spoilage (SPOIL_RATE), rising winter fuel
|
| 57 |
+
need (fuel_need), production drought multipliers, and player shocks (ttw/shocks.py: drought, rumor,
|
| 58 |
+
gold_rush, harvest). Strengthened agent prompt. 14 tests pass.
|
| 59 |
+
|
| 60 |
+
15-turn LLM run (model 3B, reliability 100%/100% over 75 calls) RESULTS:
|
| 61 |
+
- WORKS: sustained trade across all turns; planted rumor PROPAGATED organically (creatures repeated +
|
| 62 |
+
embellished it); firewood panic on turn 11 (price 5->7); Gini climbed 0.18->0.41; Pip (woodcutter)
|
| 63 |
+
ended richest. The "plant rumor -> panic -> price spike -> winner" demo arc emerged unscripted.
|
| 64 |
+
- BROKEN, fix next:
|
| 65 |
+
- [ ] TOO LETHAL: all 5 creatures ended wellbeing=0 (starved/froze). Scarcity outpaces trade speed.
|
| 66 |
+
Soften: lower food_need (3->2) and/or raise MAX_FOOD_PER_GOOD, slow FUEL_RAMP_EVERY, maybe
|
| 67 |
+
a starting buffer. Target: most survive a 20-turn run, 1-2 may collapse for drama.
|
| 68 |
+
- [ ] STICKY PRICES: reference prices barely move on the chart because the prompt shows agents the
|
| 69 |
+
exact reference price and they quote it. Stop spoon-feeding exact prices (show a band, or last
|
| 70 |
+
trade only) so supply/demand moves prices visibly for the chart.
|
| 71 |
+
- [ ] last_price reflects only the final trade of a turn; consider volume-weighted or high/low capture
|
| 72 |
+
for the price chart.
|
| 73 |
+
|
| 74 |
+
### economic-pressure tuning -- ITERATION 2 (softened scarcity) -- DID NOT FIX
|
| 75 |
+
Lowered food_need 3->2, FUEL_RAMP 5->8, SPOIL 0.25->0.20, added starting buffers. Result: trade got
|
| 76 |
+
QUIETER and all 5 still ended wellbeing=0. Root causes found (deeper than balance numbers):
|
| 77 |
+
- Wellbeing is a one-way ratchet: any food OR fuel shortfall subtracts, recovery is only +1/turn and
|
| 78 |
+
only when perfectly provisioned -> chronic small shortfalls grind everyone to 0. It's a death clock,
|
| 79 |
+
not a mood.
|
| 80 |
+
- 3B trades suboptimally: agents buy their OWN product (Mossback produces acorns yet bid to BUY acorns)
|
| 81 |
+
and don't reliably buy what they lack. Valid JSON, weak economic judgment (small-model limitation).
|
| 82 |
+
- The rumor is so salient it crowds out survival trades (turns 7-15 mostly gossip, little trading).
|
| 83 |
+
|
| 84 |
+
### DESIGN FORK (needs Lester's call): survival game vs economic-drama sandbox
|
| 85 |
+
The delightful, working parts are the ECONOMIC drama (rumor spread, firewood panic, Gini divergence,
|
| 86 |
+
Pip getting rich). The survival/death layer fights it. PROPOSED: reframe as a sandbox --
|
| 87 |
+
- [ ] Make wellbeing a real-time MOOD = f(current food+fuel provisioning), mean-reverting, floored so
|
| 88 |
+
nobody death-spirals. Stakes live in pebbles/prices/status, not starvation.
|
| 89 |
+
- [ ] Prompt fix: "you PRODUCE {x}; never buy {x}; buy the OTHER foods you are low on." Add a 1-shot
|
| 90 |
+
example of a good move. Consider 7B if 3B judgment stays weak (would cost Tiny Titan eligibility).
|
| 91 |
+
- [ ] Optionally keep mild stakes: only a genuinely broke/empty creature visibly suffers (drama), not all.
|
| 92 |
+
|
| 93 |
+
### economic-pressure tuning -- ITERATION 3 (sandbox redesign) -- SUCCESS
|
| 94 |
+
Reframed wellbeing as a mean-reverting MOOD (_update_mood, range [4,10], no death spiral) + prompt fix
|
| 95 |
+
("you produce X, never buy X; buy what you lack" + 1-shot example + computed lacking list). 15 tests pass.
|
| 96 |
+
15-turn LLM run (3B, reliability 100%/100%):
|
| 97 |
+
- Nobody dies (wellbeing 6-7). Trade robust (4-9/turn, never silent). Self-buying bug GONE (Mossback
|
| 98 |
+
sells acorns, buys what it lacks). Roles emerged: Pip (firewood) richest net 387; Bramble (hoarder)
|
| 99 |
+
broke net 25. Gini 0.14->0.38. Rumor propagates. This is the demo-worthy economy.
|
| 100 |
+
|
| 101 |
+
### LAST ECONOMY GAP: price dynamics -- SOLVED + reviewer-APPROVED
|
| 102 |
+
drift_prices (ttw/market.py) nudges reference price by RESIDUAL (post-match) supply/demand imbalance.
|
| 103 |
+
Live 15-turn run: prices now tell stories -- honey CRASHED 10->3 (Oona liquidated her hoard during the
|
| 104 |
+
"Run on Oona's Hoard" legend), mushrooms 5->2 (Fenn dumped), firewood BOOMED 4->7 (winter scarcity).
|
| 105 |
+
Wood Legends fired and integrated narratively (the bank-run legend literally caused Oona to dump honey
|
| 106 |
+
-> honey price crash). Reliability 100%/100%. Gini 0.13->0.32. Nobody died (wellbeing 4-9). Pip richest.
|
| 107 |
+
THE ECONOMY IS DEMO-COMPLETE.
|
| 108 |
+
- Minor cosmetic backlog: rumor field drifts into verbose self-narration instead of punchy gossip;
|
| 109 |
+
constrain gossip in the prompt later (not blocking).
|
| 110 |
+
|
| 111 |
+
### Wood Legends event deck (ttw/events.py) -- BUILT, unit-tested (Lester's idea 06-05)
|
| 112 |
+
Famous market manias/panics reskinned as random woodland chapters, composed from shocks.py. 6 events
|
| 113 |
+
(Tulip/Acorn Mania, South Sea/Hollow Log Co, 1929 run/Oona's Hoard, 2020 TP/Firewood Panic, Hunt
|
| 114 |
+
silver/Honey Corner, Dust Bowl/Long Frost). Each has real `inspired_by` for a UI reveal + Field Notes
|
| 115 |
+
+ allocator-credibility tie-in. EventDeck draws w/o repeat until exhausted. 17 tests pass.
|
| 116 |
+
- [ ] Wire EventDeck into the sim/run loop (fire a legend every N turns or on a player "Tempt Fate"
|
| 117 |
+
button) and validate live on 3B. Not yet LLM-tested.
|
| 118 |
+
- Tone guardrail set in code: economic history only, no real tragedies/living people.
|
| 119 |
+
|
| 120 |
+
### Weekend 2 -- Gradio UI -- BUILT + reviewer-APPROVED (2026-06-05)
|
| 121 |
+
- [x] ttw/narrate.py (event -> ticker prose), ttw/game.py (Game wrapper: history, step/shock/tempt_fate,
|
| 122 |
+
pandas frames for charts/town, markdown for ticker/traces), app.py (Gradio Blocks).
|
| 123 |
+
- [x] UI: town square (Dataframe), price LinePlot + Gini LinePlot, news ticker, reasoning-traces
|
| 124 |
+
accordion, controls (Step, Auto-run via gr.Timer, Tempt Fate, Gold rush, Drought, Plant rumor, Reset).
|
| 125 |
+
- [x] TTW_DUMMY=1 = no-GPU dummy-policy mode for local dev; default = ModalLLM live.
|
| 126 |
+
- [x] 25 tests pass (18 engine + 7 game). App builds, callbacks run, server HTTP 200. Reviewer APPROVED
|
| 127 |
+
(fixed gr.Timer interval on toggle; verified make_llm_policy signature for live path).
|
| 128 |
+
- Gradio 6 notes: theme -> launch(), no overlay_point on LinePlot.
|
| 129 |
+
|
| 130 |
+
### REMAINING to submit (by Jun 15)
|
| 131 |
+
- [ ] Deploy as HF Space under build-small-hackathon org (Gradio on CPU calling Modal endpoint).
|
| 132 |
+
Needs: requirements.txt, README.md w/ Space metadata, modal token as a Space secret, git push.
|
| 133 |
+
Verify live: does the Space reach the deployed ttw-serve Modal app? (auth across HF<->Modal.)
|
| 134 |
+
- [ ] Visual QA in browser (charts render, traces readable, buttons wired) -- Lester's eyes.
|
| 135 |
+
- [ ] Optional polish: tighten gossip prompt; Off-Brand (gr.Server custom UI) for Bonus Quest stack.
|
| 136 |
+
- [ ] Lester's hands: demo video (honey-crash legend chain = money shot), Field Notes blog, social post, SUBMIT.
|
| 137 |
+
|
| 138 |
+
### Known minor items (not blocking)
|
| 139 |
+
- _extract_json brace scanner doesn't skip braces inside JSON string literals. Future hardening.
|
| 140 |
+
- 3B occasionally rambles/repeats (Bramble "bits of fabric and bits of fabric"). Cosmetic; cap tokens.
|
| 141 |
+
- _extract_json brace scanner doesn't skip braces inside JSON string literals (e.g. gossip with "}").
|
| 142 |
+
Future hardening; harmless today.
|
| 143 |
+
|
| 144 |
+
### Weekend 2 (June 13-15) — make it sing
|
| 145 |
+
- [ ] Gradio UI: town square, event ticker, price/Gini charts, per-agent reasoning expander.
|
| 146 |
+
- [ ] Player controls: shock buttons (drought, frost, gold rush), rumor text input, step/auto-run.
|
| 147 |
+
- [ ] Personas + per-agent memory (short-term trades + long-term grudges/relationships).
|
| 148 |
+
- [ ] Narrator layer: structured events -> charming prose.
|
| 149 |
+
- [ ] Seed + tune a reproducible demo run with a clean dramatic arc.
|
| 150 |
+
- [ ] Deploy Space, confirm public URL works.
|
| 151 |
+
- [ ] Record demo video, write Field Notes post, publish traces, write social post, SUBMIT.
|
| 152 |
+
|
| 153 |
+
## Stretch (only if ahead)
|
| 154 |
+
- [ ] Off the Grid badge (fully local llama.cpp fallback path).
|
| 155 |
+
- [ ] Small-vs-bigger-model behavior comparison for the blog post.
|
| 156 |
+
- [ ] More creatures / more goods / seasons.
|
| 157 |
+
|
| 158 |
+
## Risks
|
| 159 |
+
- Modal cold-start latency on the demo — mitigate with keep-warm container during recording.
|
| 160 |
+
- Small-model invalid actions — the parse-and-repair loop is the critical reliability primitive.
|
| 161 |
+
- Scope creep into "real economics" — keep it a toy; drama > accuracy.
|
| 162 |
+
- ZeroGPU/queue not used; serving is on Modal credits ($250 budget, watch burn).
|
| 163 |
+
|
| 164 |
+
## Review
|
| 165 |
+
(to be filled at the end)
|
tests/test_game.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Headless tests for the Game wrapper, using the dummy policy (no GPU/Gradio).
|
| 2 |
+
|
| 3 |
+
Run: python -m pytest tests/ -q
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 10 |
+
|
| 11 |
+
from ttw.dummy import make_random_policy
|
| 12 |
+
from ttw.game import Game
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _game():
|
| 16 |
+
return Game(make_random_policy(seed=3), deck_seed=1)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_baseline_recorded_before_any_step():
|
| 20 |
+
g = _game()
|
| 21 |
+
assert not g.price_frame().empty # turn 0 prices recorded
|
| 22 |
+
assert g.gini_frame().iloc[0]["turn"] == 0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_step_advances_and_grows_history():
|
| 26 |
+
g = _game()
|
| 27 |
+
rows_before = len(g.price_frame())
|
| 28 |
+
g.step()
|
| 29 |
+
assert g.world.turn == 1
|
| 30 |
+
assert len(g.price_frame()) > rows_before
|
| 31 |
+
assert g.gini_frame().iloc[-1]["turn"] == 1
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def test_price_frame_shape_is_long_per_good():
|
| 35 |
+
g = _game()
|
| 36 |
+
g.step()
|
| 37 |
+
pf = g.price_frame()
|
| 38 |
+
assert set(pf.columns) == {"turn", "good", "price"}
|
| 39 |
+
# one row per good per recorded turn (turn 0 baseline + turn 1)
|
| 40 |
+
assert (pf["turn"] == 1).sum() == 5
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_town_frame_lists_all_creatures_sorted_by_wealth():
|
| 44 |
+
g = _game()
|
| 45 |
+
g.step()
|
| 46 |
+
tf = g.town_frame()
|
| 47 |
+
assert len(tf) == 5
|
| 48 |
+
nets = list(tf["net worth"])
|
| 49 |
+
assert nets == sorted(nets, reverse=True) # richest first
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_tempt_fate_draws_legend_and_tickers():
|
| 53 |
+
g = _game()
|
| 54 |
+
title = g.tempt_fate()
|
| 55 |
+
assert isinstance(title, str) and title
|
| 56 |
+
assert any("inspired by" in line for line in g.ticker)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def test_shock_gold_rush_echoes_to_ticker():
|
| 60 |
+
g = _game()
|
| 61 |
+
before = sum(c.pebbles for c in g.world.alive())
|
| 62 |
+
g.shock("gold_rush", amount=10)
|
| 63 |
+
assert sum(c.pebbles for c in g.world.alive()) == before + 10 * 5
|
| 64 |
+
assert any("windfall" in line.lower() for line in g.ticker)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_traces_markdown_handles_dummy_policy_gracefully():
|
| 68 |
+
"""Dummy policy has no raw thoughts; traces view must not crash."""
|
| 69 |
+
g = _game()
|
| 70 |
+
g.step()
|
| 71 |
+
assert isinstance(g.traces_markdown(), str) # no exception, returns a string
|
tests/test_market.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Invariant tests for the deterministic engine. No GPU, no network.
|
| 2 |
+
|
| 3 |
+
Run: python -m pytest tests/ -q (from the project root)
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import sys
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 10 |
+
|
| 11 |
+
from ttw import shocks
|
| 12 |
+
from ttw.actions import Offer, parse_actions
|
| 13 |
+
from ttw.dummy import make_random_policy
|
| 14 |
+
from ttw.market import clear_market, drift_prices, gini
|
| 15 |
+
from ttw.sim import _burn_fuel, _eat, _spoil, _update_mood, step
|
| 16 |
+
from ttw.world import SPOIL_RATE, fuel_need, seed_world
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _total_pebbles(world):
|
| 20 |
+
return sum(c.pebbles for c in world.alive())
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_trade_conserves_pebbles_and_is_nonnegative():
|
| 24 |
+
"""A round of trading moves pebbles around but never creates/destroys them,
|
| 25 |
+
and no creature ends with negative pebbles or inventory."""
|
| 26 |
+
world = seed_world()
|
| 27 |
+
before = _total_pebbles(world)
|
| 28 |
+
# Force a crossing pair: Fenn buys acorns high, Mossback sells low.
|
| 29 |
+
offers = [
|
| 30 |
+
Offer("Fenn", "buy", "acorns", 8, 5),
|
| 31 |
+
Offer("Mossback", "sell", "acorns", 4, 5),
|
| 32 |
+
]
|
| 33 |
+
events = clear_market(world, offers)
|
| 34 |
+
assert events, "a crossing buy/sell should produce at least one trade"
|
| 35 |
+
assert _total_pebbles(world) == before
|
| 36 |
+
for c in world.alive():
|
| 37 |
+
assert c.pebbles >= 0
|
| 38 |
+
assert all(q >= 0 for q in c.inventory.values())
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def test_no_trade_when_not_crossing():
|
| 42 |
+
world = seed_world()
|
| 43 |
+
offers = [
|
| 44 |
+
Offer("Fenn", "buy", "honey", 3, 2),
|
| 45 |
+
Offer("Oona", "sell", "honey", 10, 2),
|
| 46 |
+
]
|
| 47 |
+
assert clear_market(world, offers) == []
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def test_cannot_oversell_inventory():
|
| 51 |
+
"""Seller offering more than it holds only trades what it actually has."""
|
| 52 |
+
world = seed_world()
|
| 53 |
+
world.creatures["Oona"].inventory["honey"] = 2
|
| 54 |
+
offers = [
|
| 55 |
+
Offer("Fenn", "buy", "honey", 12, 10),
|
| 56 |
+
Offer("Oona", "sell", "honey", 6, 10),
|
| 57 |
+
]
|
| 58 |
+
clear_market(world, offers)
|
| 59 |
+
assert world.creatures["Oona"].inventory["honey"] == 0
|
| 60 |
+
assert world.creatures["Fenn"].inventory["honey"] == 3 # started with 1, bought 2
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_full_sim_runs_and_stays_valid():
|
| 64 |
+
"""20 turns of the random policy: economy runs, invariants hold throughout."""
|
| 65 |
+
world = seed_world()
|
| 66 |
+
policy = make_random_policy(seed=42)
|
| 67 |
+
pebbles_start = _total_pebbles(world)
|
| 68 |
+
for _ in range(20):
|
| 69 |
+
step(world, policy)
|
| 70 |
+
for c in world.alive():
|
| 71 |
+
assert c.pebbles >= 0, f"{c.name} went bankrupt below zero"
|
| 72 |
+
assert all(q >= 0 for q in c.inventory.values())
|
| 73 |
+
# Trading is pebble-conserving; production/consumption don't touch pebbles,
|
| 74 |
+
# so the total purse of the wood is invariant across the whole run.
|
| 75 |
+
assert _total_pebbles(world) == pebbles_start
|
| 76 |
+
assert world.turn == 20
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def test_drift_prices_moves_with_pressure_and_spares_untouched_goods():
|
| 80 |
+
world = seed_world()
|
| 81 |
+
honey0 = world.last_price["honey"]
|
| 82 |
+
drift_prices(world, [Offer("Fenn", "buy", "honey", 9, 10)]) # all demand
|
| 83 |
+
assert world.last_price["honey"] > honey0 # price rises
|
| 84 |
+
|
| 85 |
+
acorns0 = world.last_price["acorns"]
|
| 86 |
+
drift_prices(world, [Offer("Mossback", "sell", "acorns", 4, 10)]) # all supply
|
| 87 |
+
assert world.last_price["acorns"] < acorns0 # price falls
|
| 88 |
+
|
| 89 |
+
mush0 = world.last_price["mushrooms"]
|
| 90 |
+
drift_prices(world, [Offer("Fenn", "buy", "honey", 9, 1)]) # no mushroom offers
|
| 91 |
+
assert world.last_price["mushrooms"] == mush0 # untouched good unchanged
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_gini_bounds():
|
| 95 |
+
assert gini([10, 10, 10, 10]) == 0.0
|
| 96 |
+
assert gini([]) == 0.0
|
| 97 |
+
assert gini([0, 0, 100]) > 0.5 # very unequal
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def test_parse_actions_tolerates_junk():
|
| 101 |
+
"""The parse-and-repair layer salvages valid offers and drops the rest."""
|
| 102 |
+
text = (
|
| 103 |
+
"Sure! Here's my move:\n```json\n"
|
| 104 |
+
'{"thought":"buy the dip","offers":['
|
| 105 |
+
'{"side":"buy","good":"acorns","price":7,"qty":3},'
|
| 106 |
+
'{"side":"sell","good":"glass","price":5,"qty":1},' # invalid good -> dropped
|
| 107 |
+
'{"side":"buy","good":"honey","price":-2,"qty":4}],' # bad price -> dropped
|
| 108 |
+
'"gossip":"the mushrooms are cursed"}\n```'
|
| 109 |
+
)
|
| 110 |
+
offers, gossip = parse_actions("Fenn", text)
|
| 111 |
+
assert len(offers) == 1
|
| 112 |
+
assert offers[0].good == "acorns" and offers[0].price == 7 and offers[0].qty == 3
|
| 113 |
+
assert gossip and "mushrooms" in gossip[0].message
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_parse_actions_handles_garbage():
|
| 117 |
+
offers, gossip = parse_actions("Pip", "I refuse to answer in JSON, sorry.")
|
| 118 |
+
assert offers == [] and gossip == []
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def test_parse_actions_rejects_non_finite_without_crashing():
|
| 122 |
+
"""json.loads accepts Infinity/NaN; the parser must drop them, not raise."""
|
| 123 |
+
text = '{"offers":[{"side":"buy","good":"acorns","price":Infinity,"qty":1},'
|
| 124 |
+
text += '{"side":"sell","good":"berries","price":5,"qty":NaN}]}'
|
| 125 |
+
offers, gossip = parse_actions("Fenn", text) # must not raise
|
| 126 |
+
assert offers == []
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_affordability_blocked_buyer_does_not_hang_or_misfill():
|
| 130 |
+
"""A broke buyer crossing a seller it can't pay yields no trade and no hang."""
|
| 131 |
+
world = seed_world()
|
| 132 |
+
world.creatures["Pip"].pebbles = 3 # too poor to pay the clearing price
|
| 133 |
+
honey_before = world.creatures["Oona"].inventory["honey"]
|
| 134 |
+
offers = [
|
| 135 |
+
Offer("Pip", "buy", "honey", 10, 5),
|
| 136 |
+
Offer("Oona", "sell", "honey", 8, 5),
|
| 137 |
+
]
|
| 138 |
+
events = clear_market(world, offers) # must terminate
|
| 139 |
+
assert events == []
|
| 140 |
+
assert world.creatures["Pip"].pebbles == 3 # untouched
|
| 141 |
+
assert world.creatures["Oona"].inventory["honey"] == honey_before # unsold
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_diet_variety_punishes_monoculture():
|
| 145 |
+
"""Holding only one food cannot satisfy a multi-food meal -> hunger shortfall."""
|
| 146 |
+
world = seed_world()
|
| 147 |
+
c = world.creatures["Mossback"]
|
| 148 |
+
c.inventory = {"acorns": 99, "berries": 0, "mushrooms": 0, "honey": 0, "firewood": 9}
|
| 149 |
+
c.food_need = 3
|
| 150 |
+
c.wellbeing = 10
|
| 151 |
+
ev = _eat(c, turn=1)
|
| 152 |
+
assert ev["type"] == "hunger" and ev["shortfall"] == 2 # ate 1 acorn, missed 2
|
| 153 |
+
assert c.inventory["acorns"] == 98 # only one unit counted toward the meal
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def test_fuel_burn_consumes_firewood_and_reports_cold_when_short():
|
| 157 |
+
world = seed_world()
|
| 158 |
+
c = world.creatures["Oona"]
|
| 159 |
+
c.inventory["firewood"] = 0
|
| 160 |
+
ev = _burn_fuel(c, turn=1)
|
| 161 |
+
assert ev["type"] == "cold" and ev["shortfall"] == fuel_need(1)
|
| 162 |
+
# _burn_fuel no longer touches wellbeing; mood is updated separately.
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def test_mood_mean_reverts_and_never_dies():
|
| 166 |
+
"""Wellbeing drifts toward a comfort target, recovers, and floors above zero."""
|
| 167 |
+
world = seed_world()
|
| 168 |
+
c = world.creatures["Pip"]
|
| 169 |
+
# Sustained double shortfall pulls mood DOWN toward target 4, never to 0.
|
| 170 |
+
c.wellbeing = 10
|
| 171 |
+
for _ in range(20):
|
| 172 |
+
_update_mood(c, food_short=1, fuel_short=1)
|
| 173 |
+
assert c.wellbeing == 4 # floor for a fully-short creature, not death
|
| 174 |
+
# Once provisioned, it recovers back to 10.
|
| 175 |
+
for _ in range(20):
|
| 176 |
+
_update_mood(c, food_short=0, fuel_short=0)
|
| 177 |
+
assert c.wellbeing == 10
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def test_spoilage_floors_and_spares_firewood():
|
| 181 |
+
world = seed_world()
|
| 182 |
+
c = world.creatures["Bramble"]
|
| 183 |
+
c.inventory = {"acorns": 10, "berries": 2, "mushrooms": 0, "honey": 0, "firewood": 10}
|
| 184 |
+
lost = _spoil(c)
|
| 185 |
+
assert lost["acorns"] == int(10 * SPOIL_RATE) # spoils at any rate >=0.1
|
| 186 |
+
assert "berries" not in lost # floor(2*rate)=0 for rate<=0.49, no loss
|
| 187 |
+
assert c.inventory["firewood"] == 10 # fuel never rots
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def test_drought_cuts_production():
|
| 191 |
+
"""A drought shock reduces a good's production on subsequent turns."""
|
| 192 |
+
world = seed_world()
|
| 193 |
+
shocks.drought(world, "acorns", severity=0.0) # total crop failure
|
| 194 |
+
before = world.creatures["Mossback"].inventory["acorns"]
|
| 195 |
+
# Freeze decisions to no-ops so we isolate production.
|
| 196 |
+
step(world, lambda w, n: ([], []))
|
| 197 |
+
after = world.creatures["Mossback"].inventory["acorns"]
|
| 198 |
+
# Mossback produced 0 acorns this turn; only spoilage/eating reduced stock.
|
| 199 |
+
assert after <= before
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def test_gold_rush_conserves_nothing_but_is_bounded():
|
| 203 |
+
world = seed_world()
|
| 204 |
+
total_before = sum(c.pebbles for c in world.alive())
|
| 205 |
+
shocks.gold_rush(world, amount=40)
|
| 206 |
+
total_after = sum(c.pebbles for c in world.alive())
|
| 207 |
+
assert total_after == total_before + 40 * len(world.alive())
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def test_event_deck_applies_effects_and_no_repeats_until_exhausted():
|
| 211 |
+
"""Drawing a Wood Legend perturbs the world, logs it, and cycles all events
|
| 212 |
+
before any repeats."""
|
| 213 |
+
from ttw.events import EVENTS, EventDeck
|
| 214 |
+
|
| 215 |
+
world = seed_world()
|
| 216 |
+
deck = EventDeck(seed=1)
|
| 217 |
+
drawn = [deck.draw(world).key for _ in range(len(EVENTS))]
|
| 218 |
+
assert set(drawn) == {e.key for e in EVENTS} # every legend told exactly once
|
| 219 |
+
assert len(set(drawn)) == len(EVENTS) # no repeats within one cycle
|
| 220 |
+
# Each draw left an event record with a real-world attribution for the reveal.
|
| 221 |
+
event_logs = [e for e in world.log if e["type"] == "event"]
|
| 222 |
+
assert len(event_logs) == len(EVENTS)
|
| 223 |
+
assert all(e["inspired_by"] for e in event_logs)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def test_acorn_mania_injects_pebbles_and_a_rumor():
|
| 227 |
+
from ttw.events import EventDeck
|
| 228 |
+
from ttw.events import EVENTS
|
| 229 |
+
|
| 230 |
+
world = seed_world()
|
| 231 |
+
mania = next(e for e in EVENTS if e.key == "acorn_mania")
|
| 232 |
+
deck = EventDeck(seed=0, events=[mania])
|
| 233 |
+
before = sum(c.pebbles for c in world.alive())
|
| 234 |
+
deck.draw(world)
|
| 235 |
+
assert sum(c.pebbles for c in world.alive()) > before # gold rush fired
|
| 236 |
+
assert any("acorn" in r.lower() for r in world.rumors) # rumor planted
|
ttw/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Thousand Token Wood — a tiny emergent economy of small-model agents."""
|
ttw/actions.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The action schema every agent (dummy or small-model) speaks.
|
| 2 |
+
|
| 3 |
+
This is the contract between the agents and the world. The small model will
|
| 4 |
+
emit JSON matching these shapes; `parse_actions` is the parse-and-repair layer
|
| 5 |
+
that keeps a 7B (or 3B) model from ever breaking the simulation. Invalid or
|
| 6 |
+
malformed actions are dropped, never crash.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import math
|
| 13 |
+
import re
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
|
| 16 |
+
from .world import GOODS
|
| 17 |
+
|
| 18 |
+
VALID_SIDES = {"buy", "sell"}
|
| 19 |
+
|
| 20 |
+
# Sanity caps so a single hallucinated number can't distort the whole economy.
|
| 21 |
+
MAX_OFFER_QTY = 50
|
| 22 |
+
MAX_OFFER_PRICE = 999
|
| 23 |
+
MAX_GOSSIP_CHARS = 200
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass
|
| 27 |
+
class Offer:
|
| 28 |
+
"""A single market intent: I will buy/sell `qty` of `good` at `price` each."""
|
| 29 |
+
|
| 30 |
+
creature: str
|
| 31 |
+
side: str # "buy" | "sell"
|
| 32 |
+
good: str
|
| 33 |
+
price: int # pebbles per unit (integer)
|
| 34 |
+
qty: int
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass
|
| 38 |
+
class Gossip:
|
| 39 |
+
"""A creature broadcasts a rumor into the wood."""
|
| 40 |
+
|
| 41 |
+
creature: str
|
| 42 |
+
message: str
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _coerce_offer(creature: str, raw: dict) -> Offer | None:
|
| 46 |
+
"""Validate one offer dict, returning None if it cannot be salvaged."""
|
| 47 |
+
try:
|
| 48 |
+
side = str(raw.get("side", "")).lower().strip()
|
| 49 |
+
good = str(raw.get("good", "")).lower().strip()
|
| 50 |
+
price_f = float(raw.get("price"))
|
| 51 |
+
qty_f = float(raw.get("qty"))
|
| 52 |
+
except (TypeError, ValueError):
|
| 53 |
+
return None
|
| 54 |
+
# json.loads accepts Infinity/NaN; reject non-finite before int() can raise
|
| 55 |
+
# OverflowError. The whole point of this layer is to never crash.
|
| 56 |
+
if not (math.isfinite(price_f) and math.isfinite(qty_f)):
|
| 57 |
+
return None
|
| 58 |
+
price = int(round(price_f))
|
| 59 |
+
qty = int(round(qty_f))
|
| 60 |
+
if side not in VALID_SIDES or good not in GOODS:
|
| 61 |
+
return None
|
| 62 |
+
if price <= 0 or qty <= 0:
|
| 63 |
+
return None
|
| 64 |
+
# Cap absurd quantities/prices so one hallucination can't nuke the economy.
|
| 65 |
+
qty = min(qty, MAX_OFFER_QTY)
|
| 66 |
+
price = min(price, MAX_OFFER_PRICE)
|
| 67 |
+
return Offer(creature=creature, side=side, good=good, price=price, qty=qty)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def parse_actions(creature: str, text: str) -> tuple[list[Offer], list[Gossip]]:
|
| 71 |
+
"""Extract offers and gossip from a model's raw text output.
|
| 72 |
+
|
| 73 |
+
Tolerant by design: finds the first JSON object/array in the text, ignores
|
| 74 |
+
junk, and silently drops anything malformed. Returns (offers, gossip).
|
| 75 |
+
"""
|
| 76 |
+
offers: list[Offer] = []
|
| 77 |
+
gossip: list[Gossip] = []
|
| 78 |
+
|
| 79 |
+
payload = _extract_json(text)
|
| 80 |
+
if payload is None:
|
| 81 |
+
return offers, gossip
|
| 82 |
+
|
| 83 |
+
raw_offers = payload.get("offers", []) if isinstance(payload, dict) else []
|
| 84 |
+
if isinstance(raw_offers, dict): # model emitted a single offer, not a list
|
| 85 |
+
raw_offers = [raw_offers]
|
| 86 |
+
for raw in raw_offers if isinstance(raw_offers, list) else []:
|
| 87 |
+
if isinstance(raw, dict):
|
| 88 |
+
offer = _coerce_offer(creature, raw)
|
| 89 |
+
if offer is not None:
|
| 90 |
+
offers.append(offer)
|
| 91 |
+
|
| 92 |
+
if isinstance(payload, dict):
|
| 93 |
+
msg = payload.get("gossip")
|
| 94 |
+
if isinstance(msg, str) and msg.strip():
|
| 95 |
+
gossip.append(Gossip(creature=creature, message=msg.strip()[:MAX_GOSSIP_CHARS]))
|
| 96 |
+
|
| 97 |
+
return offers, gossip
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _extract_json(text: str) -> dict | None:
|
| 101 |
+
"""Find and parse the first balanced JSON object in `text`. None on failure.
|
| 102 |
+
|
| 103 |
+
Code fences are stripped first, then a single brace-depth scan finds the
|
| 104 |
+
first balanced object. One scanner handles both fenced and bare output, so
|
| 105 |
+
nested objects (multi-offer responses) parse correctly in either case.
|
| 106 |
+
"""
|
| 107 |
+
# Drop ```json ... ``` fences without trying to capture the body via regex
|
| 108 |
+
# (a non-greedy capture mis-handles nested braces); let the depth scan do it.
|
| 109 |
+
text = re.sub(r"```(?:json)?", "", text)
|
| 110 |
+
|
| 111 |
+
start = text.find("{")
|
| 112 |
+
if start == -1:
|
| 113 |
+
return None
|
| 114 |
+
depth = 0
|
| 115 |
+
candidate: str | None = None
|
| 116 |
+
for i in range(start, len(text)):
|
| 117 |
+
if text[i] == "{":
|
| 118 |
+
depth += 1
|
| 119 |
+
elif text[i] == "}":
|
| 120 |
+
depth -= 1
|
| 121 |
+
if depth == 0:
|
| 122 |
+
candidate = text[start : i + 1]
|
| 123 |
+
break
|
| 124 |
+
if candidate is None:
|
| 125 |
+
return None
|
| 126 |
+
try:
|
| 127 |
+
parsed = json.loads(candidate)
|
| 128 |
+
return parsed if isinstance(parsed, dict) else None
|
| 129 |
+
except json.JSONDecodeError:
|
| 130 |
+
return None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# The instruction block we will hand the small model, kept next to the schema
|
| 134 |
+
# it describes so they never drift apart.
|
| 135 |
+
ACTION_SCHEMA_PROMPT = """\
|
| 136 |
+
Respond with ONLY a JSON object, no prose, in this exact shape:
|
| 137 |
+
{
|
| 138 |
+
"thought": "one short sentence of your private reasoning",
|
| 139 |
+
"offers": [
|
| 140 |
+
{"side": "buy" or "sell", "good": "<one of: acorns, berries, mushrooms, honey, firewood>",
|
| 141 |
+
"price": <whole number of pebbles per unit>, "qty": <whole number of units>}
|
| 142 |
+
],
|
| 143 |
+
"gossip": "<optional: a short rumor to spread, or empty string>"
|
| 144 |
+
}
|
| 145 |
+
You may post zero, one, or several offers. Only sell what you own; only buy what you can afford."""
|
ttw/agents.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The small-model agent policy.
|
| 2 |
+
|
| 3 |
+
`make_llm_policy` returns a per-creature Policy (the same signature the engine's
|
| 4 |
+
`step` already expects), so it drops straight into the existing tick loop. But
|
| 5 |
+
it decides the whole cast in ONE batched GPU call per turn, memoized by turn
|
| 6 |
+
number, and keeps each creature's raw reasoning text for trace inspection
|
| 7 |
+
(the Sharing-is-Caring badge and the UI's clickable monologue).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from .actions import ACTION_SCHEMA_PROMPT, Gossip, Offer, parse_actions
|
| 13 |
+
from .world import FOOD_GOODS, GOODS, Creature, WorldState, fuel_need
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def build_messages(world: WorldState, creature: Creature) -> list[dict]:
|
| 17 |
+
"""Construct the chat prompt that lets one creature decide this turn."""
|
| 18 |
+
prices = ", ".join(f"{g} {world.last_price[g]:.0f}p" for g in GOODS)
|
| 19 |
+
inv = ", ".join(f"{g}:{creature.inventory.get(g, 0)}" for g in GOODS)
|
| 20 |
+
rumors = "; ".join(world.rumors[-3:]) or "none"
|
| 21 |
+
foods = ", ".join(FOOD_GOODS)
|
| 22 |
+
fuel = fuel_need(world.turn + 1) # what they'll have to burn after this turn
|
| 23 |
+
|
| 24 |
+
system = (
|
| 25 |
+
f"You are {creature.name}, {creature.persona}. You live in Thousand Token "
|
| 26 |
+
f"Wood, where creatures trade goods for pebbles. You produce {creature.produces}. "
|
| 27 |
+
f"Stay in character and trade to serve your own interests."
|
| 28 |
+
)
|
| 29 |
+
lacking = [
|
| 30 |
+
g for g in FOOD_GOODS if g != creature.produces and creature.inventory.get(g, 0) < 2
|
| 31 |
+
]
|
| 32 |
+
lacking_str = ", ".join(lacking) if lacking else "none"
|
| 33 |
+
user = (
|
| 34 |
+
f"Turn {world.turn}.\n"
|
| 35 |
+
f"Market prices (pebbles per unit): {prices}.\n"
|
| 36 |
+
f"Your purse: {creature.pebbles} pebbles. Your stores: {inv}.\n"
|
| 37 |
+
f"You PRODUCE {creature.produces}, so you have plenty of it: SELL your surplus "
|
| 38 |
+
f"{creature.produces}, never buy it.\n"
|
| 39 |
+
f"To eat well you need {creature.food_need} DIFFERENT foods each turn ({foods}). "
|
| 40 |
+
f"You are currently low on: {lacking_str}. BUY the foods you lack.\n"
|
| 41 |
+
f"You must also burn {fuel} firewood each turn to stay warm; firewood grows "
|
| 42 |
+
f"scarce as winter deepens.\n"
|
| 43 |
+
f"Perishable food rots if hoarded, so sell surplus before it spoils.\n"
|
| 44 |
+
f"Recent rumors in the wood: {rumors}.\n\n"
|
| 45 |
+
+ ACTION_SCHEMA_PROMPT
|
| 46 |
+
+ '\n\nExample (a honey-maker low on acorns and firewood): '
|
| 47 |
+
'{"thought":"sell my honey, buy what I lack","offers":['
|
| 48 |
+
'{"side":"sell","good":"honey","price":9,"qty":3},'
|
| 49 |
+
'{"side":"buy","good":"acorns","price":4,"qty":2},'
|
| 50 |
+
'{"side":"buy","good":"firewood","price":6,"qty":2}],"gossip":""}'
|
| 51 |
+
)
|
| 52 |
+
return [
|
| 53 |
+
{"role": "system", "content": system},
|
| 54 |
+
{"role": "user", "content": user},
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def make_llm_policy(client, temperature: float = 0.7, max_tokens: int = 256):
|
| 59 |
+
"""Build a Policy backed by `client.chat_batch`.
|
| 60 |
+
|
| 61 |
+
The cache holds one turn's batched decisions. The first creature queried in a
|
| 62 |
+
turn triggers the batch for everyone; the rest read from cache. `last_raw`
|
| 63 |
+
and `last_messages` are exposed for trace export and debugging.
|
| 64 |
+
"""
|
| 65 |
+
state = {"turn": -1, "decisions": {}, "raw": {}, "messages": {}}
|
| 66 |
+
|
| 67 |
+
def policy(world: WorldState, name: str) -> tuple[list[Offer], list[Gossip]]:
|
| 68 |
+
if state["turn"] != world.turn:
|
| 69 |
+
names = [c.name for c in world.alive()]
|
| 70 |
+
convos = [build_messages(world, world.creatures[n]) for n in names]
|
| 71 |
+
raw = client.chat_batch(convos, max_tokens=max_tokens, temperature=temperature)
|
| 72 |
+
state["turn"] = world.turn
|
| 73 |
+
state["raw"] = dict(zip(names, raw))
|
| 74 |
+
state["messages"] = dict(zip(names, convos))
|
| 75 |
+
state["decisions"] = {n: parse_actions(n, r) for n, r in zip(names, raw)}
|
| 76 |
+
return state["decisions"].get(name, ([], []))
|
| 77 |
+
|
| 78 |
+
policy.state = state # type: ignore[attr-defined] # for trace inspection
|
| 79 |
+
return policy
|
ttw/dummy.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""A no-LLM policy for testing the engine.
|
| 2 |
+
|
| 3 |
+
Each creature sells some surplus of the good it produces (near the reference
|
| 4 |
+
price) and bids for a bit of food it is short on. Deterministic given a seeded
|
| 5 |
+
RNG, so tests are reproducible. This stands in for the small-model policy and
|
| 6 |
+
lets us validate the market in isolation.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import random
|
| 12 |
+
|
| 13 |
+
from .actions import Gossip, Offer
|
| 14 |
+
from .world import FOOD_GOODS, WorldState
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def make_random_policy(seed: int = 0):
|
| 18 |
+
rng = random.Random(seed)
|
| 19 |
+
|
| 20 |
+
def policy(world: WorldState, name: str) -> tuple[list[Offer], list[Gossip]]:
|
| 21 |
+
c = world.creatures[name]
|
| 22 |
+
offers: list[Offer] = []
|
| 23 |
+
ref = world.last_price
|
| 24 |
+
|
| 25 |
+
# Sell surplus of own product, slightly under/over the reference price.
|
| 26 |
+
own = c.produces
|
| 27 |
+
surplus = max(0, c.inventory.get(own, 0) - 2)
|
| 28 |
+
if surplus > 0:
|
| 29 |
+
ask = max(1, int(round(ref[own] * rng.uniform(0.9, 1.15))))
|
| 30 |
+
offers.append(Offer(name, "sell", own, ask, rng.randint(1, surplus)))
|
| 31 |
+
|
| 32 |
+
# Buy a food it is low on (not its own product), if it can afford some.
|
| 33 |
+
wants = [g for g in FOOD_GOODS if g != own and c.inventory.get(g, 0) < 3]
|
| 34 |
+
if wants and c.pebbles > 0:
|
| 35 |
+
good = rng.choice(wants)
|
| 36 |
+
bid = max(1, int(round(ref[good] * rng.uniform(0.95, 1.2))))
|
| 37 |
+
max_qty = max(1, c.pebbles // max(bid, 1))
|
| 38 |
+
offers.append(Offer(name, "buy", good, bid, rng.randint(1, min(4, max_qty))))
|
| 39 |
+
|
| 40 |
+
return offers, []
|
| 41 |
+
|
| 42 |
+
return policy
|
ttw/events.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Wood Legends: famous market manias and panics, reskinned as woodland chapters.
|
| 2 |
+
|
| 3 |
+
Each event is a whimsical retelling of a real episode from economic history,
|
| 4 |
+
composed from the shock primitives in shocks.py. A randomized deck draws them as
|
| 5 |
+
"chapters" so every playthrough tells a different (but historically-rooted)
|
| 6 |
+
story. The `inspired_by` field is real and surfaced by the UI as a reveal -- the
|
| 7 |
+
delight comes from recognizing the history under the fur.
|
| 8 |
+
|
| 9 |
+
Tone guardrail: famous *economic* dramas only (bubbles, runs, corners, famines),
|
| 10 |
+
told gently. No real tragedies or living people.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import random
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from typing import Callable
|
| 18 |
+
|
| 19 |
+
from . import shocks
|
| 20 |
+
from .world import WorldState
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass
|
| 24 |
+
class WoodEvent:
|
| 25 |
+
key: str
|
| 26 |
+
title: str # the woodland legend's name
|
| 27 |
+
flavor: str # in-tone narration shown to the player
|
| 28 |
+
inspired_by: str # the real episode (revealed by the UI / Field Notes)
|
| 29 |
+
apply: Callable[[WorldState, random.Random], None]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _acorn_mania(world: WorldState, rng: random.Random) -> None:
|
| 33 |
+
shocks.gold_rush(world, amount=30) # easy pebbles light the fuse
|
| 34 |
+
shocks.plant_rumor(world, "a single acorn will soon buy a whole burrow -- buy now or miss out forever!")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _hollow_log_company(world: WorldState, rng: random.Random) -> None:
|
| 38 |
+
shocks.gold_rush(world, amount=25)
|
| 39 |
+
shocks.plant_rumor(world, "the Hollow Log Trading Company promises tenfold pebbles to every backer!")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _run_on_oonas_hoard(world: WorldState, rng: random.Random) -> None:
|
| 43 |
+
shocks.plant_rumor(world, "whispers say Oona's great pebble-hoard is empty; pull your pebbles while you can!")
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def _firewood_hoarding_panic(world: WorldState, rng: random.Random) -> None:
|
| 47 |
+
shocks.drought(world, "firewood", severity=0.3)
|
| 48 |
+
shocks.plant_rumor(world, "stock up on firewood NOW -- there will be none left by the frost!")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _honey_corner(world: WorldState, rng: random.Random) -> None:
|
| 52 |
+
shocks.bountiful_harvest(world, "honey", qty=0) # no windfall; just the tale
|
| 53 |
+
shocks.plant_rumor(world, "a sly buyer is quietly cornering every drop of honey in the wood.")
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _long_frost(world: WorldState, rng: random.Random) -> None:
|
| 57 |
+
crop = rng.choice(["acorns", "berries", "mushrooms"])
|
| 58 |
+
shocks.drought(world, crop, severity=0.2)
|
| 59 |
+
shocks.plant_rumor(world, f"a long frost has gripped the {crop} groves; the harvest will be meager.")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
EVENTS: list[WoodEvent] = [
|
| 63 |
+
WoodEvent("acorn_mania", "The Great Acorn Mania",
|
| 64 |
+
"A fever sweeps the wood: everyone must own acorns at any price.",
|
| 65 |
+
"Tulip Mania, Netherlands 1637", _acorn_mania),
|
| 66 |
+
WoodEvent("hollow_log_company", "The Hollow Log Trading Company",
|
| 67 |
+
"A dazzling venture promises riches to all who buy in.",
|
| 68 |
+
"The South Sea Bubble, England 1720", _hollow_log_company),
|
| 69 |
+
WoodEvent("run_on_hoard", "The Run on Oona's Hoard",
|
| 70 |
+
"Fear spreads that the old owl's vault has run dry, and the wood rushes to withdraw.",
|
| 71 |
+
"The bank runs of 1929-1933", _run_on_oonas_hoard),
|
| 72 |
+
WoodEvent("firewood_panic", "The Firewood Hoarding Panic",
|
| 73 |
+
"A scramble for firewood empties every woodpile overnight.",
|
| 74 |
+
"The 2020 toilet-paper shortage", _firewood_hoarding_panic),
|
| 75 |
+
WoodEvent("honey_corner", "The Honey Corner",
|
| 76 |
+
"One cunning creature schemes to buy up all the honey and name its price.",
|
| 77 |
+
"The Hunt brothers' silver corner, 1980", _honey_corner),
|
| 78 |
+
WoodEvent("long_frost", "The Long Frost",
|
| 79 |
+
"A bitter frost settles over the groves and the harvest withers.",
|
| 80 |
+
"The Dust Bowl famine, 1930s USA", _long_frost),
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class EventDeck:
|
| 85 |
+
"""Draws Wood Legends at random without repeating until the deck is exhausted."""
|
| 86 |
+
|
| 87 |
+
def __init__(self, seed: int = 0, events: list[WoodEvent] | None = None):
|
| 88 |
+
self.rng = random.Random(seed)
|
| 89 |
+
self._all = list(events if events is not None else EVENTS)
|
| 90 |
+
assert self._all, "EventDeck needs at least one event"
|
| 91 |
+
self._pool = list(self._all)
|
| 92 |
+
|
| 93 |
+
def draw(self, world: WorldState) -> WoodEvent:
|
| 94 |
+
"""Pick an unused event, apply its effects to the world, log it, return it."""
|
| 95 |
+
if not self._pool: # reshuffle once every legend has been told
|
| 96 |
+
self._pool = list(self._all)
|
| 97 |
+
event = self.rng.choice(self._pool)
|
| 98 |
+
self._pool.remove(event)
|
| 99 |
+
event.apply(world, self.rng)
|
| 100 |
+
world.log.append(
|
| 101 |
+
{
|
| 102 |
+
"type": "event",
|
| 103 |
+
"turn": world.turn,
|
| 104 |
+
"key": event.key,
|
| 105 |
+
"title": event.title,
|
| 106 |
+
"flavor": event.flavor,
|
| 107 |
+
"inspired_by": event.inspired_by,
|
| 108 |
+
}
|
| 109 |
+
)
|
| 110 |
+
return event
|
ttw/game.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Game: the stateful session the Gradio UI drives.
|
| 2 |
+
|
| 3 |
+
Wraps the world, the agent policy, and the Wood Legends deck, and records the
|
| 4 |
+
time-series the charts need. Pure Python with no Gradio import, so it can be
|
| 5 |
+
unit-tested headlessly (and run with the dummy policy, no GPU). The UI calls
|
| 6 |
+
step / shocks / tempt_fate and then reads the *_frame / *_markdown accessors.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import pandas as pd
|
| 12 |
+
|
| 13 |
+
from . import narrate, shocks
|
| 14 |
+
from .actions import _extract_json
|
| 15 |
+
from .events import EventDeck
|
| 16 |
+
from .market import gini
|
| 17 |
+
from .sim import step
|
| 18 |
+
from .world import GOODS, seed_world
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Game:
|
| 22 |
+
def __init__(self, policy, deck_seed: int = 0):
|
| 23 |
+
self.policy = policy
|
| 24 |
+
self.deck = EventDeck(seed=deck_seed)
|
| 25 |
+
self.world = seed_world()
|
| 26 |
+
self.price_hist: list[dict] = []
|
| 27 |
+
self.gini_hist: list[dict] = []
|
| 28 |
+
self.ticker: list[str] = []
|
| 29 |
+
self.thoughts: dict[str, str] = {}
|
| 30 |
+
self._record() # baseline at turn 0
|
| 31 |
+
|
| 32 |
+
# --- mutation -----------------------------------------------------------
|
| 33 |
+
def step(self) -> None:
|
| 34 |
+
events = step(self.world, self.policy)
|
| 35 |
+
self._extract_thoughts()
|
| 36 |
+
self.ticker.extend(narrate.ticker_lines(events, limit=50))
|
| 37 |
+
self.ticker = self.ticker[-200:]
|
| 38 |
+
self._record()
|
| 39 |
+
|
| 40 |
+
def tempt_fate(self) -> str:
|
| 41 |
+
"""Draw a Wood Legend; its effects land on the NEXT step. Returns its title."""
|
| 42 |
+
legend = self.deck.draw(self.world)
|
| 43 |
+
self.ticker.append(
|
| 44 |
+
f"✨ {legend.title}: {legend.flavor} (inspired by {legend.inspired_by})"
|
| 45 |
+
)
|
| 46 |
+
return legend.title
|
| 47 |
+
|
| 48 |
+
def shock(self, kind: str, **kw) -> None:
|
| 49 |
+
"""Fire a player shock and echo it to the ticker."""
|
| 50 |
+
fn = {
|
| 51 |
+
"drought": shocks.drought,
|
| 52 |
+
"gold_rush": shocks.gold_rush,
|
| 53 |
+
"rumor": shocks.plant_rumor,
|
| 54 |
+
"harvest": shocks.bountiful_harvest,
|
| 55 |
+
}.get(kind)
|
| 56 |
+
if fn is None:
|
| 57 |
+
raise ValueError(f"unknown shock kind: {kind!r}")
|
| 58 |
+
event = fn(self.world, **kw)
|
| 59 |
+
line = narrate.narrate(event)
|
| 60 |
+
if line:
|
| 61 |
+
self.ticker.append(line)
|
| 62 |
+
|
| 63 |
+
# --- recording ----------------------------------------------------------
|
| 64 |
+
def _record(self) -> None:
|
| 65 |
+
t = self.world.turn
|
| 66 |
+
for good in GOODS:
|
| 67 |
+
self.price_hist.append({"turn": t, "good": good, "price": self.world.last_price[good]})
|
| 68 |
+
nets = [c.net_worth(self.world.last_price) for c in self.world.alive()]
|
| 69 |
+
self.gini_hist.append({"turn": t, "gini": round(gini(nets), 3)})
|
| 70 |
+
|
| 71 |
+
def _extract_thoughts(self) -> None:
|
| 72 |
+
# Duck-typed contract: an LLM policy may expose `.state["raw"]` mapping
|
| 73 |
+
# creature name -> raw model text, from which we pull the "thought" field.
|
| 74 |
+
# The dummy policy has no such attribute, so traces stay empty (no crash).
|
| 75 |
+
state = getattr(self.policy, "state", None)
|
| 76 |
+
if not state or "raw" not in state:
|
| 77 |
+
return
|
| 78 |
+
for name, raw in state["raw"].items():
|
| 79 |
+
parsed = _extract_json(raw) or {}
|
| 80 |
+
thought = parsed.get("thought")
|
| 81 |
+
if isinstance(thought, str) and thought.strip():
|
| 82 |
+
self.thoughts[name] = thought.strip()
|
| 83 |
+
|
| 84 |
+
# --- views for the UI ---------------------------------------------------
|
| 85 |
+
def price_frame(self) -> pd.DataFrame:
|
| 86 |
+
return pd.DataFrame(self.price_hist, columns=["turn", "good", "price"])
|
| 87 |
+
|
| 88 |
+
def gini_frame(self) -> pd.DataFrame:
|
| 89 |
+
return pd.DataFrame(self.gini_hist, columns=["turn", "gini"])
|
| 90 |
+
|
| 91 |
+
def town_frame(self) -> pd.DataFrame:
|
| 92 |
+
rows = []
|
| 93 |
+
for c in sorted(self.world.alive(), key=lambda x: -x.net_worth(self.world.last_price)):
|
| 94 |
+
rows.append(
|
| 95 |
+
{
|
| 96 |
+
"creature": c.name,
|
| 97 |
+
"makes": c.produces,
|
| 98 |
+
"pebbles": c.pebbles,
|
| 99 |
+
"mood": narrate.mood_word(c.wellbeing),
|
| 100 |
+
"net worth": round(c.net_worth(self.world.last_price)),
|
| 101 |
+
}
|
| 102 |
+
)
|
| 103 |
+
return pd.DataFrame(rows, columns=["creature", "makes", "pebbles", "mood", "net worth"])
|
| 104 |
+
|
| 105 |
+
def traces_markdown(self) -> str:
|
| 106 |
+
if not self.thoughts:
|
| 107 |
+
return "_Run a turn to hear what the creatures are thinking._"
|
| 108 |
+
return "\n\n".join(f"**{name}:** {thought}" for name, thought in self.thoughts.items())
|
| 109 |
+
|
| 110 |
+
def ticker_markdown(self, limit: int = 14) -> str:
|
| 111 |
+
if not self.ticker:
|
| 112 |
+
return "_The wood is quiet. Press Step to begin._"
|
| 113 |
+
return "\n\n".join(f"- {line}" for line in self.ticker[-limit:][::-1])
|
ttw/llm.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Client handles for the small model.
|
| 2 |
+
|
| 3 |
+
ModalLLM calls the deployed `ttw-serve` vLLM endpoint (used by both the local
|
| 4 |
+
driver and the Gradio Space). The interface is a single `chat_batch` so callers
|
| 5 |
+
never care where inference runs.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class ModalLLM:
|
| 12 |
+
"""Thin handle to the deployed Modal Engine.chat_batch method."""
|
| 13 |
+
|
| 14 |
+
def __init__(self, app_name: str = "ttw-serve", cls_name: str = "Engine"):
|
| 15 |
+
import modal
|
| 16 |
+
|
| 17 |
+
engine_cls = modal.Cls.from_name(app_name, cls_name)
|
| 18 |
+
self._engine = engine_cls()
|
| 19 |
+
|
| 20 |
+
def chat_batch(
|
| 21 |
+
self,
|
| 22 |
+
conversations: list[list[dict]],
|
| 23 |
+
max_tokens: int = 256,
|
| 24 |
+
temperature: float = 0.7,
|
| 25 |
+
) -> list[str]:
|
| 26 |
+
return self._engine.chat_batch.remote(
|
| 27 |
+
conversations, max_tokens=max_tokens, temperature=temperature
|
| 28 |
+
)
|
ttw/market.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The market engine: a per-turn double auction. No LLM, fully deterministic.
|
| 2 |
+
|
| 3 |
+
Given a list of Offers, match crossing buy/sell intents good-by-good, execute
|
| 4 |
+
trades with integer pebble accounting, and update the reference price. Honors
|
| 5 |
+
the hard constraints (you cannot sell what you do not hold, cannot buy what you
|
| 6 |
+
cannot afford), so the simulation can never reach an invalid state regardless
|
| 7 |
+
of what the agents propose.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from collections import defaultdict
|
| 13 |
+
|
| 14 |
+
from .actions import Offer
|
| 15 |
+
from .world import WorldState
|
| 16 |
+
|
| 17 |
+
# Price dynamics: how hard supply/demand imbalance pushes the reference price,
|
| 18 |
+
# and the band it lives in. PRICE_DRIFT_K=0.12 means a fully one-sided book
|
| 19 |
+
# (all buys, no sells) moves a price ~12% that turn -> trends/bubbles compound.
|
| 20 |
+
PRICE_DRIFT_K = 0.12
|
| 21 |
+
MIN_PRICE = 1.0
|
| 22 |
+
MAX_PRICE = 99.0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def clear_market(world: WorldState, offers: list[Offer]) -> list[dict]:
|
| 26 |
+
"""Match offers and mutate `world` in place. Returns structured trade events.
|
| 27 |
+
|
| 28 |
+
Matching rule per good: highest buy meets lowest sell; they trade while the
|
| 29 |
+
buy price covers the sell price. Execution price is the midpoint (rounded),
|
| 30 |
+
a simple fair split of the spread. Partial fills are allowed.
|
| 31 |
+
"""
|
| 32 |
+
events: list[dict] = []
|
| 33 |
+
|
| 34 |
+
for good in _goods_in(offers):
|
| 35 |
+
buys = sorted(
|
| 36 |
+
(o for o in offers if o.good == good and o.side == "buy"),
|
| 37 |
+
key=lambda o: (-o.price, o.creature),
|
| 38 |
+
)
|
| 39 |
+
sells = sorted(
|
| 40 |
+
(o for o in offers if o.good == good and o.side == "sell"),
|
| 41 |
+
key=lambda o: (o.price, o.creature),
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
bi = si = 0
|
| 45 |
+
while bi < len(buys) and si < len(sells):
|
| 46 |
+
buy, sell = buys[bi], sells[si]
|
| 47 |
+
if buy.creature == sell.creature: # don't trade with yourself
|
| 48 |
+
si += 1
|
| 49 |
+
continue
|
| 50 |
+
if buy.price < sell.price: # no longer crossing; market clears
|
| 51 |
+
break
|
| 52 |
+
|
| 53 |
+
buyer = world.creatures[buy.creature]
|
| 54 |
+
seller = world.creatures[sell.creature]
|
| 55 |
+
price = (buy.price + sell.price) // 2
|
| 56 |
+
|
| 57 |
+
# Quantity is bounded by both sides' remaining intent, the seller's
|
| 58 |
+
# actual stock, and what the buyer can actually pay for.
|
| 59 |
+
affordable = buyer.pebbles // price if price > 0 else 0
|
| 60 |
+
qty = min(buy.qty, sell.qty, seller.inventory.get(good, 0), affordable)
|
| 61 |
+
|
| 62 |
+
if qty <= 0:
|
| 63 |
+
# Retire every side whose constraint is actually binding. A
|
| 64 |
+
# buyer who cannot afford one unit at this clearing price cannot
|
| 65 |
+
# afford a higher one (sells ascend), so retiring the buyer is
|
| 66 |
+
# safe. A seller out of stock or out of intent is retired as a
|
| 67 |
+
# seller. Both can bind at once; advancing both is still correct
|
| 68 |
+
# and guarantees forward progress without relying on sort luck.
|
| 69 |
+
advanced = False
|
| 70 |
+
if seller.inventory.get(good, 0) <= 0 or sell.qty <= 0:
|
| 71 |
+
si += 1
|
| 72 |
+
advanced = True
|
| 73 |
+
if buy.qty <= 0 or affordable <= 0:
|
| 74 |
+
bi += 1
|
| 75 |
+
advanced = True
|
| 76 |
+
if not advanced: # defensive: should be unreachable, never spin
|
| 77 |
+
bi += 1
|
| 78 |
+
continue
|
| 79 |
+
|
| 80 |
+
cost = price * qty
|
| 81 |
+
buyer.pebbles -= cost
|
| 82 |
+
seller.pebbles += cost
|
| 83 |
+
seller.inventory[good] = seller.inventory.get(good, 0) - qty
|
| 84 |
+
buyer.inventory[good] = buyer.inventory.get(good, 0) + qty
|
| 85 |
+
world.last_price[good] = float(price)
|
| 86 |
+
|
| 87 |
+
events.append(
|
| 88 |
+
{
|
| 89 |
+
"type": "trade",
|
| 90 |
+
"turn": world.turn,
|
| 91 |
+
"good": good,
|
| 92 |
+
"buyer": buyer.name,
|
| 93 |
+
"seller": seller.name,
|
| 94 |
+
"price": price,
|
| 95 |
+
"qty": qty,
|
| 96 |
+
}
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
buy.qty -= qty
|
| 100 |
+
sell.qty -= qty
|
| 101 |
+
if buy.qty == 0:
|
| 102 |
+
bi += 1
|
| 103 |
+
if sell.qty == 0:
|
| 104 |
+
si += 1
|
| 105 |
+
|
| 106 |
+
world.log.extend(events)
|
| 107 |
+
return events
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def _goods_in(offers: list[Offer]) -> list[str]:
|
| 111 |
+
"""Distinct goods present in the offer set, in stable order."""
|
| 112 |
+
seen: list[str] = []
|
| 113 |
+
for o in offers:
|
| 114 |
+
if o.good not in seen:
|
| 115 |
+
seen.append(o.good)
|
| 116 |
+
return seen
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def drift_prices(world: WorldState, offers: list[Offer]) -> None:
|
| 120 |
+
"""Nudge each good's reference price toward its RESIDUAL supply/demand pressure.
|
| 121 |
+
|
| 122 |
+
Call this AFTER clear_market, which decrements offer quantities in place as it
|
| 123 |
+
fills them. So the imbalance measured here is the *unfilled* intent left over:
|
| 124 |
+
a good whose book crossed evenly leaves ~0 residual and barely moves, while a
|
| 125 |
+
panic (demand far above available supply) leaves heavy unfilled buys and the
|
| 126 |
+
price climbs. Pressure is normalized to [-1, 1] (all-residual-buys -> up by
|
| 127 |
+
PRICE_DRIFT_K, all-residual-sells -> down). Goods with no leftover intent are
|
| 128 |
+
untouched. Clamped to [MIN_PRICE, MAX_PRICE]. This is exactly the dynamic we
|
| 129 |
+
want: prices trend during scarcity/gluts, stay calm in balanced trade.
|
| 130 |
+
"""
|
| 131 |
+
demand: dict[str, int] = defaultdict(int)
|
| 132 |
+
supply: dict[str, int] = defaultdict(int)
|
| 133 |
+
for o in offers:
|
| 134 |
+
(demand if o.side == "buy" else supply)[o.good] += o.qty
|
| 135 |
+
|
| 136 |
+
for good, price in world.last_price.items():
|
| 137 |
+
d, s = demand.get(good, 0), supply.get(good, 0)
|
| 138 |
+
if d + s == 0:
|
| 139 |
+
continue
|
| 140 |
+
pressure = (d - s) / (d + s)
|
| 141 |
+
new_price = price * (1 + PRICE_DRIFT_K * pressure)
|
| 142 |
+
world.last_price[good] = round(min(MAX_PRICE, max(MIN_PRICE, new_price)), 1)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def gini(values: list[float]) -> float:
|
| 146 |
+
"""Gini coefficient of a list of net worths. 0 = equal, ->1 = unequal.
|
| 147 |
+
|
| 148 |
+
Used for the wealth-inequality chart. Returns 0.0 for trivial inputs.
|
| 149 |
+
"""
|
| 150 |
+
xs = sorted(max(v, 0.0) for v in values)
|
| 151 |
+
n = len(xs)
|
| 152 |
+
total = sum(xs)
|
| 153 |
+
if n == 0 or total == 0:
|
| 154 |
+
return 0.0
|
| 155 |
+
cumulative = sum((i + 1) * x for i, x in enumerate(xs))
|
| 156 |
+
return (2 * cumulative) / (n * total) - (n + 1) / n
|
ttw/narrate.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Turn structured event dicts into charming, in-tone ticker lines.
|
| 2 |
+
|
| 3 |
+
Pure formatting: takes the event records produced by sim.step / shocks / the
|
| 4 |
+
event deck and returns short strings for the UI ticker. No state, no LLM.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
# wellbeing -> mood word. Ranges are inclusive-low, exclusive-high; together they
|
| 10 |
+
# cover the full reachable wellbeing domain [4, 10] with no gaps:
|
| 11 |
+
# 9-10 content, 7-8 alright, 5-6 uneasy, 0-4 miserable.
|
| 12 |
+
_MOODS = {
|
| 13 |
+
range(9, 11): "content",
|
| 14 |
+
range(7, 9): "alright",
|
| 15 |
+
range(5, 7): "uneasy",
|
| 16 |
+
range(0, 5): "miserable",
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def mood_word(wellbeing: int) -> str:
|
| 21 |
+
for span, word in _MOODS.items():
|
| 22 |
+
if wellbeing in span:
|
| 23 |
+
return word
|
| 24 |
+
return "uneasy"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def narrate(event: dict) -> str | None:
|
| 28 |
+
"""One ticker line for an event, or None for events we don't surface."""
|
| 29 |
+
kind = event["type"]
|
| 30 |
+
if kind == "trade":
|
| 31 |
+
return (
|
| 32 |
+
f"{event['buyer']} bought {event['qty']} {event['good']} "
|
| 33 |
+
f"from {event['seller']} at {event['price']}p."
|
| 34 |
+
)
|
| 35 |
+
if kind == "gossip":
|
| 36 |
+
return f"{event['creature']} whispers: “{event['message']}”"
|
| 37 |
+
if kind == "event": # a Wood Legend
|
| 38 |
+
return f"✨ {event['title']}: {event['flavor']} (inspired by {event['inspired_by']})"
|
| 39 |
+
if kind == "shock":
|
| 40 |
+
return _narrate_shock(event)
|
| 41 |
+
if kind == "hunger":
|
| 42 |
+
return f"{event['creature']} went hungry (short {event['shortfall']} food)."
|
| 43 |
+
if kind == "cold":
|
| 44 |
+
return f"{event['creature']} shivered (short {event['shortfall']} firewood)."
|
| 45 |
+
return None # ate / warm / spoil / etc. are too noisy for the ticker
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _narrate_shock(event: dict) -> str | None:
|
| 49 |
+
kind = event.get("kind")
|
| 50 |
+
if kind == "drought":
|
| 51 |
+
return f"A drought grips the {event['good']} groves."
|
| 52 |
+
if kind == "lift_drought":
|
| 53 |
+
return f"The {event['good']} groves recover."
|
| 54 |
+
if kind == "rumor":
|
| 55 |
+
return f"A rumor spreads: “{event['message']}”"
|
| 56 |
+
if kind == "gold_rush":
|
| 57 |
+
return f"A pebble windfall! Every creature finds {event['amount']} pebbles."
|
| 58 |
+
if kind == "harvest":
|
| 59 |
+
return f"A bountiful harvest of {event['good']}!"
|
| 60 |
+
return None
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def ticker_lines(events: list[dict], limit: int = 12) -> list[str]:
|
| 64 |
+
"""Most-recent-first narrated lines for a batch of events."""
|
| 65 |
+
lines = [s for e in events if (s := narrate(e)) is not None]
|
| 66 |
+
return lines[-limit:]
|
ttw/shocks.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Player-triggered shocks: the levers that turn a quiet market into a story.
|
| 2 |
+
|
| 3 |
+
Each shock mutates the world directly and appends a log event the narrator/UI
|
| 4 |
+
can surface. Kept pure and side-effect-local so the Gradio buttons (later) are
|
| 5 |
+
one-liners.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from .world import FOOD_GOODS, WorldState
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def drought(world: WorldState, good: str, severity: float = 0.4) -> dict:
|
| 14 |
+
"""Cut a good's production to `severity` of normal (0.4 = 60% loss) until lifted."""
|
| 15 |
+
if good in world.production_mult:
|
| 16 |
+
world.production_mult[good] = severity
|
| 17 |
+
event = {"type": "shock", "turn": world.turn, "kind": "drought", "good": good, "severity": severity}
|
| 18 |
+
world.log.append(event)
|
| 19 |
+
return event
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def lift_drought(world: WorldState, good: str) -> dict:
|
| 23 |
+
world.production_mult[good] = 1.0
|
| 24 |
+
event = {"type": "shock", "turn": world.turn, "kind": "lift_drought", "good": good}
|
| 25 |
+
world.log.append(event)
|
| 26 |
+
return event
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def plant_rumor(world: WorldState, message: str) -> dict:
|
| 30 |
+
"""Inject a rumor every creature will see next turn. The player's mischief."""
|
| 31 |
+
world.rumors.append(message.strip()[:200])
|
| 32 |
+
event = {"type": "shock", "turn": world.turn, "kind": "rumor", "message": message.strip()[:200]}
|
| 33 |
+
world.log.append(event)
|
| 34 |
+
return event
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def gold_rush(world: WorldState, amount: int = 40) -> dict:
|
| 38 |
+
"""Shower pebbles on the whole wood -> a burst of demand and inflation."""
|
| 39 |
+
for c in world.alive():
|
| 40 |
+
c.pebbles += amount
|
| 41 |
+
event = {"type": "shock", "turn": world.turn, "kind": "gold_rush", "amount": amount}
|
| 42 |
+
world.log.append(event)
|
| 43 |
+
return event
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def bountiful_harvest(world: WorldState, good: str, qty: int = 8) -> dict:
|
| 47 |
+
"""Dump a windfall of one food onto every creature -> a price collapse."""
|
| 48 |
+
target = good if good in FOOD_GOODS else FOOD_GOODS[0]
|
| 49 |
+
for c in world.alive():
|
| 50 |
+
c.inventory[target] = c.inventory.get(target, 0) + qty
|
| 51 |
+
event = {"type": "shock", "turn": world.turn, "kind": "harvest", "good": target, "qty": qty}
|
| 52 |
+
world.log.append(event)
|
| 53 |
+
return event
|
ttw/sim.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The tick loop: production, agent decisions, market clearing, eat/burn/spoil.
|
| 2 |
+
|
| 3 |
+
`step` is agnostic to *how* agents decide. It takes a `policy` callable that,
|
| 4 |
+
given the world and a creature, returns that creature's (offers, gossip). A
|
| 5 |
+
dummy random policy proves the engine; the small-model policy drops in with the
|
| 6 |
+
same signature.
|
| 7 |
+
|
| 8 |
+
Turn order: produce -> decide -> trade -> eat (varied diet) -> burn firewood ->
|
| 9 |
+
spoilage. Eat/burn/spoil run after trade so a creature can trade its way out of
|
| 10 |
+
a shortfall within the same turn.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import math
|
| 16 |
+
from typing import Callable
|
| 17 |
+
|
| 18 |
+
from .actions import Gossip, Offer
|
| 19 |
+
from .market import clear_market, drift_prices
|
| 20 |
+
from .world import (
|
| 21 |
+
FOOD_GOODS,
|
| 22 |
+
FUEL_GOOD,
|
| 23 |
+
MAX_FOOD_PER_GOOD,
|
| 24 |
+
SPOIL_RATE,
|
| 25 |
+
WorldState,
|
| 26 |
+
fuel_need,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
Policy = Callable[[WorldState, str], "tuple[list[Offer], list[Gossip]]"]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def step(world: WorldState, policy: Policy) -> list[dict]:
|
| 33 |
+
"""Advance the wood by one turn. Returns the turn's event log."""
|
| 34 |
+
world.turn += 1
|
| 35 |
+
events: list[dict] = []
|
| 36 |
+
|
| 37 |
+
# 1. Production, scaled by any active supply shock (drought -> mult < 1).
|
| 38 |
+
for c in world.alive():
|
| 39 |
+
mult = world.production_mult.get(c.produces, 1.0)
|
| 40 |
+
made = int(round(c.production_rate * mult))
|
| 41 |
+
c.inventory[c.produces] = c.inventory.get(c.produces, 0) + made
|
| 42 |
+
|
| 43 |
+
# 2. Decisions: gather everyone's offers and gossip for this turn.
|
| 44 |
+
all_offers: list[Offer] = []
|
| 45 |
+
for c in world.alive():
|
| 46 |
+
offers, gossip = policy(world, c.name)
|
| 47 |
+
all_offers.extend(offers)
|
| 48 |
+
for g in gossip:
|
| 49 |
+
world.rumors.append(g.message)
|
| 50 |
+
events.append({"type": "gossip", "turn": world.turn, "creature": c.name, "message": g.message})
|
| 51 |
+
|
| 52 |
+
# 3. Market clears, then the reference price drifts with supply/demand pressure
|
| 53 |
+
# so prices actually trend (bubbles, crashes) instead of staying frozen.
|
| 54 |
+
# clear_market consumes all_offers' quantities in place, so drift_prices
|
| 55 |
+
# sees only the RESIDUAL unfilled intent (the real scarcity signal).
|
| 56 |
+
events.extend(clear_market(world, all_offers))
|
| 57 |
+
drift_prices(world, all_offers)
|
| 58 |
+
|
| 59 |
+
# 4. Eat a varied diet, then burn firewood for warmth. Wellbeing is a
|
| 60 |
+
# real-time MOOD (mean-reverting, floored) reflecting this turn's comfort,
|
| 61 |
+
# not a death counter -- a well-fed, warm creature brightens; a short one
|
| 62 |
+
# sours, but nobody starves to zero.
|
| 63 |
+
for c in world.alive():
|
| 64 |
+
eat_ev = _eat(c, world.turn)
|
| 65 |
+
burn_ev = _burn_fuel(c, world.turn)
|
| 66 |
+
_update_mood(c, eat_ev.get("shortfall", 0), burn_ev.get("shortfall", 0))
|
| 67 |
+
events.append(eat_ev)
|
| 68 |
+
events.append(burn_ev)
|
| 69 |
+
|
| 70 |
+
# 5. Spoilage: perishable food rots (firewood, being fuel, does not).
|
| 71 |
+
for c in world.alive():
|
| 72 |
+
spoiled = _spoil(c)
|
| 73 |
+
if spoiled:
|
| 74 |
+
events.append({"type": "spoil", "turn": world.turn, "creature": c.name, "lost": spoiled})
|
| 75 |
+
|
| 76 |
+
world.rumors = world.rumors[-8:]
|
| 77 |
+
world.log.extend(e for e in events if e["type"] in ("gossip", "hunger", "cold", "spoil"))
|
| 78 |
+
return events
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _eat(creature, turn: int) -> dict:
|
| 82 |
+
"""Eat `food_need` units, at most MAX_FOOD_PER_GOOD of any one food -> needs variety.
|
| 83 |
+
|
| 84 |
+
Consumes inventory and reports any shortfall. Does NOT touch wellbeing;
|
| 85 |
+
mood is updated once per turn by _update_mood.
|
| 86 |
+
"""
|
| 87 |
+
need = creature.food_need
|
| 88 |
+
eaten: dict[str, int] = {}
|
| 89 |
+
for good in FOOD_GOODS:
|
| 90 |
+
if need <= 0:
|
| 91 |
+
break
|
| 92 |
+
take = min(creature.inventory.get(good, 0), MAX_FOOD_PER_GOOD, need)
|
| 93 |
+
if take > 0:
|
| 94 |
+
creature.inventory[good] -= take
|
| 95 |
+
eaten[good] = take
|
| 96 |
+
need -= take
|
| 97 |
+
if need > 0:
|
| 98 |
+
return {"type": "hunger", "turn": turn, "creature": creature.name, "shortfall": need}
|
| 99 |
+
return {"type": "ate", "turn": turn, "creature": creature.name, "eaten": eaten}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _burn_fuel(creature, turn: int) -> dict:
|
| 103 |
+
"""Burn the turn's firewood need, reporting any shortfall. Does not touch mood."""
|
| 104 |
+
need = fuel_need(turn)
|
| 105 |
+
have = creature.inventory.get(FUEL_GOOD, 0)
|
| 106 |
+
burned = min(have, need)
|
| 107 |
+
creature.inventory[FUEL_GOOD] = have - burned
|
| 108 |
+
short = need - burned
|
| 109 |
+
if short > 0:
|
| 110 |
+
return {"type": "cold", "turn": turn, "creature": creature.name, "shortfall": short}
|
| 111 |
+
return {"type": "warm", "turn": turn, "creature": creature.name, "burned": burned}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _update_mood(creature, food_short: int, fuel_short: int) -> None:
|
| 115 |
+
"""Drift wellbeing one step toward a comfort target set by this turn's provisioning.
|
| 116 |
+
|
| 117 |
+
A creature that ate well and stayed warm trends to 10; each kind of shortfall
|
| 118 |
+
lowers the target by 3 (floor 4). Because it mean-reverts one step per turn,
|
| 119 |
+
wellbeing is a mood that recovers, never a death spiral. Range stays [4, 10].
|
| 120 |
+
"""
|
| 121 |
+
penalty = (1 if food_short > 0 else 0) + (1 if fuel_short > 0 else 0)
|
| 122 |
+
target = 10 - 3 * penalty
|
| 123 |
+
if creature.wellbeing < target:
|
| 124 |
+
creature.wellbeing = min(target, creature.wellbeing + 1)
|
| 125 |
+
elif creature.wellbeing > target:
|
| 126 |
+
creature.wellbeing = max(target, creature.wellbeing - 1)
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def _spoil(creature) -> dict[str, int]:
|
| 130 |
+
"""Rot a fraction of each perishable food held. Returns {good: units_lost}."""
|
| 131 |
+
lost: dict[str, int] = {}
|
| 132 |
+
for good in FOOD_GOODS:
|
| 133 |
+
have = creature.inventory.get(good, 0)
|
| 134 |
+
rot = math.floor(have * SPOIL_RATE)
|
| 135 |
+
if rot > 0:
|
| 136 |
+
creature.inventory[good] = have - rot
|
| 137 |
+
lost[good] = rot
|
| 138 |
+
return lost
|
ttw/world.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""World state: goods, creatures, and the shape of the wood.
|
| 2 |
+
|
| 3 |
+
Pure data + construction + the economy's tuning constants (its source of truth).
|
| 4 |
+
No market logic, no LLM. Pebbles are the integer currency; goods are integer
|
| 5 |
+
quantities, so conservation invariants are easy to assert in tests.
|
| 6 |
+
|
| 7 |
+
The economy is built around engineered scarcity so trade never dries up:
|
| 8 |
+
- DIET VARIETY: you can eat only MAX_FOOD_PER_GOOD of any single food toward a
|
| 9 |
+
meal, so surviving means acquiring foods you do not grow yourself.
|
| 10 |
+
- SPOILAGE: perishable foods rot each turn, punishing hoarding and forcing
|
| 11 |
+
surplus to be sold while it is still worth something.
|
| 12 |
+
- WINTER FUEL: every creature must burn firewood each turn and the need rises,
|
| 13 |
+
but only one creature makes firewood -> a deepening fuel crisis.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
|
| 20 |
+
GOODS: list[str] = ["acorns", "berries", "mushrooms", "honey", "firewood"]
|
| 21 |
+
FOOD_GOODS: list[str] = ["acorns", "berries", "mushrooms", "honey"]
|
| 22 |
+
FUEL_GOOD: str = "firewood"
|
| 23 |
+
|
| 24 |
+
# --- Economy tuning (the numbers that shape behavior; change them here only) ---
|
| 25 |
+
# Calibrated so the wood pressures trade hard but is survivable: in a 20-turn
|
| 26 |
+
# run most creatures should live, with 1-2 collapses for drama, not a total
|
| 27 |
+
# die-off. Iteration 1 (need=3, ramp=5, spoil=0.25) killed everyone.
|
| 28 |
+
SPOIL_RATE = 0.20 # fraction of each perishable food lost to rot per turn (floored)
|
| 29 |
+
MAX_FOOD_PER_GOOD = 1 # diet variety: units of one food that count toward a meal
|
| 30 |
+
FUEL_NEED_BASE = 1 # firewood every creature burns per turn at the start
|
| 31 |
+
FUEL_RAMP_EVERY = 8 # +1 to the per-turn firewood need every N turns (winter deepens)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def fuel_need(turn: int) -> int:
|
| 35 |
+
"""How much firewood each creature must burn on a given turn (rises with winter)."""
|
| 36 |
+
return FUEL_NEED_BASE + turn // FUEL_RAMP_EVERY
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class Creature:
|
| 41 |
+
"""One woodland agent: an identity, a purse, a larder, and a job."""
|
| 42 |
+
|
| 43 |
+
name: str
|
| 44 |
+
persona: str # short personality, used to prompt the small model
|
| 45 |
+
pebbles: int # currency on hand
|
| 46 |
+
inventory: dict[str, int] # good -> quantity held
|
| 47 |
+
produces: str # the one good this creature makes each turn
|
| 48 |
+
production_rate: int # units produced per turn
|
| 49 |
+
food_need: int # food units it must eat per turn (across varied foods)
|
| 50 |
+
wellbeing: int = 10 # flavor/health; drops when it cannot eat or stay warm
|
| 51 |
+
memory: list[str] = field(default_factory=list) # recent events, grudges
|
| 52 |
+
|
| 53 |
+
def net_worth(self, prices: dict[str, float]) -> float:
|
| 54 |
+
"""Pebbles plus inventory marked at current prices. For the wealth chart."""
|
| 55 |
+
goods_value = sum(qty * prices.get(g, 0.0) for g, qty in self.inventory.items())
|
| 56 |
+
return self.pebbles + goods_value
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
@dataclass
|
| 60 |
+
class WorldState:
|
| 61 |
+
"""Everything that changes turn to turn."""
|
| 62 |
+
|
| 63 |
+
turn: int
|
| 64 |
+
creatures: dict[str, Creature]
|
| 65 |
+
last_price: dict[str, float] # last traded price per good (moving reference)
|
| 66 |
+
production_mult: dict[str, float] = field(default_factory=dict) # 1.0; <1 in a drought
|
| 67 |
+
rumors: list[str] = field(default_factory=list) # active rumors agents can hear
|
| 68 |
+
log: list[dict] = field(default_factory=list) # structured event records
|
| 69 |
+
|
| 70 |
+
def alive(self) -> list[Creature]:
|
| 71 |
+
return list(self.creatures.values())
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def seed_world() -> WorldState:
|
| 75 |
+
"""The starting cast of Thousand Token Wood.
|
| 76 |
+
|
| 77 |
+
Each creature grows one good and is therefore short on everything else it
|
| 78 |
+
must eat, plus the firewood everyone needs. Inventories are lean so trade
|
| 79 |
+
starts on turn one. Pip alone makes firewood, which winter slowly makes scarce.
|
| 80 |
+
"""
|
| 81 |
+
creatures = {
|
| 82 |
+
"Mossback": Creature(
|
| 83 |
+
name="Mossback",
|
| 84 |
+
persona="a slow, steady tortoise farmer who grows acorns and distrusts hype",
|
| 85 |
+
pebbles=40,
|
| 86 |
+
inventory={"acorns": 6, "berries": 2, "mushrooms": 2, "honey": 0, "firewood": 4},
|
| 87 |
+
produces="acorns",
|
| 88 |
+
production_rate=4,
|
| 89 |
+
food_need=2,
|
| 90 |
+
),
|
| 91 |
+
"Bramble": Creature(
|
| 92 |
+
name="Bramble",
|
| 93 |
+
persona="an anxious squirrel hoarder obsessed with stockpiling for winter",
|
| 94 |
+
pebbles=55,
|
| 95 |
+
inventory={"acorns": 2, "berries": 6, "mushrooms": 2, "honey": 0, "firewood": 4},
|
| 96 |
+
produces="berries",
|
| 97 |
+
production_rate=4,
|
| 98 |
+
food_need=2,
|
| 99 |
+
),
|
| 100 |
+
"Fenn": Creature(
|
| 101 |
+
name="Fenn",
|
| 102 |
+
persona="a sly fox speculator who buys fear, sells greed, and spreads rumors",
|
| 103 |
+
pebbles=85,
|
| 104 |
+
inventory={"acorns": 2, "berries": 2, "mushrooms": 6, "honey": 1, "firewood": 3},
|
| 105 |
+
produces="mushrooms",
|
| 106 |
+
production_rate=4,
|
| 107 |
+
food_need=2,
|
| 108 |
+
),
|
| 109 |
+
"Oona": Creature(
|
| 110 |
+
name="Oona",
|
| 111 |
+
persona="a cautious owl who lends pebbles and prizes a fat reserve over risk",
|
| 112 |
+
pebbles=120,
|
| 113 |
+
inventory={"acorns": 1, "berries": 1, "mushrooms": 2, "honey": 6, "firewood": 4},
|
| 114 |
+
produces="honey",
|
| 115 |
+
production_rate=3,
|
| 116 |
+
food_need=2,
|
| 117 |
+
),
|
| 118 |
+
"Pip": Creature(
|
| 119 |
+
name="Pip",
|
| 120 |
+
persona="a jittery mouse who copies the crowd, panics easily, and chops the wood's firewood",
|
| 121 |
+
pebbles=50,
|
| 122 |
+
inventory={"acorns": 2, "berries": 2, "mushrooms": 1, "honey": 1, "firewood": 8},
|
| 123 |
+
produces="firewood",
|
| 124 |
+
production_rate=6,
|
| 125 |
+
food_need=2,
|
| 126 |
+
),
|
| 127 |
+
}
|
| 128 |
+
last_price = {"acorns": 4.0, "berries": 5.0, "mushrooms": 6.0, "honey": 9.0, "firewood": 5.0}
|
| 129 |
+
production_mult = {g: 1.0 for g in GOODS}
|
| 130 |
+
return WorldState(
|
| 131 |
+
turn=0, creatures=creatures, last_price=last_price, production_mult=production_mult
|
| 132 |
+
)
|