kikikita commited on
Commit
fbab920
·
1 Parent(s): 029c015

Implement survival mechanics in the god simulator

Browse files

- Added survival loop in `survival.py` to manage hunger, resources, and beast interactions.
- Integrated survival mechanics into the world tick process in `tick.py`, ensuring survival actions are prioritized.
- Enhanced NPC spawning in `spawning.py` to include resource nodes and beasts based on survival configuration.
- Created unit tests in `test_survival_loop.py` to validate survival mechanics, including hunger growth, beast targeting, and resource gathering.
- Ensured that invalid actions do not disrupt the simulation flow.

config/game.llm.local.json CHANGED
@@ -3,7 +3,8 @@
3
  "width": 80,
4
  "depth": 80,
5
  "terrain": "plain_green",
6
- "seed": 42
 
7
  },
8
  "npcs": {
9
  "count": 10
 
3
  "width": 80,
4
  "depth": 80,
5
  "terrain": "plain_green",
6
+ "seed": 42,
7
+ "survival": true
8
  },
9
  "npcs": {
10
  "count": 10
config/game.local.json CHANGED
@@ -3,7 +3,8 @@
3
  "width": 80,
4
  "depth": 80,
5
  "terrain": "plain_green",
6
- "seed": 42
 
7
  },
8
  "npcs": {
9
  "count": 10
 
3
  "width": 80,
4
  "depth": 80,
5
  "terrain": "plain_green",
6
+ "seed": 42,
7
+ "survival": true
8
  },
9
  "npcs": {
10
  "count": 10
config/game.modal.a10.local.json CHANGED
@@ -3,7 +3,8 @@
3
  "width": 80,
4
  "depth": 80,
5
  "terrain": "plain_green",
6
- "seed": 42
 
7
  },
8
  "npcs": {
9
  "count": 10
 
3
  "width": 80,
4
  "depth": 80,
5
  "terrain": "plain_green",
6
+ "seed": 42,
7
+ "survival": true
8
  },
9
  "npcs": {
10
  "count": 10
config/game.modal.b200.local.json CHANGED
@@ -3,7 +3,8 @@
3
  "width": 80,
4
  "depth": 80,
5
  "terrain": "plain_green",
6
- "seed": 42
 
7
  },
8
  "npcs": {
9
  "count": 10
 
3
  "width": 80,
4
  "depth": 80,
5
  "terrain": "plain_green",
6
+ "seed": 42,
7
+ "survival": true
8
  },
9
  "npcs": {
10
  "count": 10
config/game.modal.local.json CHANGED
@@ -3,7 +3,8 @@
3
  "width": 80,
4
  "depth": 80,
5
  "terrain": "plain_green",
6
- "seed": 42
 
7
  },
8
  "npcs": {
9
  "count": 10
 
3
  "width": 80,
4
  "depth": 80,
5
  "terrain": "plain_green",
6
+ "seed": 42,
7
+ "survival": true
8
  },
9
  "npcs": {
10
  "count": 10
config/game.modal.t4.local.json CHANGED
@@ -3,7 +3,8 @@
3
  "width": 80,
4
  "depth": 80,
5
  "terrain": "plain_green",
6
- "seed": 42
 
7
  },
8
  "npcs": {
9
  "count": 10
 
3
  "width": 80,
4
  "depth": 80,
5
  "terrain": "plain_green",
6
+ "seed": 42,
7
+ "survival": true
8
  },
9
  "npcs": {
10
  "count": 10
frontend/src/components/AgentPanel.tsx CHANGED
@@ -24,6 +24,7 @@ function AgentDetails({ entity }: { entity: EntitySnapshot }) {
24
  return (
25
  <>
26
  <AgentReadout entity={entity} />
 
27
  {entity.state.god_directive ? (
28
  <div className="directiveBanner">
29
  <span>Directive</span>
@@ -35,6 +36,30 @@ function AgentDetails({ entity }: { entity: EntitySnapshot }) {
35
  );
36
  }
37
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  function AgentReadout({ entity }: { entity: EntitySnapshot }) {
39
  const selectedHealth = `${entity.state.health} / ${entity.state.max_health} HP`;
40
  const selectedAttackDamage = `DMG ${entity.state.attack_damage}`;
 
24
  return (
25
  <>
26
  <AgentReadout entity={entity} />
27
+ <SurvivalReadout entity={entity} />
28
  {entity.state.god_directive ? (
29
  <div className="directiveBanner">
30
  <span>Directive</span>
 
36
  );
37
  }
38
 
39
+ function SurvivalReadout({ entity }: { entity: EntitySnapshot }) {
40
+ const { hunger, fear, goal, inventory } = entity.state;
41
+ if (hunger === undefined && fear === undefined && goal === undefined && !inventory) {
42
+ return null;
43
+ }
44
+
45
+ const inventoryLabel = inventory
46
+ ? `food ${inventory.food} · herbs ${inventory.herbs} · wood ${inventory.wood} · wpn ${inventory.weapon}`
47
+ : "—";
48
+
49
+ return (
50
+ <div className="agentReadout">
51
+ {hunger !== undefined ? (
52
+ <TooltipLabel className="readoutCell" value={`Hunger ${hunger.toFixed(0)}`} />
53
+ ) : null}
54
+ {fear !== undefined ? (
55
+ <TooltipLabel className="readoutCell" value={`Fear ${fear.toFixed(0)}`} />
56
+ ) : null}
57
+ {goal ? <TooltipLabel className="readoutCell" value={goal} /> : null}
58
+ <TooltipLabel className="readoutCell" value={inventoryLabel} />
59
+ </div>
60
+ );
61
+ }
62
+
63
  function AgentReadout({ entity }: { entity: EntitySnapshot }) {
64
  const selectedHealth = `${entity.state.health} / ${entity.state.max_health} HP`;
65
  const selectedAttackDamage = `DMG ${entity.state.attack_damage}`;
frontend/src/types.ts CHANGED
@@ -17,6 +17,13 @@ export type TerrainSnapshot = {
17
  };
18
  };
19
 
 
 
 
 
 
 
 
20
  export type EntitySnapshot = {
21
  id: string;
22
  kind: "npc";
@@ -31,6 +38,10 @@ export type EntitySnapshot = {
31
  personality: string;
32
  god_directive: string | null;
33
  is_alive: boolean;
 
 
 
 
34
  memory_count: number;
35
  memory_summary: string | null;
36
  memories: MemorySnapshot[];
@@ -48,6 +59,23 @@ export type MemorySnapshot = {
48
  text: string;
49
  };
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  export type WorldSnapshot = {
52
  schema_version: 1;
53
  tick: number;
@@ -59,6 +87,8 @@ export type WorldSnapshot = {
59
  };
60
  terrain: TerrainSnapshot;
61
  entities: EntitySnapshot[];
 
 
62
  };
63
 
64
  export type ModelStatusSnapshot = {
 
17
  };
18
  };
19
 
20
+ export type InventorySnapshot = {
21
+ food: number;
22
+ herbs: number;
23
+ wood: number;
24
+ weapon: number;
25
+ };
26
+
27
  export type EntitySnapshot = {
28
  id: string;
29
  kind: "npc";
 
38
  personality: string;
39
  god_directive: string | null;
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;
47
  memories: MemorySnapshot[];
 
59
  text: string;
60
  };
61
 
62
+ export type ResourceNodeSnapshot = {
63
+ id: string;
64
+ type: "food" | "herbs" | "wood" | "weapon" | string;
65
+ position: Vec3;
66
+ amount: number;
67
+ max_amount?: number;
68
+ };
69
+
70
+ export type BeastSnapshot = {
71
+ id: string;
72
+ position: Vec3;
73
+ health: number;
74
+ max_health: number;
75
+ state: "hunting" | "attacking" | "retreating" | "dead" | string;
76
+ target_npc_id: string | null;
77
+ };
78
+
79
  export type WorldSnapshot = {
80
  schema_version: 1;
81
  tick: number;
 
87
  };
88
  terrain: TerrainSnapshot;
89
  entities: EntitySnapshot[];
90
+ resource_nodes?: ResourceNodeSnapshot[];
91
+ beasts?: BeastSnapshot[];
92
  };
93
 
94
  export type ModelStatusSnapshot = {
src/god_simulator/config.py CHANGED
@@ -16,6 +16,7 @@ class WorldConfig:
16
  depth: int
17
  terrain: TerrainKind
18
  seed: int
 
19
 
20
 
21
  @dataclass(frozen=True, slots=True)
@@ -89,6 +90,7 @@ def parse_game_config(raw: dict[str, Any]) -> GameConfig:
89
  depth=_required_positive_int(world, "depth"),
90
  terrain=terrain,
91
  seed=_required_int(world, "seed"),
 
92
  ),
93
  npcs=NpcConfig(count=_required_non_negative_int(npcs, "count")),
94
  simulation=SimulationConfig(tick_ms=_required_positive_int(simulation, "tick_ms")),
 
16
  depth: int
17
  terrain: TerrainKind
18
  seed: int
19
+ survival: bool = False
20
 
21
 
22
  @dataclass(frozen=True, slots=True)
 
90
  depth=_required_positive_int(world, "depth"),
91
  terrain=terrain,
92
  seed=_required_int(world, "seed"),
93
+ survival=_optional_bool(world, "survival", default=False),
94
  ),
95
  npcs=NpcConfig(count=_required_non_negative_int(npcs, "count")),
96
  simulation=SimulationConfig(tick_ms=_required_positive_int(simulation, "tick_ms")),
src/god_simulator/domain.py CHANGED
@@ -84,6 +84,35 @@ class Npc:
84
  emotions: CitizenEmotions = field(default_factory=CitizenEmotions)
85
  current_goal: CitizenGoal | None = None
86
  mode: CitizenMode = "routine"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
 
88
 
89
  @dataclass(slots=True)
@@ -102,6 +131,8 @@ class WorldState:
102
  npcs: list[Npc]
103
  last_tick_source: str = "initial"
104
  last_action_debug: list[dict[str, Any]] = field(default_factory=list)
 
 
105
 
106
  def to_dict(self) -> dict[str, Any]:
107
  return asdict(self)
 
84
  emotions: CitizenEmotions = field(default_factory=CitizenEmotions)
85
  current_goal: CitizenGoal | None = None
86
  mode: CitizenMode = "routine"
87
+ # Survival core-loop state (deterministic, engine-owned).
88
+ hunger: float = 25.0
89
+ fear: float = 0.0
90
+ survival_goal: str = "routine_life"
91
+ inventory_food: int = 0
92
+ inventory_herbs: int = 0
93
+ inventory_wood: int = 0
94
+ inventory_weapon: int = 0
95
+
96
+
97
+ @dataclass(slots=True)
98
+ class ResourceNode:
99
+ id: str
100
+ resource_type: str # "food" | "herbs" | "wood" | "weapon"
101
+ position: Vec3
102
+ amount: int
103
+ max_amount: int = 0 # regrowth cap; 0 means "no regrowth" (set to amount at spawn)
104
+
105
+
106
+ @dataclass(slots=True)
107
+ class Beast:
108
+ id: str
109
+ position: Vec3
110
+ health: float = 100.0
111
+ damage: float = 25.0
112
+ attack_range: float = 1.5 # in tiles (one tile == one terrain block)
113
+ speed: float = 1.0 # in tiles per tick
114
+ target_npc_id: str | None = None
115
+ state: str = "hunting" # hunting | attacking | retreating | dead
116
 
117
 
118
  @dataclass(slots=True)
 
131
  npcs: list[Npc]
132
  last_tick_source: str = "initial"
133
  last_action_debug: list[dict[str, Any]] = field(default_factory=list)
134
+ resource_nodes: list[ResourceNode] = field(default_factory=list)
135
+ beasts: list[Beast] = field(default_factory=list)
136
 
137
  def to_dict(self) -> dict[str, Any]:
138
  return asdict(self)
src/god_simulator/rendering/claw3d_contract.py CHANGED
@@ -51,6 +51,15 @@ def to_claw3d_snapshot(
51
  "personality": npc.personality,
52
  "god_directive": npc.god_directive,
53
  "is_alive": is_alive(npc),
 
 
 
 
 
 
 
 
 
54
  "memory_count": len(npc.memory),
55
  "memory_summary": npc.memory_summary,
56
  "memories": [
@@ -70,4 +79,33 @@ def to_claw3d_snapshot(
70
  }
71
  for npc in world.npcs
72
  ],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  }
 
51
  "personality": npc.personality,
52
  "god_directive": npc.god_directive,
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,
60
+ "wood": npc.inventory_wood,
61
+ "weapon": npc.inventory_weapon,
62
+ },
63
  "memory_count": len(npc.memory),
64
  "memory_summary": npc.memory_summary,
65
  "memories": [
 
79
  }
80
  for npc in world.npcs
81
  ],
82
+ "resource_nodes": [
83
+ {
84
+ "id": node.id,
85
+ "type": node.resource_type,
86
+ "position": {
87
+ "x": node.position.x,
88
+ "y": node.position.y,
89
+ "z": node.position.z,
90
+ },
91
+ "amount": node.amount,
92
+ "max_amount": node.max_amount,
93
+ }
94
+ for node in world.resource_nodes
95
+ ],
96
+ "beasts": [
97
+ {
98
+ "id": beast.id,
99
+ "position": {
100
+ "x": beast.position.x,
101
+ "y": beast.position.y,
102
+ "z": beast.position.z,
103
+ },
104
+ "health": round(beast.health, 1),
105
+ "max_health": 100,
106
+ "state": beast.state,
107
+ "target_npc_id": beast.target_npc_id,
108
+ }
109
+ for beast in world.beasts
110
+ ],
111
  }
src/god_simulator/simulation/connectors/base.py CHANGED
@@ -18,7 +18,18 @@ SemanticActionKind: TypeAlias = Literal[
18
  "move_to_target",
19
  "attack_back",
20
  ]
21
- ActionKind: TypeAlias = EngineActionKind | SemanticActionKind
 
 
 
 
 
 
 
 
 
 
 
22
 
23
 
24
  @dataclass(frozen=True, slots=True)
 
18
  "move_to_target",
19
  "attack_back",
20
  ]
21
+ SurvivalActionKind: TypeAlias = Literal[
22
+ "gather",
23
+ "eat",
24
+ "heal",
25
+ "move_to_resource",
26
+ "move_to",
27
+ "trade",
28
+ "steal",
29
+ "find_food",
30
+ "find_herbs",
31
+ ]
32
+ ActionKind: TypeAlias = EngineActionKind | SemanticActionKind | SurvivalActionKind
33
 
34
 
35
  @dataclass(frozen=True, slots=True)
src/god_simulator/simulation/connectors/deterministic.py CHANGED
@@ -5,12 +5,18 @@ import random
5
  from god_simulator.domain import Vec3, WorldState
6
  from god_simulator.simulation.connectors.base import NpcDirective, TickPlan
7
  from god_simulator.simulation.mechanics import BLOCK_SIZE, is_alive
 
8
 
9
 
10
  class DeterministicWorldSimulator:
11
  name = "deterministic"
12
 
13
  def propose_tick(self, world: WorldState, next_tick: int) -> TickPlan:
 
 
 
 
 
14
  half_width = world.terrain.width / 2
15
  half_depth = world.terrain.depth / 2
16
  directives: list[NpcDirective] = []
 
5
  from god_simulator.domain import Vec3, WorldState
6
  from god_simulator.simulation.connectors.base import NpcDirective, TickPlan
7
  from god_simulator.simulation.mechanics import BLOCK_SIZE, is_alive
8
+ from god_simulator.simulation.survival import propose_survival_tick
9
 
10
 
11
  class DeterministicWorldSimulator:
12
  name = "deterministic"
13
 
14
  def propose_tick(self, world: WorldState, next_tick: int) -> TickPlan:
15
+ # Survival worlds get the deterministic survival planner so the core
16
+ # loop (flee/fight/gather/eat/heal) is visible without any LLM.
17
+ if world.beasts or world.resource_nodes:
18
+ return propose_survival_tick(world, next_tick)
19
+
20
  half_width = world.terrain.width / 2
21
  half_depth = world.terrain.depth / 2
22
  directives: list[NpcDirective] = []
src/god_simulator/simulation/connectors/openai_compatible.py CHANGED
@@ -29,9 +29,17 @@ from god_simulator.simulation.mechanics import (
29
  distance_between,
30
  has_hostile_intent,
31
  is_alive,
 
32
  )
33
  from god_simulator.simulation.memory import recent_meaningful_memories
34
  from god_simulator.simulation.perception import CitizenPerception
 
 
 
 
 
 
 
35
 
36
  ChatCompleter = Callable[[dict[str, Any]], dict[str, Any]]
37
 
@@ -227,7 +235,11 @@ class OpenAICompatibleWorldSimulator:
227
  f"LLM connector returned unusable action for {npc.id}; using safe fallback: {exc}",
228
  flush=True,
229
  )
230
- return [_safe_fallback_directive(world, npc, reason="model output was unusable")]
 
 
 
 
231
 
232
  directives = [
233
  directive
@@ -235,7 +247,9 @@ class OpenAICompatibleWorldSimulator:
235
  if directive.npc_id == npc.id
236
  ]
237
  if not directives:
238
- return [_safe_fallback_directive(world, npc, reason="model omitted this NPC")]
 
 
239
  return directives
240
 
241
  def _build_request(
@@ -245,13 +259,22 @@ class OpenAICompatibleWorldSimulator:
245
  npc: Npc,
246
  ) -> dict[str, Any]:
247
  assert self._config.model is not None
248
- perception, goal = refresh_citizen_motivation(world, npc)
249
- action_mask = build_action_mask(world, npc, perception, goal)
 
 
 
 
 
 
 
 
 
250
 
251
  request: dict[str, Any] = {
252
  "model": self._config.model,
253
- "messages": _build_messages(world, next_tick, npc, perception, action_mask),
254
- "tools": _action_tools(action_mask.allowed_actions),
255
  "temperature": self._config.temperature,
256
  "top_p": self._config.top_p,
257
  "max_tokens": self._config.max_tokens,
@@ -364,6 +387,73 @@ def _build_messages(
364
  ]
365
 
366
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
367
  def _npc_context(
368
  world: WorldState,
369
  npc: Npc,
@@ -533,6 +623,16 @@ def _action_kind(raw: object) -> ActionKind | None:
533
  "search_target",
534
  "move_to_target",
535
  "attack_back",
 
 
 
 
 
 
 
 
 
 
536
  ):
537
  return cast(ActionKind, raw)
538
  return None
@@ -636,7 +736,16 @@ def _missing_npcs(npcs: Sequence[Npc], directives: Sequence[NpcDirective]) -> li
636
  return [npc for npc in npcs if npc.id not in npc_ids_with_directives]
637
 
638
 
639
- def _safe_fallback_directive(world: WorldState, npc: Npc, *, reason: str) -> NpcDirective:
 
 
 
 
 
 
 
 
 
640
  perception, goal = refresh_citizen_motivation(world, npc)
641
  action_mask = build_action_mask(world, npc, perception, goal)
642
  action = action_mask.fallback_action
 
29
  distance_between,
30
  has_hostile_intent,
31
  is_alive,
32
+ vec_distance,
33
  )
34
  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
+ is_survival_world,
39
+ nearest_beast,
40
+ survival_directive_for,
41
+ )
42
+ from god_simulator.simulation.survival import select_goal as select_survival_goal
43
 
44
  ChatCompleter = Callable[[dict[str, Any]], dict[str, Any]]
45
 
 
235
  f"LLM connector returned unusable action for {npc.id}; using safe fallback: {exc}",
236
  flush=True,
237
  )
238
+ return [
239
+ _safe_fallback_directive(
240
+ world, npc, next_tick, reason="model output was unusable"
241
+ )
242
+ ]
243
 
244
  directives = [
245
  directive
 
247
  if directive.npc_id == npc.id
248
  ]
249
  if not directives:
250
+ return [
251
+ _safe_fallback_directive(world, npc, next_tick, reason="model omitted this NPC")
252
+ ]
253
  return directives
254
 
255
  def _build_request(
 
259
  npc: Npc,
260
  ) -> dict[str, Any]:
261
  assert self._config.model is not None
262
+
263
+ if is_survival_world(world):
264
+ survival_goal = select_survival_goal(npc, world)
265
+ allowed = GOAL_ACTIONS.get(survival_goal) or ["observe"]
266
+ messages = _build_survival_messages(world, next_tick, npc, survival_goal, allowed)
267
+ tools = _action_tools(cast(list[ActionKind], allowed))
268
+ else:
269
+ perception, goal = refresh_citizen_motivation(world, npc)
270
+ action_mask = build_action_mask(world, npc, perception, goal)
271
+ messages = _build_messages(world, next_tick, npc, perception, action_mask)
272
+ tools = _action_tools(action_mask.allowed_actions)
273
 
274
  request: dict[str, Any] = {
275
  "model": self._config.model,
276
+ "messages": messages,
277
+ "tools": tools,
278
  "temperature": self._config.temperature,
279
  "top_p": self._config.top_p,
280
  "max_tokens": self._config.max_tokens,
 
387
  ]
388
 
389
 
390
+ def _build_survival_messages(
391
+ world: WorldState,
392
+ next_tick: int,
393
+ npc: Npc,
394
+ goal: str,
395
+ allowed_actions: Sequence[str],
396
+ ) -> list[dict[str, str]]:
397
+ """Compact survival prompt: state in, one allowed action out."""
398
+ beast = nearest_beast(world, npc.position)
399
+ if beast is not None:
400
+ threat = f"Beast {beast.id} is {vec_distance(npc.position, beast.position):.1f} tiles away!"
401
+ else:
402
+ threat = "No threat"
403
+
404
+ nearby_nodes = sorted(
405
+ world.resource_nodes,
406
+ key=lambda node: vec_distance(npc.position, node.position),
407
+ )[:2]
408
+ resources = [
409
+ f"{node.id}:{node.resource_type} dist {vec_distance(npc.position, node.position):.0f} "
410
+ f"(x{node.amount})"
411
+ for node in nearby_nodes
412
+ ]
413
+ people = [
414
+ f"{other.name} ({'armed' if other.inventory_weapon > 0 else 'unarmed'})"
415
+ for other in world.npcs
416
+ if other.id != npc.id
417
+ and is_alive(other)
418
+ and vec_distance(npc.position, other.position) <= 4 * BLOCK_SIZE
419
+ ]
420
+
421
+ payload = {
422
+ "npc_id": npc.id,
423
+ "status": {
424
+ "health": round(npc.health),
425
+ "hunger": round(npc.hunger),
426
+ "fear": round(npc.fear),
427
+ },
428
+ "inventory": {
429
+ "food": npc.inventory_food,
430
+ "herbs": npc.inventory_herbs,
431
+ "wood": npc.inventory_wood,
432
+ "weapon": npc.inventory_weapon,
433
+ },
434
+ "goal": goal,
435
+ "threat": threat,
436
+ "nearby_resources": resources,
437
+ "nearby_people": people,
438
+ "allowed_actions": list(allowed_actions),
439
+ }
440
+
441
+ return [
442
+ {
443
+ "role": "system",
444
+ "content": (
445
+ f"You are {npc.name}, surviving in a dangerous world. "
446
+ "Choose exactly ONE action with a single choose_action tool call. "
447
+ "Pick only from allowed_actions. No prose, no reasoning."
448
+ ),
449
+ },
450
+ {
451
+ "role": "user",
452
+ "content": json.dumps(payload, ensure_ascii=False),
453
+ },
454
+ ]
455
+
456
+
457
  def _npc_context(
458
  world: WorldState,
459
  npc: Npc,
 
623
  "search_target",
624
  "move_to_target",
625
  "attack_back",
626
+ # Survival actions (resolved by the survival engine in survival worlds).
627
+ "gather",
628
+ "eat",
629
+ "heal",
630
+ "move_to_resource",
631
+ "move_to",
632
+ "trade",
633
+ "steal",
634
+ "find_food",
635
+ "find_herbs",
636
  ):
637
  return cast(ActionKind, raw)
638
  return None
 
736
  return [npc for npc in npcs if npc.id not in npc_ids_with_directives]
737
 
738
 
739
+ def _safe_fallback_directive(
740
+ world: WorldState,
741
+ npc: Npc,
742
+ next_tick: int,
743
+ *,
744
+ reason: str,
745
+ ) -> NpcDirective:
746
+ if is_survival_world(world):
747
+ return survival_directive_for(world, npc, next_tick)
748
+
749
  perception, goal = refresh_citizen_motivation(world, npc)
750
  action_mask = build_action_mask(world, npc, perception, goal)
751
  action = action_mask.fallback_action
src/god_simulator/simulation/mechanics.py CHANGED
@@ -21,6 +21,71 @@ def distance_between(first: Npc, second: Npc) -> float:
21
  return math.hypot(first.position.x - second.position.x, first.position.z - second.position.z)
22
 
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  _HOSTILE_KEYWORDS = frozenset(
25
  {
26
  "attack",
 
21
  return math.hypot(first.position.x - second.position.x, first.position.z - second.position.z)
22
 
23
 
24
+ def vec_distance(first: Vec3, second: Vec3) -> float:
25
+ """Planar distance between two world positions (x/z plane)."""
26
+ return math.hypot(first.x - second.x, first.z - second.z)
27
+
28
+
29
+ def move_toward(
30
+ origin: Vec3,
31
+ destination: Vec3,
32
+ step: float,
33
+ *,
34
+ half_width: float | None = None,
35
+ half_depth: float | None = None,
36
+ ) -> Vec3:
37
+ """Move ``origin`` up to ``step`` world units toward ``destination``."""
38
+ delta_x = destination.x - origin.x
39
+ delta_z = destination.z - origin.z
40
+ distance = math.hypot(delta_x, delta_z)
41
+ if distance == 0 or distance <= step:
42
+ target_x, target_z = destination.x, destination.z
43
+ else:
44
+ scale = step / distance
45
+ target_x = origin.x + (delta_x * scale)
46
+ target_z = origin.z + (delta_z * scale)
47
+ return _bounded_vec(target_x, target_z, half_width=half_width, half_depth=half_depth)
48
+
49
+
50
+ def move_away(
51
+ origin: Vec3,
52
+ threat: Vec3,
53
+ step: float,
54
+ *,
55
+ half_width: float | None = None,
56
+ half_depth: float | None = None,
57
+ ) -> Vec3:
58
+ """Move ``origin`` up to ``step`` world units directly away from ``threat``."""
59
+ delta_x = origin.x - threat.x
60
+ delta_z = origin.z - threat.z
61
+ distance = math.hypot(delta_x, delta_z)
62
+ if distance == 0:
63
+ delta_x = -1.0 if origin.x > 0 else 1.0
64
+ delta_z = 0.0
65
+ distance = 1.0
66
+ scale = step / distance
67
+ return _bounded_vec(
68
+ origin.x + (delta_x * scale),
69
+ origin.z + (delta_z * scale),
70
+ half_width=half_width,
71
+ half_depth=half_depth,
72
+ )
73
+
74
+
75
+ def _bounded_vec(
76
+ x: float,
77
+ z: float,
78
+ *,
79
+ half_width: float | None,
80
+ half_depth: float | None,
81
+ ) -> Vec3:
82
+ if half_width is not None:
83
+ x = _clamp(x, -half_width, half_width)
84
+ if half_depth is not None:
85
+ z = _clamp(z, -half_depth, half_depth)
86
+ return Vec3(x=round(x, 3), y=0.0, z=round(z, 3))
87
+
88
+
89
  _HOSTILE_KEYWORDS = frozenset(
90
  {
91
  "attack",
src/god_simulator/simulation/spawning.py CHANGED
@@ -3,7 +3,15 @@ from __future__ import annotations
3
  import random
4
 
5
  from god_simulator.config import GameConfig
6
- from god_simulator.domain import MemoryEntry, Npc, Terrain, Vec3, WorldState
 
 
 
 
 
 
 
 
7
  from god_simulator.simulation.mechanics import BLOCK_SIZE, GRID_BLOCKS
8
 
9
  _NAMES = [
@@ -47,6 +55,8 @@ def create_world(config: GameConfig) -> WorldState:
47
  _spawn_npc(index=index, config=config, rng=rng)
48
  for index in range(config.npcs.count)
49
  ],
 
 
50
  )
51
 
52
 
@@ -59,7 +69,7 @@ def _spawn_npc(*, index: int, config: GameConfig, rng: random.Random) -> Npc:
59
  x = _random_block_center(rng, half_width=half_width)
60
  z = _random_block_center(rng, half_width=half_depth)
61
 
62
- return Npc(
63
  id=f"npc-{index + 1:03d}",
64
  name=name,
65
  role=role,
@@ -68,6 +78,45 @@ def _spawn_npc(*, index: int, config: GameConfig, rng: random.Random) -> Npc:
68
  memory=[MemoryEntry(tick=0, text=f"{name} arrived as a {role}.")],
69
  )
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
  def _random_block_center(rng: random.Random, *, half_width: float) -> float:
73
  first_center = -half_width + (BLOCK_SIZE / 2)
 
3
  import random
4
 
5
  from god_simulator.config import GameConfig
6
+ from god_simulator.domain import (
7
+ Beast,
8
+ MemoryEntry,
9
+ Npc,
10
+ ResourceNode,
11
+ Terrain,
12
+ Vec3,
13
+ WorldState,
14
+ )
15
  from god_simulator.simulation.mechanics import BLOCK_SIZE, GRID_BLOCKS
16
 
17
  _NAMES = [
 
55
  _spawn_npc(index=index, config=config, rng=rng)
56
  for index in range(config.npcs.count)
57
  ],
58
+ resource_nodes=_spawn_resource_nodes(config),
59
+ beasts=_spawn_beasts(config),
60
  )
61
 
62
 
 
69
  x = _random_block_center(rng, half_width=half_width)
70
  z = _random_block_center(rng, half_width=half_depth)
71
 
72
+ npc = Npc(
73
  id=f"npc-{index + 1:03d}",
74
  name=name,
75
  role=role,
 
78
  memory=[MemoryEntry(tick=0, text=f"{name} arrived as a {role}.")],
79
  )
80
 
81
+ if config.world.survival:
82
+ # Independent per-NPC RNG so survival inventory never perturbs the shared
83
+ # position/damage stream (keeps non-survival spawning deterministic).
84
+ inv_rng = random.Random(config.world.seed + index)
85
+ npc.inventory_food = inv_rng.randint(0, 2)
86
+ npc.inventory_herbs = inv_rng.randint(0, 1)
87
+ npc.inventory_weapon = 1 if index % 3 == 0 else 0
88
+
89
+ return npc
90
+
91
+
92
+ def _spawn_resource_nodes(config: GameConfig) -> list[ResourceNode]:
93
+ if not config.world.survival:
94
+ return []
95
+ # Spread across the whole 80x80 map so foragers disperse instead of clumping.
96
+ spec = [
97
+ ("res_food_1", "food", -12.0, -12.0, 5),
98
+ ("res_food_2", "food", 16.0, 8.0, 5),
99
+ ("res_food_3", "food", -24.0, -4.0, 5),
100
+ ("res_food_4", "food", 20.0, -20.0, 5),
101
+ ("res_herbs_1", "herbs", 4.0, -16.0, 4),
102
+ ("res_herbs_2", "herbs", -20.0, 12.0, 4),
103
+ ("res_herbs_3", "herbs", 28.0, 20.0, 4),
104
+ ("res_wood_1", "wood", 8.0, 4.0, 6),
105
+ ("res_wood_2", "wood", -30.0, -24.0, 6),
106
+ ("res_weapon_1", "weapon", -4.0, 8.0, 2),
107
+ ("res_weapon_2", "weapon", 24.0, -8.0, 2),
108
+ ]
109
+ return [
110
+ ResourceNode(node_id, kind, Vec3(x=x, y=0.0, z=z), amount=amount, max_amount=amount)
111
+ for node_id, kind, x, z, amount in spec
112
+ ]
113
+
114
+
115
+ def _spawn_beasts(config: GameConfig) -> list[Beast]:
116
+ if not config.world.survival:
117
+ return []
118
+ return [Beast("beast_1", Vec3(x=6.0, y=0.0, z=6.0))]
119
+
120
 
121
  def _random_block_center(rng: random.Random, *, half_width: float) -> float:
122
  first_center = -half_width + (BLOCK_SIZE / 2)
src/god_simulator/simulation/survival.py ADDED
@@ -0,0 +1,664 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic survival core loop: hunger, resources, and a hunting beast.
2
+
3
+ This is an additive layer on top of the existing NPC-vs-NPC social pipeline. It
4
+ owns environment truth (hunger, beasts, resource nodes) and resolves a small set
5
+ of survival actions. The LLM never mutates health/position/inventory directly;
6
+ everything flows through :func:`apply_survival_tick` and :func:`apply_action_effects`.
7
+
8
+ Distances in this module are in world units. The task design is expressed in
9
+ "tiles"; one tile is one terrain block (``BLOCK_SIZE`` world units), so tile-based
10
+ thresholds are scaled by ``TILE`` below.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import math
16
+ import random
17
+
18
+ from god_simulator.domain import Beast, Npc, ResourceNode, Vec3, WorldState
19
+ from god_simulator.simulation.connectors.base import NpcDirective, TickPlan
20
+ from god_simulator.simulation.mechanics import (
21
+ BLOCK_SIZE,
22
+ TALK_RADIUS,
23
+ is_alive,
24
+ move_away,
25
+ move_toward,
26
+ vec_distance,
27
+ )
28
+ from god_simulator.simulation.memory import remember
29
+
30
+ TILE = BLOCK_SIZE
31
+
32
+ # Hunger / health economy.
33
+ HUNGER_RATE = 2.0
34
+ HUNGER_DAMAGE = 3
35
+ STARVATION_THRESHOLD = 80.0
36
+ HUNGER_CAP = 150.0
37
+ HEAL_GOAL_HEALTH = 40 # health < this (with herbs) -> heal_self
38
+ FIND_HERBS_HEALTH = 50 # health < this (without herbs) -> find_herbs
39
+
40
+ # Awareness radii (world units).
41
+ BEAST_ALERT_RADIUS = 6 * TILE
42
+ ALLY_RADIUS = 5 * TILE
43
+ WITNESS_RADIUS = 5 * TILE
44
+ FEAR_DECAY_RADIUS = 8 * TILE
45
+ GATHER_RADIUS = 2 * TILE
46
+ TRADE_RADIUS = 2 * TILE
47
+
48
+ # Fear dynamics.
49
+ FEAR_ON_ATTACK = 40.0
50
+ FEAR_ON_WITNESS = 20.0
51
+ FEAR_DECAY = 5.0
52
+
53
+ # Movement / combat (world units).
54
+ NPC_MOVE_STEP = 1 * TILE
55
+ FLEE_STEP = 2 * TILE
56
+ NPC_MELEE_REACH = 2 * TILE
57
+ BASE_NPC_DAMAGE = 15
58
+ WEAPON_DAMAGE_BONUS = 10
59
+ BEAST_RETREAT_HEALTH = 20.0
60
+
61
+ # Resource regrowth: every node regrows +1 toward its cap on this cadence, so the
62
+ # settlement is sustainable instead of starving once nodes are first depleted.
63
+ RESOURCE_REGEN_INTERVAL = 2
64
+ RESOURCE_REGEN_AMOUNT = 1
65
+ # How many of the nearest non-empty nodes foragers spread across (anti-clumping).
66
+ SPREAD_NODE_CHOICES = 3
67
+ # Minimum surplus food before an NPC is willing to share with a needier neighbor.
68
+ SHARE_FOOD_SURPLUS = 2
69
+
70
+ # Goal -> allowed survival actions (the deterministic planner and any LLM both
71
+ # choose from these sets; effects are resolved by the engine).
72
+ GOAL_ACTIONS: dict[str, list[str]] = {
73
+ "survive_threat_fight": ["attack", "defend", "call_for_help"],
74
+ "survive_threat_flee": ["flee", "call_for_help", "defend"],
75
+ "heal_self": ["heal"],
76
+ "eat_food": ["eat"],
77
+ "find_food": ["move_to_resource", "gather", "trade", "steal"],
78
+ "find_herbs": ["move_to_resource", "gather", "trade"],
79
+ "routine_life": ["gather", "trade", "move_to", "talk"],
80
+ "dead": [],
81
+ }
82
+
83
+ # Every action the survival resolver knows how to execute.
84
+ VALID_SURVIVAL_ACTIONS = frozenset(
85
+ {action for actions in GOAL_ACTIONS.values() for action in actions}
86
+ | {"move_to_resource", "find_food", "find_herbs", "idle"}
87
+ )
88
+
89
+
90
+ # --------------------------------------------------------------------------- #
91
+ # Spatial queries
92
+ # --------------------------------------------------------------------------- #
93
+ def nearest_beast(world: WorldState, position: Vec3) -> Beast | None:
94
+ living = [beast for beast in world.beasts if beast.state != "dead"]
95
+ if not living:
96
+ return None
97
+ return min(living, key=lambda beast: (vec_distance(position, beast.position), beast.id))
98
+
99
+
100
+ def nearest_beast_distance(world: WorldState, npc: Npc) -> float:
101
+ beast = nearest_beast(world, npc.position)
102
+ if beast is None:
103
+ return float("inf")
104
+ return vec_distance(npc.position, beast.position)
105
+
106
+
107
+ def find_nearest_alive_npc(
108
+ world: WorldState,
109
+ position: Vec3,
110
+ *,
111
+ exclude_id: str | None = None,
112
+ ) -> Npc | None:
113
+ candidates = [
114
+ npc
115
+ for npc in world.npcs
116
+ if is_alive(npc) and npc.id != exclude_id
117
+ ]
118
+ if not candidates:
119
+ return None
120
+ return min(candidates, key=lambda npc: (vec_distance(position, npc.position), npc.id))
121
+
122
+
123
+ def nearest_alive_npc(
124
+ world: WorldState,
125
+ npc: Npc,
126
+ *,
127
+ max_dist: float,
128
+ require_food: bool = False,
129
+ ) -> Npc | None:
130
+ candidates = [
131
+ other
132
+ for other in world.npcs
133
+ if other.id != npc.id
134
+ and is_alive(other)
135
+ and vec_distance(npc.position, other.position) <= max_dist
136
+ and (not require_food or other.inventory_food > 0)
137
+ ]
138
+ if not candidates:
139
+ return None
140
+ return min(candidates, key=lambda other: (vec_distance(npc.position, other.position), other.id))
141
+
142
+
143
+ def nearest_resource(
144
+ world: WorldState,
145
+ position: Vec3,
146
+ *,
147
+ resource_type: str | None = None,
148
+ max_dist: float | None = None,
149
+ require_amount: bool = True,
150
+ ) -> ResourceNode | None:
151
+ candidates = [
152
+ node
153
+ for node in world.resource_nodes
154
+ if (resource_type is None or node.resource_type == resource_type)
155
+ and (not require_amount or node.amount > 0)
156
+ and (max_dist is None or vec_distance(position, node.position) <= max_dist)
157
+ ]
158
+ if not candidates:
159
+ return None
160
+ return min(candidates, key=lambda node: (vec_distance(position, node.position), node.id))
161
+
162
+
163
+ def npcs_in_radius(
164
+ world: WorldState,
165
+ position: Vec3,
166
+ radius: float,
167
+ *,
168
+ exclude_id: str | None = None,
169
+ ) -> list[Npc]:
170
+ return [
171
+ npc
172
+ for npc in world.npcs
173
+ if is_alive(npc)
174
+ and npc.id != exclude_id
175
+ and vec_distance(position, npc.position) <= radius
176
+ ]
177
+
178
+
179
+ def count_alive_npcs_in_radius(world: WorldState, position: Vec3, radius: float) -> int:
180
+ return sum(
181
+ 1
182
+ for npc in world.npcs
183
+ if is_alive(npc) and vec_distance(position, npc.position) <= radius
184
+ )
185
+
186
+
187
+ # --------------------------------------------------------------------------- #
188
+ # Deterministic environment tick (no LLM)
189
+ # --------------------------------------------------------------------------- #
190
+ def apply_survival_tick(world: WorldState) -> None:
191
+ """Advance hunger, the beast, fear, and starvation deaths for one tick.
192
+
193
+ No-op for worlds without survival entities, so the existing social-only
194
+ pipeline and its tests are unaffected.
195
+ """
196
+ if not world.beasts and not world.resource_nodes:
197
+ return
198
+
199
+ half_width = world.terrain.width / 2
200
+ half_depth = world.terrain.depth / 2
201
+
202
+ if world.tick % RESOURCE_REGEN_INTERVAL == 0:
203
+ for node in world.resource_nodes:
204
+ if node.max_amount > 0 and node.amount < node.max_amount:
205
+ node.amount = min(node.max_amount, node.amount + RESOURCE_REGEN_AMOUNT)
206
+
207
+ for npc in world.npcs:
208
+ if not is_alive(npc):
209
+ continue
210
+ npc.hunger = min(HUNGER_CAP, npc.hunger + HUNGER_RATE)
211
+ if npc.hunger > STARVATION_THRESHOLD:
212
+ npc.health -= HUNGER_DAMAGE
213
+
214
+ for beast in world.beasts:
215
+ if beast.state == "dead":
216
+ continue
217
+ target = find_nearest_alive_npc(world, beast.position)
218
+ if target is None:
219
+ continue
220
+ beast.target_npc_id = target.id
221
+
222
+ distance = vec_distance(beast.position, target.position)
223
+ step = beast.speed * TILE
224
+ reach = beast.attack_range * TILE
225
+
226
+ if beast.health < BEAST_RETREAT_HEALTH:
227
+ beast.state = "retreating"
228
+ beast.position = move_away(
229
+ beast.position,
230
+ target.position,
231
+ step,
232
+ half_width=half_width,
233
+ half_depth=half_depth,
234
+ )
235
+ continue
236
+
237
+ if distance <= reach:
238
+ beast.state = "attacking"
239
+ target.health -= int(beast.damage)
240
+ target.fear = min(100.0, target.fear + FEAR_ON_ATTACK)
241
+ remember(target, world.tick, f"Beast {beast.id} attacked me at tick {world.tick}.")
242
+ if not is_alive(target):
243
+ target.intention = "dead"
244
+ remember(target, world.tick, "You were killed by the beast.")
245
+ for witness in npcs_in_radius(world, beast.position, WITNESS_RADIUS, exclude_id=target.id):
246
+ witness.fear = min(100.0, witness.fear + FEAR_ON_WITNESS)
247
+ remember(witness, world.tick, f"The beast attacked {target.name} nearby.")
248
+ else:
249
+ beast.state = "hunting"
250
+ beast.position = move_toward(
251
+ beast.position,
252
+ target.position,
253
+ step,
254
+ half_width=half_width,
255
+ half_depth=half_depth,
256
+ )
257
+
258
+ for npc in world.npcs:
259
+ if not is_alive(npc):
260
+ npc.survival_goal = "dead"
261
+ continue
262
+ if nearest_beast_distance(world, npc) > FEAR_DECAY_RADIUS:
263
+ npc.fear = max(0.0, npc.fear - FEAR_DECAY)
264
+ # Keep the live goal current for the snapshot, even when planning ran on a
265
+ # deep-copied world (the HTTP server path).
266
+ npc.survival_goal = select_goal(npc, world)
267
+
268
+
269
+ # --------------------------------------------------------------------------- #
270
+ # Goal selection + action mask
271
+ # --------------------------------------------------------------------------- #
272
+ def select_goal(npc: Npc, world: WorldState) -> str:
273
+ if not is_alive(npc):
274
+ return "dead"
275
+
276
+ beast_nearby = nearest_beast_distance(world, npc) < BEAST_ALERT_RADIUS
277
+ has_weapon = npc.inventory_weapon > 0
278
+ allies_nearby = count_alive_npcs_in_radius(world, npc.position, ALLY_RADIUS) > 1
279
+
280
+ if beast_nearby and has_weapon and allies_nearby:
281
+ return "survive_threat_fight"
282
+ if beast_nearby:
283
+ return "survive_threat_flee"
284
+ if npc.health < HEAL_GOAL_HEALTH and npc.inventory_herbs > 0:
285
+ return "heal_self"
286
+ if npc.hunger > STARVATION_THRESHOLD and npc.inventory_food > 0:
287
+ return "eat_food"
288
+ if npc.hunger > STARVATION_THRESHOLD:
289
+ return "find_food"
290
+ if npc.health < FIND_HERBS_HEALTH and npc.inventory_herbs == 0:
291
+ return "find_herbs"
292
+ return "routine_life"
293
+
294
+
295
+ def allowed_actions_for_goal(goal: str) -> list[str]:
296
+ return list(GOAL_ACTIONS.get(goal, []))
297
+
298
+
299
+ def validate_survival_action(npc: Npc, action: str, world: WorldState) -> str:
300
+ """Return a safe, executable action, repairing impossible requests."""
301
+ if action == "gather":
302
+ node = nearest_resource(world, npc.position, max_dist=GATHER_RADIUS)
303
+ if node is None or node.amount <= 0:
304
+ return "move_to_resource"
305
+ return "gather"
306
+ if action == "eat":
307
+ return "eat" if npc.inventory_food > 0 else "find_food"
308
+ if action == "heal":
309
+ return "heal" if npc.inventory_herbs > 0 else "find_herbs"
310
+ if action == "attack":
311
+ beast = nearest_beast(world, npc.position)
312
+ if beast is None:
313
+ return "defend"
314
+ return "attack" # apply_action_effects approaches when out of melee reach
315
+ if action == "steal":
316
+ partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True)
317
+ return "steal" if partner is not None else "move_to_resource"
318
+ if action in VALID_SURVIVAL_ACTIONS:
319
+ return action
320
+ return "idle"
321
+
322
+
323
+ # --------------------------------------------------------------------------- #
324
+ # Effect resolver (engine owns all state changes)
325
+ # --------------------------------------------------------------------------- #
326
+ def apply_action_effects(
327
+ npc: Npc,
328
+ action: str,
329
+ world: WorldState,
330
+ *,
331
+ destination: Vec3 | None = None,
332
+ ) -> str:
333
+ """Apply one validated survival action; return a short intention summary."""
334
+ half_width = world.terrain.width / 2
335
+ half_depth = world.terrain.depth / 2
336
+
337
+ if action == "eat":
338
+ if npc.inventory_food > 0:
339
+ npc.inventory_food -= 1
340
+ npc.hunger = max(0.0, npc.hunger - 40.0)
341
+ npc.health = min(100, npc.health + 5)
342
+ return "eating food"
343
+ return "searching for food"
344
+
345
+ if action == "heal":
346
+ if npc.inventory_herbs > 0:
347
+ npc.inventory_herbs -= 1
348
+ npc.health = min(100, npc.health + 25)
349
+ return "healing with herbs"
350
+ return "searching for herbs"
351
+
352
+ if action == "gather":
353
+ node = nearest_resource(world, npc.position, max_dist=GATHER_RADIUS)
354
+ if node is not None and node.amount > 0:
355
+ node.amount -= 1
356
+ _add_to_inventory(npc, node.resource_type)
357
+ remember(npc, world.tick, f"You gathered {node.resource_type}.")
358
+ return f"gathering {node.resource_type}"
359
+ return "searching for resources"
360
+
361
+ if action in ("move_to_resource", "find_food", "find_herbs"):
362
+ dest = destination or _resource_destination(world, npc, action)
363
+ if dest is not None:
364
+ npc.position = move_toward(
365
+ npc.position, dest, NPC_MOVE_STEP, half_width=half_width, half_depth=half_depth
366
+ )
367
+ return "heading to resources"
368
+ return "wandering"
369
+
370
+ if action == "move_to":
371
+ # Free wander in a deterministic-but-varied direction (anti-clumping).
372
+ rng = random.Random(f"{world.seed}:{world.tick}:{npc.id}:wander")
373
+ angle = rng.uniform(0.0, 2.0 * math.pi)
374
+ dest = Vec3(
375
+ x=npc.position.x + math.cos(angle) * NPC_MOVE_STEP * 3,
376
+ y=0.0,
377
+ z=npc.position.z + math.sin(angle) * NPC_MOVE_STEP * 3,
378
+ )
379
+ npc.position = move_toward(
380
+ npc.position, dest, NPC_MOVE_STEP, half_width=half_width, half_depth=half_depth
381
+ )
382
+ return "wandering"
383
+
384
+ if action == "flee":
385
+ beast = nearest_beast(world, npc.position)
386
+ if beast is not None:
387
+ npc.position = move_away(
388
+ npc.position,
389
+ beast.position,
390
+ FLEE_STEP,
391
+ half_width=half_width,
392
+ half_depth=half_depth,
393
+ )
394
+ return "fleeing the beast"
395
+ return "looking around"
396
+
397
+ if action == "attack":
398
+ beast = nearest_beast(world, npc.position)
399
+ if beast is None:
400
+ return "defending"
401
+ if vec_distance(npc.position, beast.position) <= NPC_MELEE_REACH:
402
+ damage = BASE_NPC_DAMAGE + (WEAPON_DAMAGE_BONUS if npc.inventory_weapon > 0 else 0)
403
+ beast.health -= damage
404
+ remember(npc, world.tick, f"You struck {beast.id} for {damage} damage.")
405
+ if beast.health <= 0:
406
+ beast.state = "dead"
407
+ remember(npc, world.tick, f"You killed {beast.id}!")
408
+ return "killed the beast"
409
+ return "fighting the beast"
410
+ npc.position = move_toward(
411
+ npc.position,
412
+ beast.position,
413
+ NPC_MOVE_STEP,
414
+ half_width=half_width,
415
+ half_depth=half_depth,
416
+ )
417
+ return "charging the beast"
418
+
419
+ if action == "trade":
420
+ # Share food from whoever has a surplus with the neediest nearby neighbour,
421
+ # so food flows from haves to have-nots instead of people starving alone.
422
+ partner = _neediest_food_neighbor(world, npc)
423
+ if (
424
+ partner is not None
425
+ and npc.inventory_food >= SHARE_FOOD_SURPLUS
426
+ and partner.inventory_food < npc.inventory_food
427
+ ):
428
+ npc.inventory_food -= 1
429
+ partner.inventory_food += 1
430
+ remember(npc, world.tick, f"Gave food to {partner.name}.")
431
+ remember(partner, world.tick, f"Received food from {npc.name}.")
432
+ return f"sharing food with {partner.name}"
433
+ return "looking for someone to trade with"
434
+
435
+ if action == "steal":
436
+ partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True)
437
+ if partner is not None and partner.inventory_food > 0:
438
+ partner.inventory_food -= 1
439
+ npc.inventory_food += 1
440
+ remember(npc, world.tick, f"Stole food from {partner.name}.")
441
+ remember(partner, world.tick, f"{npc.name} stole food from you.")
442
+ return f"stealing from {partner.name}"
443
+ return "looking to steal"
444
+
445
+ if action == "call_for_help":
446
+ for ally in npcs_in_radius(world, npc.position, WITNESS_RADIUS, exclude_id=npc.id):
447
+ ally.fear = min(100.0, ally.fear + 5.0)
448
+ return "calling for help"
449
+
450
+ if action == "defend":
451
+ return "defending"
452
+
453
+ if action == "talk":
454
+ partner = nearest_alive_npc(world, npc, max_dist=TALK_RADIUS)
455
+ if partner is not None:
456
+ line = _small_talk_line(npc, partner)
457
+ remember(npc, world.tick, f"You talked with {partner.name}: {line}")
458
+ remember(partner, world.tick, f"{npc.name} talked with you: {line}")
459
+ return f"talking to {partner.name}"
460
+ return "wandering"
461
+
462
+ return "idle"
463
+
464
+
465
+ def _add_to_inventory(npc: Npc, resource_type: str) -> None:
466
+ if resource_type == "food":
467
+ npc.inventory_food += 1
468
+ elif resource_type == "herbs":
469
+ npc.inventory_herbs += 1
470
+ elif resource_type == "wood":
471
+ npc.inventory_wood += 1
472
+ elif resource_type == "weapon":
473
+ npc.inventory_weapon += 1
474
+
475
+
476
+ def _resource_destination(world: WorldState, npc: Npc, action: str) -> Vec3 | None:
477
+ if action == "find_food":
478
+ resource_type: str | None = "food"
479
+ elif action == "find_herbs":
480
+ resource_type = "herbs"
481
+ else:
482
+ resource_type = None
483
+ node = nearest_resource(world, npc.position, resource_type=resource_type)
484
+ if node is None:
485
+ node = nearest_resource(world, npc.position)
486
+ return node.position if node is not None else None
487
+
488
+
489
+ def _spread_resource(
490
+ world: WorldState,
491
+ npc: Npc,
492
+ index: int,
493
+ *,
494
+ resource_type: str | None = None,
495
+ ) -> ResourceNode | None:
496
+ """Pick one of the nearest non-empty nodes, fanned out by NPC index.
497
+
498
+ Spreading foragers across several nearby nodes stops the whole settlement from
499
+ piling onto a single node and then starving together.
500
+ """
501
+ nodes = sorted(
502
+ (
503
+ node
504
+ for node in world.resource_nodes
505
+ if node.amount > 0 and (resource_type is None or node.resource_type == resource_type)
506
+ ),
507
+ key=lambda node: (vec_distance(npc.position, node.position), node.id),
508
+ )
509
+ if not nodes and resource_type is not None:
510
+ nodes = sorted(
511
+ (node for node in world.resource_nodes if node.amount > 0),
512
+ key=lambda node: (vec_distance(npc.position, node.position), node.id),
513
+ )
514
+ if not nodes:
515
+ return None
516
+ return nodes[index % min(SPREAD_NODE_CHOICES, len(nodes))]
517
+
518
+
519
+ def _neediest_food_neighbor(world: WorldState, npc: Npc) -> Npc | None:
520
+ candidates = [
521
+ other
522
+ for other in world.npcs
523
+ if other.id != npc.id
524
+ and is_alive(other)
525
+ and vec_distance(npc.position, other.position) <= TRADE_RADIUS
526
+ ]
527
+ if not candidates:
528
+ return None
529
+ return min(candidates, key=lambda other: (other.inventory_food, -other.hunger, other.id))
530
+
531
+
532
+ def _small_talk_line(npc: Npc, partner: Npc) -> str:
533
+ if npc.fear > 30:
534
+ return "stay alert, the beast is near"
535
+ if npc.hunger > 60:
536
+ return "I'm getting hungry"
537
+ if npc.inventory_food >= SHARE_FOOD_SURPLUS:
538
+ return "I have food to spare if you need it"
539
+ if npc.inventory_weapon > 0:
540
+ return "keep close, I'm armed"
541
+ return "how are you holding up?"
542
+
543
+
544
+ # --------------------------------------------------------------------------- #
545
+ # Deterministic survival planner + resolver
546
+ # --------------------------------------------------------------------------- #
547
+ def is_survival_world(world: WorldState) -> bool:
548
+ return bool(world.beasts or world.resource_nodes)
549
+
550
+
551
+ def survival_directive_for(world: WorldState, npc: Npc, next_tick: int) -> NpcDirective:
552
+ """Deterministic survival directive for a single NPC (used as a safe fallback)."""
553
+ goal = select_goal(npc, world)
554
+ index = next((i for i, candidate in enumerate(world.npcs) if candidate.id == npc.id), 0)
555
+ return _plan_directive(world, npc, goal, next_tick, index)
556
+
557
+
558
+ def propose_survival_tick(world: WorldState, next_tick: int) -> TickPlan:
559
+ """Deterministic survival plan: one survival action per living NPC."""
560
+ directives: list[NpcDirective] = []
561
+ for index, npc in enumerate(world.npcs):
562
+ if not is_alive(npc):
563
+ continue
564
+ goal = select_goal(npc, world)
565
+ directives.append(_plan_directive(world, npc, goal, next_tick, index))
566
+ return TickPlan(source="survival", directives=directives)
567
+
568
+
569
+ def apply_survival_plan(
570
+ world: WorldState,
571
+ next_tick: int,
572
+ plan: TickPlan,
573
+ ) -> list[dict[str, object]]:
574
+ """Resolve a survival plan onto the live world. Returns debug traces."""
575
+ debug: list[dict[str, object]] = []
576
+ npcs_by_id = {npc.id: npc for npc in world.npcs}
577
+
578
+ for directive in plan.directives:
579
+ npc = npcs_by_id.get(directive.npc_id)
580
+ if npc is None:
581
+ continue
582
+ if not is_alive(npc):
583
+ npc.intention = "dead"
584
+ continue
585
+ action = validate_survival_action(npc, directive.action, world)
586
+ summary = apply_action_effects(npc, action, world, destination=directive.target)
587
+ npc.intention = summary
588
+ debug.append(
589
+ {
590
+ "npc_id": npc.id,
591
+ "goal": npc.survival_goal,
592
+ "requested_action": directive.action,
593
+ "action": action,
594
+ "summary": summary,
595
+ }
596
+ )
597
+
598
+ for npc in world.npcs:
599
+ if not is_alive(npc):
600
+ npc.intention = "dead"
601
+
602
+ return debug
603
+
604
+
605
+ def _plan_directive(
606
+ world: WorldState,
607
+ npc: Npc,
608
+ goal: str,
609
+ next_tick: int,
610
+ index: int,
611
+ ) -> NpcDirective:
612
+ npc.survival_goal = goal
613
+
614
+ def directive(action: str, *, target: Vec3 | None = None) -> NpcDirective:
615
+ return NpcDirective(
616
+ npc_id=npc.id,
617
+ action=action,
618
+ target=target,
619
+ intent=f"survival:{goal}",
620
+ )
621
+
622
+ if goal == "survive_threat_fight":
623
+ if (next_tick + index) % 3 == 0:
624
+ return directive("call_for_help")
625
+ return directive("attack")
626
+
627
+ if goal == "survive_threat_flee":
628
+ if (next_tick + index) % 4 == 0:
629
+ return directive("call_for_help")
630
+ return directive("flee")
631
+
632
+ if goal == "heal_self":
633
+ return directive("heal")
634
+
635
+ if goal == "eat_food":
636
+ return directive("eat")
637
+
638
+ if goal in ("find_food", "find_herbs"):
639
+ resource_type = "food" if goal == "find_food" else "herbs"
640
+ node = _spread_resource(world, npc, index, resource_type=resource_type)
641
+ if node is not None:
642
+ if vec_distance(npc.position, node.position) <= GATHER_RADIUS:
643
+ return directive("gather")
644
+ return directive("move_to_resource", target=node.position)
645
+ return directive("move_to") # nothing to gather right now -> wander, don't freeze
646
+
647
+ # routine_life: socialise, share, forage, or wander -- and spread out.
648
+ neighbor = nearest_alive_npc(world, npc, max_dist=TALK_RADIUS)
649
+ phase = (next_tick + index) % 4
650
+ if (
651
+ neighbor is not None
652
+ and npc.inventory_food >= SHARE_FOOD_SURPLUS
653
+ and neighbor.inventory_food < npc.inventory_food
654
+ and phase == 0
655
+ ):
656
+ return directive("trade")
657
+ if neighbor is not None and phase == 1:
658
+ return directive("talk")
659
+ node = _spread_resource(world, npc, index)
660
+ if node is not None and vec_distance(npc.position, node.position) <= GATHER_RADIUS:
661
+ return directive("gather")
662
+ if node is not None and phase != 3:
663
+ return directive("move_to_resource", target=node.position)
664
+ return directive("move_to") # wander to disperse
src/god_simulator/simulation/tick.py CHANGED
@@ -35,6 +35,11 @@ from god_simulator.simulation.mechanics import (
35
  )
36
  from god_simulator.simulation.memory import remember
37
  from god_simulator.simulation.perception import CitizenPerception, nearby_threat_npc
 
 
 
 
 
38
 
39
 
40
  def advance_world(world: WorldState, simulator: WorldSimulator | None = None) -> WorldState:
@@ -57,6 +62,19 @@ def plan_world_tick(
57
 
58
  def apply_tick_plan(world: WorldState, next_tick: int, plan: TickPlan) -> None:
59
  """Commit an already computed tick plan into the live world state."""
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  plan, traces = validate_tick_plan(world, plan)
61
  traces_by_npc_id = {trace.npc_id: trace for trace in traces}
62
  resolved_traces: list[ActionDebugTrace] = []
 
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
+ apply_survival_plan,
40
+ apply_survival_tick,
41
+ is_survival_world,
42
+ )
43
 
44
 
45
  def advance_world(world: WorldState, simulator: WorldSimulator | None = None) -> WorldState:
 
62
 
63
  def apply_tick_plan(world: WorldState, next_tick: int, plan: TickPlan) -> None:
64
  """Commit an already computed tick plan into the live world state."""
65
+ # Deterministic survival environment (hunger, beast, fear) runs first and is
66
+ # a no-op for worlds without survival entities.
67
+ apply_survival_tick(world)
68
+
69
+ # In a survival world all directives (deterministic or LLM) resolve through
70
+ # the survival resolver so the engine owns hunger/health/position/inventory.
71
+ if plan.source.startswith("survival") or is_survival_world(world):
72
+ debug = apply_survival_plan(world, next_tick, plan)
73
+ world.tick = next_tick
74
+ world.last_tick_source = plan.source
75
+ world.last_action_debug = debug
76
+ return
77
+
78
  plan, traces = validate_tick_plan(world, plan)
79
  traces_by_npc_id = {trace.npc_id: trace for trace in traces}
80
  resolved_traces: list[ActionDebugTrace] = []
tests/test_survival_loop.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from god_simulator.config import (
4
+ GameConfig,
5
+ NpcConfig,
6
+ ServerConfig,
7
+ SimulationConfig,
8
+ WorldConfig,
9
+ )
10
+ import json
11
+ from typing import Any
12
+
13
+ from god_simulator.config import ConnectorConfig
14
+ from god_simulator.domain import Beast, Npc, ResourceNode, Terrain, Vec3, WorldState
15
+ from god_simulator.simulation.connectors.base import NpcDirective, TickPlan
16
+ from god_simulator.simulation.connectors.deterministic import DeterministicWorldSimulator
17
+ from god_simulator.simulation.connectors.openai_compatible import OpenAICompatibleWorldSimulator
18
+ from god_simulator.simulation.spawning import create_world
19
+ from god_simulator.simulation.survival import (
20
+ GOAL_ACTIONS,
21
+ apply_action_effects,
22
+ apply_survival_tick,
23
+ select_goal,
24
+ validate_survival_action,
25
+ )
26
+ from god_simulator.simulation.tick import advance_world, apply_tick_plan
27
+
28
+
29
+ def _npc(npc_id: str = "npc-001", name: str = "Ada", *, x: float = 0.0, z: float = 0.0,
30
+ role: str = "farmer", **state: object) -> Npc:
31
+ return Npc(id=npc_id, name=name, role=role, position=Vec3(x=x, y=0.0, z=z), **state) # type: ignore[arg-type]
32
+
33
+
34
+ def _world(
35
+ npcs: list[Npc],
36
+ *,
37
+ beasts: list[Beast] | None = None,
38
+ resource_nodes: list[ResourceNode] | None = None,
39
+ tick: int = 0,
40
+ ) -> WorldState:
41
+ return WorldState(
42
+ tick=tick,
43
+ seed=42,
44
+ terrain=Terrain(kind="plain_green", width=80, depth=80),
45
+ npcs=npcs,
46
+ resource_nodes=resource_nodes or [],
47
+ beasts=beasts or [],
48
+ )
49
+
50
+
51
+ def _survival_config(*, npc_count: int = 6) -> GameConfig:
52
+ return GameConfig(
53
+ world=WorldConfig(width=80, depth=80, terrain="plain_green", seed=42, survival=True),
54
+ npcs=NpcConfig(count=npc_count),
55
+ simulation=SimulationConfig(tick_ms=500),
56
+ server=ServerConfig(host="127.0.0.1", port=8000),
57
+ )
58
+
59
+
60
+ # 1. hunger grows every tick
61
+ def test_hunger_increases_each_tick() -> None:
62
+ npc = _npc(hunger=25.0)
63
+ world = _world([npc], resource_nodes=[ResourceNode("r", "food", Vec3(30.0, 0.0, 30.0), 5)])
64
+
65
+ apply_survival_tick(world)
66
+ assert npc.hunger == 27.0
67
+
68
+ apply_survival_tick(world)
69
+ assert npc.hunger == 29.0
70
+
71
+
72
+ # 2. hunger > 80 + food in inventory -> goal = eat_food
73
+ def test_high_hunger_with_food_selects_eat_food() -> None:
74
+ npc = _npc(hunger=85.0, inventory_food=1)
75
+ assert select_goal(npc, _world([npc])) == "eat_food"
76
+
77
+
78
+ # 3. hunger > 80 + no food -> goal = find_food
79
+ def test_high_hunger_without_food_selects_find_food() -> None:
80
+ npc = _npc(hunger=85.0, inventory_food=0)
81
+ assert select_goal(npc, _world([npc])) == "find_food"
82
+
83
+
84
+ # 4. health < 40 + herbs -> goal = heal_self
85
+ def test_low_health_with_herbs_selects_heal_self() -> None:
86
+ npc = _npc(health=30, inventory_herbs=1)
87
+ assert select_goal(npc, _world([npc])) == "heal_self"
88
+
89
+
90
+ # 5. beast picks the nearest living NPC
91
+ def test_beast_targets_nearest_alive_npc() -> None:
92
+ near = _npc("npc-001", "Ada", x=8.0, z=0.0)
93
+ far = _npc("npc-002", "Boris", x=30.0, z=0.0)
94
+ beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0))
95
+ world = _world([far, near], beasts=[beast])
96
+
97
+ apply_survival_tick(world)
98
+
99
+ assert beast.target_npc_id == "npc-001"
100
+
101
+
102
+ # 6. beast in range attacks and lowers health
103
+ def test_beast_in_range_attacks_and_reduces_health() -> None:
104
+ victim = _npc(x=4.0, z=0.0, health=100)
105
+ beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0)) # damage 25, reach 1.5 tiles == 6 units
106
+ world = _world([victim], beasts=[beast])
107
+
108
+ apply_survival_tick(world)
109
+
110
+ assert victim.health == 75
111
+ assert beast.state == "attacking"
112
+
113
+
114
+ # 7. NPC attacked by beast gains fear >= 40
115
+ def test_attacked_npc_gains_fear() -> None:
116
+ victim = _npc(x=4.0, z=0.0)
117
+ beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0))
118
+ world = _world([victim], beasts=[beast])
119
+
120
+ apply_survival_tick(world)
121
+
122
+ assert victim.fear >= 40
123
+
124
+
125
+ # 8. NPC near a beast gets a survive_threat goal
126
+ def test_nearby_beast_triggers_survive_threat_goal() -> None:
127
+ npc = _npc(x=8.0, z=0.0)
128
+ beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0))
129
+
130
+ goal = select_goal(npc, _world([npc], beasts=[beast]))
131
+
132
+ assert goal.startswith("survive_threat")
133
+
134
+
135
+ # 9. armed NPC with allies -> survive_threat_fight
136
+ def test_armed_npc_with_allies_fights() -> None:
137
+ fighter = _npc("npc-001", "Ada", x=8.0, z=0.0, inventory_weapon=1)
138
+ ally = _npc("npc-002", "Boris", x=10.0, z=0.0)
139
+ beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0))
140
+
141
+ assert select_goal(fighter, _world([fighter, ally], beasts=[beast])) == "survive_threat_fight"
142
+
143
+
144
+ # 10. unarmed loner -> survive_threat_flee
145
+ def test_unarmed_loner_flees() -> None:
146
+ npc = _npc(x=8.0, z=0.0, inventory_weapon=0)
147
+ beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0))
148
+
149
+ assert select_goal(npc, _world([npc], beasts=[beast])) == "survive_threat_flee"
150
+
151
+
152
+ # 11. gather requires a nearby resource node with amount > 0
153
+ def test_gather_requires_nearby_nonempty_resource() -> None:
154
+ npc = _npc(x=0.0, z=0.0)
155
+ node = ResourceNode("r1", "food", Vec3(2.0, 0.0, 0.0), amount=3)
156
+ world = _world([npc], resource_nodes=[node])
157
+ assert validate_survival_action(npc, "gather", world) == "gather"
158
+
159
+ empty = ResourceNode("r2", "food", Vec3(2.0, 0.0, 0.0), amount=0)
160
+ npc_empty = _npc(x=0.0, z=0.0)
161
+ assert validate_survival_action(npc_empty, "gather", _world([npc_empty], resource_nodes=[empty])) == (
162
+ "move_to_resource"
163
+ )
164
+
165
+ far = ResourceNode("r3", "food", Vec3(40.0, 0.0, 0.0), amount=3)
166
+ npc_far = _npc(x=0.0, z=0.0)
167
+ assert validate_survival_action(npc_far, "gather", _world([npc_far], resource_nodes=[far])) == (
168
+ "move_to_resource"
169
+ )
170
+
171
+ # The gather effect consumes one unit and fills the right inventory slot.
172
+ apply_action_effects(npc, "gather", world)
173
+ assert npc.inventory_food == 1
174
+ assert node.amount == 2
175
+
176
+
177
+ # 12. invalid action from the model does not break the tick (safe fallback)
178
+ def test_invalid_action_does_not_break_tick() -> None:
179
+ npc = _npc(x=0.0, z=0.0)
180
+ world = _world([npc], resource_nodes=[ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), 5)])
181
+ plan = TickPlan(
182
+ source="survival",
183
+ directives=[NpcDirective(npc_id=npc.id, action="banana_dance")], # type: ignore[arg-type]
184
+ )
185
+
186
+ apply_tick_plan(world, 1, plan) # must not raise
187
+
188
+ assert world.tick == 1
189
+ assert npc.intention == "idle"
190
+ assert validate_survival_action(npc, "banana_dance", world) == "idle"
191
+
192
+
193
+ # Extra: end-to-end deterministic survival loop runs through the public tick path.
194
+ def test_survival_world_advances_and_produces_events() -> None:
195
+ world = create_world(_survival_config(npc_count=6))
196
+ assert world.beasts and world.resource_nodes
197
+
198
+ starting_hunger = [npc.hunger for npc in world.npcs]
199
+ for _ in range(30):
200
+ advance_world(world)
201
+
202
+ assert world.tick == 30
203
+ assert world.last_tick_source == "survival"
204
+ # Every survival goal in GOAL_ACTIONS is a recognised key for the mask.
205
+ assert all(npc.survival_goal in GOAL_ACTIONS for npc in world.npcs)
206
+ # Hunger economy and beast pursuit both did something observable.
207
+ assert any(npc.hunger != start for npc, start in zip(world.npcs, starting_hunger, strict=True))
208
+ assert world.beasts[0].position != Vec3(6.0, 0.0, 6.0) or world.beasts[0].state != "hunting"
209
+
210
+
211
+ # Sustainability: resource nodes regrow toward their cap so the settlement does
212
+ # not starve to death once nodes are first depleted.
213
+ def test_resource_nodes_regrow_over_time() -> None:
214
+ node = ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), amount=1, max_amount=5)
215
+ world = _world([_npc(x=0.0, z=0.0)], resource_nodes=[node], tick=0)
216
+
217
+ apply_survival_tick(world) # tick 0 % 2 == 0 -> +1
218
+ assert node.amount == 2
219
+
220
+ world.tick = 2
221
+ apply_survival_tick(world) # -> +1
222
+ assert node.amount == 3
223
+
224
+ node.amount = 5
225
+ world.tick = 4
226
+ apply_survival_tick(world) # never exceeds the cap
227
+ assert node.amount == 5
228
+
229
+
230
+ # Interaction: talk records a real memory on BOTH participants.
231
+ def test_talk_exchanges_memory_between_npcs() -> None:
232
+ ada = _npc("npc-001", "Ada", x=0.0, z=0.0)
233
+ boris = _npc("npc-002", "Boris", x=2.0, z=0.0)
234
+ world = _world([ada, boris], resource_nodes=[ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), 5)])
235
+
236
+ summary = apply_action_effects(ada, "talk", world)
237
+
238
+ assert summary == "talking to Boris"
239
+ assert any("talked" in m.text for m in ada.memory)
240
+ assert any("Ada talked with you" in m.text for m in boris.memory)
241
+
242
+
243
+ # Cooperation: a surplus holder shares food with a needier neighbour.
244
+ def test_trade_shares_food_with_needier_neighbor() -> None:
245
+ giver = _npc("npc-001", "Ada", x=0.0, z=0.0, inventory_food=3)
246
+ taker = _npc("npc-002", "Boris", x=2.0, z=0.0, inventory_food=0)
247
+ world = _world([giver, taker])
248
+
249
+ summary = apply_action_effects(giver, "trade", world)
250
+
251
+ assert summary == "sharing food with Boris"
252
+ assert giver.inventory_food == 2
253
+ assert taker.inventory_food == 1
254
+ assert any("Gave food to Boris" in m.text for m in giver.memory)
255
+ assert any("Received food from Ada" in m.text for m in taker.memory)
256
+
257
+
258
+ # Extra: the LLM connector gets a compact survival prompt and its chosen survival
259
+ # action resolves through the engine without breaking the tick.
260
+ def test_llm_connector_drives_survival_actions() -> None:
261
+ captured: list[dict[str, Any]] = []
262
+
263
+ def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
264
+ payload = json.loads(request["messages"][1]["content"])
265
+ captured.append(payload)
266
+ action = payload["allowed_actions"][0] if payload["allowed_actions"] else "idle"
267
+ return {
268
+ "choices": [
269
+ {
270
+ "message": {
271
+ "tool_calls": [
272
+ {
273
+ "type": "function",
274
+ "function": {
275
+ "name": "choose_action",
276
+ "arguments": json.dumps(
277
+ {"npc_id": payload["npc_id"], "action": action}
278
+ ),
279
+ },
280
+ }
281
+ ]
282
+ }
283
+ }
284
+ ]
285
+ }
286
+
287
+ world = create_world(_survival_config(npc_count=4))
288
+ simulator = OpenAICompatibleWorldSimulator(
289
+ ConnectorConfig(type="openai_compatible", base_url="http://llm.local/v1", model="test"),
290
+ fallback=DeterministicWorldSimulator(),
291
+ chat_completer=fake_chat_completer,
292
+ )
293
+
294
+ advance_world(world, simulator)
295
+
296
+ assert world.tick == 1
297
+ assert captured # the compact survival prompt was built and sent
298
+ # Every captured prompt is the compact survival shape, not the social payload.
299
+ assert all("allowed_actions" in payload and "goal" in payload for payload in captured)
300
+ assert all("status" in payload and "inventory" in payload for payload in captured)
301
+ assert "openai_compatible" in world.last_tick_source