AdmiralTaco Claude Opus 4.8 commited on
Commit
c26b033
·
0 Parent(s):

Thousand Token Wood: emergent small-model economy for Build Small Hackathon

Browse files
Files changed (24) hide show
  1. .gitignore +7 -0
  2. DEPLOY.md +53 -0
  3. README.md +56 -0
  4. app.py +146 -0
  5. modal_smoke_test.py +61 -0
  6. requirements.txt +5 -0
  7. scripts/run_llm.py +80 -0
  8. scripts/run_random.py +36 -0
  9. serve.py +86 -0
  10. tasks/todo.md +165 -0
  11. tests/test_game.py +71 -0
  12. tests/test_market.py +236 -0
  13. ttw/__init__.py +1 -0
  14. ttw/actions.py +145 -0
  15. ttw/agents.py +79 -0
  16. ttw/dummy.py +42 -0
  17. ttw/events.py +110 -0
  18. ttw/game.py +113 -0
  19. ttw/llm.py +28 -0
  20. ttw/market.py +156 -0
  21. ttw/narrate.py +66 -0
  22. ttw/shocks.py +53 -0
  23. ttw/sim.py +138 -0
  24. 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
+ )