Spaces:
Runtime error
Runtime error
| from __future__ import annotations | |
| import json | |
| from typing import Any | |
| from world_simulator.config import ( | |
| ConnectorConfig, | |
| GameConfig, | |
| NpcConfig, | |
| ServerConfig, | |
| SimulationConfig, | |
| WorldConfig, | |
| ) | |
| from world_simulator.domain import Beast, Npc, ResourceNode, Terrain, Vec3, WorldState | |
| from world_simulator.simulation.connectors.base import NpcDirective, TickPlan | |
| from world_simulator.simulation.connectors.deterministic import DeterministicWorldSimulator | |
| from world_simulator.simulation.connectors.openai_compatible import ( | |
| TOOL_DEFINITIONS, | |
| OpenAICompatibleWorldSimulator, | |
| primitive_tools, | |
| survival_tools_for_role, | |
| ) | |
| from world_simulator.simulation.spawning import create_world | |
| import pytest | |
| from world_simulator.simulation.survival import ( | |
| GOAL_SUGGESTED_ACTIONS, | |
| MAX_BEASTS, | |
| _natural_beast_spawn_interval, | |
| apply_action_effects, | |
| apply_survival_tick, | |
| select_goal, | |
| validate_survival_action, | |
| ) | |
| from world_simulator.simulation.tick import advance_world, apply_tick_plan | |
| def _npc(npc_id: str = "npc-001", name: str = "Ada", *, x: float = 0.0, z: float = 0.0, | |
| role: str = "farmer", **state: object) -> Npc: | |
| return Npc(id=npc_id, name=name, role=role, position=Vec3(x=x, y=0.0, z=z), **state) # type: ignore[arg-type] | |
| def _world( | |
| npcs: list[Npc], | |
| *, | |
| beasts: list[Beast] | None = None, | |
| resource_nodes: list[ResourceNode] | None = None, | |
| tick: int = 0, | |
| ) -> WorldState: | |
| return WorldState( | |
| tick=tick, | |
| seed=42, | |
| terrain=Terrain(kind="plain_green", width=80, depth=80), | |
| npcs=npcs, | |
| resource_nodes=resource_nodes or [], | |
| beasts=beasts or [], | |
| ) | |
| def _survival_config(*, npc_count: int = 6) -> GameConfig: | |
| return GameConfig( | |
| world=WorldConfig(width=80, depth=80, terrain="plain_green", seed=42, survival=True), | |
| npcs=NpcConfig(count=npc_count), | |
| simulation=SimulationConfig(tick_ms=500), | |
| server=ServerConfig(host="127.0.0.1", port=8000), | |
| ) | |
| # 1. hunger grows every tick (GAME_DESIGN: +0.8/tick) | |
| def test_hunger_increases_each_tick() -> None: | |
| npc = _npc(hunger=25.0) | |
| world = _world([npc], resource_nodes=[ResourceNode("r", "food", Vec3(30.0, 0.0, 30.0), 5)]) | |
| apply_survival_tick(world) | |
| assert npc.hunger == pytest.approx(25.8) | |
| apply_survival_tick(world) | |
| assert npc.hunger == pytest.approx(26.6) | |
| # 2. hunger > 80 + food in inventory -> goal = eat_food | |
| def test_high_hunger_with_food_selects_eat_food() -> None: | |
| npc = _npc(hunger=85.0, inventory_food=1) | |
| assert select_goal(npc, _world([npc])) == "eat_food" | |
| # 3. hunger > 80 + no food -> goal = find_food | |
| def test_high_hunger_without_food_selects_find_food() -> None: | |
| npc = _npc(hunger=85.0, inventory_food=0) | |
| assert select_goal(npc, _world([npc])) == "find_food" | |
| # 4. health < 40 + herbs -> goal = heal_self | |
| def test_low_health_with_herbs_selects_heal_self() -> None: | |
| npc = _npc(health=30, inventory_herbs=1) | |
| assert select_goal(npc, _world([npc])) == "heal_self" | |
| # 5. beast picks the nearest living NPC | |
| def test_beast_targets_nearest_alive_npc() -> None: | |
| near = _npc("npc-001", "Ada", x=8.0, z=0.0) | |
| far = _npc("npc-002", "Boris", x=30.0, z=0.0) | |
| beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0)) | |
| world = _world([far, near], beasts=[beast]) | |
| apply_survival_tick(world) | |
| assert beast.target_npc_id == "npc-001" | |
| # 6. beast in range attacks and lowers health | |
| def test_beast_in_range_attacks_and_reduces_health() -> None: | |
| victim = _npc(x=1.5, z=0.0, health=100) | |
| beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0)) # damage 12, reach half a tile == 2 units | |
| world = _world([victim], beasts=[beast]) | |
| apply_survival_tick(world) | |
| assert victim.health == 88 | |
| assert beast.state == "attacking" | |
| # 7. NPC attacked by beast gains fear >= 40 | |
| def test_attacked_npc_gains_fear() -> None: | |
| victim = _npc(x=1.5, z=0.0) | |
| beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0)) | |
| world = _world([victim], beasts=[beast]) | |
| apply_survival_tick(world) | |
| assert victim.fear >= 40 | |
| # 8. a nearby beast no longer forces a survive/engage goal - the LLM decides. | |
| def test_nearby_beast_does_not_force_threat_goal() -> None: | |
| npc = _npc(x=8.0, z=0.0) | |
| beast = Beast("beast_1", Vec3(0.0, 0.0, 0.0)) | |
| goal = select_goal(npc, _world([npc], beasts=[beast])) | |
| assert not goal.startswith("survive_threat") | |
| assert goal != "engage_threat" | |
| # 11. use (gather) requires a nearby resource node with amount > 0 | |
| def test_gather_requires_nearby_nonempty_resource() -> None: | |
| npc = _npc(x=0.0, z=0.0) | |
| node = ResourceNode("r1", "food", Vec3(2.0, 0.0, 0.0), amount=3) | |
| world = _world([npc], resource_nodes=[node]) | |
| assert validate_survival_action(npc, "use", world) == "gather" | |
| empty = ResourceNode("r2", "food", Vec3(2.0, 0.0, 0.0), amount=0) | |
| npc_empty = _npc(x=0.0, z=0.0) | |
| empty_world = _world([npc_empty], resource_nodes=[empty]) | |
| assert validate_survival_action(npc_empty, "use", empty_world) == "move_to_resource" | |
| far = ResourceNode("r3", "food", Vec3(40.0, 0.0, 0.0), amount=3) | |
| npc_far = _npc(x=0.0, z=0.0) | |
| assert validate_survival_action(npc_far, "use", _world([npc_far], resource_nodes=[far])) == ( | |
| "move_to_resource" | |
| ) | |
| # The gather effect consumes one unit and fills the right inventory slot. | |
| apply_action_effects(npc, "use", world) | |
| assert npc.inventory_food == 1 | |
| assert node.amount == 2 | |
| # 12. invalid action from the model does not break the tick (safe fallback) | |
| def test_invalid_action_does_not_break_tick() -> None: | |
| npc = _npc(x=0.0, z=0.0) | |
| world = _world([npc], resource_nodes=[ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), 5)]) | |
| plan = TickPlan( | |
| source="survival", | |
| directives=[NpcDirective(npc_id=npc.id, action="banana_dance")], # type: ignore[arg-type] | |
| ) | |
| apply_tick_plan(world, 1, plan) # must not raise | |
| assert world.tick == 1 | |
| assert npc.intention == "idle" | |
| assert validate_survival_action(npc, "banana_dance", world) == "idle" | |
| # Extra: end-to-end deterministic survival loop runs through the public tick path. | |
| def test_survival_world_advances_and_produces_events() -> None: | |
| world = create_world(_survival_config(npc_count=6)) | |
| assert world.beasts and world.resource_nodes | |
| starting_hunger = [npc.hunger for npc in world.npcs] | |
| for _ in range(30): | |
| advance_world(world) | |
| assert world.tick == 30 | |
| assert world.last_tick_source == "survival" | |
| # Every survival goal is a recognised key for the suggestion table. | |
| assert all(npc.survival_goal in GOAL_SUGGESTED_ACTIONS for npc in world.npcs) | |
| # Hunger economy and beast pursuit both did something observable. | |
| assert any(npc.hunger != start for npc, start in zip(world.npcs, starting_hunger, strict=True)) | |
| assert world.beasts[0].position != Vec3(6.0, 0.0, 6.0) or world.beasts[0].state != "hunting" | |
| # Sustainability: resource nodes regrow toward their cap so the settlement does | |
| # not starve to death once nodes are first depleted. | |
| def test_resource_nodes_regrow_over_time() -> None: | |
| node = ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), amount=1, max_amount=5) | |
| world = _world([_npc(x=0.0, z=0.0)], resource_nodes=[node], tick=0) | |
| apply_survival_tick(world) # food regrows every 8 ticks; tick 0 % 8 == 0 -> +1 | |
| assert node.amount == 2 | |
| world.tick = 8 | |
| apply_survival_tick(world) # -> +1 | |
| assert node.amount == 3 | |
| node.amount = 5 | |
| world.tick = 16 | |
| apply_survival_tick(world) # never exceeds the cap | |
| assert node.amount == 5 | |
| def test_resources_spawn_randomly_but_deterministically_by_seed() -> None: | |
| node_a = ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), amount=1, max_amount=5) | |
| node_b = ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), amount=1, max_amount=5) | |
| first = _world([_npc(x=0.0, z=0.0)], resource_nodes=[node_a], tick=7) | |
| second = _world([_npc(x=0.0, z=0.0)], resource_nodes=[node_b], tick=7) | |
| apply_survival_tick(first) | |
| apply_survival_tick(second) | |
| assert len(first.resource_nodes) == 2 | |
| assert first.resource_nodes[-1] == second.resource_nodes[-1] | |
| assert first.resource_nodes[-1].id.startswith("res_") | |
| def test_beast_random_spawn_respects_max_beasts_and_cooldown() -> None: | |
| def villagers() -> list[Npc]: | |
| # Natural beast spawns require population >= 4. | |
| return [_npc(f"npc-{index}", f"Villager{index}", x=float(index)) for index in range(4)] | |
| def find_spawn_tick() -> int: | |
| probe = _world(villagers(), tick=0) | |
| for tick in range(60, 2000): | |
| probe.tick = tick | |
| if tick % _natural_beast_spawn_interval(probe) == 0: | |
| return tick | |
| raise AssertionError("no natural beast spawn tick found for this seed") | |
| spawn_tick = find_spawn_tick() | |
| node = ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), amount=1, max_amount=5) | |
| cooldown_world = _world(villagers(), resource_nodes=[node], beasts=[], tick=1) | |
| apply_survival_tick(cooldown_world) | |
| assert cooldown_world.beasts == [] | |
| spawn_world = _world(villagers(), resource_nodes=[node], beasts=[], tick=spawn_tick) | |
| apply_survival_tick(spawn_world) | |
| assert len(spawn_world.beasts) == 1 | |
| assert spawn_world.beasts[0].id == "beast_1" | |
| capped_world = _world( | |
| villagers(), | |
| resource_nodes=[node], | |
| beasts=[ | |
| Beast(f"beast_{index}", Vec3(float(index), 0.0, 0.0)) | |
| for index in range(1, MAX_BEASTS + 1) | |
| ], | |
| tick=spawn_tick, | |
| ) | |
| apply_survival_tick(capped_world) | |
| assert len(capped_world.beasts) == MAX_BEASTS | |
| # Interaction: speak records a real memory on BOTH participants. | |
| def test_speak_without_target_is_heard_by_everyone_nearby() -> None: | |
| ada = _npc("npc-001", "Ada", x=0.0, z=0.0) | |
| boris = _npc("npc-002", "Boris", x=2.0, z=0.0) | |
| world = _world([ada, boris], resource_nodes=[ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), 5)]) | |
| summary = apply_action_effects(ada, "speak", world) | |
| assert summary.startswith("shouting") | |
| assert any("You said" in m.text for m in ada.memory) | |
| assert any("Ada said:" in m.text for m in boris.memory) | |
| def test_speak_with_target_exchanges_memory_between_npcs() -> None: | |
| ada = _npc("npc-001", "Ada", x=0.0, z=0.0) | |
| boris = _npc("npc-002", "Boris", x=2.0, z=0.0) | |
| world = _world([ada, boris], resource_nodes=[ResourceNode("r", "food", Vec3(40.0, 0.0, 40.0), 5)]) | |
| directive = NpcDirective(npc_id=ada.id, action="speak", target_npc_id=boris.id, message="hi Boris") | |
| summary = apply_action_effects(ada, "speak", world, directive=directive) | |
| assert summary == "talking to Boris" | |
| assert any("talked" in m.text for m in ada.memory) | |
| assert any("Ada talked with you" in m.text for m in boris.memory) | |
| # Cooperation: a surplus holder shares food with a needier neighbour. | |
| def test_transfer_shares_food_with_needier_neighbor() -> None: | |
| giver = _npc("npc-001", "Ada", x=0.0, z=0.0, inventory_food=3) | |
| taker = _npc("npc-002", "Boris", x=2.0, z=0.0, inventory_food=0) | |
| world = _world([giver, taker]) | |
| summary = apply_action_effects(giver, "transfer", world) | |
| assert summary == "sharing food with Boris" | |
| assert giver.inventory_food == 2 | |
| assert taker.inventory_food == 1 | |
| assert any("Gave food to Boris" in m.text for m in giver.memory) | |
| assert any("Received food from Ada" in m.text for m in taker.memory) | |
| # Extra: the LLM connector gets a compact survival prompt and its chosen survival | |
| # action resolves through the engine without breaking the tick. | |
| def test_llm_connector_drives_survival_actions() -> None: | |
| captured: list[str] = [] | |
| def fake_chat_completer(request: dict[str, Any]) -> dict[str, Any]: | |
| # The survival prompt is a readable text briefing; pick the first tool the | |
| # connector offered (always valid) instead of parsing a JSON payload. | |
| captured.append(request["messages"][1]["content"]) | |
| action = request["tools"][0]["function"]["name"] | |
| return { | |
| "choices": [ | |
| { | |
| "message": { | |
| "tool_calls": [ | |
| { | |
| "type": "function", | |
| "function": { | |
| "name": action, | |
| "arguments": json.dumps({}), | |
| }, | |
| } | |
| ] | |
| } | |
| } | |
| ] | |
| } | |
| world = create_world(_survival_config(npc_count=4)) | |
| simulator = OpenAICompatibleWorldSimulator( | |
| ConnectorConfig(type="openai_compatible", base_url="http://llm.local/v1", model="test"), | |
| fallback=DeterministicWorldSimulator(), | |
| chat_completer=fake_chat_completer, | |
| ) | |
| advance_world(world, simulator) | |
| assert world.tick == 1 | |
| assert captured # the survival briefing was built and sent | |
| # Every captured prompt is the readable survival briefing, not a JSON dump. | |
| assert all("YOU are" in briefing for briefing in captured) | |
| assert all("ENEMY NATION" in briefing for briefing in captured) | |
| assert all("ACTIONS YOU CAN TAKE:" in briefing for briefing in captured) | |
| assert all("max_age" not in briefing for briefing in captured) | |
| assert "openai_compatible" in world.last_tick_source | |
| def test_llm_always_sees_all_public_primitive_tools() -> None: | |
| names = [tool["function"]["name"] for tool in primitive_tools()] | |
| assert names == ["move", "speak", "attack", "use", "transfer"] | |
| assert set(names).issubset(set(TOOL_DEFINITIONS)) | |
| assert "vote" in TOOL_DEFINITIONS | |
| # Every suggested action references a real tool. | |
| for suggested in GOAL_SUGGESTED_ACTIONS.values(): | |
| assert set(suggested).issubset(set(TOOL_DEFINITIONS)) | |
| def test_survival_tools_are_not_role_gated() -> None: | |
| guard_names = {tool["function"]["name"] for tool in survival_tools_for_role("guard")} | |
| builder_names = {tool["function"]["name"] for tool in survival_tools_for_role("builder")} | |
| gatherer_names = {tool["function"]["name"] for tool in survival_tools_for_role("gatherer")} | |
| assert guard_names == builder_names == gatherer_names | |
| for names in (guard_names, builder_names, gatherer_names): | |
| assert names == {"move", "speak", "attack", "use", "transfer"} | |