world-simulator / tests /test_observability_perception.py
kikikita's picture
Refactor survival prompt to provide a readable mission briefing
566718d
Raw
History Blame Contribute Delete
10.7 kB
from __future__ import annotations
import json
from http import HTTPStatus
from pathlib import Path
from typing import Any
from world_simulator.api.runtime import GameRuntime
from world_simulator.config import (
ConnectorConfig,
GameConfig,
NpcConfig,
ServerConfig,
SimulationConfig,
WorldConfig,
)
from world_simulator.domain import Beast, House, Npc, ResourceNode, Terrain, Vec3, WorldState
from world_simulator.observability import RunLedger
from world_simulator.simulation.connectors.deterministic import DeterministicWorldSimulator
from world_simulator.simulation.connectors.openai_compatible import OpenAICompatibleWorldSimulator
from world_simulator.simulation.directives import (
DEFAULT_DIRECTIVE_TTL_TICKS,
expire_directives,
set_active_directive,
)
from world_simulator.simulation.spawning import create_world
from world_simulator.simulation.survival import _kill_npc
from world_simulator.simulation.tick import advance_world
def test_dead_npcs_never_appear_in_model_target_fields_over_scripted_deaths() -> None:
world = _survival_world(npc_count=8)
world.beasts.clear()
captured_payloads: list[str] = []
def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
# The survival prompt is now a readable text briefing; the connector
# injects the npc_id onto the tool call for us.
captured_payloads.append(request["messages"][1]["content"])
return _tool_response("speak", {"message": "observing"})
simulator = OpenAICompatibleWorldSimulator(
ConnectorConfig(
type="openai_compatible",
base_url="http://llm.local/v1",
model="test-model",
max_parallel_npc_calls=8,
),
fallback=DeterministicWorldSimulator(),
chat_completer=fake_chat_completer,
)
for iteration in range(200):
if iteration in {3, 17, 41, 73} and len(world.living_npcs()) > 3:
victim = world.living_npcs()[-1]
victim.inventory_food = 1
world.houses[0].occupant_ids.append(victim.id)
world.beasts.append(Beast(f"beast_scripted_{iteration}", victim.position, target_npc_id=victim.id))
_kill_npc(world, victim, cause="scripted", actor_id="test")
captured_payloads.clear()
pre_tick_dead_ids = {npc.id for npc in world.npcs if npc.health <= 0}
advance_world(world, simulator)
dead_ids = {npc.id for npc in world.npcs if npc.health <= 0}
if not dead_ids:
continue
for payload in captured_payloads:
_assert_survival_payload_has_no_dead_targets(payload, world, pre_tick_dead_ids)
_assert_world_live_registries_have_no_dead_targets(world, dead_ids)
def test_directive_ttl_expiry_and_replacement() -> None:
npc = _npc("npc-001", "Ada")
world = _world([npc])
set_active_directive(npc, "Gather food.", source="test", tick=0)
assert npc.directive_ttl_ticks == DEFAULT_DIRECTIVE_TTL_TICKS
expire_directives(world, tick=DEFAULT_DIRECTIVE_TTL_TICKS - 1)
assert npc.god_directive == "Gather food."
expire_directives(world, tick=DEFAULT_DIRECTIVE_TTL_TICKS)
assert npc.god_directive is None
assert world.event_log[-1].type == "directive_expired"
set_active_directive(npc, "First order.", source="test", tick=30, ttl_ticks=24)
set_active_directive(npc, "Replacement order.", source="test", tick=31, ttl_ticks=7)
assert npc.god_directive == "Replacement order."
assert npc.directive_issued_tick == 31
assert npc.directive_ttl_ticks == 7
expire_directives(world, tick=37)
assert npc.god_directive == "Replacement order."
expire_directives(world, tick=38)
assert npc.god_directive is None
def test_death_drops_loot_and_updates_witnesses_and_live_registries() -> None:
killer = _npc("npc-001", "Ada", x=0.0)
victim = _npc(
"npc-002",
"Boris",
x=1.0,
inventory_food=2,
inventory_herbs=1,
inventory_wood=3,
inventory_weapon=1,
)
witness = _npc("npc-003", "Cora", x=2.0)
house = House("house_1", Vec3(1.0, 0.0, 0.0), occupant_ids=[victim.id], owner_ids=[victim.id])
beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0), target_npc_id=victim.id)
world = _world([killer, victim, witness], houses=[house], beasts=[beast], tick=7)
killer.focus_target_id = victim.id
witness.help_target_id = victim.id
set_active_directive(witness, "Help Boris.", source="test", tick=7)
_kill_npc(world, victim, cause="violence", actor_id=killer.id)
assert victim.health <= 0
assert {node.resource_type: node.amount for node in world.resource_nodes} == {
"food": 2,
"herbs": 1,
"wood": 3,
"weapon": 1,
}
assert witness.relationships[killer.id] == -0.5
assert witness.structured_memory.episodes[-1].emotional_weight == 0.9
assert victim.id not in house.occupant_ids
assert victim.id not in house.owner_ids
assert killer.focus_target_id is None
assert witness.help_target_id is None
assert witness.god_directive is None
assert beast.target_npc_id is None
def test_beast_kill_gives_witness_fear_and_beast_relationship() -> None:
victim = _npc("npc-001", "Ada", x=0.0)
witness = _npc("npc-002", "Boris", x=2.0, fear=10.0)
world = _world([victim, witness], tick=5)
_kill_npc(world, victim, cause="beast", actor_id="beast_1")
assert witness.fear == 40.0
assert witness.relationships["beast_1"] == -1.0
assert witness.structured_memory.episodes[-1].subject_kind == "beast"
assert witness.structured_memory.episodes[-1].emotional_weight == 0.9
def test_runtime_ledger_contains_all_required_phase_types_after_20_ticks() -> None:
world = create_world(_config(npc_count=4, survival=True))
ledger_root = Path("tmp") / "ledger_tests"
ledger_root.mkdir(parents=True, exist_ok=True)
ledger = RunLedger(root=ledger_root)
runtime = GameRuntime(
world=world,
simulator=_ledger_test_simulator(),
ledger=ledger,
)
for _ in range(20):
status, _ = runtime.tick()
assert status == HTTPStatus.OK
records = [
json.loads(line)
for line in ledger.ledger_path.read_text(encoding="utf-8").splitlines()
]
phases = {record["phase"] for record in records}
assert {"npc_request", "npc_response", "npc_fallback", "engine_events"}.issubset(phases)
assert "god_command" not in phases
assert "overseer_request" not in phases
assert "overseer_response" not in phases
status, payload = runtime.admin_log_records(run_id="current", limit=5)
assert status == HTTPStatus.OK
assert payload["records"]
assert ledger.pretty_path.read_text(encoding="utf-8").strip()
def _ledger_test_simulator() -> OpenAICompatibleWorldSimulator:
def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]:
# The briefing prints the NPC id in plain text. Force a model outage for
# npc-001 so the deterministic fallback path is exercised/logged.
content = request["messages"][1]["content"]
if "(npc-001)" in content:
raise RuntimeError("scripted model outage")
return _tool_response("speak", {"message": "observing"})
return OpenAICompatibleWorldSimulator(
ConnectorConfig(
type="openai_compatible",
base_url="http://llm.local/v1",
model="test-model",
max_parallel_npc_calls=4,
),
fallback=DeterministicWorldSimulator(),
chat_completer=fake_chat_completer,
)
def _assert_survival_payload_has_no_dead_targets(
briefing: str,
world: WorldState,
dead_ids: set[str],
) -> None:
# Allies, threats, resources and shelter are all rendered as " - ..." bullet
# lines. A dead NPC must never be offered as a target there.
bullet_lines = [
line for line in briefing.splitlines() if line.strip().startswith("- ")
]
for dead_id in dead_ids:
for line in bullet_lines:
assert dead_id not in line, f"dead {dead_id} leaked into briefing: {line}"
def _assert_world_live_registries_have_no_dead_targets(
world: WorldState,
dead_ids: set[str],
) -> None:
for house in world.houses:
assert not (set(house.occupant_ids) & dead_ids)
assert not (set(house.owner_ids) & dead_ids)
for beast in world.beasts:
assert beast.target_npc_id not in dead_ids
for npc in world.living_npcs():
assert npc.focus_target_id not in dead_ids
assert npc.help_target_id not in dead_ids
if npc.god_directive:
lowered = npc.god_directive.lower()
dead_names = [
dead.name.lower()
for dead in world.npcs
if dead.id in dead_ids
]
assert not any(dead_id.lower() in lowered for dead_id in dead_ids)
assert not any(name in lowered for name in dead_names)
def _survival_world(*, npc_count: int) -> WorldState:
return create_world(_config(npc_count=npc_count, survival=True))
def _world(
npcs: list[Npc],
*,
houses: list[House] | None = None,
beasts: list[Beast] | None = None,
tick: int = 0,
) -> WorldState:
return WorldState(
tick=tick,
seed=42,
terrain=Terrain(kind="plain_green", width=80, depth=80),
npcs=npcs,
houses=houses or [],
beasts=beasts or [],
)
def _npc(
npc_id: str,
name: str,
*,
x: float = 0.0,
z: float = 0.0,
**state: Any,
) -> Npc:
return Npc(
id=npc_id,
name=name,
role=str(state.pop("role", "gatherer")),
position=Vec3(x=x, y=0.0, z=z),
**state,
)
def _config(*, npc_count: int, survival: bool) -> GameConfig:
return GameConfig(
world=WorldConfig(width=80, depth=80, terrain="plain_green", seed=42, survival=survival),
npcs=NpcConfig(count=npc_count),
simulation=SimulationConfig(tick_ms=500),
server=ServerConfig(host="127.0.0.1", port=8000),
connector=ConnectorConfig(type="deterministic"),
)
def _tool_response(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
return {
"choices": [
{
"message": {
"tool_calls": [
{
"type": "function",
"function": {
"name": name,
"arguments": json.dumps(arguments),
},
}
]
}
}
]
}