kikikita commited on
Commit
d0c158f
·
1 Parent(s): 5fed43d

Refactor NPC roles and enhance survival mechanics

Browse files

- Updated NPC role for Ada from "farmer" to "gatherer" across multiple tests and memory summaries.
- Modified the NPC simulation to ensure only guards engage threats, changing the goal from "survive_threat_fight" to "engage_threat".
- Adjusted hunger mechanics in survival tests to reflect a more accurate growth rate.
- Enhanced world advancement logic to incorporate overseer metadata and post-survival tick applications.
- Improved beast spawning logic to respect population requirements and cooldowns.
- Ensured memory management retains recent entries and summarizes older events correctly.

Files changed (41) hide show
  1. GAME_DESIGN.md +341 -0
  2. frontend/src/App.tsx +25 -7
  3. frontend/src/api.ts +28 -0
  4. frontend/src/components/AgentPanel.tsx +19 -2
  5. frontend/src/components/ChaosDock.tsx +107 -0
  6. frontend/src/components/EventFeedPanel.tsx +57 -0
  7. frontend/src/components/GameOverOverlay.tsx +122 -0
  8. frontend/src/components/GodConsolePanel.tsx +0 -50
  9. frontend/src/components/OverseerPanel.tsx +77 -0
  10. frontend/src/components/ScoreboardHeader.tsx +96 -0
  11. frontend/src/components/StatusBar.tsx +0 -50
  12. frontend/src/eventDecor.ts +47 -0
  13. frontend/src/hooks/useWorldSimulation.ts +131 -2
  14. frontend/src/scene/NpcLabelButton.tsx +43 -52
  15. frontend/src/scene/WorldView.tsx +123 -1
  16. frontend/src/scene/characterUtils.ts +21 -0
  17. frontend/src/styles.css +876 -231
  18. frontend/src/types.ts +72 -0
  19. playtest_report.md +656 -0
  20. prompts/overseer.md +15 -0
  21. scripts/headless_sim.py +144 -0
  22. scripts/playtest_agent.py +811 -0
  23. src/god_simulator/api/server.py +125 -8
  24. src/god_simulator/config.py +53 -0
  25. src/god_simulator/domain.py +63 -0
  26. src/god_simulator/rendering/claw3d_contract.py +62 -1
  27. src/god_simulator/simulation/chaos.py +142 -0
  28. src/god_simulator/simulation/connectors/base.py +6 -1
  29. src/god_simulator/simulation/connectors/openai_compatible.py +85 -2
  30. src/god_simulator/simulation/god_console.py +4 -2
  31. src/god_simulator/simulation/mechanics.py +1 -1
  32. src/god_simulator/simulation/overseer.py +536 -0
  33. src/god_simulator/simulation/roles.py +99 -0
  34. src/god_simulator/simulation/spawning.py +78 -17
  35. src/god_simulator/simulation/survival.py +1218 -157
  36. src/god_simulator/simulation/tick.py +18 -5
  37. tests/test_claw3d_contract.py +1 -1
  38. tests/test_npc_simulation_v2.py +9 -2
  39. tests/test_openai_connector.py +11 -12
  40. tests/test_survival_loop.py +29 -16
  41. tests/test_world_spawning.py +1 -1
GAME_DESIGN.md ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GAME_DESIGN.md — GOD_SIMULATOR: "Overseer vs Chaos"
2
+
3
+ > **This document is the single source of truth for all coding agents.**
4
+ > If code and this document disagree, this document wins.
5
+ > Do not invent your own balance numbers — use the constants below.
6
+ > All constants should live in one config file (`config/game_balance.py` or equivalent) so they can be tuned without touching logic.
7
+
8
+ ---
9
+
10
+ ## 0. One-liner
11
+
12
+ **A village of tiny LLM citizens trying to survive and multiply, guided by an AI Overseer — while the human player acts as the God of Chaos trying to destroy them.**
13
+
14
+ Architecture principle (unchanged from current codebase):
15
+ - **LLM = picks NPC actions only.**
16
+ - **Engine = the only source of truth.** Validates every action, applies all consequences (hp, hunger, inventory, trust, memory, movement, death, birth).
17
+ - **Overseer = a larger LLM** that reads logs/snapshots and issues directives through the existing directive system. The engine validates Overseer directives exactly like God Console directives.
18
+
19
+ ---
20
+
21
+ ## 1. Win / Lose conditions
22
+
23
+ | Condition | Value |
24
+ |---|---|
25
+ | **WIN (village)** | Population reaches **12** alive NPCs, OR the village survives **600 ticks** with population ≥ 3 |
26
+ | **LOSE (village)** | Population reaches **0** |
27
+ | Game length target | A typical session ends in 300–600 ticks (~5–10 min of real time) |
28
+
29
+ `world.game_status`: `"running" | "win_population" | "win_survival" | "lose"` — must be present in every snapshot.
30
+
31
+ When the game ends, the engine emits a `game_over` event containing summary stats: total births, deaths (by cause), houses built, beasts killed, peak population, final scoreboard.
32
+
33
+ ---
34
+
35
+ ## 2. Core NPC stats
36
+
37
+ | Stat | Range | Notes |
38
+ |---|---|---|
39
+ | `hp` | 0–100 | 0 = death |
40
+ | `hunger` | 0–100 | 100 = starving (hp drain) |
41
+ | `fear` | 0–100 | affects goal selection |
42
+ | `safety` | 0–100 | NEW. 100 required for reproduction |
43
+ | `age` | ticks | NEW. Increments +1 every tick |
44
+ | `max_age` | ticks | NEW. Randomized at spawn: **400 ± 80** (uniform 320–480) |
45
+ | `importance` | float ≥ 0 | NEW. Derived score, see §8 |
46
+ | `reproduction_cooldown` | ticks | NEW. Counts down to 0 |
47
+
48
+ ### Stat dynamics per tick
49
+
50
+ | Rule | Value |
51
+ |---|---|
52
+ | Hunger growth | **+0.8 / tick** |
53
+ | Starvation | if `hunger ≥ 70`: **hp −0.5 / tick**; if `hunger ≥ 90`: **hp −1.5 / tick** |
54
+ | Eating (`consume`, costs 1 food) | **hunger −40**, **hp +5** |
55
+ | Healing (`heal`, costs 1 herbs) | **hp +25** (cap 100) |
56
+ | Fear gain | beast within **8 units**: fear → at least 60, +20 per attack witnessed |
57
+ | Fear decay | **−2 / tick** when no beast within 12 units |
58
+ | Old age death | when `age ≥ max_age`: NPC dies, cause `old_age`, witnesses get a memory episode |
59
+
60
+ ### Safety (NEW)
61
+
62
+ `safety` is computed, not LLM-controlled:
63
+
64
+ - **Inside a completed house** AND no living beast within **12 units** of the house: `safety +12 / tick` (cap 100).
65
+ - Inside a house but a beast is within 12 units: safety frozen (no gain, no loss).
66
+ - Outside any house: `safety −8 / tick` (floor 0).
67
+ - Hostile NPC directive targeting this NPC within 10 units counts as a threat (same as beast).
68
+
69
+ This means an NPC needs ~9 consecutive calm ticks at home to reach safety 100. That is the intended pacing.
70
+
71
+ ---
72
+
73
+ ## 3. Classes (roles)
74
+
75
+ Every NPC has `role: "guard" | "builder" | "gatherer"`, fixed at spawn.
76
+
77
+ **Action whitelist is enforced by the engine BEFORE the LLM is called.** The LLM only ever sees the actions its role allows. Smaller action space = better behavior from small models.
78
+
79
+ ### Shared actions (all roles)
80
+ `move_to_resource`, `consume`, `heal`, `flee`, `communicate` (help_request / warning / trade_request / social), `transfer`, `go_home` (NEW: move into nearest house with free capacity), `wander`.
81
+
82
+ ### Gatherer 🌾
83
+ - Extra actions: `gather` (any resource type), `steal`.
84
+ - Combat: may `attack` only in self-defense (beast within 2 units), damage multiplier **×0.3**.
85
+ - Intended loop: gather food/herbs/wood → transfer wood to builders, food to hungry NPCs → go home → reproduce.
86
+
87
+ ### Guard 🗡
88
+ - Extra actions: `attack`, `defend`, `patrol` (NEW: cycle between houses / village center).
89
+ - Damage multiplier **×1.5**; base damage 10 → effective 15 per hit.
90
+ - Spawns with `weapon: 1`.
91
+ - Cannot `gather`, cannot `steal`.
92
+ - Engine-level reflex (not LLM): if a beast is within **10 units of any house or NPC**, the guard's goal is forced to `engage_threat` (LLM then picks attack/defend/communicate within that goal).
93
+
94
+ ### Builder 🔨
95
+ - Extra actions: `build` (see §4), `gather` restricted to **wood only**.
96
+ - Cannot `attack` (flee/defend only), damage multiplier ×0.3 in self-defense.
97
+ - Intended loop: collect/receive wood → build houses → houses enable reproduction.
98
+
99
+ ### Role balance rules
100
+ - New NPC (born) inherits a role chosen to fix the biggest scarcity: target ratio **gatherer : guard : builder = 3 : 2 : 1**. If ratios are balanced, inherit a parent's role.
101
+ - Starting village (tick 0): **6 NPCs = 3 gatherers, 2 guards, 1 builder.**
102
+
103
+ ---
104
+
105
+ ## 4. Houses & building
106
+
107
+ New entity:
108
+
109
+ ```json
110
+ {
111
+ "id": "house_001",
112
+ "position": {"x": 0, "z": 0},
113
+ "hp": 60,
114
+ "max_hp": 60,
115
+ "state": "under_construction | completed",
116
+ "build_progress": 0,
117
+ "capacity": 3,
118
+ "occupant_ids": []
119
+ }
120
+ ```
121
+
122
+ | Rule | Value |
123
+ |---|---|
124
+ | Build cost | **5 wood** (consumed from builder inventory at start) |
125
+ | Build time | **10 ticks** of the builder staying within 2 units performing `build` (progress +1/tick; pauses if builder flees, resumes later) |
126
+ | Capacity | **3 NPCs** |
127
+ | House radius ("inside") | **2.5 units** |
128
+ | Min distance between houses | 4 units (engine rejects closer build attempts) |
129
+ | Beasts vs houses | Beasts **cannot damage NPCs inside** a completed house. A beast whose target hides indoors attacks the house: **5 dmg/hit**. House at 0 hp is destroyed; occupants are exposed and fear → 80 |
130
+ | Starting houses | **1 completed house** at village center (so reproduction is possible from tick 0, but one house caps growth — building matters) |
131
+
132
+ Engine emits events: `build_started`, `build_completed`, `house_damaged`, `house_destroyed`.
133
+
134
+ ---
135
+
136
+ ## 5. Reproduction & lifespan
137
+
138
+ An NPC reproduces (engine-checked every tick, no LLM involvement in the check) when **all** are true:
139
+
140
+ 1. `hp ≥ 95`
141
+ 2. `hunger ≤ 5`
142
+ 3. `safety == 100`
143
+ 4. `age ≥ 50`
144
+ 5. `reproduction_cooldown == 0`
145
+ 6. NPC is inside a completed house with free capacity for the child OR village population < cap
146
+ 7. Population < **16** (hard cap)
147
+
148
+ Effects of birth:
149
+ - New NPC spawns at the house: `age 0`, `hp 70`, `hunger 20`, `fear 0`, empty inventory (guards still get weapon 1), role per §3 scarcity rule, `max_age` rerolled.
150
+ - Parent: `reproduction_cooldown = 60` ticks, `hunger +30` (reproduction is costly — prevents runaway loops).
151
+ - Trust: child ↔ parent initialized at **+0.6**.
152
+ - Event `npc_born` + memory episodes for parent (self) and house occupants (witness).
153
+ - Child is a full NPC immediately (no childhood mechanics — out of scope).
154
+
155
+ Death (any cause: beast, starvation, old age, NPC attack):
156
+ - Event `npc_died` with `cause`.
157
+ - Witnesses within 10 units get a memory episode (`emotional_weight 0.9`).
158
+ - Inventory drops as a resource node at death position (lootable via `gather`).
159
+
160
+ ---
161
+
162
+ ## 6. Beasts
163
+
164
+ Mostly unchanged; tuned values:
165
+
166
+ | Param | Value |
167
+ |---|---|
168
+ | Beast hp | **60** |
169
+ | Beast damage | **9 per hit** (NPC needs ~2 hits survived to feel danger; not one-shot) |
170
+ | Attack radius | 1.5 units |
171
+ | Target priority | nearest NPC outside houses; prefers `hp < 50` if within 1.3× distance of nearest |
172
+ | Retreat | at `hp < 20`, beast flees to map edge and despawns after 15 ticks |
173
+ | MAX_BEASTS | **3** (natural spawns); God Console can exceed up to 6 |
174
+ | Natural spawn cooldown | 1 beast per **60–90 ticks** (random), only while population ≥ 4 |
175
+ | Beast vs guard math | Guard (15 dmg/hit) kills a beast in 4 hits; beast kills a full-hp NPC in 12 hits → 2 guards handle 1 beast comfortably, 1 guard wins but ends wounded. 3 beasts overwhelm 2 guards — that's the Chaos lever. |
176
+
177
+ ---
178
+
179
+ ## 7. Resources
180
+
181
+ | Type | Nodes at start | max_amount | Regen | Notes |
182
+ |---|---|---|---|---|
183
+ | food | 4 | 5 | +1 per **8 ticks** | consumed via `consume` |
184
+ | herbs | 2 | 4 | +1 per **12 ticks** | consumed via `heal` |
185
+ | wood | 3 | 6 | +1 per **10 ticks** | consumed via `build` |
186
+ | weapon | 1 | 2 | +1 per **40 ticks** | picked up via `gather`, mostly for replacing dead guards |
187
+
188
+ - `gather` = 1 unit per tick within 1.5 units of node.
189
+ - Famine (Chaos tool) halves `amount` and `max_amount` of all food nodes for 100 ticks.
190
+ - Total food throughput at start: 4 nodes × 1/8 = 0.5 food/tick ≈ supports ~10 NPCs eating every ~50 ticks. Tight but survivable — scarcity is intentional.
191
+
192
+ ---
193
+
194
+ ## 8. Importance score
195
+
196
+ Recomputed by engine every 10 ticks (visible in UI and Overseer context, used by nothing else for now):
197
+
198
+ ```
199
+ importance = 1.0
200
+ + 2.0 * (role_scarcity) # 1 / count_of_same_role_alive
201
+ + 0.5 * beasts_killed
202
+ + 0.3 * resources_transferred
203
+ + 0.4 * children_count
204
+ + 0.5 * houses_built # builders
205
+ ```
206
+
207
+ Purpose: gives the Overseer an explicit signal whom to protect ("our last builder!") and gives judges a visible "value of a life" number. **No other mechanic depends on it** — keep it cheap.
208
+
209
+ ---
210
+
211
+ ## 9. Trust (unchanged primitives, fixed deltas)
212
+
213
+ | Event | Trust delta |
214
+ |---|---|
215
+ | `transfer` (received a resource) | **+0.15** |
216
+ | Real help: attacked/defended a threat near me while I was in danger | **+0.20** |
217
+ | `steal` (victim and witnesses) | **−0.30** |
218
+ | NPC attacked NPC | **−0.40** |
219
+ | Beast relationship | fixed −1.0 |
220
+ | `help_request` alone | 0 (asking isn't helping) |
221
+ | Clamp | −1.0 … +1.0 |
222
+ | "Trusted ally" threshold for cooperation goals | **trust > 0.3** |
223
+
224
+ ---
225
+
226
+ ## 10. Overseer (the AI commander)
227
+
228
+ | Param | Value |
229
+ |---|---|
230
+ | Cycle | every **8 ticks** |
231
+ | Max directives per cycle | **3** |
232
+ | Modes | `off` / `advisor` (thoughts shown, directives NOT applied) / `autopilot` (directives applied) |
233
+ | Model | configurable endpoint, separate from NPC model (larger model) |
234
+ | Fallback | on API error/timeout (>10s) or invalid JSON: skip cycle, log `overseer_skipped`, never block the world tick |
235
+
236
+ **Overseer context (compact, ≤ ~1500 tokens):** tick, game_status, population by role, win-condition progress, list of NPCs (id, role, hp, hunger, safety, position zone, importance, current goal), houses (state, hp), resource node totals, active beasts (position, hp, current target), last 20 event-log lines, current chaos events.
237
+
238
+ **Overseer output — strict JSON only:**
239
+
240
+ ```json
241
+ {
242
+ "thoughts": "Two beasts north. Boris (last builder, importance 4.1) must hide. Guards intercept.",
243
+ "directives": [
244
+ {"npc_id": "npc_003", "directive": "go home and stay inside until beasts are gone"},
245
+ {"npc_id": "npc_001", "directive": "attack beast_2 together with npc_005"}
246
+ ],
247
+ "priority_alert": "Protect the last builder"
248
+ }
249
+ ```
250
+
251
+ Directives are injected via the **existing directive system**; the engine validates radius/movement/damage as it does for God Console. A directive cannot grant actions outside the NPC's role whitelist. Invalid `npc_id` → directive dropped + logged.
252
+
253
+ ---
254
+
255
+ ## 11. Scoreboard: Overseer vs Chaos
256
+
257
+ | Event | Points |
258
+ |---|---|
259
+ | `npc_born` | Overseer **+3** |
260
+ | `build_completed` | Overseer **+2** |
261
+ | Beast killed by NPCs | Overseer **+2** |
262
+ | `npc_died` (any cause) | Chaos **+3** |
263
+ | `house_destroyed` | Chaos **+2** |
264
+ | `steal` occurred | Chaos **+1** (social decay counts as chaos) |
265
+
266
+ Score is cosmetic (does not affect win/lose) but must be in every snapshot and prominent in UI.
267
+
268
+ ### Chaos tools (God Console quick-buttons)
269
+
270
+ | Tool | Effect | Cooldown |
271
+ |---|---|---|
272
+ | Spawn Beast | 1 beast at map edge | 20 ticks |
273
+ | Beast Pack | 3 beasts | 80 ticks |
274
+ | Famine | food nodes halved for 100 ticks | 120 ticks |
275
+ | Maniac | hostile directive on a random NPC: attack others (engine-validated; victim can flee/call help) for 60 ticks or until subdued | 100 ticks |
276
+
277
+ Cooldowns exist so the player cannot trivially insta-wipe the village; the duel must look winnable for both sides. Free-form God Console directives remain available (power user mode).
278
+
279
+ ---
280
+
281
+ ## 12. Goal selection priority (deterministic layer above the LLM)
282
+
283
+ Engine assigns a **goal**; the LLM picks the concrete action within it. Priority order (first match wins):
284
+
285
+ 1. `survive_threat` — beast/hostile within 8 units → (guard: engage; others: flee / go_home / help_request)
286
+ 2. `engage_threat` — guards only, beast within 10 units of village
287
+ 3. `recover` — hp < 40 → heal if herbs, else seek herbs / go home
288
+ 4. `eat` — hunger ≥ 60 → consume if food, else trade_request to trusted ally with surplus, else gather food; if hunger ≥ 85 and no options → steal becomes available (gatherers)
289
+ 5. `obey_directive` — active Overseer/God directive (note: threats and critical needs above CAN preempt a directive — citizens are not robots; this is intended and makes autopilot imperfect)
290
+ 6. `settle` — hp ≥ 80, hunger ≤ 30, no threats → go_home, rest toward reproduction
291
+ 7. `work` — role routine: gatherer gathers scarcest resource, builder builds/gathers wood, guard patrols
292
+ 8. `social` — communicate / share surplus / wander
293
+
294
+ ---
295
+
296
+ ## 13. Event log (the spine of UI and Overseer)
297
+
298
+ Every engine consequence emits a structured event:
299
+
300
+ ```json
301
+ {"tick": 142, "type": "npc_born", "actor_id": "npc_003", "target_id": "npc_009", "summary": "Elena gave birth to Mira", "severity": "good"}
302
+ ```
303
+
304
+ `type` enum (minimum): `beast_spawned, beast_attack, beast_killed, beast_retreat, npc_attack, npc_died, npc_born, build_started, build_completed, house_damaged, house_destroyed, gather, transfer, steal, heal, consume, help_request, directive_issued, directive_completed, chaos_event, overseer_skipped, game_over`.
305
+
306
+ `severity`: `good | neutral | warning | danger`. The UI event feed and the Overseer context both read from this log. **No mechanic should be invisible** — if it happened, it's an event.
307
+
308
+ ---
309
+
310
+ ## 14. Balance targets (what playtests must show)
311
+
312
+ 1. **No chaos, no overseer:** village survives 300+ ticks, builds ≥ 1 house, ≥ 2 births. Median run does NOT die out.
313
+ 2. **Scheduled chaos (beast@50, famine@120, pack@200, maniac@280), overseer OFF:** village usually loses or ends crippled (population ≤ 3).
314
+ 3. **Same chaos, overseer ON (autopilot):** village survives noticeably more often (target: ≥ 50% of runs reach tick 400).
315
+ 4. **Max-aggression human chaos:** village can be destroyed — Chaos must be able to win.
316
+ 5. No runaway growth: population should not hit the 16 cap before tick ~250 in a calm run.
317
+
318
+ If a target fails, tune **numbers in this file only** (hunger rate, beast damage, regen, cooldowns) — do not add mechanics.
319
+
320
+ ---
321
+
322
+ ## 15. Out of scope (do NOT build)
323
+
324
+ Factions, economy/money, crafting beyond `build`, childhood/aging stages, social status & power hierarchy (README roadmap only), multiplayer, persistence/SQLite, long-term story arcs, new LLM tools beyond the action lists above, 3D polish at the expense of gameplay.
325
+
326
+ ---
327
+
328
+ ## 16. Acceptance tests (headless, must stay green)
329
+
330
+ `scripts/headless_sim.py` (deterministic fallback, no LLM, fixed seed) must assert over a 300-tick run:
331
+
332
+ 1. ≥ 1 `build_completed`
333
+ 2. ≥ 1 `npc_born`
334
+ 3. ≥ 1 beast engaged by a guard (`npc_attack` with beast target by a guard)
335
+ 4. ≥ 1 death with correct cause attribution
336
+ 5. Population never negative; no crashes; every tick ≤ reasonable time budget
337
+ 6. Role whitelists never violated (no gather by guards, no attack actions chosen by builders)
338
+ 7. Reproduction never fires when any condition in §5 is false (property check)
339
+ 8. `game_status` transitions correctly on win/lose seeds
340
+
341
+ `scripts/playtest_agent.py` runs the scheduled-chaos scenarios from §14 and produces `playtest_report.md` with: event timeline, population-over-time data, which causal chains occurred (beast→fear→help_request→guard response→trust change; hunger→gather/steal; build→reproduce), and a balance verdict.
frontend/src/App.tsx CHANGED
@@ -1,13 +1,17 @@
1
  import { AgentPanel } from "./components/AgentPanel";
2
- import { GodConsolePanel } from "./components/GodConsolePanel";
 
 
 
 
3
  import { SimulationControls } from "./components/SimulationControls";
4
- import { StatusBar } from "./components/StatusBar";
5
  import { StatusToasts } from "./components/StatusToasts";
6
  import { useWorldSimulation } from "./hooks/useWorldSimulation";
7
  import { GodSimulatorScene } from "./scene/GodSimulatorScene";
8
 
9
  export function App() {
10
  const simulation = useWorldSimulation();
 
11
 
12
  return (
13
  <main className="shell">
@@ -17,12 +21,16 @@ export function App() {
17
  onSelectEntity={simulation.setSelectedId}
18
  />
19
 
20
- <StatusBar
21
  snapshot={simulation.snapshot}
22
  isGodPending={simulation.isGodPending}
23
  isWaitingForTick={simulation.isWaitingForTick}
24
  />
25
 
 
 
 
 
26
  <SimulationControls
27
  hasSnapshot={simulation.snapshot !== null}
28
  isAutoTicking={simulation.isAutoTicking}
@@ -37,13 +45,19 @@ export function App() {
37
  }}
38
  />
39
 
40
- <GodConsolePanel
41
- command={simulation.godCommand}
 
 
 
 
42
  isGodPending={simulation.isGodPending}
43
  isWaitingForTick={simulation.isWaitingForTick}
44
- message={simulation.godMessage}
 
 
45
  onCommandChange={simulation.setGodCommand}
46
- onSubmit={(event) => {
47
  void simulation.runGodCommand(event);
48
  }}
49
  />
@@ -54,6 +68,10 @@ export function App() {
54
  error={simulation.error}
55
  isWaitingForTick={simulation.isWaitingForTick}
56
  />
 
 
 
 
57
  </main>
58
  );
59
  }
 
1
  import { AgentPanel } from "./components/AgentPanel";
2
+ import { ChaosDock } from "./components/ChaosDock";
3
+ import { EventFeedPanel } from "./components/EventFeedPanel";
4
+ import { GameOverOverlay } from "./components/GameOverOverlay";
5
+ import { OverseerPanel } from "./components/OverseerPanel";
6
+ import { ScoreboardHeader } from "./components/ScoreboardHeader";
7
  import { SimulationControls } from "./components/SimulationControls";
 
8
  import { StatusToasts } from "./components/StatusToasts";
9
  import { useWorldSimulation } from "./hooks/useWorldSimulation";
10
  import { GodSimulatorScene } from "./scene/GodSimulatorScene";
11
 
12
  export function App() {
13
  const simulation = useWorldSimulation();
14
+ const gameStatus = simulation.snapshot?.simulation.game_status ?? "running";
15
 
16
  return (
17
  <main className="shell">
 
21
  onSelectEntity={simulation.setSelectedId}
22
  />
23
 
24
+ <ScoreboardHeader
25
  snapshot={simulation.snapshot}
26
  isGodPending={simulation.isGodPending}
27
  isWaitingForTick={simulation.isWaitingForTick}
28
  />
29
 
30
+ <OverseerPanel snapshot={simulation.snapshot} log={simulation.overseerLog} />
31
+
32
+ <EventFeedPanel events={simulation.eventHistory} />
33
+
34
  <SimulationControls
35
  hasSnapshot={simulation.snapshot !== null}
36
  isAutoTicking={simulation.isAutoTicking}
 
45
  }}
46
  />
47
 
48
+ <ChaosDock
49
+ snapshot={simulation.snapshot}
50
+ pendingChaosAction={simulation.pendingChaosAction}
51
+ chaosMessage={simulation.chaosMessage}
52
+ godCommand={simulation.godCommand}
53
+ godMessage={simulation.godMessage}
54
  isGodPending={simulation.isGodPending}
55
  isWaitingForTick={simulation.isWaitingForTick}
56
+ onChaosAction={(action) => {
57
+ void simulation.runChaosAction(action);
58
+ }}
59
  onCommandChange={simulation.setGodCommand}
60
+ onSubmitCommand={(event) => {
61
  void simulation.runGodCommand(event);
62
  }}
63
  />
 
68
  error={simulation.error}
69
  isWaitingForTick={simulation.isWaitingForTick}
70
  />
71
+
72
+ {simulation.snapshot && gameStatus !== "running" ? (
73
+ <GameOverOverlay snapshot={simulation.snapshot} events={simulation.eventHistory} />
74
+ ) : null}
75
  </main>
76
  );
77
  }
frontend/src/api.ts CHANGED
@@ -53,3 +53,31 @@ export async function sendGodCommand(command: string): Promise<GodCommandResult>
53
  }
54
  return (await response.json()) as GodCommandResult;
55
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
53
  }
54
  return (await response.json()) as GodCommandResult;
55
  }
56
+
57
+ export type ChaosActionResult = {
58
+ action: string;
59
+ applied: string[];
60
+ summary: string;
61
+ snapshot: WorldSnapshot;
62
+ };
63
+
64
+ export async function sendChaosAction(action: string): Promise<ChaosActionResult> {
65
+ const response = await fetch(`${API_BASE}/chaos`, {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ action }),
69
+ });
70
+ if (!response.ok) {
71
+ let message = `Chaos action failed: ${response.status}`;
72
+ try {
73
+ const body = (await response.json()) as { message?: string };
74
+ if (body.message) {
75
+ message = body.message;
76
+ }
77
+ } catch {
78
+ // keep the generic message
79
+ }
80
+ throw new ApiResponseError(message, response.status);
81
+ }
82
+ return (await response.json()) as ChaosActionResult;
83
+ }
frontend/src/components/AgentPanel.tsx CHANGED
@@ -38,8 +38,16 @@ function AgentDetails({ entity }: { entity: EntitySnapshot }) {
38
  }
39
 
40
  function SurvivalReadout({ entity }: { entity: EntitySnapshot }) {
41
- const { hunger, fear, goal, inventory } = entity.state;
42
- if (hunger === undefined && fear === undefined && goal === undefined && !inventory) {
 
 
 
 
 
 
 
 
43
  return null;
44
  }
45
 
@@ -55,6 +63,15 @@ function SurvivalReadout({ entity }: { entity: EntitySnapshot }) {
55
  {fear !== undefined ? (
56
  <TooltipLabel className="readoutCell" value={`Fear ${fear.toFixed(0)}`} />
57
  ) : null}
 
 
 
 
 
 
 
 
 
58
  {goal ? <TooltipLabel className="readoutCell" value={goal} /> : null}
59
  <TooltipLabel className="readoutCell" value={inventoryLabel} />
60
  </div>
 
38
  }
39
 
40
  function SurvivalReadout({ entity }: { entity: EntitySnapshot }) {
41
+ const { hunger, fear, safety, age, max_age, importance, goal, inventory } = entity.state;
42
+ if (
43
+ hunger === undefined &&
44
+ fear === undefined &&
45
+ safety === undefined &&
46
+ age === undefined &&
47
+ importance === undefined &&
48
+ goal === undefined &&
49
+ !inventory
50
+ ) {
51
  return null;
52
  }
53
 
 
63
  {fear !== undefined ? (
64
  <TooltipLabel className="readoutCell" value={`Fear ${fear.toFixed(0)}`} />
65
  ) : null}
66
+ {safety !== undefined ? (
67
+ <TooltipLabel className="readoutCell" value={`Safety ${safety.toFixed(0)}`} />
68
+ ) : null}
69
+ {age !== undefined && max_age !== undefined ? (
70
+ <TooltipLabel className="readoutCell" value={`Age ${age}/${max_age}`} />
71
+ ) : null}
72
+ {importance !== undefined ? (
73
+ <TooltipLabel className="readoutCell" value={`Importance ${importance.toFixed(1)}`} />
74
+ ) : null}
75
  {goal ? <TooltipLabel className="readoutCell" value={goal} /> : null}
76
  <TooltipLabel className="readoutCell" value={inventoryLabel} />
77
  </div>
frontend/src/components/ChaosDock.tsx ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LoaderCircle, Send } from "lucide-react";
2
+ import type { FormEvent } from "react";
3
+
4
+ import type { ChaosToolSnapshot, WorldSnapshot } from "../types";
5
+
6
+ const CHAOS_ICONS: Record<string, string> = {
7
+ spawn_beast: "🐺",
8
+ beast_pack: "🐺×3",
9
+ famine: "🌾",
10
+ maniac: "🔪",
11
+ };
12
+
13
+ const FALLBACK_TOOLS: ChaosToolSnapshot[] = [
14
+ { action: "spawn_beast", label: "Spawn Beast", cooldown_ticks: 20, remaining_ticks: 0 },
15
+ { action: "beast_pack", label: "Beast Pack", cooldown_ticks: 80, remaining_ticks: 0 },
16
+ { action: "famine", label: "Famine", cooldown_ticks: 120, remaining_ticks: 0 },
17
+ { action: "maniac", label: "Maniac", cooldown_ticks: 100, remaining_ticks: 0 },
18
+ ];
19
+
20
+ type ChaosDockProps = {
21
+ snapshot: WorldSnapshot | null;
22
+ pendingChaosAction: string | null;
23
+ chaosMessage: string | null;
24
+ godCommand: string;
25
+ godMessage: string | null;
26
+ isGodPending: boolean;
27
+ isWaitingForTick: boolean;
28
+ onChaosAction: (action: string) => void;
29
+ onCommandChange: (command: string) => void;
30
+ onSubmitCommand: (event: FormEvent<HTMLFormElement>) => void;
31
+ };
32
+
33
+ export function ChaosDock({
34
+ snapshot,
35
+ pendingChaosAction,
36
+ chaosMessage,
37
+ godCommand,
38
+ godMessage,
39
+ isGodPending,
40
+ isWaitingForTick,
41
+ onChaosAction,
42
+ onCommandChange,
43
+ onSubmitCommand,
44
+ }: ChaosDockProps) {
45
+ const tools = snapshot?.simulation.chaos_tools ?? FALLBACK_TOOLS;
46
+ const message = chaosMessage ?? godMessage;
47
+
48
+ return (
49
+ <section className="chaosDock" aria-label="God console">
50
+ <div className="chaosHeader">
51
+ <span className="chaosTitle">🔥 GOD OF CHAOS</span>
52
+ <span className="chaosSubtitle">unleash havoc on the village</span>
53
+ </div>
54
+
55
+ <div className="chaosButtons">
56
+ {tools.map((tool) => {
57
+ const isPending = pendingChaosAction === tool.action;
58
+ const onCooldown = tool.remaining_ticks > 0;
59
+ const disabled = onCooldown || pendingChaosAction !== null || !snapshot;
60
+ return (
61
+ <button
62
+ key={tool.action}
63
+ type="button"
64
+ className={`chaosButton ${onCooldown ? "onCooldown" : ""}`}
65
+ disabled={disabled}
66
+ title={
67
+ onCooldown
68
+ ? `${tool.label}: ready in ${tool.remaining_ticks} ticks`
69
+ : `${tool.label} (cooldown ${tool.cooldown_ticks} ticks)`
70
+ }
71
+ onClick={() => onChaosAction(tool.action)}
72
+ >
73
+ {isPending ? (
74
+ <LoaderCircle className="spinIcon" size={16} />
75
+ ) : (
76
+ <span className="chaosIcon">{CHAOS_ICONS[tool.action] ?? "✦"}</span>
77
+ )}
78
+ <span className="chaosLabel">{tool.label}</span>
79
+ {onCooldown ? (
80
+ <span className="chaosCooldown">{tool.remaining_ticks}</span>
81
+ ) : null}
82
+ </button>
83
+ );
84
+ })}
85
+ </div>
86
+
87
+ <form className="chaosCommandRow" onSubmit={onSubmitCommand}>
88
+ <input
89
+ value={godCommand}
90
+ onChange={(event) => onCommandChange(event.target.value)}
91
+ placeholder="Freeform command: 'a plague rumor spreads in the north'..."
92
+ disabled={isGodPending}
93
+ />
94
+ <button
95
+ type="submit"
96
+ aria-label="Run god command"
97
+ title="Run freeform god command"
98
+ disabled={!godCommand.trim() || isGodPending || isWaitingForTick}
99
+ >
100
+ {isGodPending ? <LoaderCircle className="spinIcon" size={16} /> : <Send size={15} />}
101
+ </button>
102
+ </form>
103
+
104
+ {message ? <p className="chaosMessage">{message}</p> : null}
105
+ </section>
106
+ );
107
+ }
frontend/src/components/EventFeedPanel.tsx ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "react";
2
+
3
+ import { eventIcon, severityClass } from "../eventDecor";
4
+ import type { GameLogEventSnapshot } from "../types";
5
+
6
+ type EventFeedPanelProps = {
7
+ events: GameLogEventSnapshot[];
8
+ };
9
+
10
+ export function EventFeedPanel({ events }: EventFeedPanelProps) {
11
+ const listRef = useRef<HTMLDivElement>(null);
12
+ const isPinnedToBottomRef = useRef(true);
13
+
14
+ useEffect(() => {
15
+ const list = listRef.current;
16
+ if (list && isPinnedToBottomRef.current) {
17
+ list.scrollTop = list.scrollHeight;
18
+ }
19
+ }, [events]);
20
+
21
+ return (
22
+ <aside className="eventFeed" aria-label="Event feed">
23
+ <div className="sidePanelHeader">
24
+ <span className="sidePanelTitle">📰 Village Chronicle</span>
25
+ <span className="sidePanelHint">{events.length} events</span>
26
+ </div>
27
+ <div
28
+ className="eventList"
29
+ ref={listRef}
30
+ onScroll={() => {
31
+ const list = listRef.current;
32
+ if (list) {
33
+ isPinnedToBottomRef.current =
34
+ list.scrollHeight - list.scrollTop - list.clientHeight < 24;
35
+ }
36
+ }}
37
+ >
38
+ {events.length === 0 ? (
39
+ <p className="eventEmpty">The village is quiet... for now.</p>
40
+ ) : (
41
+ events.map((event, index) => (
42
+ <div
43
+ className={`eventLine ${severityClass(event.severity)}`}
44
+ key={`${event.tick}-${index}-${event.type}`}
45
+ >
46
+ <span className="eventTick">{event.tick}</span>
47
+ <span className="eventIcon" aria-hidden="true">
48
+ {eventIcon(event.type)}
49
+ </span>
50
+ <span className="eventText">{event.summary}</span>
51
+ </div>
52
+ ))
53
+ )}
54
+ </div>
55
+ </aside>
56
+ );
57
+ }
frontend/src/components/GameOverOverlay.tsx ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo, useState } from "react";
2
+
3
+ import { eventIcon, severityClass } from "../eventDecor";
4
+ import type { GameLogEventSnapshot, WorldSnapshot } from "../types";
5
+
6
+ type GameOverOverlayProps = {
7
+ snapshot: WorldSnapshot;
8
+ events: GameLogEventSnapshot[];
9
+ };
10
+
11
+ const RESULT_COPY: Record<string, { title: string; subtitle: string; tone: string }> = {
12
+ win_population: {
13
+ title: "🏆 THE VILLAGE PREVAILS",
14
+ subtitle: "The Overseer raised a thriving generation — population goal reached.",
15
+ tone: "win",
16
+ },
17
+ win_survival: {
18
+ title: "🏆 THE VILLAGE ENDURES",
19
+ subtitle: "Through famine and fangs, the village outlasted the chaos.",
20
+ tone: "win",
21
+ },
22
+ lose: {
23
+ title: "💀 THE VILLAGE HAS FALLEN",
24
+ subtitle: "Chaos reigns. No villager drew breath at the end.",
25
+ tone: "lose",
26
+ },
27
+ };
28
+
29
+ export function GameOverOverlay({ snapshot, events }: GameOverOverlayProps) {
30
+ const [isDismissed, setIsDismissed] = useState(false);
31
+ const status = snapshot.simulation.game_status ?? "running";
32
+ const copy = RESULT_COPY[status];
33
+
34
+ const stats = snapshot.simulation.stats;
35
+ const scoreboard = snapshot.simulation.scoreboard;
36
+ const totalDeaths = Object.values(stats?.deaths_by_cause ?? {}).reduce(
37
+ (sum, count) => sum + count,
38
+ 0,
39
+ );
40
+ const betrayals = useMemo(
41
+ () => events.filter((event) => event.type === "steal" || event.type === "npc_attack").length,
42
+ [events],
43
+ );
44
+ const storyEvents = useMemo(
45
+ () =>
46
+ events
47
+ .filter((event) =>
48
+ ["npc_born", "npc_died", "build_completed", "house_destroyed", "beast_killed", "chaos_event", "game_over"].includes(
49
+ event.type,
50
+ ),
51
+ )
52
+ .slice(-14),
53
+ [events],
54
+ );
55
+
56
+ if (!copy || isDismissed) {
57
+ return null;
58
+ }
59
+
60
+ return (
61
+ <div className={`gameOverOverlay gameOver-${copy.tone}`} role="dialog" aria-label="Game over">
62
+ <div className="gameOverCard">
63
+ <h1>{copy.title}</h1>
64
+ <p className="gameOverSubtitle">{copy.subtitle}</p>
65
+
66
+ <div className="gameOverScore">
67
+ <span className="scoreOverseerText">🧠 Overseer {scoreboard?.overseer ?? 0}</span>
68
+ <span className="scoreDivider">—</span>
69
+ <span className="scoreChaosText">{scoreboard?.chaos ?? 0} Chaos 🔥</span>
70
+ </div>
71
+
72
+ <div className="gameOverStats">
73
+ <Stat icon="👶" label="Births" value={stats?.total_births ?? 0} />
74
+ <Stat icon="💀" label="Deaths" value={totalDeaths} />
75
+ <Stat icon="🏠" label="Houses built" value={stats?.houses_built ?? 0} />
76
+ <Stat icon="⚔️" label="Beasts slain" value={stats?.beasts_killed ?? 0} />
77
+ <Stat icon="🕵️" label="Betrayals" value={betrayals} />
78
+ <Stat icon="👥" label="Peak population" value={snapshot.simulation.peak_population ?? 0} />
79
+ </div>
80
+
81
+ {Object.keys(stats?.deaths_by_cause ?? {}).length > 0 ? (
82
+ <p className="gameOverCauses">
83
+ Causes of death:{" "}
84
+ {Object.entries(stats?.deaths_by_cause ?? {})
85
+ .map(([cause, count]) => `${cause.replaceAll("_", " ")} ×${count}`)
86
+ .join(", ")}
87
+ </p>
88
+ ) : null}
89
+
90
+ {storyEvents.length > 0 ? (
91
+ <div className="gameOverStory">
92
+ <h2>The village's story</h2>
93
+ <div className="gameOverStoryList">
94
+ {storyEvents.map((event, index) => (
95
+ <div className={`eventLine ${severityClass(event.severity)}`} key={index}>
96
+ <span className="eventTick">{event.tick}</span>
97
+ <span className="eventIcon">{eventIcon(event.type)}</span>
98
+ <span className="eventText">{event.summary}</span>
99
+ </div>
100
+ ))}
101
+ </div>
102
+ </div>
103
+ ) : null}
104
+
105
+ <button type="button" className="gameOverDismiss" onClick={() => setIsDismissed(true)}>
106
+ Inspect the aftermath
107
+ </button>
108
+ </div>
109
+ </div>
110
+ );
111
+ }
112
+
113
+ function Stat({ icon, label, value }: { icon: string; label: string; value: number }) {
114
+ return (
115
+ <div className="gameOverStat">
116
+ <span className="gameOverStatValue">
117
+ {icon} {value}
118
+ </span>
119
+ <span className="gameOverStatLabel">{label}</span>
120
+ </div>
121
+ );
122
+ }
frontend/src/components/GodConsolePanel.tsx DELETED
@@ -1,50 +0,0 @@
1
- import { LoaderCircle, Send, Sparkles } from "lucide-react";
2
- import type { FormEvent } from "react";
3
-
4
- type GodConsolePanelProps = {
5
- command: string;
6
- isGodPending: boolean;
7
- isWaitingForTick: boolean;
8
- message: string | null;
9
- onCommandChange: (command: string) => void;
10
- onSubmit: (event: FormEvent<HTMLFormElement>) => void;
11
- };
12
-
13
- export function GodConsolePanel({
14
- command,
15
- isGodPending,
16
- isWaitingForTick,
17
- message,
18
- onCommandChange,
19
- onSubmit,
20
- }: GodConsolePanelProps) {
21
- return (
22
- <form className="godConsole" aria-label="God console" onSubmit={onSubmit}>
23
- <div className="consoleHeader">
24
- <Sparkles size={15} aria-hidden="true" />
25
- <span>God Console</span>
26
- </div>
27
- <div className="consoleRow">
28
- <input
29
- value={command}
30
- onChange={(event) => onCommandChange(event.target.value)}
31
- placeholder="Ada wants to kill everyone"
32
- disabled={isGodPending}
33
- />
34
- <button
35
- type="submit"
36
- aria-label="Run god command"
37
- title="Run god command"
38
- disabled={!command.trim() || isGodPending || isWaitingForTick}
39
- >
40
- {isGodPending ? (
41
- <LoaderCircle className="spinIcon" size={17} />
42
- ) : (
43
- <Send size={16} />
44
- )}
45
- </button>
46
- </div>
47
- {message ? <p>{message}</p> : null}
48
- </form>
49
- );
50
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/components/OverseerPanel.tsx ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from "react";
2
+
3
+ import type { OverseerLogEntry } from "../hooks/useWorldSimulation";
4
+ import type { WorldSnapshot } from "../types";
5
+
6
+ type OverseerPanelProps = {
7
+ snapshot: WorldSnapshot | null;
8
+ log: OverseerLogEntry[];
9
+ };
10
+
11
+ export function OverseerPanel({ snapshot, log }: OverseerPanelProps) {
12
+ const overseer = snapshot?.simulation.overseer;
13
+ const mode = overseer?.mode ?? "off";
14
+ const listRef = useRef<HTMLDivElement>(null);
15
+
16
+ useEffect(() => {
17
+ const list = listRef.current;
18
+ if (list) {
19
+ list.scrollTop = list.scrollHeight;
20
+ }
21
+ }, [log]);
22
+
23
+ return (
24
+ <aside className="overseerPanel" aria-label="Overseer log">
25
+ <div className="sidePanelHeader">
26
+ <span className="sidePanelTitle">🧠 Overseer</span>
27
+ <span className={`overseerMode overseerMode-${mode}`}>{mode.toUpperCase()}</span>
28
+ </div>
29
+
30
+ {overseer?.priority_alert ? (
31
+ <div className="overseerAlert">⚠ {overseer.priority_alert}</div>
32
+ ) : null}
33
+
34
+ <div className="overseerLog" ref={listRef}>
35
+ {log.length === 0 ? (
36
+ <p className="eventEmpty">
37
+ {mode === "off"
38
+ ? "The Overseer is dormant."
39
+ : "Awaiting the Overseer's first assessment..."}
40
+ </p>
41
+ ) : (
42
+ log.map((entry) => <OverseerLogItem key={entry.tick} entry={entry} />)
43
+ )}
44
+ </div>
45
+ </aside>
46
+ );
47
+ }
48
+
49
+ function OverseerLogItem({ entry }: { entry: OverseerLogEntry }) {
50
+ return (
51
+ <div className="overseerEntry">
52
+ <div className="overseerEntryHead">
53
+ <span className="overseerEntryTick">T{entry.tick}</span>
54
+ {entry.priorityAlert ? (
55
+ <span className="overseerEntryAlert">{entry.priorityAlert}</span>
56
+ ) : null}
57
+ </div>
58
+ {entry.thoughts ? <p className="overseerThought">{entry.thoughts}</p> : null}
59
+ {entry.directives.length > 0 ? (
60
+ <ul className="overseerDirectives">
61
+ {entry.directives.map((directive, index) => (
62
+ <li
63
+ key={`${entry.tick}-${index}`}
64
+ className={directive.applied === false ? "directiveSkipped" : ""}
65
+ >
66
+ <span className="directiveTarget">{directive.npc_id ?? "?"}</span>
67
+ <span className="directiveText">{directive.directive ?? ""}</span>
68
+ {directive.applied === false ? (
69
+ <span className="directiveBadge">advisory</span>
70
+ ) : null}
71
+ </li>
72
+ ))}
73
+ </ul>
74
+ ) : null}
75
+ </div>
76
+ );
77
+ }
frontend/src/components/ScoreboardHeader.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { WorldSnapshot } from "../types";
2
+ import { formatModelStatus, modelStatusClass, shortModelLabel } from "./modelStatus";
3
+ import { TooltipLabel } from "./TooltipLabel";
4
+
5
+ const WIN_POPULATION = 12;
6
+ const WIN_SURVIVAL_TICKS = 600;
7
+
8
+ type ScoreboardHeaderProps = {
9
+ snapshot: WorldSnapshot | null;
10
+ isGodPending: boolean;
11
+ isWaitingForTick: boolean;
12
+ };
13
+
14
+ export function ScoreboardHeader({
15
+ snapshot,
16
+ isGodPending,
17
+ isWaitingForTick,
18
+ }: ScoreboardHeaderProps) {
19
+ const simulation = snapshot?.simulation;
20
+ const overseerScore = simulation?.scoreboard?.overseer ?? 0;
21
+ const chaosScore = simulation?.scoreboard?.chaos ?? 0;
22
+ const population = simulation?.population ?? snapshot?.entities.length ?? 0;
23
+ const tick = snapshot?.tick ?? 0;
24
+ const populationProgress = Math.min(1, population / WIN_POPULATION);
25
+ const survivalProgress = Math.min(1, tick / WIN_SURVIVAL_TICKS);
26
+ const famineUntil = simulation?.famine_until ?? -1;
27
+ const isFamineActive = famineUntil >= 0 && tick <= famineUntil;
28
+ const activityLabel = isGodPending
29
+ ? "god console..."
30
+ : isWaitingForTick
31
+ ? "simulating..."
32
+ : (simulation?.last_tick_source ?? "idle");
33
+
34
+ return (
35
+ <header className="scoreboard" aria-label="Simulation status">
36
+ <div className="scoreDuel">
37
+ <div className="scoreSide scoreOverseer">
38
+ <span className="scoreName">🧠 OVERSEER</span>
39
+ <span className="scoreValue">{overseerScore}</span>
40
+ </div>
41
+ <div className="scoreVs">vs</div>
42
+ <div className="scoreSide scoreChaos">
43
+ <span className="scoreValue">{chaosScore}</span>
44
+ <span className="scoreName">CHAOS 🔥</span>
45
+ </div>
46
+ </div>
47
+
48
+ <div className="scoreMeta">
49
+ <span className="scoreTick" title="Current simulation tick">
50
+ ⏱ Tick {tick}
51
+ </span>
52
+ {isFamineActive ? (
53
+ <span className="famineBadge" title={`Food nodes halved until tick ${famineUntil}`}>
54
+ 🌾 FAMINE ({famineUntil - tick})
55
+ </span>
56
+ ) : null}
57
+ <span className="scoreActivity">{activityLabel}</span>
58
+ </div>
59
+
60
+ <div className="winProgress" title={`Win: ${WIN_POPULATION} villagers, or survive ${WIN_SURVIVAL_TICKS} ticks`}>
61
+ <div className="winRow">
62
+ <span className="winLabel">👥 Population {population}/{WIN_POPULATION}</span>
63
+ <div className="winBar">
64
+ <div
65
+ className="winFill winFillPopulation"
66
+ style={{ width: `${populationProgress * 100}%` }}
67
+ />
68
+ </div>
69
+ </div>
70
+ <div className="winRow">
71
+ <span className="winLabel">🛡 Survival {Math.min(tick, WIN_SURVIVAL_TICKS)}/{WIN_SURVIVAL_TICKS}</span>
72
+ <div className="winBar">
73
+ <div
74
+ className="winFill winFillSurvival"
75
+ style={{ width: `${survivalProgress * 100}%` }}
76
+ />
77
+ </div>
78
+ </div>
79
+ </div>
80
+
81
+ {(simulation?.models ?? []).length > 0 ? (
82
+ <div className="modelPills">
83
+ {(simulation?.models ?? []).map((model) => (
84
+ <TooltipLabel
85
+ key={model.id}
86
+ className={`modelPill ${modelStatusClass(model.status)}`}
87
+ value={`${model.label}: ${model.model ?? "unknown model"} - ${formatModelStatus(model.status)}`}
88
+ >
89
+ {`${shortModelLabel(model.label)} ${formatModelStatus(model.status)}`}
90
+ </TooltipLabel>
91
+ ))}
92
+ </div>
93
+ ) : null}
94
+ </header>
95
+ );
96
+ }
frontend/src/components/StatusBar.tsx DELETED
@@ -1,50 +0,0 @@
1
- import { Activity } from "lucide-react";
2
-
3
- import type { WorldSnapshot } from "../types";
4
- import { TooltipLabel } from "./TooltipLabel";
5
- import { formatModelStatus, modelStatusClass, shortModelLabel } from "./modelStatus";
6
-
7
- type StatusBarProps = {
8
- snapshot: WorldSnapshot | null;
9
- isGodPending: boolean;
10
- isWaitingForTick: boolean;
11
- };
12
-
13
- export function StatusBar({ snapshot, isGodPending, isWaitingForTick }: StatusBarProps) {
14
- const isServerTickPending = snapshot?.simulation.tick_in_progress ?? false;
15
- const pendingTick = snapshot?.simulation.pending_tick;
16
- const tickLabel = snapshot
17
- ? isServerTickPending && pendingTick
18
- ? `Tick ${snapshot.tick} -> ${pendingTick}`
19
- : `Tick ${snapshot.tick}`
20
- : "Connecting";
21
- const npcCountLabel = snapshot ? `${snapshot.entities.length} NPCs` : "0 NPCs";
22
- const simulatorLabel = isGodPending
23
- ? "God console"
24
- : isWaitingForTick
25
- ? "Waiting"
26
- : (snapshot?.simulation.last_tick_source ?? "idle");
27
-
28
- return (
29
- <div className="topBar" aria-label="Simulation status">
30
- <div className="brand">
31
- <Activity size={18} aria-hidden="true" />
32
- <span>God Simulator</span>
33
- </div>
34
- <div className="statusPills">
35
- <TooltipLabel className="statusPill" value={tickLabel} />
36
- <TooltipLabel className="statusPill" value={npcCountLabel} />
37
- <TooltipLabel className="statusPill" value={simulatorLabel} />
38
- {(snapshot?.simulation.models ?? []).map((model) => (
39
- <TooltipLabel
40
- key={model.id}
41
- className={`statusPill modelStatus ${modelStatusClass(model.status)}`}
42
- value={`${model.label}: ${model.model ?? "unknown model"} - ${formatModelStatus(model.status)}`}
43
- >
44
- {`${shortModelLabel(model.label)} ${formatModelStatus(model.status)}`}
45
- </TooltipLabel>
46
- ))}
47
- </div>
48
- </div>
49
- );
50
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/eventDecor.ts ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { GameLogEventSnapshot } from "./types";
2
+
3
+ const EVENT_ICONS: Record<string, string> = {
4
+ beast_spawned: "🐺",
5
+ beast_attack: "🐺",
6
+ beast_killed: "⚔️",
7
+ beast_retreat: "🏃",
8
+ npc_attack: "🗡️",
9
+ npc_died: "💀",
10
+ npc_born: "👶",
11
+ build_started: "🚧",
12
+ build_completed: "🏠",
13
+ house_damaged: "🏚️",
14
+ house_destroyed: "💥",
15
+ gather: "🌾",
16
+ transfer: "🤝",
17
+ steal: "🕵️",
18
+ heal: "💊",
19
+ consume: "🍖",
20
+ help_request: "🆘",
21
+ directive_issued: "📜",
22
+ directive_completed: "✅",
23
+ chaos_event: "🔥",
24
+ overseer_skipped: "🌫️",
25
+ game_over: "🏁",
26
+ };
27
+
28
+ export function eventIcon(type: string): string {
29
+ return EVENT_ICONS[type] ?? "•";
30
+ }
31
+
32
+ export function severityClass(severity: string): string {
33
+ switch (severity) {
34
+ case "good":
35
+ return "severityGood";
36
+ case "warning":
37
+ return "severityWarning";
38
+ case "danger":
39
+ return "severityDanger";
40
+ default:
41
+ return "severityNeutral";
42
+ }
43
+ }
44
+
45
+ export function isMajorEvent(event: GameLogEventSnapshot): boolean {
46
+ return event.severity !== "neutral" || event.type === "build_started";
47
+ }
frontend/src/hooks/useWorldSimulation.ts CHANGED
@@ -1,8 +1,25 @@
1
  import type { FormEvent } from "react";
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
 
4
- import { ApiResponseError, fetchWorldSnapshot, sendGodCommand, tickWorld } from "../api";
5
- import type { WorldSnapshot } from "../types";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
  export function useWorldSimulation() {
8
  const [snapshot, setSnapshot] = useState<WorldSnapshot | null>(null);
@@ -13,7 +30,11 @@ export function useWorldSimulation() {
13
  const [godMessage, setGodMessage] = useState<string | null>(null);
14
  const [error, setError] = useState<string | null>(null);
15
  const [selectedId, setSelectedId] = useState<string | null>(null);
 
 
16
  const isTickingRef = useRef(false);
 
 
17
 
18
  const refresh = useCallback(async () => {
19
  const nextSnapshot = await fetchWorldSnapshot();
@@ -76,6 +97,42 @@ export function useWorldSimulation() {
76
  [godCommand, isGodPending, isWaitingForTick],
77
  );
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  useInitialSnapshot(setSnapshot, setSelectedId, setError);
80
  useSelectedEntityFallback(snapshot, setSelectedId);
81
  useServerTickPolling(isServerTickPending, refresh, setError);
@@ -97,24 +154,96 @@ export function useWorldSimulation() {
97
  );
98
 
99
  return {
 
100
  error,
 
101
  godCommand,
102
  godMessage,
103
  isAutoTicking,
104
  isGodPending,
105
  isWaitingForTick,
106
  isWorldCommandPending,
 
 
107
  selectedEntity,
108
  selectedId,
109
  setGodCommand,
110
  setIsAutoTicking,
111
  setSelectedId,
112
  snapshot,
 
113
  runGodCommand,
114
  step,
115
  };
116
  }
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  function useInitialSnapshot(
119
  setSnapshot: (snapshot: WorldSnapshot) => void,
120
  setSelectedId: (id: string | null) => void,
 
1
  import type { FormEvent } from "react";
2
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
 
4
+ import {
5
+ ApiResponseError,
6
+ fetchWorldSnapshot,
7
+ sendChaosAction,
8
+ sendGodCommand,
9
+ tickWorld,
10
+ } from "../api";
11
+ import type {
12
+ GameLogEventSnapshot,
13
+ OverseerDirectiveSnapshot,
14
+ WorldSnapshot,
15
+ } from "../types";
16
+
17
+ export type OverseerLogEntry = {
18
+ tick: number;
19
+ thoughts: string | null;
20
+ priorityAlert: string | null;
21
+ directives: OverseerDirectiveSnapshot[];
22
+ };
23
 
24
  export function useWorldSimulation() {
25
  const [snapshot, setSnapshot] = useState<WorldSnapshot | null>(null);
 
30
  const [godMessage, setGodMessage] = useState<string | null>(null);
31
  const [error, setError] = useState<string | null>(null);
32
  const [selectedId, setSelectedId] = useState<string | null>(null);
33
+ const [pendingChaosAction, setPendingChaosAction] = useState<string | null>(null);
34
+ const [chaosMessage, setChaosMessage] = useState<string | null>(null);
35
  const isTickingRef = useRef(false);
36
+ const eventHistory = useEventHistory(snapshot);
37
+ const overseerLog = useOverseerLog(snapshot);
38
 
39
  const refresh = useCallback(async () => {
40
  const nextSnapshot = await fetchWorldSnapshot();
 
97
  [godCommand, isGodPending, isWaitingForTick],
98
  );
99
 
100
+ const runChaosAction = useCallback(
101
+ async (action: string) => {
102
+ if (pendingChaosAction) {
103
+ return;
104
+ }
105
+ setPendingChaosAction(action);
106
+ setChaosMessage(null);
107
+ try {
108
+ for (let attempt = 0; ; attempt += 1) {
109
+ try {
110
+ const result = await sendChaosAction(action);
111
+ setSnapshot(result.snapshot);
112
+ setChaosMessage(result.summary);
113
+ return;
114
+ } catch (nextError) {
115
+ const message =
116
+ nextError instanceof Error ? nextError.message : "Chaos action failed";
117
+ const isTickBusy =
118
+ nextError instanceof ApiResponseError &&
119
+ nextError.status === 409 &&
120
+ message.includes("tick is running");
121
+ if (isTickBusy && attempt < 20) {
122
+ await delay(500);
123
+ continue;
124
+ }
125
+ setChaosMessage(message);
126
+ return;
127
+ }
128
+ }
129
+ } finally {
130
+ setPendingChaosAction(null);
131
+ }
132
+ },
133
+ [pendingChaosAction],
134
+ );
135
+
136
  useInitialSnapshot(setSnapshot, setSelectedId, setError);
137
  useSelectedEntityFallback(snapshot, setSelectedId);
138
  useServerTickPolling(isServerTickPending, refresh, setError);
 
154
  );
155
 
156
  return {
157
+ chaosMessage,
158
  error,
159
+ eventHistory,
160
  godCommand,
161
  godMessage,
162
  isAutoTicking,
163
  isGodPending,
164
  isWaitingForTick,
165
  isWorldCommandPending,
166
+ overseerLog,
167
+ pendingChaosAction,
168
  selectedEntity,
169
  selectedId,
170
  setGodCommand,
171
  setIsAutoTicking,
172
  setSelectedId,
173
  snapshot,
174
+ runChaosAction,
175
  runGodCommand,
176
  step,
177
  };
178
  }
179
 
180
+ function delay(ms: number): Promise<void> {
181
+ return new Promise((resolve) => {
182
+ window.setTimeout(resolve, ms);
183
+ });
184
+ }
185
+
186
+ function useEventHistory(snapshot: WorldSnapshot | null): GameLogEventSnapshot[] {
187
+ const seenKeysRef = useRef<Set<string>>(new Set());
188
+ const [history, setHistory] = useState<GameLogEventSnapshot[]>([]);
189
+
190
+ useEffect(() => {
191
+ const log = snapshot?.event_log ?? [];
192
+ if (log.length === 0) {
193
+ return;
194
+ }
195
+ const fresh: GameLogEventSnapshot[] = [];
196
+ for (const event of log) {
197
+ const key = [
198
+ event.tick,
199
+ event.type,
200
+ event.actor_id ?? "",
201
+ event.target_id ?? "",
202
+ event.summary,
203
+ ].join("|");
204
+ if (!seenKeysRef.current.has(key)) {
205
+ seenKeysRef.current.add(key);
206
+ fresh.push(event);
207
+ }
208
+ }
209
+ if (fresh.length > 0) {
210
+ setHistory((current) => [...current, ...fresh].slice(-500));
211
+ }
212
+ }, [snapshot]);
213
+
214
+ return history;
215
+ }
216
+
217
+ function useOverseerLog(snapshot: WorldSnapshot | null): OverseerLogEntry[] {
218
+ const seenTicksRef = useRef<Set<number>>(new Set());
219
+ const [log, setLog] = useState<OverseerLogEntry[]>([]);
220
+
221
+ useEffect(() => {
222
+ const overseer = snapshot?.simulation.overseer;
223
+ if (!overseer || overseer.last_tick === null) {
224
+ return;
225
+ }
226
+ const lastTick = overseer.last_tick;
227
+ if (seenTicksRef.current.has(lastTick)) {
228
+ return;
229
+ }
230
+ seenTicksRef.current.add(lastTick);
231
+ setLog((current) =>
232
+ [
233
+ ...current,
234
+ {
235
+ tick: lastTick,
236
+ thoughts: overseer.thoughts,
237
+ priorityAlert: overseer.priority_alert,
238
+ directives: overseer.directives,
239
+ },
240
+ ].slice(-40),
241
+ );
242
+ }, [snapshot]);
243
+
244
+ return log;
245
+ }
246
+
247
  function useInitialSnapshot(
248
  setSnapshot: (snapshot: WorldSnapshot) => void,
249
  setSelectedId: (id: string | null) => void,
frontend/src/scene/NpcLabelButton.tsx CHANGED
@@ -1,6 +1,5 @@
1
- import { useLayoutEffect, useRef, useState } from "react";
2
-
3
  import type { EntitySnapshot } from "../types";
 
4
 
5
  type NpcLabelButtonProps = {
6
  entity: EntitySnapshot;
@@ -8,64 +7,56 @@ type NpcLabelButtonProps = {
8
  };
9
 
10
  export function NpcLabelButton({ entity, onSelect }: NpcLabelButtonProps) {
11
- const buttonRef = useRef<HTMLButtonElement>(null);
12
- const labelMeasureRef = useRef<HTMLSpanElement>(null);
13
- const intentionMeasureRef = useRef<HTMLSpanElement>(null);
14
- const [isCollapsed, setIsCollapsed] = useState(false);
15
-
16
- useLayoutEffect(() => {
17
- const buttonElement = buttonRef.current;
18
- const observedElements = [
19
- buttonElement,
20
- labelMeasureRef.current,
21
- intentionMeasureRef.current,
22
- ].filter((element): element is HTMLElement => element !== null);
23
- if (!buttonElement || observedElements.length < 3) {
24
- return undefined;
25
- }
26
-
27
- const updateCollapsedState = () => {
28
- const styles = window.getComputedStyle(buttonElement);
29
- const horizontalPadding =
30
- Number.parseFloat(styles.paddingLeft) + Number.parseFloat(styles.paddingRight);
31
- const availableWidth = buttonElement.clientWidth - horizontalPadding;
32
- const fullLabelWidth = labelMeasureRef.current?.getBoundingClientRect().width ?? 0;
33
- const fullIntentionWidth =
34
- intentionMeasureRef.current?.getBoundingClientRect().width ?? 0;
35
-
36
- setIsCollapsed(Math.max(fullLabelWidth, fullIntentionWidth) > availableWidth + 1);
37
- };
38
-
39
- updateCollapsedState();
40
-
41
- const resizeObserver = new ResizeObserver(updateCollapsedState);
42
- for (const element of observedElements) {
43
- resizeObserver.observe(element);
44
- }
45
- window.addEventListener("resize", updateCollapsedState);
46
-
47
- return () => {
48
- resizeObserver.disconnect();
49
- window.removeEventListener("resize", updateCollapsedState);
50
- };
51
- }, [entity.label, entity.state.intention]);
52
 
53
  return (
54
  <button
55
- ref={buttonRef}
56
  type="button"
 
 
57
  onClick={onSelect}
58
  aria-label={`Select ${entity.label}`}
59
- data-tooltip={isCollapsed ? `${entity.label}: ${entity.state.intention}` : undefined}
60
  >
61
- <strong>{entity.label}</strong>
62
- <span>{entity.state.intention}</span>
63
- <strong className="npcLabelMeasure" ref={labelMeasureRef} aria-hidden="true">
64
- {entity.label}
65
- </strong>
66
- <span className="npcLabelMeasure" ref={intentionMeasureRef} aria-hidden="true">
67
- {entity.state.intention}
 
68
  </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
69
  </button>
70
  );
71
  }
 
 
 
1
  import type { EntitySnapshot } from "../types";
2
+ import { roleMeta } from "./characterUtils";
3
 
4
  type NpcLabelButtonProps = {
5
  entity: EntitySnapshot;
 
7
  };
8
 
9
  export function NpcLabelButton({ entity, onSelect }: NpcLabelButtonProps) {
10
+ const meta = roleMeta(entity.role);
11
+ const isAlive = entity.state.is_alive;
12
+ const health = Math.max(0, entity.state.health);
13
+ const maxHealth = Math.max(1, entity.state.max_health);
14
+ const healthRatio = Math.min(1, health / maxHealth);
15
+ const age = entity.state.age;
16
+ const maxAge = entity.state.max_age;
17
+ const ageRatio =
18
+ age !== undefined && maxAge !== undefined && maxAge > 0
19
+ ? Math.min(1, age / maxAge)
20
+ : null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
 
22
  return (
23
  <button
 
24
  type="button"
25
+ className={`npcCard ${isAlive ? "" : "npcCardDead"}`}
26
+ style={{ borderColor: meta.color }}
27
  onClick={onSelect}
28
  aria-label={`Select ${entity.label}`}
29
+ title={`${entity.label} ${meta.label}, ${entity.state.intention}`}
30
  >
31
+ <span className="npcCardName">
32
+ <span className="npcCardIcon" style={{ color: meta.color }} aria-hidden="true">
33
+ {isAlive ? meta.icon : "💀"}
34
+ </span>
35
+ <strong>{entity.label}</strong>
36
+ </span>
37
+ <span className="npcCardRole" style={{ color: meta.color }}>
38
+ {meta.label}
39
  </span>
40
+ {isAlive ? (
41
+ <>
42
+ <span className="npcBar npcHpBar" aria-hidden="true">
43
+ <span
44
+ className="npcBarFill"
45
+ style={{
46
+ width: `${healthRatio * 100}%`,
47
+ background: healthRatio > 0.5 ? "#6fd96a" : healthRatio > 0.25 ? "#f0c34e" : "#f06a4e",
48
+ }}
49
+ />
50
+ </span>
51
+ {ageRatio !== null ? (
52
+ <span className="npcBar npcAgeBar" aria-hidden="true" title={`Age ${age}/${maxAge}`}>
53
+ <span className="npcBarFill npcAgeFill" style={{ width: `${ageRatio * 100}%` }} />
54
+ </span>
55
+ ) : null}
56
+ </>
57
+ ) : (
58
+ <span className="npcCardRole">dead</span>
59
+ )}
60
  </button>
61
  );
62
  }
frontend/src/scene/WorldView.tsx CHANGED
@@ -1,4 +1,6 @@
1
- import type { BeastSnapshot, ResourceNodeSnapshot, WorldSnapshot } from "../types";
 
 
2
  import { NpcActor } from "./NpcActor";
3
 
4
  type WorldViewProps = {
@@ -30,6 +32,9 @@ export function WorldView({ snapshot, selectedId, onSelectEntity }: WorldViewPro
30
  onSelect={() => onSelectEntity(entity.id)}
31
  />
32
  ))}
 
 
 
33
  {(snapshot.resource_nodes ?? []).map((node) => (
34
  <ResourceMarker key={node.id} node={node} />
35
  ))}
@@ -40,6 +45,88 @@ export function WorldView({ snapshot, selectedId, onSelectEntity }: WorldViewPro
40
  );
41
  }
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  type TerrainProps = {
44
  width: number;
45
  depth: number;
@@ -69,6 +156,11 @@ function ResourceMarker({ node }: { node: ResourceNodeSnapshot }) {
69
  <ringGeometry args={[0.55, 0.72, 20]} />
70
  <meshBasicMaterial color={color} transparent opacity={0.45} />
71
  </mesh>
 
 
 
 
 
72
  </group>
73
  );
74
  }
@@ -76,6 +168,7 @@ function ResourceMarker({ node }: { node: ResourceNodeSnapshot }) {
76
  function BeastMarker({ beast }: { beast: BeastSnapshot }) {
77
  const isDead = beast.state === "dead";
78
  const color = isDead ? "#3d3532" : "#8f1f17";
 
79
 
80
  return (
81
  <group position={[beast.position.x, beast.position.y, beast.position.z]}>
@@ -91,6 +184,19 @@ function BeastMarker({ beast }: { beast: BeastSnapshot }) {
91
  <ringGeometry args={[0.85, 1.12, 28]} />
92
  <meshBasicMaterial color="#ffb09c" transparent opacity={isDead ? 0.18 : 0.58} />
93
  </mesh>
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  </group>
95
  );
96
  }
@@ -110,3 +216,19 @@ function resourceColor(type: string) {
110
  }
111
  return "#d9d2aa";
112
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Html } from "@react-three/drei";
2
+
3
+ import type { BeastSnapshot, HouseSnapshot, ResourceNodeSnapshot, WorldSnapshot } from "../types";
4
  import { NpcActor } from "./NpcActor";
5
 
6
  type WorldViewProps = {
 
32
  onSelect={() => onSelectEntity(entity.id)}
33
  />
34
  ))}
35
+ {(snapshot.houses ?? []).map((house) => (
36
+ <HouseBuilding key={house.id} house={house} />
37
+ ))}
38
  {(snapshot.resource_nodes ?? []).map((node) => (
39
  <ResourceMarker key={node.id} node={node} />
40
  ))}
 
45
  );
46
  }
47
 
48
+ function HouseBuilding({ house }: { house: HouseSnapshot }) {
49
+ const isComplete = house.state === "completed";
50
+ const isDamaged = house.hp < house.max_hp;
51
+ const buildRatio = Math.min(1, house.build_progress / 10);
52
+ const hpRatio = house.max_hp > 0 ? Math.max(0, house.hp / house.max_hp) : 0;
53
+ const occupancy = `${house.occupant_ids.length}/${house.capacity}`;
54
+
55
+ return (
56
+ <group position={[house.position.x, house.position.y, house.position.z]}>
57
+ {isComplete ? (
58
+ <>
59
+ <mesh castShadow receiveShadow position={[0, 1.0, 0]}>
60
+ <boxGeometry args={[3.2, 2.0, 3.2]} />
61
+ <meshStandardMaterial color="#a8794f" roughness={0.85} />
62
+ </mesh>
63
+ <mesh castShadow position={[0, 2.55, 0]} rotation={[0, Math.PI / 4, 0]}>
64
+ <coneGeometry args={[2.7, 1.3, 4]} />
65
+ <meshStandardMaterial color="#7e4a33" roughness={0.8} />
66
+ </mesh>
67
+ <mesh position={[0, 0.6, 1.61]}>
68
+ <boxGeometry args={[0.7, 1.2, 0.06]} />
69
+ <meshStandardMaterial color="#54392a" roughness={0.9} />
70
+ </mesh>
71
+ </>
72
+ ) : (
73
+ <>
74
+ {/* Frame rising with build progress. */}
75
+ <mesh castShadow receiveShadow position={[0, Math.max(0.15, buildRatio), 0]}>
76
+ <boxGeometry args={[3.2, Math.max(0.3, buildRatio * 2.0), 3.2]} />
77
+ <meshStandardMaterial color="#c9a06a" roughness={0.9} transparent opacity={0.85} />
78
+ </mesh>
79
+ {[-1.45, 1.45].flatMap((x) =>
80
+ [-1.45, 1.45].map((z) => (
81
+ <mesh key={`${x}:${z}`} castShadow position={[x, 1.1, z]}>
82
+ <boxGeometry args={[0.14, 2.2, 0.14]} />
83
+ <meshStandardMaterial color="#8a6437" roughness={0.9} />
84
+ </mesh>
85
+ )),
86
+ )}
87
+ </>
88
+ )}
89
+
90
+ <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.035, 0]}>
91
+ <ringGeometry args={[2.2, 2.55, 32]} />
92
+ <meshBasicMaterial
93
+ color={isComplete ? "#f2d9a0" : "#ffd07a"}
94
+ transparent
95
+ opacity={0.38}
96
+ />
97
+ </mesh>
98
+
99
+ <Html center distanceFactor={26} position={[0, isComplete ? 3.7 : 2.9, 0]} zIndexRange={[7, 0]}>
100
+ <div className={`houseTag ${isComplete ? "" : "houseTagBuilding"}`}>
101
+ <span className="houseTagTitle">
102
+ {isComplete ? "🏠" : "🚧"} {isComplete ? `Home ${occupancy}` : "Building"}
103
+ </span>
104
+ {isComplete ? (
105
+ isDamaged ? (
106
+ <span className="npcBar houseHpBar">
107
+ <span
108
+ className="npcBarFill"
109
+ style={{
110
+ width: `${hpRatio * 100}%`,
111
+ background: hpRatio > 0.5 ? "#6fd96a" : hpRatio > 0.25 ? "#f0c34e" : "#f06a4e",
112
+ }}
113
+ />
114
+ </span>
115
+ ) : null
116
+ ) : (
117
+ <span className="npcBar houseHpBar">
118
+ <span
119
+ className="npcBarFill"
120
+ style={{ width: `${buildRatio * 100}%`, background: "#ffd07a" }}
121
+ />
122
+ </span>
123
+ )}
124
+ </div>
125
+ </Html>
126
+ </group>
127
+ );
128
+ }
129
+
130
  type TerrainProps = {
131
  width: number;
132
  depth: number;
 
156
  <ringGeometry args={[0.55, 0.72, 20]} />
157
  <meshBasicMaterial color={color} transparent opacity={0.45} />
158
  </mesh>
159
+ <Html center distanceFactor={30} position={[0, height + 0.85, 0]} zIndexRange={[6, 0]}>
160
+ <div className="resourceTag">
161
+ {resourceIcon(node.type)} {node.amount}
162
+ </div>
163
+ </Html>
164
  </group>
165
  );
166
  }
 
168
  function BeastMarker({ beast }: { beast: BeastSnapshot }) {
169
  const isDead = beast.state === "dead";
170
  const color = isDead ? "#3d3532" : "#8f1f17";
171
+ const hpRatio = Math.max(0, Math.min(1, beast.health / Math.max(1, beast.max_health)));
172
 
173
  return (
174
  <group position={[beast.position.x, beast.position.y, beast.position.z]}>
 
184
  <ringGeometry args={[0.85, 1.12, 28]} />
185
  <meshBasicMaterial color="#ffb09c" transparent opacity={isDead ? 0.18 : 0.58} />
186
  </mesh>
187
+ {!isDead ? (
188
+ <Html center distanceFactor={28} position={[0, 2.2, 0]} zIndexRange={[7, 0]}>
189
+ <div className="beastTag">
190
+ <span className="beastTagTitle">🐺 {beast.state}</span>
191
+ <span className="npcBar beastHpBar">
192
+ <span
193
+ className="npcBarFill"
194
+ style={{ width: `${hpRatio * 100}%`, background: "#f06a4e" }}
195
+ />
196
+ </span>
197
+ </div>
198
+ </Html>
199
+ ) : null}
200
  </group>
201
  );
202
  }
 
216
  }
217
  return "#d9d2aa";
218
  }
219
+
220
+ function resourceIcon(type: string) {
221
+ if (type === "food") {
222
+ return "🍎";
223
+ }
224
+ if (type === "herbs") {
225
+ return "🌿";
226
+ }
227
+ if (type === "wood") {
228
+ return "🪵";
229
+ }
230
+ if (type === "weapon") {
231
+ return "⚔️";
232
+ }
233
+ return "✦";
234
+ }
frontend/src/scene/characterUtils.ts CHANGED
@@ -7,6 +7,26 @@ export type RolePalette = {
7
  hair: string;
8
  };
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  export function rolePalette(role: string): RolePalette {
11
  switch (role) {
12
  case "builder":
@@ -19,6 +39,7 @@ export function rolePalette(role: string): RolePalette {
19
  hair: "#4c3a2f",
20
  };
21
  case "forager":
 
22
  return {
23
  coat: "#5faa59",
24
  front: "#91d67b",
 
7
  hair: string;
8
  };
9
 
10
+ export type RoleMeta = {
11
+ icon: string;
12
+ label: string;
13
+ color: string;
14
+ };
15
+
16
+ export function roleMeta(role: string): RoleMeta {
17
+ switch (role) {
18
+ case "guard":
19
+ return { icon: "🗡️", label: "Guard", color: "#e2675a" };
20
+ case "builder":
21
+ return { icon: "🔨", label: "Builder", color: "#5b96f0" };
22
+ case "forager":
23
+ case "gatherer":
24
+ return { icon: "🌾", label: "Gatherer", color: "#6fc46a" };
25
+ default:
26
+ return { icon: "👤", label: role || "Villager", color: "#d9b35a" };
27
+ }
28
+ }
29
+
30
  export function rolePalette(role: string): RolePalette {
31
  switch (role) {
32
  case "builder":
 
39
  hair: "#4c3a2f",
40
  };
41
  case "forager":
42
+ case "gatherer":
43
  return {
44
  coat: "#5faa59",
45
  front: "#91d67b",
frontend/src/styles.css CHANGED
@@ -39,87 +39,608 @@ button {
39
  height: 100% !important;
40
  }
41
 
42
- .topBar {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  position: absolute;
44
  top: 14px;
45
- left: 14px;
46
- right: 14px;
47
  z-index: 60;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  display: flex;
49
  align-items: center;
50
  justify-content: space-between;
51
- gap: 12px;
52
- pointer-events: none;
 
53
  }
54
 
55
- .brand,
56
- .statusPills {
57
- pointer-events: auto;
 
58
  }
59
 
60
- .brand,
61
- .statusPills,
62
- .controls,
63
- .agentPanel,
64
- .godConsole,
65
- .errorToast,
66
- .busyToast {
67
- border: 1px solid rgb(255 255 255 / 18%);
68
- background: rgb(18 28 23 / 76%);
69
- box-shadow: 0 18px 60px rgb(0 0 0 / 18%);
70
- backdrop-filter: blur(14px);
71
  }
72
 
73
- .brand {
74
- min-height: 42px;
75
- display: inline-flex;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  align-items: center;
77
- gap: 9px;
78
- padding: 0 14px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  border-radius: 8px;
80
- font-size: 14px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
81
  font-weight: 700;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  }
83
 
84
- .statusPills {
85
- min-height: 42px;
86
- display: flex;
87
- align-items: center;
88
- gap: 8px;
89
- padding: 0 8px;
90
- border-radius: 8px;
91
  }
92
 
93
- .statusPill {
94
- position: relative;
95
- min-width: 74px;
96
- max-width: 150px;
97
- padding: 7px 10px;
98
- border-radius: 6px;
99
- background: rgb(255 255 255 / 9%);
100
- color: rgb(245 247 240 / 88%);
101
- font-size: 12px;
102
- line-height: 1;
103
- text-align: center;
104
  }
105
 
106
- .modelStatus {
107
- min-width: 86px;
 
 
108
  }
109
 
110
- .modelStatusReady {
111
- color: #d9f4cf;
 
112
  }
113
 
114
- .modelStatusWarmup,
115
- .modelStatusUnknown {
116
- color: #fff3a1;
117
  }
118
 
119
- .modelStatusError {
120
- color: #ffd3c8;
 
 
 
121
  }
122
 
 
 
123
  .controls {
124
  position: absolute;
125
  right: 14px;
@@ -163,87 +684,21 @@ button {
163
 
164
  .controls button:focus-visible,
165
  .npcLabel button:focus-visible,
166
- .godConsole input:focus-visible,
167
- .godConsole button:focus-visible {
 
168
  outline: 2px solid #f8df6b;
169
  outline-offset: 2px;
170
  }
171
 
172
- .godConsole {
173
- position: absolute;
174
- top: 70px;
175
- right: 14px;
176
- z-index: 35;
177
- width: min(390px, calc(100vw - 28px));
178
- display: grid;
179
- gap: 8px;
180
- padding: 10px;
181
- border-radius: 8px;
182
- }
183
-
184
- .consoleHeader {
185
- display: flex;
186
- align-items: center;
187
- gap: 7px;
188
- color: #fffce9;
189
- font-size: 12px;
190
- font-weight: 750;
191
- }
192
-
193
- .consoleRow {
194
- display: grid;
195
- grid-template-columns: minmax(0, 1fr) 38px;
196
- gap: 7px;
197
- }
198
-
199
- .godConsole input,
200
- .godConsole button {
201
- height: 38px;
202
- border: 1px solid rgb(255 255 255 / 14%);
203
- border-radius: 7px;
204
- color: #fffce9;
205
- background: rgb(255 255 255 / 10%);
206
- }
207
-
208
- .godConsole input {
209
- min-width: 0;
210
- padding: 0 11px;
211
- font: inherit;
212
- font-size: 13px;
213
- }
214
-
215
- .godConsole input::placeholder {
216
- color: rgb(245 247 240 / 46%);
217
- }
218
-
219
- .godConsole button {
220
- display: grid;
221
- place-items: center;
222
- cursor: pointer;
223
- }
224
-
225
- .godConsole button:disabled {
226
- cursor: wait;
227
- opacity: 0.68;
228
- }
229
-
230
- .godConsole button:hover:not(:disabled) {
231
- background: rgb(255 255 255 / 18%);
232
- }
233
-
234
- .godConsole p {
235
- margin: 0;
236
- color: rgb(245 247 240 / 76%);
237
- font-size: 12px;
238
- line-height: 1.35;
239
- }
240
 
241
  .agentPanel {
242
  position: absolute;
243
  left: 14px;
244
  bottom: 18px;
245
  z-index: 30;
246
- width: min(320px, calc(100vw - 108px));
247
  border-radius: 8px;
248
  overflow: visible;
249
  }
@@ -331,7 +786,7 @@ button {
331
  }
332
 
333
  .memoryList {
334
- max-height: 168px;
335
  overflow-y: auto;
336
  padding: 9px 10px 10px;
337
  }
@@ -371,6 +826,8 @@ button {
371
  color: rgb(245 247 240 / 72%);
372
  }
373
 
 
 
374
  .tooltipAnchor {
375
  position: relative;
376
  min-width: 0;
@@ -403,9 +860,7 @@ button {
403
  .tooltipAnchor[data-tooltip]::before,
404
  .tooltipAnchor[data-tooltip]::after,
405
  .memoryItem p[data-tooltip]::before,
406
- .memoryItem p[data-tooltip]::after,
407
- .npcLabel button[data-tooltip]::before,
408
- .npcLabel button[data-tooltip]::after {
409
  position: absolute;
410
  left: 50%;
411
  z-index: 80;
@@ -419,15 +874,13 @@ button {
419
  }
420
 
421
  .tooltipAnchor[data-tooltip]::before,
422
- .memoryItem p[data-tooltip]::before,
423
- .npcLabel button[data-tooltip]::before {
424
  content: "";
425
  border: 6px solid transparent;
426
  }
427
 
428
  .tooltipAnchor[data-tooltip]::after,
429
- .memoryItem p[data-tooltip]::after,
430
- .npcLabel button[data-tooltip]::after {
431
  content: attr(data-tooltip);
432
  width: max-content;
433
  max-width: min(320px, calc(100vw - 28px));
@@ -451,26 +904,20 @@ button {
451
  .tooltipAnchor[data-tooltip]:focus-visible::before,
452
  .tooltipAnchor[data-tooltip]:focus-visible::after,
453
  .memoryItem p[data-tooltip]:hover::before,
454
- .memoryItem p[data-tooltip]:hover::after,
455
- .npcLabel button[data-tooltip]:hover::before,
456
- .npcLabel button[data-tooltip]:hover::after,
457
- .npcLabel button[data-tooltip]:focus-visible::before,
458
- .npcLabel button[data-tooltip]:focus-visible::after {
459
  opacity: 1;
460
  visibility: visible;
461
  }
462
 
463
  .tooltipAnchor[data-tooltip]::before,
464
- .memoryItem p[data-tooltip]::before,
465
- .npcLabel button[data-tooltip]::before {
466
  bottom: calc(100% + 2px);
467
  border-top-color: rgb(10 17 14 / 96%);
468
  transform: translate(-50%, 4px);
469
  }
470
 
471
  .tooltipAnchor[data-tooltip]::after,
472
- .memoryItem p[data-tooltip]::after,
473
- .npcLabel button[data-tooltip]::after {
474
  bottom: calc(100% + 14px);
475
  transform: translate(-50%, 4px);
476
  }
@@ -480,42 +927,17 @@ button {
480
  .tooltipAnchor[data-tooltip]:focus-visible::before,
481
  .tooltipAnchor[data-tooltip]:focus-visible::after,
482
  .memoryItem p[data-tooltip]:hover::before,
483
- .memoryItem p[data-tooltip]:hover::after,
484
- .npcLabel button[data-tooltip]:hover::before,
485
- .npcLabel button[data-tooltip]:hover::after,
486
- .npcLabel button[data-tooltip]:focus-visible::before,
487
- .npcLabel button[data-tooltip]:focus-visible::after {
488
  transform: translate(-50%, 0);
489
  }
490
 
491
- .topBar .tooltipAnchor[data-tooltip]::before {
492
- top: calc(100% + 2px);
493
- bottom: auto;
494
- border-top-color: transparent;
495
- border-bottom-color: rgb(10 17 14 / 96%);
496
- }
497
-
498
- .topBar .tooltipAnchor[data-tooltip]::after {
499
- top: calc(100% + 14px);
500
- bottom: auto;
501
- }
502
-
503
- .statusPills .tooltipAnchor[data-tooltip]:last-child::after {
504
- right: 0;
505
- left: auto;
506
- transform: translateY(4px);
507
- }
508
-
509
- .statusPills .tooltipAnchor[data-tooltip]:last-child:hover::after,
510
- .statusPills .tooltipAnchor[data-tooltip]:last-child:focus-visible::after {
511
- transform: translateY(0);
512
- }
513
 
514
  .errorToast {
515
  position: absolute;
516
- top: 70px;
517
  left: 50%;
518
- z-index: 40;
519
  max-width: min(560px, calc(100vw - 28px));
520
  padding: 11px 14px;
521
  border-color: rgb(255 120 94 / 54%);
@@ -527,9 +949,9 @@ button {
527
 
528
  .busyToast {
529
  position: absolute;
530
- top: 70px;
531
  left: 50%;
532
- z-index: 40;
533
  display: inline-flex;
534
  align-items: center;
535
  gap: 8px;
@@ -551,112 +973,335 @@ button {
551
  }
552
  }
553
 
 
 
554
  .npcLabel {
555
  pointer-events: auto;
556
  }
557
 
558
- .npcLabel button {
559
- position: relative;
560
- min-width: 84px;
561
- max-width: 128px;
562
  display: grid;
563
- gap: 2px;
564
- padding: 6px 8px;
565
- border: 1px solid rgb(255 255 255 / 22%);
566
- border-radius: 7px;
567
  color: #fffce9;
568
- background: rgb(15 24 20 / 78%);
569
  box-shadow: 0 10px 28px rgb(0 0 0 / 24%);
570
  cursor: pointer;
 
571
  }
572
 
573
- .npcLabel button:hover {
574
- border-color: rgb(248 223 107 / 48%);
575
- background: rgb(18 30 24 / 88%);
576
  box-shadow: 0 12px 30px rgb(0 0 0 / 28%);
577
  }
578
 
579
- .npcLabel strong,
580
- .npcLabel span {
 
 
 
 
 
 
 
581
  min-width: 0;
 
 
 
582
  overflow: hidden;
583
  text-overflow: ellipsis;
584
  white-space: nowrap;
 
 
585
  }
586
 
587
- .npcLabel strong {
588
  font-size: 11px;
589
- line-height: 1.1;
590
  }
591
 
592
- .npcLabel span {
593
- color: #c7e6bd;
594
  font-size: 9px;
595
- line-height: 1.1;
 
 
 
596
  }
597
 
598
- .npcLabel .npcLabelMeasure {
599
- position: absolute;
600
- width: max-content;
601
- height: 0;
 
 
602
  overflow: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
603
  pointer-events: none;
604
- visibility: hidden;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  }
606
 
607
- @media (max-width: 720px) {
608
- .topBar {
609
- align-items: flex-start;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
610
  }
611
 
612
- .brand {
613
- width: 42px;
614
- padding: 0;
615
- justify-content: center;
616
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
617
 
618
- .brand span {
619
- position: absolute;
620
- width: 1px;
621
- height: 1px;
622
- overflow: hidden;
623
- clip: rect(0, 0, 0, 0);
624
- white-space: nowrap;
625
  }
626
 
627
- .statusPills {
628
- flex-wrap: wrap;
629
- justify-content: flex-end;
630
- max-width: 190px;
631
  }
 
632
 
633
- .statusPills .statusPill {
634
- min-width: 72px;
 
 
635
  }
636
 
637
- .agentPanel {
638
- right: 14px;
639
- width: auto;
640
  }
641
 
642
- .agentReadout {
643
- grid-template-columns: 1fr;
 
 
644
  }
645
 
646
- .controls {
647
- left: 14px;
648
- right: auto;
649
- bottom: 378px;
650
  }
651
 
652
- .godConsole {
653
- top: 118px;
654
- left: 14px;
655
- right: 14px;
656
- width: auto;
657
  }
658
 
659
- .memoryList {
660
- max-height: 126px;
 
661
  }
662
  }
 
39
  height: 100% !important;
40
  }
41
 
42
+ /* ---------------------------------------------------------------- panels */
43
+
44
+ .scoreboard,
45
+ .overseerPanel,
46
+ .eventFeed,
47
+ .chaosDock,
48
+ .controls,
49
+ .agentPanel,
50
+ .errorToast,
51
+ .busyToast {
52
+ border: 1px solid rgb(255 255 255 / 18%);
53
+ background: rgb(18 28 23 / 80%);
54
+ box-shadow: 0 18px 60px rgb(0 0 0 / 22%);
55
+ backdrop-filter: blur(14px);
56
+ }
57
+
58
+ /* ------------------------------------------------------------ scoreboard */
59
+
60
+ .scoreboard {
61
  position: absolute;
62
  top: 14px;
63
+ left: 50%;
 
64
  z-index: 60;
65
+ width: min(460px, calc(100vw - 28px));
66
+ display: grid;
67
+ gap: 8px;
68
+ padding: 10px 14px 12px;
69
+ border-radius: 10px;
70
+ transform: translateX(-50%);
71
+ }
72
+
73
+ .scoreDuel {
74
+ display: grid;
75
+ grid-template-columns: 1fr auto 1fr;
76
+ align-items: center;
77
+ gap: 10px;
78
+ }
79
+
80
+ .scoreSide {
81
+ display: flex;
82
+ align-items: baseline;
83
+ gap: 10px;
84
+ }
85
+
86
+ .scoreOverseer {
87
+ justify-content: flex-start;
88
+ }
89
+
90
+ .scoreChaos {
91
+ justify-content: flex-end;
92
+ }
93
+
94
+ .scoreName {
95
+ font-size: 13px;
96
+ font-weight: 800;
97
+ letter-spacing: 0.06em;
98
+ }
99
+
100
+ .scoreOverseer .scoreName {
101
+ color: #9fd1ff;
102
+ }
103
+
104
+ .scoreChaos .scoreName {
105
+ color: #ffab8a;
106
+ }
107
+
108
+ .scoreValue {
109
+ font-size: 24px;
110
+ font-weight: 800;
111
+ line-height: 1;
112
+ }
113
+
114
+ .scoreOverseer .scoreValue {
115
+ color: #cfe7ff;
116
+ }
117
+
118
+ .scoreChaos .scoreValue {
119
+ color: #ffd2c2;
120
+ }
121
+
122
+ .scoreVs {
123
+ color: rgb(245 247 240 / 55%);
124
+ font-size: 12px;
125
+ font-weight: 700;
126
+ text-transform: uppercase;
127
+ }
128
+
129
+ .scoreMeta {
130
+ display: flex;
131
+ justify-content: space-between;
132
+ gap: 10px;
133
+ color: rgb(245 247 240 / 78%);
134
+ font-size: 12px;
135
+ }
136
+
137
+ .scoreActivity {
138
+ overflow: hidden;
139
+ text-overflow: ellipsis;
140
+ white-space: nowrap;
141
+ color: #c7e6bd;
142
+ }
143
+
144
+ .famineBadge {
145
+ padding: 2px 8px;
146
+ border: 1px solid rgb(240 195 78 / 50%);
147
+ border-radius: 5px;
148
+ background: rgb(240 195 78 / 14%);
149
+ color: #ffeebc;
150
+ font-size: 11px;
151
+ font-weight: 800;
152
+ letter-spacing: 0.04em;
153
+ animation: faminePulse 1.6s ease-in-out infinite;
154
+ }
155
+
156
+ @keyframes faminePulse {
157
+ 0%,
158
+ 100% {
159
+ opacity: 1;
160
+ }
161
+
162
+ 50% {
163
+ opacity: 0.55;
164
+ }
165
+ }
166
+
167
+ .winProgress {
168
+ display: grid;
169
+ gap: 5px;
170
+ }
171
+
172
+ .winRow {
173
+ display: grid;
174
+ grid-template-columns: 158px 1fr;
175
+ align-items: center;
176
+ gap: 8px;
177
+ }
178
+
179
+ .winLabel {
180
+ font-size: 11px;
181
+ color: rgb(245 247 240 / 82%);
182
+ white-space: nowrap;
183
+ }
184
+
185
+ .winBar {
186
+ height: 8px;
187
+ border-radius: 5px;
188
+ background: rgb(255 255 255 / 12%);
189
+ overflow: hidden;
190
+ }
191
+
192
+ .winFill {
193
+ display: block;
194
+ height: 100%;
195
+ border-radius: 5px;
196
+ transition: width 320ms ease;
197
+ }
198
+
199
+ .winFillPopulation {
200
+ background: linear-gradient(90deg, #6fd96a, #b8f08a);
201
+ }
202
+
203
+ .winFillSurvival {
204
+ background: linear-gradient(90deg, #5b96f0, #9fd1ff);
205
+ }
206
+
207
+ .modelPills {
208
+ display: flex;
209
+ gap: 6px;
210
+ justify-content: center;
211
+ }
212
+
213
+ .modelPill {
214
+ padding: 4px 8px;
215
+ border-radius: 5px;
216
+ background: rgb(255 255 255 / 9%);
217
+ font-size: 10px;
218
+ }
219
+
220
+ .modelStatusReady {
221
+ color: #d9f4cf;
222
+ }
223
+
224
+ .modelStatusWarmup,
225
+ .modelStatusUnknown {
226
+ color: #fff3a1;
227
+ }
228
+
229
+ .modelStatusError {
230
+ color: #ffd3c8;
231
+ }
232
+
233
+ .scoreboard .tooltipAnchor[data-tooltip]::before {
234
+ top: calc(100% + 2px);
235
+ bottom: auto;
236
+ border-top-color: transparent;
237
+ border-bottom-color: rgb(10 17 14 / 96%);
238
+ }
239
+
240
+ .scoreboard .tooltipAnchor[data-tooltip]::after {
241
+ top: calc(100% + 14px);
242
+ bottom: auto;
243
+ }
244
+
245
+ /* ------------------------------------------------------------ side panels */
246
+
247
+ .sidePanelHeader {
248
  display: flex;
249
  align-items: center;
250
  justify-content: space-between;
251
+ gap: 10px;
252
+ padding: 11px 12px 9px;
253
+ border-bottom: 1px solid rgb(255 255 255 / 14%);
254
  }
255
 
256
+ .sidePanelTitle {
257
+ font-size: 13px;
258
+ font-weight: 800;
259
+ color: #fffce9;
260
  }
261
 
262
+ .sidePanelHint {
263
+ font-size: 11px;
264
+ color: rgb(245 247 240 / 55%);
 
 
 
 
 
 
 
 
265
  }
266
 
267
+ .eventEmpty {
268
+ margin: 0;
269
+ padding: 12px;
270
+ color: rgb(245 247 240 / 55%);
271
+ font-size: 12px;
272
+ }
273
+
274
+ /* ------------------------------------------------------------- event feed */
275
+
276
+ .eventFeed {
277
+ position: absolute;
278
+ top: 14px;
279
+ right: 14px;
280
+ bottom: 84px;
281
+ z-index: 30;
282
+ width: min(316px, calc(100vw - 28px));
283
+ display: flex;
284
+ flex-direction: column;
285
+ border-radius: 10px;
286
+ overflow: hidden;
287
+ }
288
+
289
+ .eventList {
290
+ flex: 1;
291
+ min-height: 0;
292
+ overflow-y: auto;
293
+ padding: 6px 8px 10px;
294
+ display: grid;
295
+ gap: 2px;
296
+ align-content: start;
297
+ scrollbar-width: thin;
298
+ }
299
+
300
+ .eventLine {
301
+ display: grid;
302
+ grid-template-columns: 34px 20px 1fr;
303
+ gap: 6px;
304
+ align-items: baseline;
305
+ padding: 4px 6px;
306
+ border-left: 3px solid transparent;
307
+ border-radius: 4px;
308
+ font-size: 12px;
309
+ line-height: 1.3;
310
+ }
311
+
312
+ .eventTick {
313
+ color: rgb(245 247 240 / 45%);
314
+ font-size: 10px;
315
+ font-variant-numeric: tabular-nums;
316
+ text-align: right;
317
+ }
318
+
319
+ .eventIcon {
320
+ text-align: center;
321
+ }
322
+
323
+ .eventText {
324
+ color: rgb(245 247 240 / 88%);
325
+ overflow-wrap: anywhere;
326
+ }
327
+
328
+ .severityGood {
329
+ border-left-color: #6fd96a;
330
+ background: rgb(111 217 106 / 8%);
331
+ }
332
+
333
+ .severityGood .eventText {
334
+ color: #d6f5cf;
335
+ }
336
+
337
+ .severityWarning {
338
+ border-left-color: #f0c34e;
339
+ background: rgb(240 195 78 / 8%);
340
+ }
341
+
342
+ .severityWarning .eventText {
343
+ color: #ffeebc;
344
+ }
345
+
346
+ .severityDanger {
347
+ border-left-color: #f06a4e;
348
+ background: rgb(240 106 78 / 10%);
349
+ }
350
+
351
+ .severityDanger .eventText {
352
+ color: #ffd3c8;
353
+ }
354
+
355
+ .severityNeutral {
356
+ border-left-color: rgb(255 255 255 / 18%);
357
+ }
358
+
359
+ /* --------------------------------------------------------- overseer panel */
360
+
361
+ .overseerPanel {
362
+ position: absolute;
363
+ top: 14px;
364
+ left: 14px;
365
+ z-index: 30;
366
+ width: min(316px, calc(100vw - 28px));
367
+ max-height: calc(100svh - 420px);
368
+ min-height: 180px;
369
+ display: flex;
370
+ flex-direction: column;
371
+ border-radius: 10px;
372
+ overflow: hidden;
373
+ }
374
+
375
+ .overseerMode {
376
+ padding: 3px 8px;
377
+ border-radius: 5px;
378
+ font-size: 10px;
379
+ font-weight: 800;
380
+ letter-spacing: 0.08em;
381
+ }
382
+
383
+ .overseerMode-off {
384
+ background: rgb(255 255 255 / 10%);
385
+ color: rgb(245 247 240 / 60%);
386
+ }
387
+
388
+ .overseerMode-advisor {
389
+ background: rgb(240 195 78 / 18%);
390
+ color: #ffeebc;
391
+ }
392
+
393
+ .overseerMode-autopilot {
394
+ background: rgb(91 150 240 / 22%);
395
+ color: #cfe7ff;
396
+ }
397
+
398
+ .overseerAlert {
399
+ margin: 8px 10px 0;
400
+ padding: 7px 10px;
401
+ border: 1px solid rgb(240 195 78 / 45%);
402
+ border-radius: 7px;
403
+ background: rgb(240 195 78 / 12%);
404
+ color: #ffeebc;
405
+ font-size: 12px;
406
+ font-weight: 700;
407
+ }
408
+
409
+ .overseerLog {
410
+ flex: 1;
411
+ min-height: 0;
412
+ overflow-y: auto;
413
+ padding: 8px 10px 10px;
414
+ display: grid;
415
+ gap: 8px;
416
+ align-content: start;
417
+ scrollbar-width: thin;
418
+ }
419
+
420
+ .overseerEntry {
421
+ display: grid;
422
+ gap: 5px;
423
+ padding: 8px 10px;
424
+ border: 1px solid rgb(159 209 255 / 14%);
425
+ border-radius: 8px;
426
+ background: rgb(91 150 240 / 7%);
427
+ }
428
+
429
+ .overseerEntryHead {
430
+ display: flex;
431
  align-items: center;
432
+ gap: 8px;
433
+ }
434
+
435
+ .overseerEntryTick {
436
+ color: #9fd1ff;
437
+ font-size: 10px;
438
+ font-weight: 800;
439
+ font-variant-numeric: tabular-nums;
440
+ }
441
+
442
+ .overseerEntryAlert {
443
+ color: #ffeebc;
444
+ font-size: 10px;
445
+ font-weight: 700;
446
+ overflow: hidden;
447
+ text-overflow: ellipsis;
448
+ white-space: nowrap;
449
+ }
450
+
451
+ .overseerThought {
452
+ margin: 0;
453
+ color: rgb(245 247 240 / 90%);
454
+ font-size: 12px;
455
+ font-style: italic;
456
+ line-height: 1.4;
457
+ }
458
+
459
+ .overseerDirectives {
460
+ margin: 0;
461
+ padding: 0;
462
+ display: grid;
463
+ gap: 3px;
464
+ list-style: none;
465
+ }
466
+
467
+ .overseerDirectives li {
468
+ display: flex;
469
+ align-items: baseline;
470
+ gap: 6px;
471
+ font-size: 11px;
472
+ line-height: 1.35;
473
+ }
474
+
475
+ .directiveTarget {
476
+ flex-shrink: 0;
477
+ color: #9fd1ff;
478
+ font-weight: 700;
479
+ font-family: ui-monospace, monospace;
480
+ font-size: 10px;
481
+ }
482
+
483
+ .directiveText {
484
+ color: rgb(245 247 240 / 82%);
485
+ }
486
+
487
+ .directiveBadge {
488
+ flex-shrink: 0;
489
+ padding: 1px 5px;
490
+ border-radius: 4px;
491
+ background: rgb(240 195 78 / 18%);
492
+ color: #ffeebc;
493
+ font-size: 9px;
494
+ font-weight: 700;
495
+ }
496
+
497
+ .directiveSkipped .directiveText {
498
+ color: rgb(245 247 240 / 55%);
499
+ }
500
+
501
+ /* ------------------------------------------------------------- chaos dock */
502
+
503
+ .chaosDock {
504
+ position: absolute;
505
+ bottom: 18px;
506
+ left: 50%;
507
+ z-index: 35;
508
+ width: min(520px, calc(100vw - 28px));
509
+ display: grid;
510
+ gap: 8px;
511
+ padding: 10px 12px 11px;
512
+ border-color: rgb(240 106 78 / 35%);
513
+ border-radius: 10px;
514
+ transform: translateX(-50%);
515
+ }
516
+
517
+ .chaosHeader {
518
+ display: flex;
519
+ align-items: baseline;
520
+ gap: 10px;
521
+ }
522
+
523
+ .chaosTitle {
524
+ color: #ffab8a;
525
+ font-size: 13px;
526
+ font-weight: 800;
527
+ letter-spacing: 0.06em;
528
+ }
529
+
530
+ .chaosSubtitle {
531
+ color: rgb(245 247 240 / 50%);
532
+ font-size: 11px;
533
+ }
534
+
535
+ .chaosButtons {
536
+ display: grid;
537
+ grid-template-columns: repeat(4, 1fr);
538
+ gap: 7px;
539
+ }
540
+
541
+ .chaosButton {
542
+ position: relative;
543
+ display: grid;
544
+ justify-items: center;
545
+ gap: 3px;
546
+ padding: 8px 4px 7px;
547
+ border: 1px solid rgb(240 106 78 / 40%);
548
  border-radius: 8px;
549
+ background: rgb(240 106 78 / 12%);
550
+ color: #ffd3c8;
551
+ cursor: pointer;
552
+ transition: background 120ms ease;
553
+ }
554
+
555
+ .chaosButton:hover:not(:disabled) {
556
+ background: rgb(240 106 78 / 26%);
557
+ }
558
+
559
+ .chaosButton:disabled {
560
+ cursor: not-allowed;
561
+ opacity: 0.55;
562
+ }
563
+
564
+ .chaosButton.onCooldown {
565
+ border-style: dashed;
566
+ }
567
+
568
+ .chaosIcon {
569
+ font-size: 16px;
570
+ line-height: 1;
571
+ }
572
+
573
+ .chaosLabel {
574
+ font-size: 10px;
575
  font-weight: 700;
576
+ white-space: nowrap;
577
+ }
578
+
579
+ .chaosCooldown {
580
+ position: absolute;
581
+ top: -7px;
582
+ right: -5px;
583
+ min-width: 20px;
584
+ padding: 2px 5px;
585
+ border-radius: 9px;
586
+ background: #3a2420;
587
+ border: 1px solid rgb(240 106 78 / 55%);
588
+ color: #ffab8a;
589
+ font-size: 10px;
590
+ font-weight: 800;
591
+ text-align: center;
592
+ }
593
+
594
+ .chaosCommandRow {
595
+ display: grid;
596
+ grid-template-columns: minmax(0, 1fr) 36px;
597
+ gap: 7px;
598
+ }
599
+
600
+ .chaosCommandRow input,
601
+ .chaosCommandRow button {
602
+ height: 34px;
603
+ border: 1px solid rgb(255 255 255 / 14%);
604
+ border-radius: 7px;
605
+ color: #fffce9;
606
+ background: rgb(255 255 255 / 10%);
607
  }
608
 
609
+ .chaosCommandRow input {
610
+ min-width: 0;
611
+ padding: 0 11px;
612
+ font: inherit;
613
+ font-size: 12px;
 
 
614
  }
615
 
616
+ .chaosCommandRow input::placeholder {
617
+ color: rgb(245 247 240 / 42%);
 
 
 
 
 
 
 
 
 
618
  }
619
 
620
+ .chaosCommandRow button {
621
+ display: grid;
622
+ place-items: center;
623
+ cursor: pointer;
624
  }
625
 
626
+ .chaosCommandRow button:disabled {
627
+ cursor: wait;
628
+ opacity: 0.68;
629
  }
630
 
631
+ .chaosCommandRow button:hover:not(:disabled) {
632
+ background: rgb(255 255 255 / 18%);
 
633
  }
634
 
635
+ .chaosMessage {
636
+ margin: 0;
637
+ color: rgb(245 247 240 / 76%);
638
+ font-size: 11px;
639
+ line-height: 1.35;
640
  }
641
 
642
+ /* --------------------------------------------------------------- controls */
643
+
644
  .controls {
645
  position: absolute;
646
  right: 14px;
 
684
 
685
  .controls button:focus-visible,
686
  .npcLabel button:focus-visible,
687
+ .chaosCommandRow input:focus-visible,
688
+ .chaosCommandRow button:focus-visible,
689
+ .chaosButton:focus-visible {
690
  outline: 2px solid #f8df6b;
691
  outline-offset: 2px;
692
  }
693
 
694
+ /* -------------------------------------------------------------- agent panel */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
695
 
696
  .agentPanel {
697
  position: absolute;
698
  left: 14px;
699
  bottom: 18px;
700
  z-index: 30;
701
+ width: min(316px, calc(100vw - 108px));
702
  border-radius: 8px;
703
  overflow: visible;
704
  }
 
786
  }
787
 
788
  .memoryList {
789
+ max-height: 148px;
790
  overflow-y: auto;
791
  padding: 9px 10px 10px;
792
  }
 
826
  color: rgb(245 247 240 / 72%);
827
  }
828
 
829
+ /* ----------------------------------------------------------------- tooltips */
830
+
831
  .tooltipAnchor {
832
  position: relative;
833
  min-width: 0;
 
860
  .tooltipAnchor[data-tooltip]::before,
861
  .tooltipAnchor[data-tooltip]::after,
862
  .memoryItem p[data-tooltip]::before,
863
+ .memoryItem p[data-tooltip]::after {
 
 
864
  position: absolute;
865
  left: 50%;
866
  z-index: 80;
 
874
  }
875
 
876
  .tooltipAnchor[data-tooltip]::before,
877
+ .memoryItem p[data-tooltip]::before {
 
878
  content: "";
879
  border: 6px solid transparent;
880
  }
881
 
882
  .tooltipAnchor[data-tooltip]::after,
883
+ .memoryItem p[data-tooltip]::after {
 
884
  content: attr(data-tooltip);
885
  width: max-content;
886
  max-width: min(320px, calc(100vw - 28px));
 
904
  .tooltipAnchor[data-tooltip]:focus-visible::before,
905
  .tooltipAnchor[data-tooltip]:focus-visible::after,
906
  .memoryItem p[data-tooltip]:hover::before,
907
+ .memoryItem p[data-tooltip]:hover::after {
 
 
 
 
908
  opacity: 1;
909
  visibility: visible;
910
  }
911
 
912
  .tooltipAnchor[data-tooltip]::before,
913
+ .memoryItem p[data-tooltip]::before {
 
914
  bottom: calc(100% + 2px);
915
  border-top-color: rgb(10 17 14 / 96%);
916
  transform: translate(-50%, 4px);
917
  }
918
 
919
  .tooltipAnchor[data-tooltip]::after,
920
+ .memoryItem p[data-tooltip]::after {
 
921
  bottom: calc(100% + 14px);
922
  transform: translate(-50%, 4px);
923
  }
 
927
  .tooltipAnchor[data-tooltip]:focus-visible::before,
928
  .tooltipAnchor[data-tooltip]:focus-visible::after,
929
  .memoryItem p[data-tooltip]:hover::before,
930
+ .memoryItem p[data-tooltip]:hover::after {
 
 
 
 
931
  transform: translate(-50%, 0);
932
  }
933
 
934
+ /* ------------------------------------------------------------------ toasts */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
935
 
936
  .errorToast {
937
  position: absolute;
938
+ top: 142px;
939
  left: 50%;
940
+ z-index: 70;
941
  max-width: min(560px, calc(100vw - 28px));
942
  padding: 11px 14px;
943
  border-color: rgb(255 120 94 / 54%);
 
949
 
950
  .busyToast {
951
  position: absolute;
952
+ top: 142px;
953
  left: 50%;
954
+ z-index: 65;
955
  display: inline-flex;
956
  align-items: center;
957
  gap: 8px;
 
973
  }
974
  }
975
 
976
+ /* ------------------------------------------------------------ scene labels */
977
+
978
  .npcLabel {
979
  pointer-events: auto;
980
  }
981
 
982
+ .npcCard {
983
+ min-width: 96px;
984
+ max-width: 136px;
 
985
  display: grid;
986
+ gap: 3px;
987
+ padding: 6px 8px 7px;
988
+ border: 1.5px solid rgb(255 255 255 / 22%);
989
+ border-radius: 8px;
990
  color: #fffce9;
991
+ background: rgb(15 24 20 / 82%);
992
  box-shadow: 0 10px 28px rgb(0 0 0 / 24%);
993
  cursor: pointer;
994
+ text-align: left;
995
  }
996
 
997
+ .npcCard:hover {
998
+ background: rgb(18 30 24 / 92%);
 
999
  box-shadow: 0 12px 30px rgb(0 0 0 / 28%);
1000
  }
1001
 
1002
+ .npcCardDead {
1003
+ opacity: 0.62;
1004
+ filter: grayscale(0.7);
1005
+ }
1006
+
1007
+ .npcCardName {
1008
+ display: flex;
1009
+ align-items: center;
1010
+ gap: 5px;
1011
  min-width: 0;
1012
+ }
1013
+
1014
+ .npcCardName strong {
1015
  overflow: hidden;
1016
  text-overflow: ellipsis;
1017
  white-space: nowrap;
1018
+ font-size: 12px;
1019
+ line-height: 1.1;
1020
  }
1021
 
1022
+ .npcCardIcon {
1023
  font-size: 11px;
1024
+ line-height: 1;
1025
  }
1026
 
1027
+ .npcCardRole {
 
1028
  font-size: 9px;
1029
+ font-weight: 700;
1030
+ letter-spacing: 0.07em;
1031
+ line-height: 1;
1032
+ text-transform: uppercase;
1033
  }
1034
 
1035
+ .npcBar {
1036
+ display: block;
1037
+ width: 100%;
1038
+ height: 5px;
1039
+ border-radius: 3px;
1040
+ background: rgb(255 255 255 / 14%);
1041
  overflow: hidden;
1042
+ }
1043
+
1044
+ .npcBarFill {
1045
+ display: block;
1046
+ height: 100%;
1047
+ border-radius: 3px;
1048
+ transition: width 280ms ease;
1049
+ }
1050
+
1051
+ .npcAgeBar {
1052
+ height: 3px;
1053
+ }
1054
+
1055
+ .npcAgeFill {
1056
+ background: #d9b35a;
1057
+ }
1058
+
1059
+ .houseTag,
1060
+ .beastTag,
1061
+ .resourceTag {
1062
  pointer-events: none;
1063
+ display: grid;
1064
+ gap: 3px;
1065
+ padding: 5px 8px;
1066
+ border: 1px solid rgb(255 255 255 / 20%);
1067
+ border-radius: 7px;
1068
+ background: rgb(15 24 20 / 80%);
1069
+ color: #fffce9;
1070
+ white-space: nowrap;
1071
+ }
1072
+
1073
+ .houseTag {
1074
+ min-width: 88px;
1075
+ }
1076
+
1077
+ .houseTagBuilding {
1078
+ border-color: rgb(255 208 122 / 50%);
1079
+ }
1080
+
1081
+ .houseTagTitle,
1082
+ .beastTagTitle {
1083
+ font-size: 11px;
1084
+ font-weight: 700;
1085
+ line-height: 1.1;
1086
+ }
1087
+
1088
+ .houseHpBar,
1089
+ .beastHpBar {
1090
+ height: 4px;
1091
+ }
1092
+
1093
+ .beastTag {
1094
+ min-width: 80px;
1095
+ border-color: rgb(240 106 78 / 50%);
1096
+ }
1097
+
1098
+ .beastTagTitle {
1099
+ color: #ffd3c8;
1100
  }
1101
 
1102
+ .resourceTag {
1103
+ padding: 3px 7px;
1104
+ font-size: 11px;
1105
+ font-weight: 700;
1106
+ }
1107
+
1108
+ /* -------------------------------------------------------- game over overlay */
1109
+
1110
+ .gameOverOverlay {
1111
+ position: absolute;
1112
+ inset: 0;
1113
+ z-index: 100;
1114
+ display: grid;
1115
+ place-items: center;
1116
+ padding: 20px;
1117
+ background: rgb(8 13 10 / 78%);
1118
+ backdrop-filter: blur(6px);
1119
+ animation: overlayIn 400ms ease;
1120
+ }
1121
+
1122
+ @keyframes overlayIn {
1123
+ from {
1124
+ opacity: 0;
1125
  }
1126
 
1127
+ to {
1128
+ opacity: 1;
 
 
1129
  }
1130
+ }
1131
+
1132
+ .gameOverCard {
1133
+ width: min(620px, calc(100vw - 40px));
1134
+ max-height: calc(100svh - 60px);
1135
+ overflow-y: auto;
1136
+ display: grid;
1137
+ gap: 14px;
1138
+ padding: 26px 28px;
1139
+ border: 1px solid rgb(255 255 255 / 22%);
1140
+ border-radius: 14px;
1141
+ background: rgb(18 28 23 / 96%);
1142
+ box-shadow: 0 30px 90px rgb(0 0 0 / 50%);
1143
+ text-align: center;
1144
+ }
1145
+
1146
+ .gameOver-win .gameOverCard {
1147
+ border-color: rgb(111 217 106 / 45%);
1148
+ }
1149
+
1150
+ .gameOver-lose .gameOverCard {
1151
+ border-color: rgb(240 106 78 / 45%);
1152
+ }
1153
+
1154
+ .gameOverCard h1 {
1155
+ margin: 0;
1156
+ font-size: 26px;
1157
+ letter-spacing: 0.04em;
1158
+ }
1159
+
1160
+ .gameOver-win .gameOverCard h1 {
1161
+ color: #d6f5cf;
1162
+ }
1163
+
1164
+ .gameOver-lose .gameOverCard h1 {
1165
+ color: #ffd3c8;
1166
+ }
1167
+
1168
+ .gameOverSubtitle {
1169
+ margin: 0;
1170
+ color: rgb(245 247 240 / 78%);
1171
+ font-size: 14px;
1172
+ }
1173
+
1174
+ .gameOverScore {
1175
+ display: flex;
1176
+ justify-content: center;
1177
+ gap: 12px;
1178
+ font-size: 17px;
1179
+ font-weight: 800;
1180
+ }
1181
+
1182
+ .scoreOverseerText {
1183
+ color: #9fd1ff;
1184
+ }
1185
+
1186
+ .scoreChaosText {
1187
+ color: #ffab8a;
1188
+ }
1189
+
1190
+ .scoreDivider {
1191
+ color: rgb(245 247 240 / 40%);
1192
+ }
1193
+
1194
+ .gameOverStats {
1195
+ display: grid;
1196
+ grid-template-columns: repeat(3, 1fr);
1197
+ gap: 8px;
1198
+ }
1199
+
1200
+ .gameOverStat {
1201
+ display: grid;
1202
+ gap: 3px;
1203
+ padding: 10px 6px;
1204
+ border-radius: 8px;
1205
+ background: rgb(255 255 255 / 7%);
1206
+ }
1207
+
1208
+ .gameOverStatValue {
1209
+ font-size: 17px;
1210
+ font-weight: 800;
1211
+ }
1212
+
1213
+ .gameOverStatLabel {
1214
+ color: rgb(245 247 240 / 62%);
1215
+ font-size: 11px;
1216
+ text-transform: uppercase;
1217
+ letter-spacing: 0.05em;
1218
+ }
1219
+
1220
+ .gameOverCauses {
1221
+ margin: 0;
1222
+ color: rgb(245 247 240 / 68%);
1223
+ font-size: 12px;
1224
+ }
1225
+
1226
+ .gameOverStory {
1227
+ display: grid;
1228
+ gap: 7px;
1229
+ text-align: left;
1230
+ }
1231
+
1232
+ .gameOverStory h2 {
1233
+ margin: 0;
1234
+ font-size: 13px;
1235
+ color: rgb(245 247 240 / 80%);
1236
+ text-transform: uppercase;
1237
+ letter-spacing: 0.06em;
1238
+ }
1239
+
1240
+ .gameOverStoryList {
1241
+ display: grid;
1242
+ gap: 2px;
1243
+ max-height: 220px;
1244
+ overflow-y: auto;
1245
+ padding: 6px;
1246
+ border-radius: 8px;
1247
+ background: rgb(0 0 0 / 22%);
1248
+ scrollbar-width: thin;
1249
+ }
1250
+
1251
+ .gameOverDismiss {
1252
+ justify-self: center;
1253
+ padding: 10px 22px;
1254
+ border: 1px solid rgb(255 255 255 / 26%);
1255
+ border-radius: 8px;
1256
+ background: rgb(255 255 255 / 10%);
1257
+ color: #fffce9;
1258
+ font-size: 13px;
1259
+ font-weight: 700;
1260
+ cursor: pointer;
1261
+ }
1262
+
1263
+ .gameOverDismiss:hover {
1264
+ background: rgb(255 255 255 / 18%);
1265
+ }
1266
 
1267
+ /* ---------------------------------------------------------------- responsive */
1268
+
1269
+ @media (max-width: 1180px) {
1270
+ .overseerPanel {
1271
+ width: min(262px, calc(100vw - 28px));
 
 
1272
  }
1273
 
1274
+ .eventFeed {
1275
+ width: min(262px, calc(100vw - 28px));
 
 
1276
  }
1277
+ }
1278
 
1279
+ @media (max-width: 860px) {
1280
+ .scoreboard {
1281
+ top: 8px;
1282
+ width: min(400px, calc(100vw - 16px));
1283
  }
1284
 
1285
+ .overseerPanel {
1286
+ display: none;
 
1287
  }
1288
 
1289
+ .eventFeed {
1290
+ top: auto;
1291
+ bottom: 200px;
1292
+ max-height: 32svh;
1293
  }
1294
 
1295
+ .agentPanel {
1296
+ display: none;
 
 
1297
  }
1298
 
1299
+ .chaosDock {
1300
+ width: calc(100vw - 16px);
 
 
 
1301
  }
1302
 
1303
+ .controls {
1304
+ right: 8px;
1305
+ bottom: 200px;
1306
  }
1307
  }
frontend/src/types.ts CHANGED
@@ -40,7 +40,13 @@ export type EntitySnapshot = {
40
  is_alive: boolean;
41
  hunger?: number;
42
  fear?: number;
 
 
 
 
 
43
  goal?: string;
 
44
  inventory?: InventorySnapshot;
45
  memory_count: number;
46
  memory_summary: string | null;
@@ -89,6 +95,19 @@ export type BeastSnapshot = {
89
  max_health: number;
90
  state: "hunting" | "attacking" | "retreating" | "dead" | string;
91
  target_npc_id: string | null;
 
 
 
 
 
 
 
 
 
 
 
 
 
92
  };
93
 
94
  export type WorldEventSnapshot = {
@@ -107,13 +126,66 @@ export type WorldSnapshot = {
107
  last_tick_source: string;
108
  tick_in_progress: boolean;
109
  pending_tick: number | null;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
  models?: ModelStatusSnapshot[];
111
  };
112
  terrain: TerrainSnapshot;
113
  entities: EntitySnapshot[];
114
  resource_nodes?: ResourceNodeSnapshot[];
115
  beasts?: BeastSnapshot[];
 
116
  active_events?: WorldEventSnapshot[];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  };
118
 
119
  export type ModelStatusSnapshot = {
 
40
  is_alive: boolean;
41
  hunger?: number;
42
  fear?: number;
43
+ safety?: number;
44
+ age?: number;
45
+ max_age?: number;
46
+ reproduction_cooldown?: number;
47
+ importance?: number;
48
  goal?: string;
49
+ home_house_id?: string | null;
50
  inventory?: InventorySnapshot;
51
  memory_count: number;
52
  memory_summary: string | null;
 
95
  max_health: number;
96
  state: "hunting" | "attacking" | "retreating" | "dead" | string;
97
  target_npc_id: string | null;
98
+ target_house_id?: string | null;
99
+ };
100
+
101
+ export type HouseSnapshot = {
102
+ id: string;
103
+ position: Vec3;
104
+ owner_ids: string[];
105
+ hp: number;
106
+ max_hp: number;
107
+ state: "under_construction" | "completed" | string;
108
+ build_progress: number;
109
+ capacity: number;
110
+ occupant_ids: string[];
111
  };
112
 
113
  export type WorldEventSnapshot = {
 
126
  last_tick_source: string;
127
  tick_in_progress: boolean;
128
  pending_tick: number | null;
129
+ game_status?: "running" | "win_population" | "win_survival" | "lose" | string;
130
+ population?: number;
131
+ population_cap?: number;
132
+ peak_population?: number;
133
+ scoreboard?: {
134
+ overseer: number;
135
+ chaos: number;
136
+ };
137
+ stats?: GameStatsSnapshot;
138
+ famine_until?: number;
139
+ chaos_tools?: ChaosToolSnapshot[];
140
+ overseer?: {
141
+ mode: "off" | "advisor" | "autopilot" | string;
142
+ cycle_ticks: number;
143
+ last_tick: number | null;
144
+ status: string;
145
+ thoughts: string | null;
146
+ priority_alert: string | null;
147
+ directives: OverseerDirectiveSnapshot[];
148
+ };
149
  models?: ModelStatusSnapshot[];
150
  };
151
  terrain: TerrainSnapshot;
152
  entities: EntitySnapshot[];
153
  resource_nodes?: ResourceNodeSnapshot[];
154
  beasts?: BeastSnapshot[];
155
+ houses?: HouseSnapshot[];
156
  active_events?: WorldEventSnapshot[];
157
+ event_log?: GameLogEventSnapshot[];
158
+ };
159
+
160
+ export type GameStatsSnapshot = {
161
+ total_births: number;
162
+ deaths_by_cause: Record<string, number>;
163
+ houses_built: number;
164
+ beasts_killed: number;
165
+ };
166
+
167
+ export type ChaosToolSnapshot = {
168
+ action: string;
169
+ label: string;
170
+ cooldown_ticks: number;
171
+ remaining_ticks: number;
172
+ };
173
+
174
+ export type GameLogEventSnapshot = {
175
+ tick: number;
176
+ type: string;
177
+ actor_id: string | null;
178
+ target_id: string | null;
179
+ object_id: string | null;
180
+ summary: string;
181
+ severity: "good" | "neutral" | "warning" | "danger" | string;
182
+ };
183
+
184
+ export type OverseerDirectiveSnapshot = {
185
+ npc_id?: string;
186
+ directive?: string;
187
+ applied?: boolean;
188
+ action?: string | null;
189
  };
190
 
191
  export type ModelStatusSnapshot = {
playtest_report.md ADDED
@@ -0,0 +1,656 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Playtest Report
2
+
3
+ ## Run
4
+
5
+ - Ticks requested: 400
6
+ - Final tick: 400
7
+ - Simulator: deterministic
8
+ - Overseer: mock autopilot
9
+ - Elapsed seconds: 0.29
10
+ - Game status: running
11
+ - Population: final 4, peak 10, min 4
12
+ - Births: 0
13
+ - Deaths by cause: {"beast": 1, "old_age": 5}
14
+ - Houses built: 4
15
+ - Beasts killed: 8
16
+ - Score: Overseer 24, Chaos 15
17
+
18
+ ## Verdict
19
+
20
+ overseer run survived to 400 with population 4
21
+
22
+ ## Core Chains
23
+
24
+ - beast attack -> fear -> help_request -> guard response -> trust change: yes; ticks={"beast_attack": 4, "fear": 5, "guard_response": 5, "help_request": 5, "trust_change": 34}
25
+ - tick 4: gather: Ada gathered wood
26
+ - tick 4: npc_attack: Dima attacked beast_1 for 15
27
+ - tick 4: gather: Hana gathered herbs
28
+ - tick 4: transfer: Ivan gave 1 food to Hana
29
+ - tick 4: npc_attack: Juno attacked beast_1 for 15
30
+ - tick 4: beast_attack: beast_1 attacked Dima
31
+ - tick 5: npc_attack: Dima attacked beast_1 for 15
32
+ - tick 5: npc_attack: Elena attacked beast_1 for 15
33
+ - hunger -> gather/steal: yes; ticks={"gather": 42, "hunger": 40, "steal": null}
34
+ - tick 40: directive_issued: Overseer applied npc-004: attack the nearest beast
35
+ - tick 40: directive_issued: Overseer applied npc-006: attack beast_1 even though this should be validated
36
+ - tick 40: directive_issued: Overseer applied npc-007: go home
37
+ - tick 42: gather: Ada gathered food
38
+ - tick 42: transfer: Cora gave 1 food to Elena
39
+ - tick 42: consume: Gita ate food
40
+ - tick 42: npc-001 gatherer gather: gathering food
41
+ - tick 42: trust npc-003->npc-005 0.45->0.65
42
+ - build -> reproduce: no; ticks={"build_completed": 21, "npc_born": null}
43
+ - tick 21: build_completed: Farid completed house_002
44
+
45
+ ## Event Counts
46
+
47
+ ```json
48
+ {
49
+ "beast_attack": 14,
50
+ "beast_killed": 8,
51
+ "beast_spawned": 3,
52
+ "build_completed": 4,
53
+ "build_started": 4,
54
+ "chaos_event": 7,
55
+ "consume": 63,
56
+ "directive_issued": 147,
57
+ "gather": 239,
58
+ "heal": 2,
59
+ "house_damaged": 1,
60
+ "npc_attack": 48,
61
+ "npc_died": 6,
62
+ "overseer_thoughts": 50,
63
+ "transfer": 140
64
+ }
65
+ ```
66
+
67
+ ## Action Counts
68
+
69
+ ```json
70
+ {
71
+ "attack": 146,
72
+ "build": 85,
73
+ "communicate": 577,
74
+ "consume": 63,
75
+ "defend": 48,
76
+ "flee": 91,
77
+ "gather": 239,
78
+ "go_home": 483,
79
+ "heal": 2,
80
+ "move_to": 269,
81
+ "move_to_resource": 744,
82
+ "patrol": 689,
83
+ "transfer": 133
84
+ }
85
+ ```
86
+
87
+ ## Timeline
88
+
89
+ - tick 2: transfer actor=npc-003 target=npc-009: Cora gave 1 food to Ivan
90
+ - tick 4: npc_attack actor=npc-004 target=beast_1: Dima attacked beast_1 for 15
91
+ - tick 4: transfer actor=npc-009 target=npc-008: Ivan gave 1 food to Hana
92
+ - tick 4: npc_attack actor=npc-010 target=beast_1: Juno attacked beast_1 for 15
93
+ - tick 4: beast_attack actor=beast_1 target=npc-004: beast_1 attacked Dima
94
+ - tick 5: npc_attack actor=npc-004 target=beast_1: Dima attacked beast_1 for 15
95
+ - tick 5: npc_attack actor=npc-005 target=beast_1: Elena attacked beast_1 for 15
96
+ - tick 5: beast_killed actor=npc-005 target=beast_1: Elena killed beast_1
97
+ - tick 8: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
98
+ - tick 8: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
99
+ - tick 8: directive_issued actor=overseer target=npc-007: Overseer applied npc-007: go home
100
+ - tick 11: build_started actor=npc-006 target=house_002: Farid started house_002
101
+ - tick 16: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
102
+ - tick 16: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
103
+ - tick 16: directive_issued actor=overseer target=npc-007: Overseer applied npc-007: go home
104
+ - tick 21: build_completed actor=npc-006 target=house_002: Farid completed house_002
105
+ - tick 22: transfer actor=npc-007 target=npc-008: Gita gave 1 food to Hana
106
+ - tick 24: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
107
+ - tick 24: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
108
+ - tick 24: directive_issued actor=overseer target=npc-007: Overseer applied npc-007: go home
109
+ - tick 25: transfer actor=npc-007 target=npc-002: Gita gave 1 wood to Boris
110
+ - tick 25: transfer actor=npc-008 target=npc-003: Hana gave 1 wood to Cora
111
+ - tick 26: transfer actor=npc-002 target=npc-006: Boris gave 1 wood to Farid
112
+ - tick 26: transfer actor=npc-007 target=npc-002: Gita gave 1 food to Boris
113
+ - tick 29: transfer actor=npc-008 target=npc-002: Hana gave 1 food to Boris
114
+ - tick 31: transfer actor=npc-002 target=npc-009: Boris gave 1 food to Ivan
115
+ - tick 32: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
116
+ - tick 32: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
117
+ - tick 32: directive_issued actor=overseer target=npc-007: Overseer applied npc-007: go home
118
+ - tick 33: transfer actor=npc-008 target=npc-003: Hana gave 1 food to Cora
119
+ - tick 34: transfer actor=npc-007 target=npc-004: Gita gave 1 food to Dima
120
+ - tick 40: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
121
+ - tick 40: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
122
+ - tick 40: directive_issued actor=overseer target=npc-007: Overseer applied npc-007: go home
123
+ - tick 42: transfer actor=npc-003 target=npc-005: Cora gave 1 food to Elena
124
+ - tick 42: consume actor=npc-007: Gita ate food
125
+ - tick 43: consume actor=npc-009: Ivan ate food
126
+ - tick 44: transfer actor=npc-001 target=npc-002: Ada gave 1 food to Boris
127
+ - tick 44: consume actor=npc-003: Cora ate food
128
+ - tick 45: transfer actor=npc-008 target=npc-009: Hana gave 1 food to Ivan
129
+ - tick 47: transfer actor=npc-002 target=npc-009: Boris gave 1 food to Ivan
130
+ - tick 47: consume actor=npc-004: Dima ate food
131
+ - tick 48: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
132
+ - tick 48: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
133
+ - tick 48: directive_issued actor=overseer target=npc-008: Overseer applied npc-008: go home
134
+ - tick 48: consume actor=npc-010: Juno ate food
135
+ - tick 50: consume actor=npc-005: Elena ate food
136
+ - tick 50: consume actor=npc-008: Hana ate food
137
+ - tick 50: chaos_event actor=beast_2: Chaos unleashed beast_2 at the edge of the map
138
+ - tick 51: transfer actor=npc-002 target=npc-005: Boris gave 1 food to Elena
139
+ - tick 51: transfer actor=npc-007 target=npc-006: Gita gave 1 wood to Farid
140
+ - tick 52: consume actor=npc-002: Boris ate food
141
+ - tick 52: transfer actor=npc-007 target=npc-006: Gita gave 1 wood to Farid
142
+ - tick 53: transfer actor=npc-007 target=npc-006: Gita gave 1 wood to Farid
143
+ - tick 54: transfer actor=npc-007 target=npc-006: Gita gave 1 wood to Farid
144
+ - tick 56: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
145
+ - tick 56: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
146
+ - tick 56: directive_issued actor=overseer target=npc-001: Overseer applied npc-001: go home
147
+ - tick 56: npc_attack actor=npc-004 target=beast_2: Dima attacked beast_2 for 15
148
+ - tick 56: transfer actor=npc-007 target=npc-006: Gita gave 1 wood to Farid
149
+ - tick 56: transfer actor=npc-009 target=npc-003: Ivan gave 1 food to Cora
150
+ - tick 56: beast_attack actor=beast_2 target=npc-004: beast_2 attacked Dima
151
+ - tick 57: npc_attack actor=npc-004 target=beast_2: Dima attacked beast_2 for 15
152
+ - tick 57: beast_attack actor=beast_2 target=npc-004: beast_2 attacked Dima
153
+ - tick 58: npc_attack actor=npc-004 target=beast_2: Dima attacked beast_2 for 15
154
+ - tick 58: beast_attack actor=beast_2 target=npc-004: beast_2 attacked Dima
155
+ - tick 59: transfer actor=npc-001 target=npc-006: Ada gave 1 wood to Farid
156
+ - tick 59: npc_attack actor=npc-004 target=beast_2: Dima attacked beast_2 for 15
157
+ - tick 59: transfer actor=npc-001 target=npc-006: Ada gave 1 food to Farid
158
+ - tick 59: beast_attack actor=beast_2 target=npc-004: beast_2 attacked Dima
159
+ - tick 60: transfer actor=npc-001 target=npc-006: Ada gave 1 wood to Farid
160
+ - tick 60: npc_attack actor=npc-004 target=beast_2: Dima attacked beast_2 for 15
161
+ - tick 60: npc_attack actor=npc-005 target=beast_2: Elena attacked beast_2 for 15
162
+ - tick 60: consume actor=npc-006: Farid ate food
163
+ - tick 60: transfer actor=npc-009 target=npc-003: Ivan gave 1 food to Cora
164
+ - tick 60: npc_attack actor=npc-010 target=beast_2: Juno attacked beast_2 for 15
165
+ - tick 61: transfer actor=npc-001 target=npc-006: Ada gave 1 wood to Farid
166
+ - tick 61: npc_attack actor=npc-004 target=beast_2: Dima attacked beast_2 for 15
167
+ - tick 61: beast_killed actor=npc-004 target=beast_2: Dima killed beast_2
168
+ - tick 62: transfer actor=npc-003 target=npc-009: Cora gave 1 food to Ivan
169
+ - tick 62: heal actor=npc-004: Dima healed with herbs
170
+ - tick 62: build_started actor=npc-006 target=house_003: Farid started house_003
171
+ - tick 64: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
172
+ - tick 64: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
173
+ - tick 64: directive_issued actor=overseer target=npc-001: Overseer applied npc-001: go home
174
+ - tick 64: transfer actor=npc-009 target=npc-003: Ivan gave 1 food to Cora
175
+ - tick 65: beast_spawned actor=beast_3: beast_3 appeared at the edge of the map
176
+ - tick 66: transfer actor=npc-003 target=npc-008: Cora gave 1 food to Hana
177
+ - tick 68: transfer actor=npc-001 target=npc-007: Ada gave 1 food to Gita
178
+ - tick 70: transfer actor=npc-001 target=npc-006: Ada gave 1 wood to Farid
179
+ - tick 70: transfer actor=npc-007 target=npc-002: Gita gave 1 food to Boris
180
+ - tick 71: beast_attack actor=beast_3 target=npc-004: beast_3 attacked Dima
181
+ - tick 72: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
182
+ - tick 72: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
183
+ - tick 72: directive_issued actor=overseer target=npc-001: Overseer applied npc-001: go home
184
+ - tick 72: npc_attack actor=npc-004 target=beast_3: Dima attacked beast_3 for 15
185
+ - tick 72: npc_attack actor=npc-005 target=beast_3: Elena attacked beast_3 for 15
186
+ - tick 72: beast_attack actor=beast_3 target=npc-010: beast_3 attacked Juno
187
+ - tick 73: npc_attack actor=npc-004 target=beast_3: Dima attacked beast_3 for 15
188
+ - tick 73: npc_attack actor=npc-005 target=beast_3: Elena attacked beast_3 for 15
189
+ - tick 73: beast_killed actor=npc-005 target=beast_3: Elena killed beast_3
190
+ - tick 76: transfer actor=npc-001 target=npc-003: Ada gave 1 food to Cora
191
+ - tick 77: consume actor=npc-001: Ada ate food
192
+ - tick 78: transfer actor=npc-003 target=npc-009: Cora gave 1 food to Ivan
193
+ - tick 78: transfer actor=npc-008 target=npc-006: Hana gave 1 wood to Farid
194
+ - tick 80: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
195
+ - tick 80: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
196
+ - tick 80: directive_issued actor=overseer target=npc-007: Overseer applied npc-007: go home
197
+ - tick 80: transfer actor=npc-008 target=npc-006: Hana gave 1 wood to Farid
198
+ - tick 82: build_completed actor=npc-006 target=house_003: Farid completed house_003
199
+ - tick 84: transfer actor=npc-009 target=npc-004: Ivan gave 1 food to Dima
200
+ - tick 88: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
201
+ - tick 88: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
202
+ - tick 88: directive_issued actor=overseer target=npc-007: Overseer applied npc-007: go home
203
+ - tick 88: build_started actor=npc-006 target=house_004: Farid started house_004
204
+ - tick 92: consume actor=npc-007: Gita ate food
205
+ - tick 93: consume actor=npc-009: Ivan ate food
206
+ - tick 94: consume actor=npc-003: Cora ate food
207
+ - tick 96: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
208
+ - tick 96: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
209
+ - tick 96: directive_issued actor=overseer target=npc-008: Overseer applied npc-008: go home
210
+ - tick 96: transfer actor=npc-001 target=npc-003: Ada gave 1 food to Cora
211
+ - tick 97: consume actor=npc-004: Dima ate food
212
+ - tick 98: consume actor=npc-010: Juno ate food
213
+ - tick 100: transfer actor=npc-001 target=npc-003: Ada gave 1 food to Cora
214
+ - tick 100: consume actor=npc-005: Elena ate food
215
+ - tick 100: consume actor=npc-008: Hana ate food
216
+ - tick 102: consume actor=npc-002: Boris ate food
217
+ - tick 104: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
218
+ - tick 104: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
219
+ - tick 104: directive_issued actor=overseer target=npc-001: Overseer applied npc-001: go home
220
+ - tick 108: transfer actor=npc-001 target=npc-002: Ada gave 1 food to Boris
221
+ - tick 110: transfer actor=npc-003 target=npc-009: Cora gave 1 food to Ivan
222
+ - tick 112: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
223
+ - tick 112: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
224
+ - tick 112: directive_issued actor=overseer target=npc-001: Overseer applied npc-001: go home
225
+ - tick 116: transfer actor=npc-001 target=npc-002: Ada gave 1 food to Boris
226
+ - tick 118: transfer actor=npc-003 target=npc-009: Cora gave 1 food to Ivan
227
+ - tick 120: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
228
+ - tick 120: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
229
+ - tick 120: directive_issued actor=overseer target=npc-001: Overseer applied npc-001: go home
230
+ - tick 120: chaos_event: Famine grips the land: 8 food nodes halved for 180 ticks
231
+ - tick 121: transfer actor=npc-008 target=npc-005: Hana gave 1 food to Elena
232
+ - tick 124: transfer actor=npc-009 target=npc-010: Ivan gave 1 food to Juno
233
+ - tick 126: transfer actor=npc-003 target=npc-006: Cora gave 1 wood to Farid
234
+ - tick 127: consume actor=npc-001: Ada ate food
235
+ - tick 127: transfer actor=npc-002 target=npc-010: Boris gave 1 food to Juno
236
+ - tick 128: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
237
+ - tick 128: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
238
+ - tick 128: directive_issued actor=overseer target=npc-007: Overseer applied npc-007: go home
239
+ - tick 129: transfer actor=npc-008 target=npc-010: Hana gave 1 food to Juno
240
+ - tick 131: transfer actor=npc-010 target=npc-006: Juno gave 1 food to Farid
241
+ - tick 132: consume actor=npc-006: Farid ate food
242
+ - tick 133: transfer actor=npc-008 target=npc-003: Hana gave 1 food to Cora
243
+ - tick 134: transfer actor=npc-003 target=npc-008: Cora gave 1 food to Hana
244
+ - tick 136: directive_issued actor=overseer target=npc-004: Overseer applied npc-004: attack the nearest beast
245
+ - tick 136: directive_issued actor=overseer target=npc-006: Overseer applied npc-006: attack beast_1 even though this should be validated
246
+ - tick 136: directive_issued actor=overseer target=npc-007: Overseer applied npc-007: go home
247
+ - tick 139: build_completed actor=npc-006 target=house_004: Farid completed house_004
248
+ - tick 140: transfer actor=npc-001 target=npc-005: Ada gave 1 food to Elena
249
+ - ... 287 additional major events omitted
250
+
251
+ ## Population Graph Data
252
+
253
+ ```csv
254
+ tick,population,living_beasts,completed_houses,total_food,avg_hunger,max_hunger,max_fear
255
+ 0,10,1,1,15,21.5,28.0,0.0
256
+ 1,10,1,1,18,22.3,28.8,0.0
257
+ 2,10,1,1,18,23.1,29.6,5.0
258
+ 3,10,1,1,18,23.9,30.4,15.0
259
+ 4,10,1,1,18,24.7,31.2,15.0
260
+ 5,10,0,1,18,25.5,32.0,60.0
261
+ 6,10,0,1,18,26.3,32.8,58.0
262
+ 7,10,0,1,18,27.1,33.6,56.0
263
+ 8,10,0,1,21,27.9,34.4,54.0
264
+ 9,10,0,1,24,28.7,35.2,52.0
265
+ 10,10,0,1,24,29.5,36.0,50.0
266
+ 11,10,0,1,24,30.3,36.8,48.0
267
+ 12,10,0,1,24,31.1,37.6,46.0
268
+ 13,10,0,1,24,31.9,38.4,44.0
269
+ 14,10,0,1,24,32.7,39.2,42.0
270
+ 15,10,0,1,26,33.5,40.0,40.0
271
+ 16,10,0,1,26,34.3,40.8,38.0
272
+ 17,10,0,1,27,35.1,41.6,36.0
273
+ 18,10,0,1,26,35.9,42.4,34.0
274
+ 19,10,0,1,26,36.7,43.2,32.0
275
+ 20,10,0,1,25,37.5,44.0,30.0
276
+ 21,10,0,2,25,38.3,44.8,28.0
277
+ 22,10,0,2,25,39.1,45.6,26.0
278
+ 23,10,0,2,25,39.9,46.4,24.0
279
+ 24,10,0,2,25,40.7,47.2,22.0
280
+ 25,10,0,2,26,41.5,48.0,20.0
281
+ 26,10,0,2,26,42.3,48.8,18.0
282
+ 27,10,0,2,26,43.1,49.6,16.0
283
+ 28,10,0,2,25,43.9,50.4,14.0
284
+ 29,10,0,2,28,44.7,51.2,12.0
285
+ 30,10,0,2,28,45.5,52.0,10.0
286
+ 31,10,0,2,28,46.3,52.8,8.0
287
+ 32,10,0,2,28,47.1,53.6,6.0
288
+ 33,10,0,2,30,47.9,54.4,4.0
289
+ 34,10,0,2,30,48.7,55.2,2.0
290
+ 35,10,0,2,29,49.5,56.0,0.0
291
+ 36,10,0,2,29,50.3,56.8,0.0
292
+ 37,10,0,2,29,51.1,57.6,0.0
293
+ 38,10,0,2,29,51.9,58.4,0.0
294
+ 39,10,0,2,29,52.7,59.2,0.0
295
+ 40,10,0,2,29,53.5,60.0,0.0
296
+ 41,10,0,2,31,54.3,60.8,0.0
297
+ 42,10,0,2,30,51.1,60.6,0.0
298
+ 43,10,0,2,30,47.9,60.4,0.0
299
+ 44,10,0,2,30,44.7,60.2,0.0
300
+ 45,10,0,2,30,45.5,61.0,0.0
301
+ 46,10,0,2,30,46.3,61.8,0.0
302
+ 47,10,0,2,30,43.1,62.6,0.0
303
+ 48,10,0,2,30,39.9,63.4,0.0
304
+ 49,10,0,2,31,40.7,64.2,0.0
305
+ 50,10,1,2,31,33.5,65.0,0.0
306
+ 51,10,1,2,30,34.3,65.8,0.0
307
+ 52,10,1,2,30,31.1,66.6,0.0
308
+ 53,10,1,2,30,31.9,67.4,0.0
309
+ 54,10,1,2,30,32.7,68.2,0.0
310
+ 55,10,1,2,30,33.5,69.0,0.0
311
+ 56,10,1,2,30,34.3,69.8,0.0
312
+ 57,10,1,2,35,35.1,70.6,60.0
313
+ 58,10,1,2,34,35.9,71.4,60.0
314
+ 59,10,1,2,34,36.7,72.2,60.0
315
+ 60,10,1,2,34,33.5,48.0,80.0
316
+ 61,10,0,2,34,34.3,48.8,80.0
317
+ 62,10,0,2,34,35.1,49.6,78.0
318
+ 63,10,0,2,34,35.9,50.4,76.0
319
+ 64,10,0,2,34,36.7,51.2,74.0
320
+ 65,10,0,2,36,37.5,52.0,72.0
321
+ 66,10,1,2,35,38.3,52.8,70.0
322
+ 67,10,1,2,35,39.1,53.6,75.0
323
+ 68,10,1,2,35,39.9,54.4,75.0
324
+ 69,10,1,2,35,40.7,55.2,75.0
325
+ 70,10,1,2,35,41.5,56.0,75.0
326
+ 71,10,1,2,35,42.3,56.8,80.0
327
+ 72,10,1,2,35,43.1,57.6,100.0
328
+ 73,10,0,2,36,43.9,58.4,100.0
329
+ 74,10,0,2,35,44.7,59.2,98.0
330
+ 75,10,0,2,34,45.5,60.0,96.0
331
+ 76,10,0,2,34,46.3,60.8,94.0
332
+ 77,10,0,2,34,43.1,49.6,92.0
333
+ 78,10,0,2,34,43.9,50.4,90.0
334
+ 79,10,0,2,34,44.7,51.2,88.0
335
+ 80,10,0,2,34,45.5,52.0,86.0
336
+ 81,10,0,2,36,46.3,52.8,84.0
337
+ 82,10,0,3,36,47.1,53.6,82.0
338
+ 83,10,0,3,36,47.9,54.4,80.0
339
+ 84,10,0,3,36,48.7,55.2,78.0
340
+ 85,10,0,3,36,49.5,56.0,76.0
341
+ 86,10,0,3,36,50.3,56.8,74.0
342
+ 87,10,0,3,36,51.1,57.6,72.0
343
+ 88,10,0,3,36,51.9,58.4,70.0
344
+ 89,10,0,3,38,52.7,59.2,68.0
345
+ 90,10,0,3,38,53.5,60.0,66.0
346
+ 91,10,0,3,37,54.3,60.8,64.0
347
+ 92,10,0,3,37,51.1,60.6,62.0
348
+ 93,10,0,3,37,47.9,60.4,60.0
349
+ 94,10,0,3,36,44.7,60.2,58.0
350
+ 95,10,0,3,34,45.5,61.0,56.0
351
+ 96,10,0,3,34,46.3,61.8,54.0
352
+ 97,10,0,3,36,43.1,62.6,52.0
353
+ 98,10,0,3,35,39.9,63.4,50.0
354
+ 99,10,0,3,34,40.7,64.2,48.0
355
+ 100,10,0,3,34,33.5,65.0,46.0
356
+ 101,10,0,3,34,34.3,65.8,44.0
357
+ 102,10,0,3,33,31.1,66.6,42.0
358
+ 103,10,0,3,33,31.9,67.4,40.0
359
+ 104,10,0,3,33,32.7,68.2,38.0
360
+ 105,10,0,3,35,33.5,69.0,36.0
361
+ 106,10,0,3,34,34.3,69.8,34.0
362
+ 107,10,0,3,34,35.1,70.6,32.0
363
+ 108,10,0,3,34,35.9,71.4,30.0
364
+ 109,10,0,3,34,36.7,72.2,28.0
365
+ 110,10,0,3,34,37.5,73.0,26.0
366
+ 111,10,0,3,34,38.3,73.8,24.0
367
+ 112,10,0,3,34,39.1,74.6,22.0
368
+ 113,10,0,3,36,39.9,75.4,20.0
369
+ 114,10,0,3,36,40.7,76.2,18.0
370
+ 115,10,0,3,36,41.5,77.0,16.0
371
+ 116,10,0,3,36,42.3,77.8,14.0
372
+ 117,10,0,3,35,43.1,78.6,12.0
373
+ 118,10,0,3,35,43.9,79.4,10.0
374
+ 119,10,0,3,35,44.7,80.2,8.0
375
+ 120,10,0,3,13,45.5,81.0,6.0
376
+ 121,10,0,3,15,46.3,81.8,4.0
377
+ 122,10,0,3,14,47.1,82.6,2.0
378
+ 123,10,0,3,14,47.9,83.4,0.0
379
+ 124,10,0,3,13,48.7,84.2,0.0
380
+ 125,10,0,3,12,49.5,85.0,0.0
381
+ 126,10,0,3,12,50.3,85.8,0.0
382
+ 127,10,0,3,12,47.1,86.6,0.0
383
+ 128,10,0,3,12,47.9,87.4,0.0
384
+ 129,10,0,3,14,48.7,88.2,0.0
385
+ 130,10,0,3,14,49.5,89.0,0.0
386
+ 131,10,0,3,14,50.3,89.8,0.0
387
+ 132,10,0,3,13,47.1,53.6,0.0
388
+ 133,10,0,3,13,47.9,54.4,0.0
389
+ 134,10,0,3,12,48.7,55.2,0.0
390
+ 135,10,0,3,12,49.5,56.0,0.0
391
+ 136,10,0,3,12,50.3,56.8,0.0
392
+ 137,10,0,3,14,51.1,57.6,0.0
393
+ 138,10,0,3,13,51.9,58.4,0.0
394
+ 139,10,0,4,13,52.7,59.2,0.0
395
+ 140,10,0,4,13,53.5,60.0,0.0
396
+ 141,10,0,4,12,54.3,60.8,0.0
397
+ 142,10,0,4,12,51.1,60.6,0.0
398
+ 143,10,0,4,12,47.9,60.4,0.0
399
+ 144,10,0,4,12,48.7,61.2,0.0
400
+ 145,10,0,4,14,45.5,61.0,0.0
401
+ 146,10,0,4,14,46.3,61.8,0.0
402
+ 147,10,1,4,14,43.1,62.6,0.0
403
+ 148,10,1,4,14,43.9,63.4,0.0
404
+ 149,10,1,4,14,44.7,64.2,0.0
405
+ 150,10,1,4,14,41.5,65.0,5.0
406
+ 151,10,1,4,14,42.3,65.8,10.0
407
+ 152,10,0,4,14,43.1,66.6,10.0
408
+ 153,10,0,4,16,31.9,67.4,8.0
409
+ 154,10,0,4,16,32.7,68.2,6.0
410
+ 155,10,0,4,16,33.5,69.0,4.0
411
+ 156,10,0,4,15,34.3,69.8,2.0
412
+ 157,10,0,4,14,35.1,70.6,0.0
413
+ 158,10,0,4,14,35.9,71.4,0.0
414
+ 159,10,0,4,14,36.7,72.2,0.0
415
+ 160,10,0,4,14,37.5,73.0,0.0
416
+ 161,10,0,4,15,38.3,73.8,0.0
417
+ 162,10,0,4,15,39.1,74.6,0.0
418
+ 163,10,0,4,15,35.9,50.4,0.0
419
+ 164,10,0,4,14,36.7,51.2,0.0
420
+ 165,10,0,4,14,37.5,52.0,0.0
421
+ 166,10,0,4,14,38.3,52.8,0.0
422
+ 167,10,0,4,13,39.1,53.6,0.0
423
+ 168,10,0,4,13,39.9,54.4,0.0
424
+ 169,10,0,4,15,40.7,55.2,0.0
425
+ 170,10,0,4,14,41.5,56.0,0.0
426
+ 171,10,0,4,13,42.3,56.8,0.0
427
+ 172,10,0,4,13,43.1,57.6,0.0
428
+ 173,10,0,4,13,43.9,58.4,0.0
429
+ 174,10,0,4,13,44.7,59.2,0.0
430
+ 175,10,0,4,13,45.5,60.0,0.0
431
+ 176,10,0,4,13,46.3,60.8,0.0
432
+ 177,10,0,4,15,43.1,49.6,0.0
433
+ 178,10,0,4,15,43.9,50.4,0.0
434
+ 179,10,0,4,15,44.7,51.2,0.0
435
+ 180,10,0,4,15,45.5,52.0,0.0
436
+ 181,10,0,4,15,46.3,52.8,0.0
437
+ 182,10,0,4,15,47.1,53.6,0.0
438
+ 183,10,0,5,15,47.9,54.4,0.0
439
+ 184,10,0,5,15,48.7,55.2,0.0
440
+ 185,10,0,5,16,49.5,56.0,0.0
441
+ 186,10,0,5,16,50.3,56.8,0.0
442
+ 187,10,0,5,16,51.1,57.6,0.0
443
+ 188,10,0,5,16,51.9,58.4,0.0
444
+ 189,10,0,5,15,52.7,59.2,0.0
445
+ 190,10,0,5,15,53.5,60.0,0.0
446
+ 191,10,0,5,14,54.3,60.8,0.0
447
+ 192,10,0,5,14,55.1,61.6,0.0
448
+ 193,10,0,5,16,47.9,60.4,0.0
449
+ 194,10,0,5,15,44.7,60.2,0.0
450
+ 195,10,0,5,14,45.5,61.0,0.0
451
+ 196,10,0,5,14,46.3,61.8,0.0
452
+ 197,10,0,5,14,47.1,62.6,0.0
453
+ 198,10,0,5,14,39.9,62.4,0.0
454
+ 199,10,0,5,14,40.7,63.2,0.0
455
+ 200,10,3,5,14,37.5,64.0,0.0
456
+ 201,10,3,5,15,34.3,64.8,0.0
457
+ 202,10,3,5,14,31.1,65.6,0.0
458
+ 203,10,3,5,14,31.9,66.4,5.0
459
+ 204,10,3,5,14,32.7,67.2,10.0
460
+ 205,10,3,5,14,33.5,68.0,10.0
461
+ 206,10,3,5,14,34.3,68.8,15.0
462
+ 207,10,2,5,14,35.1,69.6,20.0
463
+ 208,10,2,5,14,35.9,70.4,20.0
464
+ 209,10,2,5,15,36.7,71.2,20.0
465
+ 210,10,2,5,15,37.5,72.0,20.0
466
+ 211,10,2,5,15,38.3,72.8,65.0
467
+ 212,10,2,5,15,39.1,73.6,80.0
468
+ 213,10,1,5,15,39.9,74.4,100.0
469
+ 214,10,1,5,15,40.7,75.2,100.0
470
+ 215,9,1,5,16,42.22,76.0,100.0
471
+ 216,9,1,5,16,43.02,76.8,100.0
472
+ 217,9,0,5,18,43.82,77.6,100.0
473
+ 218,9,0,5,18,40.18,54.4,98.0
474
+ 219,9,0,5,18,40.98,55.2,96.0
475
+ 220,9,0,5,18,41.78,56.0,94.0
476
+ 221,9,0,5,18,42.58,56.8,92.0
477
+ 222,9,0,5,18,43.38,57.6,90.0
478
+ 223,9,0,5,18,44.18,58.4,88.0
479
+ 224,9,0,5,18,44.98,59.2,86.0
480
+ 225,9,0,5,19,45.78,60.0,84.0
481
+ 226,9,0,5,18,46.58,60.8,82.0
482
+ 227,9,0,5,17,42.93,49.6,80.0
483
+ 228,9,0,5,17,43.73,50.4,78.0
484
+ 229,9,1,5,17,44.53,51.2,78.0
485
+ 230,9,1,5,17,45.33,52.0,83.0
486
+ 231,9,1,5,17,46.13,52.8,88.0
487
+ 232,9,1,5,17,46.93,53.6,88.0
488
+ 233,9,1,5,19,47.73,54.4,88.0
489
+ 234,9,1,5,19,48.53,55.2,93.0
490
+ 235,9,1,5,19,49.33,56.0,93.0
491
+ 236,9,1,5,19,50.13,56.8,93.0
492
+ 237,9,1,5,19,50.93,57.6,93.0
493
+ 238,9,1,5,19,51.73,58.4,93.0
494
+ 239,9,1,5,19,52.53,59.2,93.0
495
+ 240,9,0,5,19,53.33,60.0,93.0
496
+ 241,9,0,5,21,54.13,60.8,91.0
497
+ 242,9,0,5,21,50.49,60.6,89.0
498
+ 243,9,0,5,21,46.84,60.4,87.0
499
+ 244,9,0,5,21,43.2,60.2,85.0
500
+ 245,9,0,5,21,44.0,61.0,83.0
501
+ 246,9,0,5,21,44.8,61.8,81.0
502
+ 247,9,0,5,21,41.16,62.6,79.0
503
+ 248,9,0,5,21,41.96,63.4,77.0
504
+ 249,9,0,5,21,42.76,64.2,75.0
505
+ 250,9,0,5,21,34.67,65.0,73.0
506
+ 251,9,0,5,21,35.47,65.8,71.0
507
+ 252,9,0,5,21,31.82,66.6,69.0
508
+ 253,9,0,5,21,32.62,67.4,67.0
509
+ 254,9,0,5,21,33.42,68.2,65.0
510
+ 255,9,0,5,21,34.22,69.0,63.0
511
+ 256,9,0,5,21,35.02,69.8,61.0
512
+ 257,9,0,5,21,35.82,70.6,59.0
513
+ 258,9,0,5,21,36.62,71.4,57.0
514
+ 259,9,0,5,21,37.42,72.2,55.0
515
+ 260,9,0,5,21,38.22,73.0,53.0
516
+ 261,9,0,5,20,39.02,73.8,51.0
517
+ 262,9,0,5,20,39.82,74.6,49.0
518
+ 263,9,0,5,20,40.62,75.4,47.0
519
+ 264,9,0,5,19,41.42,76.2,45.0
520
+ 265,9,0,5,20,42.22,77.0,43.0
521
+ 266,9,0,5,20,43.02,77.8,41.0
522
+ 267,9,0,5,19,43.82,78.6,39.0
523
+ 268,9,0,5,19,44.62,79.4,37.0
524
+ 269,9,0,5,19,45.42,80.2,35.0
525
+ 270,9,0,5,19,46.22,81.0,33.0
526
+ 271,9,0,5,19,47.02,81.8,31.0
527
+ 272,8,0,5,21,46.6,82.6,49.0
528
+ 273,8,0,5,23,47.4,83.4,47.0
529
+ 274,8,0,5,23,48.2,84.2,45.0
530
+ 275,8,0,5,23,49.0,85.0,43.0
531
+ 276,8,0,5,22,49.8,85.8,41.0
532
+ 277,8,0,5,22,50.6,86.6,39.0
533
+ 278,8,0,5,22,51.4,87.4,37.0
534
+ 279,8,0,5,21,52.2,88.2,35.0
535
+ 280,8,0,5,20,53.0,89.0,33.0
536
+ 281,8,0,5,22,53.8,89.8,31.0
537
+ 282,8,0,5,22,54.6,90.6,29.0
538
+ 283,8,0,5,21,55.4,91.4,27.0
539
+ 284,8,0,5,21,56.2,92.2,25.0
540
+ 285,8,0,5,21,57.0,93.0,23.0
541
+ 286,8,0,5,21,57.8,93.8,21.0
542
+ 287,8,0,5,21,58.6,94.6,19.0
543
+ 288,8,0,5,21,59.4,95.4,17.0
544
+ 289,8,0,5,22,60.2,96.2,15.0
545
+ 290,8,0,5,22,61.0,97.0,13.0
546
+ 291,8,0,5,20,61.8,97.8,11.0
547
+ 292,8,0,5,20,57.6,98.6,9.0
548
+ 293,8,0,5,20,53.4,99.4,7.0
549
+ 294,8,0,5,20,49.2,100.2,5.0
550
+ 295,8,0,5,20,50.0,101.0,3.0
551
+ 296,8,0,5,20,50.8,101.8,1.0
552
+ 297,8,0,5,22,46.6,102.6,0.0
553
+ 298,8,0,5,22,47.4,103.4,0.0
554
+ 299,8,0,5,22,48.2,104.2,0.0
555
+ 300,8,0,5,22,39.0,105.0,0.0
556
+ 301,8,0,5,22,39.8,105.8,0.0
557
+ 302,8,0,5,22,40.6,106.6,0.0
558
+ 303,8,0,5,22,41.4,107.4,0.0
559
+ 304,8,0,5,22,42.2,108.2,0.0
560
+ 305,8,0,5,30,43.0,109.0,0.0
561
+ 306,8,0,5,30,43.8,109.8,0.0
562
+ 307,8,0,5,30,44.6,110.6,0.0
563
+ 308,8,0,5,29,45.4,111.4,0.0
564
+ 309,8,0,5,28,46.2,112.2,0.0
565
+ 310,8,0,5,28,47.0,113.0,0.0
566
+ 311,8,0,5,28,47.8,113.8,0.0
567
+ 312,8,0,5,28,48.6,114.6,0.0
568
+ 313,8,0,5,37,49.4,115.4,0.0
569
+ 314,8,0,5,37,50.2,116.2,0.0
570
+ 315,8,0,5,37,51.0,117.0,0.0
571
+ 316,8,0,5,36,46.8,117.8,0.0
572
+ 317,8,0,5,36,47.6,118.6,0.0
573
+ 318,8,0,5,35,48.4,119.4,0.0
574
+ 319,8,0,5,34,49.2,120.2,0.0
575
+ 320,8,0,5,34,50.0,121.0,0.0
576
+ 321,8,0,5,42,50.8,121.8,0.0
577
+ 322,8,0,5,41,51.6,122.6,0.0
578
+ 323,8,0,5,41,52.4,123.4,0.0
579
+ 324,8,0,5,41,53.2,124.2,0.0
580
+ 325,8,0,5,40,54.0,125.0,0.0
581
+ 326,8,0,5,40,54.8,125.8,0.0
582
+ 327,8,0,5,40,55.6,126.6,0.0
583
+ 328,8,0,5,40,56.4,127.4,0.0
584
+ 329,8,0,5,42,57.2,128.2,0.0
585
+ 330,8,0,5,41,58.0,129.0,0.0
586
+ 331,8,0,5,41,58.8,129.8,0.0
587
+ 332,8,0,5,41,59.6,130.6,0.0
588
+ 333,8,0,5,40,60.4,131.4,0.0
589
+ 334,8,0,5,40,61.2,132.2,0.0
590
+ 335,8,0,5,40,62.0,133.0,0.0
591
+ 336,8,0,5,40,62.8,133.8,0.0
592
+ 337,8,0,5,42,63.6,134.6,0.0
593
+ 338,8,0,5,40,64.4,135.4,0.0
594
+ 339,8,0,5,40,60.2,96.2,0.0
595
+ 340,8,0,5,40,61.0,97.0,0.0
596
+ 341,8,0,5,40,61.8,97.8,0.0
597
+ 342,8,0,5,40,57.6,98.6,0.0
598
+ 343,8,0,5,40,58.4,99.4,0.0
599
+ 344,8,0,5,40,59.2,100.2,0.0
600
+ 345,8,0,5,42,60.0,101.0,0.0
601
+ 346,8,0,5,42,60.8,101.8,0.0
602
+ 347,8,0,5,42,51.6,64.6,0.0
603
+ 348,8,0,5,42,52.4,65.4,0.0
604
+ 349,8,0,5,42,48.2,66.2,0.0
605
+ 350,8,0,5,42,39.0,67.0,0.0
606
+ 351,8,0,5,42,39.8,67.8,0.0
607
+ 352,8,0,5,42,35.6,68.6,0.0
608
+ 353,8,0,5,44,36.4,69.4,0.0
609
+ 354,7,0,5,50,38.06,70.2,18.0
610
+ 355,7,0,5,50,38.86,71.0,16.0
611
+ 356,7,0,5,50,39.66,71.8,14.0
612
+ 357,7,0,5,50,40.46,72.6,12.0
613
+ 358,7,0,5,50,41.26,73.4,10.0
614
+ 359,7,0,5,50,42.06,74.2,8.0
615
+ 360,7,0,5,50,42.86,75.0,6.0
616
+ 361,7,0,5,52,43.66,75.8,4.0
617
+ 362,7,0,5,52,44.46,76.6,2.0
618
+ 363,7,0,5,52,45.26,77.4,0.0
619
+ 364,7,0,5,52,46.06,78.2,0.0
620
+ 365,7,0,5,52,46.86,79.0,0.0
621
+ 366,7,0,5,52,47.66,79.8,0.0
622
+ 367,7,0,5,52,48.46,80.6,0.0
623
+ 368,6,0,5,54,51.73,81.4,18.0
624
+ 369,6,0,5,57,52.53,82.2,16.0
625
+ 370,6,0,5,57,53.33,83.0,14.0
626
+ 371,6,0,5,57,54.13,83.8,12.0
627
+ 372,6,0,5,57,54.93,84.6,10.0
628
+ 373,6,0,5,57,55.73,85.4,8.0
629
+ 374,6,0,5,57,56.53,86.2,6.0
630
+ 375,6,0,5,57,57.33,87.0,4.0
631
+ 376,6,0,5,57,58.13,87.8,2.0
632
+ 377,6,0,5,60,58.93,88.6,0.0
633
+ 378,6,0,5,60,59.73,89.4,0.0
634
+ 379,6,0,5,60,60.53,90.2,0.0
635
+ 380,5,0,5,60,63.8,91.0,18.0
636
+ 381,5,0,5,60,64.6,91.8,16.0
637
+ 382,5,0,5,60,65.4,92.6,14.0
638
+ 383,5,0,5,60,66.2,93.4,12.0
639
+ 384,5,0,5,60,67.0,94.2,10.0
640
+ 385,5,0,5,61,67.8,95.0,8.0
641
+ 386,4,0,5,61,72.55,95.8,26.0
642
+ 387,4,0,5,61,73.35,96.6,24.0
643
+ 388,4,0,5,61,74.15,97.4,22.0
644
+ 389,4,0,5,61,74.95,98.2,20.0
645
+ 390,4,0,5,61,75.75,99.0,18.0
646
+ 391,4,0,5,61,76.55,99.8,16.0
647
+ 392,4,0,5,61,77.35,100.6,14.0
648
+ 393,4,0,5,61,78.15,101.4,12.0
649
+ 394,4,0,5,61,78.95,102.2,10.0
650
+ 395,4,0,5,61,79.75,103.0,8.0
651
+ 396,4,0,5,61,80.55,103.8,6.0
652
+ 397,4,0,5,61,81.35,104.6,4.0
653
+ 398,4,0,5,61,82.15,105.4,2.0
654
+ 399,4,0,5,61,82.95,106.2,0.0
655
+ 400,4,0,5,61,73.75,107.0,0.0
656
+ ```
prompts/overseer.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ You are the Overseer, protector of a small village in a deterministic survival simulation.
2
+
3
+ Read the compact world context, recent event log, active beasts, houses, resources, and critical NPC stats. Your job is to help the village survive and grow against the God of Chaos.
4
+
5
+ Return strict JSON only:
6
+ {"thoughts":"short tactical reasoning","directives":[{"npc_id":"npc-004","directive":"attack beast_2"}],"priority_alert":"short alert"}
7
+
8
+ Rules:
9
+ - Issue at most 3 concrete directives.
10
+ - Use only NPC ids from the context.
11
+ - Directives must be short, specific, and feasible for the NPC role.
12
+ - Protect scarce roles, especially the last builder and wounded guards.
13
+ - Prefer: guards attack/defend/patrol, builders build or gather wood, gatherers gather/transfer/go home.
14
+ - The engine validates all actions and consequences. You only command intent.
15
+ - If no action is needed, return an empty directives array.
scripts/headless_sim.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ from collections import Counter
5
+ from pathlib import Path
6
+ import sys
7
+ import time
8
+
9
+ REPO_ROOT = Path(__file__).resolve().parents[1]
10
+ SRC_ROOT = REPO_ROOT / "src"
11
+ if str(SRC_ROOT) not in sys.path:
12
+ sys.path.insert(0, str(SRC_ROOT))
13
+
14
+ from god_simulator.config import ( # noqa: E402
15
+ GameConfig,
16
+ NpcConfig,
17
+ ServerConfig,
18
+ SimulationConfig,
19
+ WorldConfig,
20
+ )
21
+ from god_simulator.simulation.spawning import create_world # noqa: E402
22
+ from god_simulator.simulation.overseer import scripted_overseer_controller # noqa: E402
23
+ from god_simulator.simulation.tick import advance_world # noqa: E402
24
+
25
+
26
+ def main() -> None:
27
+ parser = argparse.ArgumentParser(description="Run a deterministic headless village simulation.")
28
+ parser.add_argument("--mock-overseer", action="store_true", help="Enable scripted Overseer directives.")
29
+ args = parser.parse_args()
30
+
31
+ world = create_world(
32
+ GameConfig(
33
+ world=WorldConfig(
34
+ width=80,
35
+ depth=80,
36
+ terrain="plain_green",
37
+ seed=42,
38
+ survival=True,
39
+ ),
40
+ npcs=NpcConfig(count=6),
41
+ simulation=SimulationConfig(tick_ms=1),
42
+ server=ServerConfig(host="127.0.0.1", port=8000),
43
+ )
44
+ )
45
+
46
+ min_population = world.population
47
+ role_violations: list[str] = []
48
+ tick_durations: list[float] = []
49
+ overseer = scripted_overseer_controller() if args.mock_overseer else None
50
+ all_debug: list[dict[str, object]] = []
51
+
52
+ mode_label = "deterministic+mock_overseer" if args.mock_overseer else "deterministic"
53
+ print(f"HEADLESS_SIM start seed=42 ticks=300 mode={mode_label}")
54
+ print(
55
+ "HEADLESS_SIM initial "
56
+ f"population={world.population} roles={dict(Counter(npc.role for npc in world.npcs))} "
57
+ f"houses={len(world.houses)} beasts={len(world.beasts)}"
58
+ )
59
+
60
+ for _ in range(300):
61
+ before_events = len(world.event_log)
62
+ started = time.perf_counter()
63
+ advance_world(world, overseer=overseer)
64
+ tick_durations.append(time.perf_counter() - started)
65
+ min_population = min(min_population, world.population)
66
+ all_debug.extend(world.last_action_debug)
67
+
68
+ for trace in world.last_action_debug:
69
+ action = str(trace.get("action", ""))
70
+ npc_id = str(trace.get("npc_id", ""))
71
+ npc = next((candidate for candidate in world.npcs if candidate.id == npc_id), None)
72
+ if npc is None:
73
+ continue
74
+ if npc.role == "guard" and action == "gather":
75
+ role_violations.append(f"tick {world.tick}: guard {npc.id} gathered")
76
+ if npc.role == "builder" and action == "attack":
77
+ role_violations.append(f"tick {world.tick}: builder {npc.id} attacked")
78
+
79
+ new_events = world.event_log[before_events:]
80
+ event_text = "; ".join(
81
+ f"{event.tick}:{event.type}:{event.summary}" for event in new_events
82
+ )
83
+ if not event_text:
84
+ event_text = "no_events"
85
+ print(
86
+ f"tick={world.tick:03d} pop={world.population:02d} "
87
+ f"status={world.game_status} events={event_text}"
88
+ )
89
+
90
+ counts = Counter(event.type for event in world.event_log)
91
+ role_by_id = {npc.id: npc.role for npc in world.npcs}
92
+ guard_engagements = [
93
+ event
94
+ for event in world.event_log
95
+ if event.type == "npc_attack"
96
+ and event.target_id is not None
97
+ and event.target_id.startswith("beast")
98
+ and role_by_id.get(event.actor_id or "") == "guard"
99
+ ]
100
+
101
+ assert counts["build_completed"] >= 1, "expected at least one completed house"
102
+ assert counts["npc_born"] >= 1, "expected at least one reproduction"
103
+ assert guard_engagements, "expected at least one guard attack against a beast"
104
+ assert min_population >= 0, "population went negative"
105
+ assert not role_violations, "\n".join(role_violations)
106
+ assert all(duration < 0.25 for duration in tick_durations), "tick exceeded time budget"
107
+
108
+ if args.mock_overseer:
109
+ directive_events = [event for event in world.event_log if event.type == "directive_issued"]
110
+ assert directive_events, "expected mock Overseer directives to reach the event log"
111
+ assert any(
112
+ trace.get("npc_id") == "npc-006"
113
+ and trace.get("requested_action") == "attack"
114
+ and trace.get("action") != "attack"
115
+ for trace in all_debug
116
+ ), "expected invalid builder attack directive to be validated/repaired"
117
+ assert world.overseer_last_thoughts, "expected mock Overseer thoughts in world state"
118
+ assert world.overseer_score > 0, "expected scoreboard Overseer points to update"
119
+
120
+ houses_by_state = Counter(house.state for house in world.houses)
121
+ print("HEADLESS_SIM summary")
122
+ print(f" ticks={world.tick}")
123
+ print(f" game_status={world.game_status}")
124
+ print(f" final_population={world.population}")
125
+ print(f" peak_population={world.peak_population}")
126
+ print(f" min_population={min_population}")
127
+ print(f" births={counts['npc_born']}")
128
+ print(f" deaths={counts['npc_died']} causes={dict(world.deaths_by_cause)}")
129
+ print(f" build_completed={counts['build_completed']}")
130
+ print(f" houses={dict(houses_by_state)}")
131
+ print(f" guard_beast_engagements={len(guard_engagements)}")
132
+ print(f" beasts_killed={counts['beast_killed']}")
133
+ if args.mock_overseer:
134
+ print(f" directive_issued={counts['directive_issued']}")
135
+ print(f" overseer_status={world.overseer_status}")
136
+ print(f" overseer_last_tick={world.overseer_last_tick}")
137
+ print(f" overseer_score={world.overseer_score}")
138
+ print(f" chaos_score={world.chaos_score}")
139
+ print(f" max_tick_ms={max(tick_durations) * 1000:.3f}")
140
+ print("HEADLESS_SIM PASS")
141
+
142
+
143
+ if __name__ == "__main__":
144
+ main()
scripts/playtest_agent.py ADDED
@@ -0,0 +1,811 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ from collections import Counter
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ import sys
10
+ import time
11
+ from typing import Any
12
+ from urllib.error import URLError
13
+ from urllib.parse import urlparse
14
+ from urllib.request import Request, urlopen
15
+
16
+ REPO_ROOT = Path(__file__).resolve().parents[1]
17
+ SRC_ROOT = REPO_ROOT / "src"
18
+ if str(SRC_ROOT) not in sys.path:
19
+ sys.path.insert(0, str(SRC_ROOT))
20
+
21
+ from god_simulator.config import ( # noqa: E402
22
+ ConnectorConfig,
23
+ GameConfig,
24
+ NpcConfig,
25
+ OverseerConfig,
26
+ ServerConfig,
27
+ SimulationConfig,
28
+ WorldConfig,
29
+ load_game_config,
30
+ )
31
+ from god_simulator.domain import WorldLogEvent, WorldState # noqa: E402
32
+ from god_simulator.simulation.chaos import apply_chaos_action # noqa: E402
33
+ from god_simulator.simulation.connectors.deterministic import ( # noqa: E402
34
+ DeterministicWorldSimulator,
35
+ )
36
+ from god_simulator.simulation.connectors.factory import create_world_simulator # noqa: E402
37
+ from god_simulator.simulation.connectors.openai_compatible import ( # noqa: E402
38
+ OpenAICompatibleWorldSimulator,
39
+ )
40
+ from god_simulator.simulation.mechanics import is_alive # noqa: E402
41
+ from god_simulator.simulation.overseer import ( # noqa: E402
42
+ OverseerController,
43
+ create_overseer,
44
+ scripted_overseer_controller,
45
+ )
46
+ from god_simulator.simulation.roles import normalize_role # noqa: E402
47
+ from god_simulator.simulation.spawning import create_world # noqa: E402
48
+ from god_simulator.simulation.tick import advance_world # noqa: E402
49
+
50
+
51
+ CHAOS_SCHEDULE: dict[int, list[str]] = {
52
+ 50: ["spawn_beast"],
53
+ 120: ["famine"],
54
+ 200: ["beast_pack"],
55
+ 280: ["maniac"],
56
+ }
57
+ MAJOR_EVENT_TYPES = {
58
+ "beast_spawned",
59
+ "beast_attack",
60
+ "beast_killed",
61
+ "beast_retreat",
62
+ "npc_attack",
63
+ "npc_died",
64
+ "npc_born",
65
+ "build_started",
66
+ "build_completed",
67
+ "house_damaged",
68
+ "house_destroyed",
69
+ "transfer",
70
+ "heal",
71
+ "consume",
72
+ "chaos_event",
73
+ "directive_issued",
74
+ "overseer_skipped",
75
+ "game_over",
76
+ }
77
+
78
+
79
+ @dataclass(frozen=True, slots=True)
80
+ class Snapshot:
81
+ tick: int
82
+ population: int
83
+ living_beasts: int
84
+ completed_houses: int
85
+ total_food: int
86
+ avg_hunger: float
87
+ max_hunger: float
88
+ max_fear: float
89
+
90
+
91
+ @dataclass(frozen=True, slots=True)
92
+ class ActionTrace:
93
+ tick: int
94
+ npc_id: str
95
+ role: str
96
+ goal: str
97
+ requested_action: str
98
+ action: str
99
+ summary: str
100
+
101
+
102
+ @dataclass(frozen=True, slots=True)
103
+ class TrustDelta:
104
+ tick: int
105
+ npc_id: str
106
+ target_id: str
107
+ before: float
108
+ after: float
109
+
110
+
111
+ @dataclass(slots=True)
112
+ class ChainResult:
113
+ name: str
114
+ occurred: bool
115
+ ticks: dict[str, int | None]
116
+ evidence: list[str] = field(default_factory=list)
117
+
118
+
119
+ @dataclass(slots=True)
120
+ class PlaytestResult:
121
+ world: WorldState
122
+ report_path: Path
123
+ ticks_requested: int
124
+ elapsed_seconds: float
125
+ simulator_label: str
126
+ overseer_label: str
127
+ snapshots: list[Snapshot]
128
+ action_traces: list[ActionTrace]
129
+ trust_deltas: list[TrustDelta]
130
+ chains: list[ChainResult]
131
+ verdict: str
132
+
133
+
134
+ def main() -> None:
135
+ args = _parse_args()
136
+ started = time.perf_counter()
137
+
138
+ config = _load_or_default_config(args.config)
139
+ config = _with_playtest_overrides(config, seed=args.seed, npc_count=args.npcs)
140
+ world = create_world(config)
141
+ simulator, simulator_label = _build_simulator(config, args.use_llm)
142
+ overseer, overseer_label = _build_overseer(
143
+ config,
144
+ enabled=args.overseer_on,
145
+ real=args.real_overseer,
146
+ )
147
+
148
+ snapshots: list[Snapshot] = [_snapshot(world)]
149
+ action_traces: list[ActionTrace] = []
150
+ trust_deltas: list[TrustDelta] = []
151
+ previous_relationships = _relationship_matrix(world)
152
+
153
+ while world.tick < args.ticks:
154
+ before_event_count = len(world.event_log)
155
+ advance_world(world, simulator=simulator, overseer=overseer)
156
+
157
+ action_traces.extend(_action_traces(world))
158
+ trust_deltas.extend(_trust_deltas(world, previous_relationships))
159
+ previous_relationships = _relationship_matrix(world)
160
+
161
+ for action in CHAOS_SCHEDULE.get(world.tick, []):
162
+ applied = apply_chaos_action(world, action)
163
+ if not applied:
164
+ _append_manual_event(
165
+ world,
166
+ "chaos_event",
167
+ f"Scheduled chaos action {action} had no effect",
168
+ severity="warning",
169
+ )
170
+
171
+ if len(world.event_log) > before_event_count:
172
+ previous_relationships = _relationship_matrix(world)
173
+ snapshots.append(_snapshot(world))
174
+
175
+ chains = _analyze_chains(world, snapshots, action_traces, trust_deltas)
176
+ verdict = _balance_verdict(world, snapshots, overseer_on=args.overseer_on)
177
+ elapsed_seconds = time.perf_counter() - started
178
+
179
+ result = PlaytestResult(
180
+ world=world,
181
+ report_path=args.report,
182
+ ticks_requested=args.ticks,
183
+ elapsed_seconds=elapsed_seconds,
184
+ simulator_label=simulator_label,
185
+ overseer_label=overseer_label,
186
+ snapshots=snapshots,
187
+ action_traces=action_traces,
188
+ trust_deltas=trust_deltas,
189
+ chains=chains,
190
+ verdict=verdict,
191
+ )
192
+ _write_report(result)
193
+ _print_summary(result)
194
+
195
+
196
+ def _parse_args() -> argparse.Namespace:
197
+ parser = argparse.ArgumentParser(
198
+ description="Run the scheduled-chaos playtest and write playtest_report.md."
199
+ )
200
+ parser.add_argument("--config", type=Path, default=Path("config/game.modal.local.json"))
201
+ parser.add_argument("--report", type=Path, default=Path("playtest_report.md"))
202
+ parser.add_argument("--ticks", type=int, default=400)
203
+ parser.add_argument("--seed", type=int, default=None)
204
+ parser.add_argument("--npcs", type=int, default=None)
205
+ parser.add_argument("--overseer-on", action="store_true", help="Enable Overseer autopilot.")
206
+ parser.add_argument(
207
+ "--real-overseer",
208
+ action="store_true",
209
+ help="Use config.overseer instead of the deterministic mock when --overseer-on is set.",
210
+ )
211
+ parser.add_argument(
212
+ "--use-llm",
213
+ choices=("auto", "always", "never"),
214
+ default="auto",
215
+ help="auto uses an OpenAI-compatible NPC connector only when it looks reachable.",
216
+ )
217
+ return parser.parse_args()
218
+
219
+
220
+ def _load_or_default_config(path: Path) -> GameConfig:
221
+ if path.is_file():
222
+ return load_game_config(path)
223
+ return GameConfig(
224
+ world=WorldConfig(width=80, depth=80, terrain="plain_green", seed=42, survival=True),
225
+ npcs=NpcConfig(count=10),
226
+ simulation=SimulationConfig(tick_ms=1),
227
+ server=ServerConfig(host="127.0.0.1", port=8000),
228
+ )
229
+
230
+
231
+ def _with_playtest_overrides(
232
+ config: GameConfig,
233
+ *,
234
+ seed: int | None,
235
+ npc_count: int | None,
236
+ ) -> GameConfig:
237
+ world = config.world
238
+ npcs = config.npcs
239
+ simulation = config.simulation
240
+ if seed is not None:
241
+ world = WorldConfig(
242
+ width=world.width,
243
+ depth=world.depth,
244
+ terrain=world.terrain,
245
+ seed=seed,
246
+ survival=True,
247
+ )
248
+ elif not world.survival:
249
+ world = WorldConfig(
250
+ width=world.width,
251
+ depth=world.depth,
252
+ terrain=world.terrain,
253
+ seed=world.seed,
254
+ survival=True,
255
+ )
256
+ if npc_count is not None:
257
+ npcs = NpcConfig(count=npc_count)
258
+ if simulation.tick_ms != 1:
259
+ simulation = SimulationConfig(tick_ms=1)
260
+ return GameConfig(
261
+ world=world,
262
+ npcs=npcs,
263
+ simulation=simulation,
264
+ server=config.server,
265
+ connector=config.connector,
266
+ god_console=config.god_console,
267
+ overseer=config.overseer,
268
+ )
269
+
270
+
271
+ def _build_simulator(
272
+ config: GameConfig,
273
+ use_llm: str,
274
+ ) -> tuple[DeterministicWorldSimulator | OpenAICompatibleWorldSimulator, str]:
275
+ deterministic = DeterministicWorldSimulator()
276
+ if use_llm == "never" or config.connector.type != "openai_compatible":
277
+ return deterministic, "deterministic"
278
+
279
+ reason = _connector_unavailable_reason(config.connector)
280
+ if reason and use_llm == "auto":
281
+ return deterministic, f"deterministic (LLM skipped: {reason})"
282
+
283
+ try:
284
+ simulator = create_world_simulator(config)
285
+ except Exception as exc:
286
+ if use_llm == "always":
287
+ raise
288
+ return deterministic, f"deterministic (LLM unavailable: {exc})"
289
+
290
+ if not isinstance(simulator, OpenAICompatibleWorldSimulator):
291
+ return deterministic, "deterministic"
292
+ return simulator, "openai_compatible with deterministic fallback"
293
+
294
+
295
+ def _connector_unavailable_reason(config: ConnectorConfig) -> str | None:
296
+ if config.api_key_env and not os.getenv(config.api_key_env):
297
+ return f"missing {config.api_key_env}"
298
+ base_url = config.base_url or config.api_url
299
+ if not base_url:
300
+ return "missing base_url"
301
+ if not config.model:
302
+ return "missing model"
303
+ health_url = _health_url(base_url)
304
+ try:
305
+ request = Request(health_url, method="GET")
306
+ with urlopen(request, timeout=2.0) as response:
307
+ if 200 <= response.status < 500:
308
+ return None
309
+ return f"health returned HTTP {response.status}"
310
+ except (OSError, URLError) as exc:
311
+ return f"health check failed at {health_url}: {exc}"
312
+
313
+
314
+ def _health_url(base_url: str) -> str:
315
+ parsed = urlparse(base_url)
316
+ path = parsed.path.removesuffix("/v1").rstrip("/")
317
+ return parsed._replace(path=f"{path}/health", params="", query="", fragment="").geturl()
318
+
319
+
320
+ def _build_overseer(
321
+ config: GameConfig,
322
+ *,
323
+ enabled: bool,
324
+ real: bool,
325
+ ) -> tuple[OverseerController | None, str]:
326
+ if not enabled:
327
+ return None, "off"
328
+ if not real:
329
+ return scripted_overseer_controller(mode="autopilot", cycle_ticks=8), "mock autopilot"
330
+ real_config = config
331
+ if config.overseer.mode == "off":
332
+ real_config = GameConfig(
333
+ world=config.world,
334
+ npcs=config.npcs,
335
+ simulation=config.simulation,
336
+ server=config.server,
337
+ connector=config.connector,
338
+ god_console=config.god_console,
339
+ overseer=OverseerConfig(
340
+ mode="autopilot",
341
+ cycle_ticks=8,
342
+ max_directives=3,
343
+ connector=config.connector,
344
+ ),
345
+ )
346
+ return create_overseer(real_config), "real/config autopilot"
347
+
348
+
349
+ def _snapshot(world: WorldState) -> Snapshot:
350
+ living = [npc for npc in world.npcs if is_alive(npc)]
351
+ total_food = sum(node.amount for node in world.resource_nodes if node.resource_type == "food")
352
+ return Snapshot(
353
+ tick=world.tick,
354
+ population=sum(1 for npc in living),
355
+ living_beasts=sum(1 for beast in world.beasts if beast.state != "dead"),
356
+ completed_houses=sum(
357
+ 1
358
+ for house in world.houses
359
+ if house.state == "completed" and house.hp > 0
360
+ ),
361
+ total_food=total_food,
362
+ avg_hunger=round(sum(npc.hunger for npc in living) / max(1, len(living)), 2),
363
+ max_hunger=round(max((npc.hunger for npc in living), default=0.0), 2),
364
+ max_fear=round(max((npc.fear for npc in living), default=0.0), 2),
365
+ )
366
+
367
+
368
+ def _action_traces(world: WorldState) -> list[ActionTrace]:
369
+ roles = {npc.id: normalize_role(npc.role) for npc in world.npcs}
370
+ traces: list[ActionTrace] = []
371
+ for trace in world.last_action_debug:
372
+ npc_id = str(trace.get("npc_id", ""))
373
+ traces.append(
374
+ ActionTrace(
375
+ tick=world.tick,
376
+ npc_id=npc_id,
377
+ role=roles.get(npc_id, "unknown"),
378
+ goal=str(trace.get("goal", "")),
379
+ requested_action=str(trace.get("requested_action", "")),
380
+ action=str(trace.get("action", "")),
381
+ summary=str(trace.get("summary", "")),
382
+ )
383
+ )
384
+ return traces
385
+
386
+
387
+ def _relationship_matrix(world: WorldState) -> dict[tuple[str, str], float]:
388
+ matrix: dict[tuple[str, str], float] = {}
389
+ for npc in world.npcs:
390
+ for target_id, value in npc.relationships.items():
391
+ if target_id.startswith("beast"):
392
+ continue
393
+ matrix[(npc.id, target_id)] = float(value)
394
+ return matrix
395
+
396
+
397
+ def _trust_deltas(
398
+ world: WorldState,
399
+ previous: dict[tuple[str, str], float],
400
+ ) -> list[TrustDelta]:
401
+ current = _relationship_matrix(world)
402
+ deltas: list[TrustDelta] = []
403
+ for key, after in current.items():
404
+ before = previous.get(key, after)
405
+ if abs(after - before) >= 0.001:
406
+ deltas.append(
407
+ TrustDelta(
408
+ tick=world.tick,
409
+ npc_id=key[0],
410
+ target_id=key[1],
411
+ before=round(before, 3),
412
+ after=round(after, 3),
413
+ )
414
+ )
415
+ return deltas
416
+
417
+
418
+ def _append_manual_event(
419
+ world: WorldState,
420
+ event_type: str,
421
+ summary: str,
422
+ *,
423
+ severity: str,
424
+ ) -> None:
425
+ world.event_log.append(
426
+ WorldLogEvent(
427
+ tick=world.tick,
428
+ type=event_type,
429
+ summary=summary,
430
+ severity=severity, # type: ignore[arg-type]
431
+ )
432
+ )
433
+
434
+
435
+ def _analyze_chains(
436
+ world: WorldState,
437
+ snapshots: list[Snapshot],
438
+ traces: list[ActionTrace],
439
+ trust_deltas: list[TrustDelta],
440
+ ) -> list[ChainResult]:
441
+ beast_attack_tick = _first_event_tick(world, "beast_attack")
442
+ fear_tick = _first_snapshot_tick(
443
+ snapshots,
444
+ min_tick=beast_attack_tick,
445
+ predicate=lambda snapshot: snapshot.max_fear >= 60.0,
446
+ )
447
+ help_tick = _first_trace_tick(
448
+ traces,
449
+ min_tick=beast_attack_tick,
450
+ predicate=lambda trace: _is_help_request(trace),
451
+ )
452
+ guard_response_tick = _first_guard_response_tick(
453
+ world,
454
+ traces,
455
+ min_tick=help_tick or beast_attack_tick,
456
+ )
457
+ trust_tick = _first_trust_tick(
458
+ trust_deltas,
459
+ min_tick=guard_response_tick,
460
+ guard_ids={npc.id for npc in world.npcs if normalize_role(npc.role) == "guard"},
461
+ )
462
+ beast_chain = ChainResult(
463
+ name="beast attack -> fear -> help_request -> guard response -> trust change",
464
+ occurred=all(
465
+ tick is not None
466
+ for tick in (beast_attack_tick, fear_tick, help_tick, guard_response_tick, trust_tick)
467
+ ),
468
+ ticks={
469
+ "beast_attack": beast_attack_tick,
470
+ "fear": fear_tick,
471
+ "help_request": help_tick,
472
+ "guard_response": guard_response_tick,
473
+ "trust_change": trust_tick,
474
+ },
475
+ evidence=_evidence_lines(
476
+ world,
477
+ traces,
478
+ trust_deltas,
479
+ ticks=(beast_attack_tick, fear_tick, help_tick, guard_response_tick, trust_tick),
480
+ ),
481
+ )
482
+
483
+ hunger_tick = _first_snapshot_tick(
484
+ snapshots,
485
+ min_tick=0,
486
+ predicate=lambda snapshot: snapshot.max_hunger >= 60.0,
487
+ )
488
+ gather_tick = _first_event_tick(
489
+ world,
490
+ "gather",
491
+ min_tick=hunger_tick,
492
+ predicate=lambda event: "food" in event.summary.lower()
493
+ or (event.object_id or "").startswith("res_food"),
494
+ )
495
+ steal_tick = _first_trace_tick(
496
+ traces,
497
+ min_tick=hunger_tick,
498
+ predicate=lambda trace: trace.action == "steal" or "stealing" in trace.summary,
499
+ )
500
+ hunger_chain = ChainResult(
501
+ name="hunger -> gather/steal",
502
+ occurred=hunger_tick is not None and (gather_tick is not None or steal_tick is not None),
503
+ ticks={
504
+ "hunger": hunger_tick,
505
+ "gather": gather_tick,
506
+ "steal": steal_tick,
507
+ },
508
+ evidence=_evidence_lines(
509
+ world,
510
+ traces,
511
+ trust_deltas,
512
+ ticks=(hunger_tick, gather_tick, steal_tick),
513
+ ),
514
+ )
515
+
516
+ build_tick = _first_event_tick(world, "build_completed")
517
+ birth_tick = _first_event_tick(world, "npc_born", min_tick=build_tick)
518
+ build_chain = ChainResult(
519
+ name="build -> reproduce",
520
+ occurred=build_tick is not None and birth_tick is not None,
521
+ ticks={
522
+ "build_completed": build_tick,
523
+ "npc_born": birth_tick,
524
+ },
525
+ evidence=_evidence_lines(world, traces, trust_deltas, ticks=(build_tick, birth_tick)),
526
+ )
527
+ return [beast_chain, hunger_chain, build_chain]
528
+
529
+
530
+ def _first_event_tick(
531
+ world: WorldState,
532
+ event_type: str,
533
+ *,
534
+ min_tick: int | None = None,
535
+ predicate: Any | None = None,
536
+ ) -> int | None:
537
+ for event in world.event_log:
538
+ if event.type != event_type:
539
+ continue
540
+ if min_tick is not None and event.tick < min_tick:
541
+ continue
542
+ if predicate is not None and not predicate(event):
543
+ continue
544
+ return event.tick
545
+ return None
546
+
547
+
548
+ def _first_snapshot_tick(
549
+ snapshots: list[Snapshot],
550
+ *,
551
+ min_tick: int | None,
552
+ predicate: Any,
553
+ ) -> int | None:
554
+ for snapshot in snapshots:
555
+ if min_tick is not None and snapshot.tick < min_tick:
556
+ continue
557
+ if predicate(snapshot):
558
+ return snapshot.tick
559
+ return None
560
+
561
+
562
+ def _first_trace_tick(
563
+ traces: list[ActionTrace],
564
+ *,
565
+ min_tick: int | None,
566
+ predicate: Any,
567
+ ) -> int | None:
568
+ for trace in traces:
569
+ if min_tick is not None and trace.tick < min_tick:
570
+ continue
571
+ if predicate(trace):
572
+ return trace.tick
573
+ return None
574
+
575
+
576
+ def _is_help_request(trace: ActionTrace) -> bool:
577
+ return (
578
+ trace.action == "communicate"
579
+ and (
580
+ "calling for help" in trace.summary
581
+ or "help_request" in trace.requested_action
582
+ or "help" in trace.summary
583
+ )
584
+ )
585
+
586
+
587
+ def _first_guard_response_tick(
588
+ world: WorldState,
589
+ traces: list[ActionTrace],
590
+ *,
591
+ min_tick: int | None,
592
+ ) -> int | None:
593
+ roles = {npc.id: normalize_role(npc.role) for npc in world.npcs}
594
+ for event in world.event_log:
595
+ if min_tick is not None and event.tick < min_tick:
596
+ continue
597
+ if (
598
+ event.type == "npc_attack"
599
+ and event.actor_id is not None
600
+ and roles.get(event.actor_id) == "guard"
601
+ and (event.target_id or "").startswith("beast")
602
+ ):
603
+ return event.tick
604
+ for trace in traces:
605
+ if min_tick is not None and trace.tick < min_tick:
606
+ continue
607
+ if trace.role == "guard" and trace.action == "attack":
608
+ return trace.tick
609
+ return None
610
+
611
+
612
+ def _first_trust_tick(
613
+ deltas: list[TrustDelta],
614
+ *,
615
+ min_tick: int | None,
616
+ guard_ids: set[str],
617
+ ) -> int | None:
618
+ if min_tick is None:
619
+ return None
620
+ for delta in deltas:
621
+ if delta.tick < min_tick:
622
+ continue
623
+ if delta.target_id in guard_ids or delta.npc_id in guard_ids:
624
+ return delta.tick
625
+ return None
626
+
627
+
628
+ def _evidence_lines(
629
+ world: WorldState,
630
+ traces: list[ActionTrace],
631
+ trust_deltas: list[TrustDelta],
632
+ *,
633
+ ticks: tuple[int | None, ...],
634
+ ) -> list[str]:
635
+ wanted = {tick for tick in ticks if tick is not None}
636
+ if not wanted:
637
+ return []
638
+ lines: list[str] = []
639
+ for event in world.event_log:
640
+ if event.tick in wanted and event.type in MAJOR_EVENT_TYPES | {"gather"}:
641
+ lines.append(f"tick {event.tick}: {event.type}: {event.summary}")
642
+ for trace in traces:
643
+ if trace.tick in wanted and (
644
+ "help" in trace.summary or trace.action in {"attack", "steal", "gather"}
645
+ ):
646
+ lines.append(
647
+ f"tick {trace.tick}: {trace.npc_id} {trace.role} {trace.action}: {trace.summary}"
648
+ )
649
+ for delta in trust_deltas:
650
+ if delta.tick in wanted:
651
+ lines.append(
652
+ f"tick {delta.tick}: trust {delta.npc_id}->{delta.target_id} "
653
+ f"{delta.before:g}->{delta.after:g}"
654
+ )
655
+ return _dedupe(lines)[:8]
656
+
657
+
658
+ def _balance_verdict(
659
+ world: WorldState,
660
+ snapshots: list[Snapshot],
661
+ *,
662
+ overseer_on: bool,
663
+ ) -> str:
664
+ final_population = snapshots[-1].population
665
+ min_population = min(snapshot.population for snapshot in snapshots)
666
+ first_zero = next((snapshot.tick for snapshot in snapshots if snapshot.population <= 0), None)
667
+ first_crippled = next(
668
+ (snapshot.tick for snapshot in snapshots if snapshot.population <= 3),
669
+ None,
670
+ )
671
+ if first_zero is not None and first_zero < 250:
672
+ return f"died too fast: population hit 0 at tick {first_zero}"
673
+ if not overseer_on and final_population > 6:
674
+ return f"survived too easily: overseer off ended with population {final_population}"
675
+ if not overseer_on and final_population <= 3:
676
+ return (
677
+ "chaos pressure is strong enough: "
678
+ f"final population {final_population}, first crippled tick {first_crippled}"
679
+ )
680
+ if overseer_on and final_population <= 3:
681
+ return f"overseer impact too weak: final population {final_population}"
682
+ if overseer_on and world.game_status == "running":
683
+ return f"overseer run survived to 400 with population {final_population}"
684
+ return (
685
+ f"mixed: status={world.game_status}, final population={final_population}, "
686
+ f"min population={min_population}"
687
+ )
688
+
689
+
690
+ def _write_report(result: PlaytestResult) -> None:
691
+ result.report_path.parent.mkdir(parents=True, exist_ok=True)
692
+ world = result.world
693
+ event_counts = Counter(event.type for event in world.event_log)
694
+ action_counts = Counter(trace.action for trace in result.action_traces)
695
+ major_events = [event for event in world.event_log if event.type in MAJOR_EVENT_TYPES]
696
+ lines = [
697
+ "# Playtest Report",
698
+ "",
699
+ "## Run",
700
+ "",
701
+ f"- Ticks requested: {result.ticks_requested}",
702
+ f"- Final tick: {world.tick}",
703
+ f"- Simulator: {result.simulator_label}",
704
+ f"- Overseer: {result.overseer_label}",
705
+ f"- Elapsed seconds: {result.elapsed_seconds:.2f}",
706
+ f"- Game status: {world.game_status}",
707
+ f"- Population: final {world.population}, peak {world.peak_population}, "
708
+ f"min {min(snapshot.population for snapshot in result.snapshots)}",
709
+ f"- Births: {world.total_births}",
710
+ f"- Deaths by cause: {json.dumps(world.deaths_by_cause, sort_keys=True)}",
711
+ f"- Houses built: {world.houses_built}",
712
+ f"- Beasts killed: {world.beasts_killed}",
713
+ f"- Score: Overseer {world.overseer_score}, Chaos {world.chaos_score}",
714
+ "",
715
+ "## Verdict",
716
+ "",
717
+ result.verdict,
718
+ "",
719
+ "## Core Chains",
720
+ "",
721
+ ]
722
+ for chain in result.chains:
723
+ status = "yes" if chain.occurred else "no"
724
+ lines.append(f"- {chain.name}: {status}; ticks={json.dumps(chain.ticks, sort_keys=True)}")
725
+ for evidence in chain.evidence:
726
+ lines.append(f" - {evidence}")
727
+ lines.extend(
728
+ [
729
+ "",
730
+ "## Event Counts",
731
+ "",
732
+ "```json",
733
+ json.dumps(dict(sorted(event_counts.items())), indent=2, sort_keys=True),
734
+ "```",
735
+ "",
736
+ "## Action Counts",
737
+ "",
738
+ "```json",
739
+ json.dumps(dict(sorted(action_counts.items())), indent=2, sort_keys=True),
740
+ "```",
741
+ "",
742
+ "## Timeline",
743
+ "",
744
+ ]
745
+ )
746
+ for event in major_events[:160]:
747
+ actor = f" actor={event.actor_id}" if event.actor_id else ""
748
+ target = f" target={event.target_id}" if event.target_id else ""
749
+ lines.append(f"- tick {event.tick}: {event.type}{actor}{target}: {event.summary}")
750
+ if len(major_events) > 160:
751
+ lines.append(f"- ... {len(major_events) - 160} additional major events omitted")
752
+
753
+ lines.extend(
754
+ [
755
+ "",
756
+ "## Population Graph Data",
757
+ "",
758
+ "```csv",
759
+ (
760
+ "tick,population,living_beasts,completed_houses,total_food,"
761
+ "avg_hunger,max_hunger,max_fear"
762
+ ),
763
+ ]
764
+ )
765
+ for snapshot in result.snapshots:
766
+ lines.append(
767
+ f"{snapshot.tick},{snapshot.population},{snapshot.living_beasts},"
768
+ f"{snapshot.completed_houses},{snapshot.total_food},{snapshot.avg_hunger},"
769
+ f"{snapshot.max_hunger},{snapshot.max_fear}"
770
+ )
771
+ lines.extend(["```", ""])
772
+ result.report_path.write_text("\n".join(lines), encoding="utf-8")
773
+
774
+
775
+ def _print_summary(result: PlaytestResult) -> None:
776
+ world = result.world
777
+ chain_summary = ", ".join(
778
+ f"{chain.name.split(' -> ')[0]}={'yes' if chain.occurred else 'no'}"
779
+ for chain in result.chains
780
+ )
781
+ print(f"PLAYTEST report={result.report_path}")
782
+ print(f" simulator={result.simulator_label}")
783
+ print(f" overseer={result.overseer_label}")
784
+ print(f" ticks={world.tick} status={world.game_status} verdict={result.verdict}")
785
+ print(
786
+ " "
787
+ f"pop final={world.population} peak={world.peak_population} "
788
+ f"min={min(snapshot.population for snapshot in result.snapshots)} "
789
+ f"births={world.total_births} deaths={dict(world.deaths_by_cause)}"
790
+ )
791
+ print(
792
+ " "
793
+ f"houses_built={world.houses_built} beasts_killed={world.beasts_killed} "
794
+ f"score={world.overseer_score}-{world.chaos_score}"
795
+ )
796
+ print(f" chains={chain_summary}")
797
+
798
+
799
+ def _dedupe(lines: list[str]) -> list[str]:
800
+ seen: set[str] = set()
801
+ deduped: list[str] = []
802
+ for line in lines:
803
+ if line in seen:
804
+ continue
805
+ seen.add(line)
806
+ deduped.append(line)
807
+ return deduped
808
+
809
+
810
+ if __name__ == "__main__":
811
+ main()
src/god_simulator/api/server.py CHANGED
@@ -3,7 +3,7 @@ from __future__ import annotations
3
  import json
4
  import time
5
  from copy import deepcopy
6
- from dataclasses import dataclass
7
  from http import HTTPStatus
8
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
  from threading import Lock, Thread
@@ -15,17 +15,24 @@ from urllib.request import urlopen
15
  from god_simulator.config import ConnectorConfig, GameConfig
16
  from god_simulator.domain import WorldState
17
  from god_simulator.rendering.claw3d_contract import to_claw3d_snapshot
 
 
 
 
 
18
  from god_simulator.simulation.connectors.base import WorldSimulator
19
  from god_simulator.simulation.connectors.factory import create_world_simulator
20
  from god_simulator.simulation.god_console import (
21
  GodConsole,
22
  apply_god_command_plan,
23
  )
 
24
  from god_simulator.simulation.tick import apply_tick_plan, plan_world_tick
25
 
26
 
27
  def run_server(*, world: WorldState, config: GameConfig) -> None:
28
  simulator = create_world_simulator(config)
 
29
  god_console_config = config.god_console or config.connector
30
  god_console = (
31
  GodConsole(god_console_config)
@@ -33,14 +40,14 @@ def run_server(*, world: WorldState, config: GameConfig) -> None:
33
  else None
34
  )
35
  modal_health_warmer = _ModalHealthWarmer(_modal_health_targets(config))
36
- handler = _build_handler(world, simulator, god_console, modal_health_warmer)
37
  address = (config.server.host, config.server.port)
38
  server = ThreadingHTTPServer(address, handler)
39
  print(f"God Simulator API listening on http://{config.server.host}:{config.server.port}")
40
  print(f"World simulator connector: {simulator.name}")
41
  print(
42
  "Available endpoints: GET /health, GET /state, GET /claw3d/state, "
43
- "POST /tick, POST /god-command"
44
  )
45
  server.serve_forever()
46
 
@@ -50,10 +57,12 @@ def _build_handler(
50
  simulator: WorldSimulator,
51
  god_console: GodConsole | None = None,
52
  modal_health_warmer: _HealthWarmer | None = None,
 
53
  ) -> type[BaseHTTPRequestHandler]:
54
  lock = Lock()
55
  tick_status = _TickStatus()
56
  god_status = _GodCommandStatus()
 
57
 
58
  class GodSimulatorRequestHandler(BaseHTTPRequestHandler):
59
  server_version = "GodSimulator/0.1"
@@ -97,6 +106,9 @@ def _build_handler(
97
  tick_in_progress=tick_status.in_progress,
98
  pending_tick=tick_status.pending_tick,
99
  )
 
 
 
100
  if modal_health_warmer is not None:
101
  payload["simulation"]["models"] = modal_health_warmer.statuses()
102
  self._send_json(payload)
@@ -142,7 +154,12 @@ def _build_handler(
142
  assert planning_world is not None
143
  assert next_tick is not None
144
  try:
145
- planned_tick, plan = plan_world_tick(planning_world, simulator, next_tick)
 
 
 
 
 
146
  except Exception as exc:
147
  with lock:
148
  tick_status.in_progress = False
@@ -170,11 +187,87 @@ def _build_handler(
170
  self._handle_god_command()
171
  return
172
 
 
 
 
 
173
  self._send_json(
174
  {"error": "not_found", "path": self.path},
175
  status=HTTPStatus.NOT_FOUND,
176
  )
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  def log_message(self, format: str, *args: object) -> None:
179
  print(f"{self.address_string()} - {format % args}")
180
 
@@ -273,6 +366,9 @@ def _build_handler(
273
  self.send_header("Access-Control-Allow-Headers", "Content-Type")
274
 
275
  def _read_command(self) -> str:
 
 
 
276
  length_raw = self.headers.get("Content-Length")
277
  if not length_raw:
278
  raise ValueError("Missing request body.")
@@ -290,10 +386,10 @@ def _build_handler(
290
 
291
  if not isinstance(payload, dict):
292
  raise ValueError("Request body must be a JSON object.")
293
- command = payload.get("command")
294
- if not isinstance(command, str) or not command.strip():
295
- raise ValueError("Expected non-empty 'command'.")
296
- return command.strip()
297
 
298
  return GodSimulatorRequestHandler
299
 
@@ -309,6 +405,27 @@ class _GodCommandStatus:
309
  in_progress: bool = False
310
 
311
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  def _tick_status_payload(tick_status: _TickStatus) -> dict[str, Any]:
313
  return {
314
  "tick_in_progress": tick_status.in_progress,
 
3
  import json
4
  import time
5
  from copy import deepcopy
6
+ from dataclasses import dataclass, field
7
  from http import HTTPStatus
8
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
9
  from threading import Lock, Thread
 
15
  from god_simulator.config import ConnectorConfig, GameConfig
16
  from god_simulator.domain import WorldState
17
  from god_simulator.rendering.claw3d_contract import to_claw3d_snapshot
18
+ from god_simulator.simulation.chaos import (
19
+ CHAOS_COOLDOWNS,
20
+ CHAOS_TOOL_LABELS,
21
+ apply_chaos_action,
22
+ )
23
  from god_simulator.simulation.connectors.base import WorldSimulator
24
  from god_simulator.simulation.connectors.factory import create_world_simulator
25
  from god_simulator.simulation.god_console import (
26
  GodConsole,
27
  apply_god_command_plan,
28
  )
29
+ from god_simulator.simulation.overseer import OverseerController, create_overseer
30
  from god_simulator.simulation.tick import apply_tick_plan, plan_world_tick
31
 
32
 
33
  def run_server(*, world: WorldState, config: GameConfig) -> None:
34
  simulator = create_world_simulator(config)
35
+ overseer = create_overseer(config)
36
  god_console_config = config.god_console or config.connector
37
  god_console = (
38
  GodConsole(god_console_config)
 
40
  else None
41
  )
42
  modal_health_warmer = _ModalHealthWarmer(_modal_health_targets(config))
43
+ handler = _build_handler(world, simulator, god_console, modal_health_warmer, overseer)
44
  address = (config.server.host, config.server.port)
45
  server = ThreadingHTTPServer(address, handler)
46
  print(f"God Simulator API listening on http://{config.server.host}:{config.server.port}")
47
  print(f"World simulator connector: {simulator.name}")
48
  print(
49
  "Available endpoints: GET /health, GET /state, GET /claw3d/state, "
50
+ "POST /tick, POST /god-command, POST /chaos"
51
  )
52
  server.serve_forever()
53
 
 
57
  simulator: WorldSimulator,
58
  god_console: GodConsole | None = None,
59
  modal_health_warmer: _HealthWarmer | None = None,
60
+ overseer: OverseerController | None = None,
61
  ) -> type[BaseHTTPRequestHandler]:
62
  lock = Lock()
63
  tick_status = _TickStatus()
64
  god_status = _GodCommandStatus()
65
+ chaos_status = _ChaosStatus()
66
 
67
  class GodSimulatorRequestHandler(BaseHTTPRequestHandler):
68
  server_version = "GodSimulator/0.1"
 
106
  tick_in_progress=tick_status.in_progress,
107
  pending_tick=tick_status.pending_tick,
108
  )
109
+ payload["simulation"]["chaos_tools"] = _chaos_tools_payload(
110
+ chaos_status, world.tick
111
+ )
112
  if modal_health_warmer is not None:
113
  payload["simulation"]["models"] = modal_health_warmer.statuses()
114
  self._send_json(payload)
 
154
  assert planning_world is not None
155
  assert next_tick is not None
156
  try:
157
+ planned_tick, plan = plan_world_tick(
158
+ planning_world,
159
+ simulator,
160
+ next_tick,
161
+ overseer=overseer,
162
+ )
163
  except Exception as exc:
164
  with lock:
165
  tick_status.in_progress = False
 
187
  self._handle_god_command()
188
  return
189
 
190
+ if self.path == "/chaos":
191
+ self._handle_chaos_action()
192
+ return
193
+
194
  self._send_json(
195
  {"error": "not_found", "path": self.path},
196
  status=HTTPStatus.NOT_FOUND,
197
  )
198
 
199
+ def _handle_chaos_action(self) -> None:
200
+ try:
201
+ action = self._read_json_field("action")
202
+ except ValueError as exc:
203
+ self._send_json(
204
+ {"error": "invalid_request", "message": str(exc)},
205
+ status=HTTPStatus.BAD_REQUEST,
206
+ )
207
+ return
208
+
209
+ if action not in CHAOS_COOLDOWNS:
210
+ self._send_json(
211
+ {
212
+ "error": "unknown_chaos_action",
213
+ "message": f"Unknown chaos action: {action}",
214
+ "actions": sorted(CHAOS_COOLDOWNS),
215
+ },
216
+ status=HTTPStatus.BAD_REQUEST,
217
+ )
218
+ return
219
+
220
+ with lock:
221
+ if tick_status.in_progress:
222
+ self._send_json(
223
+ {
224
+ "error": "tick_in_progress",
225
+ "message": "Chaos tools are disabled while a tick is running.",
226
+ **_tick_status_payload(tick_status),
227
+ },
228
+ status=HTTPStatus.CONFLICT,
229
+ )
230
+ return
231
+
232
+ last_used = chaos_status.last_used.get(action)
233
+ if last_used is not None:
234
+ remaining = last_used + CHAOS_COOLDOWNS[action] - world.tick
235
+ if remaining > 0:
236
+ self._send_json(
237
+ {
238
+ "error": "chaos_on_cooldown",
239
+ "message": (
240
+ f"{CHAOS_TOOL_LABELS[action]} ready in "
241
+ f"{remaining} ticks."
242
+ ),
243
+ "remaining_ticks": remaining,
244
+ },
245
+ status=HTTPStatus.CONFLICT,
246
+ )
247
+ return
248
+
249
+ applied = apply_chaos_action(world, action)
250
+ if applied:
251
+ chaos_status.last_used[action] = world.tick
252
+ snapshot = to_claw3d_snapshot(
253
+ world,
254
+ tick_in_progress=tick_status.in_progress,
255
+ pending_tick=tick_status.pending_tick,
256
+ )
257
+ snapshot["simulation"]["chaos_tools"] = _chaos_tools_payload(
258
+ chaos_status, world.tick
259
+ )
260
+ response = {
261
+ "action": action,
262
+ "applied": applied,
263
+ "summary": applied[0]
264
+ if applied
265
+ else f"{CHAOS_TOOL_LABELS[action]} had no effect.",
266
+ "snapshot": snapshot,
267
+ }
268
+
269
+ self._send_json(response)
270
+
271
  def log_message(self, format: str, *args: object) -> None:
272
  print(f"{self.address_string()} - {format % args}")
273
 
 
366
  self.send_header("Access-Control-Allow-Headers", "Content-Type")
367
 
368
  def _read_command(self) -> str:
369
+ return self._read_json_field("command")
370
+
371
+ def _read_json_field(self, field: str) -> str:
372
  length_raw = self.headers.get("Content-Length")
373
  if not length_raw:
374
  raise ValueError("Missing request body.")
 
386
 
387
  if not isinstance(payload, dict):
388
  raise ValueError("Request body must be a JSON object.")
389
+ value = payload.get(field)
390
+ if not isinstance(value, str) or not value.strip():
391
+ raise ValueError(f"Expected non-empty '{field}'.")
392
+ return value.strip()
393
 
394
  return GodSimulatorRequestHandler
395
 
 
405
  in_progress: bool = False
406
 
407
 
408
+ @dataclass(slots=True)
409
+ class _ChaosStatus:
410
+ last_used: dict[str, int] = field(default_factory=dict)
411
+
412
+
413
+ def _chaos_tools_payload(chaos_status: _ChaosStatus, tick: int) -> list[dict[str, Any]]:
414
+ tools: list[dict[str, Any]] = []
415
+ for action, cooldown in CHAOS_COOLDOWNS.items():
416
+ last_used = chaos_status.last_used.get(action)
417
+ remaining = 0 if last_used is None else max(0, last_used + cooldown - tick)
418
+ tools.append(
419
+ {
420
+ "action": action,
421
+ "label": CHAOS_TOOL_LABELS[action],
422
+ "cooldown_ticks": cooldown,
423
+ "remaining_ticks": remaining,
424
+ }
425
+ )
426
+ return tools
427
+
428
+
429
  def _tick_status_payload(tick_status: _TickStatus) -> dict[str, Any]:
430
  return {
431
  "tick_in_progress": tick_status.in_progress,
src/god_simulator/config.py CHANGED
@@ -8,6 +8,7 @@ from typing import Any, Literal, cast
8
 
9
  TerrainKind = Literal["plain_green"]
10
  ConnectorKind = Literal["deterministic", "openai_compatible"]
 
11
 
12
 
13
  @dataclass(frozen=True, slots=True)
@@ -52,6 +53,16 @@ class ConnectorConfig:
52
  extra_body: dict[str, Any] | None = None
53
 
54
 
 
 
 
 
 
 
 
 
 
 
55
  @dataclass(frozen=True, slots=True)
56
  class GameConfig:
57
  world: WorldConfig
@@ -62,6 +73,7 @@ class GameConfig:
62
  default_factory=lambda: ConnectorConfig(type="deterministic")
63
  )
64
  god_console: ConnectorConfig | None = None
 
65
 
66
 
67
  def load_game_config(path: Path) -> GameConfig:
@@ -78,6 +90,7 @@ def parse_game_config(raw: dict[str, Any]) -> GameConfig:
78
  server = _required_mapping(raw, "server")
79
  connector = _optional_mapping(raw, "connector") or {"type": "deterministic"}
80
  god_console = _optional_mapping(raw, "god_console")
 
81
 
82
  terrain_raw = _required_str(world, "terrain")
83
  if terrain_raw != "plain_green":
@@ -100,6 +113,46 @@ def parse_game_config(raw: dict[str, Any]) -> GameConfig:
100
  ),
101
  connector=_parse_connector_config(connector),
102
  god_console=_parse_connector_config(god_console) if god_console else None,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  )
104
 
105
 
 
8
 
9
  TerrainKind = Literal["plain_green"]
10
  ConnectorKind = Literal["deterministic", "openai_compatible"]
11
+ OverseerMode = Literal["off", "advisor", "autopilot"]
12
 
13
 
14
  @dataclass(frozen=True, slots=True)
 
53
  extra_body: dict[str, Any] | None = None
54
 
55
 
56
+ @dataclass(frozen=True, slots=True)
57
+ class OverseerConfig:
58
+ mode: OverseerMode = "off"
59
+ cycle_ticks: int = 8
60
+ max_directives: int = 3
61
+ connector: ConnectorConfig = field(
62
+ default_factory=lambda: ConnectorConfig(type="deterministic")
63
+ )
64
+
65
+
66
  @dataclass(frozen=True, slots=True)
67
  class GameConfig:
68
  world: WorldConfig
 
73
  default_factory=lambda: ConnectorConfig(type="deterministic")
74
  )
75
  god_console: ConnectorConfig | None = None
76
+ overseer: OverseerConfig = field(default_factory=OverseerConfig)
77
 
78
 
79
  def load_game_config(path: Path) -> GameConfig:
 
90
  server = _required_mapping(raw, "server")
91
  connector = _optional_mapping(raw, "connector") or {"type": "deterministic"}
92
  god_console = _optional_mapping(raw, "god_console")
93
+ overseer = _optional_mapping(raw, "overseer")
94
 
95
  terrain_raw = _required_str(world, "terrain")
96
  if terrain_raw != "plain_green":
 
113
  ),
114
  connector=_parse_connector_config(connector),
115
  god_console=_parse_connector_config(god_console) if god_console else None,
116
+ overseer=_parse_overseer_config(overseer),
117
+ )
118
+
119
+
120
+ def _parse_overseer_config(raw: dict[str, Any] | None) -> OverseerConfig:
121
+ if raw is None:
122
+ return OverseerConfig()
123
+ mode_raw = _optional_str(raw, "mode") or "off"
124
+ if mode_raw not in ("off", "advisor", "autopilot"):
125
+ raise ValueError(f"Unsupported overseer mode: {mode_raw}")
126
+ connector_raw = _optional_mapping(raw, "connector")
127
+ if connector_raw is None:
128
+ connector_keys = {
129
+ "type",
130
+ "base_url",
131
+ "api_url",
132
+ "model",
133
+ "api_key_env",
134
+ "timeout_seconds",
135
+ "max_tokens",
136
+ "temperature",
137
+ "top_p",
138
+ "tool_choice",
139
+ "max_parallel_npc_calls",
140
+ "fallback_to_deterministic",
141
+ "extra_body",
142
+ "base_url_env",
143
+ "api_url_env",
144
+ "model_env",
145
+ }
146
+ connector_raw = {
147
+ key: value
148
+ for key, value in raw.items()
149
+ if key in connector_keys
150
+ } or {"type": "deterministic"}
151
+ return OverseerConfig(
152
+ mode=cast(OverseerMode, mode_raw),
153
+ cycle_ticks=_optional_positive_int(raw, "cycle_ticks") or 8,
154
+ max_directives=_optional_positive_int(raw, "max_directives") or 3,
155
+ connector=_parse_connector_config(connector_raw),
156
  )
157
 
158
 
src/god_simulator/domain.py CHANGED
@@ -24,6 +24,9 @@ CitizenMode = Literal[
24
  "fleeing",
25
  "recovering",
26
  ]
 
 
 
27
 
28
 
29
  @dataclass(frozen=True, slots=True)
@@ -123,12 +126,24 @@ class Npc:
123
  # Survival core-loop state (deterministic, engine-owned).
124
  hunger: float = 25.0
125
  fear: float = 0.0
 
 
 
 
 
126
  survival_goal: str = "routine_life"
127
  inventory_food: int = 0
128
  inventory_herbs: int = 0
129
  inventory_wood: int = 0
130
  inventory_weapon: int = 0
131
  help_target_id: str | None = None
 
 
 
 
 
 
 
132
 
133
 
134
  @dataclass(slots=True)
@@ -149,9 +164,35 @@ class Beast:
149
  attack_range: float = 1.5 # in tiles (one tile == one terrain block)
150
  speed: float = 1.0 # in tiles per tick
151
  target_npc_id: str | None = None
 
 
152
  state: str = "hunting" # hunting | attacking | retreating | dead
153
 
154
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
155
  @dataclass(slots=True)
156
  class Terrain:
157
  kind: Literal["plain_green"]
@@ -180,7 +221,29 @@ class WorldState:
180
  last_action_debug: list[dict[str, Any]] = field(default_factory=list)
181
  resource_nodes: list[ResourceNode] = field(default_factory=list)
182
  beasts: list[Beast] = field(default_factory=list)
 
183
  active_events: list[WorldEvent] = field(default_factory=list)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
184
 
185
  def to_dict(self) -> dict[str, Any]:
186
  return asdict(self)
 
24
  "fleeing",
25
  "recovering",
26
  ]
27
+ NpcRole = Literal["guard", "builder", "gatherer"]
28
+ HouseState = Literal["under_construction", "completed"]
29
+ GameStatus = Literal["running", "win_population", "win_survival", "lose"]
30
 
31
 
32
  @dataclass(frozen=True, slots=True)
 
126
  # Survival core-loop state (deterministic, engine-owned).
127
  hunger: float = 25.0
128
  fear: float = 0.0
129
+ safety: float = 0.0
130
+ age: int = 0
131
+ max_age: int = 400
132
+ reproduction_cooldown: int = 0
133
+ importance: float = 1.0
134
  survival_goal: str = "routine_life"
135
  inventory_food: int = 0
136
  inventory_herbs: int = 0
137
  inventory_wood: int = 0
138
  inventory_weapon: int = 0
139
  help_target_id: str | None = None
140
+ home_house_id: str | None = None
141
+ build_target_house_id: str | None = None
142
+ patrol_index: int = 0
143
+ resources_transferred: int = 0
144
+ beasts_killed: int = 0
145
+ children_count: int = 0
146
+ houses_built: int = 0
147
 
148
 
149
  @dataclass(slots=True)
 
164
  attack_range: float = 1.5 # in tiles (one tile == one terrain block)
165
  speed: float = 1.0 # in tiles per tick
166
  target_npc_id: str | None = None
167
+ target_house_id: str | None = None
168
+ retreat_ticks: int = 0
169
  state: str = "hunting" # hunting | attacking | retreating | dead
170
 
171
 
172
+ @dataclass(slots=True)
173
+ class House:
174
+ id: str
175
+ position: Vec3
176
+ owner_ids: list[str] = field(default_factory=list)
177
+ hp: float = 60.0
178
+ max_hp: float = 60.0
179
+ state: HouseState = "completed"
180
+ build_progress: int = 10
181
+ capacity: int = 3
182
+ occupant_ids: list[str] = field(default_factory=list)
183
+
184
+
185
+ @dataclass(slots=True)
186
+ class WorldLogEvent:
187
+ tick: int
188
+ type: str
189
+ summary: str
190
+ severity: Literal["good", "neutral", "warning", "danger"] = "neutral"
191
+ actor_id: str | None = None
192
+ target_id: str | None = None
193
+ object_id: str | None = None
194
+
195
+
196
  @dataclass(slots=True)
197
  class Terrain:
198
  kind: Literal["plain_green"]
 
221
  last_action_debug: list[dict[str, Any]] = field(default_factory=list)
222
  resource_nodes: list[ResourceNode] = field(default_factory=list)
223
  beasts: list[Beast] = field(default_factory=list)
224
+ houses: list[House] = field(default_factory=list)
225
  active_events: list[WorldEvent] = field(default_factory=list)
226
+ event_log: list[WorldLogEvent] = field(default_factory=list)
227
+ game_status: GameStatus = "running"
228
+ population: int = 0
229
+ population_cap: int = 12
230
+ total_births: int = 0
231
+ deaths_by_cause: dict[str, int] = field(default_factory=dict)
232
+ houses_built: int = 0
233
+ beasts_killed: int = 0
234
+ peak_population: int = 0
235
+ overseer_score: int = 0
236
+ chaos_score: int = 0
237
+ chaos_intervention_until: int = -1
238
+ famine_until: int = -1
239
+ famine_saved_max: dict[str, int] = field(default_factory=dict)
240
+ overseer_mode: Literal["off", "advisor", "autopilot"] = "off"
241
+ overseer_cycle_ticks: int = 8
242
+ overseer_last_tick: int | None = None
243
+ overseer_last_thoughts: str | None = None
244
+ overseer_priority_alert: str | None = None
245
+ overseer_last_directives: list[dict[str, Any]] = field(default_factory=list)
246
+ overseer_status: str = "idle"
247
 
248
  def to_dict(self) -> dict[str, Any]:
249
  return asdict(self)
src/god_simulator/rendering/claw3d_contract.py CHANGED
@@ -19,6 +19,30 @@ def to_claw3d_snapshot(
19
  "last_tick_source": world.last_tick_source,
20
  "tick_in_progress": tick_in_progress,
21
  "pending_tick": pending_tick,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  },
23
  "terrain": {
24
  "kind": world.terrain.kind,
@@ -53,7 +77,13 @@ def to_claw3d_snapshot(
53
  "is_alive": is_alive(npc),
54
  "hunger": round(npc.hunger, 1),
55
  "fear": round(npc.fear, 1),
 
 
 
 
 
56
  "goal": npc.survival_goal,
 
57
  "inventory": {
58
  "food": npc.inventory_food,
59
  "herbs": npc.inventory_herbs,
@@ -118,12 +148,31 @@ def to_claw3d_snapshot(
118
  "z": beast.position.z,
119
  },
120
  "health": round(beast.health, 1),
121
- "max_health": 180,
122
  "state": beast.state,
123
  "target_npc_id": beast.target_npc_id,
 
124
  }
125
  for beast in world.beasts
126
  ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  "active_events": [
128
  {
129
  "tick_created": event.tick_created,
@@ -141,4 +190,16 @@ def to_claw3d_snapshot(
141
  }
142
  for event in world.active_events
143
  ],
 
 
 
 
 
 
 
 
 
 
 
 
144
  }
 
19
  "last_tick_source": world.last_tick_source,
20
  "tick_in_progress": tick_in_progress,
21
  "pending_tick": pending_tick,
22
+ "game_status": world.game_status,
23
+ "population": world.population,
24
+ "population_cap": world.population_cap,
25
+ "peak_population": world.peak_population,
26
+ "scoreboard": {
27
+ "overseer": world.overseer_score,
28
+ "chaos": world.chaos_score,
29
+ },
30
+ "stats": {
31
+ "total_births": world.total_births,
32
+ "deaths_by_cause": dict(world.deaths_by_cause),
33
+ "houses_built": world.houses_built,
34
+ "beasts_killed": world.beasts_killed,
35
+ },
36
+ "famine_until": world.famine_until,
37
+ "overseer": {
38
+ "mode": world.overseer_mode,
39
+ "cycle_ticks": world.overseer_cycle_ticks,
40
+ "last_tick": world.overseer_last_tick,
41
+ "status": world.overseer_status,
42
+ "thoughts": world.overseer_last_thoughts,
43
+ "priority_alert": world.overseer_priority_alert,
44
+ "directives": list(world.overseer_last_directives),
45
+ },
46
  },
47
  "terrain": {
48
  "kind": world.terrain.kind,
 
77
  "is_alive": is_alive(npc),
78
  "hunger": round(npc.hunger, 1),
79
  "fear": round(npc.fear, 1),
80
+ "safety": round(npc.safety, 1),
81
+ "age": npc.age,
82
+ "max_age": npc.max_age,
83
+ "reproduction_cooldown": npc.reproduction_cooldown,
84
+ "importance": npc.importance,
85
  "goal": npc.survival_goal,
86
+ "home_house_id": npc.home_house_id,
87
  "inventory": {
88
  "food": npc.inventory_food,
89
  "herbs": npc.inventory_herbs,
 
148
  "z": beast.position.z,
149
  },
150
  "health": round(beast.health, 1),
151
+ "max_health": 60,
152
  "state": beast.state,
153
  "target_npc_id": beast.target_npc_id,
154
+ "target_house_id": beast.target_house_id,
155
  }
156
  for beast in world.beasts
157
  ],
158
+ "houses": [
159
+ {
160
+ "id": house.id,
161
+ "position": {
162
+ "x": house.position.x,
163
+ "y": house.position.y,
164
+ "z": house.position.z,
165
+ },
166
+ "owner_ids": list(house.owner_ids),
167
+ "hp": round(house.hp, 1),
168
+ "max_hp": round(house.max_hp, 1),
169
+ "state": house.state,
170
+ "build_progress": house.build_progress,
171
+ "capacity": house.capacity,
172
+ "occupant_ids": list(house.occupant_ids),
173
+ }
174
+ for house in world.houses
175
+ ],
176
  "active_events": [
177
  {
178
  "tick_created": event.tick_created,
 
190
  }
191
  for event in world.active_events
192
  ],
193
+ "event_log": [
194
+ {
195
+ "tick": event.tick,
196
+ "type": event.type,
197
+ "actor_id": event.actor_id,
198
+ "target_id": event.target_id,
199
+ "object_id": event.object_id,
200
+ "summary": event.summary,
201
+ "severity": event.severity,
202
+ }
203
+ for event in world.event_log[-50:]
204
+ ],
205
  }
src/god_simulator/simulation/chaos.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic Chaos tools (God Console quick-buttons).
2
+
3
+ Unlike the freeform LLM god console, these apply engine-validated effects
4
+ directly: beasts actually spawn, famine actually halves food nodes, and the
5
+ maniac directive flows through the existing hostile-directive pipeline.
6
+ Cooldowns (in ticks) follow GAME_DESIGN.md §11.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import random
12
+
13
+ from god_simulator.domain import Beast, WorldState
14
+ from god_simulator.simulation.mechanics import is_alive
15
+ from god_simulator.simulation.survival import (
16
+ _log_event,
17
+ _next_beast_index,
18
+ _random_map_edge_position,
19
+ )
20
+
21
+ CHAOS_COOLDOWNS: dict[str, int] = {
22
+ "spawn_beast": 20,
23
+ "beast_pack": 80,
24
+ "famine": 120,
25
+ "maniac": 100,
26
+ }
27
+ CHAOS_TOOL_LABELS: dict[str, str] = {
28
+ "spawn_beast": "Spawn Beast",
29
+ "beast_pack": "Beast Pack",
30
+ "famine": "Famine",
31
+ "maniac": "Maniac",
32
+ }
33
+ FAMINE_DURATION_TICKS = 180
34
+ MANIAC_DIRECTIVE = (
35
+ "Madness consumes you. Attack the nearest villager — ignore work, ignore friends."
36
+ )
37
+ CHAOS_BEAST_CAP = 6 # God Console may exceed MAX_BEASTS (3) up to this.
38
+
39
+
40
+ def apply_chaos_action(world: WorldState, action: str) -> list[str]:
41
+ """Apply one chaos quick-button. Returns human-readable applied lines."""
42
+ if action == "spawn_beast":
43
+ applied = _spawn_chaos_beasts(world, count=1)
44
+ elif action == "beast_pack":
45
+ applied = _spawn_chaos_beasts(world, count=3)
46
+ elif action == "famine":
47
+ applied = _apply_famine(world)
48
+ elif action == "maniac":
49
+ applied = _apply_maniac(world)
50
+ else:
51
+ raise ValueError(f"Unknown chaos action: {action}")
52
+
53
+ if applied:
54
+ world.chaos_intervention_until = max(
55
+ world.chaos_intervention_until, world.tick + 100
56
+ )
57
+ return applied
58
+
59
+
60
+ def _spawn_chaos_beasts(world: WorldState, *, count: int) -> list[str]:
61
+ living = sum(1 for beast in world.beasts if beast.state != "dead")
62
+ allowed = max(0, min(count, CHAOS_BEAST_CAP - living))
63
+ if allowed == 0:
64
+ return []
65
+
66
+ rng = random.Random(f"{world.seed}:{world.tick}:chaos_beast")
67
+ applied: list[str] = []
68
+ for _ in range(allowed):
69
+ beast = Beast(
70
+ id=f"beast_{_next_beast_index(world)}",
71
+ position=_random_map_edge_position(world, rng),
72
+ health=120.0,
73
+ damage=15.0,
74
+ )
75
+ world.beasts.append(beast)
76
+ _log_event(
77
+ world,
78
+ "chaos_event",
79
+ f"Chaos unleashed {beast.id} at the edge of the map",
80
+ severity="danger",
81
+ actor_id=beast.id,
82
+ )
83
+ applied.append(f"{beast.id} unleashed at the map edge.")
84
+ return applied
85
+
86
+
87
+ def _apply_famine(world: WorldState) -> list[str]:
88
+ if world.famine_until >= world.tick:
89
+ return []
90
+
91
+ affected = 0
92
+ for node in world.resource_nodes:
93
+ if node.resource_type != "food":
94
+ continue
95
+ world.famine_saved_max[node.id] = node.max_amount
96
+ node.amount //= 2
97
+ node.max_amount = max(1, node.max_amount // 2)
98
+ affected += 1
99
+ if affected == 0:
100
+ return []
101
+
102
+ world.famine_until = world.tick + FAMINE_DURATION_TICKS
103
+ _log_event(
104
+ world,
105
+ "chaos_event",
106
+ f"Famine grips the land: {affected} food nodes halved for "
107
+ f"{FAMINE_DURATION_TICKS} ticks",
108
+ severity="danger",
109
+ )
110
+ return [f"Famine: {affected} food nodes halved for {FAMINE_DURATION_TICKS} ticks."]
111
+
112
+
113
+ def end_famine_if_due(world: WorldState) -> None:
114
+ """Restore food node caps once the famine duration elapses."""
115
+ if world.famine_until < 0 or world.tick <= world.famine_until:
116
+ return
117
+ for node in world.resource_nodes:
118
+ saved = world.famine_saved_max.get(node.id)
119
+ if saved is not None:
120
+ node.max_amount = saved
121
+ world.famine_saved_max.clear()
122
+ world.famine_until = -1
123
+ _log_event(world, "chaos_event", "The famine has ended", severity="neutral")
124
+
125
+
126
+ def _apply_maniac(world: WorldState) -> list[str]:
127
+ candidates = [npc for npc in world.npcs if is_alive(npc)]
128
+ if not candidates:
129
+ return []
130
+
131
+ rng = random.Random(f"{world.seed}:{world.tick}:chaos_maniac")
132
+ victim = rng.choice(candidates)
133
+ victim.god_directive = MANIAC_DIRECTIVE
134
+ victim.personality = "violent maniac"
135
+ _log_event(
136
+ world,
137
+ "chaos_event",
138
+ f"{victim.name} has gone mad and turned on the village!",
139
+ severity="danger",
140
+ actor_id=victim.id,
141
+ )
142
+ return [f"{victim.name} has gone mad and turned on the village."]
src/god_simulator/simulation/connectors/base.py CHANGED
@@ -1,7 +1,7 @@
1
  from __future__ import annotations
2
 
3
  from dataclasses import dataclass
4
- from typing import Literal, Protocol, TypeAlias
5
 
6
  from god_simulator.domain import Vec3, WorldState
7
 
@@ -25,6 +25,10 @@ SurvivalActionKind: TypeAlias = Literal[
25
  "heal",
26
  "move_to_resource",
27
  "move_to",
 
 
 
 
28
  "communicate",
29
  "transfer",
30
  "trade",
@@ -100,6 +104,7 @@ class ActionDebugTrace:
100
  class TickPlan:
101
  source: str
102
  directives: list[NpcDirective]
 
103
 
104
 
105
  class WorldSimulator(Protocol):
 
1
  from __future__ import annotations
2
 
3
  from dataclasses import dataclass
4
+ from typing import Any, Literal, Protocol, TypeAlias
5
 
6
  from god_simulator.domain import Vec3, WorldState
7
 
 
25
  "heal",
26
  "move_to_resource",
27
  "move_to",
28
+ "go_home",
29
+ "patrol",
30
+ "build",
31
+ "wander",
32
  "communicate",
33
  "transfer",
34
  "trade",
 
104
  class TickPlan:
105
  source: str
106
  directives: list[NpcDirective]
107
+ overseer: dict[str, Any] | None = None
108
 
109
 
110
  class WorldSimulator(Protocol):
src/god_simulator/simulation/connectors/openai_compatible.py CHANGED
@@ -35,6 +35,7 @@ from god_simulator.simulation.memory import recent_meaningful_memories
35
  from god_simulator.simulation.perception import CitizenPerception
36
  from god_simulator.simulation.survival import (
37
  GOAL_ACTIONS,
 
38
  build_memory_summary,
39
  build_perception,
40
  build_relationships_summary,
@@ -212,6 +213,45 @@ TOOL_DEFINITIONS: dict[str, dict[str, Any]] = {
212
  },
213
  },
214
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  "transfer": {
216
  "type": "function",
217
  "function": {
@@ -274,6 +314,10 @@ TOOL_DEFINITIONS: dict[str, dict[str, Any]] = {
274
 
275
  def build_tool_list_for_goal(goal: str) -> list[dict[str, Any]]:
276
  allowed = GOAL_ACTIONS.get(goal, [])
 
 
 
 
277
  return [TOOL_DEFINITIONS[action] for action in allowed[:7] if action in TOOL_DEFINITIONS]
278
 
279
 
@@ -502,9 +546,9 @@ class OpenAICompatibleWorldSimulator:
502
 
503
  if is_survival_world(world):
504
  survival_goal = select_survival_goal(npc, world)
505
- allowed = GOAL_ACTIONS.get(survival_goal) or ["observe"]
506
  messages = _build_survival_messages(world, next_tick, npc, survival_goal, allowed)
507
- tools = build_tool_list_for_goal(survival_goal)
508
  if not tools:
509
  tools = _action_tools(cast(list[ActionKind], allowed))
510
  else:
@@ -642,15 +686,22 @@ def _build_survival_messages(
642
  "npc_id": npc.id,
643
  "tick": world.tick,
644
  "state": {
 
645
  "health": round(npc.health),
646
  "hunger": round(npc.hunger),
647
  "fear": round(npc.fear),
 
 
 
 
 
648
  "goal": goal,
649
  },
650
  "status": {
651
  "health": round(npc.health),
652
  "hunger": round(npc.hunger),
653
  "fear": round(npc.fear),
 
654
  },
655
  "inventory": {
656
  "food": npc.inventory_food,
@@ -660,6 +711,17 @@ def _build_survival_messages(
660
  },
661
  "goal": goal,
662
  "allowed_actions": list(allowed_actions),
 
 
 
 
 
 
 
 
 
 
 
663
  "perception": build_perception(npc, world),
664
  "memory": build_memory_summary(npc, last_n=3, current_tick=world.tick),
665
  "relationships": build_relationships_summary(npc, world),
@@ -681,6 +743,23 @@ def _build_survival_messages(
681
  ]
682
 
683
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
684
  def _npc_context(
685
  world: WorldState,
686
  npc: Npc,
@@ -915,6 +994,10 @@ def _action_kind(raw: object) -> ActionKind | None:
915
  "heal",
916
  "move_to_resource",
917
  "move_to",
 
 
 
 
918
  "communicate",
919
  "transfer",
920
  "trade",
 
35
  from god_simulator.simulation.perception import CitizenPerception
36
  from god_simulator.simulation.survival import (
37
  GOAL_ACTIONS,
38
+ allowed_actions_for_npc,
39
  build_memory_summary,
40
  build_perception,
41
  build_relationships_summary,
 
213
  },
214
  },
215
  },
216
+ "go_home": {
217
+ "type": "function",
218
+ "function": {
219
+ "name": "go_home",
220
+ "description": "Move into the nearest completed house to regain safety.",
221
+ "parameters": {
222
+ "type": "object",
223
+ "properties": {},
224
+ "required": [],
225
+ "additionalProperties": False,
226
+ },
227
+ },
228
+ },
229
+ "patrol": {
230
+ "type": "function",
231
+ "function": {
232
+ "name": "patrol",
233
+ "description": "Guard action: patrol between village houses and intercept threats.",
234
+ "parameters": {
235
+ "type": "object",
236
+ "properties": {},
237
+ "required": [],
238
+ "additionalProperties": False,
239
+ },
240
+ },
241
+ },
242
+ "build": {
243
+ "type": "function",
244
+ "function": {
245
+ "name": "build",
246
+ "description": "Builder action: spend wood to start or continue building a house.",
247
+ "parameters": {
248
+ "type": "object",
249
+ "properties": {},
250
+ "required": [],
251
+ "additionalProperties": False,
252
+ },
253
+ },
254
+ },
255
  "transfer": {
256
  "type": "function",
257
  "function": {
 
314
 
315
  def build_tool_list_for_goal(goal: str) -> list[dict[str, Any]]:
316
  allowed = GOAL_ACTIONS.get(goal, [])
317
+ return build_tool_list_for_actions(allowed)
318
+
319
+
320
+ def build_tool_list_for_actions(allowed: Sequence[str]) -> list[dict[str, Any]]:
321
  return [TOOL_DEFINITIONS[action] for action in allowed[:7] if action in TOOL_DEFINITIONS]
322
 
323
 
 
546
 
547
  if is_survival_world(world):
548
  survival_goal = select_survival_goal(npc, world)
549
+ allowed = allowed_actions_for_npc(world, npc, survival_goal)
550
  messages = _build_survival_messages(world, next_tick, npc, survival_goal, allowed)
551
+ tools = build_tool_list_for_actions(allowed)
552
  if not tools:
553
  tools = _action_tools(cast(list[ActionKind], allowed))
554
  else:
 
686
  "npc_id": npc.id,
687
  "tick": world.tick,
688
  "state": {
689
+ "role": npc.role,
690
  "health": round(npc.health),
691
  "hunger": round(npc.hunger),
692
  "fear": round(npc.fear),
693
+ "safety": round(npc.safety),
694
+ "age": npc.age,
695
+ "max_age": npc.max_age,
696
+ "reproduction_cooldown": npc.reproduction_cooldown,
697
+ "importance": npc.importance,
698
  "goal": goal,
699
  },
700
  "status": {
701
  "health": round(npc.health),
702
  "hunger": round(npc.hunger),
703
  "fear": round(npc.fear),
704
+ "safety": round(npc.safety),
705
  },
706
  "inventory": {
707
  "food": npc.inventory_food,
 
711
  },
712
  "goal": goal,
713
  "allowed_actions": list(allowed_actions),
714
+ "role_rules": _role_rules_for_prompt(npc.role),
715
+ "houses": [
716
+ {
717
+ "id": house.id,
718
+ "state": house.state,
719
+ "hp": round(house.hp),
720
+ "position": {"x": house.position.x, "z": house.position.z},
721
+ "occupants": list(house.occupant_ids),
722
+ }
723
+ for house in world.houses
724
+ ],
725
  "perception": build_perception(npc, world),
726
  "memory": build_memory_summary(npc, last_n=3, current_tick=world.tick),
727
  "relationships": build_relationships_summary(npc, world),
 
743
  ]
744
 
745
 
746
+ def _role_rules_for_prompt(role: str) -> list[str]:
747
+ if role == "guard":
748
+ return [
749
+ "You are a guard: attack, defend, and patrol threats near the village.",
750
+ "Do not gather resources.",
751
+ ]
752
+ if role == "builder":
753
+ return [
754
+ "You are a builder: gather only wood and use build to construct houses.",
755
+ "Do not attack.",
756
+ ]
757
+ return [
758
+ "You are a gatherer: gather resources, transfer supplies, eat, heal, flee, and communicate.",
759
+ "Do not choose attack unless the engine has specifically exposed it for self-defense.",
760
+ ]
761
+
762
+
763
  def _npc_context(
764
  world: WorldState,
765
  npc: Npc,
 
994
  "heal",
995
  "move_to_resource",
996
  "move_to",
997
+ "go_home",
998
+ "patrol",
999
+ "build",
1000
+ "wander",
1001
  "communicate",
1002
  "transfer",
1003
  "trade",
src/god_simulator/simulation/god_console.py CHANGED
@@ -277,9 +277,9 @@ def apply_god_command_plan(world: WorldState, plan: GodCommandPlan) -> list[str]
277
  if health is None:
278
  continue
279
  npc.health = health
280
- if npc.health >= 0 and npc.intention == "dead":
281
  npc.intention = "idle"
282
- if npc.health < 0:
283
  npc.intention = "dead"
284
  applied.append(f"{npc.name} health set to {npc.health}.")
285
  case "set_npc_attack_damage":
@@ -319,6 +319,8 @@ def apply_god_command_plan(world: WorldState, plan: GodCommandPlan) -> list[str]
319
  )
320
  applied.append(f"{npc.name} moved.")
321
 
 
 
322
  return applied
323
 
324
 
 
277
  if health is None:
278
  continue
279
  npc.health = health
280
+ if npc.health > 0 and npc.intention == "dead":
281
  npc.intention = "idle"
282
+ if npc.health <= 0:
283
  npc.intention = "dead"
284
  applied.append(f"{npc.name} health set to {npc.health}.")
285
  case "set_npc_attack_damage":
 
319
  )
320
  applied.append(f"{npc.name} moved.")
321
 
322
+ if applied:
323
+ world.chaos_intervention_until = max(world.chaos_intervention_until, world.tick + 100)
324
  return applied
325
 
326
 
src/god_simulator/simulation/mechanics.py CHANGED
@@ -14,7 +14,7 @@ MAX_WALK_DISTANCE = 1.0 * BLOCK_SIZE
14
 
15
 
16
  def is_alive(npc: Npc) -> bool:
17
- return npc.health >= 0
18
 
19
 
20
  def distance_between(first: Npc, second: Npc) -> float:
 
14
 
15
 
16
  def is_alive(npc: Npc) -> bool:
17
+ return npc.health > 0
18
 
19
 
20
  def distance_between(first: Npc, second: Npc) -> float:
src/god_simulator/simulation/overseer.py ADDED
@@ -0,0 +1,536 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ from typing import Any, Protocol, cast
8
+
9
+ from god_simulator.config import ConnectorConfig, GameConfig, OverseerConfig
10
+ from god_simulator.domain import Npc, WorldLogEvent, WorldState
11
+ from god_simulator.simulation.connectors.base import NpcDirective, TickPlan
12
+ from god_simulator.simulation.mechanics import is_alive, vec_distance
13
+ from god_simulator.simulation.roles import normalize_role
14
+ from god_simulator.simulation.survival import (
15
+ allowed_actions_for_npc,
16
+ nearest_beast,
17
+ nearest_house,
18
+ nearest_resource_for_npc,
19
+ select_goal,
20
+ )
21
+
22
+ PROMPT_PATH = Path(__file__).resolve().parents[3] / "prompts" / "overseer.md"
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class OverseerDirective:
27
+ npc_id: str
28
+ directive: str
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class OverseerDecision:
33
+ thoughts: str
34
+ directives: list[OverseerDirective]
35
+ priority_alert: str
36
+
37
+
38
+ class OverseerClient(Protocol):
39
+ def decide(self, context: dict[str, Any]) -> OverseerDecision:
40
+ """Return one strict Overseer decision for the supplied compact context."""
41
+
42
+
43
+ class OpenAICompatibleOverseerClient:
44
+ def __init__(
45
+ self,
46
+ config: ConnectorConfig,
47
+ *,
48
+ prompt_path: Path = PROMPT_PATH,
49
+ ) -> None:
50
+ self._config = config
51
+ self._prompt = prompt_path.read_text(encoding="utf-8")
52
+
53
+ def decide(self, context: dict[str, Any]) -> OverseerDecision:
54
+ if self._config.type != "openai_compatible":
55
+ raise ValueError("Overseer connector must be openai_compatible.")
56
+ if not self._config.model:
57
+ raise ValueError("Overseer connector requires a model.")
58
+ base_url = _resolve_base_url(self._config)
59
+ if not base_url:
60
+ raise ValueError("Overseer connector requires base_url or api_url.")
61
+
62
+ from openai import OpenAI
63
+
64
+ client = OpenAI(
65
+ api_key=_resolve_api_key(self._config),
66
+ base_url=base_url,
67
+ timeout=self._config.timeout_seconds,
68
+ max_retries=0,
69
+ )
70
+ request: dict[str, Any] = {
71
+ "model": self._config.model,
72
+ "messages": [
73
+ {"role": "system", "content": self._prompt},
74
+ {"role": "user", "content": json.dumps(context, ensure_ascii=False)},
75
+ ],
76
+ "temperature": self._config.temperature,
77
+ "top_p": self._config.top_p,
78
+ "max_tokens": self._config.max_tokens,
79
+ }
80
+ if self._config.extra_body:
81
+ request["extra_body"] = self._config.extra_body
82
+ completion = client.chat.completions.create(**request)
83
+ response = completion.model_dump()
84
+ content = response["choices"][0]["message"].get("content")
85
+ if not isinstance(content, str):
86
+ raise RuntimeError("Overseer response did not contain text content.")
87
+ return parse_overseer_decision(content)
88
+
89
+
90
+ class ScriptedOverseerClient:
91
+ """Deterministic fake used by headless tests."""
92
+
93
+ def decide(self, context: dict[str, Any]) -> OverseerDecision:
94
+ npcs = context.get("npcs", [])
95
+ guard = _first_role(npcs, "guard")
96
+ builder = _first_role(npcs, "builder")
97
+ gatherer = _first_role(npcs, "gatherer")
98
+ directives: list[OverseerDirective] = []
99
+ if guard is not None:
100
+ directives.append(
101
+ OverseerDirective(npc_id=guard["id"], directive="attack the nearest beast")
102
+ )
103
+ if builder is not None:
104
+ directives.append(
105
+ OverseerDirective(
106
+ npc_id=builder["id"],
107
+ directive="attack beast_1 even though this should be validated",
108
+ )
109
+ )
110
+ if gatherer is not None:
111
+ directives.append(OverseerDirective(npc_id=gatherer["id"], directive="go home"))
112
+ return OverseerDecision(
113
+ thoughts="Mock Overseer: test guard attack, invalid builder command, and shelter order.",
114
+ directives=directives,
115
+ priority_alert="Mock directive validation check",
116
+ )
117
+
118
+
119
+ @dataclass(slots=True)
120
+ class OverseerController:
121
+ mode: str
122
+ cycle_ticks: int
123
+ max_directives: int
124
+ client: OverseerClient | None
125
+
126
+ def augment_plan(self, world: WorldState, next_tick: int, plan: TickPlan) -> TickPlan:
127
+ if self.mode == "off" or self.client is None:
128
+ return plan
129
+ if self.cycle_ticks <= 0 or next_tick % self.cycle_ticks != 0:
130
+ return plan
131
+
132
+ context = build_overseer_context(world, next_tick)
133
+ try:
134
+ decision = self.client.decide(context)
135
+ except Exception as exc:
136
+ metadata = {
137
+ "mode": self.mode,
138
+ "tick": next_tick,
139
+ "status": "skipped",
140
+ "skipped_reason": str(exc),
141
+ "thoughts": "",
142
+ "priority_alert": "",
143
+ "directives": [],
144
+ }
145
+ return TickPlan(
146
+ source=f"{plan.source}+overseer_skipped",
147
+ directives=plan.directives,
148
+ overseer=metadata,
149
+ )
150
+
151
+ raw_directives = decision.directives[: self.max_directives]
152
+ issued: list[dict[str, Any]] = []
153
+ converted: list[NpcDirective] = []
154
+ if self.mode == "autopilot":
155
+ for raw in raw_directives:
156
+ directive = _directive_to_engine(world, raw)
157
+ issued.append(
158
+ {
159
+ "npc_id": raw.npc_id,
160
+ "directive": raw.directive,
161
+ "applied": directive is not None,
162
+ "action": directive.action if directive is not None else None,
163
+ }
164
+ )
165
+ if directive is not None:
166
+ converted.append(directive)
167
+ else:
168
+ issued = []
169
+
170
+ metadata = {
171
+ "mode": self.mode,
172
+ "tick": next_tick,
173
+ "status": "advisor"
174
+ if self.mode != "autopilot"
175
+ else ("applied" if converted else "no_directives"),
176
+ "thoughts": decision.thoughts,
177
+ "priority_alert": decision.priority_alert,
178
+ "directives": issued,
179
+ }
180
+ if self.mode != "autopilot":
181
+ return TickPlan(source=plan.source, directives=plan.directives, overseer=metadata)
182
+
183
+ merged = {directive.npc_id: directive for directive in plan.directives}
184
+ for directive in converted:
185
+ merged[directive.npc_id] = directive
186
+ return TickPlan(
187
+ source=f"{plan.source}+overseer",
188
+ directives=list(merged.values()),
189
+ overseer=metadata,
190
+ )
191
+
192
+
193
+ def create_overseer(config: GameConfig) -> OverseerController | None:
194
+ overseer_config = config.overseer
195
+ if overseer_config.mode == "off":
196
+ return OverseerController(
197
+ mode="off",
198
+ cycle_ticks=overseer_config.cycle_ticks,
199
+ max_directives=overseer_config.max_directives,
200
+ client=None,
201
+ )
202
+ return OverseerController(
203
+ mode=overseer_config.mode,
204
+ cycle_ticks=overseer_config.cycle_ticks,
205
+ max_directives=overseer_config.max_directives,
206
+ client=_client_from_config(overseer_config),
207
+ )
208
+
209
+
210
+ def scripted_overseer_controller(
211
+ *,
212
+ mode: str = "autopilot",
213
+ cycle_ticks: int = 1,
214
+ max_directives: int = 3,
215
+ ) -> OverseerController:
216
+ return OverseerController(
217
+ mode=mode,
218
+ cycle_ticks=cycle_ticks,
219
+ max_directives=max_directives,
220
+ client=ScriptedOverseerClient(),
221
+ )
222
+
223
+
224
+ def build_overseer_context(world: WorldState, next_tick: int) -> dict[str, Any]:
225
+ alive = [npc for npc in world.npcs if is_alive(npc)]
226
+ role_counts: dict[str, int] = {"gatherer": 0, "guard": 0, "builder": 0}
227
+ for npc in alive:
228
+ role_counts[normalize_role(npc.role)] += 1
229
+ critical = sorted(
230
+ alive,
231
+ key=lambda npc: (
232
+ npc.health,
233
+ -npc.hunger,
234
+ -npc.importance,
235
+ 0 if normalize_role(npc.role) == "builder" else 1,
236
+ npc.id,
237
+ ),
238
+ )[:10]
239
+ return {
240
+ "tick": world.tick,
241
+ "next_tick": next_tick,
242
+ "game_status": world.game_status,
243
+ "population": world.population,
244
+ "population_cap": world.population_cap,
245
+ "roles": role_counts,
246
+ "scoreboard": {
247
+ "overseer": world.overseer_score,
248
+ "chaos": world.chaos_score,
249
+ },
250
+ "npcs": [
251
+ {
252
+ "id": npc.id,
253
+ "name": npc.name,
254
+ "role": normalize_role(npc.role),
255
+ "hp": round(npc.health, 1),
256
+ "hunger": round(npc.hunger, 1),
257
+ "safety": round(npc.safety, 1),
258
+ "importance": npc.importance,
259
+ "goal": npc.survival_goal,
260
+ "inventory": {
261
+ "food": npc.inventory_food,
262
+ "herbs": npc.inventory_herbs,
263
+ "wood": npc.inventory_wood,
264
+ "weapon": npc.inventory_weapon,
265
+ },
266
+ "allowed_actions": allowed_actions_for_npc(world, npc, select_goal(npc, world)),
267
+ "position": {"x": npc.position.x, "z": npc.position.z},
268
+ }
269
+ for npc in critical
270
+ ],
271
+ "houses": [
272
+ {
273
+ "id": house.id,
274
+ "state": house.state,
275
+ "hp": round(house.hp, 1),
276
+ "capacity": house.capacity,
277
+ "occupants": list(house.occupant_ids),
278
+ "position": {"x": house.position.x, "z": house.position.z},
279
+ }
280
+ for house in world.houses
281
+ ],
282
+ "resources": _resource_totals(world),
283
+ "beasts": [
284
+ {
285
+ "id": beast.id,
286
+ "hp": round(beast.health, 1),
287
+ "state": beast.state,
288
+ "target_npc_id": beast.target_npc_id,
289
+ "target_house_id": beast.target_house_id,
290
+ "position": {"x": beast.position.x, "z": beast.position.z},
291
+ }
292
+ for beast in world.beasts
293
+ if beast.state != "dead"
294
+ ],
295
+ "current_threats": _current_threats(world),
296
+ "recent_events": [
297
+ {
298
+ "tick": event.tick,
299
+ "type": event.type,
300
+ "actor_id": event.actor_id,
301
+ "target_id": event.target_id,
302
+ "summary": event.summary,
303
+ "severity": event.severity,
304
+ }
305
+ for event in world.event_log[-20:]
306
+ ],
307
+ }
308
+
309
+
310
+ def parse_overseer_decision(content: str) -> OverseerDecision:
311
+ raw = json.loads(content)
312
+ if not isinstance(raw, dict):
313
+ raise ValueError("Overseer JSON must be an object.")
314
+ thoughts = _short_text(raw.get("thoughts"), limit=500)
315
+ priority_alert = _short_text(raw.get("priority_alert"), limit=240)
316
+ directives_raw = raw.get("directives")
317
+ if not isinstance(directives_raw, list):
318
+ raise ValueError("Overseer JSON must include a directives array.")
319
+ directives: list[OverseerDirective] = []
320
+ for item in directives_raw[:3]:
321
+ if not isinstance(item, dict):
322
+ continue
323
+ npc_id = _short_text(item.get("npc_id"), limit=80)
324
+ directive = _short_text(item.get("directive"), limit=240)
325
+ if npc_id and directive:
326
+ directives.append(OverseerDirective(npc_id=npc_id, directive=directive))
327
+ return OverseerDecision(
328
+ thoughts=thoughts or "",
329
+ directives=directives,
330
+ priority_alert=priority_alert or "",
331
+ )
332
+
333
+
334
+ def apply_overseer_metadata(world: WorldState, metadata: dict[str, Any] | None) -> None:
335
+ if metadata is None:
336
+ return
337
+ world.overseer_mode = str(metadata.get("mode") or world.overseer_mode)
338
+ world.overseer_last_tick = int(metadata.get("tick") or world.tick)
339
+ world.overseer_status = str(metadata.get("status") or "idle")
340
+ world.overseer_last_thoughts = _short_text(metadata.get("thoughts"), limit=500)
341
+ world.overseer_priority_alert = _short_text(metadata.get("priority_alert"), limit=240)
342
+ directives = metadata.get("directives")
343
+ world.overseer_last_directives = directives if isinstance(directives, list) else []
344
+
345
+ if world.overseer_status == "skipped":
346
+ reason = _short_text(metadata.get("skipped_reason"), limit=240) or "unknown error"
347
+ _append_log(
348
+ world,
349
+ "overseer_skipped",
350
+ f"Overseer skipped: {reason}",
351
+ severity="warning",
352
+ )
353
+ return
354
+
355
+ if world.overseer_last_thoughts:
356
+ _append_log(
357
+ world,
358
+ "overseer_thoughts",
359
+ world.overseer_last_thoughts,
360
+ severity="neutral",
361
+ )
362
+
363
+ npcs_by_id = {npc.id: npc for npc in world.npcs}
364
+ for directive in world.overseer_last_directives:
365
+ if not isinstance(directive, dict):
366
+ continue
367
+ npc_id = _short_text(directive.get("npc_id"), limit=80)
368
+ text = _short_text(directive.get("directive"), limit=240)
369
+ applied = directive.get("applied") is True
370
+ if not npc_id or not text:
371
+ continue
372
+ npc = npcs_by_id.get(npc_id)
373
+ if npc is not None and applied:
374
+ npc.god_directive = text
375
+ _append_log(
376
+ world,
377
+ "directive_issued",
378
+ f"Overseer {'applied' if applied else 'advised'} {npc_id}: {text}",
379
+ severity="good" if applied else "neutral",
380
+ actor_id="overseer",
381
+ target_id=npc_id,
382
+ )
383
+
384
+
385
+ def _client_from_config(config: OverseerConfig) -> OverseerClient | None:
386
+ if config.connector.type == "deterministic":
387
+ return ScriptedOverseerClient()
388
+ if config.connector.type == "openai_compatible":
389
+ return OpenAICompatibleOverseerClient(config.connector)
390
+ return None
391
+
392
+
393
+ def _directive_to_engine(world: WorldState, raw: OverseerDirective) -> NpcDirective | None:
394
+ npc = next((candidate for candidate in world.npcs if candidate.id == raw.npc_id), None)
395
+ if npc is None or not is_alive(npc):
396
+ return None
397
+ text = raw.directive.lower()
398
+ action = "go_home"
399
+ target_entity_id: str | None = None
400
+ resource_id: str | None = None
401
+ resource_type: str | None = None
402
+ communication_intent: str | None = None
403
+
404
+ if any(word in text for word in ("attack", "engage", "intercept", "fight")):
405
+ action = "attack"
406
+ beast = _mentioned_beast(world, text) or nearest_beast(world, npc.position)
407
+ target_entity_id = beast.id if beast is not None else None
408
+ elif "build" in text or "repair" in text:
409
+ action = "build"
410
+ elif "patrol" in text or "guard" in text:
411
+ action = "patrol" if normalize_role(npc.role) == "guard" else "go_home"
412
+ elif "defend" in text:
413
+ action = "defend"
414
+ elif "heal" in text:
415
+ action = "heal"
416
+ elif "eat" in text or "consume" in text or "food" in text and "gather" not in text:
417
+ action = "consume"
418
+ elif "wood" in text or "gather" in text or "resource" in text:
419
+ action = "move_to_resource"
420
+ if "wood" in text:
421
+ resource_type = "wood"
422
+ node = nearest_resource_for_npc(world, npc, resource_type=resource_type)
423
+ resource_id = node.id if node is not None else None
424
+ elif "help" in text or "warn" in text or "call" in text:
425
+ action = "communicate"
426
+ communication_intent = "help_request" if "help" in text else "warning"
427
+ elif "hide" in text or "home" in text or "house" in text or "inside" in text:
428
+ action = "go_home"
429
+
430
+ return NpcDirective(
431
+ npc_id=npc.id,
432
+ action=cast(Any, action),
433
+ target_entity_id=target_entity_id,
434
+ resource_id=resource_id,
435
+ resource_type=resource_type,
436
+ communication_intent=communication_intent,
437
+ message=raw.directive,
438
+ memory=f"Overseer directive: {raw.directive}",
439
+ intent="overseer_directive",
440
+ confidence=1.0,
441
+ )
442
+
443
+
444
+ def _resource_totals(world: WorldState) -> dict[str, int]:
445
+ totals: dict[str, int] = {}
446
+ for node in world.resource_nodes:
447
+ totals[node.resource_type] = totals.get(node.resource_type, 0) + node.amount
448
+ return totals
449
+
450
+
451
+ def _current_threats(world: WorldState) -> list[dict[str, Any]]:
452
+ threats: list[dict[str, Any]] = []
453
+ for beast in world.beasts:
454
+ if beast.state == "dead":
455
+ continue
456
+ nearest_npc = min(
457
+ (npc for npc in world.npcs if is_alive(npc)),
458
+ key=lambda npc: (vec_distance(beast.position, npc.position), npc.id),
459
+ default=None,
460
+ )
461
+ house = nearest_house(world, beast.position, completed_only=False)
462
+ threats.append(
463
+ {
464
+ "beast_id": beast.id,
465
+ "nearest_npc_id": nearest_npc.id if nearest_npc is not None else None,
466
+ "nearest_house_id": house.id if house is not None else None,
467
+ "state": beast.state,
468
+ }
469
+ )
470
+ return threats
471
+
472
+
473
+ def _mentioned_beast(world: WorldState, text: str):
474
+ for beast in world.beasts:
475
+ if beast.id.lower() in text and beast.state != "dead":
476
+ return beast
477
+ return None
478
+
479
+
480
+ def _first_role(npcs: object, role: str) -> dict[str, Any] | None:
481
+ if not isinstance(npcs, list):
482
+ return None
483
+ for npc in npcs:
484
+ if isinstance(npc, dict) and npc.get("role") == role:
485
+ return npc
486
+ return None
487
+
488
+
489
+ def _append_log(
490
+ world: WorldState,
491
+ event_type: str,
492
+ summary: str,
493
+ *,
494
+ severity: str,
495
+ actor_id: str | None = None,
496
+ target_id: str | None = None,
497
+ ) -> None:
498
+ safe_severity = severity if severity in {"good", "neutral", "warning", "danger"} else "neutral"
499
+ world.event_log.append(
500
+ WorldLogEvent(
501
+ tick=world.tick,
502
+ type=event_type,
503
+ actor_id=actor_id,
504
+ target_id=target_id,
505
+ object_id=None,
506
+ summary=summary,
507
+ severity=cast(Any, safe_severity),
508
+ )
509
+ )
510
+
511
+
512
+ def _short_text(raw: object, *, limit: int) -> str | None:
513
+ if not isinstance(raw, str):
514
+ return None
515
+ value = " ".join(raw.split())
516
+ if not value:
517
+ return None
518
+ return value[:limit]
519
+
520
+
521
+ def _resolve_base_url(config: ConnectorConfig) -> str | None:
522
+ base_url = config.base_url or config.api_url
523
+ if not base_url:
524
+ return None
525
+ if base_url.endswith("/chat/completions"):
526
+ return base_url.removesuffix("/chat/completions")
527
+ return base_url
528
+
529
+
530
+ def _resolve_api_key(config: ConnectorConfig) -> str:
531
+ if config.api_key_env:
532
+ api_key = os.getenv(config.api_key_env)
533
+ if api_key:
534
+ return api_key
535
+ raise ValueError(f"Missing Overseer API key environment variable: {config.api_key_env}")
536
+ return os.getenv("OPENAI_API_KEY") or "not-needed"
src/god_simulator/simulation/roles.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Iterable
4
+
5
+ VALID_ROLES = ("gatherer", "guard", "builder")
6
+
7
+ _ROLE_ALIASES = {
8
+ "farmer": "gatherer",
9
+ "forager": "gatherer",
10
+ "scribe": "gatherer",
11
+ }
12
+
13
+ _ACTION_ALIASES = {
14
+ "eat": "consume",
15
+ "talk": "communicate",
16
+ "call_for_help": "communicate",
17
+ "request_trade": "communicate",
18
+ "trade": "transfer",
19
+ "wander": "move_to",
20
+ }
21
+
22
+ _ACTION_ORDER = [
23
+ "move_to_resource",
24
+ "gather",
25
+ "transfer",
26
+ "consume",
27
+ "heal",
28
+ "flee",
29
+ "communicate",
30
+ "go_home",
31
+ "move_to",
32
+ "steal",
33
+ "attack",
34
+ "defend",
35
+ "patrol",
36
+ "build",
37
+ "idle",
38
+ ]
39
+
40
+ _SHARED_ACTIONS = {
41
+ "move_to_resource",
42
+ "consume",
43
+ "heal",
44
+ "flee",
45
+ "communicate",
46
+ "transfer",
47
+ "go_home",
48
+ "move_to",
49
+ "idle",
50
+ }
51
+
52
+ _ROLE_ACTIONS = {
53
+ "gatherer": _SHARED_ACTIONS | {"gather", "steal"},
54
+ "guard": (_SHARED_ACTIONS | {"attack", "defend", "patrol"}) - {"move_to_resource"},
55
+ "builder": _SHARED_ACTIONS | {"build", "gather"},
56
+ }
57
+
58
+
59
+ def normalize_role(role: str) -> str:
60
+ normalized = _ROLE_ALIASES.get(role, role)
61
+ return normalized if normalized in VALID_ROLES else "gatherer"
62
+
63
+
64
+ def canonical_action(action: str) -> str:
65
+ return _ACTION_ALIASES.get(action, action)
66
+
67
+
68
+ def actions_for_role(role: str) -> list[str]:
69
+ normalized = normalize_role(role)
70
+ allowed = _ROLE_ACTIONS[normalized]
71
+ return [action for action in _ACTION_ORDER if action in allowed]
72
+
73
+
74
+ def filter_actions_for_role(actions: Iterable[str], role: str) -> list[str]:
75
+ role_actions = set(actions_for_role(role))
76
+ filtered: list[str] = []
77
+ for action in actions:
78
+ canonical = canonical_action(action)
79
+ if canonical in role_actions and canonical not in filtered:
80
+ filtered.append(canonical)
81
+ return filtered
82
+
83
+
84
+ def action_allowed_for_role(
85
+ role: str,
86
+ action: str,
87
+ *,
88
+ resource_type: str | None = None,
89
+ self_defense: bool = False,
90
+ ) -> bool:
91
+ normalized_role = normalize_role(role)
92
+ canonical = canonical_action(action)
93
+ if canonical == "attack" and normalized_role == "gatherer" and self_defense:
94
+ return True
95
+ if canonical not in _ROLE_ACTIONS[normalized_role]:
96
+ return False
97
+ if canonical == "gather" and normalized_role == "builder":
98
+ return resource_type in (None, "wood")
99
+ return True
src/god_simulator/simulation/spawning.py CHANGED
@@ -5,6 +5,7 @@ import random
5
  from god_simulator.config import GameConfig
6
  from god_simulator.domain import (
7
  Beast,
 
8
  MemoryEntry,
9
  Npc,
10
  ResourceNode,
@@ -13,6 +14,7 @@ from god_simulator.domain import (
13
  WorldState,
14
  )
15
  from god_simulator.simulation.mechanics import BLOCK_SIZE, GRID_BLOCKS
 
16
 
17
  _NAMES = [
18
  "Ada",
@@ -37,7 +39,7 @@ _NAMES = [
37
  "Vera",
38
  ]
39
 
40
- _ROLES = ["farmer", "builder", "forager", "guard", "scribe"]
41
 
42
 
43
  def create_world(config: GameConfig) -> WorldState:
@@ -47,13 +49,14 @@ def create_world(config: GameConfig) -> WorldState:
47
  width=config.world.width,
48
  depth=config.world.depth,
49
  )
50
- npcs = [
51
- _spawn_npc(index=index, config=config, rng=rng)
52
- for index in range(config.npcs.count)
53
- ]
54
  if config.world.survival:
55
  _seed_survival_relationships(npcs)
 
 
56
 
 
57
  return WorldState(
58
  tick=0,
59
  seed=config.world.seed,
@@ -61,6 +64,11 @@ def create_world(config: GameConfig) -> WorldState:
61
  npcs=npcs,
62
  resource_nodes=_spawn_resource_nodes(config),
63
  beasts=_spawn_beasts(config),
 
 
 
 
 
64
  )
65
 
66
 
@@ -69,9 +77,21 @@ def _spawn_npc(*, index: int, config: GameConfig, rng: random.Random) -> Npc:
69
  half_depth = config.world.depth / 2
70
 
71
  name = _NAMES[index % len(_NAMES)]
72
- role = _ROLES[index % len(_ROLES)]
73
- x = _random_block_center(rng, half_width=half_width)
74
- z = _random_block_center(rng, half_width=half_depth)
 
 
 
 
 
 
 
 
 
 
 
 
75
 
76
  npc = Npc(
77
  id=f"npc-{index + 1:03d}",
@@ -80,15 +100,21 @@ def _spawn_npc(*, index: int, config: GameConfig, rng: random.Random) -> Npc:
80
  position=Vec3(x=x, y=0.0, z=z),
81
  attack_damage=rng.randint(5, 15),
82
  memory=[MemoryEntry(tick=0, text=f"{name} arrived as a {role}.")],
 
 
83
  )
84
 
85
  if config.world.survival:
86
  # Independent per-NPC RNG so survival inventory never perturbs the shared
87
  # position/damage stream (keeps non-survival spawning deterministic).
88
  inv_rng = random.Random(config.world.seed + index)
89
- npc.inventory_food = inv_rng.randint(0, 2)
 
 
90
  npc.inventory_herbs = inv_rng.randint(0, 1)
91
- npc.inventory_weapon = 1 if index % 3 == 0 else 0
 
 
92
 
93
  return npc
94
 
@@ -97,7 +123,7 @@ def _spawn_resource_nodes(config: GameConfig) -> list[ResourceNode]:
97
  if not config.world.survival:
98
  return []
99
  rng = random.Random(f"{config.world.seed}:initial_resources")
100
- counts = {"food": 5, "herbs": 3, "wood": 2, "weapon": 2}
101
  max_amounts = {"food": 5, "herbs": 4, "wood": 6, "weapon": 2}
102
  nodes: list[ResourceNode] = []
103
  for resource_type, count in counts.items():
@@ -122,15 +148,28 @@ def _spawn_resource_nodes(config: GameConfig) -> list[ResourceNode]:
122
  def _spawn_beasts(config: GameConfig) -> list[Beast]:
123
  if not config.world.survival:
124
  return []
125
- rng = random.Random(f"{config.world.seed}:initial_beasts")
126
  return [
127
  Beast(
128
  "beast_1",
129
- _random_world_position(
130
- rng,
131
- half_width=config.world.width / 2,
132
- half_depth=config.world.depth / 2,
133
- ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  )
135
  ]
136
 
@@ -143,6 +182,28 @@ def _seed_survival_relationships(npcs: list[Npc]) -> None:
143
  npc.relationships[other.id] = 0.45
144
 
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  def _random_block_center(rng: random.Random, *, half_width: float) -> float:
147
  first_center = -half_width + (BLOCK_SIZE / 2)
148
  return first_center + (rng.randrange(GRID_BLOCKS) * BLOCK_SIZE)
 
5
  from god_simulator.config import GameConfig
6
  from god_simulator.domain import (
7
  Beast,
8
+ House,
9
  MemoryEntry,
10
  Npc,
11
  ResourceNode,
 
14
  WorldState,
15
  )
16
  from god_simulator.simulation.mechanics import BLOCK_SIZE, GRID_BLOCKS
17
+ from god_simulator.simulation.roles import normalize_role
18
 
19
  _NAMES = [
20
  "Ada",
 
39
  "Vera",
40
  ]
41
 
42
+ _ROLES = ["gatherer", "gatherer", "gatherer", "guard", "guard", "builder"]
43
 
44
 
45
  def create_world(config: GameConfig) -> WorldState:
 
49
  width=config.world.width,
50
  depth=config.world.depth,
51
  )
52
+ npcs = [_spawn_npc(index=index, config=config, rng=rng) for index in range(config.npcs.count)]
53
+ houses = _spawn_houses(config)
 
 
54
  if config.world.survival:
55
  _seed_survival_relationships(npcs)
56
+ _assign_starting_homes(npcs, houses)
57
+ _initialize_importance(npcs)
58
 
59
+ population = sum(1 for npc in npcs if npc.health > 0)
60
  return WorldState(
61
  tick=0,
62
  seed=config.world.seed,
 
64
  npcs=npcs,
65
  resource_nodes=_spawn_resource_nodes(config),
66
  beasts=_spawn_beasts(config),
67
+ houses=houses,
68
+ population=population,
69
+ peak_population=population,
70
+ overseer_mode=config.overseer.mode,
71
+ overseer_cycle_ticks=config.overseer.cycle_ticks,
72
  )
73
 
74
 
 
77
  half_depth = config.world.depth / 2
78
 
79
  name = _NAMES[index % len(_NAMES)]
80
+ role = normalize_role(_ROLES[index % len(_ROLES)])
81
+ if config.world.survival:
82
+ ring = index % 6
83
+ offsets = [
84
+ (0.0, 0.0),
85
+ (1.2, 0.8),
86
+ (-1.0, -0.9),
87
+ (3.4, 0.0),
88
+ (-3.4, 0.0),
89
+ (0.0, 3.4),
90
+ ]
91
+ x, z = offsets[ring]
92
+ else:
93
+ x = _random_block_center(rng, half_width=half_width)
94
+ z = _random_block_center(rng, half_width=half_depth)
95
 
96
  npc = Npc(
97
  id=f"npc-{index + 1:03d}",
 
100
  position=Vec3(x=x, y=0.0, z=z),
101
  attack_damage=rng.randint(5, 15),
102
  memory=[MemoryEntry(tick=0, text=f"{name} arrived as a {role}.")],
103
+ age=60 if config.world.survival else 0,
104
+ max_age=_random_max_age(config.world.seed, index),
105
  )
106
 
107
  if config.world.survival:
108
  # Independent per-NPC RNG so survival inventory never perturbs the shared
109
  # position/damage stream (keeps non-survival spawning deterministic).
110
  inv_rng = random.Random(config.world.seed + index)
111
+ npc.hunger = 0.0 if index == 0 else 20.0 + inv_rng.randint(0, 10)
112
+ npc.safety = 100.0 if index == 0 else 0.0
113
+ npc.inventory_food = 2 if index == 0 else inv_rng.randint(0, 2)
114
  npc.inventory_herbs = inv_rng.randint(0, 1)
115
+ npc.inventory_weapon = 1 if role == "guard" else 0
116
+ if role == "builder" and index < 6:
117
+ npc.inventory_wood = 5
118
 
119
  return npc
120
 
 
123
  if not config.world.survival:
124
  return []
125
  rng = random.Random(f"{config.world.seed}:initial_resources")
126
+ counts = {"food": 4, "herbs": 2, "wood": 3, "weapon": 1}
127
  max_amounts = {"food": 5, "herbs": 4, "wood": 6, "weapon": 2}
128
  nodes: list[ResourceNode] = []
129
  for resource_type, count in counts.items():
 
148
  def _spawn_beasts(config: GameConfig) -> list[Beast]:
149
  if not config.world.survival:
150
  return []
 
151
  return [
152
  Beast(
153
  "beast_1",
154
+ Vec3(x=config.world.width / 2 - BLOCK_SIZE, y=0.0, z=0.0),
155
+ health=60.0,
156
+ damage=9.0,
157
+ )
158
+ ]
159
+
160
+
161
+ def _spawn_houses(config: GameConfig) -> list[House]:
162
+ if not config.world.survival:
163
+ return []
164
+ return [
165
+ House(
166
+ id="house_001",
167
+ position=Vec3(x=0.0, y=0.0, z=0.0),
168
+ hp=60.0,
169
+ max_hp=60.0,
170
+ state="completed",
171
+ build_progress=10,
172
+ capacity=3,
173
  )
174
  ]
175
 
 
182
  npc.relationships[other.id] = 0.45
183
 
184
 
185
+ def _assign_starting_homes(npcs: list[Npc], houses: list[House]) -> None:
186
+ if not houses:
187
+ return
188
+ home = houses[0]
189
+ for npc in npcs:
190
+ npc.home_house_id = home.id
191
+ home.occupant_ids = [npc.id for npc in npcs[: home.capacity]]
192
+
193
+
194
+ def _initialize_importance(npcs: list[Npc]) -> None:
195
+ counts = {"gatherer": 0, "guard": 0, "builder": 0}
196
+ for npc in npcs:
197
+ counts[normalize_role(npc.role)] += 1
198
+ for npc in npcs:
199
+ role_count = max(1, counts[normalize_role(npc.role)])
200
+ npc.importance = round(1.0 + 2.0 * (1 / role_count), 3)
201
+
202
+
203
+ def _random_max_age(seed: int, index: int) -> int:
204
+ return random.Random(f"{seed}:{index}:max_age").randint(320, 480)
205
+
206
+
207
  def _random_block_center(rng: random.Random, *, half_width: float) -> float:
208
  first_center = -half_width + (BLOCK_SIZE / 2)
209
  return first_center + (rng.randrange(GRID_BLOCKS) * BLOCK_SIZE)
src/god_simulator/simulation/survival.py CHANGED
@@ -14,7 +14,18 @@ from __future__ import annotations
14
  import math
15
  import random
16
 
17
- from god_simulator.domain import Beast, MemoryEpisode, Npc, ResourceNode, Vec3, WorldEvent, WorldState
 
 
 
 
 
 
 
 
 
 
 
18
  from god_simulator.simulation.connectors.base import NpcDirective, TickPlan
19
  from god_simulator.simulation.mechanics import (
20
  BLOCK_SIZE,
@@ -25,53 +36,74 @@ from god_simulator.simulation.mechanics import (
25
  vec_distance,
26
  )
27
  from god_simulator.simulation.memory import remember
 
 
 
 
 
 
28
 
29
  TILE = BLOCK_SIZE
30
 
31
  # Hunger / health economy.
32
- HUNGER_RATE = 2.0
33
- HUNGER_DAMAGE = 3
34
- STARVATION_THRESHOLD = 80.0
 
 
35
  HUNGER_CAP = 150.0
36
  HEAL_GOAL_HEALTH = 40 # health < this (with herbs) -> heal_self
37
  FIND_HERBS_HEALTH = 50 # health < this (without herbs) -> find_herbs
 
 
38
 
39
  # Awareness radii (world units).
40
  BEAST_ALERT_RADIUS = 6 * TILE
 
 
 
41
  ALLY_RADIUS = 5 * TILE
42
  WITNESS_RADIUS = 5 * TILE
43
- FEAR_DECAY_RADIUS = 8 * TILE
44
- GATHER_RADIUS = 2 * TILE
45
  TRADE_RADIUS = 2 * TILE
46
  PERCEPTION_BEAST_RADIUS = 8 * TILE
47
  PERCEPTION_RESOURCE_RADIUS = 6 * TILE
48
  PERCEPTION_NPC_RADIUS = 6 * TILE
49
  HELP_RADIUS = 6 * TILE
 
 
 
50
 
51
  # Fear dynamics.
52
- FEAR_ON_ATTACK = 40.0
53
  FEAR_ON_WITNESS = 20.0
54
- FEAR_DECAY = 5.0
 
 
55
 
56
  # Movement / combat (world units). Beasts are intentionally hard to solo and
57
  # damaging without instantly ending the village story.
58
  NPC_MOVE_STEP = 1 * TILE
59
  FLEE_STEP = 2 * TILE
60
  NPC_MELEE_REACH = 2 * TILE
61
- BASE_NPC_DAMAGE = 8
62
- WEAPON_DAMAGE_BONUS = 7
63
- BEAST_RETREAT_HEALTH = 35.0
 
 
 
64
 
65
  # Deterministic world-system spawning. Positions and ids are random-looking but
66
  # stable for a world seed/tick pair.
67
  RESOURCE_SPAWN_INTERVAL = 7
68
  MAX_RESOURCE_NODES = 18
69
- BEAST_SPAWN_INTERVAL = 25
70
- MAX_BEASTS = 2
71
 
72
  # Resource regrowth: every node regrows +1 toward its cap on this cadence, so the
73
  # settlement is sustainable instead of starving once nodes are first depleted.
74
- RESOURCE_REGEN_INTERVAL = 2
75
  RESOURCE_REGEN_AMOUNT = 1
76
  # How many of the nearest non-empty nodes foragers spread across (anti-clumping).
77
  SPREAD_NODE_CHOICES = 3
@@ -81,14 +113,17 @@ SHARE_FOOD_SURPLUS = 2
81
  # Goal -> allowed survival actions (the deterministic planner and any LLM both
82
  # choose from these sets; effects are resolved by the engine).
83
  GOAL_ACTIONS: dict[str, list[str]] = {
84
- "survive_threat_fight": ["attack", "defend", "communicate", "flee"],
 
85
  "survive_threat_flee": ["flee", "communicate", "defend"],
86
  "help_ally": ["attack", "defend", "communicate"],
87
  "heal_self": ["heal"],
88
  "eat_food": ["consume"],
89
  "find_food": ["move_to_resource", "gather", "communicate", "steal"],
90
  "find_herbs": ["move_to_resource", "gather", "communicate"],
91
- "routine_life": ["gather", "move_to_resource", "transfer", "communicate"],
 
 
92
  "dead": [],
93
  }
94
 
@@ -101,6 +136,10 @@ VALID_SURVIVAL_ACTIONS = frozenset(
101
  "find_herbs",
102
  "idle",
103
  "move_to",
 
 
 
 
104
  # Compatibility aliases accepted at validation boundaries.
105
  "eat",
106
  "talk",
@@ -303,6 +342,15 @@ def build_perception(npc: Npc, world: WorldState) -> str:
303
  f"[{node.resource_type}={node.amount}]"
304
  )
305
 
 
 
 
 
 
 
 
 
 
306
  for other in sorted(npcs_in_radius(world, npc.position, PERCEPTION_NPC_RADIUS, exclude_id=npc.id), key=lambda item: item.id):
307
  trust = npc.relationships.get(other.id, 0.0)
308
  label = _relationship_label(trust)
@@ -385,6 +433,26 @@ def nearest_alive_npc(
385
  return min(candidates, key=lambda other: (vec_distance(npc.position, other.position), other.id))
386
 
387
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
388
  def nearest_resource(
389
  world: WorldState,
390
  position: Vec3,
@@ -429,8 +497,57 @@ def count_alive_npcs_in_radius(world: WorldState, position: Vec3, radius: float)
429
  )
430
 
431
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  def apply_world_systems(world: WorldState) -> None:
433
  """Run deterministic environment systems before NPC decisions."""
 
 
 
 
434
  if world.resource_nodes:
435
  _regrow_resource_nodes(world)
436
  _maybe_spawn_resource_node(world)
@@ -439,10 +556,9 @@ def apply_world_systems(world: WorldState) -> None:
439
 
440
 
441
  def _regrow_resource_nodes(world: WorldState) -> None:
442
- if world.tick % RESOURCE_REGEN_INTERVAL != 0:
443
- return
444
  for node in world.resource_nodes:
445
- if node.max_amount > 0 and node.amount < node.max_amount:
 
446
  node.amount = min(node.max_amount, node.amount + RESOURCE_REGEN_AMOUNT)
447
 
448
 
@@ -475,18 +591,47 @@ def _maybe_spawn_beast(world: WorldState) -> None:
475
  living_beasts = [beast for beast in world.beasts if beast.state != "dead"]
476
  if len(living_beasts) >= MAX_BEASTS:
477
  return
478
- if world.tick == 0 or world.tick % BEAST_SPAWN_INTERVAL != 0:
 
 
 
479
  return
480
 
481
  rng = random.Random(f"{world.seed}:{world.tick}:beast_spawn")
482
- world.beasts.append(
483
- Beast(
484
- id=f"beast_{_next_beast_index(world)}",
485
- position=_random_map_position(world, rng),
486
- )
 
 
 
 
 
 
 
 
487
  )
488
 
489
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  def _next_resource_index(world: WorldState, resource_type: str) -> int:
491
  prefix = f"res_{resource_type}_"
492
  highest = 0
@@ -523,17 +668,34 @@ def _select_beast_target(world: WorldState, beast: Beast) -> Npc | None:
523
  npc
524
  for npc in world.npcs
525
  if is_alive(npc)
 
526
  ]
527
  if not candidates:
528
  return None
529
- return min(
530
- candidates,
531
- key=lambda npc: (
532
- vec_distance(beast.position, npc.position) + (max(0, npc.health) * 0.02),
533
- npc.inventory_weapon,
534
- npc.id,
535
- ),
536
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
 
538
 
539
  def _record_world_event(
@@ -557,6 +719,31 @@ def _record_world_event(
557
  )
558
 
559
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
560
  # --------------------------------------------------------------------------- #
561
  # Deterministic environment tick (no LLM)
562
  # --------------------------------------------------------------------------- #
@@ -575,118 +762,541 @@ def apply_survival_tick(world: WorldState) -> None:
575
 
576
  apply_world_systems(world)
577
 
578
- if not world.beasts and not world.resource_nodes:
579
  return
580
 
581
- half_width = world.terrain.width / 2
582
- half_depth = world.terrain.depth / 2
583
 
584
  for npc in world.npcs:
585
  if not is_alive(npc):
586
  continue
 
 
587
  npc.hunger = min(HUNGER_CAP, npc.hunger + HUNGER_RATE)
588
- if npc.hunger > STARVATION_THRESHOLD:
589
- npc.health -= HUNGER_DAMAGE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
590
 
591
  for beast in world.beasts:
592
  if beast.state == "dead":
593
  continue
 
594
  target = _select_beast_target(world, beast)
595
- if target is None:
 
 
 
 
 
 
 
596
  continue
597
- beast.target_npc_id = target.id
598
 
599
- distance = vec_distance(beast.position, target.position)
600
  step = beast.speed * TILE
601
  reach = beast.attack_range * TILE
602
 
603
  if beast.health < BEAST_RETREAT_HEALTH:
604
  beast.state = "retreating"
 
605
  beast.position = move_away(
606
  beast.position,
607
- target.position,
608
  step,
609
  half_width=half_width,
610
  half_depth=half_depth,
611
  )
 
 
 
 
 
 
 
 
 
612
  continue
613
 
614
- if distance <= reach:
615
- beast.state = "attacking"
616
- target.health -= int(beast.damage)
617
- target.fear = min(100.0, target.fear + FEAR_ON_ATTACK)
618
- add_episode(
619
- target,
620
- world.tick,
621
- "attack",
622
- beast.id,
623
- f"{beast.id} attacked me",
624
- target_id=target.id,
625
- subject_kind="beast",
626
- perspective="recipient",
627
- tags=["danger", "beast"],
628
- weight=1.0,
629
- )
630
- _record_world_event(
631
- world,
632
- "attack",
633
- f"{beast.id} attacked {target.name}",
634
- beast.position,
635
- radius=WITNESS_RADIUS,
636
- duration_ticks=4,
637
- )
638
- remember(target, world.tick, f"Beast {beast.id} attacked me at tick {world.tick}.")
639
- if not is_alive(target):
640
- target.intention = "dead"
641
- add_episode(
642
- target,
643
- world.tick,
644
- "death",
645
- beast.id,
646
- f"{target.name} was killed by {beast.id}",
647
- target_id=target.id,
648
- subject_kind="beast",
649
- perspective="recipient",
650
- tags=["danger", "beast"],
651
- weight=1.0,
652
- )
653
- remember(target, world.tick, "You were killed by the beast.")
654
- for witness in npcs_in_radius(world, beast.position, WITNESS_RADIUS, exclude_id=target.id):
655
- witness.fear = min(100.0, witness.fear + FEAR_ON_WITNESS)
656
- add_episode(
657
- witness,
658
- world.tick,
659
- "attack",
660
- beast.id,
661
- f"{beast.id} attacked {target.name} nearby",
662
- target_id=target.id,
663
- subject_kind="beast",
664
- perspective="witness",
665
- tags=["danger", "beast"],
666
- weight=0.8,
667
- )
668
- remember(witness, world.tick, f"The beast attacked {target.name} nearby.")
669
- else:
670
  beast.state = "hunting"
671
  beast.position = move_toward(
672
  beast.position,
673
- target.position,
674
  step,
675
  half_width=half_width,
676
  half_depth=half_depth,
677
  )
 
678
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
679
  for npc in world.npcs:
680
- if not is_alive(npc):
681
- npc.survival_goal = "dead"
682
  continue
683
- if nearest_beast_distance(world, npc) > FEAR_DECAY_RADIUS:
684
- npc.fear = max(0.0, npc.fear - FEAR_DECAY)
685
- # Keep the live goal current for the snapshot, even when planning ran on a
686
- # deep-copied world (the HTTP server path).
687
- npc.survival_goal = select_goal(npc, world)
688
- if world.tick > 0 and world.tick % 10 == 0:
689
- compress_to_facts(npc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
 
691
 
692
  # --------------------------------------------------------------------------- #
@@ -696,41 +1306,88 @@ def select_goal(npc: Npc, world: WorldState) -> str:
696
  if not is_alive(npc):
697
  return "dead"
698
 
 
 
699
  if npc.help_target_id:
700
  target = _npc_by_id(world, npc.help_target_id)
701
  if (
702
  target is not None
703
  and is_alive(target)
704
- and npc.inventory_weapon > 0
705
  and npc.relationships.get(target.id, 0.0) > 0.3
706
  and nearest_beast_distance(world, target) < BEAST_ALERT_RADIUS
707
  ):
708
  return "help_ally"
709
  npc.help_target_id = None
710
 
711
- beast_nearby = nearest_beast_distance(world, npc) < BEAST_ALERT_RADIUS
712
- has_weapon = npc.inventory_weapon > 0
713
- allies_nearby = _has_trusted_ally_nearby(world, npc)
714
 
715
- if beast_nearby and has_weapon and allies_nearby:
716
- return "survive_threat_fight"
 
717
  if beast_nearby:
718
  return "survive_threat_flee"
719
  if npc.health < HEAL_GOAL_HEALTH and npc.inventory_herbs > 0:
720
  return "heal_self"
721
- if npc.hunger > STARVATION_THRESHOLD and npc.inventory_food > 0:
 
 
 
 
722
  return "eat_food"
723
- if npc.hunger > STARVATION_THRESHOLD:
 
 
724
  return "find_food"
725
  if npc.health < FIND_HERBS_HEALTH and npc.inventory_herbs == 0:
726
  return "find_herbs"
727
- return "routine_life"
 
 
728
 
729
 
730
  def allowed_actions_for_goal(goal: str) -> list[str]:
731
  return list(GOAL_ACTIONS.get(goal, []))
732
 
733
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
734
  def validate_survival_action(
735
  npc: Npc,
736
  action: str,
@@ -739,10 +1396,27 @@ def validate_survival_action(
739
  ) -> str:
740
  """Return a safe, executable action, repairing impossible requests."""
741
  action = _canonical_action(action)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  if action == "gather":
743
  node = _resource_from_directive(world, directive, npc, max_dist=GATHER_RADIUS)
744
  if node is None or node.amount <= 0:
745
  return "move_to_resource"
 
 
746
  return "gather"
747
  if action == "consume":
748
  return "consume" if npc.inventory_food > 0 else "find_food"
@@ -753,6 +1427,18 @@ def validate_survival_action(
753
  if beast is None:
754
  return "defend"
755
  return "attack" # apply_action_effects approaches when out of melee reach
 
 
 
 
 
 
 
 
 
 
 
 
756
  if action == "steal":
757
  partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True)
758
  return "steal" if partner is not None else "move_to_resource"
@@ -763,19 +1449,43 @@ def validate_survival_action(
763
  if partner is None:
764
  partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS)
765
  return "transfer" if partner is not None else "communicate"
766
- if action in VALID_SURVIVAL_ACTIONS:
767
- return action
768
- return "idle"
769
 
770
 
771
  def _canonical_action(action: str) -> str:
772
- if action == "eat":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
773
  return "consume"
774
- if action in {"talk", "call_for_help", "request_trade"}:
775
- return "communicate"
776
- if action == "trade":
777
- return "transfer"
778
- return action
779
 
780
 
781
  # --------------------------------------------------------------------------- #
@@ -823,6 +1533,13 @@ def apply_action_effects(
823
  tags=["food"],
824
  weight=0.2,
825
  )
 
 
 
 
 
 
 
826
  return "eating food"
827
  return "searching for food"
828
 
@@ -830,12 +1547,22 @@ def apply_action_effects(
830
  if npc.inventory_herbs > 0:
831
  npc.inventory_herbs -= 1
832
  npc.health = min(100, npc.health + 25)
 
 
 
 
 
 
 
 
833
  return "healing with herbs"
834
  return "searching for herbs"
835
 
836
  if action == "gather":
837
  node = _resource_from_directive(world, directive, npc, max_dist=GATHER_RADIUS)
838
  if node is not None and node.amount > 0:
 
 
839
  node.amount -= 1
840
  _add_to_inventory(npc, node.resource_type)
841
  add_episode(
@@ -851,6 +1578,13 @@ def apply_action_effects(
851
  weight=0.3,
852
  )
853
  remember(npc, world.tick, f"You gathered {node.resource_type}.")
 
 
 
 
 
 
 
854
  return f"gathering {node.resource_type}"
855
  return "searching for resources"
856
 
@@ -878,6 +1612,36 @@ def apply_action_effects(
878
  )
879
  return "wandering"
880
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
881
  if action == "flee":
882
  beast = _beast_from_directive(world, npc, directive)
883
  if beast is not None:
@@ -896,7 +1660,7 @@ def apply_action_effects(
896
  if beast is None:
897
  return "defending"
898
  if vec_distance(npc.position, beast.position) <= NPC_MELEE_REACH:
899
- damage = BASE_NPC_DAMAGE + (WEAPON_DAMAGE_BONUS if npc.inventory_weapon > 0 else 0)
900
  beast.health -= damage
901
  npc.relationships[beast.id] = -1.0
902
  add_episode(
@@ -904,14 +1668,22 @@ def apply_action_effects(
904
  world.tick,
905
  "attack",
906
  npc.id,
907
- f"I struck {beast.id} for {damage} damage",
908
  target_id=beast.id,
909
- subject_kind="npc",
910
  perspective="self",
911
  tags=["danger", "beast"],
912
  weight=0.8,
913
  )
914
- remember(npc, world.tick, f"You struck {beast.id} for {damage} damage.")
 
 
 
 
 
 
 
 
915
  if npc.help_target_id:
916
  helped = _npc_by_id(world, npc.help_target_id)
917
  if helped is not None and is_alive(helped):
@@ -929,14 +1701,25 @@ def apply_action_effects(
929
  )
930
  if beast.health <= 0:
931
  beast.state = "dead"
 
 
 
932
  _record_world_event(
933
  world,
934
- "death",
935
  f"{npc.name} killed {beast.id}",
936
  beast.position,
937
  radius=WITNESS_RADIUS,
938
  duration_ticks=5,
939
  )
 
 
 
 
 
 
 
 
940
  remember(npc, world.tick, f"You killed {beast.id}!")
941
  return "killed the beast"
942
  return "fighting the beast"
@@ -955,27 +1738,34 @@ def apply_action_effects(
955
  partner = _transfer_partner(world, npc, directive)
956
  resource_type = _resource_type_from_directive(directive, default="food")
957
  amount = _amount_from_directive(directive, default=1)
958
- if (
959
- partner is not None
960
- and resource_type == "food"
961
  and npc.inventory_food >= amount
 
962
  and (npc.inventory_food >= SHARE_FOOD_SURPLUS or partner.hunger > npc.hunger)
963
  and partner.inventory_food < npc.inventory_food
964
- ):
965
- npc.inventory_food -= amount
966
- partner.inventory_food += amount
 
 
 
 
 
 
 
967
  _adjust_relationship(npc, partner.id, 0.2)
968
  add_episode(
969
  npc,
970
  world.tick,
971
  "transfer",
972
  npc.id,
973
- f"I gave {amount} food to {partner.name}",
974
  target_id=partner.id,
975
- object_id="food",
976
  subject_kind="npc",
977
  perspective="self",
978
- tags=["food", "help", "trade"],
979
  weight=0.5,
980
  )
981
  add_episode(
@@ -983,17 +1773,26 @@ def apply_action_effects(
983
  world.tick,
984
  "transfer",
985
  npc.id,
986
- f"{npc.name} gave me {amount} food",
987
  target_id=partner.id,
988
- object_id="food",
989
  subject_kind="npc",
990
  perspective="recipient",
991
- tags=["food", "help", "trade"],
992
  weight=0.7,
993
  )
994
- remember(npc, world.tick, f"Gave food to {partner.name}.")
995
- remember(partner, world.tick, f"Received food from {npc.name}.")
996
- return f"sharing food with {partner.name}"
 
 
 
 
 
 
 
 
 
997
  return "looking for someone to trade with"
998
 
999
  if action == "steal":
@@ -1173,6 +1972,175 @@ def _apply_communicate(
1173
  return "communicating"
1174
 
1175
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1176
  def resolve_call_for_help(caller: Npc, world: WorldState) -> list[Npc]:
1177
  helpers: list[Npc] = []
1178
  for helper in npcs_in_radius(world, caller.position, HELP_RADIUS, exclude_id=caller.id):
@@ -1208,7 +2176,7 @@ def _resource_from_directive(
1208
  node = next((node for node in world.resource_nodes if node.id == directive.resource_id), None)
1209
  if node is not None and (max_dist is None or vec_distance(npc.position, node.position) <= max_dist):
1210
  return node
1211
- return nearest_resource(world, npc.position, max_dist=max_dist)
1212
 
1213
 
1214
  def _beast_from_directive(
@@ -1299,26 +2267,55 @@ def _message_for_intent(intent: str) -> str:
1299
 
1300
 
1301
  def _add_to_inventory(npc: Npc, resource_type: str) -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1302
  if resource_type == "food":
1303
- npc.inventory_food += 1
1304
  elif resource_type == "herbs":
1305
- npc.inventory_herbs += 1
1306
  elif resource_type == "wood":
1307
- npc.inventory_wood += 1
1308
  elif resource_type == "weapon":
1309
- npc.inventory_weapon += 1
 
 
 
 
 
 
 
 
 
 
 
 
1310
 
1311
 
1312
  def _resource_destination(world: WorldState, npc: Npc, action: str) -> Vec3 | None:
1313
- if action == "find_food":
 
 
1314
  resource_type: str | None = "food"
1315
  elif action == "find_herbs":
1316
  resource_type = "herbs"
1317
  else:
1318
  resource_type = None
1319
- node = nearest_resource(world, npc.position, resource_type=resource_type)
1320
  if node is None:
1321
- node = nearest_resource(world, npc.position)
1322
  return node.position if node is not None else None
1323
 
1324
 
@@ -1334,6 +2331,8 @@ def _spread_resource(
1334
  Spreading foragers across several nearby nodes stops the whole settlement from
1335
  piling onto a single node and then starving together.
1336
  """
 
 
1337
  nodes = sorted(
1338
  (
1339
  node
@@ -1342,7 +2341,7 @@ def _spread_resource(
1342
  ),
1343
  key=lambda node: (vec_distance(npc.position, node.position), node.id),
1344
  )
1345
- if not nodes and resource_type is not None:
1346
  nodes = sorted(
1347
  (node for node in world.resource_nodes if node.amount > 0),
1348
  key=lambda node: (vec_distance(npc.position, node.position), node.id),
@@ -1432,7 +2431,7 @@ def _clamp(value: float, minimum: float, maximum: float) -> float:
1432
  # Deterministic survival planner + resolver
1433
  # --------------------------------------------------------------------------- #
1434
  def is_survival_world(world: WorldState) -> bool:
1435
- return bool(world.beasts or world.resource_nodes)
1436
 
1437
 
1438
  def survival_directive_for(world: WorldState, npc: Npc, next_tick: int) -> NpcDirective:
@@ -1483,6 +2482,8 @@ def apply_survival_plan(
1483
  directive.conversation_user,
1484
  directive.conversation_assistant,
1485
  )
 
 
1486
  if next_tick > 0 and next_tick % 10 == 0:
1487
  compress_to_facts(npc)
1488
  debug.append(
@@ -1535,6 +2536,14 @@ def _plan_directive(
1535
  intent=f"survival:{goal}",
1536
  )
1537
 
 
 
 
 
 
 
 
 
1538
  if goal == "survive_threat_fight":
1539
  if (next_tick + index) % 3 == 0:
1540
  return directive("communicate", communication_intent="help_request")
@@ -1556,6 +2565,16 @@ def _plan_directive(
1556
 
1557
  if goal in ("find_food", "find_herbs"):
1558
  resource_type = "food" if goal == "find_food" else "herbs"
 
 
 
 
 
 
 
 
 
 
1559
  if goal == "find_food":
1560
  partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True)
1561
  if (
@@ -1577,7 +2596,49 @@ def _plan_directive(
1577
  return directive("move_to_resource", target=node.position, resource_id=node.id)
1578
  return directive("move_to") # nothing to gather right now -> wander, don't freeze
1579
 
1580
- # routine_life: socialise, share, forage, or wander -- and spread out.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1581
  neighbor = nearest_alive_npc(world, npc, max_dist=TALK_RADIUS)
1582
  phase = (next_tick + index) % 4
1583
  if (
 
14
  import math
15
  import random
16
 
17
+ from god_simulator.domain import (
18
+ Beast,
19
+ MemoryEntry,
20
+ House,
21
+ MemoryEpisode,
22
+ Npc,
23
+ ResourceNode,
24
+ Vec3,
25
+ WorldEvent,
26
+ WorldLogEvent,
27
+ WorldState,
28
+ )
29
  from god_simulator.simulation.connectors.base import NpcDirective, TickPlan
30
  from god_simulator.simulation.mechanics import (
31
  BLOCK_SIZE,
 
36
  vec_distance,
37
  )
38
  from god_simulator.simulation.memory import remember
39
+ from god_simulator.simulation.roles import (
40
+ action_allowed_for_role,
41
+ canonical_action,
42
+ filter_actions_for_role,
43
+ normalize_role,
44
+ )
45
 
46
  TILE = BLOCK_SIZE
47
 
48
  # Hunger / health economy.
49
+ HUNGER_RATE = 0.8
50
+ STARVATION_THRESHOLD = 70.0
51
+ SEVERE_STARVATION_THRESHOLD = 90.0
52
+ STARVATION_DAMAGE = 0.5
53
+ SEVERE_STARVATION_DAMAGE = 1.5
54
  HUNGER_CAP = 150.0
55
  HEAL_GOAL_HEALTH = 40 # health < this (with herbs) -> heal_self
56
  FIND_HERBS_HEALTH = 50 # health < this (without herbs) -> find_herbs
57
+ EAT_GOAL_HUNGER = 60.0
58
+ REPRODUCTION_SNACK_HUNGER = 45.0
59
 
60
  # Awareness radii (world units).
61
  BEAST_ALERT_RADIUS = 6 * TILE
62
+ BEAST_THREAT_RADIUS = 8 * TILE
63
+ VILLAGE_THREAT_RADIUS = 10 * TILE
64
+ HOUSE_THREAT_RADIUS = 12 * TILE
65
  ALLY_RADIUS = 5 * TILE
66
  WITNESS_RADIUS = 5 * TILE
67
+ FEAR_DECAY_RADIUS = 12 * TILE
68
+ GATHER_RADIUS = 1.5 * TILE
69
  TRADE_RADIUS = 2 * TILE
70
  PERCEPTION_BEAST_RADIUS = 8 * TILE
71
  PERCEPTION_RESOURCE_RADIUS = 6 * TILE
72
  PERCEPTION_NPC_RADIUS = 6 * TILE
73
  HELP_RADIUS = 6 * TILE
74
+ HOUSE_RADIUS = 2.5 * TILE
75
+ HOUSE_BUILD_RADIUS = 2 * TILE
76
+ HOUSE_MIN_DISTANCE = 4 * TILE
77
 
78
  # Fear dynamics.
79
+ FEAR_ON_ATTACK = 60.0
80
  FEAR_ON_WITNESS = 20.0
81
+ FEAR_DECAY = 2.0
82
+ SAFETY_GAIN = 12.0
83
+ SAFETY_DECAY = 8.0
84
 
85
  # Movement / combat (world units). Beasts are intentionally hard to solo and
86
  # damaging without instantly ending the village story.
87
  NPC_MOVE_STEP = 1 * TILE
88
  FLEE_STEP = 2 * TILE
89
  NPC_MELEE_REACH = 2 * TILE
90
+ BASE_NPC_DAMAGE = 10
91
+ WEAPON_DAMAGE_BONUS = 0
92
+ GUARD_DAMAGE_MULTIPLIER = 1.5
93
+ WEAK_DAMAGE_MULTIPLIER = 0.3
94
+ BEAST_RETREAT_HEALTH = 20.0
95
+ BEAST_DESPAWN_TICKS = 15
96
 
97
  # Deterministic world-system spawning. Positions and ids are random-looking but
98
  # stable for a world seed/tick pair.
99
  RESOURCE_SPAWN_INTERVAL = 7
100
  MAX_RESOURCE_NODES = 18
101
+ BEAST_SPAWN_INTERVAL = 60
102
+ MAX_BEASTS = 3
103
 
104
  # Resource regrowth: every node regrows +1 toward its cap on this cadence, so the
105
  # settlement is sustainable instead of starving once nodes are first depleted.
106
+ RESOURCE_REGEN_INTERVALS = {"food": 8, "herbs": 12, "wood": 10, "weapon": 40}
107
  RESOURCE_REGEN_AMOUNT = 1
108
  # How many of the nearest non-empty nodes foragers spread across (anti-clumping).
109
  SPREAD_NODE_CHOICES = 3
 
113
  # Goal -> allowed survival actions (the deterministic planner and any LLM both
114
  # choose from these sets; effects are resolved by the engine).
115
  GOAL_ACTIONS: dict[str, list[str]] = {
116
+ "engage_threat": ["attack", "defend", "communicate", "patrol"],
117
+ "survive_threat_fight": ["attack", "defend", "communicate", "flee", "go_home"],
118
  "survive_threat_flee": ["flee", "communicate", "defend"],
119
  "help_ally": ["attack", "defend", "communicate"],
120
  "heal_self": ["heal"],
121
  "eat_food": ["consume"],
122
  "find_food": ["move_to_resource", "gather", "communicate", "steal"],
123
  "find_herbs": ["move_to_resource", "gather", "communicate"],
124
+ "settle": ["go_home", "consume", "communicate", "transfer"],
125
+ "work": ["gather", "move_to_resource", "transfer", "communicate", "build", "patrol"],
126
+ "routine_life": ["gather", "move_to_resource", "transfer", "communicate", "go_home", "build", "patrol"],
127
  "dead": [],
128
  }
129
 
 
136
  "find_herbs",
137
  "idle",
138
  "move_to",
139
+ "go_home",
140
+ "patrol",
141
+ "build",
142
+ "wander",
143
  # Compatibility aliases accepted at validation boundaries.
144
  "eat",
145
  "talk",
 
342
  f"[{node.resource_type}={node.amount}]"
343
  )
344
 
345
+ for house in sorted(world.houses, key=lambda item: vec_distance(npc.position, item.position)):
346
+ distance = vec_distance(npc.position, house.position)
347
+ if distance <= PERCEPTION_RESOURCE_RADIUS:
348
+ lines.append(
349
+ f"- {house.id} is {_tiles(distance):.0f} tiles "
350
+ f"{_direction(npc.position, house.position)} "
351
+ f"[{house.state} hp={house.hp:.0f} occupants={len(house.occupant_ids)}/{house.capacity}]"
352
+ )
353
+
354
  for other in sorted(npcs_in_radius(world, npc.position, PERCEPTION_NPC_RADIUS, exclude_id=npc.id), key=lambda item: item.id):
355
  trust = npc.relationships.get(other.id, 0.0)
356
  label = _relationship_label(trust)
 
433
  return min(candidates, key=lambda other: (vec_distance(npc.position, other.position), other.id))
434
 
435
 
436
+ def _nearest_role(
437
+ world: WorldState,
438
+ npc: Npc,
439
+ role: str,
440
+ *,
441
+ max_dist: float,
442
+ ) -> Npc | None:
443
+ candidates = [
444
+ other
445
+ for other in world.npcs
446
+ if other.id != npc.id
447
+ and is_alive(other)
448
+ and normalize_role(other.role) == role
449
+ and vec_distance(npc.position, other.position) <= max_dist
450
+ ]
451
+ if not candidates:
452
+ return None
453
+ return min(candidates, key=lambda other: (vec_distance(npc.position, other.position), other.id))
454
+
455
+
456
  def nearest_resource(
457
  world: WorldState,
458
  position: Vec3,
 
497
  )
498
 
499
 
500
+ def count_alive_population(world: WorldState) -> int:
501
+ return sum(1 for npc in world.npcs if is_alive(npc))
502
+
503
+
504
+ def completed_houses(world: WorldState) -> list[House]:
505
+ return [house for house in world.houses if house.state == "completed" and house.hp > 0]
506
+
507
+
508
+ def house_containing_npc(world: WorldState, npc: Npc) -> House | None:
509
+ for house in completed_houses(world):
510
+ if vec_distance(npc.position, house.position) <= HOUSE_RADIUS:
511
+ return house
512
+ return None
513
+
514
+
515
+ def is_npc_inside_house(world: WorldState, npc: Npc) -> bool:
516
+ return house_containing_npc(world, npc) is not None
517
+
518
+
519
+ def nearest_house(world: WorldState, position: Vec3, *, completed_only: bool = True) -> House | None:
520
+ houses = completed_houses(world) if completed_only else [house for house in world.houses if house.hp > 0]
521
+ if not houses:
522
+ return None
523
+ return min(houses, key=lambda house: (vec_distance(position, house.position), house.id))
524
+
525
+
526
+ def nearest_resource_for_npc(
527
+ world: WorldState,
528
+ npc: Npc,
529
+ *,
530
+ resource_type: str | None = None,
531
+ max_dist: float | None = None,
532
+ require_amount: bool = True,
533
+ ) -> ResourceNode | None:
534
+ if normalize_role(npc.role) == "builder":
535
+ resource_type = "wood"
536
+ return nearest_resource(
537
+ world,
538
+ npc.position,
539
+ resource_type=resource_type,
540
+ max_dist=max_dist,
541
+ require_amount=require_amount,
542
+ )
543
+
544
+
545
  def apply_world_systems(world: WorldState) -> None:
546
  """Run deterministic environment systems before NPC decisions."""
547
+ # Local import: chaos.py reuses survival helpers at module level.
548
+ from god_simulator.simulation.chaos import end_famine_if_due
549
+
550
+ end_famine_if_due(world)
551
  if world.resource_nodes:
552
  _regrow_resource_nodes(world)
553
  _maybe_spawn_resource_node(world)
 
556
 
557
 
558
  def _regrow_resource_nodes(world: WorldState) -> None:
 
 
559
  for node in world.resource_nodes:
560
+ interval = RESOURCE_REGEN_INTERVALS.get(node.resource_type, 10)
561
+ if world.tick % interval == 0 and node.max_amount > 0 and node.amount < node.max_amount:
562
  node.amount = min(node.max_amount, node.amount + RESOURCE_REGEN_AMOUNT)
563
 
564
 
 
591
  living_beasts = [beast for beast in world.beasts if beast.state != "dead"]
592
  if len(living_beasts) >= MAX_BEASTS:
593
  return
594
+ if count_alive_population(world) < 4:
595
+ return
596
+ cooldown = _natural_beast_spawn_interval(world)
597
+ if world.tick == 0 or world.tick % cooldown != 0:
598
  return
599
 
600
  rng = random.Random(f"{world.seed}:{world.tick}:beast_spawn")
601
+ beast = Beast(
602
+ id=f"beast_{_next_beast_index(world)}",
603
+ position=_random_map_edge_position(world, rng),
604
+ health=60.0,
605
+ damage=9.0,
606
+ )
607
+ world.beasts.append(beast)
608
+ _log_event(
609
+ world,
610
+ "beast_spawned",
611
+ f"{beast.id} appeared at the edge of the map",
612
+ severity="warning",
613
+ actor_id=beast.id,
614
  )
615
 
616
 
617
+ def _natural_beast_spawn_interval(world: WorldState) -> int:
618
+ rng = random.Random(f"{world.seed}:beast_spawn_interval:{world.tick // BEAST_SPAWN_INTERVAL}")
619
+ return rng.randint(60, 90)
620
+
621
+
622
+ def _random_map_edge_position(world: WorldState, rng: random.Random) -> Vec3:
623
+ half_width = world.terrain.width / 2
624
+ half_depth = world.terrain.depth / 2
625
+ side = rng.choice(["north", "south", "east", "west"])
626
+ if side == "north":
627
+ return Vec3(x=round(rng.uniform(-half_width, half_width), 3), y=0.0, z=half_depth)
628
+ if side == "south":
629
+ return Vec3(x=round(rng.uniform(-half_width, half_width), 3), y=0.0, z=-half_depth)
630
+ if side == "east":
631
+ return Vec3(x=half_width, y=0.0, z=round(rng.uniform(-half_depth, half_depth), 3))
632
+ return Vec3(x=-half_width, y=0.0, z=round(rng.uniform(-half_depth, half_depth), 3))
633
+
634
+
635
  def _next_resource_index(world: WorldState, resource_type: str) -> int:
636
  prefix = f"res_{resource_type}_"
637
  highest = 0
 
668
  npc
669
  for npc in world.npcs
670
  if is_alive(npc)
671
+ and not is_npc_inside_house(world, npc)
672
  ]
673
  if not candidates:
674
  return None
675
+ nearest_distance = min(vec_distance(beast.position, npc.position) for npc in candidates)
676
+ vulnerable = [
677
+ npc
678
+ for npc in candidates
679
+ if npc.health < 50 and vec_distance(beast.position, npc.position) <= nearest_distance * 1.3
680
+ ]
681
+ pool = vulnerable or candidates
682
+ return min(pool, key=lambda npc: (vec_distance(beast.position, npc.position), npc.health, npc.id))
683
+
684
+
685
+ def _select_house_target(world: WorldState, beast: Beast) -> House | None:
686
+ houses = completed_houses(world)
687
+ if not houses:
688
+ return None
689
+ occupied_ids = set()
690
+ for npc in world.npcs:
691
+ if not is_alive(npc):
692
+ continue
693
+ house = house_containing_npc(world, npc)
694
+ if house is not None:
695
+ occupied_ids.add(house.id)
696
+ occupied = [house for house in houses if house.id in occupied_ids]
697
+ pool = occupied or houses
698
+ return min(pool, key=lambda house: (vec_distance(beast.position, house.position), house.id))
699
 
700
 
701
  def _record_world_event(
 
719
  )
720
 
721
 
722
+ def _log_event(
723
+ world: WorldState,
724
+ event_type: str,
725
+ summary: str,
726
+ *,
727
+ severity: str = "neutral",
728
+ actor_id: str | None = None,
729
+ target_id: str | None = None,
730
+ object_id: str | None = None,
731
+ tick: int | None = None,
732
+ ) -> None:
733
+ normalized_severity = severity if severity in {"good", "neutral", "warning", "danger"} else "neutral"
734
+ world.event_log.append(
735
+ WorldLogEvent(
736
+ tick=world.tick if tick is None else tick,
737
+ type=event_type,
738
+ actor_id=actor_id,
739
+ target_id=target_id,
740
+ object_id=object_id,
741
+ summary=summary,
742
+ severity=normalized_severity, # type: ignore[arg-type]
743
+ )
744
+ )
745
+
746
+
747
  # --------------------------------------------------------------------------- #
748
  # Deterministic environment tick (no LLM)
749
  # --------------------------------------------------------------------------- #
 
762
 
763
  apply_world_systems(world)
764
 
765
+ if not world.beasts and not world.resource_nodes and not world.houses:
766
  return
767
 
768
+ _refresh_house_occupants(world)
 
769
 
770
  for npc in world.npcs:
771
  if not is_alive(npc):
772
  continue
773
+ npc.age += 1
774
+ npc.reproduction_cooldown = max(0, npc.reproduction_cooldown - 1)
775
  npc.hunger = min(HUNGER_CAP, npc.hunger + HUNGER_RATE)
776
+ if npc.hunger >= SEVERE_STARVATION_THRESHOLD:
777
+ npc.health -= SEVERE_STARVATION_DAMAGE
778
+ elif npc.hunger >= STARVATION_THRESHOLD:
779
+ npc.health -= STARVATION_DAMAGE
780
+ if npc.health <= 0:
781
+ _kill_npc(world, npc, cause="starvation", actor_id=npc.id)
782
+ continue
783
+ if npc.age >= npc.max_age:
784
+ _kill_npc(world, npc, cause="old_age", actor_id=npc.id)
785
+
786
+ _apply_beast_system(world)
787
+ _refresh_house_occupants(world)
788
+
789
+ for npc in world.npcs:
790
+ if not is_alive(npc):
791
+ npc.survival_goal = "dead"
792
+ continue
793
+ _update_safety(world, npc)
794
+ if nearest_beast_distance(world, npc) > FEAR_DECAY_RADIUS:
795
+ npc.fear = max(0.0, npc.fear - FEAR_DECAY)
796
+ # Keep the live goal current for the snapshot, even when planning ran on a
797
+ # deep-copied world (the HTTP server path).
798
+ npc.survival_goal = select_goal(npc, world)
799
+ if world.tick > 0 and world.tick % 10 == 0:
800
+ compress_to_facts(npc)
801
+
802
+
803
+ def apply_post_survival_tick(world: WorldState, next_tick: int) -> None:
804
+ """Run deterministic end-of-tick systems after NPC actions resolve."""
805
+ _refresh_house_occupants(world)
806
+ for npc in list(world.npcs):
807
+ if is_alive(npc):
808
+ _maybe_reproduce(world, npc, next_tick)
809
+ _refresh_house_occupants(world)
810
+ if next_tick % 10 == 0:
811
+ _recompute_importance(world)
812
+ _update_population_and_status(world, next_tick)
813
+
814
+
815
+ def _apply_beast_system(world: WorldState) -> None:
816
+ half_width = world.terrain.width / 2
817
+ half_depth = world.terrain.depth / 2
818
 
819
  for beast in world.beasts:
820
  if beast.state == "dead":
821
  continue
822
+
823
  target = _select_beast_target(world, beast)
824
+ house_target = None if target is not None else _select_house_target(world, beast)
825
+ beast.target_npc_id = target.id if target is not None else None
826
+ beast.target_house_id = house_target.id if house_target is not None else None
827
+
828
+ target_position = target.position if target is not None else (
829
+ house_target.position if house_target is not None else None
830
+ )
831
+ if target_position is None:
832
  continue
 
833
 
 
834
  step = beast.speed * TILE
835
  reach = beast.attack_range * TILE
836
 
837
  if beast.health < BEAST_RETREAT_HEALTH:
838
  beast.state = "retreating"
839
+ beast.retreat_ticks += 1
840
  beast.position = move_away(
841
  beast.position,
842
+ target_position,
843
  step,
844
  half_width=half_width,
845
  half_depth=half_depth,
846
  )
847
+ if beast.retreat_ticks >= BEAST_DESPAWN_TICKS:
848
+ beast.state = "dead"
849
+ _log_event(
850
+ world,
851
+ "beast_retreat",
852
+ f"{beast.id} fled the map",
853
+ severity="neutral",
854
+ actor_id=beast.id,
855
+ )
856
  continue
857
 
858
+ beast.retreat_ticks = 0
859
+ distance = vec_distance(beast.position, target_position)
860
+ if distance > reach:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
861
  beast.state = "hunting"
862
  beast.position = move_toward(
863
  beast.position,
864
+ target_position,
865
  step,
866
  half_width=half_width,
867
  half_depth=half_depth,
868
  )
869
+ continue
870
 
871
+ beast.state = "attacking"
872
+ if target is not None:
873
+ _beast_attack_npc(world, beast, target)
874
+ elif house_target is not None:
875
+ _damage_house(world, house_target, beast, amount=5.0)
876
+
877
+
878
+ def _beast_attack_npc(world: WorldState, beast: Beast, target: Npc) -> None:
879
+ if is_npc_inside_house(world, target):
880
+ house = house_containing_npc(world, target)
881
+ if house is not None:
882
+ _damage_house(world, house, beast, amount=5.0)
883
+ return
884
+
885
+ target.health -= beast.damage
886
+ target.fear = min(100.0, max(target.fear, FEAR_ON_ATTACK))
887
+ add_episode(
888
+ target,
889
+ world.tick,
890
+ "attack",
891
+ beast.id,
892
+ f"{beast.id} attacked me",
893
+ target_id=target.id,
894
+ subject_kind="beast",
895
+ perspective="recipient",
896
+ tags=["danger", "beast"],
897
+ weight=1.0,
898
+ )
899
+ _record_world_event(
900
+ world,
901
+ "beast_attack",
902
+ f"{beast.id} attacked {target.name}",
903
+ beast.position,
904
+ radius=WITNESS_RADIUS,
905
+ duration_ticks=4,
906
+ )
907
+ _log_event(
908
+ world,
909
+ "beast_attack",
910
+ f"{beast.id} attacked {target.name}",
911
+ severity="danger",
912
+ actor_id=beast.id,
913
+ target_id=target.id,
914
+ )
915
+ remember(target, world.tick, f"Beast {beast.id} attacked me at tick {world.tick}.")
916
+ if target.health <= 0:
917
+ _kill_npc(world, target, cause="beast", actor_id=beast.id)
918
+
919
+ for witness in npcs_in_radius(world, beast.position, WITNESS_RADIUS, exclude_id=target.id):
920
+ witness.fear = min(100.0, witness.fear + FEAR_ON_WITNESS)
921
+ add_episode(
922
+ witness,
923
+ world.tick,
924
+ "attack",
925
+ beast.id,
926
+ f"{beast.id} attacked {target.name} nearby",
927
+ target_id=target.id,
928
+ subject_kind="beast",
929
+ perspective="witness",
930
+ tags=["danger", "beast"],
931
+ weight=0.8,
932
+ )
933
+ remember(witness, world.tick, f"The beast attacked {target.name} nearby.")
934
+
935
+
936
+ def _damage_house(world: WorldState, house: House, beast: Beast, *, amount: float) -> None:
937
+ house.hp = max(0.0, house.hp - amount)
938
+ _record_world_event(
939
+ world,
940
+ "house_damaged",
941
+ f"{beast.id} damaged {house.id}",
942
+ house.position,
943
+ radius=WITNESS_RADIUS,
944
+ duration_ticks=4,
945
+ )
946
+ _log_event(
947
+ world,
948
+ "house_damaged",
949
+ f"{beast.id} damaged {house.id}",
950
+ severity="warning",
951
+ actor_id=beast.id,
952
+ target_id=house.id,
953
+ )
954
+ if house.hp > 0:
955
+ return
956
+
957
+ house.state = "under_construction"
958
+ house.occupant_ids = []
959
+ if _chaos_points_active(world):
960
+ world.chaos_score += 2
961
+ _record_world_event(
962
+ world,
963
+ "house_destroyed",
964
+ f"{house.id} was destroyed",
965
+ house.position,
966
+ radius=WITNESS_RADIUS,
967
+ duration_ticks=6,
968
+ )
969
+ _log_event(
970
+ world,
971
+ "house_destroyed",
972
+ f"{house.id} was destroyed",
973
+ severity="danger",
974
+ actor_id=beast.id,
975
+ target_id=house.id,
976
+ )
977
+ for npc in npcs_in_radius(world, house.position, HOUSE_RADIUS):
978
+ npc.fear = max(npc.fear, 80.0)
979
+ npc.safety = 0.0
980
+ remember(npc, world.tick, f"{house.id} was destroyed around you.")
981
+
982
+
983
+ def _kill_npc(world: WorldState, npc: Npc, *, cause: str, actor_id: str | None) -> None:
984
+ if npc.health <= 0 and npc.intention == "dead":
985
+ return
986
+ npc.health = min(npc.health, 0)
987
+ npc.intention = "dead"
988
+ npc.survival_goal = "dead"
989
+ world.deaths_by_cause[cause] = world.deaths_by_cause.get(cause, 0) + 1
990
+ if _chaos_points_active(world):
991
+ world.chaos_score += 3
992
+ add_episode(
993
+ npc,
994
+ world.tick,
995
+ "death",
996
+ actor_id or npc.id,
997
+ f"{npc.name} died of {cause}",
998
+ target_id=npc.id,
999
+ subject_kind="beast" if actor_id and actor_id.startswith("beast") else "npc",
1000
+ perspective="recipient",
1001
+ tags=["danger", "death", cause],
1002
+ weight=1.0,
1003
+ )
1004
+ remember(npc, world.tick, "You died.")
1005
+ _drop_inventory(world, npc)
1006
+ _record_world_event(
1007
+ world,
1008
+ "npc_died",
1009
+ f"{npc.name} died of {cause}",
1010
+ npc.position,
1011
+ radius=WITNESS_RADIUS,
1012
+ duration_ticks=6,
1013
+ )
1014
+ _log_event(
1015
+ world,
1016
+ "npc_died",
1017
+ f"{npc.name} died of {cause}",
1018
+ severity="danger",
1019
+ actor_id=actor_id,
1020
+ target_id=npc.id,
1021
+ object_id=cause,
1022
+ )
1023
+ for witness in npcs_in_radius(world, npc.position, WITNESS_RADIUS, exclude_id=npc.id):
1024
+ witness.fear = min(100.0, witness.fear + FEAR_ON_WITNESS)
1025
+ add_episode(
1026
+ witness,
1027
+ world.tick,
1028
+ "death",
1029
+ actor_id or npc.id,
1030
+ f"{npc.name} died of {cause}",
1031
+ target_id=npc.id,
1032
+ subject_kind="npc",
1033
+ perspective="witness",
1034
+ tags=["danger", "death", cause],
1035
+ weight=0.9,
1036
+ )
1037
+ remember(witness, world.tick, f"{npc.name} died of {cause}.")
1038
+
1039
+
1040
+ def _drop_inventory(world: WorldState, npc: Npc) -> None:
1041
+ for resource_type, amount in (
1042
+ ("food", npc.inventory_food),
1043
+ ("herbs", npc.inventory_herbs),
1044
+ ("wood", npc.inventory_wood),
1045
+ ("weapon", npc.inventory_weapon),
1046
+ ):
1047
+ if amount <= 0:
1048
+ continue
1049
+ index = _next_resource_index(world, resource_type)
1050
+ world.resource_nodes.append(
1051
+ ResourceNode(
1052
+ id=f"res_{resource_type}_{index}",
1053
+ resource_type=resource_type,
1054
+ position=npc.position,
1055
+ amount=amount,
1056
+ max_amount=max(amount, {"food": 5, "herbs": 4, "wood": 6, "weapon": 2}[resource_type]),
1057
+ )
1058
+ )
1059
+ npc.inventory_food = 0
1060
+ npc.inventory_herbs = 0
1061
+ npc.inventory_wood = 0
1062
+ npc.inventory_weapon = 0
1063
+
1064
+
1065
+ def _refresh_house_occupants(world: WorldState) -> None:
1066
+ for house in world.houses:
1067
+ if house.state != "completed" or house.hp <= 0:
1068
+ house.occupant_ids = []
1069
+ continue
1070
+ house.occupant_ids = [
1071
+ npc.id
1072
+ for npc in world.npcs
1073
+ if is_alive(npc) and vec_distance(npc.position, house.position) <= HOUSE_RADIUS
1074
+ ][: house.capacity]
1075
+
1076
+
1077
+ def _update_safety(world: WorldState, npc: Npc) -> None:
1078
+ house = house_containing_npc(world, npc)
1079
+ if house is None:
1080
+ npc.safety = max(0.0, npc.safety - SAFETY_DECAY)
1081
+ npc.needs.safety = round(npc.safety)
1082
+ return
1083
+
1084
+ threat_near_house = any(
1085
+ beast.state != "dead" and vec_distance(beast.position, house.position) <= HOUSE_THREAT_RADIUS
1086
+ for beast in world.beasts
1087
+ ) or any(
1088
+ other.id != npc.id
1089
+ and is_alive(other)
1090
+ and vec_distance(other.position, npc.position) <= 10 * TILE
1091
+ and _has_hostile_directive_toward(other, npc)
1092
+ for other in world.npcs
1093
+ )
1094
+ if not threat_near_house:
1095
+ npc.safety = min(100.0, npc.safety + SAFETY_GAIN)
1096
+ npc.needs.safety = round(npc.safety)
1097
+
1098
+
1099
+ def _has_hostile_directive_toward(actor: Npc, target: Npc) -> bool:
1100
+ if not actor.god_directive:
1101
+ return False
1102
+ lowered = actor.god_directive.lower()
1103
+ return "attack" in lowered or "kill" in lowered or target.name.lower() in lowered
1104
+
1105
+
1106
+ def _maybe_reproduce(world: WorldState, parent: Npc, next_tick: int) -> None:
1107
+ house = house_containing_npc(world, parent)
1108
+ if house is None:
1109
+ return
1110
+ if not _reproduction_conditions_met(world, parent):
1111
+ return
1112
+
1113
+ role = _role_for_child(world, parent)
1114
+ child_index = _next_npc_index(world)
1115
+ child = Npc(
1116
+ id=f"npc-{child_index:03d}",
1117
+ name=_child_name(child_index),
1118
+ role=role,
1119
+ position=house.position,
1120
+ health=70,
1121
+ hunger=20.0,
1122
+ fear=0.0,
1123
+ safety=100.0 if not _house_has_threat(world, house) else 0.0,
1124
+ age=0,
1125
+ max_age=random.Random(f"{world.seed}:{next_tick}:{child_index}:max_age").randint(320, 480),
1126
+ inventory_weapon=1 if role == "guard" else 0,
1127
+ home_house_id=house.id,
1128
+ memory=[MemoryEntry(tick=next_tick, text=f"Born in {house.id} as a {role}.")],
1129
+ )
1130
  for npc in world.npcs:
1131
+ if npc.id == child.id:
 
1132
  continue
1133
+ child.relationships[npc.id] = 0.45
1134
+ npc.relationships[child.id] = 0.45
1135
+ child.relationships[parent.id] = 0.6
1136
+ parent.relationships[child.id] = 0.6
1137
+ world.npcs.append(child)
1138
+ parent.children_count += 1
1139
+ parent.reproduction_cooldown = 100
1140
+ parent.hunger = min(HUNGER_CAP, parent.hunger + 40.0)
1141
+ world.total_births += 1
1142
+ world.overseer_score += 3
1143
+ add_episode(
1144
+ parent,
1145
+ next_tick,
1146
+ "npc_born",
1147
+ parent.id,
1148
+ f"I had a child: {child.name}",
1149
+ target_id=child.id,
1150
+ subject_kind="npc",
1151
+ perspective="self",
1152
+ tags=["birth", "family"],
1153
+ weight=0.8,
1154
+ )
1155
+ for witness in npcs_in_radius(world, house.position, HOUSE_RADIUS, exclude_id=parent.id):
1156
+ add_episode(
1157
+ witness,
1158
+ next_tick,
1159
+ "npc_born",
1160
+ parent.id,
1161
+ f"{parent.name} had a child: {child.name}",
1162
+ target_id=child.id,
1163
+ subject_kind="npc",
1164
+ perspective="witness",
1165
+ tags=["birth", "family"],
1166
+ weight=0.6,
1167
+ )
1168
+ _log_event(
1169
+ world,
1170
+ "npc_born",
1171
+ f"{parent.name} gave birth to {child.name}",
1172
+ severity="good",
1173
+ actor_id=parent.id,
1174
+ target_id=child.id,
1175
+ tick=next_tick,
1176
+ )
1177
+
1178
+
1179
+ def _reproduction_conditions_met(world: WorldState, npc: Npc) -> bool:
1180
+ return (
1181
+ npc.health >= 95
1182
+ and npc.hunger <= 5
1183
+ and round(npc.safety) >= 100
1184
+ and npc.age > 90
1185
+ and npc.reproduction_cooldown == 0
1186
+ and count_alive_population(world) < world.population_cap
1187
+ )
1188
+
1189
+
1190
+ def _house_has_threat(world: WorldState, house: House) -> bool:
1191
+ return any(
1192
+ beast.state != "dead" and vec_distance(beast.position, house.position) <= HOUSE_THREAT_RADIUS
1193
+ for beast in world.beasts
1194
+ )
1195
+
1196
+
1197
+ def _role_for_child(world: WorldState, parent: Npc) -> str:
1198
+ counts = {role: 0 for role in ("gatherer", "guard", "builder")}
1199
+ for npc in world.npcs:
1200
+ if is_alive(npc):
1201
+ counts[normalize_role(npc.role)] += 1
1202
+ total = max(1, sum(counts.values()))
1203
+ targets = {"gatherer": 3 / 6, "guard": 2 / 6, "builder": 1 / 6}
1204
+ scarcity = {
1205
+ role: targets[role] - (counts[role] / total)
1206
+ for role in counts
1207
+ }
1208
+ scarcest = max(scarcity, key=lambda role: (scarcity[role], -counts[role]))
1209
+ if scarcity[scarcest] > 0.05:
1210
+ return scarcest
1211
+ return normalize_role(parent.role)
1212
+
1213
+
1214
+ def _next_npc_index(world: WorldState) -> int:
1215
+ highest = 0
1216
+ for npc in world.npcs:
1217
+ suffix = npc.id.removeprefix("npc-")
1218
+ if suffix.isdigit():
1219
+ highest = max(highest, int(suffix))
1220
+ return highest + 1
1221
+
1222
+
1223
+ def _child_name(index: int) -> str:
1224
+ names = [
1225
+ "Ada",
1226
+ "Boris",
1227
+ "Cora",
1228
+ "Dima",
1229
+ "Elena",
1230
+ "Farid",
1231
+ "Gita",
1232
+ "Hana",
1233
+ "Ivan",
1234
+ "Juno",
1235
+ "Kira",
1236
+ "Lena",
1237
+ "Mira",
1238
+ "Niko",
1239
+ "Oleg",
1240
+ "Pavel",
1241
+ "Raya",
1242
+ "Sofia",
1243
+ "Toma",
1244
+ "Vera",
1245
+ ]
1246
+ return names[(index - 1) % len(names)]
1247
+
1248
+
1249
+ def _recompute_importance(world: WorldState) -> None:
1250
+ counts = {role: 0 for role in ("gatherer", "guard", "builder")}
1251
+ for npc in world.npcs:
1252
+ if is_alive(npc):
1253
+ counts[normalize_role(npc.role)] += 1
1254
+ for npc in world.npcs:
1255
+ role_count = max(1, counts.get(normalize_role(npc.role), 1))
1256
+ role_scarcity = 1 / role_count
1257
+ npc.importance = round(
1258
+ 1.0
1259
+ + 2.0 * role_scarcity
1260
+ + 0.5 * npc.beasts_killed
1261
+ + 0.3 * npc.resources_transferred
1262
+ + 0.4 * npc.children_count
1263
+ + 0.5 * npc.houses_built,
1264
+ 3,
1265
+ )
1266
+
1267
+
1268
+ def _update_population_and_status(world: WorldState, next_tick: int) -> None:
1269
+ population = count_alive_population(world)
1270
+ world.population = population
1271
+ world.peak_population = max(world.peak_population, population)
1272
+ if world.game_status != "running":
1273
+ return
1274
+ if population <= 0:
1275
+ world.game_status = "lose"
1276
+ elif population >= 12:
1277
+ world.game_status = "win_population"
1278
+ elif next_tick >= 600:
1279
+ world.game_status = "win_survival"
1280
+ if world.game_status != "running":
1281
+ _log_event(
1282
+ world,
1283
+ "game_over",
1284
+ _game_over_summary(world),
1285
+ severity="good" if world.game_status.startswith("win") else "danger",
1286
+ tick=next_tick,
1287
+ )
1288
+
1289
+
1290
+ def _game_over_summary(world: WorldState) -> str:
1291
+ return (
1292
+ f"{world.game_status}: births={world.total_births}, deaths={sum(world.deaths_by_cause.values())}, "
1293
+ f"houses_built={world.houses_built}, beasts_killed={world.beasts_killed}, "
1294
+ f"peak_population={world.peak_population}, score={world.overseer_score}-{world.chaos_score}"
1295
+ )
1296
+
1297
+
1298
+ def _chaos_points_active(world: WorldState) -> bool:
1299
+ return world.tick <= world.chaos_intervention_until
1300
 
1301
 
1302
  # --------------------------------------------------------------------------- #
 
1306
  if not is_alive(npc):
1307
  return "dead"
1308
 
1309
+ role = normalize_role(npc.role)
1310
+
1311
  if npc.help_target_id:
1312
  target = _npc_by_id(world, npc.help_target_id)
1313
  if (
1314
  target is not None
1315
  and is_alive(target)
1316
+ and role == "guard"
1317
  and npc.relationships.get(target.id, 0.0) > 0.3
1318
  and nearest_beast_distance(world, target) < BEAST_ALERT_RADIUS
1319
  ):
1320
  return "help_ally"
1321
  npc.help_target_id = None
1322
 
1323
+ if role == "guard" and _beast_threatens_village(world):
1324
+ return "engage_threat"
 
1325
 
1326
+ beast_nearby = nearest_beast_distance(world, npc) < BEAST_THREAT_RADIUS
1327
+ if beast_nearby and role == "guard":
1328
+ return "engage_threat"
1329
  if beast_nearby:
1330
  return "survive_threat_flee"
1331
  if npc.health < HEAL_GOAL_HEALTH and npc.inventory_herbs > 0:
1332
  return "heal_self"
1333
+ if (
1334
+ _is_reproduction_candidate(npc)
1335
+ and npc.inventory_food > 0
1336
+ and 5 < npc.hunger <= REPRODUCTION_SNACK_HUNGER
1337
+ ):
1338
  return "eat_food"
1339
+ if npc.hunger >= EAT_GOAL_HUNGER and npc.inventory_food > 0:
1340
+ return "eat_food"
1341
+ if npc.hunger >= EAT_GOAL_HUNGER:
1342
  return "find_food"
1343
  if npc.health < FIND_HERBS_HEALTH and npc.inventory_herbs == 0:
1344
  return "find_herbs"
1345
+ if npc.health >= 80 and npc.hunger <= 30 and not _has_any_living_beast_near(world, npc.position, HOUSE_THREAT_RADIUS):
1346
+ return "settle"
1347
+ return "work"
1348
 
1349
 
1350
  def allowed_actions_for_goal(goal: str) -> list[str]:
1351
  return list(GOAL_ACTIONS.get(goal, []))
1352
 
1353
 
1354
+ def allowed_actions_for_npc(world: WorldState, npc: Npc, goal: str) -> list[str]:
1355
+ _ = world
1356
+ allowed = filter_actions_for_role(allowed_actions_for_goal(goal), npc.role)
1357
+ if goal == "survive_threat_flee":
1358
+ allowed = [action for action in allowed if action != "transfer"]
1359
+ return allowed or ["idle"]
1360
+
1361
+
1362
+ def _beast_threatens_village(world: WorldState) -> bool:
1363
+ for beast in world.beasts:
1364
+ if beast.state == "dead":
1365
+ continue
1366
+ for house in completed_houses(world):
1367
+ if vec_distance(beast.position, house.position) <= VILLAGE_THREAT_RADIUS:
1368
+ return True
1369
+ for npc in world.npcs:
1370
+ if is_alive(npc) and vec_distance(beast.position, npc.position) <= VILLAGE_THREAT_RADIUS:
1371
+ return True
1372
+ return False
1373
+
1374
+
1375
+ def _has_any_living_beast_near(world: WorldState, position: Vec3, radius: float) -> bool:
1376
+ return any(
1377
+ beast.state != "dead" and vec_distance(beast.position, position) <= radius
1378
+ for beast in world.beasts
1379
+ )
1380
+
1381
+
1382
+ def _is_reproduction_candidate(npc: Npc) -> bool:
1383
+ return (
1384
+ npc.health >= 95
1385
+ and npc.age > 90
1386
+ and npc.reproduction_cooldown == 0
1387
+ and round(npc.safety) >= 100
1388
+ )
1389
+
1390
+
1391
  def validate_survival_action(
1392
  npc: Npc,
1393
  action: str,
 
1396
  ) -> str:
1397
  """Return a safe, executable action, repairing impossible requests."""
1398
  action = _canonical_action(action)
1399
+ if action not in VALID_SURVIVAL_ACTIONS:
1400
+ return "idle"
1401
+ resource_type = _resource_type_for_validation(world, npc, directive, action)
1402
+ self_defense = (
1403
+ action == "attack"
1404
+ and nearest_beast_distance(world, npc) <= NPC_MELEE_REACH
1405
+ and normalize_role(npc.role) == "gatherer"
1406
+ )
1407
+ if not action_allowed_for_role(
1408
+ npc.role,
1409
+ action,
1410
+ resource_type=resource_type,
1411
+ self_defense=self_defense,
1412
+ ):
1413
+ return _role_fallback_action(world, npc)
1414
  if action == "gather":
1415
  node = _resource_from_directive(world, directive, npc, max_dist=GATHER_RADIUS)
1416
  if node is None or node.amount <= 0:
1417
  return "move_to_resource"
1418
+ if normalize_role(npc.role) == "builder" and node.resource_type != "wood":
1419
+ return "move_to_resource"
1420
  return "gather"
1421
  if action == "consume":
1422
  return "consume" if npc.inventory_food > 0 else "find_food"
 
1427
  if beast is None:
1428
  return "defend"
1429
  return "attack" # apply_action_effects approaches when out of melee reach
1430
+ if action == "build":
1431
+ if normalize_role(npc.role) != "builder":
1432
+ return _role_fallback_action(world, npc)
1433
+ if npc.build_target_house_id:
1434
+ house = _house_by_id(world, npc.build_target_house_id)
1435
+ if house is not None and house.state == "under_construction":
1436
+ return "build"
1437
+ return "build" if npc.inventory_wood >= 5 else "move_to_resource"
1438
+ if action == "go_home":
1439
+ return "go_home" if nearest_house(world, npc.position) is not None else "move_to"
1440
+ if action == "patrol":
1441
+ return "patrol" if normalize_role(npc.role) == "guard" else _role_fallback_action(world, npc)
1442
  if action == "steal":
1443
  partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True)
1444
  return "steal" if partner is not None else "move_to_resource"
 
1449
  if partner is None:
1450
  partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS)
1451
  return "transfer" if partner is not None else "communicate"
1452
+ return action
 
 
1453
 
1454
 
1455
  def _canonical_action(action: str) -> str:
1456
+ return canonical_action(action)
1457
+
1458
+
1459
+ def _resource_type_for_validation(
1460
+ world: WorldState,
1461
+ npc: Npc,
1462
+ directive: NpcDirective | None,
1463
+ action: str,
1464
+ ) -> str | None:
1465
+ if directive is not None and directive.resource_type:
1466
+ return directive.resource_type
1467
+ if directive is not None and directive.resource_id:
1468
+ node = next((node for node in world.resource_nodes if node.id == directive.resource_id), None)
1469
+ return node.resource_type if node is not None else None
1470
+ if action in {"gather", "move_to_resource"}:
1471
+ node = nearest_resource_for_npc(world, npc)
1472
+ return node.resource_type if node is not None else None
1473
+ return None
1474
+
1475
+
1476
+ def _role_fallback_action(world: WorldState, npc: Npc) -> str:
1477
+ role = normalize_role(npc.role)
1478
+ if role == "guard":
1479
+ if nearest_beast(world, npc.position) is not None:
1480
+ return "attack"
1481
+ return "patrol"
1482
+ if role == "builder":
1483
+ if npc.inventory_wood >= 5:
1484
+ return "build"
1485
+ return "move_to_resource"
1486
+ if npc.inventory_food > 0 and npc.hunger >= EAT_GOAL_HUNGER:
1487
  return "consume"
1488
+ return "move_to_resource"
 
 
 
 
1489
 
1490
 
1491
  # --------------------------------------------------------------------------- #
 
1533
  tags=["food"],
1534
  weight=0.2,
1535
  )
1536
+ _log_event(
1537
+ world,
1538
+ "consume",
1539
+ f"{npc.name} ate food",
1540
+ actor_id=npc.id,
1541
+ object_id="food",
1542
+ )
1543
  return "eating food"
1544
  return "searching for food"
1545
 
 
1547
  if npc.inventory_herbs > 0:
1548
  npc.inventory_herbs -= 1
1549
  npc.health = min(100, npc.health + 25)
1550
+ _log_event(
1551
+ world,
1552
+ "heal",
1553
+ f"{npc.name} healed with herbs",
1554
+ severity="good",
1555
+ actor_id=npc.id,
1556
+ object_id="herbs",
1557
+ )
1558
  return "healing with herbs"
1559
  return "searching for herbs"
1560
 
1561
  if action == "gather":
1562
  node = _resource_from_directive(world, directive, npc, max_dist=GATHER_RADIUS)
1563
  if node is not None and node.amount > 0:
1564
+ if normalize_role(npc.role) == "builder" and node.resource_type != "wood":
1565
+ return "searching for wood"
1566
  node.amount -= 1
1567
  _add_to_inventory(npc, node.resource_type)
1568
  add_episode(
 
1578
  weight=0.3,
1579
  )
1580
  remember(npc, world.tick, f"You gathered {node.resource_type}.")
1581
+ _log_event(
1582
+ world,
1583
+ "gather",
1584
+ f"{npc.name} gathered {node.resource_type}",
1585
+ actor_id=npc.id,
1586
+ object_id=node.id,
1587
+ )
1588
  return f"gathering {node.resource_type}"
1589
  return "searching for resources"
1590
 
 
1612
  )
1613
  return "wandering"
1614
 
1615
+ if action == "go_home":
1616
+ house = nearest_house(world, npc.position)
1617
+ if house is not None:
1618
+ npc.home_house_id = house.id
1619
+ npc.position = move_toward(
1620
+ npc.position,
1621
+ house.position,
1622
+ NPC_MOVE_STEP,
1623
+ half_width=half_width,
1624
+ half_depth=half_depth,
1625
+ )
1626
+ return f"going home to {house.id}"
1627
+ return "looking for home"
1628
+
1629
+ if action == "patrol":
1630
+ target = _patrol_target(world, npc)
1631
+ if target is not None:
1632
+ npc.position = move_toward(
1633
+ npc.position,
1634
+ target,
1635
+ NPC_MOVE_STEP,
1636
+ half_width=half_width,
1637
+ half_depth=half_depth,
1638
+ )
1639
+ return "patrolling"
1640
+ return "standing guard"
1641
+
1642
+ if action == "build":
1643
+ return _apply_build(npc, world)
1644
+
1645
  if action == "flee":
1646
  beast = _beast_from_directive(world, npc, directive)
1647
  if beast is not None:
 
1660
  if beast is None:
1661
  return "defending"
1662
  if vec_distance(npc.position, beast.position) <= NPC_MELEE_REACH:
1663
+ damage = _npc_damage_against_beast(npc)
1664
  beast.health -= damage
1665
  npc.relationships[beast.id] = -1.0
1666
  add_episode(
 
1668
  world.tick,
1669
  "attack",
1670
  npc.id,
1671
+ f"I struck {beast.id} for {damage:g} damage",
1672
  target_id=beast.id,
1673
+ subject_kind="beast",
1674
  perspective="self",
1675
  tags=["danger", "beast"],
1676
  weight=0.8,
1677
  )
1678
+ remember(npc, world.tick, f"You struck {beast.id} for {damage:g} damage.")
1679
+ _log_event(
1680
+ world,
1681
+ "npc_attack",
1682
+ f"{npc.name} attacked {beast.id} for {damage:g}",
1683
+ severity="warning",
1684
+ actor_id=npc.id,
1685
+ target_id=beast.id,
1686
+ )
1687
  if npc.help_target_id:
1688
  helped = _npc_by_id(world, npc.help_target_id)
1689
  if helped is not None and is_alive(helped):
 
1701
  )
1702
  if beast.health <= 0:
1703
  beast.state = "dead"
1704
+ npc.beasts_killed += 1
1705
+ world.beasts_killed += 1
1706
+ world.overseer_score += 2
1707
  _record_world_event(
1708
  world,
1709
+ "beast_killed",
1710
  f"{npc.name} killed {beast.id}",
1711
  beast.position,
1712
  radius=WITNESS_RADIUS,
1713
  duration_ticks=5,
1714
  )
1715
+ _log_event(
1716
+ world,
1717
+ "beast_killed",
1718
+ f"{npc.name} killed {beast.id}",
1719
+ severity="good",
1720
+ actor_id=npc.id,
1721
+ target_id=beast.id,
1722
+ )
1723
  remember(npc, world.tick, f"You killed {beast.id}!")
1724
  return "killed the beast"
1725
  return "fighting the beast"
 
1738
  partner = _transfer_partner(world, npc, directive)
1739
  resource_type = _resource_type_from_directive(directive, default="food")
1740
  amount = _amount_from_directive(directive, default=1)
1741
+ can_transfer_food = (
1742
+ resource_type == "food"
 
1743
  and npc.inventory_food >= amount
1744
+ and partner is not None
1745
  and (npc.inventory_food >= SHARE_FOOD_SURPLUS or partner.hunger > npc.hunger)
1746
  and partner.inventory_food < npc.inventory_food
1747
+ )
1748
+ can_transfer_nonfood = (
1749
+ resource_type != "food"
1750
+ and partner is not None
1751
+ and _inventory_amount(npc, resource_type) >= amount
1752
+ )
1753
+ if partner is not None and (can_transfer_food or can_transfer_nonfood):
1754
+ _remove_from_inventory(npc, resource_type, amount)
1755
+ _add_to_inventory_amount(partner, resource_type, amount)
1756
+ npc.resources_transferred += amount
1757
  _adjust_relationship(npc, partner.id, 0.2)
1758
  add_episode(
1759
  npc,
1760
  world.tick,
1761
  "transfer",
1762
  npc.id,
1763
+ f"I gave {amount} {resource_type} to {partner.name}",
1764
  target_id=partner.id,
1765
+ object_id=resource_type,
1766
  subject_kind="npc",
1767
  perspective="self",
1768
+ tags=[resource_type, "help", "trade"],
1769
  weight=0.5,
1770
  )
1771
  add_episode(
 
1773
  world.tick,
1774
  "transfer",
1775
  npc.id,
1776
+ f"{npc.name} gave me {amount} {resource_type}",
1777
  target_id=partner.id,
1778
+ object_id=resource_type,
1779
  subject_kind="npc",
1780
  perspective="recipient",
1781
+ tags=[resource_type, "help", "trade"],
1782
  weight=0.7,
1783
  )
1784
+ remember(npc, world.tick, f"Gave {resource_type} to {partner.name}.")
1785
+ remember(partner, world.tick, f"Received {resource_type} from {npc.name}.")
1786
+ _log_event(
1787
+ world,
1788
+ "transfer",
1789
+ f"{npc.name} gave {amount} {resource_type} to {partner.name}",
1790
+ severity="good",
1791
+ actor_id=npc.id,
1792
+ target_id=partner.id,
1793
+ object_id=resource_type,
1794
+ )
1795
+ return f"sharing {resource_type} with {partner.name}"
1796
  return "looking for someone to trade with"
1797
 
1798
  if action == "steal":
 
1972
  return "communicating"
1973
 
1974
 
1975
+ def _apply_build(npc: Npc, world: WorldState) -> str:
1976
+ if normalize_role(npc.role) != "builder":
1977
+ return "cannot build"
1978
+
1979
+ half_width = world.terrain.width / 2
1980
+ half_depth = world.terrain.depth / 2
1981
+ active_house = _house_by_id(world, npc.build_target_house_id or "")
1982
+ if active_house is not None and active_house.state == "under_construction":
1983
+ distance = vec_distance(npc.position, active_house.position)
1984
+ if distance > HOUSE_BUILD_RADIUS:
1985
+ npc.position = move_toward(
1986
+ npc.position,
1987
+ active_house.position,
1988
+ NPC_MOVE_STEP,
1989
+ half_width=half_width,
1990
+ half_depth=half_depth,
1991
+ )
1992
+ return f"returning to build {active_house.id}"
1993
+ active_house.build_progress += 1
1994
+ if npc.id not in active_house.owner_ids:
1995
+ active_house.owner_ids.append(npc.id)
1996
+ if active_house.build_progress >= 10:
1997
+ active_house.state = "completed"
1998
+ active_house.hp = active_house.max_hp
1999
+ npc.build_target_house_id = None
2000
+ npc.houses_built += 1
2001
+ world.houses_built += 1
2002
+ world.overseer_score += 2
2003
+ _record_world_event(
2004
+ world,
2005
+ "build_completed",
2006
+ f"{npc.name} completed {active_house.id}",
2007
+ active_house.position,
2008
+ radius=WITNESS_RADIUS,
2009
+ duration_ticks=6,
2010
+ )
2011
+ _log_event(
2012
+ world,
2013
+ "build_completed",
2014
+ f"{npc.name} completed {active_house.id}",
2015
+ severity="good",
2016
+ actor_id=npc.id,
2017
+ target_id=active_house.id,
2018
+ )
2019
+ remember(npc, world.tick, f"You completed {active_house.id}.")
2020
+ return f"completed {active_house.id}"
2021
+ return f"building {active_house.id}"
2022
+
2023
+ if npc.inventory_wood < 5:
2024
+ return "needs wood to build"
2025
+
2026
+ if not _can_start_house(world, npc.position):
2027
+ site = _preferred_build_site(world, npc)
2028
+ npc.position = move_toward(
2029
+ npc.position,
2030
+ site,
2031
+ NPC_MOVE_STEP,
2032
+ half_width=half_width,
2033
+ half_depth=half_depth,
2034
+ )
2035
+ return "moving to build site"
2036
+
2037
+ npc.inventory_wood -= 5
2038
+ house = House(
2039
+ id=f"house_{_next_house_index(world):03d}",
2040
+ position=npc.position,
2041
+ owner_ids=[npc.id],
2042
+ hp=60.0,
2043
+ max_hp=60.0,
2044
+ state="under_construction",
2045
+ build_progress=1,
2046
+ capacity=3,
2047
+ )
2048
+ world.houses.append(house)
2049
+ npc.build_target_house_id = house.id
2050
+ _record_world_event(
2051
+ world,
2052
+ "build_started",
2053
+ f"{npc.name} started {house.id}",
2054
+ house.position,
2055
+ radius=WITNESS_RADIUS,
2056
+ duration_ticks=5,
2057
+ )
2058
+ _log_event(
2059
+ world,
2060
+ "build_started",
2061
+ f"{npc.name} started {house.id}",
2062
+ severity="good",
2063
+ actor_id=npc.id,
2064
+ target_id=house.id,
2065
+ )
2066
+ remember(npc, world.tick, f"You started building {house.id}.")
2067
+ return f"building {house.id}"
2068
+
2069
+
2070
+ def _patrol_target(world: WorldState, npc: Npc) -> Vec3 | None:
2071
+ threat = _nearest_village_threat(world, npc)
2072
+ if threat is not None:
2073
+ return threat.position
2074
+ houses = completed_houses(world)
2075
+ if not houses:
2076
+ return Vec3(x=0.0, y=0.0, z=0.0)
2077
+ houses = sorted(houses, key=lambda house: house.id)
2078
+ if vec_distance(npc.position, houses[npc.patrol_index % len(houses)].position) <= NPC_MOVE_STEP:
2079
+ npc.patrol_index = (npc.patrol_index + 1) % len(houses)
2080
+ return houses[npc.patrol_index % len(houses)].position
2081
+
2082
+
2083
+ def _nearest_village_threat(world: WorldState, npc: Npc) -> Beast | None:
2084
+ candidates = [
2085
+ beast
2086
+ for beast in world.beasts
2087
+ if beast.state != "dead"
2088
+ and (
2089
+ any(vec_distance(beast.position, house.position) <= VILLAGE_THREAT_RADIUS for house in world.houses)
2090
+ or any(
2091
+ is_alive(other)
2092
+ and vec_distance(beast.position, other.position) <= VILLAGE_THREAT_RADIUS
2093
+ for other in world.npcs
2094
+ )
2095
+ )
2096
+ ]
2097
+ if not candidates:
2098
+ return nearest_beast(world, npc.position)
2099
+ return min(candidates, key=lambda beast: (vec_distance(npc.position, beast.position), beast.id))
2100
+
2101
+
2102
+ def _npc_damage_against_beast(npc: Npc) -> float:
2103
+ damage = BASE_NPC_DAMAGE + (WEAPON_DAMAGE_BONUS if npc.inventory_weapon > 0 else 0)
2104
+ role = normalize_role(npc.role)
2105
+ if role == "guard":
2106
+ return damage * GUARD_DAMAGE_MULTIPLIER
2107
+ if role in {"gatherer", "builder"}:
2108
+ return damage * WEAK_DAMAGE_MULTIPLIER
2109
+ return float(damage)
2110
+
2111
+
2112
+ def _house_by_id(world: WorldState, house_id: str) -> House | None:
2113
+ if not house_id:
2114
+ return None
2115
+ return next((house for house in world.houses if house.id == house_id), None)
2116
+
2117
+
2118
+ def _can_start_house(world: WorldState, position: Vec3) -> bool:
2119
+ return all(vec_distance(position, house.position) >= HOUSE_MIN_DISTANCE for house in world.houses)
2120
+
2121
+
2122
+ def _preferred_build_site(world: WorldState, npc: Npc) -> Vec3:
2123
+ half_width = world.terrain.width / 2
2124
+ half_depth = world.terrain.depth / 2
2125
+ angle = (len(world.houses) * 1.61803398875) % (2 * math.pi)
2126
+ center = nearest_house(world, npc.position, completed_only=False)
2127
+ origin = center.position if center is not None else Vec3(x=0.0, y=0.0, z=0.0)
2128
+ return Vec3(
2129
+ x=round(_clamp(origin.x + math.cos(angle) * (HOUSE_MIN_DISTANCE + TILE), -half_width, half_width), 3),
2130
+ y=0.0,
2131
+ z=round(_clamp(origin.z + math.sin(angle) * (HOUSE_MIN_DISTANCE + TILE), -half_depth, half_depth), 3),
2132
+ )
2133
+
2134
+
2135
+ def _next_house_index(world: WorldState) -> int:
2136
+ highest = 0
2137
+ for house in world.houses:
2138
+ suffix = house.id.removeprefix("house_")
2139
+ if suffix.isdigit():
2140
+ highest = max(highest, int(suffix))
2141
+ return highest + 1
2142
+
2143
+
2144
  def resolve_call_for_help(caller: Npc, world: WorldState) -> list[Npc]:
2145
  helpers: list[Npc] = []
2146
  for helper in npcs_in_radius(world, caller.position, HELP_RADIUS, exclude_id=caller.id):
 
2176
  node = next((node for node in world.resource_nodes if node.id == directive.resource_id), None)
2177
  if node is not None and (max_dist is None or vec_distance(npc.position, node.position) <= max_dist):
2178
  return node
2179
+ return nearest_resource_for_npc(world, npc, max_dist=max_dist)
2180
 
2181
 
2182
  def _beast_from_directive(
 
2267
 
2268
 
2269
  def _add_to_inventory(npc: Npc, resource_type: str) -> None:
2270
+ _add_to_inventory_amount(npc, resource_type, 1)
2271
+
2272
+
2273
+ def _add_to_inventory_amount(npc: Npc, resource_type: str, amount: int) -> None:
2274
+ if resource_type == "food":
2275
+ npc.inventory_food += amount
2276
+ elif resource_type == "herbs":
2277
+ npc.inventory_herbs += amount
2278
+ elif resource_type == "wood":
2279
+ npc.inventory_wood += amount
2280
+ elif resource_type == "weapon":
2281
+ npc.inventory_weapon += amount
2282
+
2283
+
2284
+ def _remove_from_inventory(npc: Npc, resource_type: str, amount: int) -> None:
2285
  if resource_type == "food":
2286
+ npc.inventory_food = max(0, npc.inventory_food - amount)
2287
  elif resource_type == "herbs":
2288
+ npc.inventory_herbs = max(0, npc.inventory_herbs - amount)
2289
  elif resource_type == "wood":
2290
+ npc.inventory_wood = max(0, npc.inventory_wood - amount)
2291
  elif resource_type == "weapon":
2292
+ npc.inventory_weapon = max(0, npc.inventory_weapon - amount)
2293
+
2294
+
2295
+ def _inventory_amount(npc: Npc, resource_type: str) -> int:
2296
+ if resource_type == "food":
2297
+ return npc.inventory_food
2298
+ if resource_type == "herbs":
2299
+ return npc.inventory_herbs
2300
+ if resource_type == "wood":
2301
+ return npc.inventory_wood
2302
+ if resource_type == "weapon":
2303
+ return npc.inventory_weapon
2304
+ return 0
2305
 
2306
 
2307
  def _resource_destination(world: WorldState, npc: Npc, action: str) -> Vec3 | None:
2308
+ if normalize_role(npc.role) == "builder":
2309
+ resource_type: str | None = "wood"
2310
+ elif action == "find_food":
2311
  resource_type: str | None = "food"
2312
  elif action == "find_herbs":
2313
  resource_type = "herbs"
2314
  else:
2315
  resource_type = None
2316
+ node = nearest_resource_for_npc(world, npc, resource_type=resource_type)
2317
  if node is None:
2318
+ node = nearest_resource_for_npc(world, npc)
2319
  return node.position if node is not None else None
2320
 
2321
 
 
2331
  Spreading foragers across several nearby nodes stops the whole settlement from
2332
  piling onto a single node and then starving together.
2333
  """
2334
+ if normalize_role(npc.role) == "builder":
2335
+ resource_type = "wood"
2336
  nodes = sorted(
2337
  (
2338
  node
 
2341
  ),
2342
  key=lambda node: (vec_distance(npc.position, node.position), node.id),
2343
  )
2344
+ if not nodes and resource_type is not None and normalize_role(npc.role) != "builder":
2345
  nodes = sorted(
2346
  (node for node in world.resource_nodes if node.amount > 0),
2347
  key=lambda node: (vec_distance(npc.position, node.position), node.id),
 
2431
  # Deterministic survival planner + resolver
2432
  # --------------------------------------------------------------------------- #
2433
  def is_survival_world(world: WorldState) -> bool:
2434
+ return bool(world.beasts or world.resource_nodes or world.houses)
2435
 
2436
 
2437
  def survival_directive_for(world: WorldState, npc: Npc, next_tick: int) -> NpcDirective:
 
2482
  directive.conversation_user,
2483
  directive.conversation_assistant,
2484
  )
2485
+ if directive.memory:
2486
+ remember(npc, next_tick, directive.memory)
2487
  if next_tick > 0 and next_tick % 10 == 0:
2488
  compress_to_facts(npc)
2489
  debug.append(
 
2536
  intent=f"survival:{goal}",
2537
  )
2538
 
2539
+ role = normalize_role(npc.role)
2540
+
2541
+ if goal == "engage_threat":
2542
+ beast = _nearest_village_threat(world, npc)
2543
+ if beast is not None:
2544
+ return directive("attack", target=beast.position, resource_id=None)
2545
+ return directive("patrol")
2546
+
2547
  if goal == "survive_threat_fight":
2548
  if (next_tick + index) % 3 == 0:
2549
  return directive("communicate", communication_intent="help_request")
 
2565
 
2566
  if goal in ("find_food", "find_herbs"):
2567
  resource_type = "food" if goal == "find_food" else "herbs"
2568
+ if role == "builder" and goal == "find_food":
2569
+ partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True)
2570
+ if partner is not None:
2571
+ return directive(
2572
+ "communicate",
2573
+ target_npc_id=partner.id,
2574
+ resource_type="food",
2575
+ communication_intent="trade_request",
2576
+ )
2577
+ return directive("go_home")
2578
  if goal == "find_food":
2579
  partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True)
2580
  if (
 
2596
  return directive("move_to_resource", target=node.position, resource_id=node.id)
2597
  return directive("move_to") # nothing to gather right now -> wander, don't freeze
2598
 
2599
+ if goal == "settle":
2600
+ house = house_containing_npc(world, npc)
2601
+ if (
2602
+ house is not None
2603
+ and _is_reproduction_candidate(npc)
2604
+ and 5 < npc.hunger <= REPRODUCTION_SNACK_HUNGER
2605
+ and npc.inventory_food > 0
2606
+ ):
2607
+ return directive("consume")
2608
+ if house is None:
2609
+ return directive("go_home")
2610
+ if (next_tick + index) % 3 == 0:
2611
+ neighbor = nearest_alive_npc(world, npc, max_dist=TALK_RADIUS)
2612
+ if neighbor is not None:
2613
+ return directive(
2614
+ "communicate",
2615
+ target_npc_id=neighbor.id,
2616
+ communication_intent="social",
2617
+ )
2618
+ return directive("go_home")
2619
+
2620
+ if goal == "work":
2621
+ if role == "guard":
2622
+ return directive("patrol")
2623
+ if role == "builder":
2624
+ if npc.build_target_house_id or npc.inventory_wood >= 5:
2625
+ return directive("build")
2626
+ node = _spread_resource(world, npc, index, resource_type="wood")
2627
+ if node is not None and vec_distance(npc.position, node.position) <= GATHER_RADIUS:
2628
+ return directive("gather", resource_id=node.id)
2629
+ if node is not None:
2630
+ return directive("move_to_resource", target=node.position, resource_id=node.id)
2631
+ return directive("go_home")
2632
+ builder = _nearest_role(world, npc, "builder", max_dist=TRADE_RADIUS)
2633
+ if builder is not None and npc.inventory_wood > 0:
2634
+ return directive(
2635
+ "transfer",
2636
+ target_npc_id=builder.id,
2637
+ resource_type="wood",
2638
+ amount=1,
2639
+ )
2640
+
2641
+ # routine_life/work fallback: socialise, share, forage, or wander -- and spread out.
2642
  neighbor = nearest_alive_npc(world, npc, max_dist=TALK_RADIUS)
2643
  phase = (next_tick + index) % 4
2644
  if (
src/god_simulator/simulation/tick.py CHANGED
@@ -34,18 +34,24 @@ from god_simulator.simulation.mechanics import (
34
  walk_away_target,
35
  )
36
  from god_simulator.simulation.memory import remember
 
37
  from god_simulator.simulation.perception import CitizenPerception, nearby_threat_npc
38
  from god_simulator.simulation.survival import (
39
  add_episode,
 
40
  apply_survival_plan,
41
  apply_survival_tick,
42
  is_survival_world,
43
  )
44
 
45
 
46
- def advance_world(world: WorldState, simulator: WorldSimulator | None = None) -> WorldState:
 
 
 
 
47
  """Advance world state by asking the configured simulator for a tick plan."""
48
- next_tick, plan = plan_world_tick(world, simulator)
49
  apply_tick_plan(world, next_tick, plan)
50
  return world
51
 
@@ -54,11 +60,15 @@ def plan_world_tick(
54
  world: WorldState,
55
  simulator: WorldSimulator | None = None,
56
  next_tick: int | None = None,
 
57
  ) -> tuple[int, TickPlan]:
58
  """Build a tick plan without mutating the supplied world state."""
59
  planned_tick = next_tick if next_tick is not None else world.tick + 1
60
  active_simulator = simulator or DeterministicWorldSimulator()
61
- return planned_tick, active_simulator.propose_tick(world, planned_tick)
 
 
 
62
 
63
 
64
  def apply_tick_plan(world: WorldState, next_tick: int, plan: TickPlan) -> None:
@@ -70,8 +80,10 @@ def apply_tick_plan(world: WorldState, next_tick: int, plan: TickPlan) -> None:
70
  # In a survival world all directives (deterministic or LLM) resolve through
71
  # the survival resolver so the engine owns hunger/health/position/inventory.
72
  if plan.source.startswith("survival") or is_survival_world(world):
73
- debug = apply_survival_plan(world, next_tick, plan)
74
  world.tick = next_tick
 
 
 
75
  world.last_tick_source = plan.source
76
  world.last_action_debug = debug
77
  return
@@ -145,6 +157,7 @@ def apply_tick_plan(world: WorldState, next_tick: int, plan: TickPlan) -> None:
145
  npc.intention = "dead"
146
 
147
  world.tick = next_tick
 
148
  world.last_tick_source = plan.source
149
  world.last_action_debug = [_trace_to_dict(trace) for trace in resolved_traces]
150
 
@@ -170,7 +183,7 @@ def validate_tick_plan(
170
  )
171
  resolved_directives.append(validation.directive)
172
 
173
- return TickPlan(source=plan.source, directives=resolved_directives), traces
174
 
175
 
176
  def validate_directive(
 
34
  walk_away_target,
35
  )
36
  from god_simulator.simulation.memory import remember
37
+ from god_simulator.simulation.overseer import OverseerController, apply_overseer_metadata
38
  from god_simulator.simulation.perception import CitizenPerception, nearby_threat_npc
39
  from god_simulator.simulation.survival import (
40
  add_episode,
41
+ apply_post_survival_tick,
42
  apply_survival_plan,
43
  apply_survival_tick,
44
  is_survival_world,
45
  )
46
 
47
 
48
+ def advance_world(
49
+ world: WorldState,
50
+ simulator: WorldSimulator | None = None,
51
+ overseer: OverseerController | None = None,
52
+ ) -> WorldState:
53
  """Advance world state by asking the configured simulator for a tick plan."""
54
+ next_tick, plan = plan_world_tick(world, simulator, overseer=overseer)
55
  apply_tick_plan(world, next_tick, plan)
56
  return world
57
 
 
60
  world: WorldState,
61
  simulator: WorldSimulator | None = None,
62
  next_tick: int | None = None,
63
+ overseer: OverseerController | None = None,
64
  ) -> tuple[int, TickPlan]:
65
  """Build a tick plan without mutating the supplied world state."""
66
  planned_tick = next_tick if next_tick is not None else world.tick + 1
67
  active_simulator = simulator or DeterministicWorldSimulator()
68
+ plan = active_simulator.propose_tick(world, planned_tick)
69
+ if overseer is not None:
70
+ plan = overseer.augment_plan(world, planned_tick, plan)
71
+ return planned_tick, plan
72
 
73
 
74
  def apply_tick_plan(world: WorldState, next_tick: int, plan: TickPlan) -> None:
 
80
  # In a survival world all directives (deterministic or LLM) resolve through
81
  # the survival resolver so the engine owns hunger/health/position/inventory.
82
  if plan.source.startswith("survival") or is_survival_world(world):
 
83
  world.tick = next_tick
84
+ apply_overseer_metadata(world, plan.overseer)
85
+ debug = apply_survival_plan(world, next_tick, plan)
86
+ apply_post_survival_tick(world, next_tick)
87
  world.last_tick_source = plan.source
88
  world.last_action_debug = debug
89
  return
 
157
  npc.intention = "dead"
158
 
159
  world.tick = next_tick
160
+ apply_overseer_metadata(world, plan.overseer)
161
  world.last_tick_source = plan.source
162
  world.last_action_debug = [_trace_to_dict(trace) for trace in resolved_traces]
163
 
 
183
  )
184
  resolved_directives.append(validation.directive)
185
 
186
+ return TickPlan(source=plan.source, directives=resolved_directives, overseer=plan.overseer), traces
187
 
188
 
189
  def validate_directive(
tests/test_claw3d_contract.py CHANGED
@@ -38,7 +38,7 @@ def test_claw3d_snapshot_contains_terrain_and_entities() -> None:
38
  assert snapshot["entities"][0]["state"]["memory_count"] == 1
39
  assert snapshot["entities"][0]["state"]["memory_summary"] is None
40
  assert snapshot["entities"][0]["state"]["memories"] == [
41
- {"tick": 0, "text": "Ada arrived as a farmer."}
42
  ]
43
 
44
 
 
38
  assert snapshot["entities"][0]["state"]["memory_count"] == 1
39
  assert snapshot["entities"][0]["state"]["memory_summary"] is None
40
  assert snapshot["entities"][0]["state"]["memories"] == [
41
+ {"tick": 0, "text": "Ada arrived as a gatherer."}
42
  ]
43
 
44
 
tests/test_npc_simulation_v2.py CHANGED
@@ -249,12 +249,19 @@ def test_call_for_help_ignores_unarmed_or_low_trust_npcs() -> None:
249
 
250
 
251
  def test_armed_npc_with_trusted_ally_selects_fight() -> None:
252
- fighter = _npc("npc-001", "Ada", x=8.0, inventory_weapon=1)
 
 
 
 
 
 
 
253
  ally = _npc("npc-002", "Boris", x=12.0)
254
  fighter.relationships[ally.id] = 0.8
255
  beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0))
256
 
257
- assert select_goal(fighter, _world([fighter, ally], beasts=[beast])) == "survive_threat_fight"
258
 
259
 
260
  def test_armed_npc_with_low_trust_neighbor_selects_flee() -> None:
 
249
 
250
 
251
  def test_armed_npc_with_trusted_ally_selects_fight() -> None:
252
+ # Only guards engage threats (GAME_DESIGN 12); other roles flee.
253
+ fighter = Npc(
254
+ id="npc-001",
255
+ name="Ada",
256
+ role="guard",
257
+ position=Vec3(x=8.0, y=0.0, z=0.0),
258
+ inventory_weapon=1,
259
+ )
260
  ally = _npc("npc-002", "Boris", x=12.0)
261
  fighter.relationships[ally.id] = 0.8
262
  beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0))
263
 
264
+ assert select_goal(fighter, _world([fighter, ally], beasts=[beast])) == "engage_threat"
265
 
266
 
267
  def test_armed_npc_with_low_trust_neighbor_selects_flee() -> None:
tests/test_openai_connector.py CHANGED
@@ -92,7 +92,7 @@ def test_openai_connector_uses_tool_calls_for_npc_directives() -> None:
92
  assert world.npcs[0].position.z == 2
93
  assert world.npcs[0].intention == "walking"
94
  assert [memory.text for memory in world.npcs[0].memory] == [
95
- "Ada arrived as a farmer."
96
  ]
97
 
98
 
@@ -248,18 +248,17 @@ def test_openai_connector_sends_only_one_agent_state_per_request() -> None:
248
 
249
  advance_world(world, simulator)
250
 
251
- assert [request["npc"]["id"] for request in requests] == [
252
- "npc-001",
253
- "npc-002",
254
- "npc-003",
255
- ]
256
  assert all("npcs" not in request for request in requests)
257
- assert requests[0]["npc"]["visible_npcs"][0]["id"] == "npc-002"
258
- assert "role" not in requests[0]["npc"]["visible_npcs"][0]
259
- assert "attack_damage" not in requests[0]["npc"]["visible_npcs"][0]
260
- assert "health" not in requests[0]["npc"]["visible_npcs"][0]
261
- assert requests[0]["npc"]["visible_npcs"][0]["condition"] == "healthy"
262
- assert requests[2]["npc"]["visible_npcs"] == []
 
263
  assert {npc.intention for npc in world.npcs} == {"walking"}
264
  assert world.last_tick_source == "openai_compatible"
265
 
 
92
  assert world.npcs[0].position.z == 2
93
  assert world.npcs[0].intention == "walking"
94
  assert [memory.text for memory in world.npcs[0].memory] == [
95
+ "Ada arrived as a gatherer."
96
  ]
97
 
98
 
 
248
 
249
  advance_world(world, simulator)
250
 
251
+ # Agents are called in parallel, so request order is not deterministic.
252
+ requests_by_id = {request["npc"]["id"]: request for request in requests}
253
+ assert sorted(requests_by_id) == ["npc-001", "npc-002", "npc-003"]
 
 
254
  assert all("npcs" not in request for request in requests)
255
+ first_request = requests_by_id["npc-001"]
256
+ assert first_request["npc"]["visible_npcs"][0]["id"] == "npc-002"
257
+ assert "role" not in first_request["npc"]["visible_npcs"][0]
258
+ assert "attack_damage" not in first_request["npc"]["visible_npcs"][0]
259
+ assert "health" not in first_request["npc"]["visible_npcs"][0]
260
+ assert first_request["npc"]["visible_npcs"][0]["condition"] == "healthy"
261
+ assert requests_by_id["npc-003"]["npc"]["visible_npcs"] == []
262
  assert {npc.intention for npc in world.npcs} == {"walking"}
263
  assert world.last_tick_source == "openai_compatible"
264
 
tests/test_survival_loop.py CHANGED
@@ -19,10 +19,12 @@ from god_simulator.simulation.connectors.openai_compatible import (
19
  build_tool_list_for_goal,
20
  )
21
  from god_simulator.simulation.spawning import create_world
 
 
22
  from god_simulator.simulation.survival import (
23
- BEAST_SPAWN_INTERVAL,
24
  GOAL_ACTIONS,
25
  MAX_BEASTS,
 
26
  apply_action_effects,
27
  apply_survival_tick,
28
  select_goal,
@@ -62,16 +64,16 @@ def _survival_config(*, npc_count: int = 6) -> GameConfig:
62
  )
63
 
64
 
65
- # 1. hunger grows every tick
66
  def test_hunger_increases_each_tick() -> None:
67
  npc = _npc(hunger=25.0)
68
  world = _world([npc], resource_nodes=[ResourceNode("r", "food", Vec3(30.0, 0.0, 30.0), 5)])
69
 
70
  apply_survival_tick(world)
71
- assert npc.hunger == 27.0
72
 
73
  apply_survival_tick(world)
74
- assert npc.hunger == 29.0
75
 
76
 
77
  # 2. hunger > 80 + food in inventory -> goal = eat_food
@@ -137,14 +139,14 @@ def test_nearby_beast_triggers_survive_threat_goal() -> None:
137
  assert goal.startswith("survive_threat")
138
 
139
 
140
- # 9. armed NPC with allies -> survive_threat_fight
141
  def test_armed_npc_with_allies_fights() -> None:
142
- fighter = _npc("npc-001", "Ada", x=8.0, z=0.0, inventory_weapon=1)
143
  ally = _npc("npc-002", "Boris", x=10.0, z=0.0)
144
  fighter.relationships[ally.id] = 0.8
145
  beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0))
146
 
147
- assert select_goal(fighter, _world([fighter, ally], beasts=[beast])) == "survive_threat_fight"
148
 
149
 
150
  # 10. unarmed loner -> survive_threat_flee
@@ -220,15 +222,15 @@ def test_resource_nodes_regrow_over_time() -> None:
220
  node = ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), amount=1, max_amount=5)
221
  world = _world([_npc(x=0.0, z=0.0)], resource_nodes=[node], tick=0)
222
 
223
- apply_survival_tick(world) # tick 0 % 2 == 0 -> +1
224
  assert node.amount == 2
225
 
226
- world.tick = 2
227
  apply_survival_tick(world) # -> +1
228
  assert node.amount == 3
229
 
230
  node.amount = 5
231
- world.tick = 4
232
  apply_survival_tick(world) # never exceeds the cap
233
  assert node.amount == 5
234
 
@@ -248,27 +250,38 @@ def test_resources_spawn_randomly_but_deterministically_by_seed() -> None:
248
 
249
 
250
  def test_beast_random_spawn_respects_max_beasts_and_cooldown() -> None:
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  node = ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), amount=1, max_amount=5)
252
- cooldown_world = _world([_npc()], resource_nodes=[node], beasts=[], tick=BEAST_SPAWN_INTERVAL - 1)
253
 
 
254
  apply_survival_tick(cooldown_world)
255
-
256
  assert cooldown_world.beasts == []
257
 
258
- spawn_world = _world([_npc()], resource_nodes=[node], beasts=[], tick=BEAST_SPAWN_INTERVAL)
259
  apply_survival_tick(spawn_world)
260
-
261
  assert len(spawn_world.beasts) == 1
262
  assert spawn_world.beasts[0].id == "beast_1"
263
 
264
  capped_world = _world(
265
- [_npc()],
266
  resource_nodes=[node],
267
  beasts=[
268
  Beast(f"beast_{index}", Vec3(float(index), 0.0, 0.0))
269
  for index in range(1, MAX_BEASTS + 1)
270
  ],
271
- tick=BEAST_SPAWN_INTERVAL,
272
  )
273
  apply_survival_tick(capped_world)
274
 
 
19
  build_tool_list_for_goal,
20
  )
21
  from god_simulator.simulation.spawning import create_world
22
+ import pytest
23
+
24
  from god_simulator.simulation.survival import (
 
25
  GOAL_ACTIONS,
26
  MAX_BEASTS,
27
+ _natural_beast_spawn_interval,
28
  apply_action_effects,
29
  apply_survival_tick,
30
  select_goal,
 
64
  )
65
 
66
 
67
+ # 1. hunger grows every tick (GAME_DESIGN: +0.8/tick)
68
  def test_hunger_increases_each_tick() -> None:
69
  npc = _npc(hunger=25.0)
70
  world = _world([npc], resource_nodes=[ResourceNode("r", "food", Vec3(30.0, 0.0, 30.0), 5)])
71
 
72
  apply_survival_tick(world)
73
+ assert npc.hunger == pytest.approx(25.8)
74
 
75
  apply_survival_tick(world)
76
+ assert npc.hunger == pytest.approx(26.6)
77
 
78
 
79
  # 2. hunger > 80 + food in inventory -> goal = eat_food
 
139
  assert goal.startswith("survive_threat")
140
 
141
 
142
+ # 9. only guards engage threats (GAME_DESIGN 12); everyone else flees
143
  def test_armed_npc_with_allies_fights() -> None:
144
+ fighter = _npc("npc-001", "Ada", x=8.0, z=0.0, role="guard", inventory_weapon=1)
145
  ally = _npc("npc-002", "Boris", x=10.0, z=0.0)
146
  fighter.relationships[ally.id] = 0.8
147
  beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0))
148
 
149
+ assert select_goal(fighter, _world([fighter, ally], beasts=[beast])) == "engage_threat"
150
 
151
 
152
  # 10. unarmed loner -> survive_threat_flee
 
222
  node = ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), amount=1, max_amount=5)
223
  world = _world([_npc(x=0.0, z=0.0)], resource_nodes=[node], tick=0)
224
 
225
+ apply_survival_tick(world) # food regrows every 8 ticks; tick 0 % 8 == 0 -> +1
226
  assert node.amount == 2
227
 
228
+ world.tick = 8
229
  apply_survival_tick(world) # -> +1
230
  assert node.amount == 3
231
 
232
  node.amount = 5
233
+ world.tick = 16
234
  apply_survival_tick(world) # never exceeds the cap
235
  assert node.amount == 5
236
 
 
250
 
251
 
252
  def test_beast_random_spawn_respects_max_beasts_and_cooldown() -> None:
253
+ def villagers() -> list[Npc]:
254
+ # Natural beast spawns require population >= 4.
255
+ return [_npc(f"npc-{index}", f"Villager{index}", x=float(index)) for index in range(4)]
256
+
257
+ def find_spawn_tick() -> int:
258
+ probe = _world(villagers(), tick=0)
259
+ for tick in range(60, 2000):
260
+ probe.tick = tick
261
+ if tick % _natural_beast_spawn_interval(probe) == 0:
262
+ return tick
263
+ raise AssertionError("no natural beast spawn tick found for this seed")
264
+
265
+ spawn_tick = find_spawn_tick()
266
  node = ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), amount=1, max_amount=5)
 
267
 
268
+ cooldown_world = _world(villagers(), resource_nodes=[node], beasts=[], tick=1)
269
  apply_survival_tick(cooldown_world)
 
270
  assert cooldown_world.beasts == []
271
 
272
+ spawn_world = _world(villagers(), resource_nodes=[node], beasts=[], tick=spawn_tick)
273
  apply_survival_tick(spawn_world)
 
274
  assert len(spawn_world.beasts) == 1
275
  assert spawn_world.beasts[0].id == "beast_1"
276
 
277
  capped_world = _world(
278
+ villagers(),
279
  resource_nodes=[node],
280
  beasts=[
281
  Beast(f"beast_{index}", Vec3(float(index), 0.0, 0.0))
282
  for index in range(1, MAX_BEASTS + 1)
283
  ],
284
+ tick=spawn_tick,
285
  )
286
  apply_survival_tick(capped_world)
287
 
tests/test_world_spawning.py CHANGED
@@ -145,7 +145,7 @@ def test_memory_keeps_recent_entries_and_summarizes_older_events() -> None:
145
 
146
  assert len(speaker.memory) == 5
147
  assert speaker.memory_summary is not None
148
- assert "Ada arrived as a farmer." in speaker.memory_summary
149
  assert "Message 1." in speaker.memory_summary
150
  assert "Message 7." in speaker.memory[-1].text
151
 
 
145
 
146
  assert len(speaker.memory) == 5
147
  assert speaker.memory_summary is not None
148
+ assert "Ada arrived as a gatherer." in speaker.memory_summary
149
  assert "Message 1." in speaker.memory_summary
150
  assert "Message 7." in speaker.memory[-1].text
151