DeltaZN commited on
Commit ·
5c435e7
1
Parent(s): ac2cc93
updated textures & refactor logic
Browse files- architecture.md +41 -47
- frontend/src/App.tsx +5 -1
- frontend/src/components/AgentPanel.tsx +60 -2
- frontend/src/hooks/useWorldSimulation.ts +19 -3
- frontend/src/scene/BeastActor.tsx +433 -0
- frontend/src/scene/GodSimulatorScene.tsx +16 -5
- frontend/src/scene/NpcBody.tsx +12 -12
- frontend/src/scene/NpcHead.tsx +26 -16
- frontend/src/scene/NpcLabelButton.tsx +6 -1
- frontend/src/scene/NpcTorso.tsx +19 -11
- frontend/src/scene/ResourceActor.tsx +294 -0
- frontend/src/scene/WorldView.tsx +72 -63
- src/god_simulator/simulation/actions/__init__.py +7 -6
- src/god_simulator/simulation/actions/builtin.py +44 -80
- src/god_simulator/simulation/actions/hints.py +113 -0
- src/god_simulator/simulation/actions/masks.py +0 -177
- src/god_simulator/simulation/actions/schemas.py +8 -10
- src/god_simulator/simulation/autopilot.py +179 -0
- src/god_simulator/simulation/connectors/base.py +11 -30
- src/god_simulator/simulation/connectors/deterministic.py +1 -1
- src/god_simulator/simulation/connectors/openai_compatible.py +147 -400
- src/god_simulator/simulation/mechanics.py +0 -39
- src/god_simulator/simulation/survival.py +117 -67
- src/god_simulator/simulation/tick.py +227 -711
- tests/test_action_pipeline.py +98 -96
- tests/test_npc_simulation_v2.py +9 -4
- tests/test_openai_connector.py +34 -31
- tests/test_survival_loop.py +31 -34
- tests/test_world_spawning.py +11 -11
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`:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 430 |
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
|
| 442 |
Action lifecycle:
|
| 443 |
|
| 444 |
-
| Stage |
|
| 445 |
| --- | --- |
|
| 446 |
-
| Selection |
|
| 447 |
| Parsing | `_parse_tool_calls` / `_parse_json_content` / `_actions_to_plan`. |
|
| 448 |
-
|
|
| 449 |
-
| Execution | `apply_tick_plan` dispatches
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 152 |
-
return
|
| 153 |
}
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 60 |
-
<
|
| 61 |
-
|
| 62 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 39 |
-
<
|
| 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 |
-
<
|
| 59 |
-
<meshStandardMaterial color={palette.legs} roughness={0.
|
| 60 |
</mesh>
|
| 61 |
-
<mesh castShadow position={[0, -0.
|
| 62 |
-
<
|
| 63 |
-
<meshStandardMaterial color="#342f2c" roughness={0.
|
| 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 |
-
<
|
| 76 |
-
<meshStandardMaterial color={palette.sleeves} roughness={0.
|
| 77 |
</mesh>
|
| 78 |
-
<mesh castShadow position={[0, -0.
|
| 79 |
-
<
|
| 80 |
-
<meshStandardMaterial color="#f7c89b" roughness={0.
|
| 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 |
-
<
|
| 16 |
-
<meshStandardMaterial color="#f7c89b" roughness={0.
|
| 17 |
</mesh>
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
<
|
|
|
|
| 21 |
</mesh>
|
| 22 |
-
<mesh castShadow position={[0, 0.
|
| 23 |
-
<
|
| 24 |
-
<meshStandardMaterial color={palette.
|
| 25 |
</mesh>
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 28 |
<meshStandardMaterial color={palette.accent} roughness={0.56} />
|
| 29 |
</mesh>
|
| 30 |
-
|
| 31 |
-
|
|
|
|
| 32 |
<meshStandardMaterial color="#23231f" roughness={0.4} />
|
| 33 |
</mesh>
|
| 34 |
-
<mesh position={[0.
|
| 35 |
-
<
|
| 36 |
<meshStandardMaterial color="#23231f" roughness={0.4} />
|
| 37 |
</mesh>
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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={
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
| 16 |
-
<meshStandardMaterial color={palette.coat} roughness={0.
|
| 17 |
</mesh>
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
<
|
|
|
|
| 21 |
</mesh>
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
<
|
|
|
|
| 25 |
</mesh>
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
))}
|
| 36 |
{(snapshot.beasts ?? []).map((beast) => (
|
| 37 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
))}
|
| 39 |
</group>
|
| 40 |
);
|
|
@@ -46,67 +55,67 @@ type TerrainProps = {
|
|
| 46 |
color: string;
|
| 47 |
};
|
| 48 |
|
|
|
|
|
|
|
|
|
|
| 49 |
function Terrain({ width, depth, color }: TerrainProps) {
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
</mesh>
|
| 55 |
-
);
|
| 56 |
-
}
|
| 57 |
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
|
| 62 |
return (
|
| 63 |
-
<group
|
| 64 |
-
<
|
| 65 |
-
<
|
| 66 |
-
<meshStandardMaterial color={
|
| 67 |
-
</
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
<
|
|
|
|
| 71 |
</mesh>
|
| 72 |
</group>
|
| 73 |
);
|
| 74 |
}
|
| 75 |
|
| 76 |
-
function
|
| 77 |
-
const
|
| 78 |
-
|
| 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 |
-
"""
|
| 2 |
|
| 3 |
-
from god_simulator.simulation.actions.builtin import BUILTIN_ACTIONS
|
| 4 |
-
from god_simulator.simulation.actions.
|
| 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,
|
| 10 |
|
| 11 |
__all__ = [
|
| 12 |
"BUILTIN_ACTIONS",
|
|
|
|
| 13 |
"ActionDefinition",
|
| 14 |
-
"
|
| 15 |
"all_action_definitions",
|
| 16 |
-
"
|
| 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 |
-
"
|
| 7 |
-
id="
|
| 8 |
category="movement",
|
| 9 |
-
description_for_model=
|
| 10 |
-
|
| 11 |
-
|
|
|
|
| 12 |
),
|
| 13 |
-
"
|
| 14 |
-
id="
|
| 15 |
category="social",
|
| 16 |
-
description_for_model=
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
| 22 |
category="combat",
|
| 23 |
-
description_for_model=
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
),
|
| 27 |
"idle": ActionDefinition(
|
| 28 |
id="idle",
|
| 29 |
category="passive",
|
| 30 |
-
description_for_model=
|
| 31 |
-
|
| 32 |
-
|
| 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
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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="
|
| 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
|
|
|
|
| 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 |
-
|
| 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 |
-
|
| 50 |
-
|
| 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 |
-
"
|
| 61 |
"type": "function",
|
| 62 |
"function": {
|
| 63 |
-
"name": "
|
| 64 |
-
"description":
|
|
|
|
|
|
|
|
|
|
| 65 |
"parameters": {
|
| 66 |
"type": "object",
|
| 67 |
"properties": {
|
| 68 |
-
"
|
| 69 |
"type": "string",
|
| 70 |
-
"description": "Optional visible beast id.",
|
| 71 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
},
|
| 73 |
"required": [],
|
| 74 |
"additionalProperties": False,
|
| 75 |
},
|
| 76 |
},
|
| 77 |
},
|
| 78 |
-
"
|
| 79 |
"type": "function",
|
| 80 |
"function": {
|
| 81 |
-
"name": "
|
| 82 |
-
"description":
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 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":
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
| 116 |
},
|
| 117 |
-
"required": [],
|
| 118 |
"additionalProperties": False,
|
| 119 |
},
|
| 120 |
},
|
| 121 |
},
|
| 122 |
-
"
|
| 123 |
"type": "function",
|
| 124 |
"function": {
|
| 125 |
-
"name": "
|
| 126 |
-
"description":
|
|
|
|
|
|
|
| 127 |
"parameters": {
|
| 128 |
"type": "object",
|
| 129 |
"properties": {
|
| 130 |
"target_id": {
|
| 131 |
"type": "string",
|
| 132 |
-
"description": "
|
| 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 |
-
"
|
| 154 |
"type": "function",
|
| 155 |
"function": {
|
| 156 |
-
"name": "
|
| 157 |
-
"description":
|
| 158 |
-
|
| 159 |
-
"
|
| 160 |
-
|
| 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": "
|
| 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 |
-
"
|
| 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":
|
|
|
|
|
|
|
| 220 |
"parameters": {
|
| 221 |
"type": "object",
|
| 222 |
"properties": {
|
| 223 |
"target_id": {
|
| 224 |
"type": "string",
|
| 225 |
-
"description": "Nearby NPC id
|
| 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"
|
| 234 |
"additionalProperties": False,
|
| 235 |
},
|
| 236 |
},
|
| 237 |
},
|
| 238 |
-
"
|
| 239 |
"type": "function",
|
| 240 |
"function": {
|
| 241 |
-
"name": "
|
| 242 |
-
"description": "
|
| 243 |
"parameters": {
|
| 244 |
"type": "object",
|
| 245 |
"properties": {
|
| 246 |
-
"
|
| 247 |
"type": "string",
|
| 248 |
-
"description": "Optional
|
| 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
|
| 276 |
-
|
| 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 |
-
|
| 506 |
-
messages = _build_survival_messages(world, next_tick, npc, survival_goal,
|
| 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 |
-
|
| 513 |
-
messages = _build_messages(world, next_tick, npc, perception,
|
| 514 |
-
|
| 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 |
-
|
| 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 |
-
"
|
| 555 |
-
"
|
| 556 |
-
"
|
|
|
|
| 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"
|
| 580 |
-
f"
|
| 581 |
-
f"
|
| 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 |
-
"
|
| 602 |
-
"
|
| 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,
|
| 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 |
-
"
|
| 623 |
-
"
|
| 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 |
-
|
| 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 |
-
"
|
| 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}.
|
| 673 |
-
"
|
|
|
|
| 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 |
-
|
| 689 |
) -> dict[str, Any]:
|
| 690 |
-
if perception is None or
|
| 691 |
perception, goal = refresh_citizen_motivation(world, npc)
|
| 692 |
-
|
| 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 |
-
"
|
| 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 |
-
"
|
| 723 |
-
"
|
| 724 |
-
"
|
| 725 |
-
"
|
| 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
|
| 793 |
arguments = {}
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 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 {"
|
| 839 |
payload["resource_id"] = tokens[1]
|
| 840 |
-
if len(tokens) > 1 and action in {"
|
| 841 |
-
payload["
|
| 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 =
|
|
|
|
|
|
|
| 867 |
resource_id = _optional_resource_id(action.get("resource_id"), world)
|
| 868 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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
|
| 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
|
| 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 |
-
|
| 1075 |
-
|
| 1076 |
-
|
| 1077 |
-
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
| 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
|
| 1128 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 ->
|
| 82 |
-
#
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
"
|
| 86 |
-
"
|
| 87 |
-
"
|
| 88 |
-
"
|
| 89 |
-
"
|
| 90 |
-
"
|
| 91 |
-
"
|
|
|
|
| 92 |
"dead": [],
|
| 93 |
}
|
| 94 |
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 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
|
| 731 |
-
return list(
|
| 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 |
-
"""
|
| 741 |
-
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
| 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
|
| 767 |
return action
|
| 768 |
return "idle"
|
| 769 |
|
| 770 |
|
| 771 |
-
def
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 775 |
return "communicate"
|
| 776 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 777 |
return "transfer"
|
| 778 |
-
|
|
|
|
| 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 |
-
|
| 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("
|
| 1541 |
-
return directive("
|
| 1542 |
|
| 1543 |
if goal == "survive_threat_flee":
|
| 1544 |
if (next_tick + index) % 4 == 0:
|
| 1545 |
-
return directive("
|
| 1546 |
-
return directive("
|
| 1547 |
|
| 1548 |
if goal == "help_ally":
|
| 1549 |
-
return directive("
|
| 1550 |
|
| 1551 |
if goal == "heal_self":
|
| 1552 |
-
return directive("
|
| 1553 |
|
| 1554 |
if goal == "eat_food":
|
| 1555 |
-
return directive("
|
| 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 |
-
"
|
| 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("
|
| 1577 |
-
return directive("
|
| 1578 |
-
return directive("
|
| 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("
|
| 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("
|
| 1600 |
if node is not None and phase != 3:
|
| 1601 |
-
return directive("
|
| 1602 |
-
return directive("
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 "
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 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
|
| 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 =
|
| 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 =
|
| 197 |
-
|
| 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 |
-
|
| 229 |
-
|
| 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 "
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 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 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 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 |
-
) ->
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 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
|
| 434 |
intent="defend",
|
| 435 |
)
|
| 436 |
-
|
| 437 |
-
return _idle_directive(
|
| 438 |
directive,
|
| 439 |
-
|
| 440 |
-
|
|
|
|
| 441 |
)
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 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
|
| 502 |
-
intent="
|
| 503 |
)
|
| 504 |
-
|
| 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 |
-
|
| 518 |
-
|
|
|
|
| 519 |
)
|
| 520 |
-
|
| 521 |
-
|
|
|
|
|
|
|
| 522 |
directive,
|
| 523 |
-
|
| 524 |
-
intent=
|
| 525 |
-
)
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 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
|
| 576 |
-
|
| 577 |
-
|
| 578 |
-
return target
|
| 579 |
|
| 580 |
-
|
| 581 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 588 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
|
| 590 |
-
|
| 591 |
-
|
| 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="
|
|
|
|
| 601 |
target_npc_id=target.id,
|
| 602 |
message=directive.message,
|
| 603 |
-
memory=
|
| 604 |
-
intent="
|
| 605 |
confidence=directive.confidence,
|
| 606 |
)
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 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 |
-
|
| 637 |
-
|
| 638 |
-
if target is not None and distance_between(npc, target) <= TALK_RADIUS:
|
| 639 |
-
return NpcDirective(
|
| 640 |
npc_id=npc.id,
|
| 641 |
-
action="
|
|
|
|
| 642 |
target_npc_id=target.id,
|
| 643 |
-
message=message,
|
| 644 |
-
memory=
|
| 645 |
-
intent="
|
| 646 |
confidence=directive.confidence,
|
| 647 |
)
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
|
|
|
| 653 |
|
|
|
|
| 654 |
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 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
|
| 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 != "
|
| 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] =
|
| 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
|
| 722 |
npc: Npc,
|
| 723 |
raw_target: Vec3,
|
| 724 |
*,
|
|
@@ -743,7 +438,7 @@ def _planned_walk_target(
|
|
| 743 |
)
|
| 744 |
|
| 745 |
|
| 746 |
-
def
|
| 747 |
npc: Npc,
|
| 748 |
directive: NpcDirective,
|
| 749 |
target: Vec3 | None,
|
|
@@ -795,16 +490,19 @@ def _apply_walk(
|
|
| 795 |
)
|
| 796 |
|
| 797 |
|
| 798 |
-
def
|
| 799 |
npc: Npc,
|
| 800 |
directive: NpcDirective,
|
| 801 |
npcs_by_id: dict[str, Npc],
|
| 802 |
next_tick: int,
|
| 803 |
) -> ResolutionResult:
|
| 804 |
-
|
|
|
|
|
|
|
|
|
|
| 805 |
if not target or target.id == npc.id or not is_alive(target):
|
| 806 |
-
npc.intention = "could not
|
| 807 |
-
remember(npc, next_tick, "You could not
|
| 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
|
| 817 |
-
remember(npc, next_tick, f"You were too far away to
|
| 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 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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
|
| 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="
|
| 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 == "
|
| 37 |
-
assert traces[0].validation.resolved_action == "
|
| 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 == "
|
| 46 |
assert ada.intention == "approaching Boris to attack"
|
| 47 |
-
assert "too far to attack" not in ada.intention
|
| 48 |
|
| 49 |
|
| 50 |
-
def
|
| 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="
|
| 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
|
| 81 |
-
|
| 82 |
-
|
|
|
|
| 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
|
| 104 |
-
assert ada.
|
| 105 |
-
assert distance_between(ada, cora) < 8.0
|
| 106 |
|
| 107 |
|
| 108 |
-
def
|
|
|
|
|
|
|
| 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="
|
| 125 |
target_npc_id=ada.id,
|
| 126 |
-
message="
|
| 127 |
)
|
| 128 |
],
|
| 129 |
),
|
| 130 |
)
|
| 131 |
|
| 132 |
-
assert boris.intention == "
|
| 133 |
-
assert
|
| 134 |
-
assert not any(memory.text == "Boris told you: Nice weather today." for memory in ada.memory)
|
| 135 |
|
| 136 |
|
| 137 |
-
def
|
| 138 |
-
world = create_world(_config(npc_count=
|
| 139 |
-
ada, boris = world.npcs
|
| 140 |
ada.position = Vec3(x=0.0, y=0.0, z=0.0)
|
| 141 |
-
boris.position = Vec3(x=
|
| 142 |
-
|
| 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=
|
| 152 |
-
action="
|
| 153 |
-
|
| 154 |
-
|
| 155 |
)
|
| 156 |
],
|
| 157 |
),
|
| 158 |
)
|
| 159 |
|
| 160 |
-
assert
|
| 161 |
-
assert "
|
| 162 |
-
assert "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 196 |
validation = validate_directive(
|
| 197 |
world,
|
| 198 |
ada,
|
| 199 |
NpcDirective(
|
| 200 |
npc_id=ada.id,
|
| 201 |
-
action="
|
| 202 |
target_npc_id=boris.id,
|
| 203 |
message="What's the weather like today?",
|
| 204 |
),
|
| 205 |
)
|
| 206 |
|
| 207 |
-
assert
|
| 208 |
-
assert
|
| 209 |
-
|
| 210 |
-
assert validation.
|
| 211 |
-
assert validation.directive.
|
| 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
|
| 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 |
-
|
| 259 |
-
|
| 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 {"
|
| 266 |
-
|
| 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="
|
| 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 == "
|
| 290 |
|
| 291 |
|
| 292 |
-
def
|
| 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.
|
|
|
|
| 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="
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 203 |
world,
|
| 204 |
directive=NpcDirective(
|
| 205 |
npc_id=caller.id,
|
| 206 |
-
action="
|
| 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": "
|
| 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"]] == [
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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["
|
| 89 |
-
assert "
|
| 90 |
-
assert "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 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": "
|
| 354 |
"arguments": '{"npc_id":',
|
| 355 |
},
|
| 356 |
},
|
| 357 |
{
|
| 358 |
"type": "function",
|
| 359 |
"function": {
|
| 360 |
-
"name": "
|
| 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
|
| 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 =
|
| 423 |
-
captured_payloads.append(payload)
|
| 424 |
npc_id = payload["npc"]["id"]
|
| 425 |
-
if "
|
|
|
|
| 426 |
arguments = {
|
| 427 |
"npc_id": npc_id,
|
| 428 |
-
"
|
| 429 |
-
"target_npc_id": payload["current_goal"]["target_npc_id"],
|
| 430 |
}
|
| 431 |
else:
|
| 432 |
-
|
| 433 |
-
|
| 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":
|
| 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 |
-
|
| 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["
|
| 479 |
-
assert "
|
| 480 |
-
assert
|
|
|
|
| 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": "
|
| 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 |
-
|
| 20 |
)
|
| 21 |
from god_simulator.simulation.spawning import create_world
|
| 22 |
from god_simulator.simulation.survival import (
|
| 23 |
BEAST_SPAWN_INTERVAL,
|
| 24 |
-
|
| 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, "
|
| 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 |
-
|
| 168 |
-
|
| 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, "
|
| 174 |
"move_to_resource"
|
| 175 |
)
|
| 176 |
|
| 177 |
# The gather effect consumes one unit and fills the right inventory slot.
|
| 178 |
-
apply_action_effects(npc, "
|
| 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
|
| 211 |
-
assert all(npc.survival_goal in
|
| 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:
|
| 279 |
-
def
|
| 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, "
|
| 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
|
| 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, "
|
| 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["
|
| 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":
|
| 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("
|
| 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
|
| 353 |
-
|
| 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 |
-
|
| 360 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 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="
|
| 194 |
target=Vec3(x=2.0, y=0.0, z=2.0),
|
| 195 |
),
|
| 196 |
NpcDirective(
|
| 197 |
npc_id=second.id,
|
| 198 |
-
action="
|
| 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="
|
| 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="
|
| 255 |
target_npc_id=target.id,
|
| 256 |
),
|
| 257 |
NpcDirective(
|
| 258 |
npc_id=target.id,
|
| 259 |
-
action="
|
| 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 |
],
|