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