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.
- GAME_DESIGN.md +341 -0
- frontend/src/App.tsx +25 -7
- frontend/src/api.ts +28 -0
- frontend/src/components/AgentPanel.tsx +19 -2
- frontend/src/components/ChaosDock.tsx +107 -0
- frontend/src/components/EventFeedPanel.tsx +57 -0
- frontend/src/components/GameOverOverlay.tsx +122 -0
- frontend/src/components/GodConsolePanel.tsx +0 -50
- frontend/src/components/OverseerPanel.tsx +77 -0
- frontend/src/components/ScoreboardHeader.tsx +96 -0
- frontend/src/components/StatusBar.tsx +0 -50
- frontend/src/eventDecor.ts +47 -0
- frontend/src/hooks/useWorldSimulation.ts +131 -2
- frontend/src/scene/NpcLabelButton.tsx +43 -52
- frontend/src/scene/WorldView.tsx +123 -1
- frontend/src/scene/characterUtils.ts +21 -0
- frontend/src/styles.css +876 -231
- frontend/src/types.ts +72 -0
- playtest_report.md +656 -0
- prompts/overseer.md +15 -0
- scripts/headless_sim.py +144 -0
- scripts/playtest_agent.py +811 -0
- src/god_simulator/api/server.py +125 -8
- src/god_simulator/config.py +53 -0
- src/god_simulator/domain.py +63 -0
- src/god_simulator/rendering/claw3d_contract.py +62 -1
- src/god_simulator/simulation/chaos.py +142 -0
- src/god_simulator/simulation/connectors/base.py +6 -1
- src/god_simulator/simulation/connectors/openai_compatible.py +85 -2
- src/god_simulator/simulation/god_console.py +4 -2
- src/god_simulator/simulation/mechanics.py +1 -1
- src/god_simulator/simulation/overseer.py +536 -0
- src/god_simulator/simulation/roles.py +99 -0
- src/god_simulator/simulation/spawning.py +78 -17
- src/god_simulator/simulation/survival.py +1218 -157
- src/god_simulator/simulation/tick.py +18 -5
- tests/test_claw3d_contract.py +1 -1
- tests/test_npc_simulation_v2.py +9 -2
- tests/test_openai_connector.py +11 -12
- tests/test_survival_loop.py +29 -16
- 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 {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 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 |
-
<
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
isGodPending={simulation.isGodPending}
|
| 43 |
isWaitingForTick={simulation.isWaitingForTick}
|
| 44 |
-
|
|
|
|
|
|
|
| 45 |
onCommandChange={simulation.setGodCommand}
|
| 46 |
-
|
| 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 (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 {
|
| 5 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 12 |
-
const
|
| 13 |
-
const
|
| 14 |
-
const
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 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 |
-
|
| 60 |
>
|
| 61 |
-
<
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
<span
|
| 67 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
position: absolute;
|
| 44 |
top: 14px;
|
| 45 |
-
left:
|
| 46 |
-
right: 14px;
|
| 47 |
z-index: 60;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
display: flex;
|
| 49 |
align-items: center;
|
| 50 |
justify-content: space-between;
|
| 51 |
-
gap:
|
| 52 |
-
|
|
|
|
| 53 |
}
|
| 54 |
|
| 55 |
-
.
|
| 56 |
-
|
| 57 |
-
|
|
|
|
| 58 |
}
|
| 59 |
|
| 60 |
-
.
|
| 61 |
-
|
| 62 |
-
|
| 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 |
-
.
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
align-items: center;
|
| 77 |
-
gap:
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
border-radius: 8px;
|
| 80 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
font-weight: 700;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
}
|
| 83 |
|
| 84 |
-
.
|
| 85 |
-
min-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
padding: 0 8px;
|
| 90 |
-
border-radius: 8px;
|
| 91 |
}
|
| 92 |
|
| 93 |
-
.
|
| 94 |
-
|
| 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 |
-
.
|
| 107 |
-
|
|
|
|
|
|
|
| 108 |
}
|
| 109 |
|
| 110 |
-
.
|
| 111 |
-
|
|
|
|
| 112 |
}
|
| 113 |
|
| 114 |
-
.
|
| 115 |
-
|
| 116 |
-
color: #fff3a1;
|
| 117 |
}
|
| 118 |
|
| 119 |
-
.
|
| 120 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
.
|
| 167 |
-
.
|
|
|
|
| 168 |
outline: 2px solid #f8df6b;
|
| 169 |
outline-offset: 2px;
|
| 170 |
}
|
| 171 |
|
| 172 |
-
|
| 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(
|
| 247 |
border-radius: 8px;
|
| 248 |
overflow: visible;
|
| 249 |
}
|
|
@@ -331,7 +786,7 @@ button {
|
|
| 331 |
}
|
| 332 |
|
| 333 |
.memoryList {
|
| 334 |
-
max-height:
|
| 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 |
-
|
| 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:
|
| 517 |
left: 50%;
|
| 518 |
-
z-index:
|
| 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:
|
| 531 |
left: 50%;
|
| 532 |
-
z-index:
|
| 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 |
-
.
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
max-width: 128px;
|
| 562 |
display: grid;
|
| 563 |
-
gap:
|
| 564 |
-
padding: 6px 8px;
|
| 565 |
-
border:
|
| 566 |
-
border-radius:
|
| 567 |
color: #fffce9;
|
| 568 |
-
background: rgb(15 24 20 /
|
| 569 |
box-shadow: 0 10px 28px rgb(0 0 0 / 24%);
|
| 570 |
cursor: pointer;
|
|
|
|
| 571 |
}
|
| 572 |
|
| 573 |
-
.
|
| 574 |
-
|
| 575 |
-
background: rgb(18 30 24 / 88%);
|
| 576 |
box-shadow: 0 12px 30px rgb(0 0 0 / 28%);
|
| 577 |
}
|
| 578 |
|
| 579 |
-
.
|
| 580 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
min-width: 0;
|
|
|
|
|
|
|
|
|
|
| 582 |
overflow: hidden;
|
| 583 |
text-overflow: ellipsis;
|
| 584 |
white-space: nowrap;
|
|
|
|
|
|
|
| 585 |
}
|
| 586 |
|
| 587 |
-
.
|
| 588 |
font-size: 11px;
|
| 589 |
-
line-height: 1
|
| 590 |
}
|
| 591 |
|
| 592 |
-
.
|
| 593 |
-
color: #c7e6bd;
|
| 594 |
font-size: 9px;
|
| 595 |
-
|
|
|
|
|
|
|
|
|
|
| 596 |
}
|
| 597 |
|
| 598 |
-
.
|
| 599 |
-
|
| 600 |
-
width:
|
| 601 |
-
height:
|
|
|
|
|
|
|
| 602 |
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
pointer-events: none;
|
| 604 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 605 |
}
|
| 606 |
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 610 |
}
|
| 611 |
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
padding: 0;
|
| 615 |
-
justify-content: center;
|
| 616 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
clip: rect(0, 0, 0, 0);
|
| 624 |
-
white-space: nowrap;
|
| 625 |
}
|
| 626 |
|
| 627 |
-
.
|
| 628 |
-
|
| 629 |
-
justify-content: flex-end;
|
| 630 |
-
max-width: 190px;
|
| 631 |
}
|
|
|
|
| 632 |
|
| 633 |
-
|
| 634 |
-
|
|
|
|
|
|
|
| 635 |
}
|
| 636 |
|
| 637 |
-
.
|
| 638 |
-
|
| 639 |
-
width: auto;
|
| 640 |
}
|
| 641 |
|
| 642 |
-
.
|
| 643 |
-
|
|
|
|
|
|
|
| 644 |
}
|
| 645 |
|
| 646 |
-
.
|
| 647 |
-
|
| 648 |
-
right: auto;
|
| 649 |
-
bottom: 378px;
|
| 650 |
}
|
| 651 |
|
| 652 |
-
.
|
| 653 |
-
|
| 654 |
-
left: 14px;
|
| 655 |
-
right: 14px;
|
| 656 |
-
width: auto;
|
| 657 |
}
|
| 658 |
|
| 659 |
-
.
|
| 660 |
-
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 294 |
-
if not isinstance(
|
| 295 |
-
raise ValueError("Expected non-empty '
|
| 296 |
-
return
|
| 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":
|
| 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 =
|
| 506 |
messages = _build_survival_messages(world, next_tick, npc, survival_goal, allowed)
|
| 507 |
-
tools =
|
| 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 >
|
| 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 >
|
| 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 = ["
|
| 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 |
-
|
| 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 |
-
|
| 74 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
| 90 |
npc.inventory_herbs = inv_rng.randint(0, 1)
|
| 91 |
-
npc.inventory_weapon = 1 if
|
|
|
|
|
|
|
| 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":
|
| 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 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
| 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 =
|
| 44 |
-
GATHER_RADIUS =
|
| 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 =
|
| 53 |
FEAR_ON_WITNESS = 20.0
|
| 54 |
-
FEAR_DECAY =
|
|
|
|
|
|
|
| 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 =
|
| 62 |
-
WEAPON_DAMAGE_BONUS =
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 70 |
-
MAX_BEASTS =
|
| 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 |
-
|
| 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 |
-
"
|
|
|
|
| 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 |
-
"
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
| 479 |
return
|
| 480 |
|
| 481 |
rng = random.Random(f"{world.seed}:{world.tick}:beast_spawn")
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 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 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 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 |
-
|
| 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 >
|
| 589 |
-
npc.health -=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 608 |
step,
|
| 609 |
half_width=half_width,
|
| 610 |
half_depth=half_depth,
|
| 611 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
continue
|
| 613 |
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 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 |
-
|
| 674 |
step,
|
| 675 |
half_width=half_width,
|
| 676 |
half_depth=half_depth,
|
| 677 |
)
|
|
|
|
| 678 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 679 |
for npc in world.npcs:
|
| 680 |
-
if
|
| 681 |
-
npc.survival_goal = "dead"
|
| 682 |
continue
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 712 |
-
|
| 713 |
-
allies_nearby = _has_trusted_ally_nearby(world, npc)
|
| 714 |
|
| 715 |
-
|
| 716 |
-
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 722 |
return "eat_food"
|
| 723 |
-
if npc.hunger >
|
|
|
|
|
|
|
| 724 |
return "find_food"
|
| 725 |
if npc.health < FIND_HERBS_HEALTH and npc.inventory_herbs == 0:
|
| 726 |
return "find_herbs"
|
| 727 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 767 |
-
return action
|
| 768 |
-
return "idle"
|
| 769 |
|
| 770 |
|
| 771 |
def _canonical_action(action: str) -> str:
|
| 772 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 773 |
return "consume"
|
| 774 |
-
|
| 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 =
|
| 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="
|
| 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 |
-
"
|
| 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 |
-
|
| 959 |
-
|
| 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 |
-
|
| 966 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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}
|
| 974 |
target_id=partner.id,
|
| 975 |
-
object_id=
|
| 976 |
subject_kind="npc",
|
| 977 |
perspective="self",
|
| 978 |
-
tags=[
|
| 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}
|
| 987 |
target_id=partner.id,
|
| 988 |
-
object_id=
|
| 989 |
subject_kind="npc",
|
| 990 |
perspective="recipient",
|
| 991 |
-
tags=[
|
| 992 |
weight=0.7,
|
| 993 |
)
|
| 994 |
-
remember(npc, world.tick, f"Gave
|
| 995 |
-
remember(partner, world.tick, f"Received
|
| 996 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 1304 |
elif resource_type == "herbs":
|
| 1305 |
-
npc.inventory_herbs
|
| 1306 |
elif resource_type == "wood":
|
| 1307 |
-
npc.inventory_wood
|
| 1308 |
elif resource_type == "weapon":
|
| 1309 |
-
npc.inventory_weapon
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1310 |
|
| 1311 |
|
| 1312 |
def _resource_destination(world: WorldState, npc: Npc, action: str) -> Vec3 | None:
|
| 1313 |
-
if
|
|
|
|
|
|
|
| 1314 |
resource_type: str | None = "food"
|
| 1315 |
elif action == "find_herbs":
|
| 1316 |
resource_type = "herbs"
|
| 1317 |
else:
|
| 1318 |
resource_type = None
|
| 1319 |
-
node =
|
| 1320 |
if node is None:
|
| 1321 |
-
node =
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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])) == "
|
| 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
|
| 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 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
"npc-003",
|
| 255 |
-
]
|
| 256 |
assert all("npcs" not in request for request in requests)
|
| 257 |
-
|
| 258 |
-
assert
|
| 259 |
-
assert "
|
| 260 |
-
assert "
|
| 261 |
-
assert
|
| 262 |
-
assert
|
|
|
|
| 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 ==
|
| 72 |
|
| 73 |
apply_survival_tick(world)
|
| 74 |
-
assert npc.hunger ==
|
| 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.
|
| 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])) == "
|
| 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 %
|
| 224 |
assert node.amount == 2
|
| 225 |
|
| 226 |
-
world.tick =
|
| 227 |
apply_survival_tick(world) # -> +1
|
| 228 |
assert node.amount == 3
|
| 229 |
|
| 230 |
node.amount = 5
|
| 231 |
-
world.tick =
|
| 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(
|
| 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 |
-
|
| 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=
|
| 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
|
| 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 |
|