DeltaZN commited on
Commit
5c435e7
·
1 Parent(s): ac2cc93

updated textures & refactor logic

Browse files
architecture.md CHANGED
@@ -424,60 +424,54 @@ Other state contracts:
424
 
425
  `Gap`: no global memory/event timeline, no relation/faction maps, no action history except per-NPC memories and UI-facing `intention`.
426
 
427
- ## 9. Current Action System
428
 
429
- `Current`: actions are represented by `ActionKind = Literal["walk", "talk", "attack", "idle"]` and `NpcDirective`.
 
 
 
 
430
 
431
- ```python
432
- @dataclass(frozen=True, slots=True)
433
- class NpcDirective:
434
- npc_id: str
435
- action: ActionKind = "idle"
436
- target: Vec3 | None = None
437
- target_npc_id: str | None = None
438
- message: str | None = None
439
- memory: str | None = None
440
- ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
441
 
442
  Action lifecycle:
443
 
444
- | Stage | Current Implementation |
445
  | --- | --- |
446
- | Selection | Deterministic connector or OpenAI-compatible NPC tool call. |
447
  | Parsing | `_parse_tool_calls` / `_parse_json_content` / `_actions_to_plan`. |
448
- | Plan | `TickPlan.directives`. |
449
- | Execution | `apply_tick_plan` dispatches to `_apply_walk`, `_apply_talk`, `_apply_attack`. |
450
- | Consequences | Position, intention, health, memory updates. |
451
- | Failure states | Intention/memory strings such as `too far to attack`, `could not talk`. |
452
-
453
- Rules currently present:
454
-
455
- | Rule | File | Current |
456
- | --- | --- | --- |
457
- | Terrain clamp | `tick.py` | Walk targets clamp to half width/depth. |
458
- | Max walk distance | `tick.py` | Walk moves at most `MAX_WALK_DISTANCE = 4.0`. |
459
- | Talk range | `tick.py` | `distance_between > TALK_RADIUS` blocks talk. |
460
- | Attack range | `tick.py` | `distance_between > ATTACK_RADIUS` blocks damage. |
461
- | Dead actors | `tick.py` | Dead NPC directives ignored; intention `dead`. |
462
- | Dead targets | `tick.py` | Talk/attack unavailable if target not alive. |
463
- | Damage resolution | `tick.py` | Server computes `attack_damage + deterministic rng 0..5`. |
464
- | Occupancy | Tests | Multiple NPCs can occupy same position; no collision blocking. |
465
-
466
- Important gaps:
467
-
468
- | Missing Rule | Current Consequence |
469
- | --- | --- |
470
- | Intent vs execution distinction | LLM can request `attack`; executor only fails it instead of converting to pursuit. |
471
- | Pre-validation report | No explicit `ValidationResult` object or debug trace. |
472
- | Target visibility validation at execution | Parser checks target exists, but executor does not require that target was visible in actor context. |
473
- | Move before melee attack | Out-of-range attack becomes failure memory, not `walk` toward target. |
474
- | Talk willingness/availability | Hostile/attacked target can still be talked to if alive and in range. |
475
- | Threat proximity response | Nearby NPCs are not forced to flee/defend/call for help. |
476
- | Combat/social state machine | No mode that suppresses small talk during active violence. |
477
- | Cooldowns/action costs | None. |
478
- | Failure fallback policy | Invalid action often becomes deterministic random walk via fallback, not domain-aware fallback. |
479
-
480
- `Recommendation`: first add a structured action pipeline around existing `NpcDirective` rather than replacing all action code.
481
 
482
  ## 10. Current Frontend Architecture
483
 
 
424
 
425
  `Gap`: no global memory/event timeline, no relation/faction maps, no action history except per-NPC memories and UI-facing `intention`.
426
 
427
+ ## 9. Current Action System (primitives refactor, 2026-06)
428
 
429
+ `Current`: the whole action surface is six parameterized primitives,
430
+ `ActionKind = Literal["move", "speak", "strike", "use", "transfer", "idle"]`
431
+ (`connectors/base.py`). Behavioral flavor (fleeing, pleading, threatening,
432
+ stealing, gathering, healing...) is expressed through parameters and free-text
433
+ intent, never through dedicated action ids:
434
 
435
+ | Primitive | Key parameters | Covers |
436
+ | --- | --- | --- |
437
+ | `move` | `target` coords, `target_npc_id`/`target_entity_id`/`resource_id`, `away` | walking, pursuit, investigation, approaching resources, fleeing (`away=true`) |
438
+ | `speak` | `target_npc_id`, `message`, `communication_intent` | direct talk, pleading, threatening, trade requests; without target it is a broadcast shout (call for help) |
439
+ | `strike` | `target_npc_id` / `target_entity_id` | attacking NPCs and beasts |
440
+ | `use` | `resource_id` (gather) or `resource_type` food/herbs (eat/heal) | gathering, eating, healing |
441
+ | `transfer` | `target_npc_id`, `resource_type`, `amount`, `take` | giving resources; `take=true` is stealing |
442
+ | `idle` | `intent` (observe/defend) | waiting, observing, defending |
443
+
444
+ Design principles:
445
+
446
+ - **The engine validates only physical possibility.** `validate_directive`
447
+ (`tick.py`) and `validate_survival_action` (`survival.py`) repair missing or
448
+ dead targets, out-of-range interactions (converted to approach movement), and
449
+ empty inventories. They never reject an action for being behaviorally
450
+ implausible — that judgment belongs to the planner/LLM.
451
+ - **Hints, not masks.** `build_action_hints` (`actions/hints.py`) produces
452
+ ranked `suggested_actions` plus a reason from the citizen's goal and
453
+ perception. They are injected into the LLM prompt as hints; all six tools
454
+ are always available (`primitive_tools()` in `openai_compatible.py`).
455
+ The survival prompt equivalently uses `GOAL_SUGGESTED_ACTIONS`.
456
+ - **The deterministic planner is an autopilot, not a pre-filter.**
457
+ `autopilot.py` (social worlds) and `propose_survival_tick` /
458
+ `survival_directive_for` (survival worlds) fill gaps when no LLM directive
459
+ exists or the output is unusable. They never restrict the LLM's choices.
460
+ - **Effect resolution stays engine-owned.** Damage, hunger, inventory,
461
+ movement steps, and memory episodes are resolved deterministically in
462
+ `tick.py` / `survival.py`; inside the survival resolver primitives are
463
+ translated to private effect verbs (`_verb_for_action`) that never leave the
464
+ module.
465
 
466
  Action lifecycle:
467
 
468
+ | Stage | Implementation |
469
  | --- | --- |
470
+ | Selection | LLM picks one of six primitive tools; deterministic autopilot otherwise. |
471
  | Parsing | `_parse_tool_calls` / `_parse_json_content` / `_actions_to_plan`. |
472
+ | Validation | Physical-possibility repairs with `ValidationResult` debug traces. |
473
+ | Execution | `apply_tick_plan` dispatches `_apply_move/_apply_speak/_apply_strike/_apply_idle`; survival worlds resolve through `apply_survival_plan`. |
474
+ | Consequences | Position, intention, health, inventory, memory episode, relationship updates. |
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
475
 
476
  ## 10. Current Frontend Architecture
477
 
frontend/src/App.tsx CHANGED
@@ -48,7 +48,11 @@ export function App() {
48
  }}
49
  />
50
 
51
- <AgentPanel entity={simulation.selectedEntity} />
 
 
 
 
52
 
53
  <StatusToasts
54
  error={simulation.error}
 
48
  }}
49
  />
50
 
51
+ <AgentPanel
52
+ entity={simulation.selectedEntity}
53
+ beast={simulation.selectedBeast}
54
+ resource={simulation.selectedResource}
55
+ />
56
 
57
  <StatusToasts
58
  error={simulation.error}
frontend/src/components/AgentPanel.tsx CHANGED
@@ -1,11 +1,20 @@
1
- import type { EntitySnapshot } from "../types";
2
  import { TooltipLabel } from "./TooltipLabel";
3
 
4
  type AgentPanelProps = {
5
  entity: EntitySnapshot | null;
 
 
6
  };
7
 
8
- export function AgentPanel({ entity }: AgentPanelProps) {
 
 
 
 
 
 
 
9
  const selectedName = entity?.label ?? "No NPC";
10
  const selectedRole = entity?.role ?? "world";
11
 
@@ -20,6 +29,55 @@ export function AgentPanel({ entity }: AgentPanelProps) {
20
  );
21
  }
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  function AgentDetails({ entity }: { entity: EntitySnapshot }) {
24
  return (
25
  <>
 
1
+ import type { BeastSnapshot, EntitySnapshot, ResourceNodeSnapshot } from "../types";
2
  import { TooltipLabel } from "./TooltipLabel";
3
 
4
  type AgentPanelProps = {
5
  entity: EntitySnapshot | null;
6
+ beast?: BeastSnapshot | null;
7
+ resource?: ResourceNodeSnapshot | null;
8
  };
9
 
10
+ export function AgentPanel({ entity, beast = null, resource = null }: AgentPanelProps) {
11
+ if (beast) {
12
+ return <BeastPanel beast={beast} />;
13
+ }
14
+ if (resource) {
15
+ return <ResourcePanel resource={resource} />;
16
+ }
17
+
18
  const selectedName = entity?.label ?? "No NPC";
19
  const selectedRole = entity?.role ?? "world";
20
 
 
29
  );
30
  }
31
 
32
+ function BeastPanel({ beast }: { beast: BeastSnapshot }) {
33
+ const suffix = beast.id.replace(/^beast[_-]?/, "");
34
+ const beastName = suffix ? `Beast ${suffix}` : "Beast";
35
+ const healthLabel = `${beast.health} / ${beast.max_health} HP`;
36
+ const positionLabel = `x ${beast.position.x.toFixed(1)} / z ${beast.position.z.toFixed(1)}`;
37
+
38
+ return (
39
+ <aside className="agentPanel" aria-label="Selected beast">
40
+ <div className="panelHeader">
41
+ <TooltipLabel className="panelTitle" value={beastName} />
42
+ <TooltipLabel className="panelRole" value="beast" />
43
+ </div>
44
+ <div className="agentReadout">
45
+ <TooltipLabel className="readoutCell healthCell" value={healthLabel} />
46
+ <TooltipLabel className="readoutCell" value={beast.state} />
47
+ <TooltipLabel
48
+ className="readoutCell"
49
+ value={beast.target_npc_id ? `hunting ${beast.target_npc_id}` : "no target"}
50
+ />
51
+ <TooltipLabel className="readoutCell" value={positionLabel} />
52
+ </div>
53
+ </aside>
54
+ );
55
+ }
56
+
57
+ function ResourcePanel({ resource }: { resource: ResourceNodeSnapshot }) {
58
+ const title = resource.type
59
+ ? `${resource.type.charAt(0).toUpperCase()}${resource.type.slice(1)} node`
60
+ : "Resource node";
61
+ const amountLabel = resource.max_amount
62
+ ? `${resource.amount} / ${resource.max_amount}`
63
+ : `${resource.amount} left`;
64
+ const positionLabel = `x ${resource.position.x.toFixed(1)} / z ${resource.position.z.toFixed(1)}`;
65
+
66
+ return (
67
+ <aside className="agentPanel" aria-label="Selected resource">
68
+ <div className="panelHeader">
69
+ <TooltipLabel className="panelTitle" value={title} />
70
+ <TooltipLabel className="panelRole" value="resource" />
71
+ </div>
72
+ <div className="agentReadout">
73
+ <TooltipLabel className="readoutCell" value={amountLabel} />
74
+ <TooltipLabel className="readoutCell" value={resource.id} />
75
+ <TooltipLabel className="readoutCell" value={positionLabel} />
76
+ </div>
77
+ </aside>
78
+ );
79
+ }
80
+
81
  function AgentDetails({ entity }: { entity: EntitySnapshot }) {
82
  return (
83
  <>
frontend/src/hooks/useWorldSimulation.ts CHANGED
@@ -96,6 +96,16 @@ export function useWorldSimulation() {
96
  [selectedId, snapshot],
97
  );
98
 
 
 
 
 
 
 
 
 
 
 
99
  return {
100
  error,
101
  godCommand,
@@ -104,7 +114,9 @@ export function useWorldSimulation() {
104
  isGodPending,
105
  isWaitingForTick,
106
  isWorldCommandPending,
 
107
  selectedEntity,
 
108
  selectedId,
109
  setGodCommand,
110
  setIsAutoTicking,
@@ -148,10 +160,14 @@ function useSelectedEntityFallback(
148
  }
149
 
150
  setSelectedId((currentId) => {
151
- if (currentId && snapshot.entities.some((entity) => entity.id === currentId)) {
152
- return currentId;
153
  }
154
- return snapshot.entities[0]?.id ?? null;
 
 
 
 
155
  });
156
  }, [snapshot, setSelectedId]);
157
  }
 
96
  [selectedId, snapshot],
97
  );
98
 
99
+ const selectedBeast = useMemo(
100
+ () => snapshot?.beasts?.find((beast) => beast.id === selectedId) ?? null,
101
+ [selectedId, snapshot],
102
+ );
103
+
104
+ const selectedResource = useMemo(
105
+ () => snapshot?.resource_nodes?.find((node) => node.id === selectedId) ?? null,
106
+ [selectedId, snapshot],
107
+ );
108
+
109
  return {
110
  error,
111
  godCommand,
 
114
  isGodPending,
115
  isWaitingForTick,
116
  isWorldCommandPending,
117
+ selectedBeast,
118
  selectedEntity,
119
+ selectedResource,
120
  selectedId,
121
  setGodCommand,
122
  setIsAutoTicking,
 
160
  }
161
 
162
  setSelectedId((currentId) => {
163
+ if (!currentId) {
164
+ return null;
165
  }
166
+ const stillExists =
167
+ snapshot.entities.some((entity) => entity.id === currentId) ||
168
+ (snapshot.beasts ?? []).some((beast) => beast.id === currentId) ||
169
+ (snapshot.resource_nodes ?? []).some((node) => node.id === currentId);
170
+ return stillExists ? currentId : null;
171
  });
172
  }, [snapshot, setSelectedId]);
173
  }
frontend/src/scene/BeastActor.tsx ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Html } from "@react-three/drei";
2
+ import { useFrame } from "@react-three/fiber";
3
+ import { useMemo, useRef } from "react";
4
+ import type { Group, Mesh } from "three";
5
+ import { MathUtils, Vector3 } from "three";
6
+
7
+ import type { BeastSnapshot } from "../types";
8
+ import { angularDistance, dampAngle, hashToUnit } from "./characterUtils";
9
+
10
+ type BeastActorProps = {
11
+ beast: BeastSnapshot;
12
+ isSelected: boolean;
13
+ onSelect: () => void;
14
+ };
15
+
16
+ type BeastPalette = {
17
+ fur: string;
18
+ belly: string;
19
+ legs: string;
20
+ snout: string;
21
+ horns: string;
22
+ eyes: string;
23
+ };
24
+
25
+ const LIVING_PALETTE: BeastPalette = {
26
+ fur: "#8f2c1e",
27
+ belly: "#b3543a",
28
+ legs: "#5d2014",
29
+ snout: "#c97a55",
30
+ horns: "#3a241d",
31
+ eyes: "#ffd23e",
32
+ };
33
+
34
+ const DEAD_PALETTE: BeastPalette = {
35
+ fur: "#3d3532",
36
+ belly: "#4c423e",
37
+ legs: "#322b28",
38
+ snout: "#564a44",
39
+ horns: "#2a2421",
40
+ eyes: "#1f1b19",
41
+ };
42
+
43
+ export function BeastActor({ beast, isSelected, onSelect }: BeastActorProps) {
44
+ const groupRef = useRef<Group>(null);
45
+ const pulseRef = useRef<Mesh>(null);
46
+ const bodyRef = useRef<Group>(null);
47
+ const headRef = useRef<Group>(null);
48
+ const tailRef = useRef<Group>(null);
49
+ const frontLeftLegRef = useRef<Group>(null);
50
+ const frontRightLegRef = useRef<Group>(null);
51
+ const backLeftLegRef = useRef<Group>(null);
52
+ const backRightLegRef = useRef<Group>(null);
53
+ const walkBlendRef = useRef(0);
54
+ const moveDirection = useMemo(() => new Vector3(0, 0, 1), []);
55
+ const animationPhase = useMemo(() => hashToUnit(beast.id) * Math.PI * 2, [beast.id]);
56
+ const initialPosition = useRef<[number, number, number]>([
57
+ beast.position.x,
58
+ beast.position.y,
59
+ beast.position.z,
60
+ ]);
61
+
62
+ const isDead = beast.state === "dead";
63
+ const palette = isDead ? DEAD_PALETTE : LIVING_PALETTE;
64
+
65
+ useFrame((state, delta) => {
66
+ const group = groupRef.current;
67
+ if (!group) {
68
+ return;
69
+ }
70
+
71
+ const time = state.clock.elapsedTime + animationPhase;
72
+ const walkTarget = isDead ? 0 : updatePosition(group, beast, moveDirection, delta);
73
+ walkBlendRef.current = MathUtils.damp(walkBlendRef.current, walkTarget, 7, delta);
74
+
75
+ if (pulseRef.current) {
76
+ const pulse = 1 + Math.sin(state.clock.elapsedTime * 4) * 0.08;
77
+ pulseRef.current.scale.setScalar(isSelected ? pulse : 0.001);
78
+ }
79
+
80
+ if (isDead) {
81
+ animateDeath(
82
+ bodyRef.current,
83
+ [frontLeftLegRef, frontRightLegRef, backLeftLegRef, backRightLegRef],
84
+ delta,
85
+ );
86
+ return;
87
+ }
88
+
89
+ animateGait({
90
+ time,
91
+ delta,
92
+ walkBlend: walkBlendRef.current,
93
+ isAttacking: beast.state === "attacking",
94
+ body: bodyRef.current,
95
+ head: headRef.current,
96
+ tail: tailRef.current,
97
+ frontLeft: frontLeftLegRef.current,
98
+ frontRight: frontRightLegRef.current,
99
+ backLeft: backLeftLegRef.current,
100
+ backRight: backRightLegRef.current,
101
+ });
102
+ });
103
+
104
+ const beastName = useMemo(() => {
105
+ const suffix = beast.id.replace(/^beast[_-]?/, "");
106
+ return suffix ? `Beast ${suffix}` : "Beast";
107
+ }, [beast.id]);
108
+
109
+ return (
110
+ <group ref={groupRef} position={initialPosition.current}>
111
+ <mesh
112
+ ref={pulseRef}
113
+ position={[0, 0.035, 0]}
114
+ rotation={[-Math.PI / 2, 0, 0]}
115
+ onClick={(event) => {
116
+ event.stopPropagation();
117
+ onSelect();
118
+ }}
119
+ >
120
+ <ringGeometry args={[0.95, 1.35, 40]} />
121
+ <meshBasicMaterial color="#ff7a5c" transparent opacity={0.72} />
122
+ </mesh>
123
+ <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.04, 0]}>
124
+ <ringGeometry args={[0.85, 1.12, 28]} />
125
+ <meshBasicMaterial color="#ffb09c" transparent opacity={isDead ? 0.18 : 0.45} />
126
+ </mesh>
127
+ <BeastBody
128
+ bodyRef={bodyRef}
129
+ headRef={headRef}
130
+ tailRef={tailRef}
131
+ legRefs={{
132
+ frontLeft: frontLeftLegRef,
133
+ frontRight: frontRightLegRef,
134
+ backLeft: backLeftLegRef,
135
+ backRight: backRightLegRef,
136
+ }}
137
+ palette={palette}
138
+ onSelect={onSelect}
139
+ />
140
+ <Html
141
+ center
142
+ distanceFactor={24}
143
+ position={[0, 2.45, 0]}
144
+ className="npcLabel"
145
+ zIndexRange={[8, 0]}
146
+ >
147
+ <button
148
+ type="button"
149
+ onClick={(event) => {
150
+ // Keep the click out of the r3f event container, where it would
151
+ // raycast into empty space and fire onPointerMissed (deselect).
152
+ event.stopPropagation();
153
+ onSelect();
154
+ }}
155
+ aria-label={`Select ${beastName}`}
156
+ >
157
+ <strong>{beastName}</strong>
158
+ <span>{beast.state}</span>
159
+ </button>
160
+ </Html>
161
+ </group>
162
+ );
163
+ }
164
+
165
+ function updatePosition(
166
+ group: Group,
167
+ beast: BeastSnapshot,
168
+ moveDirection: Vector3,
169
+ delta: number,
170
+ ): number {
171
+ group.position.y = MathUtils.damp(group.position.y, beast.position.y, 8, delta);
172
+
173
+ const deltaX = beast.position.x - group.position.x;
174
+ const deltaZ = beast.position.z - group.position.z;
175
+ const distanceToTarget = Math.hypot(deltaX, deltaZ);
176
+ if (distanceToTarget <= 0.025) {
177
+ group.position.x = beast.position.x;
178
+ group.position.z = beast.position.z;
179
+ return 0;
180
+ }
181
+
182
+ moveDirection.set(deltaX / distanceToTarget, 0, deltaZ / distanceToTarget);
183
+ const desiredRotation = Math.atan2(moveDirection.x, moveDirection.z);
184
+ const rotationDelta = angularDistance(group.rotation.y, desiredRotation);
185
+ const alignment = MathUtils.clamp(1 - rotationDelta / (Math.PI * 0.62), 0, 1);
186
+ const distanceBlend = MathUtils.clamp(distanceToTarget / 0.45, 0, 1);
187
+ const movementBlend = distanceBlend * MathUtils.lerp(0.2, 1, alignment);
188
+ const step = Math.min(distanceToTarget, 2.1 * MathUtils.lerp(0.18, 1, movementBlend) * delta);
189
+
190
+ group.position.x += moveDirection.x * step;
191
+ group.position.z += moveDirection.z * step;
192
+ group.rotation.y = dampAngle(group.rotation.y, desiredRotation, 5.5, delta);
193
+ return movementBlend;
194
+ }
195
+
196
+ type GaitOptions = {
197
+ time: number;
198
+ delta: number;
199
+ walkBlend: number;
200
+ isAttacking: boolean;
201
+ body: Group | null;
202
+ head: Group | null;
203
+ tail: Group | null;
204
+ frontLeft: Group | null;
205
+ frontRight: Group | null;
206
+ backLeft: Group | null;
207
+ backRight: Group | null;
208
+ };
209
+
210
+ function animateGait(options: GaitOptions) {
211
+ const { time, delta, walkBlend, isAttacking, body, head, tail } = options;
212
+ const stride = Math.sin(time * 8.4) * walkBlend;
213
+ const counterStride = Math.sin(time * 8.4 + Math.PI) * walkBlend;
214
+ const breathing = Math.sin(time * 2.4) * 0.02;
215
+
216
+ if (body) {
217
+ body.position.y = MathUtils.damp(
218
+ body.position.y,
219
+ Math.abs(stride) * 0.06 + breathing,
220
+ 10,
221
+ delta,
222
+ );
223
+ body.rotation.z = MathUtils.damp(body.rotation.z, 0, 6, delta);
224
+ body.rotation.x = MathUtils.damp(
225
+ body.rotation.x,
226
+ isAttacking ? Math.sin(time * 11) * 0.07 - 0.06 : Math.sin(time * 8.4) * 0.022 * walkBlend,
227
+ 8,
228
+ delta,
229
+ );
230
+ }
231
+ if (head) {
232
+ const sniff = Math.sin(time * 3.4) * 0.05 * (1 - walkBlend);
233
+ head.rotation.x = isAttacking ? Math.sin(time * 11) * 0.22 + 0.12 : sniff;
234
+ head.rotation.y = Math.sin(time * 2.2) * 0.08 * (1 - walkBlend);
235
+ }
236
+ if (tail) {
237
+ const wagSpeed = isAttacking ? 14 : 3.2 + walkBlend * 5;
238
+ tail.rotation.y = Math.sin(time * wagSpeed) * (0.28 + walkBlend * 0.14);
239
+ tail.rotation.x = 0.55 + Math.sin(time * 2.6) * 0.06;
240
+ }
241
+
242
+ setLegSwing(options.frontLeft, stride);
243
+ setLegSwing(options.backRight, stride);
244
+ setLegSwing(options.frontRight, counterStride);
245
+ setLegSwing(options.backLeft, counterStride);
246
+ }
247
+
248
+ function setLegSwing(leg: Group | null, stride: number) {
249
+ if (leg) {
250
+ leg.rotation.x = stride * 0.58;
251
+ }
252
+ }
253
+
254
+ function animateDeath(
255
+ body: Group | null,
256
+ legRefs: React.RefObject<Group | null>[],
257
+ delta: number,
258
+ ) {
259
+ if (body) {
260
+ body.rotation.z = MathUtils.damp(body.rotation.z, Math.PI / 2, 4, delta);
261
+ body.rotation.x = MathUtils.damp(body.rotation.x, 0, 4, delta);
262
+ body.position.y = MathUtils.damp(body.position.y, -0.34, 4, delta);
263
+ }
264
+ legRefs.forEach((legRef, index) => {
265
+ if (legRef.current) {
266
+ const splay = index % 2 === 0 ? 0.3 : -0.25;
267
+ legRef.current.rotation.x = MathUtils.damp(legRef.current.rotation.x, splay, 4, delta);
268
+ }
269
+ });
270
+ }
271
+
272
+ type BeastLegRefs = {
273
+ frontLeft: React.RefObject<Group | null>;
274
+ frontRight: React.RefObject<Group | null>;
275
+ backLeft: React.RefObject<Group | null>;
276
+ backRight: React.RefObject<Group | null>;
277
+ };
278
+
279
+ type BeastBodyProps = {
280
+ bodyRef: React.RefObject<Group | null>;
281
+ headRef: React.RefObject<Group | null>;
282
+ tailRef: React.RefObject<Group | null>;
283
+ legRefs: BeastLegRefs;
284
+ palette: BeastPalette;
285
+ onSelect: () => void;
286
+ };
287
+
288
+ function BeastBody({ bodyRef, headRef, tailRef, legRefs, palette, onSelect }: BeastBodyProps) {
289
+ return (
290
+ <group
291
+ ref={bodyRef}
292
+ onClick={(event) => {
293
+ event.stopPropagation();
294
+ onSelect();
295
+ }}
296
+ >
297
+ {/* torso */}
298
+ <mesh castShadow receiveShadow position={[0, 0.84, -0.1]}>
299
+ <boxGeometry args={[0.92, 0.6, 1.4]} />
300
+ <meshStandardMaterial color={palette.fur} roughness={0.85} />
301
+ </mesh>
302
+ {/* chest */}
303
+ <mesh castShadow position={[0, 0.88, 0.48]}>
304
+ <boxGeometry args={[1.04, 0.72, 0.58]} />
305
+ <meshStandardMaterial color={palette.fur} roughness={0.85} />
306
+ </mesh>
307
+ {/* belly */}
308
+ <mesh castShadow position={[0, 0.6, -0.05]}>
309
+ <boxGeometry args={[0.78, 0.22, 1.18]} />
310
+ <meshStandardMaterial color={palette.belly} roughness={0.88} />
311
+ </mesh>
312
+ {/* hips */}
313
+ <mesh castShadow position={[0, 0.82, -0.78]}>
314
+ <boxGeometry args={[0.82, 0.54, 0.44]} />
315
+ <meshStandardMaterial color={palette.fur} roughness={0.85} />
316
+ </mesh>
317
+ {/* back spikes */}
318
+ <mesh castShadow position={[0, 1.22, -0.15]}>
319
+ <boxGeometry args={[0.16, 0.18, 0.16]} />
320
+ <meshStandardMaterial color={palette.horns} roughness={0.7} />
321
+ </mesh>
322
+ <mesh castShadow position={[0, 1.2, -0.55]}>
323
+ <boxGeometry args={[0.14, 0.14, 0.14]} />
324
+ <meshStandardMaterial color={palette.horns} roughness={0.7} />
325
+ </mesh>
326
+
327
+ <BeastHead headRef={headRef} palette={palette} />
328
+
329
+ {/* tail */}
330
+ <group ref={tailRef} position={[0, 0.96, -0.98]} rotation={[0.55, 0, 0]}>
331
+ <mesh castShadow position={[0, 0, -0.26]}>
332
+ <boxGeometry args={[0.16, 0.16, 0.52]} />
333
+ <meshStandardMaterial color={palette.fur} roughness={0.85} />
334
+ </mesh>
335
+ <mesh castShadow position={[0, 0, -0.56]}>
336
+ <boxGeometry args={[0.22, 0.22, 0.16]} />
337
+ <meshStandardMaterial color={palette.horns} roughness={0.75} />
338
+ </mesh>
339
+ </group>
340
+
341
+ <BeastLeg legRef={legRefs.frontLeft} x={-0.34} z={0.48} palette={palette} />
342
+ <BeastLeg legRef={legRefs.frontRight} x={0.34} z={0.48} palette={palette} />
343
+ <BeastLeg legRef={legRefs.backLeft} x={-0.3} z={-0.72} palette={palette} />
344
+ <BeastLeg legRef={legRefs.backRight} x={0.3} z={-0.72} palette={palette} />
345
+ </group>
346
+ );
347
+ }
348
+
349
+ function BeastHead({
350
+ headRef,
351
+ palette,
352
+ }: {
353
+ headRef: React.RefObject<Group | null>;
354
+ palette: BeastPalette;
355
+ }) {
356
+ return (
357
+ <group ref={headRef} position={[0, 1.12, 0.92]}>
358
+ {/* skull */}
359
+ <mesh castShadow>
360
+ <boxGeometry args={[0.62, 0.54, 0.52]} />
361
+ <meshStandardMaterial color={palette.fur} roughness={0.82} />
362
+ </mesh>
363
+ {/* snout */}
364
+ <mesh castShadow position={[0, -0.1, 0.38]}>
365
+ <boxGeometry args={[0.36, 0.26, 0.34]} />
366
+ <meshStandardMaterial color={palette.snout} roughness={0.85} />
367
+ </mesh>
368
+ {/* nose */}
369
+ <mesh castShadow position={[0, -0.04, 0.56]}>
370
+ <boxGeometry args={[0.16, 0.1, 0.06]} />
371
+ <meshStandardMaterial color={palette.horns} roughness={0.6} />
372
+ </mesh>
373
+ {/* jaw */}
374
+ <mesh castShadow position={[0, -0.24, 0.32]}>
375
+ <boxGeometry args={[0.3, 0.08, 0.26]} />
376
+ <meshStandardMaterial color={palette.belly} roughness={0.85} />
377
+ </mesh>
378
+ {/* ears */}
379
+ <mesh castShadow position={[-0.22, 0.36, -0.08]} rotation={[0, 0, 0.18]}>
380
+ <boxGeometry args={[0.14, 0.24, 0.1]} />
381
+ <meshStandardMaterial color={palette.horns} roughness={0.75} />
382
+ </mesh>
383
+ <mesh castShadow position={[0.22, 0.36, -0.08]} rotation={[0, 0, -0.18]}>
384
+ <boxGeometry args={[0.14, 0.24, 0.1]} />
385
+ <meshStandardMaterial color={palette.horns} roughness={0.75} />
386
+ </mesh>
387
+ {/* eyes */}
388
+ <mesh position={[-0.17, 0.08, 0.27]}>
389
+ <boxGeometry args={[0.1, 0.08, 0.04]} />
390
+ <meshStandardMaterial
391
+ color={palette.eyes}
392
+ emissive={palette.eyes}
393
+ emissiveIntensity={0.9}
394
+ roughness={0.3}
395
+ />
396
+ </mesh>
397
+ <mesh position={[0.17, 0.08, 0.27]}>
398
+ <boxGeometry args={[0.1, 0.08, 0.04]} />
399
+ <meshStandardMaterial
400
+ color={palette.eyes}
401
+ emissive={palette.eyes}
402
+ emissiveIntensity={0.9}
403
+ roughness={0.3}
404
+ />
405
+ </mesh>
406
+ </group>
407
+ );
408
+ }
409
+
410
+ function BeastLeg({
411
+ legRef,
412
+ x,
413
+ z,
414
+ palette,
415
+ }: {
416
+ legRef: React.RefObject<Group | null>;
417
+ x: number;
418
+ z: number;
419
+ palette: BeastPalette;
420
+ }) {
421
+ return (
422
+ <group ref={legRef} position={[x, 0.62, z]}>
423
+ <mesh castShadow position={[0, -0.3, 0]}>
424
+ <boxGeometry args={[0.22, 0.5, 0.24]} />
425
+ <meshStandardMaterial color={palette.legs} roughness={0.85} />
426
+ </mesh>
427
+ <mesh castShadow position={[0, -0.56, 0.04]}>
428
+ <boxGeometry args={[0.26, 0.12, 0.3]} />
429
+ <meshStandardMaterial color={palette.horns} roughness={0.8} />
430
+ </mesh>
431
+ </group>
432
+ );
433
+ }
frontend/src/scene/GodSimulatorScene.tsx CHANGED
@@ -8,7 +8,7 @@ import { WorldView } from "./WorldView";
8
  type GodSimulatorSceneProps = {
9
  snapshot: WorldSnapshot | null;
10
  selectedId: string | null;
11
- onSelectEntity: (id: string) => void;
12
  };
13
 
14
  export function GodSimulatorScene({
@@ -23,6 +23,11 @@ export function GodSimulatorScene({
23
  camera={{ position: [34, 38, 42], fov: 45, near: 0.1, far: 300 }}
24
  dpr={[1, 1.75]}
25
  gl={{ preserveDrawingBuffer: true }}
 
 
 
 
 
26
  >
27
  <color attach="background" args={["#b9d7f2"]} />
28
  <fog attach="fog" args={["#b9d7f2", 70, 165]} />
@@ -56,9 +61,15 @@ export function GodSimulatorScene({
56
 
57
  function LoadingTerrain() {
58
  return (
59
- <mesh rotation={[-Math.PI / 2, 0, 0]}>
60
- <planeGeometry args={[80, 80, 1, 1]} />
61
- <meshStandardMaterial color="#43a047" roughness={0.85} />
62
- </mesh>
 
 
 
 
 
 
63
  );
64
  }
 
8
  type GodSimulatorSceneProps = {
9
  snapshot: WorldSnapshot | null;
10
  selectedId: string | null;
11
+ onSelectEntity: (id: string | null) => void;
12
  };
13
 
14
  export function GodSimulatorScene({
 
23
  camera={{ position: [34, 38, 42], fov: 45, near: 0.1, far: 300 }}
24
  dpr={[1, 1.75]}
25
  gl={{ preserveDrawingBuffer: true }}
26
+ onPointerMissed={(event) => {
27
+ if (event.type === "click") {
28
+ onSelectEntity(null);
29
+ }
30
+ }}
31
  >
32
  <color attach="background" args={["#b9d7f2"]} />
33
  <fog attach="fog" args={["#b9d7f2", 70, 165]} />
 
61
 
62
  function LoadingTerrain() {
63
  return (
64
+ <group>
65
+ <mesh position={[0, -0.3, 0]}>
66
+ <boxGeometry args={[80, 0.6, 80]} />
67
+ <meshStandardMaterial color="#43a047" roughness={0.85} />
68
+ </mesh>
69
+ <mesh position={[0, -1.3, 0]}>
70
+ <boxGeometry args={[80, 1.6, 80]} />
71
+ <meshStandardMaterial color="#6b4a2c" roughness={0.95} />
72
+ </mesh>
73
+ </group>
74
  );
75
  }
frontend/src/scene/NpcBody.tsx CHANGED
@@ -35,8 +35,8 @@ export function NpcBody({ refs, palette, onSelect }: NpcBodyProps) {
35
  <NpcTorso torsoRef={refs.torsoRef} palette={palette} />
36
  <NpcArm side="left" armRef={refs.leftArmRef} palette={palette} />
37
  <NpcArm side="right" armRef={refs.rightArmRef} palette={palette} />
38
- <mesh castShadow position={[0, 1.66, 0]} rotation={[Math.PI / 2, 0, 0]}>
39
- <torusGeometry args={[0.22, 0.035, 8, 26]} />
40
  <meshStandardMaterial color={palette.accent} roughness={0.5} />
41
  </mesh>
42
  <NpcHead headRef={refs.headRef} palette={palette} />
@@ -55,12 +55,12 @@ function NpcLeg({ side, legRef, palette }: LimbProps & { legRef: RefObject<Group
55
  return (
56
  <group ref={legRef} position={[x, 0.78, 0]}>
57
  <mesh castShadow position={[0, -0.28, 0.02]}>
58
- <capsuleGeometry args={[0.075, 0.48, 5, 12]} />
59
- <meshStandardMaterial color={palette.legs} roughness={0.68} />
60
  </mesh>
61
- <mesh castShadow position={[0, -0.59, 0.08]} scale={[0.92, 0.42, 1.25]}>
62
- <sphereGeometry args={[0.13, 12, 12]} />
63
- <meshStandardMaterial color="#342f2c" roughness={0.72} />
64
  </mesh>
65
  </group>
66
  );
@@ -72,12 +72,12 @@ function NpcArm({ side, armRef, palette }: LimbProps & { armRef: RefObject<Group
72
  return (
73
  <group ref={armRef} position={[x, 1.5, 0.02]}>
74
  <mesh castShadow position={[0, -0.32, 0]}>
75
- <capsuleGeometry args={[0.065, 0.52, 5, 12]} />
76
- <meshStandardMaterial color={palette.sleeves} roughness={0.62} />
77
  </mesh>
78
- <mesh castShadow position={[0, -0.63, 0.02]}>
79
- <sphereGeometry args={[0.09, 12, 12]} />
80
- <meshStandardMaterial color="#f7c89b" roughness={0.62} />
81
  </mesh>
82
  </group>
83
  );
 
35
  <NpcTorso torsoRef={refs.torsoRef} palette={palette} />
36
  <NpcArm side="left" armRef={refs.leftArmRef} palette={palette} />
37
  <NpcArm side="right" armRef={refs.rightArmRef} palette={palette} />
38
+ <mesh castShadow position={[0, 1.64, 0]}>
39
+ <boxGeometry args={[0.5, 0.08, 0.42]} />
40
  <meshStandardMaterial color={palette.accent} roughness={0.5} />
41
  </mesh>
42
  <NpcHead headRef={refs.headRef} palette={palette} />
 
55
  return (
56
  <group ref={legRef} position={[x, 0.78, 0]}>
57
  <mesh castShadow position={[0, -0.28, 0.02]}>
58
+ <boxGeometry args={[0.2, 0.56, 0.22]} />
59
+ <meshStandardMaterial color={palette.legs} roughness={0.78} />
60
  </mesh>
61
+ <mesh castShadow position={[0, -0.62, 0.06]}>
62
+ <boxGeometry args={[0.22, 0.14, 0.32]} />
63
+ <meshStandardMaterial color="#342f2c" roughness={0.8} />
64
  </mesh>
65
  </group>
66
  );
 
72
  return (
73
  <group ref={armRef} position={[x, 1.5, 0.02]}>
74
  <mesh castShadow position={[0, -0.32, 0]}>
75
+ <boxGeometry args={[0.16, 0.58, 0.18]} />
76
+ <meshStandardMaterial color={palette.sleeves} roughness={0.74} />
77
  </mesh>
78
+ <mesh castShadow position={[0, -0.66, 0.01]}>
79
+ <boxGeometry args={[0.15, 0.14, 0.16]} />
80
+ <meshStandardMaterial color="#f7c89b" roughness={0.7} />
81
  </mesh>
82
  </group>
83
  );
frontend/src/scene/NpcHead.tsx CHANGED
@@ -11,32 +11,42 @@ type NpcHeadProps = {
11
  export function NpcHead({ headRef, palette }: NpcHeadProps) {
12
  return (
13
  <group ref={headRef} position={[0, 1.94, 0]}>
 
14
  <mesh castShadow>
15
- <sphereGeometry args={[0.29, 20, 20]} />
16
- <meshStandardMaterial color="#f7c89b" roughness={0.58} />
17
  </mesh>
18
- <mesh castShadow position={[0, 0.18, -0.01]} scale={[1, 0.52, 1]}>
19
- <sphereGeometry args={[0.3, 18, 18]} />
20
- <meshStandardMaterial color={palette.hair} roughness={0.72} />
 
21
  </mesh>
22
- <mesh castShadow position={[0, 0.25, 0.12]} rotation={[0.2, 0, 0]}>
23
- <cylinderGeometry args={[0.27, 0.29, 0.12, 18]} />
24
- <meshStandardMaterial color={palette.accent} roughness={0.56} />
25
  </mesh>
26
- <mesh castShadow position={[0, 0.2, 0.34]} scale={[1.3, 0.26, 0.54]}>
27
- <sphereGeometry args={[0.16, 12, 12]} />
 
28
  <meshStandardMaterial color={palette.accent} roughness={0.56} />
29
  </mesh>
30
- <mesh position={[-0.09, 0.03, 0.27]}>
31
- <sphereGeometry args={[0.025, 8, 8]} />
 
32
  <meshStandardMaterial color="#23231f" roughness={0.4} />
33
  </mesh>
34
- <mesh position={[0.09, 0.03, 0.27]}>
35
- <sphereGeometry args={[0.025, 8, 8]} />
36
  <meshStandardMaterial color="#23231f" roughness={0.4} />
37
  </mesh>
38
- <mesh position={[0, -0.085, 0.285]} scale={[1, 0.35, 0.2]}>
39
- <boxGeometry args={[0.13, 0.018, 0.018]} />
 
 
 
 
 
 
40
  <meshStandardMaterial color="#7d4a39" roughness={0.55} />
41
  </mesh>
42
  </group>
 
11
  export function NpcHead({ headRef, palette }: NpcHeadProps) {
12
  return (
13
  <group ref={headRef} position={[0, 1.94, 0]}>
14
+ {/* skull */}
15
  <mesh castShadow>
16
+ <boxGeometry args={[0.46, 0.44, 0.44]} />
17
+ <meshStandardMaterial color="#f7c89b" roughness={0.7} />
18
  </mesh>
19
+ {/* hair cap */}
20
+ <mesh castShadow position={[0, 0.21, -0.02]}>
21
+ <boxGeometry args={[0.5, 0.18, 0.48]} />
22
+ <meshStandardMaterial color={palette.hair} roughness={0.78} />
23
  </mesh>
24
+ <mesh castShadow position={[0, 0.04, -0.22]}>
25
+ <boxGeometry args={[0.5, 0.34, 0.08]} />
26
+ <meshStandardMaterial color={palette.hair} roughness={0.78} />
27
  </mesh>
28
+ {/* headband */}
29
+ <mesh castShadow position={[0, 0.13, 0.02]}>
30
+ <boxGeometry args={[0.52, 0.07, 0.5]} />
31
  <meshStandardMaterial color={palette.accent} roughness={0.56} />
32
  </mesh>
33
+ {/* eyes */}
34
+ <mesh position={[-0.11, 0.02, 0.225]}>
35
+ <boxGeometry args={[0.07, 0.07, 0.02]} />
36
  <meshStandardMaterial color="#23231f" roughness={0.4} />
37
  </mesh>
38
+ <mesh position={[0.11, 0.02, 0.225]}>
39
+ <boxGeometry args={[0.07, 0.07, 0.02]} />
40
  <meshStandardMaterial color="#23231f" roughness={0.4} />
41
  </mesh>
42
+ {/* nose */}
43
+ <mesh castShadow position={[0, -0.06, 0.24]}>
44
+ <boxGeometry args={[0.08, 0.1, 0.06]} />
45
+ <meshStandardMaterial color="#eab184" roughness={0.7} />
46
+ </mesh>
47
+ {/* mouth */}
48
+ <mesh position={[0, -0.15, 0.225]}>
49
+ <boxGeometry args={[0.14, 0.03, 0.02]} />
50
  <meshStandardMaterial color="#7d4a39" roughness={0.55} />
51
  </mesh>
52
  </group>
frontend/src/scene/NpcLabelButton.tsx CHANGED
@@ -54,7 +54,12 @@ export function NpcLabelButton({ entity, onSelect }: NpcLabelButtonProps) {
54
  <button
55
  ref={buttonRef}
56
  type="button"
57
- onClick={onSelect}
 
 
 
 
 
58
  aria-label={`Select ${entity.label}`}
59
  data-tooltip={isCollapsed ? `${entity.label}: ${entity.state.intention}` : undefined}
60
  >
 
54
  <button
55
  ref={buttonRef}
56
  type="button"
57
+ onClick={(event) => {
58
+ // Keep the click out of the r3f event container, where it would
59
+ // raycast into empty space and fire onPointerMissed (deselect).
60
+ event.stopPropagation();
61
+ onSelect();
62
+ }}
63
  aria-label={`Select ${entity.label}`}
64
  data-tooltip={isCollapsed ? `${entity.label}: ${entity.state.intention}` : undefined}
65
  >
frontend/src/scene/NpcTorso.tsx CHANGED
@@ -12,20 +12,28 @@ export function NpcTorso({ torsoRef, palette }: NpcTorsoProps) {
12
  return (
13
  <group ref={torsoRef} position={[0, 1.18, 0]}>
14
  <mesh castShadow>
15
- <capsuleGeometry args={[0.31, 0.72, 8, 18]} />
16
- <meshStandardMaterial color={palette.coat} roughness={0.58} />
17
  </mesh>
18
- <mesh castShadow position={[0, 0.03, 0.305]} scale={[0.72, 0.88, 0.08]}>
19
- <boxGeometry args={[0.5, 0.82, 0.08]} />
20
- <meshStandardMaterial color={palette.front} roughness={0.64} />
 
21
  </mesh>
22
- <mesh castShadow position={[0, 0.32, 0.36]} scale={[0.86, 0.34, 0.12]}>
23
- <boxGeometry args={[0.38, 0.16, 0.08]} />
24
- <meshStandardMaterial color={palette.accent} roughness={0.5} />
 
25
  </mesh>
26
- <mesh castShadow position={[0, -0.06, 0.365]} scale={[0.5, 0.5, 0.12]}>
27
- <sphereGeometry args={[0.08, 12, 12]} />
28
- <meshStandardMaterial color="#f8df6b" roughness={0.42} />
 
 
 
 
 
 
29
  </mesh>
30
  </group>
31
  );
 
12
  return (
13
  <group ref={torsoRef} position={[0, 1.18, 0]}>
14
  <mesh castShadow>
15
+ <boxGeometry args={[0.58, 0.78, 0.36]} />
16
+ <meshStandardMaterial color={palette.coat} roughness={0.72} />
17
  </mesh>
18
+ {/* shoulders */}
19
+ <mesh castShadow position={[0, 0.32, 0]}>
20
+ <boxGeometry args={[0.68, 0.16, 0.4]} />
21
+ <meshStandardMaterial color={palette.coat} roughness={0.72} />
22
  </mesh>
23
+ {/* front panel */}
24
+ <mesh castShadow position={[0, 0.02, 0.2]}>
25
+ <boxGeometry args={[0.4, 0.66, 0.04]} />
26
+ <meshStandardMaterial color={palette.front} roughness={0.74} />
27
  </mesh>
28
+ {/* belt */}
29
+ <mesh castShadow position={[0, -0.34, 0]}>
30
+ <boxGeometry args={[0.6, 0.12, 0.38]} />
31
+ <meshStandardMaterial color={palette.accent} roughness={0.6} />
32
+ </mesh>
33
+ {/* buckle */}
34
+ <mesh castShadow position={[0, -0.34, 0.2]}>
35
+ <boxGeometry args={[0.14, 0.1, 0.04]} />
36
+ <meshStandardMaterial color="#f8df6b" roughness={0.45} />
37
  </mesh>
38
  </group>
39
  );
frontend/src/scene/ResourceActor.tsx ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Html } from "@react-three/drei";
2
+ import { useFrame } from "@react-three/fiber";
3
+ import { useMemo, useRef } from "react";
4
+ import type { Group, Mesh } from "three";
5
+ import { MathUtils } from "three";
6
+
7
+ import type { ResourceNodeSnapshot } from "../types";
8
+ import { hashToUnit } from "./characterUtils";
9
+
10
+ type ResourceActorProps = {
11
+ node: ResourceNodeSnapshot;
12
+ isSelected: boolean;
13
+ onSelect: () => void;
14
+ };
15
+
16
+ export function ResourceActor({ node, isSelected, onSelect }: ResourceActorProps) {
17
+ const pulseRef = useRef<Mesh>(null);
18
+ const swayRef = useRef<Group>(null);
19
+ const animationPhase = useMemo(() => hashToUnit(node.id) * Math.PI * 2, [node.id]);
20
+ const color = resourceColor(node.type);
21
+ const richness = resourceRichness(node);
22
+
23
+ useFrame((state) => {
24
+ const time = state.clock.elapsedTime + animationPhase;
25
+ if (pulseRef.current) {
26
+ const pulse = 1 + Math.sin(state.clock.elapsedTime * 4) * 0.08;
27
+ pulseRef.current.scale.setScalar(isSelected ? pulse : 0.001);
28
+ }
29
+ if (swayRef.current) {
30
+ swayRef.current.rotation.z = Math.sin(time * 1.4) * 0.035;
31
+ swayRef.current.rotation.x = Math.sin(time * 1.1) * 0.025;
32
+ }
33
+ });
34
+
35
+ return (
36
+ <group position={[node.position.x, node.position.y, node.position.z]}>
37
+ <mesh
38
+ ref={pulseRef}
39
+ position={[0, 0.035, 0]}
40
+ rotation={[-Math.PI / 2, 0, 0]}
41
+ onClick={(event) => {
42
+ event.stopPropagation();
43
+ onSelect();
44
+ }}
45
+ >
46
+ <ringGeometry args={[0.78, 1.1, 40]} />
47
+ <meshBasicMaterial color={color} transparent opacity={0.78} />
48
+ </mesh>
49
+ <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.025, 0]}>
50
+ <ringGeometry args={[0.55, 0.72, 20]} />
51
+ <meshBasicMaterial color={color} transparent opacity={0.4} />
52
+ </mesh>
53
+ <group
54
+ scale={[richness, richness, richness]}
55
+ onClick={(event) => {
56
+ event.stopPropagation();
57
+ onSelect();
58
+ }}
59
+ >
60
+ <ResourceModel type={node.type} swayRef={swayRef} />
61
+ </group>
62
+ <Html
63
+ center
64
+ distanceFactor={24}
65
+ position={[0, 2.05, 0]}
66
+ className="npcLabel"
67
+ zIndexRange={[6, 0]}
68
+ >
69
+ <button
70
+ type="button"
71
+ onClick={(event) => {
72
+ // Keep the click out of the r3f event container, where it would
73
+ // raycast into empty space and fire onPointerMissed (deselect).
74
+ event.stopPropagation();
75
+ onSelect();
76
+ }}
77
+ aria-label={`Select ${node.type} node`}
78
+ >
79
+ <strong>{resourceTitle(node.type)}</strong>
80
+ <span>{node.amount} left</span>
81
+ </button>
82
+ </Html>
83
+ </group>
84
+ );
85
+ }
86
+
87
+ function ResourceModel({
88
+ type,
89
+ swayRef,
90
+ }: {
91
+ type: string;
92
+ swayRef: React.RefObject<Group | null>;
93
+ }) {
94
+ if (type === "food") {
95
+ return <BerryBush swayRef={swayRef} />;
96
+ }
97
+ if (type === "herbs") {
98
+ return <HerbPatch swayRef={swayRef} />;
99
+ }
100
+ if (type === "wood") {
101
+ return <LogPile />;
102
+ }
103
+ if (type === "weapon") {
104
+ return <WeaponCache />;
105
+ }
106
+ return <SupplyCrate />;
107
+ }
108
+
109
+ function BerryBush({ swayRef }: { swayRef: React.RefObject<Group | null> }) {
110
+ return (
111
+ <group>
112
+ {/* trunk stub */}
113
+ <mesh castShadow position={[0, 0.16, 0]}>
114
+ <boxGeometry args={[0.22, 0.32, 0.22]} />
115
+ <meshStandardMaterial color="#6b4a2c" roughness={0.9} />
116
+ </mesh>
117
+ <group ref={swayRef} position={[0, 0.32, 0]}>
118
+ {/* foliage blocks */}
119
+ <mesh castShadow position={[0, 0.34, 0]}>
120
+ <boxGeometry args={[0.92, 0.62, 0.92]} />
121
+ <meshStandardMaterial color="#4e8f3e" roughness={0.88} />
122
+ </mesh>
123
+ <mesh castShadow position={[0.3, 0.72, 0.12]}>
124
+ <boxGeometry args={[0.5, 0.4, 0.5]} />
125
+ <meshStandardMaterial color="#5fa84a" roughness={0.88} />
126
+ </mesh>
127
+ <mesh castShadow position={[-0.28, 0.66, -0.18]}>
128
+ <boxGeometry args={[0.44, 0.36, 0.44]} />
129
+ <meshStandardMaterial color="#5fa84a" roughness={0.88} />
130
+ </mesh>
131
+ {/* berries */}
132
+ <Berry x={0.32} y={0.42} z={0.42} />
133
+ <Berry x={-0.34} y={0.3} z={0.36} />
134
+ <Berry x={0.42} y={0.26} z={-0.26} />
135
+ <Berry x={-0.18} y={0.52} z={-0.4} />
136
+ <Berry x={0.05} y={0.78} z={0.3} />
137
+ </group>
138
+ </group>
139
+ );
140
+ }
141
+
142
+ function Berry({ x, y, z }: { x: number; y: number; z: number }) {
143
+ return (
144
+ <mesh castShadow position={[x, y, z]}>
145
+ <boxGeometry args={[0.14, 0.14, 0.14]} />
146
+ <meshStandardMaterial color="#d8453e" roughness={0.55} />
147
+ </mesh>
148
+ );
149
+ }
150
+
151
+ function HerbPatch({ swayRef }: { swayRef: React.RefObject<Group | null> }) {
152
+ return (
153
+ <group>
154
+ {/* dirt mound */}
155
+ <mesh receiveShadow castShadow position={[0, 0.07, 0]}>
156
+ <boxGeometry args={[1.05, 0.14, 1.05]} />
157
+ <meshStandardMaterial color="#5e4630" roughness={0.95} />
158
+ </mesh>
159
+ <group ref={swayRef} position={[0, 0.14, 0]}>
160
+ <HerbSprout x={-0.28} z={-0.2} height={0.52} />
161
+ <HerbSprout x={0.26} z={-0.28} height={0.42} />
162
+ <HerbSprout x={0.05} z={0.27} height={0.6} />
163
+ <HerbSprout x={-0.3} z={0.3} height={0.36} />
164
+ </group>
165
+ </group>
166
+ );
167
+ }
168
+
169
+ function HerbSprout({ x, z, height }: { x: number; z: number; height: number }) {
170
+ return (
171
+ <group position={[x, 0, z]}>
172
+ <mesh castShadow position={[0, height / 2, 0]}>
173
+ <boxGeometry args={[0.08, height, 0.08]} />
174
+ <meshStandardMaterial color="#3f7a3c" roughness={0.85} />
175
+ </mesh>
176
+ <mesh castShadow position={[0, height + 0.08, 0]}>
177
+ <boxGeometry args={[0.24, 0.16, 0.24]} />
178
+ <meshStandardMaterial color="#7cc96d" roughness={0.8} />
179
+ </mesh>
180
+ </group>
181
+ );
182
+ }
183
+
184
+ function LogPile() {
185
+ return (
186
+ <group>
187
+ <Log x={-0.24} y={0.16} rotationY={0.06} />
188
+ <Log x={0.26} y={0.16} rotationY={-0.04} />
189
+ <Log x={0} y={0.46} rotationY={0.02} />
190
+ </group>
191
+ );
192
+ }
193
+
194
+ function Log({ x, y, rotationY }: { x: number; y: number; rotationY: number }) {
195
+ return (
196
+ <group position={[x, y, 0]} rotation={[0, rotationY, 0]}>
197
+ <mesh castShadow receiveShadow>
198
+ <boxGeometry args={[0.3, 0.3, 1.1]} />
199
+ <meshStandardMaterial color="#6b4a2c" roughness={0.9} />
200
+ </mesh>
201
+ <mesh position={[0, 0, 0.56]}>
202
+ <boxGeometry args={[0.24, 0.24, 0.03]} />
203
+ <meshStandardMaterial color="#c9a76a" roughness={0.8} />
204
+ </mesh>
205
+ <mesh position={[0, 0, -0.56]}>
206
+ <boxGeometry args={[0.24, 0.24, 0.03]} />
207
+ <meshStandardMaterial color="#c9a76a" roughness={0.8} />
208
+ </mesh>
209
+ </group>
210
+ );
211
+ }
212
+
213
+ function WeaponCache() {
214
+ return (
215
+ <group>
216
+ {/* stone block */}
217
+ <mesh castShadow receiveShadow position={[0, 0.2, 0]}>
218
+ <boxGeometry args={[0.85, 0.4, 0.7]} />
219
+ <meshStandardMaterial color="#7c8089" roughness={0.9} />
220
+ </mesh>
221
+ {/* sword blade */}
222
+ <group position={[0, 0.4, 0]} rotation={[0, 0.5, 0.16]}>
223
+ <mesh castShadow position={[0, 0.5, 0]}>
224
+ <boxGeometry args={[0.1, 0.85, 0.05]} />
225
+ <meshStandardMaterial color="#d7dde6" roughness={0.3} metalness={0.6} />
226
+ </mesh>
227
+ <mesh castShadow position={[0, 0.92, 0]}>
228
+ <boxGeometry args={[0.34, 0.09, 0.09]} />
229
+ <meshStandardMaterial color="#8a6b35" roughness={0.6} />
230
+ </mesh>
231
+ <mesh castShadow position={[0, 1.08, 0]}>
232
+ <boxGeometry args={[0.09, 0.24, 0.09]} />
233
+ <meshStandardMaterial color="#5c4322" roughness={0.7} />
234
+ </mesh>
235
+ </group>
236
+ {/* axe leaning on the stone */}
237
+ <group position={[0.42, 0.32, 0.18]} rotation={[0, 0, -0.45]}>
238
+ <mesh castShadow position={[0, 0.3, 0]}>
239
+ <boxGeometry args={[0.07, 0.62, 0.07]} />
240
+ <meshStandardMaterial color="#5c4322" roughness={0.7} />
241
+ </mesh>
242
+ <mesh castShadow position={[0.1, 0.56, 0]}>
243
+ <boxGeometry args={[0.26, 0.18, 0.06]} />
244
+ <meshStandardMaterial color="#b7bec8" roughness={0.35} metalness={0.55} />
245
+ </mesh>
246
+ </group>
247
+ </group>
248
+ );
249
+ }
250
+
251
+ function SupplyCrate() {
252
+ return (
253
+ <group>
254
+ <mesh castShadow receiveShadow position={[0, 0.3, 0]}>
255
+ <boxGeometry args={[0.7, 0.6, 0.7]} />
256
+ <meshStandardMaterial color="#a98c52" roughness={0.85} />
257
+ </mesh>
258
+ <mesh castShadow position={[0, 0.62, 0]}>
259
+ <boxGeometry args={[0.76, 0.08, 0.76]} />
260
+ <meshStandardMaterial color="#7d6437" roughness={0.85} />
261
+ </mesh>
262
+ </group>
263
+ );
264
+ }
265
+
266
+ export function resourceTitle(type: string): string {
267
+ if (!type) {
268
+ return "Resource";
269
+ }
270
+ return `${type.charAt(0).toUpperCase()}${type.slice(1)}`;
271
+ }
272
+
273
+ export function resourceColor(type: string): string {
274
+ if (type === "food") {
275
+ return "#e3c454";
276
+ }
277
+ if (type === "herbs") {
278
+ return "#58a65c";
279
+ }
280
+ if (type === "wood") {
281
+ return "#7a5234";
282
+ }
283
+ if (type === "weapon") {
284
+ return "#b7bec8";
285
+ }
286
+ return "#d9d2aa";
287
+ }
288
+
289
+ function resourceRichness(node: ResourceNodeSnapshot): number {
290
+ const ratio = node.max_amount
291
+ ? MathUtils.clamp(node.amount / node.max_amount, 0, 1)
292
+ : MathUtils.clamp(node.amount / 10, 0, 1);
293
+ return MathUtils.lerp(0.55, 1.15, ratio);
294
+ }
frontend/src/scene/WorldView.tsx CHANGED
@@ -1,5 +1,11 @@
1
- import type { BeastSnapshot, ResourceNodeSnapshot, WorldSnapshot } from "../types";
 
 
 
 
 
2
  import { NpcActor } from "./NpcActor";
 
3
 
4
  type WorldViewProps = {
5
  snapshot: WorldSnapshot;
@@ -8,9 +14,6 @@ type WorldViewProps = {
8
  };
9
 
10
  export function WorldView({ snapshot, selectedId, onSelectEntity }: WorldViewProps) {
11
- const gridSize = Math.max(snapshot.terrain.width, snapshot.terrain.depth);
12
- const gridDivisions = snapshot.terrain.grid_blocks;
13
-
14
  return (
15
  <group>
16
  <Terrain
@@ -18,10 +21,6 @@ export function WorldView({ snapshot, selectedId, onSelectEntity }: WorldViewPro
18
  depth={snapshot.terrain.depth}
19
  color={snapshot.terrain.color}
20
  />
21
- <gridHelper
22
- args={[gridSize, gridDivisions, "#2f7d37", "#5dbb63"]}
23
- position={[0, 0.018, 0]}
24
- />
25
  {snapshot.entities.map((entity) => (
26
  <NpcActor
27
  key={entity.id}
@@ -31,10 +30,20 @@ export function WorldView({ snapshot, selectedId, onSelectEntity }: WorldViewPro
31
  />
32
  ))}
33
  {(snapshot.resource_nodes ?? []).map((node) => (
34
- <ResourceMarker key={node.id} node={node} />
 
 
 
 
 
35
  ))}
36
  {(snapshot.beasts ?? []).map((beast) => (
37
- <BeastMarker key={beast.id} beast={beast} />
 
 
 
 
 
38
  ))}
39
  </group>
40
  );
@@ -46,67 +55,67 @@ type TerrainProps = {
46
  color: string;
47
  };
48
 
 
 
 
49
  function Terrain({ width, depth, color }: TerrainProps) {
50
- return (
51
- <mesh receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
52
- <planeGeometry args={[width, depth, 1, 1]} />
53
- <meshStandardMaterial color={color} roughness={0.9} metalness={0.02} />
54
- </mesh>
55
- );
56
- }
57
 
58
- function ResourceMarker({ node }: { node: ResourceNodeSnapshot }) {
59
- const color = resourceColor(node.type);
60
- const height = Math.max(0.12, 0.18 + node.amount * 0.035);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
 
62
  return (
63
- <group position={[node.position.x, node.position.y, node.position.z]}>
64
- <mesh castShadow receiveShadow position={[0, height / 2, 0]}>
65
- <cylinderGeometry args={[0.42, 0.5, height, 12]} />
66
- <meshStandardMaterial color={color} roughness={0.78} />
67
- </mesh>
68
- <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.025, 0]}>
69
- <ringGeometry args={[0.55, 0.72, 20]} />
70
- <meshBasicMaterial color={color} transparent opacity={0.45} />
 
71
  </mesh>
72
  </group>
73
  );
74
  }
75
 
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]}>
82
- <mesh castShadow receiveShadow position={[0, 0.65, 0]} scale={[1.15, 0.72, 1.55]}>
83
- <sphereGeometry args={[0.72, 18, 14]} />
84
- <meshStandardMaterial color={color} roughness={0.82} metalness={0.02} />
85
- </mesh>
86
- <mesh castShadow position={[0, 1.08, -0.55]} scale={[0.72, 0.62, 0.72]}>
87
- <sphereGeometry args={[0.44, 16, 12]} />
88
- <meshStandardMaterial color={color} roughness={0.8} />
89
- </mesh>
90
- <mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, 0.04, 0]}>
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
  }
97
 
98
- function resourceColor(type: string) {
99
- if (type === "food") {
100
- return "#e3c454";
101
- }
102
- if (type === "herbs") {
103
- return "#58a65c";
104
- }
105
- if (type === "wood") {
106
- return "#7a5234";
107
- }
108
- if (type === "weapon") {
109
- return "#b7bec8";
110
- }
111
- return "#d9d2aa";
112
- }
 
1
+ import { useLayoutEffect, useRef } from "react";
2
+ import { Color, Matrix4 } from "three";
3
+ import type { InstancedMesh } from "three";
4
+
5
+ import type { WorldSnapshot } from "../types";
6
+ import { BeastActor } from "./BeastActor";
7
  import { NpcActor } from "./NpcActor";
8
+ import { ResourceActor } from "./ResourceActor";
9
 
10
  type WorldViewProps = {
11
  snapshot: WorldSnapshot;
 
14
  };
15
 
16
  export function WorldView({ snapshot, selectedId, onSelectEntity }: WorldViewProps) {
 
 
 
17
  return (
18
  <group>
19
  <Terrain
 
21
  depth={snapshot.terrain.depth}
22
  color={snapshot.terrain.color}
23
  />
 
 
 
 
24
  {snapshot.entities.map((entity) => (
25
  <NpcActor
26
  key={entity.id}
 
30
  />
31
  ))}
32
  {(snapshot.resource_nodes ?? []).map((node) => (
33
+ <ResourceActor
34
+ key={node.id}
35
+ node={node}
36
+ isSelected={node.id === selectedId}
37
+ onSelect={() => onSelectEntity(node.id)}
38
+ />
39
  ))}
40
  {(snapshot.beasts ?? []).map((beast) => (
41
+ <BeastActor
42
+ key={beast.id}
43
+ beast={beast}
44
+ isSelected={beast.id === selectedId}
45
+ onSelect={() => onSelectEntity(beast.id)}
46
+ />
47
  ))}
48
  </group>
49
  );
 
55
  color: string;
56
  };
57
 
58
+ const VOXEL_SIZE = 1;
59
+ const VOXEL_HEIGHT = 0.6;
60
+
61
  function Terrain({ width, depth, color }: TerrainProps) {
62
+ const meshRef = useRef<InstancedMesh>(null);
63
+ const cols = Math.max(1, Math.round(width / VOXEL_SIZE));
64
+ const rows = Math.max(1, Math.round(depth / VOXEL_SIZE));
65
+ const count = cols * rows;
 
 
 
66
 
67
+ useLayoutEffect(() => {
68
+ const mesh = meshRef.current;
69
+ if (!mesh) {
70
+ return;
71
+ }
72
+ const base = new Color(color);
73
+ const dirt = new Color("#7a5a36");
74
+ const shade = new Color();
75
+ const matrix = new Matrix4();
76
+ let index = 0;
77
+ for (let col = 0; col < cols; col += 1) {
78
+ for (let row = 0; row < rows; row += 1) {
79
+ const x = (col + 0.5) * VOXEL_SIZE - width / 2;
80
+ const z = (row + 0.5) * VOXEL_SIZE - depth / 2;
81
+ const heightRoll = tileNoise(col, row, 1);
82
+ const tintRoll = tileNoise(col, row, 2);
83
+ // Subtle steps only — keep the surface readable as flat ground.
84
+ const lift = heightRoll > 0.94 ? 0.08 : heightRoll > 0.84 ? 0.04 : 0;
85
+ matrix.makeTranslation(x, lift - VOXEL_HEIGHT / 2, z);
86
+ mesh.setMatrixAt(index, matrix);
87
+ shade.copy(base);
88
+ shade.offsetHSL((tintRoll - 0.5) * 0.02, (heightRoll - 0.5) * 0.08, (tintRoll - 0.5) * 0.07);
89
+ if (tintRoll > 0.975) {
90
+ shade.lerp(dirt, 0.65);
91
+ }
92
+ mesh.setColorAt(index, shade);
93
+ index += 1;
94
+ }
95
+ }
96
+ mesh.instanceMatrix.needsUpdate = true;
97
+ if (mesh.instanceColor) {
98
+ mesh.instanceColor.needsUpdate = true;
99
+ }
100
+ }, [cols, rows, width, depth, color]);
101
 
102
  return (
103
+ <group>
104
+ <instancedMesh key={count} ref={meshRef} args={[undefined, undefined, count]} receiveShadow>
105
+ <boxGeometry args={[VOXEL_SIZE, VOXEL_HEIGHT, VOXEL_SIZE]} />
106
+ <meshStandardMaterial color="#ffffff" roughness={0.9} metalness={0.02} />
107
+ </instancedMesh>
108
+ {/* dirt slab so the world edge reads as a floating voxel block */}
109
+ <mesh position={[0, -VOXEL_HEIGHT - 0.7, 0]}>
110
+ <boxGeometry args={[width, 1.6, depth]} />
111
+ <meshStandardMaterial color="#6b4a2c" roughness={0.95} />
112
  </mesh>
113
  </group>
114
  );
115
  }
116
 
117
+ function tileNoise(x: number, z: number, seed: number): number {
118
+ const value = Math.sin(x * 127.1 + z * 311.7 + seed * 74.7) * 43758.5453;
119
+ return value - Math.floor(value);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  }
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/god_simulator/simulation/actions/__init__.py CHANGED
@@ -1,18 +1,19 @@
1
- """Action registry and motivation-aware action masks."""
2
 
3
- from god_simulator.simulation.actions.builtin import BUILTIN_ACTIONS
4
- from god_simulator.simulation.actions.masks import build_action_mask
5
  from god_simulator.simulation.actions.registry import (
6
  all_action_definitions,
7
  get_action_definition,
8
  )
9
- from god_simulator.simulation.actions.schemas import ActionDefinition, ActionMask
10
 
11
  __all__ = [
12
  "BUILTIN_ACTIONS",
 
13
  "ActionDefinition",
14
- "ActionMask",
15
  "all_action_definitions",
16
- "build_action_mask",
17
  "get_action_definition",
18
  ]
 
1
+ """Primitive action registry and motivation-aware action hints."""
2
 
3
+ from god_simulator.simulation.actions.builtin import BUILTIN_ACTIONS, PRIMITIVE_ACTION_IDS
4
+ from god_simulator.simulation.actions.hints import build_action_hints
5
  from god_simulator.simulation.actions.registry import (
6
  all_action_definitions,
7
  get_action_definition,
8
  )
9
+ from god_simulator.simulation.actions.schemas import ActionDefinition, ActionHints
10
 
11
  __all__ = [
12
  "BUILTIN_ACTIONS",
13
+ "PRIMITIVE_ACTION_IDS",
14
  "ActionDefinition",
15
+ "ActionHints",
16
  "all_action_definitions",
17
+ "build_action_hints",
18
  "get_action_definition",
19
  ]
src/god_simulator/simulation/actions/builtin.py CHANGED
@@ -2,94 +2,58 @@ from __future__ import annotations
2
 
3
  from god_simulator.simulation.actions.schemas import ActionDefinition
4
 
 
 
 
5
  BUILTIN_ACTIONS: dict[str, ActionDefinition] = {
6
- "walk": ActionDefinition(
7
- id="walk",
8
  category="movement",
9
- description_for_model="Move one block in routine mode.",
10
- required_params=("target_x", "target_z"),
11
- forbidden_modes=("threatened", "hostile_pursuit"),
 
12
  ),
13
- "talk": ActionDefinition(
14
- id="talk",
15
  category="social",
16
- description_for_model="Say direct speech to a nearby citizen in routine mode.",
17
- required_params=("target_npc_id", "message"),
18
- forbidden_modes=("threatened", "hostile_pursuit", "fleeing"),
19
- ),
20
- "attack": ActionDefinition(
21
- id="attack",
 
 
22
  category="combat",
23
- description_for_model="Attack a nearby target; the engine resolves damage.",
24
- required_params=("target_npc_id",),
25
- allowed_modes=("hostile_pursuit",),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  ),
27
  "idle": ActionDefinition(
28
  id="idle",
29
  category="passive",
30
- description_for_model="Do nothing when no safer action exists.",
31
- ),
32
- "flee": ActionDefinition(
33
- id="flee",
34
- category="survival",
35
- description_for_model="Move away from the nearest threat.",
36
- allowed_modes=("threatened", "fleeing"),
37
- ),
38
- "defend": ActionDefinition(
39
- id="defend",
40
- category="survival",
41
- description_for_model="Hold position defensively and watch the threat.",
42
- allowed_modes=("threatened", "helping"),
43
- ),
44
- "call_for_help": ActionDefinition(
45
- id="call_for_help",
46
- category="social_crisis",
47
- description_for_model="Call nearby citizens for help in a crisis.",
48
- allowed_modes=("threatened", "helping"),
49
- ),
50
- "plead": ActionDefinition(
51
- id="plead",
52
- category="social_crisis",
53
- description_for_model="Address a threat with a short plea or de-escalation.",
54
- required_params=("target_npc_id", "message"),
55
- allowed_modes=("threatened",),
56
- ),
57
- "threaten": ActionDefinition(
58
- id="threaten",
59
- category="hostile_social",
60
- description_for_model="Threaten the target without deciding damage or outcome.",
61
- required_params=("target_npc_id", "message"),
62
- allowed_modes=("hostile_pursuit",),
63
- ),
64
- "observe": ActionDefinition(
65
- id="observe",
66
- category="awareness",
67
- description_for_model="Observe the current situation without routine chatter.",
68
- ),
69
- "investigate": ActionDefinition(
70
- id="investigate",
71
- category="awareness",
72
- description_for_model="Move toward or observe a suspicious target or event.",
73
- allowed_modes=("investigating", "routine"),
74
- ),
75
- "search_target": ActionDefinition(
76
- id="search_target",
77
- category="hostile_movement",
78
- description_for_model="Search for the hostile directive target.",
79
- allowed_modes=("hostile_pursuit",),
80
- ),
81
- "move_to_target": ActionDefinition(
82
- id="move_to_target",
83
- category="targeted_movement",
84
- description_for_model="Move toward a specific target without resolving an outcome.",
85
- required_params=("target_npc_id",),
86
- allowed_modes=("hostile_pursuit", "investigating"),
87
- ),
88
- "attack_back": ActionDefinition(
89
- id="attack_back",
90
- category="combat",
91
- description_for_model="Attack the threatening citizen in self-defense.",
92
- required_params=("target_npc_id",),
93
- allowed_modes=("threatened",),
94
  ),
95
  }
 
 
 
2
 
3
  from god_simulator.simulation.actions.schemas import ActionDefinition
4
 
5
+ # The whole action surface: six parameterized primitives. Behavioral flavor
6
+ # (fleeing, pleading, threatening, stealing, healing...) is expressed through
7
+ # parameters and free-text intent, not through dedicated action ids.
8
  BUILTIN_ACTIONS: dict[str, ActionDefinition] = {
9
+ "move": ActionDefinition(
10
+ id="move",
11
  category="movement",
12
+ description_for_model=(
13
+ "Move toward a position, NPC, beast, or resource node. "
14
+ "Set away=true to move away from the target instead (flee)."
15
+ ),
16
  ),
17
+ "speak": ActionDefinition(
18
+ id="speak",
19
  category="social",
20
+ description_for_model=(
21
+ "Say one short message. With target_id it is direct speech; without "
22
+ "a target it is a shout heard by everyone nearby (e.g. a call for help)."
23
+ ),
24
+ required_params=("message",),
25
+ ),
26
+ "strike": ActionDefinition(
27
+ id="strike",
28
  category="combat",
29
+ description_for_model=(
30
+ "Attack a nearby NPC or beast. The engine resolves movement, damage, and death."
31
+ ),
32
+ required_params=("target_id",),
33
+ ),
34
+ "use": ActionDefinition(
35
+ id="use",
36
+ category="resource",
37
+ description_for_model=(
38
+ "Use something: gather from a resource node (resource_id), eat food "
39
+ "(item=food), or heal with herbs (item=herbs)."
40
+ ),
41
+ ),
42
+ "transfer": ActionDefinition(
43
+ id="transfer",
44
+ category="resource",
45
+ description_for_model=(
46
+ "Give a resource to a nearby NPC, or set take=true to steal from them."
47
+ ),
48
+ required_params=("target_id",),
49
  ),
50
  "idle": ActionDefinition(
51
  id="idle",
52
  category="passive",
53
+ description_for_model=(
54
+ "Do nothing this tick: wait, observe, or hold position defensively."
55
+ ),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  ),
57
  }
58
+
59
+ PRIMITIVE_ACTION_IDS: tuple[str, ...] = tuple(BUILTIN_ACTIONS)
src/god_simulator/simulation/actions/hints.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ranked action hints for the LLM prompt.
2
+
3
+ Hints replace the old hard action masks: they describe what is *plausible*
4
+ given the citizen's goal and perception, ranked best-first, but never forbid
5
+ anything. The engine validates only physical possibility (range, existence,
6
+ inventory); behavioral plausibility is the model's call.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING, TypeAlias
12
+
13
+ from god_simulator.domain import CitizenGoal, Npc, WorldState
14
+ from god_simulator.simulation.actions.schemas import ActionHints
15
+ from god_simulator.simulation.mechanics import ATTACK_RADIUS, distance_between, is_alive
16
+ from god_simulator.simulation.perception import CitizenPerception
17
+
18
+ if TYPE_CHECKING:
19
+ from god_simulator.simulation.connectors.base import ActionKind
20
+ else:
21
+ ActionKind: TypeAlias = str
22
+
23
+
24
+ def build_action_hints(
25
+ world: WorldState,
26
+ npc: Npc,
27
+ perception: CitizenPerception,
28
+ goal: CitizenGoal | None,
29
+ ) -> ActionHints:
30
+ if goal is None or not is_alive(npc):
31
+ return ActionHints(
32
+ suggested_actions=[],
33
+ reason="Dead or unavailable citizens cannot act",
34
+ )
35
+
36
+ match goal.goal_type:
37
+ case "survive" | "call_for_help":
38
+ suggested: list[ActionKind] = ["move", "speak", "idle"]
39
+ if _goal_target_in_range(world, npc, goal):
40
+ suggested.insert(1, "strike")
41
+ return ActionHints(
42
+ suggested_actions=suggested,
43
+ reason=(
44
+ "A threat is nearby: moving away, calling for help, pleading, "
45
+ "defending, or striking back are the most plausible responses"
46
+ ),
47
+ )
48
+
49
+ case "pursue_and_attack_target":
50
+ if _goal_target_in_range(world, npc, goal):
51
+ return ActionHints(
52
+ suggested_actions=["strike", "speak"],
53
+ reason="The hostile directive target is within striking range",
54
+ )
55
+ return ActionHints(
56
+ suggested_actions=["move", "speak"],
57
+ reason="The hostile directive target is out of range; closing in is most plausible",
58
+ )
59
+
60
+ case "retaliate":
61
+ if _goal_target_in_range(world, npc, goal):
62
+ return ActionHints(
63
+ suggested_actions=["strike", "speak", "move"],
64
+ reason="The recent attacker is within reach",
65
+ )
66
+ return ActionHints(
67
+ suggested_actions=["move", "speak"],
68
+ reason="The recent attacker is out of reach",
69
+ )
70
+
71
+ case "help_or_defend":
72
+ helping: list[ActionKind] = ["speak", "idle", "move"]
73
+ if _goal_target_in_range(world, npc, goal):
74
+ helping.insert(0, "strike")
75
+ return ActionHints(
76
+ suggested_actions=helping,
77
+ reason="Someone nearby is being harmed; helping or defending is most plausible",
78
+ )
79
+
80
+ case "respond_to_event" | "investigate":
81
+ return ActionHints(
82
+ suggested_actions=["move", "idle", "speak"],
83
+ reason="Something suspicious happened; investigating or watching is most plausible",
84
+ )
85
+
86
+ case "find_food":
87
+ return ActionHints(
88
+ suggested_actions=["move", "use", "speak"],
89
+ reason="Hunger is high; finding and consuming food is most plausible",
90
+ )
91
+
92
+ case "routine_life":
93
+ return ActionHints(
94
+ suggested_actions=["move", "speak", "use", "transfer", "idle"],
95
+ reason="Nothing urgent is happening; everyday activity is most plausible",
96
+ )
97
+
98
+ return ActionHints(
99
+ suggested_actions=["idle"],
100
+ reason="Unknown goal; observing is the safest default",
101
+ )
102
+
103
+
104
+ def _goal_target_in_range(world: WorldState, npc: Npc, goal: CitizenGoal) -> bool:
105
+ if goal.target_npc_id is None:
106
+ return False
107
+ target = next(
108
+ (candidate for candidate in world.npcs if candidate.id == goal.target_npc_id),
109
+ None,
110
+ )
111
+ if target is None or not is_alive(target):
112
+ return False
113
+ return distance_between(npc, target) <= ATTACK_RADIUS
src/god_simulator/simulation/actions/masks.py DELETED
@@ -1,177 +0,0 @@
1
- from __future__ import annotations
2
-
3
- from typing import TYPE_CHECKING, TypeAlias
4
-
5
- from god_simulator.domain import CitizenGoal, Npc, WorldState
6
- from god_simulator.simulation.actions.schemas import ActionMask
7
- from god_simulator.simulation.mechanics import ATTACK_RADIUS, distance_between, is_alive
8
- from god_simulator.simulation.perception import CitizenPerception
9
-
10
- if TYPE_CHECKING:
11
- from god_simulator.simulation.connectors.base import ActionKind
12
- else:
13
- ActionKind: TypeAlias = str
14
-
15
-
16
- def build_action_mask(
17
- world: WorldState,
18
- npc: Npc,
19
- perception: CitizenPerception,
20
- goal: CitizenGoal | None,
21
- ) -> ActionMask:
22
- if goal is None or not is_alive(npc):
23
- return ActionMask(
24
- allowed_actions=[],
25
- forbidden_actions=_all_action_ids(),
26
- reason="Dead or unavailable citizens cannot act",
27
- fallback_action="observe",
28
- )
29
-
30
- match goal.goal_type:
31
- case "survive" | "call_for_help":
32
- allowed: list[ActionKind] = ["flee", "defend", "call_for_help", "plead"]
33
- if _goal_target_in_range(world, npc, goal):
34
- allowed.append("attack_back")
35
- return ActionMask(
36
- allowed_actions=allowed,
37
- forbidden_actions=[
38
- "talk",
39
- "walk",
40
- "idle",
41
- "ordinary_talk",
42
- "weather_talk",
43
- "routine_walk",
44
- "work",
45
- ],
46
- reason="Survival goal allows only defensive crisis actions",
47
- fallback_action="flee",
48
- )
49
-
50
- case "pursue_and_attack_target":
51
- if _goal_target_in_range(world, npc, goal):
52
- return ActionMask(
53
- allowed_actions=["attack", "threaten"],
54
- forbidden_actions=[
55
- "talk",
56
- "walk",
57
- "idle",
58
- "ordinary_talk",
59
- "weather_talk",
60
- "routine_walk",
61
- "observe",
62
- ],
63
- reason="Hostile target is in attack range",
64
- fallback_action="attack",
65
- )
66
- return ActionMask(
67
- allowed_actions=["move_to_target", "search_target", "threaten"],
68
- forbidden_actions=[
69
- "talk",
70
- "walk",
71
- "idle",
72
- "ordinary_talk",
73
- "weather_talk",
74
- "routine_walk",
75
- "observe",
76
- ],
77
- reason="Hostile target is outside attack range",
78
- fallback_action="move_to_target",
79
- )
80
-
81
- case "retaliate":
82
- allowed = ["threaten"]
83
- fallback_action: ActionKind = "move_to_target"
84
- if _goal_target_in_range(world, npc, goal):
85
- allowed = ["attack_back", "threaten"]
86
- fallback_action = "attack_back"
87
- else:
88
- allowed = ["move_to_target", "threaten"]
89
- return ActionMask(
90
- allowed_actions=allowed,
91
- forbidden_actions=["talk", "walk", "idle", "ordinary_talk", "weather_talk"],
92
- reason="Recent attacker is the priority target",
93
- fallback_action=fallback_action,
94
- )
95
-
96
- case "help_or_defend":
97
- allowed = ["defend", "call_for_help", "plead"]
98
- if _goal_target_in_range(world, npc, goal):
99
- allowed.append("attack_back")
100
- return ActionMask(
101
- allowed_actions=allowed,
102
- forbidden_actions=["talk", "walk", "idle", "ordinary_talk", "weather_talk"],
103
- reason="Nearby harm calls for defense or help",
104
- fallback_action="call_for_help",
105
- )
106
-
107
- case "respond_to_event" | "investigate":
108
- allowed = ["investigate", "observe", "call_for_help"]
109
- if perception.under_threat:
110
- allowed.append("flee")
111
- return ActionMask(
112
- allowed_actions=allowed,
113
- forbidden_actions=["talk", "ordinary_talk", "weather_talk", "routine_walk"],
114
- reason="Suspicious event should be investigated or watched",
115
- fallback_action="investigate",
116
- )
117
-
118
- case "find_food":
119
- return ActionMask(
120
- allowed_actions=["walk", "observe", "investigate"],
121
- forbidden_actions=["attack", "threaten", "ordinary_talk", "weather_talk"],
122
- reason="Food need is high but no economy exists yet",
123
- fallback_action="observe",
124
- )
125
-
126
- case "routine_life":
127
- return ActionMask(
128
- allowed_actions=["walk", "talk", "observe"],
129
- forbidden_actions=[
130
- "attack",
131
- "attack_back",
132
- "threaten",
133
- "flee",
134
- "call_for_help",
135
- "plead",
136
- ],
137
- reason="No urgent threat or hostile directive is present",
138
- fallback_action="observe",
139
- )
140
-
141
- return ActionMask(
142
- allowed_actions=["observe"],
143
- forbidden_actions=["ordinary_talk", "weather_talk"],
144
- reason="Unknown goal falls back to observation",
145
- fallback_action="observe",
146
- )
147
-
148
-
149
- def _goal_target_in_range(world: WorldState, npc: Npc, goal: CitizenGoal) -> bool:
150
- if goal.target_npc_id is None:
151
- return False
152
- target = next(
153
- (candidate for candidate in world.npcs if candidate.id == goal.target_npc_id),
154
- None,
155
- )
156
- if target is None or not is_alive(target):
157
- return False
158
- return distance_between(npc, target) <= ATTACK_RADIUS
159
-
160
-
161
- def _all_action_ids() -> list[str]:
162
- return [
163
- "walk",
164
- "talk",
165
- "attack",
166
- "idle",
167
- "flee",
168
- "defend",
169
- "call_for_help",
170
- "plead",
171
- "threaten",
172
- "observe",
173
- "investigate",
174
- "search_target",
175
- "move_to_target",
176
- "attack_back",
177
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/god_simulator/simulation/actions/schemas.py CHANGED
@@ -3,8 +3,6 @@ from __future__ import annotations
3
  from dataclasses import dataclass, field
4
  from typing import TYPE_CHECKING, TypeAlias
5
 
6
- from god_simulator.domain import CitizenMode
7
-
8
  if TYPE_CHECKING:
9
  from god_simulator.simulation.connectors.base import ActionKind
10
  else:
@@ -17,16 +15,16 @@ class ActionDefinition:
17
  category: str
18
  description_for_model: str
19
  required_params: tuple[str, ...] = ()
20
- allowed_modes: tuple[CitizenMode, ...] = ()
21
- forbidden_modes: tuple[CitizenMode, ...] = ()
22
- preconditions: tuple[str, ...] = ()
23
- fallback_policy: str = "safe_goal_fallback"
24
 
25
 
26
  @dataclass(frozen=True, slots=True)
27
- class ActionMask:
28
- allowed_actions: list[ActionKind]
29
- forbidden_actions: list[str]
 
 
 
 
 
30
  reason: str
31
- fallback_action: ActionKind = "observe"
32
  metadata: dict[str, str] = field(default_factory=dict)
 
3
  from dataclasses import dataclass, field
4
  from typing import TYPE_CHECKING, TypeAlias
5
 
 
 
6
  if TYPE_CHECKING:
7
  from god_simulator.simulation.connectors.base import ActionKind
8
  else:
 
15
  category: str
16
  description_for_model: str
17
  required_params: tuple[str, ...] = ()
 
 
 
 
18
 
19
 
20
  @dataclass(frozen=True, slots=True)
21
+ class ActionHints:
22
+ """Ranked behavioral suggestions for the LLM prompt.
23
+
24
+ Hints never forbid anything: any primitive remains choosable and the engine
25
+ only rejects or repairs physically impossible actions.
26
+ """
27
+
28
+ suggested_actions: list[ActionKind]
29
  reason: str
 
30
  metadata: dict[str, str] = field(default_factory=dict)
src/god_simulator/simulation/autopilot.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic autopilot for social (non-survival) worlds.
2
+
3
+ The autopilot fills gaps: it produces one safe, goal-consistent primitive when
4
+ an NPC has no usable directive (LLM error, unknown action, omitted NPC). It is
5
+ a fallback, never a filter — it does not constrain what a planner may choose.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from god_simulator.domain import CitizenGoal, Npc, WorldState
11
+ from god_simulator.simulation.connectors.base import NpcDirective
12
+ from god_simulator.simulation.mechanics import (
13
+ ATTACK_RADIUS,
14
+ distance_between,
15
+ is_alive,
16
+ walk_away_target,
17
+ )
18
+ from god_simulator.simulation.perception import CitizenPerception, nearby_threat_npc
19
+
20
+
21
+ def autopilot_directive(
22
+ world: WorldState,
23
+ npc: Npc,
24
+ directive: NpcDirective,
25
+ perception: CitizenPerception,
26
+ goal: CitizenGoal | None,
27
+ ) -> NpcDirective:
28
+ target = target_from_directive_or_goal(world, npc, directive, goal, perception)
29
+
30
+ if (
31
+ goal is not None
32
+ and goal.goal_type in ("pursue_and_attack_target", "retaliate")
33
+ and target is not None
34
+ ):
35
+ return strike_or_approach_target(
36
+ npc,
37
+ directive,
38
+ target,
39
+ memory="Autopilot pursued the hostile target.",
40
+ )
41
+
42
+ if goal is not None and goal.goal_type in ("survive", "call_for_help", "help_or_defend"):
43
+ if goal.goal_type == "call_for_help":
44
+ return NpcDirective(
45
+ npc_id=npc.id,
46
+ action="speak",
47
+ message="Help! I am in danger!",
48
+ memory="Autopilot called for help.",
49
+ communication_intent="help_request",
50
+ intent="call_for_help",
51
+ )
52
+ if target is not None:
53
+ return flee_directive(world, npc, target, directive)
54
+ return idle_directive(
55
+ directive,
56
+ memory="Autopilot held a defensive stance.",
57
+ intent="defend",
58
+ )
59
+
60
+ if (
61
+ goal is not None
62
+ and goal.goal_type in ("respond_to_event", "investigate")
63
+ and target is not None
64
+ ):
65
+ return move_toward_target_directive(npc, directive, target, intent="investigate")
66
+
67
+ return idle_directive(directive, memory=None, intent="observe")
68
+
69
+
70
+ def target_from_directive_or_goal(
71
+ world: WorldState,
72
+ npc: Npc,
73
+ directive: NpcDirective,
74
+ goal: CitizenGoal | None,
75
+ perception: CitizenPerception,
76
+ ) -> Npc | None:
77
+ if directive.target_npc_id:
78
+ target = _npc_by_id(world, directive.target_npc_id)
79
+ if target is not None and target.id != npc.id and is_alive(target):
80
+ return target
81
+
82
+ if goal is not None and goal.target_npc_id:
83
+ target = _npc_by_id(world, goal.target_npc_id)
84
+ if target is not None and target.id != npc.id and is_alive(target):
85
+ return target
86
+
87
+ threat = nearby_threat_npc(world, perception)
88
+ if threat is not None and threat.id != npc.id and is_alive(threat):
89
+ return threat
90
+
91
+ return None
92
+
93
+
94
+ def strike_or_approach_target(
95
+ npc: Npc,
96
+ directive: NpcDirective,
97
+ target: Npc,
98
+ *,
99
+ memory: str,
100
+ ) -> NpcDirective:
101
+ if distance_between(npc, target) <= ATTACK_RADIUS:
102
+ return NpcDirective(
103
+ npc_id=npc.id,
104
+ action="strike",
105
+ target_npc_id=target.id,
106
+ message=directive.message,
107
+ memory=memory,
108
+ intent="hostile_attack",
109
+ confidence=directive.confidence,
110
+ )
111
+ return move_toward_target_directive(
112
+ npc,
113
+ directive,
114
+ target,
115
+ intent="approach_target_for_attack",
116
+ memory=memory,
117
+ )
118
+
119
+
120
+ def move_toward_target_directive(
121
+ npc: Npc,
122
+ directive: NpcDirective,
123
+ target: Npc,
124
+ *,
125
+ intent: str,
126
+ memory: str | None = None,
127
+ ) -> NpcDirective:
128
+ return NpcDirective(
129
+ npc_id=npc.id,
130
+ action="move",
131
+ target=target.position,
132
+ target_npc_id=target.id,
133
+ message=directive.message,
134
+ memory=memory or f"You moved toward {target.name}.",
135
+ intent=intent,
136
+ confidence=directive.confidence,
137
+ )
138
+
139
+
140
+ def flee_directive(
141
+ world: WorldState,
142
+ npc: Npc,
143
+ threat: Npc,
144
+ directive: NpcDirective,
145
+ ) -> NpcDirective:
146
+ half_width = world.terrain.width / 2
147
+ half_depth = world.terrain.depth / 2
148
+ return NpcDirective(
149
+ npc_id=npc.id,
150
+ action="move",
151
+ target=walk_away_target(npc, threat, half_width=half_width, half_depth=half_depth),
152
+ target_npc_id=threat.id,
153
+ message=directive.message,
154
+ memory=f"You backed away from {threat.name}.",
155
+ intent="flee_from_threat",
156
+ confidence=directive.confidence,
157
+ )
158
+
159
+
160
+ def idle_directive(
161
+ directive: NpcDirective,
162
+ *,
163
+ memory: str | None,
164
+ intent: str,
165
+ ) -> NpcDirective:
166
+ return NpcDirective(
167
+ npc_id=directive.npc_id,
168
+ action="idle",
169
+ target=None,
170
+ target_npc_id=directive.target_npc_id,
171
+ message=directive.message,
172
+ memory=memory,
173
+ intent=intent,
174
+ confidence=directive.confidence,
175
+ )
176
+
177
+
178
+ def _npc_by_id(world: WorldState, npc_id: str) -> Npc | None:
179
+ return next((candidate for candidate in world.npcs if candidate.id == npc_id), None)
src/god_simulator/simulation/connectors/base.py CHANGED
@@ -5,35 +5,10 @@ from typing import Literal, Protocol, TypeAlias
5
 
6
  from god_simulator.domain import Vec3, WorldState
7
 
8
- EngineActionKind: TypeAlias = Literal["walk", "talk", "attack", "idle"]
9
- SemanticActionKind: TypeAlias = Literal[
10
- "flee",
11
- "defend",
12
- "call_for_help",
13
- "plead",
14
- "threaten",
15
- "observe",
16
- "investigate",
17
- "search_target",
18
- "move_to_target",
19
- "attack_back",
20
- ]
21
- SurvivalActionKind: TypeAlias = Literal[
22
- "gather",
23
- "consume",
24
- "eat",
25
- "heal",
26
- "move_to_resource",
27
- "move_to",
28
- "communicate",
29
- "transfer",
30
- "trade",
31
- "request_trade",
32
- "steal",
33
- "find_food",
34
- "find_herbs",
35
- ]
36
- ActionKind: TypeAlias = EngineActionKind | SemanticActionKind | SurvivalActionKind
37
 
38
 
39
  @dataclass(frozen=True, slots=True)
@@ -50,6 +25,8 @@ class CitizenAction:
50
  message: str | None = None
51
  intent: str | None = None
52
  confidence: float | None = None
 
 
53
 
54
 
55
  @dataclass(frozen=True, slots=True)
@@ -67,6 +44,8 @@ class NpcDirective:
67
  memory: str | None = None
68
  intent: str | None = None
69
  confidence: float | None = None
 
 
70
  conversation_user: str | None = None
71
  conversation_assistant: str | None = None
72
 
@@ -74,7 +53,9 @@ class NpcDirective:
74
  @dataclass(frozen=True, slots=True)
75
  class ValidationResult:
76
  valid: bool
77
- original_action: ActionKind
 
 
78
  resolved_action: ActionKind
79
  reason: str
80
  directive: NpcDirective
 
5
 
6
  from god_simulator.domain import Vec3, WorldState
7
 
8
+ # The public action surface: every NPC decision is one of these six primitives.
9
+ # Legacy action names (walk, talk, attack, gather, flee, ...) are accepted as raw
10
+ # strings at parse/validation boundaries and normalized to primitives there.
11
+ ActionKind: TypeAlias = Literal["move", "speak", "strike", "use", "transfer", "idle"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
 
14
  @dataclass(frozen=True, slots=True)
 
25
  message: str | None = None
26
  intent: str | None = None
27
  confidence: float | None = None
28
+ away: bool = False
29
+ take: bool = False
30
 
31
 
32
  @dataclass(frozen=True, slots=True)
 
44
  memory: str | None = None
45
  intent: str | None = None
46
  confidence: float | None = None
47
+ away: bool = False
48
+ take: bool = False
49
  conversation_user: str | None = None
50
  conversation_assistant: str | None = None
51
 
 
53
  @dataclass(frozen=True, slots=True)
54
  class ValidationResult:
55
  valid: bool
56
+ # The original action is whatever the planner/LLM asked for, including
57
+ # legacy aliases or unknown ids; the resolved action is always a primitive.
58
+ original_action: str
59
  resolved_action: ActionKind
60
  reason: str
61
  directive: NpcDirective
src/god_simulator/simulation/connectors/deterministic.py CHANGED
@@ -36,7 +36,7 @@ class DeterministicWorldSimulator:
36
  directives.append(
37
  NpcDirective(
38
  npc_id=npc.id,
39
- action="walk",
40
  target=target,
41
  )
42
  )
 
36
  directives.append(
37
  NpcDirective(
38
  npc_id=npc.id,
39
+ action="move",
40
  target=target,
41
  )
42
  )
src/god_simulator/simulation/connectors/openai_compatible.py CHANGED
@@ -4,14 +4,15 @@ import json
4
  import os
5
  from collections.abc import Callable, Sequence
6
  from concurrent.futures import ThreadPoolExecutor
7
- from dataclasses import asdict
8
  from typing import Any, cast
9
 
10
  from openai import OpenAI
11
 
12
  from god_simulator.config import ConnectorConfig
13
  from god_simulator.domain import Npc, Vec3, WorldState
14
- from god_simulator.simulation.actions import ActionMask, build_action_mask
 
15
  from god_simulator.simulation.connectors.base import (
16
  ActionKind,
17
  NpcDirective,
@@ -26,226 +27,176 @@ from god_simulator.simulation.mechanics import (
26
  MAX_WALK_DISTANCE,
27
  TALK_RADIUS,
28
  VISIBLE_RADIUS,
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
  build_memory_summary,
39
  build_perception,
40
  build_relationships_summary,
41
  is_survival_world,
42
- nearest_beast,
43
  survival_directive_for,
44
  )
45
  from god_simulator.simulation.survival import select_goal as select_survival_goal
46
 
47
  ChatCompleter = Callable[[dict[str, Any]], dict[str, Any]]
48
 
49
- LEGACY_ACTION_TOOLS = {
50
- "walk",
51
- "talk",
52
- "attack",
53
- "call_for_help",
54
- "eat",
55
- "trade",
56
- "request_trade",
57
- }
58
-
59
  TOOL_DEFINITIONS: dict[str, dict[str, Any]] = {
60
- "flee": {
61
  "type": "function",
62
  "function": {
63
- "name": "flee",
64
- "description": "Run away from the nearest threat at full speed.",
 
 
 
65
  "parameters": {
66
  "type": "object",
67
  "properties": {
68
- "threat_id": {
69
  "type": "string",
70
- "description": "Optional visible beast id.",
71
- }
 
 
 
 
 
 
 
 
 
 
 
 
72
  },
73
  "required": [],
74
  "additionalProperties": False,
75
  },
76
  },
77
  },
78
- "communicate": {
79
  "type": "function",
80
  "function": {
81
- "name": "communicate",
82
- "description": "Say one short message for help, warning, trade, or social contact.",
 
 
 
83
  "parameters": {
84
  "type": "object",
85
  "properties": {
86
- "intent": {
87
- "type": "string",
88
- "enum": ["help_request", "warning", "trade_request", "social"],
89
- },
90
  "target_id": {
91
  "type": "string",
92
- "description": "Optional nearby NPC id.",
93
  },
94
  "message": {
95
  "type": "string",
96
- "description": "Short direct speech.",
97
  },
98
- },
99
- "required": ["intent"],
100
- "additionalProperties": False,
101
- },
102
- },
103
- },
104
- "defend": {
105
- "type": "function",
106
- "function": {
107
- "name": "defend",
108
- "description": "Hold position defensively and watch the threat.",
109
- "parameters": {
110
- "type": "object",
111
- "properties": {
112
- "target_id": {
113
  "type": "string",
114
- "description": "Optional NPC or beast id being defended.",
115
- }
 
 
 
116
  },
117
- "required": [],
118
  "additionalProperties": False,
119
  },
120
  },
121
  },
122
- "attack": {
123
  "type": "function",
124
  "function": {
125
- "name": "attack",
126
- "description": "Attack the nearest beast; the engine resolves movement and damage.",
 
 
127
  "parameters": {
128
  "type": "object",
129
  "properties": {
130
  "target_id": {
131
  "type": "string",
132
- "description": "Optional beast id.",
133
  }
134
  },
135
- "required": [],
136
- "additionalProperties": False,
137
- },
138
- },
139
- },
140
- "heal": {
141
- "type": "function",
142
- "function": {
143
- "name": "heal",
144
- "description": "Use one herb to heal yourself.",
145
- "parameters": {
146
- "type": "object",
147
- "properties": {},
148
- "required": [],
149
  "additionalProperties": False,
150
  },
151
  },
152
  },
153
- "consume": {
154
  "type": "function",
155
  "function": {
156
- "name": "consume",
157
- "description": "Eat one food from inventory.",
158
- "parameters": {
159
- "type": "object",
160
- "properties": {},
161
- "required": [],
162
- "additionalProperties": False,
163
- },
164
- },
165
- },
166
- "gather": {
167
- "type": "function",
168
- "function": {
169
- "name": "gather",
170
- "description": "Collect resource from a nearby node.",
171
  "parameters": {
172
  "type": "object",
173
  "properties": {
174
  "resource_id": {
175
  "type": "string",
176
- "description": "ID of the resource node to gather from.",
177
- }
178
- },
179
- "required": ["resource_id"],
180
- "additionalProperties": False,
181
- },
182
- },
183
- },
184
- "move_to_resource": {
185
- "type": "function",
186
- "function": {
187
- "name": "move_to_resource",
188
- "description": "Move toward the most relevant known resource node.",
189
- "parameters": {
190
- "type": "object",
191
- "properties": {
192
- "resource_id": {
193
  "type": "string",
194
- "description": "Optional ID of the resource node to approach.",
195
- }
 
196
  },
197
  "required": [],
198
  "additionalProperties": False,
199
  },
200
  },
201
  },
202
- "move_to": {
203
- "type": "function",
204
- "function": {
205
- "name": "move_to",
206
- "description": "Wander or reposition when no urgent action is needed.",
207
- "parameters": {
208
- "type": "object",
209
- "properties": {},
210
- "required": [],
211
- "additionalProperties": False,
212
- },
213
- },
214
- },
215
  "transfer": {
216
  "type": "function",
217
  "function": {
218
  "name": "transfer",
219
- "description": "Give one of your resources to a nearby NPC.",
 
 
220
  "parameters": {
221
  "type": "object",
222
  "properties": {
223
  "target_id": {
224
  "type": "string",
225
- "description": "Nearby NPC id to receive the resource.",
226
  },
227
  "resource_type": {
228
  "type": "string",
229
  "enum": ["food", "herbs", "wood", "weapon"],
230
  },
231
  "amount": {"type": "integer", "minimum": 1, "maximum": 5},
 
 
 
 
232
  },
233
- "required": ["target_id", "resource_type"],
234
  "additionalProperties": False,
235
  },
236
  },
237
  },
238
- "steal": {
239
  "type": "function",
240
  "function": {
241
- "name": "steal",
242
- "description": "Steal food from a nearby NPC as a desperate hostile act.",
243
  "parameters": {
244
  "type": "object",
245
  "properties": {
246
- "target_id": {
247
  "type": "string",
248
- "description": "Optional nearby NPC id to steal from.",
249
  }
250
  },
251
  "required": [],
@@ -253,87 +204,11 @@ TOOL_DEFINITIONS: dict[str, dict[str, Any]] = {
253
  },
254
  },
255
  },
256
- "talk": {
257
- "type": "function",
258
- "function": {
259
- "name": "talk",
260
- "description": "Say a short direct line to a nearby NPC.",
261
- "parameters": {
262
- "type": "object",
263
- "properties": {
264
- "target_npc_id": {"type": "string"},
265
- "message": {"type": "string"},
266
- },
267
- "required": ["target_npc_id", "message"],
268
- "additionalProperties": False,
269
- },
270
- },
271
- },
272
  }
273
 
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
-
280
- def _action_tools(allowed_actions: Sequence[ActionKind]) -> list[dict[str, Any]]:
281
- enum_actions = list(allowed_actions) or ["observe"]
282
- return [
283
- {
284
- "type": "function",
285
- "function": {
286
- "name": "choose_action",
287
- "description": (
288
- "Choose exactly one allowed action for this NPC. The engine validates "
289
- "movement, damage, death, and side effects."
290
- ),
291
- "parameters": {
292
- "type": "object",
293
- "properties": {
294
- "npc_id": {
295
- "type": "string",
296
- "description": "Existing living NPC id, such as npc-001.",
297
- },
298
- "action": {
299
- "type": "string",
300
- "enum": enum_actions,
301
- "description": "One action id from allowed_actions.",
302
- },
303
- "target_npc_id": {
304
- "type": "string",
305
- "description": (
306
- "Known visible NPC id when the action targets a citizen."
307
- ),
308
- },
309
- "target_x": {
310
- "type": "number",
311
- "description": "Optional desired x coordinate for movement actions.",
312
- },
313
- "target_z": {
314
- "type": "number",
315
- "description": "Optional desired z coordinate for movement actions.",
316
- },
317
- "message": {
318
- "type": "string",
319
- "description": "Direct speech only for talk, plead, threaten, or help.",
320
- },
321
- "intent": {
322
- "type": "string",
323
- "description": "Short reason for choosing this action.",
324
- },
325
- "confidence": {
326
- "type": "number",
327
- "minimum": 0,
328
- "maximum": 1,
329
- },
330
- },
331
- "required": ["npc_id", "action"],
332
- "additionalProperties": False,
333
- },
334
- },
335
- }
336
- ]
337
 
338
 
339
  class OpenAICompatibleWorldSimulator:
@@ -502,16 +377,13 @@ class OpenAICompatibleWorldSimulator:
502
 
503
  if is_survival_world(world):
504
  survival_goal = select_survival_goal(npc, world)
505
- allowed = GOAL_ACTIONS.get(survival_goal) or ["observe"]
506
- messages = _build_survival_messages(world, next_tick, npc, survival_goal, allowed)
507
- tools = build_tool_list_for_goal(survival_goal)
508
- if not tools:
509
- tools = _action_tools(cast(list[ActionKind], allowed))
510
  else:
511
  perception, goal = refresh_citizen_motivation(world, npc)
512
- action_mask = build_action_mask(world, npc, perception, goal)
513
- messages = _build_messages(world, next_tick, npc, perception, action_mask)
514
- tools = _action_tools(action_mask.allowed_actions)
515
 
516
  request: dict[str, Any] = {
517
  "model": self._config.model,
@@ -540,7 +412,7 @@ def _build_messages(
540
  next_tick: int,
541
  npc: Npc,
542
  perception: CitizenPerception,
543
- action_mask: ActionMask,
544
  ) -> list[dict[str, str]]:
545
  half_width = world.terrain.width / 2
546
  half_depth = world.terrain.depth / 2
@@ -551,9 +423,10 @@ def _build_messages(
551
  "content": (
552
  f"You are {npc.name}, a {npc.role} in a small civilization sandbox. "
553
  "Choose only your own next action from your current state and observations. "
554
- "Use exactly one choose_action tool call. Do not write prose, "
555
- "JSON, narration, or reasoning text. For talk, the message argument is "
556
- "the exact words you say aloud. Choose only from allowed_actions."
 
557
  ),
558
  },
559
  {
@@ -576,9 +449,9 @@ def _build_messages(
576
  f"one block is {BLOCK_SIZE:g} world coordinate units",
577
  "prefer target coordinates at block centers",
578
  "do not invent npc ids",
579
- f"walk moves at most {MAX_WALK_DISTANCE:g} world units per tick",
580
- f"talk requires target distance <= {TALK_RADIUS:g} world units",
581
- f"attack requires target distance <= {ATTACK_RADIUS:g} world units",
582
  f"each NPC can see only visible_npcs within {VISIBLE_RADIUS:g} world units",
583
  "visible_npcs is the full set of other NPCs you can currently observe",
584
  "do not target dead NPCs",
@@ -592,14 +465,9 @@ def _build_messages(
592
  "you choose only the attempted action; "
593
  "the engine resolves damage and death"
594
  ),
595
- "choose exactly one action from allowed_actions",
596
- "never choose an action from forbidden_actions",
597
- "ordinary weather or generic small talk is forbidden during crisis",
598
- "do not choose ordinary small talk during threatened or hostile modes",
599
- "if threatened, prefer fleeing, pleading, help, defense, or attacking back",
600
  (
601
- "if you want to attack a far target, walk toward them "
602
- "or let the engine convert it"
603
  ),
604
  ],
605
  "world": {
@@ -613,15 +481,14 @@ def _build_messages(
613
  "block_size": BLOCK_SIZE,
614
  },
615
  },
616
- "npc": _npc_context(world, npc, perception, action_mask),
617
  "needs": asdict(npc.needs),
618
  "emotions": asdict(npc.emotions),
619
  "current_goal": asdict(npc.current_goal) if npc.current_goal else None,
620
  "current_mode": npc.mode,
621
  "perception": perception.to_dict(),
622
- "allowed_actions": action_mask.allowed_actions,
623
- "forbidden_actions": action_mask.forbidden_actions,
624
- "action_mask_reason": action_mask.reason,
625
  },
626
  ensure_ascii=False,
627
  ),
@@ -634,7 +501,7 @@ def _build_survival_messages(
634
  next_tick: int,
635
  npc: Npc,
636
  goal: str,
637
- allowed_actions: Sequence[str],
638
  ) -> list[dict[str, str]]:
639
  """Build a compact single-NPC survival prompt."""
640
  _ = next_tick
@@ -659,7 +526,7 @@ def _build_survival_messages(
659
  "weapon": npc.inventory_weapon,
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),
@@ -669,8 +536,9 @@ def _build_survival_messages(
669
  {
670
  "role": "system",
671
  "content": (
672
- f"You are {npc.name}. Choose exactly one allowed tool/action for the next tick. "
673
- "Do not invent ids. Do not mutate world state; the engine validates and applies effects."
 
674
  ),
675
  },
676
  *npc.conversation_context.messages,
@@ -685,11 +553,11 @@ def _npc_context(
685
  world: WorldState,
686
  npc: Npc,
687
  perception: CitizenPerception | None = None,
688
- action_mask: ActionMask | None = None,
689
  ) -> dict[str, Any]:
690
- if perception is None or action_mask is None:
691
  perception, goal = refresh_citizen_motivation(world, npc)
692
- action_mask = build_action_mask(world, npc, perception, goal)
693
 
694
  return {
695
  "id": npc.id,
@@ -705,8 +573,7 @@ def _npc_context(
705
  "current_goal": asdict(npc.current_goal) if npc.current_goal else None,
706
  "needs": asdict(npc.needs),
707
  "emotions": asdict(npc.emotions),
708
- "allowed_actions": action_mask.allowed_actions,
709
- "forbidden_actions": action_mask.forbidden_actions,
710
  "memory_summary": npc.memory_summary,
711
  "recent_observations": [
712
  {
@@ -719,11 +586,10 @@ def _npc_context(
719
  "nearby_threats": perception.nearby_threats,
720
  "under_threat": perception.under_threat,
721
  "threat_response_options": [
722
- "flee",
723
- "defend",
724
- "call_for_help",
725
- "plead",
726
- "attack_back if the threat is within attack range",
727
  ]
728
  if perception.nearby_threats
729
  else [],
@@ -778,27 +644,19 @@ def _parse_tool_calls(
778
  if not isinstance(function, dict):
779
  continue
780
  tool_name = function.get("name")
781
- if (
782
- tool_name != "choose_action"
783
- and tool_name not in LEGACY_ACTION_TOOLS
784
- and tool_name not in TOOL_DEFINITIONS
785
- ):
786
  continue
787
  try:
788
  arguments = _parse_arguments(function.get("arguments"))
789
  except json.JSONDecodeError as exc:
790
  print(f"Ignoring malformed LLM tool call arguments: {exc}", flush=True)
791
  continue
792
- if arguments is None and (tool_name in TOOL_DEFINITIONS or tool_name in LEGACY_ACTION_TOOLS):
793
  arguments = {}
794
- if arguments is not None:
795
- if tool_name in LEGACY_ACTION_TOOLS:
796
- arguments["action"] = tool_name
797
- elif tool_name != "choose_action":
798
- arguments["action"] = tool_name
799
- if npc_id is not None and "npc_id" not in arguments:
800
- arguments["npc_id"] = npc_id
801
- actions.append(arguments)
802
 
803
  return _actions_to_plan(actions, world, source=source)
804
 
@@ -835,10 +693,10 @@ def _parse_text_content(
835
  return TickPlan(source=source, directives=[])
836
 
837
  payload: dict[str, Any] = {"npc_id": resolved_npc_id, "action": action}
838
- if len(tokens) > 1 and action in {"gather", "move_to_resource"}:
839
  payload["resource_id"] = tokens[1]
840
- if len(tokens) > 1 and action in {"trade", "request_trade", "steal", "talk"}:
841
- payload["target_npc_id"] = tokens[1]
842
  return _actions_to_plan([payload], world, source=source)
843
 
844
 
@@ -863,14 +721,22 @@ def _actions_to_plan(actions: Sequence[object], world: WorldState, *, source: st
863
  target = _target_from_action(action, half_width=half_width, half_depth=half_depth)
864
  raw_target_id = action.get("target_id") or action.get("target_npc_id")
865
  target_npc_id = _optional_known_npc_id(raw_target_id, world)
866
- target_entity_id = None if target_npc_id else _optional_known_entity_id(raw_target_id, world)
 
 
867
  resource_id = _optional_resource_id(action.get("resource_id"), world)
868
- resource_type = _optional_resource_type(action.get("resource_type"))
 
 
 
 
869
  amount = _optional_amount(action.get("amount"))
870
  communication_intent = _optional_communication_intent(action.get("intent"))
871
  message = _optional_short_text(action.get("message"), limit=140)
872
  intent = _optional_short_text(action.get("intent"), limit=80)
873
  confidence = _optional_confidence(action.get("confidence"))
 
 
874
 
875
  directives.append(
876
  NpcDirective(
@@ -886,6 +752,8 @@ def _actions_to_plan(actions: Sequence[object], world: WorldState, *, source: st
886
  message=message,
887
  intent=intent,
888
  confidence=confidence,
 
 
889
  )
890
  )
891
 
@@ -893,37 +761,8 @@ def _actions_to_plan(actions: Sequence[object], world: WorldState, *, source: st
893
 
894
 
895
  def _action_kind(raw: object) -> ActionKind | None:
896
- if raw in (
897
- "walk",
898
- "talk",
899
- "attack",
900
- "idle",
901
- "flee",
902
- "defend",
903
- "call_for_help",
904
- "plead",
905
- "threaten",
906
- "observe",
907
- "investigate",
908
- "search_target",
909
- "move_to_target",
910
- "attack_back",
911
- # Survival actions (resolved by the survival engine in survival worlds).
912
- "gather",
913
- "consume",
914
- "eat",
915
- "heal",
916
- "move_to_resource",
917
- "move_to",
918
- "communicate",
919
- "transfer",
920
- "trade",
921
- "request_trade",
922
- "steal",
923
- "find_food",
924
- "find_herbs",
925
- ):
926
- return cast(ActionKind, raw)
927
  return None
928
 
929
 
@@ -954,7 +793,7 @@ def _optional_resource_id(raw: object, world: WorldState) -> str | None:
954
 
955
  def _optional_resource_type(raw: object) -> str | None:
956
  if raw in {"food", "herbs", "wood", "weapon"}:
957
- return cast(str, raw)
958
  return None
959
 
960
 
@@ -965,8 +804,8 @@ def _optional_amount(raw: object) -> int | None:
965
 
966
 
967
  def _optional_communication_intent(raw: object) -> str | None:
968
- if raw in {"help_request", "warning", "trade_request", "social"}:
969
- return cast(str, raw)
970
  return None
971
 
972
 
@@ -1067,26 +906,21 @@ def _safe_fallback_directive(
1067
  *,
1068
  reason: str,
1069
  ) -> NpcDirective:
 
1070
  if is_survival_world(world):
1071
  return survival_directive_for(world, npc, next_tick)
1072
 
1073
  perception, goal = refresh_citizen_motivation(world, npc)
1074
- action_mask = build_action_mask(world, npc, perception, goal)
1075
- action = action_mask.fallback_action
1076
- target_npc_id = goal.target_npc_id if goal and goal.target_npc_id else None
1077
-
1078
- if target_npc_id is None and perception.nearby_threats:
1079
- target_npc_id = str(perception.nearby_threats[0]["npc_id"])
1080
- if target_npc_id is None and perception.visible_targets:
1081
- target_npc_id = str(perception.visible_targets[0]["npc_id"])
1082
-
1083
- return NpcDirective(
1084
- npc_id=npc.id,
1085
- action=action,
1086
- target_npc_id=target_npc_id,
1087
- message=_fallback_message(action, npc),
1088
- memory=f"Safe fallback selected because {reason}.",
1089
- intent="safe_goal_fallback",
1090
  confidence=0.0,
1091
  )
1092
 
@@ -1124,100 +958,13 @@ def _with_conversation_turn(
1124
  user_content: str,
1125
  assistant_content: str,
1126
  ) -> NpcDirective:
1127
- return NpcDirective(
1128
- npc_id=directive.npc_id,
1129
- action=directive.action,
1130
- target=directive.target,
1131
- target_npc_id=directive.target_npc_id,
1132
- target_entity_id=directive.target_entity_id,
1133
- resource_id=directive.resource_id,
1134
- resource_type=directive.resource_type,
1135
- amount=directive.amount,
1136
- communication_intent=directive.communication_intent,
1137
- message=directive.message,
1138
- memory=directive.memory,
1139
- intent=directive.intent,
1140
- confidence=directive.confidence,
1141
  conversation_user=user_content,
1142
  conversation_assistant=assistant_content,
1143
  )
1144
 
1145
 
1146
- def _fallback_message(action: ActionKind, npc: Npc) -> str | None:
1147
- match action:
1148
- case "call_for_help":
1149
- return "Help! I am in danger!"
1150
- case "plead":
1151
- return "Please stop."
1152
- case "threaten":
1153
- return f"Stay back. {npc.name} will not yield."
1154
- case _:
1155
- return None
1156
-
1157
-
1158
- def _visible_npc_summaries(world: WorldState, viewer: Npc) -> list[dict[str, Any]]:
1159
- visible: list[dict[str, Any]] = []
1160
- for other in world.npcs:
1161
- if other.id == viewer.id:
1162
- continue
1163
- distance = distance_between(viewer, other)
1164
- if distance > VISIBLE_RADIUS:
1165
- continue
1166
- is_active_threat = has_hostile_intent(other)
1167
- visible.append(
1168
- {
1169
- "id": other.id,
1170
- "name": other.name,
1171
- "position": {"x": other.position.x, "z": other.position.z},
1172
- "condition": _health_condition(other),
1173
- "is_alive": is_alive(other),
1174
- "distance": round(distance, 3),
1175
- "can_talk": is_alive(other) and distance <= TALK_RADIUS,
1176
- "can_attack": is_alive(other) and distance <= ATTACK_RADIUS,
1177
- "can_attack_you": is_active_threat and distance <= ATTACK_RADIUS,
1178
- "is_active_threat": is_active_threat,
1179
- "ordinary_talk_allowed": not is_active_threat,
1180
- }
1181
- )
1182
- return visible
1183
-
1184
-
1185
- def _nearby_threats(world: WorldState, viewer: Npc) -> list[dict[str, Any]]:
1186
- threats: list[dict[str, Any]] = []
1187
- for other in world.npcs:
1188
- if other.id == viewer.id or not has_hostile_intent(other):
1189
- continue
1190
- distance = distance_between(viewer, other)
1191
- if distance > VISIBLE_RADIUS:
1192
- continue
1193
- threats.append(
1194
- {
1195
- "npc_id": other.id,
1196
- "name": other.name,
1197
- "reason": _threat_reason(other),
1198
- "distance": round(distance, 3),
1199
- "can_attack_you": distance <= ATTACK_RADIUS,
1200
- }
1201
- )
1202
- return threats
1203
-
1204
-
1205
- def _threat_reason(npc: Npc) -> str:
1206
- if npc.god_directive and has_hostile_intent(npc):
1207
- return "active hostile directive"
1208
- return "active hostile behavior"
1209
-
1210
-
1211
- def _health_condition(npc: Npc) -> str:
1212
- if not is_alive(npc):
1213
- return "dead"
1214
- if npc.health <= 25:
1215
- return "critical"
1216
- if npc.health <= 60:
1217
- return "injured"
1218
- return "healthy"
1219
-
1220
-
1221
  def _resolve_base_url(config: ConnectorConfig) -> str | None:
1222
  base_url = config.base_url or config.api_url
1223
  if not base_url:
 
4
  import os
5
  from collections.abc import Callable, Sequence
6
  from concurrent.futures import ThreadPoolExecutor
7
+ from dataclasses import asdict, replace
8
  from typing import Any, cast
9
 
10
  from openai import OpenAI
11
 
12
  from god_simulator.config import ConnectorConfig
13
  from god_simulator.domain import Npc, Vec3, WorldState
14
+ from god_simulator.simulation.actions import ActionHints, build_action_hints
15
+ from god_simulator.simulation.autopilot import autopilot_directive
16
  from god_simulator.simulation.connectors.base import (
17
  ActionKind,
18
  NpcDirective,
 
27
  MAX_WALK_DISTANCE,
28
  TALK_RADIUS,
29
  VISIBLE_RADIUS,
 
 
30
  is_alive,
 
31
  )
32
  from god_simulator.simulation.memory import recent_meaningful_memories
33
  from god_simulator.simulation.perception import CitizenPerception
34
  from god_simulator.simulation.survival import (
35
+ GOAL_SUGGESTED_ACTIONS,
36
  build_memory_summary,
37
  build_perception,
38
  build_relationships_summary,
39
  is_survival_world,
 
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
 
46
+ # One tool per primitive. Every NPC always sees all six; suggested_actions in
47
+ # the payload are ranked hints, never restrictions.
 
 
 
 
 
 
 
 
48
  TOOL_DEFINITIONS: dict[str, dict[str, Any]] = {
49
+ "move": {
50
  "type": "function",
51
  "function": {
52
+ "name": "move",
53
+ "description": (
54
+ "Move toward a position, NPC, beast, or resource node. "
55
+ "Set away=true to move away from the target instead (flee)."
56
+ ),
57
  "parameters": {
58
  "type": "object",
59
  "properties": {
60
+ "target_id": {
61
  "type": "string",
62
+ "description": "Optional visible NPC, beast, or resource node id.",
63
+ },
64
+ "target_x": {
65
+ "type": "number",
66
+ "description": "Optional desired x coordinate.",
67
+ },
68
+ "target_z": {
69
+ "type": "number",
70
+ "description": "Optional desired z coordinate.",
71
+ },
72
+ "away": {
73
+ "type": "boolean",
74
+ "description": "Move away from the target/threat instead of toward it.",
75
+ },
76
  },
77
  "required": [],
78
  "additionalProperties": False,
79
  },
80
  },
81
  },
82
+ "speak": {
83
  "type": "function",
84
  "function": {
85
+ "name": "speak",
86
+ "description": (
87
+ "Say one short message. With target_id it is direct speech; without a "
88
+ "target it is a shout heard by everyone nearby (e.g. a call for help)."
89
+ ),
90
  "parameters": {
91
  "type": "object",
92
  "properties": {
 
 
 
 
93
  "target_id": {
94
  "type": "string",
95
+ "description": "Optional nearby NPC id to address directly.",
96
  },
97
  "message": {
98
  "type": "string",
99
+ "description": "The exact words said aloud.",
100
  },
101
+ "intent": {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
102
  "type": "string",
103
+ "description": (
104
+ "Why you are speaking, e.g. help_request, warning, "
105
+ "trade_request, social, plead, threaten."
106
+ ),
107
+ },
108
  },
109
+ "required": ["message"],
110
  "additionalProperties": False,
111
  },
112
  },
113
  },
114
+ "strike": {
115
  "type": "function",
116
  "function": {
117
+ "name": "strike",
118
+ "description": (
119
+ "Attack a nearby NPC or beast. The engine resolves movement, damage, and death."
120
+ ),
121
  "parameters": {
122
  "type": "object",
123
  "properties": {
124
  "target_id": {
125
  "type": "string",
126
+ "description": "Visible NPC or beast id to attack.",
127
  }
128
  },
129
+ "required": ["target_id"],
 
 
 
 
 
 
 
 
 
 
 
 
 
130
  "additionalProperties": False,
131
  },
132
  },
133
  },
134
+ "use": {
135
  "type": "function",
136
  "function": {
137
+ "name": "use",
138
+ "description": (
139
+ "Use something: gather from a resource node (resource_id), eat food "
140
+ "(item=food), or heal with herbs (item=herbs)."
141
+ ),
 
 
 
 
 
 
 
 
 
 
142
  "parameters": {
143
  "type": "object",
144
  "properties": {
145
  "resource_id": {
146
  "type": "string",
147
+ "description": "Nearby resource node id to gather from.",
148
+ },
149
+ "item": {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  "type": "string",
151
+ "enum": ["food", "herbs"],
152
+ "description": "Inventory item to consume.",
153
+ },
154
  },
155
  "required": [],
156
  "additionalProperties": False,
157
  },
158
  },
159
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  "transfer": {
161
  "type": "function",
162
  "function": {
163
  "name": "transfer",
164
+ "description": (
165
+ "Give a resource to a nearby NPC, or set take=true to steal from them."
166
+ ),
167
  "parameters": {
168
  "type": "object",
169
  "properties": {
170
  "target_id": {
171
  "type": "string",
172
+ "description": "Nearby NPC id.",
173
  },
174
  "resource_type": {
175
  "type": "string",
176
  "enum": ["food", "herbs", "wood", "weapon"],
177
  },
178
  "amount": {"type": "integer", "minimum": 1, "maximum": 5},
179
+ "take": {
180
+ "type": "boolean",
181
+ "description": "Steal from the target instead of giving.",
182
+ },
183
  },
184
+ "required": ["target_id"],
185
  "additionalProperties": False,
186
  },
187
  },
188
  },
189
+ "idle": {
190
  "type": "function",
191
  "function": {
192
+ "name": "idle",
193
+ "description": "Do nothing this tick: wait, observe, or hold position defensively.",
194
  "parameters": {
195
  "type": "object",
196
  "properties": {
197
+ "intent": {
198
  "type": "string",
199
+ "description": "Optional flavor, e.g. observe or defend.",
200
  }
201
  },
202
  "required": [],
 
204
  },
205
  },
206
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  }
208
 
209
 
210
+ def primitive_tools() -> list[dict[str, Any]]:
211
+ return list(TOOL_DEFINITIONS.values())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
 
214
  class OpenAICompatibleWorldSimulator:
 
377
 
378
  if is_survival_world(world):
379
  survival_goal = select_survival_goal(npc, world)
380
+ suggested = GOAL_SUGGESTED_ACTIONS.get(survival_goal) or ["idle"]
381
+ messages = _build_survival_messages(world, next_tick, npc, survival_goal, suggested)
 
 
 
382
  else:
383
  perception, goal = refresh_citizen_motivation(world, npc)
384
+ hints = build_action_hints(world, npc, perception, goal)
385
+ messages = _build_messages(world, next_tick, npc, perception, hints)
386
+ tools = primitive_tools()
387
 
388
  request: dict[str, Any] = {
389
  "model": self._config.model,
 
412
  next_tick: int,
413
  npc: Npc,
414
  perception: CitizenPerception,
415
+ hints: ActionHints,
416
  ) -> list[dict[str, str]]:
417
  half_width = world.terrain.width / 2
418
  half_depth = world.terrain.depth / 2
 
423
  "content": (
424
  f"You are {npc.name}, a {npc.role} in a small civilization sandbox. "
425
  "Choose only your own next action from your current state and observations. "
426
+ "Call exactly one tool. Do not write prose, JSON, narration, or "
427
+ "reasoning text. For speak, the message argument is the exact words "
428
+ "you say aloud. suggested_actions are ranked hints, not restrictions: "
429
+ "any tool is allowed and the engine repairs physically impossible actions."
430
  ),
431
  },
432
  {
 
449
  f"one block is {BLOCK_SIZE:g} world coordinate units",
450
  "prefer target coordinates at block centers",
451
  "do not invent npc ids",
452
+ f"move covers at most {MAX_WALK_DISTANCE:g} world units per tick",
453
+ f"speak requires target distance <= {TALK_RADIUS:g} world units",
454
+ f"strike requires target distance <= {ATTACK_RADIUS:g} world units",
455
  f"each NPC can see only visible_npcs within {VISIBLE_RADIUS:g} world units",
456
  "visible_npcs is the full set of other NPCs you can currently observe",
457
  "do not target dead NPCs",
 
465
  "you choose only the attempted action; "
466
  "the engine resolves damage and death"
467
  ),
 
 
 
 
 
468
  (
469
+ "out-of-range speak or strike is automatically converted "
470
+ "into movement toward the target"
471
  ),
472
  ],
473
  "world": {
 
481
  "block_size": BLOCK_SIZE,
482
  },
483
  },
484
+ "npc": _npc_context(world, npc, perception, hints),
485
  "needs": asdict(npc.needs),
486
  "emotions": asdict(npc.emotions),
487
  "current_goal": asdict(npc.current_goal) if npc.current_goal else None,
488
  "current_mode": npc.mode,
489
  "perception": perception.to_dict(),
490
+ "suggested_actions": hints.suggested_actions,
491
+ "suggestion_reason": hints.reason,
 
492
  },
493
  ensure_ascii=False,
494
  ),
 
501
  next_tick: int,
502
  npc: Npc,
503
  goal: str,
504
+ suggested_actions: Sequence[str],
505
  ) -> list[dict[str, str]]:
506
  """Build a compact single-NPC survival prompt."""
507
  _ = next_tick
 
526
  "weapon": npc.inventory_weapon,
527
  },
528
  "goal": goal,
529
+ "suggested_actions": list(suggested_actions),
530
  "perception": build_perception(npc, world),
531
  "memory": build_memory_summary(npc, last_n=3, current_tick=world.tick),
532
  "relationships": build_relationships_summary(npc, world),
 
536
  {
537
  "role": "system",
538
  "content": (
539
+ f"You are {npc.name}. Call exactly one tool for the next tick. "
540
+ "suggested_actions are ranked hints, not restrictions. Do not invent ids. "
541
+ "Do not mutate world state; the engine validates and applies effects."
542
  ),
543
  },
544
  *npc.conversation_context.messages,
 
553
  world: WorldState,
554
  npc: Npc,
555
  perception: CitizenPerception | None = None,
556
+ hints: ActionHints | None = None,
557
  ) -> dict[str, Any]:
558
+ if perception is None or hints is None:
559
  perception, goal = refresh_citizen_motivation(world, npc)
560
+ hints = build_action_hints(world, npc, perception, goal)
561
 
562
  return {
563
  "id": npc.id,
 
573
  "current_goal": asdict(npc.current_goal) if npc.current_goal else None,
574
  "needs": asdict(npc.needs),
575
  "emotions": asdict(npc.emotions),
576
+ "suggested_actions": hints.suggested_actions,
 
577
  "memory_summary": npc.memory_summary,
578
  "recent_observations": [
579
  {
 
586
  "nearby_threats": perception.nearby_threats,
587
  "under_threat": perception.under_threat,
588
  "threat_response_options": [
589
+ "move away from the threat",
590
+ "speak to plead or call for help",
591
+ "idle defensively",
592
+ "strike back if the threat is within attack range",
 
593
  ]
594
  if perception.nearby_threats
595
  else [],
 
644
  if not isinstance(function, dict):
645
  continue
646
  tool_name = function.get("name")
647
+ if tool_name not in TOOL_DEFINITIONS:
 
 
 
 
648
  continue
649
  try:
650
  arguments = _parse_arguments(function.get("arguments"))
651
  except json.JSONDecodeError as exc:
652
  print(f"Ignoring malformed LLM tool call arguments: {exc}", flush=True)
653
  continue
654
+ if arguments is None:
655
  arguments = {}
656
+ arguments["action"] = tool_name
657
+ if npc_id is not None and "npc_id" not in arguments:
658
+ arguments["npc_id"] = npc_id
659
+ actions.append(arguments)
 
 
 
 
660
 
661
  return _actions_to_plan(actions, world, source=source)
662
 
 
693
  return TickPlan(source=source, directives=[])
694
 
695
  payload: dict[str, Any] = {"npc_id": resolved_npc_id, "action": action}
696
+ if len(tokens) > 1 and action in {"use", "move"}:
697
  payload["resource_id"] = tokens[1]
698
+ if len(tokens) > 1 and action in {"transfer", "speak", "strike"}:
699
+ payload["target_id"] = tokens[1]
700
  return _actions_to_plan([payload], world, source=source)
701
 
702
 
 
721
  target = _target_from_action(action, half_width=half_width, half_depth=half_depth)
722
  raw_target_id = action.get("target_id") or action.get("target_npc_id")
723
  target_npc_id = _optional_known_npc_id(raw_target_id, world)
724
+ target_entity_id = (
725
+ None if target_npc_id else _optional_known_entity_id(raw_target_id, world)
726
+ )
727
  resource_id = _optional_resource_id(action.get("resource_id"), world)
728
+ if resource_id is None and target_npc_id is None and target_entity_id is None:
729
+ resource_id = _optional_resource_id(raw_target_id, world)
730
+ resource_type = _optional_resource_type(
731
+ action.get("resource_type") or action.get("item")
732
+ )
733
  amount = _optional_amount(action.get("amount"))
734
  communication_intent = _optional_communication_intent(action.get("intent"))
735
  message = _optional_short_text(action.get("message"), limit=140)
736
  intent = _optional_short_text(action.get("intent"), limit=80)
737
  confidence = _optional_confidence(action.get("confidence"))
738
+ away = action.get("away") is True
739
+ take = action.get("take") is True
740
 
741
  directives.append(
742
  NpcDirective(
 
752
  message=message,
753
  intent=intent,
754
  confidence=confidence,
755
+ away=away,
756
+ take=take,
757
  )
758
  )
759
 
 
761
 
762
 
763
  def _action_kind(raw: object) -> ActionKind | None:
764
+ if raw in ("move", "speak", "strike", "use", "transfer", "idle"):
765
+ return raw
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
766
  return None
767
 
768
 
 
793
 
794
  def _optional_resource_type(raw: object) -> str | None:
795
  if raw in {"food", "herbs", "wood", "weapon"}:
796
+ return raw
797
  return None
798
 
799
 
 
804
 
805
 
806
  def _optional_communication_intent(raw: object) -> str | None:
807
+ if raw in {"help_request", "warning", "trade_request", "social", "plead", "threaten"}:
808
+ return raw
809
  return None
810
 
811
 
 
906
  *,
907
  reason: str,
908
  ) -> NpcDirective:
909
+ """Deterministic autopilot directive used when the LLM output is unusable."""
910
  if is_survival_world(world):
911
  return survival_directive_for(world, npc, next_tick)
912
 
913
  perception, goal = refresh_citizen_motivation(world, npc)
914
+ directive = autopilot_directive(
915
+ world,
916
+ npc,
917
+ NpcDirective(npc_id=npc.id),
918
+ perception,
919
+ goal,
920
+ )
921
+ return replace(
922
+ directive,
923
+ memory=directive.memory or f"Autopilot selected because {reason}.",
 
 
 
 
 
 
924
  confidence=0.0,
925
  )
926
 
 
958
  user_content: str,
959
  assistant_content: str,
960
  ) -> NpcDirective:
961
+ return replace(
962
+ directive,
 
 
 
 
 
 
 
 
 
 
 
 
963
  conversation_user=user_content,
964
  conversation_assistant=assistant_content,
965
  )
966
 
967
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
968
  def _resolve_base_url(config: ConnectorConfig) -> str | None:
969
  base_url = config.base_url or config.api_url
970
  if not base_url:
src/god_simulator/simulation/mechanics.py CHANGED
@@ -103,34 +103,6 @@ _HOSTILE_KEYWORDS = frozenset(
103
  "violence",
104
  }
105
  )
106
- _CRISIS_TALK_KEYWORDS = frozenset(
107
- {
108
- "back away",
109
- "calm",
110
- "don't",
111
- "dont",
112
- "help",
113
- "leave",
114
- "mercy",
115
- "please",
116
- "spare",
117
- "stop",
118
- "surrender",
119
- "truce",
120
- }
121
- )
122
- _ORDINARY_SMALL_TALK_KEYWORDS = frozenset(
123
- {
124
- "fine thanks",
125
- "fine, thanks",
126
- "how are you",
127
- "nice day",
128
- "nice today",
129
- "weather",
130
- }
131
- )
132
-
133
-
134
  def has_hostile_intent(npc: Npc) -> bool:
135
  if not is_alive(npc):
136
  return False
@@ -140,17 +112,6 @@ def has_hostile_intent(npc: Npc) -> bool:
140
  )
141
 
142
 
143
- def is_crisis_talk(message: str | None) -> bool:
144
- return _contains_any(message, _CRISIS_TALK_KEYWORDS)
145
-
146
-
147
- def is_ordinary_small_talk(message: str | None) -> bool:
148
- return (
149
- _contains_any(message, _ORDINARY_SMALL_TALK_KEYWORDS)
150
- and not is_crisis_talk(message)
151
- )
152
-
153
-
154
  def walk_away_target(
155
  npc: Npc,
156
  threat: Npc,
 
103
  "violence",
104
  }
105
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106
  def has_hostile_intent(npc: Npc) -> bool:
107
  if not is_alive(npc):
108
  return False
 
112
  )
113
 
114
 
 
 
 
 
 
 
 
 
 
 
 
115
  def walk_away_target(
116
  npc: Npc,
117
  threat: Npc,
src/god_simulator/simulation/survival.py CHANGED
@@ -14,7 +14,15 @@ from __future__ import annotations
14
  import math
15
  import random
16
 
17
- from god_simulator.domain import Beast, MemoryEpisode, Npc, ResourceNode, Vec3, WorldEvent, WorldState
 
 
 
 
 
 
 
 
18
  from god_simulator.simulation.connectors.base import NpcDirective, TickPlan
19
  from god_simulator.simulation.mechanics import (
20
  BLOCK_SIZE,
@@ -78,35 +86,44 @@ SPREAD_NODE_CHOICES = 3
78
  # Minimum surplus food before an NPC is willing to share with a needier neighbor.
79
  SHARE_FOOD_SURPLUS = 2
80
 
81
- # Goal -> allowed survival actions (the deterministic planner and any LLM both
82
- # choose from these sets; effects are resolved by the engine).
83
- GOAL_ACTIONS: dict[str, list[str]] = {
84
- "survive_threat_fight": ["attack", "defend", "communicate", "flee"],
85
- "survive_threat_flee": ["flee", "communicate", "defend"],
86
- "help_ally": ["attack", "defend", "communicate"],
87
- "heal_self": ["heal"],
88
- "eat_food": ["consume"],
89
- "find_food": ["move_to_resource", "gather", "communicate", "steal"],
90
- "find_herbs": ["move_to_resource", "gather", "communicate"],
91
- "routine_life": ["gather", "move_to_resource", "transfer", "communicate"],
 
92
  "dead": [],
93
  }
94
 
95
- # Every action the survival resolver knows how to execute.
96
- VALID_SURVIVAL_ACTIONS = frozenset(
97
- {action for actions in GOAL_ACTIONS.values() for action in actions}
98
- | {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  "move_to_resource",
 
100
  "find_food",
101
  "find_herbs",
102
- "idle",
103
- "move_to",
104
- # Compatibility aliases accepted at validation boundaries.
105
- "eat",
106
- "talk",
107
- "call_for_help",
108
- "trade",
109
- "request_trade",
110
  }
111
  )
112
 
@@ -727,8 +744,8 @@ def select_goal(npc: Npc, world: WorldState) -> str:
727
  return "routine_life"
728
 
729
 
730
- def allowed_actions_for_goal(goal: str) -> list[str]:
731
- return list(GOAL_ACTIONS.get(goal, []))
732
 
733
 
734
  def validate_survival_action(
@@ -737,8 +754,10 @@ def validate_survival_action(
737
  world: WorldState,
738
  directive: NpcDirective | None = None,
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:
@@ -754,28 +773,69 @@ def validate_survival_action(
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"
759
- if action == "communicate":
760
- return "communicate"
761
  if action == "transfer":
762
  partner = _target_npc_from_directive(world, npc, directive, max_dist=TRADE_RADIUS)
763
  if partner is None:
764
  partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS)
765
  return "transfer" if partner is not None else "communicate"
766
- if action in VALID_SURVIVAL_ACTIONS:
767
  return action
768
  return "idle"
769
 
770
 
771
- def _canonical_action(action: str) -> str:
772
- if action == "eat":
773
- return "consume"
774
- if action in {"talk", "call_for_help", "request_trade"}:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
775
  return "communicate"
776
- if action == "trade":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
777
  return "transfer"
778
- return action
 
779
 
780
 
781
  # --------------------------------------------------------------------------- #
@@ -790,19 +850,7 @@ def apply_action_effects(
790
  directive: NpcDirective | None = None,
791
  ) -> str:
792
  """Apply one validated survival action; return a short intention summary."""
793
- original_action = action
794
- action = _canonical_action(action)
795
- if directive is None and original_action in {"call_for_help", "request_trade", "talk"}:
796
- alias_intent = {
797
- "call_for_help": "help_request",
798
- "request_trade": "trade_request",
799
- "talk": "social",
800
- }[original_action]
801
- directive = NpcDirective(
802
- npc_id=npc.id,
803
- action=action,
804
- communication_intent=alias_intent,
805
- )
806
  half_width = world.terrain.width / 2
807
  half_depth = world.terrain.depth / 2
808
 
@@ -1521,10 +1569,11 @@ def _plan_directive(
1521
  amount: int | None = None,
1522
  communication_intent: str | None = None,
1523
  message: str | None = None,
 
1524
  ) -> NpcDirective:
1525
  return NpcDirective(
1526
  npc_id=npc.id,
1527
- action=action,
1528
  target=target,
1529
  target_npc_id=target_npc_id,
1530
  resource_id=resource_id,
@@ -1532,27 +1581,28 @@ def _plan_directive(
1532
  amount=amount,
1533
  communication_intent=communication_intent,
1534
  message=message,
 
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")
1541
- return directive("attack")
1542
 
1543
  if goal == "survive_threat_flee":
1544
  if (next_tick + index) % 4 == 0:
1545
- return directive("communicate", communication_intent="help_request")
1546
- return directive("flee")
1547
 
1548
  if goal == "help_ally":
1549
- return directive("attack")
1550
 
1551
  if goal == "heal_self":
1552
- return directive("heal")
1553
 
1554
  if goal == "eat_food":
1555
- return directive("consume")
1556
 
1557
  if goal in ("find_food", "find_herbs"):
1558
  resource_type = "food" if goal == "find_food" else "herbs"
@@ -1565,7 +1615,7 @@ def _plan_directive(
1565
  and npc.relationships.get(partner.id, 0.0) > 0.3
1566
  ):
1567
  return directive(
1568
- "communicate",
1569
  target_npc_id=partner.id,
1570
  resource_type="food",
1571
  communication_intent="trade_request",
@@ -1573,9 +1623,9 @@ def _plan_directive(
1573
  node = _spread_resource(world, npc, index, resource_type=resource_type)
1574
  if node is not None:
1575
  if vec_distance(npc.position, node.position) <= GATHER_RADIUS:
1576
- return directive("gather", resource_id=node.id)
1577
- return directive("move_to_resource", target=node.position, resource_id=node.id)
1578
- return directive("move_to") # nothing to gather right now -> wander, don't freeze
1579
 
1580
  # routine_life: socialise, share, forage, or wander -- and spread out.
1581
  neighbor = nearest_alive_npc(world, npc, max_dist=TALK_RADIUS)
@@ -1593,10 +1643,10 @@ def _plan_directive(
1593
  amount=1,
1594
  )
1595
  if neighbor is not None and phase == 1:
1596
- return directive("communicate", target_npc_id=neighbor.id, communication_intent="social")
1597
  node = _spread_resource(world, npc, index)
1598
  if node is not None and vec_distance(npc.position, node.position) <= GATHER_RADIUS:
1599
- return directive("gather", resource_id=node.id)
1600
  if node is not None and phase != 3:
1601
- return directive("move_to_resource", target=node.position, resource_id=node.id)
1602
- return directive("move_to") # wander to disperse
 
14
  import math
15
  import random
16
 
17
+ from god_simulator.domain import (
18
+ Beast,
19
+ MemoryEpisode,
20
+ Npc,
21
+ ResourceNode,
22
+ Vec3,
23
+ WorldEvent,
24
+ WorldState,
25
+ )
26
  from god_simulator.simulation.connectors.base import NpcDirective, TickPlan
27
  from god_simulator.simulation.mechanics import (
28
  BLOCK_SIZE,
 
86
  # Minimum surplus food before an NPC is willing to share with a needier neighbor.
87
  SHARE_FOOD_SURPLUS = 2
88
 
89
+ # Goal -> ranked primitive suggestions for the LLM prompt, best first. These are
90
+ # hints only: any primitive remains choosable and the engine merely repairs
91
+ # physically impossible requests. The deterministic autopilot has its own plan.
92
+ GOAL_SUGGESTED_ACTIONS: dict[str, list[str]] = {
93
+ "survive_threat_fight": ["strike", "idle", "speak", "move"],
94
+ "survive_threat_flee": ["move", "speak", "idle"],
95
+ "help_ally": ["strike", "idle", "speak"],
96
+ "heal_self": ["use"],
97
+ "eat_food": ["use"],
98
+ "find_food": ["move", "use", "speak", "transfer"],
99
+ "find_herbs": ["move", "use", "speak"],
100
+ "routine_life": ["use", "move", "transfer", "speak"],
101
  "dead": [],
102
  }
103
 
104
+ PRIMITIVE_SURVIVAL_ACTIONS = frozenset(
105
+ {"move", "speak", "strike", "use", "transfer", "idle"}
106
+ )
107
+
108
+ # Internal effect verbs the resolver knows how to execute. Primitives are
109
+ # translated to one of these in validate_survival_action; the verbs never leave
110
+ # this module.
111
+ VALID_SURVIVAL_VERBS = frozenset(
112
+ {
113
+ "gather",
114
+ "consume",
115
+ "heal",
116
+ "attack",
117
+ "steal",
118
+ "communicate",
119
+ "transfer",
120
+ "flee",
121
+ "defend",
122
+ "idle",
123
  "move_to_resource",
124
+ "move_to",
125
  "find_food",
126
  "find_herbs",
 
 
 
 
 
 
 
 
127
  }
128
  )
129
 
 
744
  return "routine_life"
745
 
746
 
747
+ def suggested_actions_for_goal(goal: str) -> list[str]:
748
+ return list(GOAL_SUGGESTED_ACTIONS.get(goal, []))
749
 
750
 
751
  def validate_survival_action(
 
754
  world: WorldState,
755
  directive: NpcDirective | None = None,
756
  ) -> str:
757
+ """Translate a primitive into an executable effect verb, repairing
758
+ physically impossible requests (missing nodes, empty inventory, no target).
759
+ """
760
+ action = _verb_for_action(npc, action, world, directive)
761
  if action == "gather":
762
  node = _resource_from_directive(world, directive, npc, max_dist=GATHER_RADIUS)
763
  if node is None or node.amount <= 0:
 
773
  return "defend"
774
  return "attack" # apply_action_effects approaches when out of melee reach
775
  if action == "steal":
776
+ partner = _target_npc_from_directive(world, npc, directive, max_dist=TRADE_RADIUS)
777
+ if partner is None:
778
+ partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS, require_food=True)
779
  return "steal" if partner is not None else "move_to_resource"
 
 
780
  if action == "transfer":
781
  partner = _target_npc_from_directive(world, npc, directive, max_dist=TRADE_RADIUS)
782
  if partner is None:
783
  partner = nearest_alive_npc(world, npc, max_dist=TRADE_RADIUS)
784
  return "transfer" if partner is not None else "communicate"
785
+ if action in VALID_SURVIVAL_VERBS:
786
  return action
787
  return "idle"
788
 
789
 
790
+ def _verb_for_action(
791
+ npc: Npc,
792
+ action: str,
793
+ world: WorldState,
794
+ directive: NpcDirective | None,
795
+ ) -> str:
796
+ """Map a primitive plus its parameters onto an internal effect verb.
797
+
798
+ Non-primitive verbs (already translated, or internal repairs) pass through.
799
+ """
800
+ if action not in PRIMITIVE_SURVIVAL_ACTIONS:
801
+ return action
802
+
803
+ if action == "move":
804
+ if directive is not None and directive.away:
805
+ return "flee"
806
+ if directive is not None and (
807
+ directive.resource_id or directive.resource_type or directive.target
808
+ ):
809
+ return "move_to_resource"
810
+ return "move_to"
811
+
812
+ if action == "speak":
813
  return "communicate"
814
+
815
+ if action == "strike":
816
+ return "attack"
817
+
818
+ if action == "use":
819
+ if directive is not None and directive.resource_id:
820
+ return "gather"
821
+ resource_type = directive.resource_type if directive is not None else None
822
+ if resource_type == "food":
823
+ return "consume"
824
+ if resource_type == "herbs":
825
+ return "heal"
826
+ node = _resource_from_directive(world, directive, npc, max_dist=GATHER_RADIUS)
827
+ if node is not None and node.amount > 0:
828
+ return "gather"
829
+ if npc.inventory_food > 0 and npc.hunger > STARVATION_THRESHOLD:
830
+ return "consume"
831
+ return "gather"
832
+
833
+ if action == "transfer":
834
+ if directive is not None and directive.take:
835
+ return "steal"
836
  return "transfer"
837
+
838
+ return "idle"
839
 
840
 
841
  # --------------------------------------------------------------------------- #
 
850
  directive: NpcDirective | None = None,
851
  ) -> str:
852
  """Apply one validated survival action; return a short intention summary."""
853
+ action = _verb_for_action(npc, action, world, directive)
 
 
 
 
 
 
 
 
 
 
 
 
854
  half_width = world.terrain.width / 2
855
  half_depth = world.terrain.depth / 2
856
 
 
1569
  amount: int | None = None,
1570
  communication_intent: str | None = None,
1571
  message: str | None = None,
1572
+ away: bool = False,
1573
  ) -> NpcDirective:
1574
  return NpcDirective(
1575
  npc_id=npc.id,
1576
+ action=action, # type: ignore[arg-type]
1577
  target=target,
1578
  target_npc_id=target_npc_id,
1579
  resource_id=resource_id,
 
1581
  amount=amount,
1582
  communication_intent=communication_intent,
1583
  message=message,
1584
+ away=away,
1585
  intent=f"survival:{goal}",
1586
  )
1587
 
1588
  if goal == "survive_threat_fight":
1589
  if (next_tick + index) % 3 == 0:
1590
+ return directive("speak", communication_intent="help_request")
1591
+ return directive("strike")
1592
 
1593
  if goal == "survive_threat_flee":
1594
  if (next_tick + index) % 4 == 0:
1595
+ return directive("speak", communication_intent="help_request")
1596
+ return directive("move", away=True)
1597
 
1598
  if goal == "help_ally":
1599
+ return directive("strike")
1600
 
1601
  if goal == "heal_self":
1602
+ return directive("use", resource_type="herbs")
1603
 
1604
  if goal == "eat_food":
1605
+ return directive("use", resource_type="food")
1606
 
1607
  if goal in ("find_food", "find_herbs"):
1608
  resource_type = "food" if goal == "find_food" else "herbs"
 
1615
  and npc.relationships.get(partner.id, 0.0) > 0.3
1616
  ):
1617
  return directive(
1618
+ "speak",
1619
  target_npc_id=partner.id,
1620
  resource_type="food",
1621
  communication_intent="trade_request",
 
1623
  node = _spread_resource(world, npc, index, resource_type=resource_type)
1624
  if node is not None:
1625
  if vec_distance(npc.position, node.position) <= GATHER_RADIUS:
1626
+ return directive("use", resource_id=node.id)
1627
+ return directive("move", target=node.position, resource_id=node.id)
1628
+ return directive("move") # nothing to gather right now -> wander, don't freeze
1629
 
1630
  # routine_life: socialise, share, forage, or wander -- and spread out.
1631
  neighbor = nearest_alive_npc(world, npc, max_dist=TALK_RADIUS)
 
1643
  amount=1,
1644
  )
1645
  if neighbor is not None and phase == 1:
1646
+ return directive("speak", target_npc_id=neighbor.id, communication_intent="social")
1647
  node = _spread_resource(world, npc, index)
1648
  if node is not None and vec_distance(npc.position, node.position) <= GATHER_RADIUS:
1649
+ return directive("use", resource_id=node.id)
1650
  if node is not None and phase != 3:
1651
+ return directive("move", target=node.position, resource_id=node.id)
1652
+ return directive("move") # wander to disperse
src/god_simulator/simulation/tick.py CHANGED
@@ -1,14 +1,20 @@
1
  from __future__ import annotations
2
 
3
- from dataclasses import asdict
4
  import math
5
  import random
 
6
 
7
  from god_simulator.domain import CitizenGoal, Npc, Vec3, WorldState
8
- from god_simulator.simulation.actions import ActionMask, build_action_mask
 
 
 
 
 
 
 
9
  from god_simulator.simulation.connectors.base import (
10
  ActionDebugTrace,
11
- ActionKind,
12
  NpcDirective,
13
  ResolutionResult,
14
  TickPlan,
@@ -16,10 +22,7 @@ from god_simulator.simulation.connectors.base import (
16
  WorldSimulator,
17
  )
18
  from god_simulator.simulation.connectors.deterministic import DeterministicWorldSimulator
19
- from god_simulator.simulation.goals import (
20
- refresh_citizen_motivation,
21
- resolve_hostile_directive_target,
22
- )
23
  from god_simulator.simulation.mechanics import (
24
  ATTACK_RADIUS,
25
  BLOCK_SIZE,
@@ -29,12 +32,9 @@ from god_simulator.simulation.mechanics import (
29
  distance_between,
30
  has_hostile_intent,
31
  is_alive,
32
- is_crisis_talk,
33
- is_ordinary_small_talk,
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,
@@ -82,7 +82,7 @@ def apply_tick_plan(world: WorldState, next_tick: int, plan: TickPlan) -> None:
82
  half_width = world.terrain.width / 2
83
  half_depth = world.terrain.depth / 2
84
  npcs_by_id = {npc.id: npc for npc in world.npcs}
85
- walk_targets = _resolve_walk_targets(
86
  world,
87
  plan.directives,
88
  half_width=half_width,
@@ -106,23 +106,20 @@ def apply_tick_plan(world: WorldState, next_tick: int, plan: TickPlan) -> None:
106
  continue
107
 
108
  match directive.action:
109
- case "walk":
110
- if not directive.target:
111
- resolution = _apply_walk(npc, directive, None, npcs_by_id, next_tick)
112
- else:
113
- resolution = _apply_walk(
114
- npc,
115
- directive,
116
- walk_targets.get(npc.id),
117
- npcs_by_id,
118
- next_tick,
119
- )
120
- case "talk":
121
- resolution = _apply_talk(npc, directive, npcs_by_id, next_tick)
122
- case "attack":
123
- resolution = _apply_attack(npc, directive, npcs_by_id, world, next_tick)
124
  case "idle":
125
- resolution = _apply_idle(npc, directive, npcs_by_id, next_tick)
126
  case _:
127
  resolution = ResolutionResult(
128
  npc_id=directive.npc_id,
@@ -178,519 +175,217 @@ def validate_directive(
178
  npc: Npc | None,
179
  directive: NpcDirective,
180
  ) -> ValidationResult:
181
- """Validate or repair one directive before engine execution."""
 
 
 
 
 
182
  if npc is None or npc.id != directive.npc_id:
183
- resolved = _idle_directive(
184
  directive,
185
  memory="Action ignored because the actor was unavailable.",
186
  intent="invalid_actor",
187
  )
188
- return _validation_result(
189
- directive,
190
- resolved,
191
- valid=False,
192
- reason="actor unavailable",
193
- )
194
 
195
  if not is_alive(npc):
196
- resolved = _idle_directive(
197
- directive,
198
- memory=None,
199
- intent="actor_dead",
200
- )
201
- return _validation_result(
202
- directive,
203
- resolved,
204
- valid=False,
205
- reason="actor dead",
206
- )
207
 
208
  perception, goal = refresh_citizen_motivation(world, npc)
209
- action_mask = build_action_mask(world, npc, perception, goal)
210
-
211
- if not _is_supported_action(directive.action):
212
- fallback = _fallback_for_context(
213
- world,
214
- npc,
215
- directive,
216
- perception,
217
- goal,
218
- action_mask,
219
- memory="Unsupported action replaced by goal-based fallback.",
220
- )
221
- return _validation_result(
222
- directive,
223
- fallback,
224
- valid=False,
225
- reason="unsupported action replaced by goal fallback",
226
- )
227
 
228
- normalized = _normalize_directive_for_context(world, npc, directive, perception, goal)
229
- if normalized is not directive and _is_action_allowed(
230
- normalized,
231
- action_mask,
232
- goal,
233
- perception,
234
- ):
235
- return _validate_normalized_directive(world, npc, normalized, perception, goal, directive)
236
-
237
- if not _is_action_allowed(directive, action_mask, goal, perception):
238
- fallback = _fallback_for_context(
239
- world,
240
- npc,
241
- directive,
242
- perception,
243
- goal,
244
- action_mask,
245
- memory=(
246
- f"{directive.action} was not allowed for goal "
247
- f"{goal.goal_type if goal else 'none'}."
248
- ),
249
- )
250
  return _validation_result(
251
  directive,
252
  fallback,
253
  valid=False,
254
- reason=(
255
- f"{directive.action} forbidden by action mask: "
256
- f"{', '.join(action_mask.forbidden_actions)}"
257
- ),
258
- )
259
-
260
- if directive.action not in ("walk", "talk", "attack", "idle"):
261
- resolved = _semantic_to_engine_directive(world, npc, directive, perception, goal)
262
- return _validation_result(
263
- directive,
264
- resolved,
265
- valid=True,
266
- reason=f"{directive.action} mapped to engine action {resolved.action}",
267
  )
268
 
269
  match directive.action:
270
  case "idle":
271
  return _validation_result(directive, directive, valid=True, reason="idle accepted")
272
- case "walk":
273
- if directive.target is None:
274
- resolved = _idle_directive(directive, memory=None, intent="missing_walk_target")
275
- return _validation_result(
276
- directive,
277
- resolved,
278
- valid=False,
279
- reason="walk target missing",
280
- )
281
- resolved = NpcDirective(
282
- npc_id=directive.npc_id,
283
- action="walk",
284
- target=_bounded_target(world, directive.target),
285
- target_npc_id=directive.target_npc_id,
286
- message=directive.message,
287
- memory=directive.memory,
288
- intent=directive.intent,
289
- confidence=directive.confidence,
290
- )
291
- return _validation_result(directive, resolved, valid=True, reason="walk accepted")
292
- case "talk":
293
- return _validate_talk(world, npc, directive)
294
- case "attack":
295
- return _validate_attack(world, npc, directive)
296
-
297
- resolved = _idle_directive(
298
- directive,
299
- memory="You could not act because the action was not supported.",
300
- intent="unsupported_action",
301
- )
302
- return _validation_result(
303
- directive,
304
- resolved,
305
- valid=False,
306
- reason="unsupported action",
307
- )
308
-
309
-
310
- def _validate_normalized_directive(
311
- world: WorldState,
312
- npc: Npc,
313
- normalized: NpcDirective,
314
- perception: CitizenPerception,
315
- goal: CitizenGoal | None,
316
- original: NpcDirective,
317
- ) -> ValidationResult:
318
- if normalized.action not in ("walk", "talk", "attack", "idle"):
319
- resolved = _semantic_to_engine_directive(world, npc, normalized, perception, goal)
320
- return _validation_result(
321
- original,
322
- resolved,
323
- valid=True,
324
- reason=f"{original.action} normalized to {normalized.action}",
325
- )
326
- if normalized.action == "talk":
327
- validation = _validate_talk(world, npc, normalized)
328
- elif normalized.action == "attack":
329
- validation = _validate_attack(world, npc, normalized)
330
- elif normalized.action == "walk" and normalized.target is not None:
331
- validation = _validation_result(
332
- normalized,
333
- NpcDirective(
334
- npc_id=normalized.npc_id,
335
- action="walk",
336
- target=_bounded_target(world, normalized.target),
337
- target_npc_id=normalized.target_npc_id,
338
- message=normalized.message,
339
- memory=normalized.memory,
340
- intent=normalized.intent,
341
- confidence=normalized.confidence,
342
- ),
343
- valid=True,
344
- reason="walk accepted",
345
- )
346
- else:
347
- validation = _validation_result(normalized, normalized, valid=True, reason="idle accepted")
348
-
349
- return _validation_result(
350
- original,
351
- validation.directive,
352
- valid=validation.valid,
353
- reason=f"{original.action} normalized to {normalized.action}: {validation.reason}",
354
- )
355
-
356
-
357
- def _normalize_directive_for_context(
358
- world: WorldState,
359
- npc: Npc,
360
- directive: NpcDirective,
361
- perception: CitizenPerception,
362
- goal: CitizenGoal | None,
363
- ) -> NpcDirective:
364
- if directive.action == "talk" and npc.mode in ("threatened", "fleeing"):
365
- target = _target_from_directive_or_goal(world, npc, directive, goal, perception)
366
- if target is not None and is_crisis_talk(directive.message):
367
- return NpcDirective(
368
- npc_id=npc.id,
369
- action="plead",
370
- target_npc_id=target.id,
371
- message=directive.message,
372
- memory=directive.memory,
373
- intent="plead_for_safety",
374
- confidence=directive.confidence,
375
  )
376
-
377
- if directive.action == "attack" and goal is not None and goal.goal_type in (
378
- "survive",
379
- "retaliate",
380
- "help_or_defend",
381
- ):
382
- target = _target_from_directive_or_goal(world, npc, directive, goal, perception)
383
- if target is not None:
384
- return NpcDirective(
385
- npc_id=npc.id,
386
- action="attack_back",
387
- target_npc_id=target.id,
388
- message=directive.message,
389
- memory=directive.memory,
390
- intent="attack_back",
391
- confidence=directive.confidence,
392
  )
393
 
394
- return directive
395
-
396
-
397
- def _is_action_allowed(
398
- directive: NpcDirective,
399
- action_mask: ActionMask,
400
- goal: CitizenGoal | None,
401
- perception: CitizenPerception,
402
- ) -> bool:
403
- if directive.action not in action_mask.allowed_actions:
404
- return False
405
-
406
- if goal is not None and goal.goal_type != "routine_life":
407
- if directive.action == "talk" and is_ordinary_small_talk(directive.message):
408
- return False
409
- if directive.action == "talk" and not is_crisis_talk(directive.message):
410
- return False
411
-
412
- if perception.under_threat and directive.action == "talk":
413
- return is_crisis_talk(directive.message)
414
-
415
- return True
416
 
417
-
418
- def _semantic_to_engine_directive(
419
  world: WorldState,
420
  npc: Npc,
421
  directive: NpcDirective,
422
  perception: CitizenPerception,
423
  goal: CitizenGoal | None,
424
- ) -> NpcDirective:
425
- target = _target_from_directive_or_goal(world, npc, directive, goal, perception)
426
-
427
- match directive.action:
428
- case "flee":
429
- if target is not None:
430
- return _flee_directive(world, npc, target, directive)
431
- return _idle_directive(
432
  directive,
433
- memory="You braced because no escape target was visible.",
434
  intent="defend",
435
  )
436
- case "defend":
437
- return _idle_directive(
438
  directive,
439
- memory="You held your ground defensively.",
440
- intent="defend",
 
441
  )
442
- case "call_for_help":
443
- return _idle_directive(
444
- _with_message(directive, "Help! I am in danger!"),
445
- memory="You called for help.",
446
- intent="call_for_help",
447
- )
448
- case "plead":
449
- if target is not None and distance_between(npc, target) <= TALK_RADIUS:
450
- return NpcDirective(
451
- npc_id=npc.id,
452
- action="talk",
453
- target_npc_id=target.id,
454
- message=directive.message or "Please stop.",
455
- memory=directive.memory,
456
- intent="plead",
457
- confidence=directive.confidence,
458
- )
459
- if target is not None:
460
- return _flee_directive(world, npc, target, directive)
461
- return _idle_directive(
462
- _with_message(directive, "Please stop."),
463
- memory="You pleaded, but no target could hear you.",
464
- intent="plead",
465
- )
466
- case "threaten":
467
- return _threaten_directive(npc, directive, target)
468
- case "observe":
469
- return _idle_directive(
470
- directive,
471
- memory="You observed the situation.",
472
- intent="observe",
473
- )
474
- case "investigate":
475
- if target is not None:
476
- return _move_toward_target_directive(npc, directive, target, intent="investigate")
477
- if directive.target is not None:
478
- return NpcDirective(
479
- npc_id=npc.id,
480
- action="walk",
481
- target=directive.target,
482
- target_npc_id=directive.target_npc_id,
483
- message=directive.message,
484
- memory="You investigated the nearby area.",
485
- intent="investigate",
486
- confidence=directive.confidence,
487
- )
488
- return _idle_directive(directive, memory="You watched for clues.", intent="observe")
489
- case "search_target" | "move_to_target":
490
- if target is not None:
491
- return _move_toward_target_directive(
492
- npc,
493
- directive,
494
- target,
495
- intent="approach_target_for_attack"
496
- if goal and goal.goal_type == "pursue_and_attack_target"
497
- else "move_to_target",
498
- )
499
- return _idle_directive(
500
  directive,
501
- memory="You searched, but no target was available.",
502
- intent="search_target",
503
  )
504
- case "attack_back":
505
- if target is not None:
506
- return NpcDirective(
507
- npc_id=npc.id,
508
- action="attack",
509
- target_npc_id=target.id,
510
- message=directive.message,
511
- memory=directive.memory,
512
- intent="attack_back",
513
- confidence=directive.confidence,
514
- )
515
- return _idle_directive(
516
  directive,
517
- memory="You could not attack back because no threat was available.",
518
- intent="invalid_attack_target",
 
519
  )
520
- case _:
521
- return _idle_directive(
 
 
522
  directive,
523
- memory="You could not act because the action was not supported.",
524
- intent="unsupported_action",
525
- )
526
-
527
-
528
- def _fallback_for_context(
529
- world: WorldState,
530
- npc: Npc,
531
- directive: NpcDirective,
532
- perception: CitizenPerception,
533
- goal: CitizenGoal | None,
534
- action_mask: ActionMask,
535
- *,
536
- memory: str,
537
- ) -> NpcDirective:
538
- target = _target_from_directive_or_goal(world, npc, directive, goal, perception)
539
-
540
- if goal is not None and goal.goal_type in ("pursue_and_attack_target", "retaliate"):
541
- if target is not None:
542
- return _attack_or_approach_target(npc, directive, target, memory=memory)
543
-
544
- if goal is not None and goal.goal_type in ("survive", "call_for_help", "help_or_defend"):
545
- if action_mask.fallback_action == "call_for_help":
546
- return _idle_directive(
547
- _with_message(directive, "Help! I am in danger!"),
548
- memory=memory,
549
- intent="call_for_help",
550
- )
551
- if target is not None:
552
- return _flee_directive(world, npc, target, directive)
553
- return _idle_directive(directive, memory=memory, intent="defend")
554
-
555
- if goal is not None and goal.goal_type in ("respond_to_event", "investigate"):
556
- if target is not None:
557
- return _move_toward_target_directive(npc, directive, target, intent="investigate")
558
- return _idle_directive(directive, memory=memory, intent="observe")
559
-
560
- return _idle_directive(directive, memory=memory, intent="observe")
561
-
562
-
563
- def _target_from_directive_or_goal(
564
- world: WorldState,
565
- npc: Npc,
566
- directive: NpcDirective,
567
- goal: CitizenGoal | None,
568
- perception: CitizenPerception,
569
- ) -> Npc | None:
570
- if directive.target_npc_id:
571
- target = _npc_by_id(world, directive.target_npc_id)
572
- if target is not None and target.id != npc.id and is_alive(target):
573
- return target
574
 
575
- if goal is not None and goal.target_npc_id:
576
- target = _npc_by_id(world, goal.target_npc_id)
577
- if target is not None and target.id != npc.id and is_alive(target):
578
- return target
579
 
580
- threat = nearby_threat_npc(world, perception)
581
- if threat is not None and threat.id != npc.id and is_alive(threat):
582
- return threat
583
 
584
- if goal is not None and goal.goal_type == "pursue_and_attack_target":
585
- return resolve_hostile_directive_target(world, npc)
586
 
587
- return None
 
 
 
 
 
 
 
 
588
 
 
 
 
 
 
 
 
 
 
 
 
 
 
589
 
590
- def _attack_or_approach_target(
591
- npc: Npc,
592
- directive: NpcDirective,
593
- target: Npc,
594
- *,
595
- memory: str,
596
- ) -> NpcDirective:
597
- if distance_between(npc, target) <= ATTACK_RADIUS:
598
- return NpcDirective(
599
  npc_id=npc.id,
600
- action="attack",
 
601
  target_npc_id=target.id,
602
  message=directive.message,
603
- memory=memory,
604
- intent="hostile_attack",
605
  confidence=directive.confidence,
606
  )
607
- return _move_toward_target_directive(
608
- npc,
609
- directive,
610
- target,
611
- intent="approach_target_for_attack",
612
- memory=memory,
613
- )
614
 
 
615
 
616
- def _move_toward_target_directive(
617
- npc: Npc,
618
- directive: NpcDirective,
619
- target: Npc,
620
- *,
621
- intent: str,
622
- memory: str | None = None,
623
- ) -> NpcDirective:
624
- return NpcDirective(
625
- npc_id=npc.id,
626
- action="walk",
627
- target=target.position,
628
- target_npc_id=target.id,
629
- message=directive.message,
630
- memory=memory or f"You moved toward {target.name}.",
631
- intent=intent,
632
- confidence=directive.confidence,
633
- )
634
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
635
 
636
- def _threaten_directive(npc: Npc, directive: NpcDirective, target: Npc | None) -> NpcDirective:
637
- message = directive.message or "Stay back."
638
- if target is not None and distance_between(npc, target) <= TALK_RADIUS:
639
- return NpcDirective(
640
  npc_id=npc.id,
641
- action="talk",
 
642
  target_npc_id=target.id,
643
- message=message,
644
- memory=directive.memory,
645
- intent="threaten",
646
  confidence=directive.confidence,
647
  )
648
- return _idle_directive(
649
- _with_message(directive, message),
650
- memory=f"You threatened {target.name if target else 'the target'} from a distance.",
651
- intent="threaten",
652
- )
 
653
 
 
654
 
655
- def _with_message(directive: NpcDirective, message: str) -> NpcDirective:
656
- if directive.message:
657
- return directive
658
- return NpcDirective(
659
- npc_id=directive.npc_id,
660
- action=directive.action,
661
- target=directive.target,
662
- target_npc_id=directive.target_npc_id,
663
- message=message,
664
- memory=directive.memory,
665
- intent=directive.intent,
666
- confidence=directive.confidence,
667
- )
668
 
669
 
670
  def _npc_by_id(world: WorldState, npc_id: str) -> Npc | None:
671
  return next((candidate for candidate in world.npcs if candidate.id == npc_id), None)
672
 
673
 
674
- def _is_supported_action(action: ActionKind) -> bool:
675
- return action in {
676
- "walk",
677
- "talk",
678
- "attack",
679
- "idle",
680
- "flee",
681
- "defend",
682
- "call_for_help",
683
- "plead",
684
- "threaten",
685
- "observe",
686
- "investigate",
687
- "search_target",
688
- "move_to_target",
689
- "attack_back",
690
- }
691
-
692
-
693
- def _resolve_walk_targets(
694
  world: WorldState,
695
  directives: list[NpcDirective],
696
  *,
@@ -701,14 +396,14 @@ def _resolve_walk_targets(
701
  targets: dict[str, Vec3] = {}
702
 
703
  for directive in directives:
704
- if directive.action != "walk" or not directive.target:
705
  continue
706
 
707
  npc = npcs_by_id.get(directive.npc_id)
708
  if not npc or not is_alive(npc):
709
  continue
710
 
711
- targets[npc.id] = _planned_walk_target(
712
  npc,
713
  directive.target,
714
  half_width=half_width,
@@ -718,7 +413,7 @@ def _resolve_walk_targets(
718
  return targets
719
 
720
 
721
- def _planned_walk_target(
722
  npc: Npc,
723
  raw_target: Vec3,
724
  *,
@@ -743,7 +438,7 @@ def _planned_walk_target(
743
  )
744
 
745
 
746
- def _apply_walk(
747
  npc: Npc,
748
  directive: NpcDirective,
749
  target: Vec3 | None,
@@ -795,16 +490,19 @@ def _apply_walk(
795
  )
796
 
797
 
798
- def _apply_talk(
799
  npc: Npc,
800
  directive: NpcDirective,
801
  npcs_by_id: dict[str, Npc],
802
  next_tick: int,
803
  ) -> ResolutionResult:
804
- target = npcs_by_id.get(directive.target_npc_id or "")
 
 
 
805
  if not target or target.id == npc.id or not is_alive(target):
806
- npc.intention = "could not talk"
807
- remember(npc, next_tick, "You could not talk because the target was unavailable.")
808
  return ResolutionResult(
809
  npc_id=npc.id,
810
  action=directive.action,
@@ -813,8 +511,8 @@ def _apply_talk(
813
  )
814
 
815
  if distance_between(npc, target) > TALK_RADIUS:
816
- npc.intention = f"too far to talk to {target.name}"
817
- remember(npc, next_tick, f"You were too far away to talk to {target.name}.")
818
  return ResolutionResult(
819
  npc_id=npc.id,
820
  action=directive.action,
@@ -823,12 +521,7 @@ def _apply_talk(
823
  )
824
 
825
  message = directive.message or "checking in"
826
- if directive.intent == "plead":
827
- npc.intention = f"pleading with {target.name}"
828
- elif directive.intent == "threaten":
829
- npc.intention = f"threatening {target.name}"
830
- else:
831
- npc.intention = f"talking to {target.name}"
832
  add_episode(
833
  npc,
834
  next_tick,
@@ -863,7 +556,45 @@ def _apply_talk(
863
  )
864
 
865
 
866
- def _apply_attack(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
867
  npc: Npc,
868
  directive: NpcDirective,
869
  npcs_by_id: dict[str, Npc],
@@ -895,7 +626,7 @@ def _apply_attack(
895
  damage = npc.attack_damage + rng.randint(0, 5)
896
  target.health -= damage
897
  npc.intention = f"attacking {target.name}"
898
- if has_hostile_intent(npc) or directive.intent in ("hostile_attack", "attack_back"):
899
  npc.mode = "hostile_pursuit"
900
  target.mode = "threatened"
901
  target.needs.safety = max(target.needs.safety, 100)
@@ -940,48 +671,12 @@ def _apply_attack(
940
  )
941
 
942
 
943
- def _apply_idle(
944
- npc: Npc,
945
- directive: NpcDirective,
946
- npcs_by_id: dict[str, Npc],
947
- next_tick: int,
948
- ) -> ResolutionResult:
949
  match directive.intent:
950
  case "defend":
951
  npc.intention = "defending"
952
  case "observe":
953
  npc.intention = "observing"
954
- case "call_for_help":
955
- npc.intention = "calling for help"
956
- message = directive.message or "Help! I am in danger!"
957
- add_episode(
958
- npc,
959
- next_tick,
960
- "communicate",
961
- npc.id,
962
- f"{npc.name} called for help",
963
- subject_kind="npc",
964
- perspective="self",
965
- tags=["help", "danger"],
966
- weight=0.6,
967
- )
968
- _remember_nearby_citizens(
969
- actor=npc,
970
- npcs_by_id=npcs_by_id,
971
- next_tick=next_tick,
972
- text=f"{npc.name} called for help: {message}",
973
- kind="communicate",
974
- tags=["help", "danger"],
975
- )
976
- case "threaten":
977
- npc.intention = "threatening"
978
- message = directive.message or "Stay back."
979
- _remember_nearby_citizens(
980
- actor=npc,
981
- npcs_by_id=npcs_by_id,
982
- next_tick=next_tick,
983
- text=f"{npc.name} threatened someone: {message}",
984
- )
985
  case _:
986
  npc.intention = "idle"
987
  return ResolutionResult(
@@ -992,185 +687,6 @@ def _apply_idle(
992
  )
993
 
994
 
995
- def _validate_talk(world: WorldState, npc: Npc, directive: NpcDirective) -> ValidationResult:
996
- target = _valid_living_target(world, npc, directive)
997
- if target is None:
998
- resolved = _idle_directive(
999
- directive,
1000
- memory="You could not talk because the target was unavailable.",
1001
- intent="invalid_talk_target",
1002
- )
1003
- return _validation_result(
1004
- directive,
1005
- resolved,
1006
- valid=False,
1007
- reason="talk target unavailable",
1008
- )
1009
-
1010
- distance = distance_between(npc, target)
1011
- if has_hostile_intent(target) and not is_crisis_talk(directive.message):
1012
- resolved = _flee_directive(world, npc, target, directive)
1013
- return _validation_result(
1014
- directive,
1015
- resolved,
1016
- valid=True,
1017
- reason="ordinary talk with active hostile target converted to flee",
1018
- )
1019
-
1020
- if distance > TALK_RADIUS:
1021
- resolved = NpcDirective(
1022
- npc_id=npc.id,
1023
- action="walk",
1024
- target=target.position,
1025
- target_npc_id=target.id,
1026
- message=directive.message,
1027
- memory=f"You moved closer to talk to {target.name}.",
1028
- intent="approach_target_for_talk",
1029
- confidence=directive.confidence,
1030
- )
1031
- return _validation_result(
1032
- directive,
1033
- resolved,
1034
- valid=True,
1035
- reason="out-of-range talk converted to approach movement",
1036
- )
1037
-
1038
- return _validation_result(directive, directive, valid=True, reason="talk accepted")
1039
-
1040
-
1041
- def _validate_attack(world: WorldState, npc: Npc, directive: NpcDirective) -> ValidationResult:
1042
- target = _valid_living_target(world, npc, directive)
1043
- if target is None:
1044
- resolved = _idle_directive(
1045
- directive,
1046
- memory="You could not attack because the target was unavailable.",
1047
- intent="invalid_attack_target",
1048
- )
1049
- return _validation_result(
1050
- directive,
1051
- resolved,
1052
- valid=False,
1053
- reason="attack target unavailable",
1054
- )
1055
-
1056
- if distance_between(npc, target) > ATTACK_RADIUS:
1057
- resolved = NpcDirective(
1058
- npc_id=npc.id,
1059
- action="walk",
1060
- target=target.position,
1061
- target_npc_id=target.id,
1062
- message=directive.message,
1063
- memory=f"You moved toward {target.name} to get within attack range.",
1064
- intent="approach_target_for_attack",
1065
- confidence=directive.confidence,
1066
- )
1067
- return _validation_result(
1068
- directive,
1069
- resolved,
1070
- valid=True,
1071
- reason="out-of-range attack converted to approach movement",
1072
- )
1073
-
1074
- return _validation_result(directive, directive, valid=True, reason="attack accepted")
1075
-
1076
-
1077
- def _valid_living_target(
1078
- world: WorldState,
1079
- npc: Npc,
1080
- directive: NpcDirective,
1081
- ) -> Npc | None:
1082
- if not directive.target_npc_id:
1083
- return None
1084
- target = {candidate.id: candidate for candidate in world.npcs}.get(directive.target_npc_id)
1085
- if target is None or target.id == npc.id or not is_alive(target):
1086
- return None
1087
- return target
1088
-
1089
-
1090
- def _hostile_target_fallback(
1091
- world: WorldState,
1092
- npc: Npc,
1093
- directive: NpcDirective,
1094
- ) -> NpcDirective | None:
1095
- if not has_hostile_intent(npc):
1096
- return None
1097
-
1098
- target = _nearest_living_target(world, npc)
1099
- if target is None:
1100
- return None
1101
-
1102
- if distance_between(npc, target) <= ATTACK_RADIUS:
1103
- return NpcDirective(
1104
- npc_id=npc.id,
1105
- action="attack",
1106
- target_npc_id=target.id,
1107
- message=directive.message,
1108
- memory=directive.memory,
1109
- intent="hostile_attack",
1110
- confidence=directive.confidence,
1111
- )
1112
-
1113
- return NpcDirective(
1114
- npc_id=npc.id,
1115
- action="walk",
1116
- target=target.position,
1117
- target_npc_id=target.id,
1118
- message=directive.message,
1119
- memory=f"You pursued {target.name} because of your hostile directive.",
1120
- intent="approach_target_for_attack",
1121
- confidence=directive.confidence,
1122
- )
1123
-
1124
-
1125
- def _nearest_living_target(world: WorldState, npc: Npc) -> Npc | None:
1126
- candidates = [
1127
- (distance_between(npc, candidate), candidate.id, candidate)
1128
- for candidate in world.npcs
1129
- if candidate.id != npc.id and is_alive(candidate)
1130
- ]
1131
- if not candidates:
1132
- return None
1133
- return min(candidates, key=lambda candidate: (candidate[0], candidate[1]))[2]
1134
-
1135
-
1136
- def _flee_directive(
1137
- world: WorldState,
1138
- npc: Npc,
1139
- threat: Npc,
1140
- directive: NpcDirective,
1141
- ) -> NpcDirective:
1142
- half_width = world.terrain.width / 2
1143
- half_depth = world.terrain.depth / 2
1144
- return NpcDirective(
1145
- npc_id=npc.id,
1146
- action="walk",
1147
- target=walk_away_target(npc, threat, half_width=half_width, half_depth=half_depth),
1148
- target_npc_id=threat.id,
1149
- message=directive.message,
1150
- memory=f"You backed away from {threat.name} instead of ordinary small talk.",
1151
- intent="flee_from_threat",
1152
- confidence=directive.confidence,
1153
- )
1154
-
1155
-
1156
- def _idle_directive(
1157
- directive: NpcDirective,
1158
- *,
1159
- memory: str | None,
1160
- intent: str,
1161
- ) -> NpcDirective:
1162
- return NpcDirective(
1163
- npc_id=directive.npc_id,
1164
- action="idle",
1165
- target=None,
1166
- target_npc_id=directive.target_npc_id,
1167
- message=directive.message,
1168
- memory=memory,
1169
- intent=intent,
1170
- confidence=directive.confidence,
1171
- )
1172
-
1173
-
1174
  def _validation_result(
1175
  original: NpcDirective,
1176
  resolved: NpcDirective,
@@ -1180,7 +696,7 @@ def _validation_result(
1180
  ) -> ValidationResult:
1181
  return ValidationResult(
1182
  valid=valid,
1183
- original_action=original.action,
1184
  resolved_action=resolved.action,
1185
  reason=reason,
1186
  directive=resolved,
 
1
  from __future__ import annotations
2
 
 
3
  import math
4
  import random
5
+ from dataclasses import asdict, replace
6
 
7
  from god_simulator.domain import CitizenGoal, Npc, Vec3, WorldState
8
+ from god_simulator.simulation.actions import PRIMITIVE_ACTION_IDS
9
+ from god_simulator.simulation.autopilot import (
10
+ autopilot_directive,
11
+ flee_directive,
12
+ idle_directive,
13
+ move_toward_target_directive,
14
+ target_from_directive_or_goal,
15
+ )
16
  from god_simulator.simulation.connectors.base import (
17
  ActionDebugTrace,
 
18
  NpcDirective,
19
  ResolutionResult,
20
  TickPlan,
 
22
  WorldSimulator,
23
  )
24
  from god_simulator.simulation.connectors.deterministic import DeterministicWorldSimulator
25
+ from god_simulator.simulation.goals import refresh_citizen_motivation
 
 
 
26
  from god_simulator.simulation.mechanics import (
27
  ATTACK_RADIUS,
28
  BLOCK_SIZE,
 
32
  distance_between,
33
  has_hostile_intent,
34
  is_alive,
 
 
 
35
  )
36
  from god_simulator.simulation.memory import remember
37
+ from god_simulator.simulation.perception import CitizenPerception
38
  from god_simulator.simulation.survival import (
39
  add_episode,
40
  apply_survival_plan,
 
82
  half_width = world.terrain.width / 2
83
  half_depth = world.terrain.depth / 2
84
  npcs_by_id = {npc.id: npc for npc in world.npcs}
85
+ move_targets = _resolve_move_targets(
86
  world,
87
  plan.directives,
88
  half_width=half_width,
 
106
  continue
107
 
108
  match directive.action:
109
+ case "move":
110
+ resolution = _apply_move(
111
+ npc,
112
+ directive,
113
+ move_targets.get(npc.id) if directive.target else None,
114
+ npcs_by_id,
115
+ next_tick,
116
+ )
117
+ case "speak":
118
+ resolution = _apply_speak(npc, directive, npcs_by_id, next_tick)
119
+ case "strike":
120
+ resolution = _apply_strike(npc, directive, npcs_by_id, world, next_tick)
 
 
 
121
  case "idle":
122
+ resolution = _apply_idle(npc, directive)
123
  case _:
124
  resolution = ResolutionResult(
125
  npc_id=directive.npc_id,
 
175
  npc: Npc | None,
176
  directive: NpcDirective,
177
  ) -> ValidationResult:
178
+ """Validate or repair one directive before engine execution.
179
+
180
+ Only physical impossibility is repaired here (missing or dead targets,
181
+ out-of-range interactions, unknown actions). Behavioral plausibility is
182
+ the planner's job: any physically possible primitive is executed as asked.
183
+ """
184
  if npc is None or npc.id != directive.npc_id:
185
+ resolved = idle_directive(
186
  directive,
187
  memory="Action ignored because the actor was unavailable.",
188
  intent="invalid_actor",
189
  )
190
+ return _validation_result(directive, resolved, valid=False, reason="actor unavailable")
 
 
 
 
 
191
 
192
  if not is_alive(npc):
193
+ resolved = idle_directive(directive, memory=None, intent="actor_dead")
194
+ return _validation_result(directive, resolved, valid=False, reason="actor dead")
 
 
 
 
 
 
 
 
 
195
 
196
  perception, goal = refresh_citizen_motivation(world, npc)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
197
 
198
+ if directive.action not in PRIMITIVE_ACTION_IDS:
199
+ fallback = autopilot_directive(world, npc, directive, perception, goal)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  return _validation_result(
201
  directive,
202
  fallback,
203
  valid=False,
204
+ reason=f"unknown action {directive.action!r} replaced by autopilot",
 
 
 
 
 
 
 
 
 
 
 
 
205
  )
206
 
207
  match directive.action:
208
  case "idle":
209
  return _validation_result(directive, directive, valid=True, reason="idle accepted")
210
+ case "move":
211
+ return _validate_move(world, npc, directive, perception, goal)
212
+ case "speak":
213
+ return _validate_speak(world, npc, directive)
214
+ case "strike":
215
+ return _validate_strike(world, npc, directive)
216
+ case _:
217
+ # use/transfer have no effects outside survival worlds.
218
+ resolved = idle_directive(
219
+ directive,
220
+ memory="There was nothing here to use or trade.",
221
+ intent="observe",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  )
223
+ return _validation_result(
224
+ directive,
225
+ resolved,
226
+ valid=False,
227
+ reason=f"{directive.action} has no effect outside survival worlds",
 
 
 
 
 
 
 
 
 
 
 
228
  )
229
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
 
231
+ def _validate_move(
 
232
  world: WorldState,
233
  npc: Npc,
234
  directive: NpcDirective,
235
  perception: CitizenPerception,
236
  goal: CitizenGoal | None,
237
+ ) -> ValidationResult:
238
+ if directive.away:
239
+ threat = target_from_directive_or_goal(world, npc, directive, goal, perception)
240
+ if threat is None:
241
+ resolved = idle_directive(
 
 
 
242
  directive,
243
+ memory="You braced because no visible threat was left to flee from.",
244
  intent="defend",
245
  )
246
+ return _validation_result(
 
247
  directive,
248
+ resolved,
249
+ valid=False,
250
+ reason="move away had no resolvable threat",
251
  )
252
+ return _validation_result(
253
+ directive,
254
+ flee_directive(world, npc, threat, directive),
255
+ valid=True,
256
+ reason="move away resolved against nearest threat",
257
+ )
258
+
259
+ if directive.target_npc_id:
260
+ target = _npc_by_id(world, directive.target_npc_id)
261
+ if target is None or target.id == npc.id or not is_alive(target):
262
+ resolved = idle_directive(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
263
  directive,
264
+ memory="You could not move toward the target because it was unavailable.",
265
+ intent="invalid_move_target",
266
  )
267
+ return _validation_result(
 
 
 
 
 
 
 
 
 
 
 
268
  directive,
269
+ resolved,
270
+ valid=False,
271
+ reason="move target unavailable",
272
  )
273
+ return _validation_result(
274
+ directive,
275
+ move_toward_target_directive(
276
+ npc,
277
  directive,
278
+ target,
279
+ intent=_move_intent(goal, target),
280
+ ),
281
+ valid=True,
282
+ reason="move toward NPC resolved to their position",
283
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
+ if directive.target is None:
286
+ resolved = idle_directive(directive, memory=None, intent="missing_move_target")
287
+ return _validation_result(directive, resolved, valid=False, reason="move target missing")
 
288
 
289
+ resolved = replace(directive, target=_bounded_target(world, directive.target))
290
+ return _validation_result(directive, resolved, valid=True, reason="move accepted")
 
291
 
 
 
292
 
293
+ def _validate_speak(world: WorldState, npc: Npc, directive: NpcDirective) -> ValidationResult:
294
+ if not directive.target_npc_id:
295
+ # An untargeted speak is a shout heard by everyone within earshot.
296
+ return _validation_result(
297
+ directive,
298
+ directive,
299
+ valid=True,
300
+ reason="broadcast speech accepted",
301
+ )
302
 
303
+ target = _npc_by_id(world, directive.target_npc_id)
304
+ if target is None or target.id == npc.id or not is_alive(target):
305
+ resolved = idle_directive(
306
+ directive,
307
+ memory="You could not speak because the listener was unavailable.",
308
+ intent="invalid_speak_target",
309
+ )
310
+ return _validation_result(
311
+ directive,
312
+ resolved,
313
+ valid=False,
314
+ reason="speak target unavailable",
315
+ )
316
 
317
+ if distance_between(npc, target) > TALK_RADIUS:
318
+ resolved = NpcDirective(
 
 
 
 
 
 
 
319
  npc_id=npc.id,
320
+ action="move",
321
+ target=target.position,
322
  target_npc_id=target.id,
323
  message=directive.message,
324
+ memory=f"You moved closer to speak with {target.name}.",
325
+ intent="approach_target_for_talk",
326
  confidence=directive.confidence,
327
  )
328
+ return _validation_result(
329
+ directive,
330
+ resolved,
331
+ valid=True,
332
+ reason="out-of-range speech converted to approach movement",
333
+ )
 
334
 
335
+ return _validation_result(directive, directive, valid=True, reason="speak accepted")
336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
 
338
+ def _validate_strike(world: WorldState, npc: Npc, directive: NpcDirective) -> ValidationResult:
339
+ target = _npc_by_id(world, directive.target_npc_id or "")
340
+ if target is None or target.id == npc.id or not is_alive(target):
341
+ resolved = idle_directive(
342
+ directive,
343
+ memory="You could not strike because the target was unavailable.",
344
+ intent="invalid_strike_target",
345
+ )
346
+ return _validation_result(
347
+ directive,
348
+ resolved,
349
+ valid=False,
350
+ reason="strike target unavailable",
351
+ )
352
 
353
+ if distance_between(npc, target) > ATTACK_RADIUS:
354
+ resolved = NpcDirective(
 
 
355
  npc_id=npc.id,
356
+ action="move",
357
+ target=target.position,
358
  target_npc_id=target.id,
359
+ message=directive.message,
360
+ memory=f"You moved toward {target.name} to get within striking range.",
361
+ intent="approach_target_for_attack",
362
  confidence=directive.confidence,
363
  )
364
+ return _validation_result(
365
+ directive,
366
+ resolved,
367
+ valid=True,
368
+ reason="out-of-range strike converted to approach movement",
369
+ )
370
 
371
+ return _validation_result(directive, directive, valid=True, reason="strike accepted")
372
 
373
+
374
+ def _move_intent(goal: CitizenGoal | None, target: Npc) -> str:
375
+ if (
376
+ goal is not None
377
+ and goal.goal_type in ("pursue_and_attack_target", "retaliate")
378
+ and goal.target_npc_id == target.id
379
+ ):
380
+ return "approach_target_for_attack"
381
+ return "move_to_target"
 
 
 
 
382
 
383
 
384
  def _npc_by_id(world: WorldState, npc_id: str) -> Npc | None:
385
  return next((candidate for candidate in world.npcs if candidate.id == npc_id), None)
386
 
387
 
388
+ def _resolve_move_targets(
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
389
  world: WorldState,
390
  directives: list[NpcDirective],
391
  *,
 
396
  targets: dict[str, Vec3] = {}
397
 
398
  for directive in directives:
399
+ if directive.action != "move" or not directive.target:
400
  continue
401
 
402
  npc = npcs_by_id.get(directive.npc_id)
403
  if not npc or not is_alive(npc):
404
  continue
405
 
406
+ targets[npc.id] = _planned_move_target(
407
  npc,
408
  directive.target,
409
  half_width=half_width,
 
413
  return targets
414
 
415
 
416
+ def _planned_move_target(
417
  npc: Npc,
418
  raw_target: Vec3,
419
  *,
 
438
  )
439
 
440
 
441
+ def _apply_move(
442
  npc: Npc,
443
  directive: NpcDirective,
444
  target: Vec3 | None,
 
490
  )
491
 
492
 
493
+ def _apply_speak(
494
  npc: Npc,
495
  directive: NpcDirective,
496
  npcs_by_id: dict[str, Npc],
497
  next_tick: int,
498
  ) -> ResolutionResult:
499
+ if not directive.target_npc_id:
500
+ return _apply_broadcast_speech(npc, directive, npcs_by_id, next_tick)
501
+
502
+ target = npcs_by_id.get(directive.target_npc_id)
503
  if not target or target.id == npc.id or not is_alive(target):
504
+ npc.intention = "could not speak"
505
+ remember(npc, next_tick, "You could not speak because the listener was unavailable.")
506
  return ResolutionResult(
507
  npc_id=npc.id,
508
  action=directive.action,
 
511
  )
512
 
513
  if distance_between(npc, target) > TALK_RADIUS:
514
+ npc.intention = f"too far to speak with {target.name}"
515
+ remember(npc, next_tick, f"You were too far away to speak with {target.name}.")
516
  return ResolutionResult(
517
  npc_id=npc.id,
518
  action=directive.action,
 
521
  )
522
 
523
  message = directive.message or "checking in"
524
+ npc.intention = f"talking to {target.name}"
 
 
 
 
 
525
  add_episode(
526
  npc,
527
  next_tick,
 
556
  )
557
 
558
 
559
+ def _apply_broadcast_speech(
560
+ npc: Npc,
561
+ directive: NpcDirective,
562
+ npcs_by_id: dict[str, Npc],
563
+ next_tick: int,
564
+ ) -> ResolutionResult:
565
+ is_help_request = directive.communication_intent == "help_request"
566
+ message = directive.message or ("Help! I am in danger!" if is_help_request else "Hey!")
567
+ npc.intention = "calling for help" if is_help_request else "shouting"
568
+ tags = ["help", "danger"] if is_help_request else ["social"]
569
+ add_episode(
570
+ npc,
571
+ next_tick,
572
+ "communicate",
573
+ npc.id,
574
+ f"I shouted: {message}",
575
+ subject_kind="npc",
576
+ perspective="self",
577
+ tags=tags,
578
+ weight=0.6 if is_help_request else 0.3,
579
+ )
580
+ _remember_nearby_citizens(
581
+ actor=npc,
582
+ npcs_by_id=npcs_by_id,
583
+ next_tick=next_tick,
584
+ text=f"{npc.name} shouted: {message}",
585
+ kind="communicate",
586
+ tags=tags,
587
+ )
588
+ remember(npc, next_tick, f"You shouted: {message}")
589
+ return ResolutionResult(
590
+ npc_id=npc.id,
591
+ action=directive.action,
592
+ success=True,
593
+ summary=npc.intention,
594
+ )
595
+
596
+
597
+ def _apply_strike(
598
  npc: Npc,
599
  directive: NpcDirective,
600
  npcs_by_id: dict[str, Npc],
 
626
  damage = npc.attack_damage + rng.randint(0, 5)
627
  target.health -= damage
628
  npc.intention = f"attacking {target.name}"
629
+ if has_hostile_intent(npc) or directive.intent == "hostile_attack":
630
  npc.mode = "hostile_pursuit"
631
  target.mode = "threatened"
632
  target.needs.safety = max(target.needs.safety, 100)
 
671
  )
672
 
673
 
674
+ def _apply_idle(npc: Npc, directive: NpcDirective) -> ResolutionResult:
 
 
 
 
 
675
  match directive.intent:
676
  case "defend":
677
  npc.intention = "defending"
678
  case "observe":
679
  npc.intention = "observing"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
680
  case _:
681
  npc.intention = "idle"
682
  return ResolutionResult(
 
687
  )
688
 
689
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
  def _validation_result(
691
  original: NpcDirective,
692
  resolved: NpcDirective,
 
696
  ) -> ValidationResult:
697
  return ValidationResult(
698
  valid=valid,
699
+ original_action=str(original.action),
700
  resolved_action=resolved.action,
701
  reason=reason,
702
  directive=resolved,
tests/test_action_pipeline.py CHANGED
@@ -4,16 +4,16 @@ from typing import Any, cast
4
 
5
  from god_simulator.config import GameConfig, NpcConfig, ServerConfig, SimulationConfig, WorldConfig
6
  from god_simulator.domain import Vec3
7
- from god_simulator.simulation.actions import build_action_mask
8
  from god_simulator.simulation.connectors.base import NpcDirective, TickPlan
9
- from god_simulator.simulation.mechanics import MAX_WALK_DISTANCE, distance_between
10
  from god_simulator.simulation.goals import refresh_citizen_motivation
 
11
  from god_simulator.simulation.perception import build_perception
12
  from god_simulator.simulation.spawning import create_world
13
  from god_simulator.simulation.tick import apply_tick_plan, validate_directive, validate_tick_plan
14
 
15
 
16
- def test_out_of_range_attack_becomes_movement_toward_target() -> None:
17
  world = create_world(_config(npc_count=2))
18
  ada, boris = world.npcs
19
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
@@ -26,15 +26,15 @@ def test_out_of_range_attack_becomes_movement_toward_target() -> None:
26
  directives=[
27
  NpcDirective(
28
  npc_id=ada.id,
29
- action="attack",
30
  target_npc_id=boris.id,
31
  )
32
  ],
33
  )
34
  resolved_plan, traces = validate_tick_plan(world, plan)
35
 
36
- assert traces[0].validation.original_action == "attack"
37
- assert traces[0].validation.resolved_action == "walk"
38
  assert traces[0].validation.directive.intent == "approach_target_for_attack"
39
 
40
  apply_tick_plan(world, 1, plan)
@@ -42,12 +42,11 @@ def test_out_of_range_attack_becomes_movement_toward_target() -> None:
42
  assert boris.health == 100
43
  assert 0 < distance_between(ada, boris) < starting_distance
44
  assert 0 < ada.position.x <= MAX_WALK_DISTANCE
45
- assert resolved_plan.directives[0].action == "walk"
46
  assert ada.intention == "approaching Boris to attack"
47
- assert "too far to attack" not in ada.intention
48
 
49
 
50
- def test_in_range_attack_applies_engine_damage_and_alerts_observer() -> None:
51
  world = create_world(_config(npc_count=3))
52
  ada, boris, cora = world.npcs
53
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
@@ -64,7 +63,7 @@ def test_in_range_attack_applies_engine_damage_and_alerts_observer() -> None:
64
  directives=[
65
  NpcDirective(
66
  npc_id=ada.id,
67
- action="attack",
68
  target_npc_id=boris.id,
69
  )
70
  ],
@@ -77,12 +76,12 @@ def test_in_range_attack_applies_engine_damage_and_alerts_observer() -> None:
77
  assert any("Ada attacked Boris" in memory.text for memory in cora.memory)
78
 
79
 
80
- def test_hostile_idle_falls_back_to_nearest_target_pursuit() -> None:
81
- world = create_world(_config(npc_count=3))
82
- ada, boris, cora = world.npcs
 
83
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
84
  boris.position = Vec3(x=20.0, y=0.0, z=0.0)
85
- cora.position = Vec3(x=8.0, y=0.0, z=0.0)
86
  ada.god_directive = "Kill everyone."
87
 
88
  apply_tick_plan(
@@ -90,28 +89,23 @@ def test_hostile_idle_falls_back_to_nearest_target_pursuit() -> None:
90
  1,
91
  TickPlan(
92
  source="test",
93
- directives=[
94
- NpcDirective(
95
- npc_id=ada.id,
96
- action="idle",
97
- )
98
- ],
99
  ),
100
  )
101
 
102
  assert boris.health == 100
103
- assert cora.health == 100
104
- assert ada.intention == "approaching Cora to attack"
105
- assert distance_between(ada, cora) < 8.0
106
 
107
 
108
- def test_ordinary_talk_with_hostile_attacker_becomes_flee() -> None:
 
 
109
  world = create_world(_config(npc_count=2))
110
  ada, boris = world.npcs
111
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
112
  boris.position = Vec3(x=1.0, y=0.0, z=0.0)
113
  ada.god_directive = "Kill everyone."
114
- starting_distance = distance_between(boris, ada)
115
 
116
  apply_tick_plan(
117
  world,
@@ -121,25 +115,24 @@ def test_ordinary_talk_with_hostile_attacker_becomes_flee() -> None:
121
  directives=[
122
  NpcDirective(
123
  npc_id=boris.id,
124
- action="talk",
125
  target_npc_id=ada.id,
126
- message="Nice weather today.",
127
  )
128
  ],
129
  ),
130
  )
131
 
132
- assert boris.intention == "fleeing from Ada"
133
- assert distance_between(boris, ada) > starting_distance
134
- assert not any(memory.text == "Boris told you: Nice weather today." for memory in ada.memory)
135
 
136
 
137
- def test_crisis_talk_with_hostile_attacker_is_allowed() -> None:
138
- world = create_world(_config(npc_count=2))
139
- ada, boris = world.npcs
140
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
141
- boris.position = Vec3(x=1.0, y=0.0, z=0.0)
142
- ada.god_directive = "Kill everyone."
143
 
144
  apply_tick_plan(
145
  world,
@@ -148,18 +141,39 @@ def test_crisis_talk_with_hostile_attacker_is_allowed() -> None:
148
  source="test",
149
  directives=[
150
  NpcDirective(
151
- npc_id=boris.id,
152
- action="talk",
153
- target_npc_id=ada.id,
154
- message="Please stop.",
155
  )
156
  ],
157
  ),
158
  )
159
 
160
- assert boris.intention == "pleading with Ada"
161
- assert "Please stop." in boris.memory[-1].text
162
- assert "Please stop." in ada.memory[-1].text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
 
164
 
165
  def test_hostile_directive_selects_pursue_goal() -> None:
@@ -184,7 +198,7 @@ def test_hostile_directive_selects_pursue_goal() -> None:
184
  assert next_goal.target_npc_id == cora.id
185
 
186
 
187
- def test_hostile_goal_forbids_weather_talk() -> None:
188
  world = create_world(_config(npc_count=2))
189
  ada, boris = world.npcs
190
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
@@ -192,60 +206,26 @@ def test_hostile_goal_forbids_weather_talk() -> None:
192
  ada.god_directive = "Kill Boris."
193
 
194
  perception, goal = refresh_citizen_motivation(world, ada)
195
- mask = build_action_mask(world, ada, perception, goal)
196
  validation = validate_directive(
197
  world,
198
  ada,
199
  NpcDirective(
200
  npc_id=ada.id,
201
- action="talk",
202
  target_npc_id=boris.id,
203
  message="What's the weather like today?",
204
  ),
205
  )
206
 
207
- assert mask.allowed_actions == ["move_to_target", "search_target", "threaten"]
208
- assert "weather_talk" in mask.forbidden_actions
209
- assert validation.valid is False
210
- assert validation.directive.action == "walk"
211
- assert validation.directive.intent == "approach_target_for_attack"
212
-
213
-
214
- def test_hostile_talk_fallbacks_to_attack_or_move() -> None:
215
- world = create_world(_config(npc_count=2))
216
- ada, boris = world.npcs
217
- ada.god_directive = "Kill Boris."
218
- ada.position = Vec3(x=0.0, y=0.0, z=0.0)
219
- boris.position = Vec3(x=16.0, y=0.0, z=0.0)
220
-
221
- far = validate_directive(
222
- world,
223
- ada,
224
- NpcDirective(
225
- npc_id=ada.id,
226
- action="talk",
227
- target_npc_id=boris.id,
228
- message="Is the weather nice today?",
229
- ),
230
- )
231
-
232
- boris.position = Vec3(x=1.0, y=0.0, z=0.0)
233
- close = validate_directive(
234
- world,
235
- ada,
236
- NpcDirective(
237
- npc_id=ada.id,
238
- action="talk",
239
- target_npc_id=boris.id,
240
- message="I'm fine, thanks.",
241
- ),
242
- )
243
-
244
- assert far.directive.action == "walk"
245
- assert close.directive.action == "attack"
246
 
247
 
248
- def test_threatened_victim_gets_survival_action_mask() -> None:
249
  world = create_world(_config(npc_count=3))
250
  ada, boris, cora = world.npcs
251
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
@@ -255,17 +235,15 @@ def test_threatened_victim_gets_survival_action_mask() -> None:
255
 
256
  boris_perception, boris_goal = refresh_citizen_motivation(world, boris)
257
  cora_perception, cora_goal = refresh_citizen_motivation(world, cora)
258
- boris_mask = build_action_mask(world, boris, boris_perception, boris_goal)
259
- cora_mask = build_action_mask(world, cora, cora_perception, cora_goal)
260
 
261
  assert boris.mode == "threatened"
262
  assert cora.mode == "threatened"
263
  assert boris_goal is not None and boris_goal.goal_type == "survive"
264
  assert cora_goal is not None and cora_goal.goal_type == "survive"
265
- assert {"flee", "defend", "call_for_help", "plead", "attack_back"}.issubset(
266
- set(boris_mask.allowed_actions)
267
- )
268
- assert "talk" in cora_mask.forbidden_actions
269
 
270
 
271
  def test_routine_npc_can_small_talk() -> None:
@@ -279,17 +257,17 @@ def test_routine_npc_can_small_talk() -> None:
279
  ada,
280
  NpcDirective(
281
  npc_id=ada.id,
282
- action="talk",
283
  target_npc_id=boris.id,
284
  message="Nice weather today.",
285
  ),
286
  )
287
 
288
  assert validation.valid is True
289
- assert validation.directive.action == "talk"
290
 
291
 
292
- def test_invalid_action_fallback_depends_on_goal() -> None:
293
  world = create_world(_config(npc_count=2))
294
  ada, boris = world.npcs
295
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
@@ -308,9 +286,11 @@ def test_invalid_action_fallback_depends_on_goal() -> None:
308
  NpcDirective(npc_id=ada.id, action=cast(Any, "dance")),
309
  )
310
 
 
311
  assert routine.directive.action == "idle"
312
  assert routine.directive.intent == "observe"
313
- assert hostile.directive.action == "walk"
 
314
  assert hostile.directive.intent == "approach_target_for_attack"
315
 
316
 
@@ -330,7 +310,7 @@ def test_attack_memory_creates_threat_perception() -> None:
330
  directives=[
331
  NpcDirective(
332
  npc_id=ada.id,
333
- action="attack",
334
  target_npc_id=boris.id,
335
  )
336
  ],
@@ -344,6 +324,28 @@ def test_attack_memory_creates_threat_perception() -> None:
344
  assert cora_perception.nearby_threats[0]["npc_id"] == ada.id
345
 
346
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
  def _config(*, npc_count: int) -> GameConfig:
348
  return GameConfig(
349
  world=WorldConfig(width=80, depth=80, terrain="plain_green", seed=42),
 
4
 
5
  from god_simulator.config import GameConfig, NpcConfig, ServerConfig, SimulationConfig, WorldConfig
6
  from god_simulator.domain import Vec3
7
+ from god_simulator.simulation.actions import build_action_hints
8
  from god_simulator.simulation.connectors.base import NpcDirective, TickPlan
 
9
  from god_simulator.simulation.goals import refresh_citizen_motivation
10
+ from god_simulator.simulation.mechanics import MAX_WALK_DISTANCE, distance_between
11
  from god_simulator.simulation.perception import build_perception
12
  from god_simulator.simulation.spawning import create_world
13
  from god_simulator.simulation.tick import apply_tick_plan, validate_directive, validate_tick_plan
14
 
15
 
16
+ def test_out_of_range_strike_becomes_movement_toward_target() -> None:
17
  world = create_world(_config(npc_count=2))
18
  ada, boris = world.npcs
19
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
 
26
  directives=[
27
  NpcDirective(
28
  npc_id=ada.id,
29
+ action="strike",
30
  target_npc_id=boris.id,
31
  )
32
  ],
33
  )
34
  resolved_plan, traces = validate_tick_plan(world, plan)
35
 
36
+ assert traces[0].validation.original_action == "strike"
37
+ assert traces[0].validation.resolved_action == "move"
38
  assert traces[0].validation.directive.intent == "approach_target_for_attack"
39
 
40
  apply_tick_plan(world, 1, plan)
 
42
  assert boris.health == 100
43
  assert 0 < distance_between(ada, boris) < starting_distance
44
  assert 0 < ada.position.x <= MAX_WALK_DISTANCE
45
+ assert resolved_plan.directives[0].action == "move"
46
  assert ada.intention == "approaching Boris to attack"
 
47
 
48
 
49
+ def test_in_range_strike_applies_engine_damage_and_alerts_observer() -> None:
50
  world = create_world(_config(npc_count=3))
51
  ada, boris, cora = world.npcs
52
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
 
63
  directives=[
64
  NpcDirective(
65
  npc_id=ada.id,
66
+ action="strike",
67
  target_npc_id=boris.id,
68
  )
69
  ],
 
76
  assert any("Ada attacked Boris" in memory.text for memory in cora.memory)
77
 
78
 
79
+ def test_explicit_idle_is_respected_even_with_hostile_directive() -> None:
80
+ """The engine no longer overrides physically possible choices."""
81
+ world = create_world(_config(npc_count=2))
82
+ ada, boris = world.npcs
83
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
84
  boris.position = Vec3(x=20.0, y=0.0, z=0.0)
 
85
  ada.god_directive = "Kill everyone."
86
 
87
  apply_tick_plan(
 
89
  1,
90
  TickPlan(
91
  source="test",
92
+ directives=[NpcDirective(npc_id=ada.id, action="idle")],
 
 
 
 
 
93
  ),
94
  )
95
 
96
  assert boris.health == 100
97
+ assert ada.intention == "idle"
98
+ assert ada.position == Vec3(x=0.0, y=0.0, z=0.0)
 
99
 
100
 
101
+ def test_speak_with_hostile_attacker_is_respected() -> None:
102
+ """Behavioral plausibility is the planner's call: small talk under threat
103
+ executes instead of being converted into fleeing."""
104
  world = create_world(_config(npc_count=2))
105
  ada, boris = world.npcs
106
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
107
  boris.position = Vec3(x=1.0, y=0.0, z=0.0)
108
  ada.god_directive = "Kill everyone."
 
109
 
110
  apply_tick_plan(
111
  world,
 
115
  directives=[
116
  NpcDirective(
117
  npc_id=boris.id,
118
+ action="speak",
119
  target_npc_id=ada.id,
120
+ message="Please stop, take my food instead.",
121
  )
122
  ],
123
  ),
124
  )
125
 
126
+ assert boris.intention == "talking to Ada"
127
+ assert "Please stop, take my food instead." in ada.memory[-1].text
 
128
 
129
 
130
+ def test_broadcast_speak_reaches_nearby_citizens() -> None:
131
+ world = create_world(_config(npc_count=3))
132
+ ada, boris, cora = world.npcs
133
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
134
+ boris.position = Vec3(x=2.0, y=0.0, z=0.0)
135
+ cora.position = Vec3(x=0.0, y=0.0, z=2.0)
136
 
137
  apply_tick_plan(
138
  world,
 
141
  source="test",
142
  directives=[
143
  NpcDirective(
144
+ npc_id=ada.id,
145
+ action="speak",
146
+ message="Help! I am in danger!",
147
+ communication_intent="help_request",
148
  )
149
  ],
150
  ),
151
  )
152
 
153
+ assert ada.intention == "calling for help"
154
+ assert any("Ada shouted" in memory.text for memory in boris.memory)
155
+ assert any("Ada shouted" in memory.text for memory in cora.memory)
156
+
157
+
158
+ def test_move_away_resolves_against_nearest_threat() -> None:
159
+ world = create_world(_config(npc_count=2))
160
+ ada, boris = world.npcs
161
+ ada.position = Vec3(x=0.0, y=0.0, z=0.0)
162
+ boris.position = Vec3(x=1.0, y=0.0, z=0.0)
163
+ ada.god_directive = "Kill everyone."
164
+ starting_distance = distance_between(boris, ada)
165
+
166
+ apply_tick_plan(
167
+ world,
168
+ 1,
169
+ TickPlan(
170
+ source="test",
171
+ directives=[NpcDirective(npc_id=boris.id, action="move", away=True)],
172
+ ),
173
+ )
174
+
175
+ assert boris.intention == "fleeing from Ada"
176
+ assert distance_between(boris, ada) > starting_distance
177
 
178
 
179
  def test_hostile_directive_selects_pursue_goal() -> None:
 
198
  assert next_goal.target_npc_id == cora.id
199
 
200
 
201
+ def test_hostile_goal_yields_hints_but_forbids_nothing() -> None:
202
  world = create_world(_config(npc_count=2))
203
  ada, boris = world.npcs
204
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
 
206
  ada.god_directive = "Kill Boris."
207
 
208
  perception, goal = refresh_citizen_motivation(world, ada)
209
+ hints = build_action_hints(world, ada, perception, goal)
210
  validation = validate_directive(
211
  world,
212
  ada,
213
  NpcDirective(
214
  npc_id=ada.id,
215
+ action="speak",
216
  target_npc_id=boris.id,
217
  message="What's the weather like today?",
218
  ),
219
  )
220
 
221
+ assert hints.suggested_actions[0] == "move"
222
+ assert hints.reason
223
+ # Physically possible speech stays speech (here: target in talk range).
224
+ assert validation.valid is True
225
+ assert validation.directive.action == "speak"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
 
227
 
228
+ def test_threatened_victim_gets_survival_hints() -> None:
229
  world = create_world(_config(npc_count=3))
230
  ada, boris, cora = world.npcs
231
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
 
235
 
236
  boris_perception, boris_goal = refresh_citizen_motivation(world, boris)
237
  cora_perception, cora_goal = refresh_citizen_motivation(world, cora)
238
+ boris_hints = build_action_hints(world, boris, boris_perception, boris_goal)
239
+ cora_hints = build_action_hints(world, cora, cora_perception, cora_goal)
240
 
241
  assert boris.mode == "threatened"
242
  assert cora.mode == "threatened"
243
  assert boris_goal is not None and boris_goal.goal_type == "survive"
244
  assert cora_goal is not None and cora_goal.goal_type == "survive"
245
+ assert {"move", "speak", "strike"}.issubset(set(boris_hints.suggested_actions))
246
+ assert cora_hints.suggested_actions[0] == "move"
 
 
247
 
248
 
249
  def test_routine_npc_can_small_talk() -> None:
 
257
  ada,
258
  NpcDirective(
259
  npc_id=ada.id,
260
+ action="speak",
261
  target_npc_id=boris.id,
262
  message="Nice weather today.",
263
  ),
264
  )
265
 
266
  assert validation.valid is True
267
+ assert validation.directive.action == "speak"
268
 
269
 
270
+ def test_unknown_action_autopilot_depends_on_goal() -> None:
271
  world = create_world(_config(npc_count=2))
272
  ada, boris = world.npcs
273
  ada.position = Vec3(x=0.0, y=0.0, z=0.0)
 
286
  NpcDirective(npc_id=ada.id, action=cast(Any, "dance")),
287
  )
288
 
289
+ assert routine.valid is False
290
  assert routine.directive.action == "idle"
291
  assert routine.directive.intent == "observe"
292
+ assert hostile.valid is False
293
+ assert hostile.directive.action == "move"
294
  assert hostile.directive.intent == "approach_target_for_attack"
295
 
296
 
 
310
  directives=[
311
  NpcDirective(
312
  npc_id=ada.id,
313
+ action="strike",
314
  target_npc_id=boris.id,
315
  )
316
  ],
 
324
  assert cora_perception.nearby_threats[0]["npc_id"] == ada.id
325
 
326
 
327
+ def test_out_of_range_speak_becomes_approach() -> None:
328
+ world = create_world(_config(npc_count=2))
329
+ ada, boris = world.npcs
330
+ ada.position = Vec3(x=0.0, y=0.0, z=0.0)
331
+ boris.position = Vec3(x=20.0, y=0.0, z=0.0)
332
+
333
+ validation = validate_directive(
334
+ world,
335
+ ada,
336
+ NpcDirective(
337
+ npc_id=ada.id,
338
+ action="speak",
339
+ target_npc_id=boris.id,
340
+ message="Hello over there!",
341
+ ),
342
+ )
343
+
344
+ assert validation.valid is True
345
+ assert validation.directive.action == "move"
346
+ assert validation.directive.intent == "approach_target_for_talk"
347
+
348
+
349
  def _config(*, npc_count: int) -> GameConfig:
350
  return GameConfig(
351
  world=WorldConfig(width=80, depth=80, terrain="plain_green", seed=42),
tests/test_npc_simulation_v2.py CHANGED
@@ -10,8 +10,8 @@ from god_simulator.domain import (
10
  WorldEvent,
11
  WorldState,
12
  )
13
- from god_simulator.simulation.mechanics import BLOCK_SIZE
14
  from god_simulator.simulation.connectors.base import NpcDirective
 
15
  from god_simulator.simulation.survival import (
16
  add_episode,
17
  apply_action_effects,
@@ -154,7 +154,12 @@ def test_relationship_updates_on_steal() -> None:
154
  victim = _npc("npc-002", "Boris", x=2.0, inventory_food=1)
155
  world = _world([thief, victim])
156
 
157
- summary = apply_action_effects(thief, "steal", world)
 
 
 
 
 
158
 
159
  assert summary == "stealing from Boris"
160
  assert victim.relationships[thief.id] == -0.5
@@ -199,11 +204,11 @@ def test_call_for_help_writes_communicate_episode_without_trust_gain() -> None:
199
 
200
  summary = apply_action_effects(
201
  caller,
202
- "communicate",
203
  world,
204
  directive=NpcDirective(
205
  npc_id=caller.id,
206
- action="communicate",
207
  communication_intent="help_request",
208
  ),
209
  )
 
10
  WorldEvent,
11
  WorldState,
12
  )
 
13
  from god_simulator.simulation.connectors.base import NpcDirective
14
+ from god_simulator.simulation.mechanics import BLOCK_SIZE
15
  from god_simulator.simulation.survival import (
16
  add_episode,
17
  apply_action_effects,
 
154
  victim = _npc("npc-002", "Boris", x=2.0, inventory_food=1)
155
  world = _world([thief, victim])
156
 
157
+ summary = apply_action_effects(
158
+ thief,
159
+ "transfer",
160
+ world,
161
+ directive=NpcDirective(npc_id=thief.id, action="transfer", take=True),
162
+ )
163
 
164
  assert summary == "stealing from Boris"
165
  assert victim.relationships[thief.id] == -0.5
 
204
 
205
  summary = apply_action_effects(
206
  caller,
207
+ "speak",
208
  world,
209
  directive=NpcDirective(
210
  npc_id=caller.id,
211
+ action="speak",
212
  communication_intent="help_request",
213
  ),
214
  )
tests/test_openai_connector.py CHANGED
@@ -1,4 +1,4 @@
1
- from __future__ import annotations
2
 
3
  import json
4
  from threading import Lock
@@ -41,7 +41,7 @@ def test_openai_connector_uses_tool_calls_for_npc_directives() -> None:
41
  {
42
  "type": "function",
43
  "function": {
44
- "name": "walk",
45
  "arguments": json.dumps(
46
  {
47
  "npc_id": npc_id,
@@ -75,19 +75,23 @@ def test_openai_connector_uses_tool_calls_for_npc_directives() -> None:
75
  assert first_request["max_tokens"] == 1400
76
  assert first_request["temperature"] == 0.6
77
  assert first_request["top_p"] == 0.95
78
- assert [tool["function"]["name"] for tool in first_request["tools"]] == ["choose_action"]
79
- action_schema = first_request["tools"][0]["function"]["parameters"]["properties"]["action"]
80
- assert action_schema["enum"] == ["walk", "talk", "observe"]
81
- assert "memory" not in first_request["tools"][0]["function"]["parameters"]["properties"]
 
 
 
 
82
  payload = json.loads(first_request["messages"][1]["content"])
83
  assert "npcs" not in payload
84
  assert payload["npc"]["id"] == "npc-001"
85
  assert payload["npc"]["health"] == 100
86
  assert "attack_damage" in payload["npc"]
87
  assert payload["current_goal"]["goal_type"] == "routine_life"
88
- assert payload["allowed_actions"] == ["walk", "talk", "observe"]
89
- assert "forbidden_actions" in payload
90
- assert "fallback_json_schema" not in payload
91
  assert world.npcs[0].position.x == 6
92
  assert world.npcs[0].position.z == 2
93
  assert world.npcs[0].intention == "walking"
@@ -111,7 +115,7 @@ def test_openai_connector_uses_configured_max_tokens() -> None:
111
  {
112
  "type": "function",
113
  "function": {
114
- "name": "walk",
115
  "arguments": json.dumps(
116
  {
117
  "npc_id": npc_id,
@@ -166,7 +170,7 @@ def test_openai_connector_calls_llm_on_every_tick() -> None:
166
  {
167
  "type": "function",
168
  "function": {
169
- "name": "walk",
170
  "arguments": json.dumps(
171
  {
172
  "npc_id": npc_id,
@@ -216,7 +220,7 @@ def test_openai_connector_sends_only_one_agent_state_per_request() -> None:
216
  {
217
  "type": "function",
218
  "function": {
219
- "name": "walk",
220
  "arguments": json.dumps(
221
  {
222
  "npc_id": npc_id,
@@ -291,7 +295,7 @@ def test_openai_connector_calls_individual_agents_in_parallel() -> None:
291
  {
292
  "type": "function",
293
  "function": {
294
- "name": "walk",
295
  "arguments": json.dumps(
296
  {
297
  "npc_id": npc_id,
@@ -350,14 +354,14 @@ def test_openai_connector_keeps_valid_tool_calls_when_one_is_malformed() -> None
350
  {
351
  "type": "function",
352
  "function": {
353
- "name": "walk",
354
  "arguments": '{"npc_id":',
355
  },
356
  },
357
  {
358
  "type": "function",
359
  "function": {
360
- "name": "talk",
361
  "arguments": json.dumps(
362
  {
363
  "npc_id": "npc-002",
@@ -413,26 +417,24 @@ def test_openai_connector_exposes_nearby_threat_context() -> None:
413
  assert cora_context["nearby_threats"][0]["npc_id"] == ada.id
414
 
415
 
416
- def test_openai_payload_contains_goal_and_allowed_actions() -> None:
417
  captured_payloads: list[dict[str, Any]] = []
418
  captured_requests: list[dict[str, Any]] = []
419
 
420
  def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
 
421
  captured_requests.append(request)
422
- payload = json.loads(request["messages"][1]["content"])
423
- captured_payloads.append(payload)
424
  npc_id = payload["npc"]["id"]
425
- if "move_to_target" in payload["allowed_actions"]:
 
426
  arguments = {
427
  "npc_id": npc_id,
428
- "action": "move_to_target",
429
- "target_npc_id": payload["current_goal"]["target_npc_id"],
430
  }
431
  else:
432
- arguments = {
433
- "npc_id": npc_id,
434
- "action": "observe",
435
- }
436
  return {
437
  "choices": [
438
  {
@@ -441,7 +443,7 @@ def test_openai_payload_contains_goal_and_allowed_actions() -> None:
441
  {
442
  "type": "function",
443
  "function": {
444
- "name": "choose_action",
445
  "arguments": json.dumps(arguments),
446
  },
447
  }
@@ -470,14 +472,15 @@ def test_openai_payload_contains_goal_and_allowed_actions() -> None:
470
 
471
  ada_payload = next(payload for payload in captured_payloads if payload["npc"]["id"] == ada.id)
472
  ada_request = captured_requests[captured_payloads.index(ada_payload)]
473
- action_schema = ada_request["tools"][0]["function"]["parameters"]["properties"]["action"]
474
 
475
  assert ada_payload["current_goal"]["goal_type"] == "pursue_and_attack_target"
476
  assert ada_payload["current_goal"]["target_npc_id"] == boris.id
477
  assert ada_payload["current_mode"] == "hostile_pursuit"
478
- assert ada_payload["allowed_actions"] == ["move_to_target", "search_target", "threaten"]
479
- assert "weather_talk" in ada_payload["forbidden_actions"]
480
- assert action_schema["enum"] == ada_payload["allowed_actions"]
 
481
 
482
 
483
  def test_openai_connector_malformed_json_output_falls_back_without_breaking_tick() -> None:
@@ -527,7 +530,7 @@ def test_openai_connector_parses_optional_json_intent_and_confidence() -> None:
527
  {
528
  "action": {
529
  "npc_id": "npc-001",
530
- "action": "walk",
531
  "target": {"x": 6, "z": 2},
532
  "intent": "approach_target_for_attack",
533
  "confidence": 1.25,
 
1
+ from __future__ import annotations
2
 
3
  import json
4
  from threading import Lock
 
41
  {
42
  "type": "function",
43
  "function": {
44
+ "name": "move",
45
  "arguments": json.dumps(
46
  {
47
  "npc_id": npc_id,
 
75
  assert first_request["max_tokens"] == 1400
76
  assert first_request["temperature"] == 0.6
77
  assert first_request["top_p"] == 0.95
78
+ assert [tool["function"]["name"] for tool in first_request["tools"]] == [
79
+ "move",
80
+ "speak",
81
+ "strike",
82
+ "use",
83
+ "transfer",
84
+ "idle",
85
+ ]
86
  payload = json.loads(first_request["messages"][1]["content"])
87
  assert "npcs" not in payload
88
  assert payload["npc"]["id"] == "npc-001"
89
  assert payload["npc"]["health"] == 100
90
  assert "attack_damage" in payload["npc"]
91
  assert payload["current_goal"]["goal_type"] == "routine_life"
92
+ assert payload["suggested_actions"] == ["move", "speak", "use", "transfer", "idle"]
93
+ assert payload["suggestion_reason"]
94
+ assert "forbidden_actions" not in payload
95
  assert world.npcs[0].position.x == 6
96
  assert world.npcs[0].position.z == 2
97
  assert world.npcs[0].intention == "walking"
 
115
  {
116
  "type": "function",
117
  "function": {
118
+ "name": "move",
119
  "arguments": json.dumps(
120
  {
121
  "npc_id": npc_id,
 
170
  {
171
  "type": "function",
172
  "function": {
173
+ "name": "move",
174
  "arguments": json.dumps(
175
  {
176
  "npc_id": npc_id,
 
220
  {
221
  "type": "function",
222
  "function": {
223
+ "name": "move",
224
  "arguments": json.dumps(
225
  {
226
  "npc_id": npc_id,
 
295
  {
296
  "type": "function",
297
  "function": {
298
+ "name": "move",
299
  "arguments": json.dumps(
300
  {
301
  "npc_id": npc_id,
 
354
  {
355
  "type": "function",
356
  "function": {
357
+ "name": "move",
358
  "arguments": '{"npc_id":',
359
  },
360
  },
361
  {
362
  "type": "function",
363
  "function": {
364
+ "name": "speak",
365
  "arguments": json.dumps(
366
  {
367
  "npc_id": "npc-002",
 
417
  assert cora_context["nearby_threats"][0]["npc_id"] == ada.id
418
 
419
 
420
+ def test_openai_payload_contains_goal_and_suggested_actions() -> None:
421
  captured_payloads: list[dict[str, Any]] = []
422
  captured_requests: list[dict[str, Any]] = []
423
 
424
  def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
425
+ captured_payloads.append(json.loads(request["messages"][1]["content"]))
426
  captured_requests.append(request)
427
+ payload = captured_payloads[-1]
 
428
  npc_id = payload["npc"]["id"]
429
+ if payload["current_goal"]["goal_type"] == "pursue_and_attack_target":
430
+ name = "move"
431
  arguments = {
432
  "npc_id": npc_id,
433
+ "target_id": payload["current_goal"]["target_npc_id"],
 
434
  }
435
  else:
436
+ name = "idle"
437
+ arguments = {"npc_id": npc_id, "intent": "observe"}
 
 
438
  return {
439
  "choices": [
440
  {
 
443
  {
444
  "type": "function",
445
  "function": {
446
+ "name": name,
447
  "arguments": json.dumps(arguments),
448
  },
449
  }
 
472
 
473
  ada_payload = next(payload for payload in captured_payloads if payload["npc"]["id"] == ada.id)
474
  ada_request = captured_requests[captured_payloads.index(ada_payload)]
475
+ tool_names = [tool["function"]["name"] for tool in ada_request["tools"]]
476
 
477
  assert ada_payload["current_goal"]["goal_type"] == "pursue_and_attack_target"
478
  assert ada_payload["current_goal"]["target_npc_id"] == boris.id
479
  assert ada_payload["current_mode"] == "hostile_pursuit"
480
+ assert ada_payload["suggested_actions"] == ["move", "speak"]
481
+ assert "forbidden_actions" not in ada_payload
482
+ assert tool_names == ["move", "speak", "strike", "use", "transfer", "idle"]
483
+ assert ada.intention == "approaching Boris to attack"
484
 
485
 
486
  def test_openai_connector_malformed_json_output_falls_back_without_breaking_tick() -> None:
 
530
  {
531
  "action": {
532
  "npc_id": "npc-001",
533
+ "action": "move",
534
  "target": {"x": 6, "z": 2},
535
  "intent": "approach_target_for_attack",
536
  "confidence": 1.25,
tests/test_survival_loop.py CHANGED
@@ -1,27 +1,28 @@
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 (
 
18
  OpenAICompatibleWorldSimulator,
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,
@@ -155,27 +156,26 @@ def test_unarmed_loner_flees() -> None:
155
  assert select_goal(npc, _world([npc], beasts=[beast])) == "survive_threat_flee"
156
 
157
 
158
- # 11. gather requires a nearby resource node with amount > 0
159
  def test_gather_requires_nearby_nonempty_resource() -> None:
160
  npc = _npc(x=0.0, z=0.0)
161
  node = ResourceNode("r1", "food", Vec3(2.0, 0.0, 0.0), amount=3)
162
  world = _world([npc], resource_nodes=[node])
163
- assert validate_survival_action(npc, "gather", world) == "gather"
164
 
165
  empty = ResourceNode("r2", "food", Vec3(2.0, 0.0, 0.0), amount=0)
166
  npc_empty = _npc(x=0.0, z=0.0)
167
- assert validate_survival_action(npc_empty, "gather", _world([npc_empty], resource_nodes=[empty])) == (
168
- "move_to_resource"
169
- )
170
 
171
  far = ResourceNode("r3", "food", Vec3(40.0, 0.0, 0.0), amount=3)
172
  npc_far = _npc(x=0.0, z=0.0)
173
- assert validate_survival_action(npc_far, "gather", _world([npc_far], resource_nodes=[far])) == (
174
  "move_to_resource"
175
  )
176
 
177
  # The gather effect consumes one unit and fills the right inventory slot.
178
- apply_action_effects(npc, "gather", world)
179
  assert npc.inventory_food == 1
180
  assert node.amount == 2
181
 
@@ -207,8 +207,8 @@ def test_survival_world_advances_and_produces_events() -> None:
207
 
208
  assert world.tick == 30
209
  assert world.last_tick_source == "survival"
210
- # Every survival goal in GOAL_ACTIONS is a recognised key for the mask.
211
- assert all(npc.survival_goal in GOAL_ACTIONS for npc in world.npcs)
212
  # Hunger economy and beast pursuit both did something observable.
213
  assert any(npc.hunger != start for npc, start in zip(world.npcs, starting_hunger, strict=True))
214
  assert world.beasts[0].position != Vec3(6.0, 0.0, 6.0) or world.beasts[0].state != "hunting"
@@ -275,13 +275,13 @@ def test_beast_random_spawn_respects_max_beasts_and_cooldown() -> None:
275
  assert len(capped_world.beasts) == MAX_BEASTS
276
 
277
 
278
- # Interaction: talk records a real memory on BOTH participants.
279
- def test_talk_exchanges_memory_between_npcs() -> None:
280
  ada = _npc("npc-001", "Ada", x=0.0, z=0.0)
281
  boris = _npc("npc-002", "Boris", x=2.0, z=0.0)
282
  world = _world([ada, boris], resource_nodes=[ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), 5)])
283
 
284
- summary = apply_action_effects(ada, "talk", world)
285
 
286
  assert summary == "talking to Boris"
287
  assert any("talked" in m.text for m in ada.memory)
@@ -289,12 +289,12 @@ def test_talk_exchanges_memory_between_npcs() -> None:
289
 
290
 
291
  # Cooperation: a surplus holder shares food with a needier neighbour.
292
- def test_trade_shares_food_with_needier_neighbor() -> None:
293
  giver = _npc("npc-001", "Ada", x=0.0, z=0.0, inventory_food=3)
294
  taker = _npc("npc-002", "Boris", x=2.0, z=0.0, inventory_food=0)
295
  world = _world([giver, taker])
296
 
297
- summary = apply_action_effects(giver, "trade", world)
298
 
299
  assert summary == "sharing food with Boris"
300
  assert giver.inventory_food == 2
@@ -311,7 +311,7 @@ def test_llm_connector_drives_survival_actions() -> None:
311
  def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
312
  payload = json.loads(request["messages"][1]["content"])
313
  captured.append(payload)
314
- action = payload["allowed_actions"][0] if payload["allowed_actions"] else "idle"
315
  return {
316
  "choices": [
317
  {
@@ -320,10 +320,8 @@ def test_llm_connector_drives_survival_actions() -> None:
320
  {
321
  "type": "function",
322
  "function": {
323
- "name": "choose_action",
324
- "arguments": json.dumps(
325
- {"npc_id": payload["npc_id"], "action": action}
326
- ),
327
  },
328
  }
329
  ]
@@ -344,17 +342,16 @@ def test_llm_connector_drives_survival_actions() -> None:
344
  assert world.tick == 1
345
  assert captured # the compact survival prompt was built and sent
346
  # Every captured prompt is the compact survival shape, not the social payload.
347
- assert all("allowed_actions" in payload and "goal" in payload for payload in captured)
348
  assert all("status" in payload and "inventory" in payload for payload in captured)
349
  assert "openai_compatible" in world.last_tick_source
350
 
351
 
352
- def test_llm_tools_are_limited_to_relevant_canonical_actions() -> None:
353
- legacy_tool_names = {"eat", "trade", "request_trade", "call_for_help"}
354
-
355
- for goal in GOAL_ACTIONS:
356
- tools = build_tool_list_for_goal(goal)
357
- names = [tool["function"]["name"] for tool in tools]
358
 
359
- assert len(names) <= 7
360
- assert legacy_tool_names.isdisjoint(names)
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ import json
4
+ from typing import Any
5
+
6
  from god_simulator.config import (
7
+ ConnectorConfig,
8
  GameConfig,
9
  NpcConfig,
10
  ServerConfig,
11
  SimulationConfig,
12
  WorldConfig,
13
  )
 
 
 
 
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 (
18
+ TOOL_DEFINITIONS,
19
  OpenAICompatibleWorldSimulator,
20
+ primitive_tools,
21
  )
22
  from god_simulator.simulation.spawning import create_world
23
  from god_simulator.simulation.survival import (
24
  BEAST_SPAWN_INTERVAL,
25
+ GOAL_SUGGESTED_ACTIONS,
26
  MAX_BEASTS,
27
  apply_action_effects,
28
  apply_survival_tick,
 
156
  assert select_goal(npc, _world([npc], beasts=[beast])) == "survive_threat_flee"
157
 
158
 
159
+ # 11. use (gather) requires a nearby resource node with amount > 0
160
  def test_gather_requires_nearby_nonempty_resource() -> None:
161
  npc = _npc(x=0.0, z=0.0)
162
  node = ResourceNode("r1", "food", Vec3(2.0, 0.0, 0.0), amount=3)
163
  world = _world([npc], resource_nodes=[node])
164
+ assert validate_survival_action(npc, "use", world) == "gather"
165
 
166
  empty = ResourceNode("r2", "food", Vec3(2.0, 0.0, 0.0), amount=0)
167
  npc_empty = _npc(x=0.0, z=0.0)
168
+ empty_world = _world([npc_empty], resource_nodes=[empty])
169
+ assert validate_survival_action(npc_empty, "use", empty_world) == "move_to_resource"
 
170
 
171
  far = ResourceNode("r3", "food", Vec3(40.0, 0.0, 0.0), amount=3)
172
  npc_far = _npc(x=0.0, z=0.0)
173
+ assert validate_survival_action(npc_far, "use", _world([npc_far], resource_nodes=[far])) == (
174
  "move_to_resource"
175
  )
176
 
177
  # The gather effect consumes one unit and fills the right inventory slot.
178
+ apply_action_effects(npc, "use", world)
179
  assert npc.inventory_food == 1
180
  assert node.amount == 2
181
 
 
207
 
208
  assert world.tick == 30
209
  assert world.last_tick_source == "survival"
210
+ # Every survival goal is a recognised key for the suggestion table.
211
+ assert all(npc.survival_goal in GOAL_SUGGESTED_ACTIONS for npc in world.npcs)
212
  # Hunger economy and beast pursuit both did something observable.
213
  assert any(npc.hunger != start for npc, start in zip(world.npcs, starting_hunger, strict=True))
214
  assert world.beasts[0].position != Vec3(6.0, 0.0, 6.0) or world.beasts[0].state != "hunting"
 
275
  assert len(capped_world.beasts) == MAX_BEASTS
276
 
277
 
278
+ # Interaction: speak records a real memory on BOTH participants.
279
+ def test_speak_exchanges_memory_between_npcs() -> None:
280
  ada = _npc("npc-001", "Ada", x=0.0, z=0.0)
281
  boris = _npc("npc-002", "Boris", x=2.0, z=0.0)
282
  world = _world([ada, boris], resource_nodes=[ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), 5)])
283
 
284
+ summary = apply_action_effects(ada, "speak", world)
285
 
286
  assert summary == "talking to Boris"
287
  assert any("talked" in m.text for m in ada.memory)
 
289
 
290
 
291
  # Cooperation: a surplus holder shares food with a needier neighbour.
292
+ def test_transfer_shares_food_with_needier_neighbor() -> None:
293
  giver = _npc("npc-001", "Ada", x=0.0, z=0.0, inventory_food=3)
294
  taker = _npc("npc-002", "Boris", x=2.0, z=0.0, inventory_food=0)
295
  world = _world([giver, taker])
296
 
297
+ summary = apply_action_effects(giver, "transfer", world)
298
 
299
  assert summary == "sharing food with Boris"
300
  assert giver.inventory_food == 2
 
311
  def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
312
  payload = json.loads(request["messages"][1]["content"])
313
  captured.append(payload)
314
+ action = payload["suggested_actions"][0] if payload["suggested_actions"] else "idle"
315
  return {
316
  "choices": [
317
  {
 
320
  {
321
  "type": "function",
322
  "function": {
323
+ "name": action,
324
+ "arguments": json.dumps({}),
 
 
325
  },
326
  }
327
  ]
 
342
  assert world.tick == 1
343
  assert captured # the compact survival prompt was built and sent
344
  # Every captured prompt is the compact survival shape, not the social payload.
345
+ assert all("suggested_actions" in payload and "goal" in payload for payload in captured)
346
  assert all("status" in payload and "inventory" in payload for payload in captured)
347
  assert "openai_compatible" in world.last_tick_source
348
 
349
 
350
+ def test_llm_always_sees_all_six_primitive_tools() -> None:
351
+ names = [tool["function"]["name"] for tool in primitive_tools()]
 
 
 
 
352
 
353
+ assert names == ["move", "speak", "strike", "use", "transfer", "idle"]
354
+ assert set(names) == set(TOOL_DEFINITIONS)
355
+ # Every suggested action references a real primitive tool.
356
+ for suggested in GOAL_SUGGESTED_ACTIONS.values():
357
+ assert set(suggested).issubset(set(names))
tests/test_world_spawning.py CHANGED
@@ -1,4 +1,4 @@
1
- from __future__ import annotations
2
 
3
  from god_simulator.config import GameConfig, NpcConfig, ServerConfig, SimulationConfig, WorldConfig
4
  from god_simulator.domain import Vec3
@@ -53,7 +53,7 @@ def test_attack_damages_target_inside_one_block_radius() -> None:
53
  directives=[
54
  NpcDirective(
55
  npc_id=attacker.id,
56
- action="attack",
57
  target_npc_id=target.id,
58
  )
59
  ],
@@ -81,7 +81,7 @@ def test_attack_outside_one_block_radius_has_no_effect() -> None:
81
  directives=[
82
  NpcDirective(
83
  npc_id=attacker.id,
84
- action="attack",
85
  target_npc_id=target.id,
86
  )
87
  ],
@@ -107,7 +107,7 @@ def test_talk_adds_memory_inside_three_block_radius() -> None:
107
  directives=[
108
  NpcDirective(
109
  npc_id=speaker.id,
110
- action="talk",
111
  target_npc_id=listener.id,
112
  message="Hold this position.",
113
  )
@@ -135,7 +135,7 @@ def test_memory_keeps_recent_entries_and_summarizes_older_events() -> None:
135
  directives=[
136
  NpcDirective(
137
  npc_id=speaker.id,
138
- action="talk",
139
  target_npc_id=listener.id,
140
  message=f"Message {tick}.",
141
  )
@@ -164,7 +164,7 @@ def test_dead_npc_stays_in_place_and_cannot_act() -> None:
164
  directives=[
165
  NpcDirective(
166
  npc_id=dead.id,
167
- action="attack",
168
  target_npc_id=target.id,
169
  )
170
  ],
@@ -190,12 +190,12 @@ def test_two_npcs_can_walk_to_same_destination_in_one_tick() -> None:
190
  directives=[
191
  NpcDirective(
192
  npc_id=first.id,
193
- action="walk",
194
  target=Vec3(x=2.0, y=0.0, z=2.0),
195
  ),
196
  NpcDirective(
197
  npc_id=second.id,
198
- action="walk",
199
  target=Vec3(x=2.0, y=0.0, z=2.0),
200
  ),
201
  ],
@@ -222,7 +222,7 @@ def test_npc_can_walk_into_occupied_position() -> None:
222
  directives=[
223
  NpcDirective(
224
  npc_id=first.id,
225
- action="walk",
226
  target=Vec3(x=2.0, y=0.0, z=2.0),
227
  )
228
  ],
@@ -251,12 +251,12 @@ def test_attack_that_drops_health_below_zero_marks_target_dead() -> None:
251
  directives=[
252
  NpcDirective(
253
  npc_id=attacker.id,
254
- action="attack",
255
  target_npc_id=target.id,
256
  ),
257
  NpcDirective(
258
  npc_id=target.id,
259
- action="walk",
260
  target=Vec3(x=2.0, y=0.0, z=0.0),
261
  ),
262
  ],
 
1
+ from __future__ import annotations
2
 
3
  from god_simulator.config import GameConfig, NpcConfig, ServerConfig, SimulationConfig, WorldConfig
4
  from god_simulator.domain import Vec3
 
53
  directives=[
54
  NpcDirective(
55
  npc_id=attacker.id,
56
+ action="strike",
57
  target_npc_id=target.id,
58
  )
59
  ],
 
81
  directives=[
82
  NpcDirective(
83
  npc_id=attacker.id,
84
+ action="strike",
85
  target_npc_id=target.id,
86
  )
87
  ],
 
107
  directives=[
108
  NpcDirective(
109
  npc_id=speaker.id,
110
+ action="speak",
111
  target_npc_id=listener.id,
112
  message="Hold this position.",
113
  )
 
135
  directives=[
136
  NpcDirective(
137
  npc_id=speaker.id,
138
+ action="speak",
139
  target_npc_id=listener.id,
140
  message=f"Message {tick}.",
141
  )
 
164
  directives=[
165
  NpcDirective(
166
  npc_id=dead.id,
167
+ action="strike",
168
  target_npc_id=target.id,
169
  )
170
  ],
 
190
  directives=[
191
  NpcDirective(
192
  npc_id=first.id,
193
+ action="move",
194
  target=Vec3(x=2.0, y=0.0, z=2.0),
195
  ),
196
  NpcDirective(
197
  npc_id=second.id,
198
+ action="move",
199
  target=Vec3(x=2.0, y=0.0, z=2.0),
200
  ),
201
  ],
 
222
  directives=[
223
  NpcDirective(
224
  npc_id=first.id,
225
+ action="move",
226
  target=Vec3(x=2.0, y=0.0, z=2.0),
227
  )
228
  ],
 
251
  directives=[
252
  NpcDirective(
253
  npc_id=attacker.id,
254
+ action="strike",
255
  target_npc_id=target.id,
256
  ),
257
  NpcDirective(
258
  npc_id=target.id,
259
+ action="move",
260
  target=Vec3(x=2.0, y=0.0, z=0.0),
261
  ),
262
  ],