from __future__ import annotations import json from world_simulator.api.persistence import ( BackgroundSnapshotWriter, SnapshotStore, build_snapshot, restore_snapshot, world_from_dict, ) from world_simulator.api.players import PlayerManager from world_simulator.config import ( GameConfig, NpcConfig, ServerConfig, SimulationConfig, WorldConfig, ) from world_simulator.simulation.connectors.base import NpcDirective, TickPlan from world_simulator.simulation.spawning import create_world from world_simulator.simulation.tick import apply_tick_plan def test_world_survives_dict_round_trip() -> None: world = create_world(_config(npc_count=8)) # Advance a few ticks so optional/nested state (goals, memory, events) is populated. for tick in range(1, 4): apply_tick_plan( world, tick, TickPlan( source="test", directives=[ NpcDirective(npc_id=world.npcs[0].id, action="speak", target_npc_id=world.npcs[1].id, message=f"hi {tick}") ], ), ) restored = world_from_dict(world.to_dict()) assert restored.to_dict() == world.to_dict() def test_world_survives_json_round_trip() -> None: world = create_world(_config(npc_count=5)) encoded = json.loads(world.to_json()) restored = world_from_dict(encoded) assert restored.to_dict() == world.to_dict() def test_player_manager_round_trip() -> None: players = PlayerManager() character = players.create(name="Pilot", icon="*", country_id="qwen", created_tick=3) character.pending_action = {"action": "move", "target": {"x": 1.0, "y": 0.0, "z": 2.0}} character.spawned = True restored = PlayerManager() restored.restore(players.snapshot()) again = restored.get(character.token) assert again.name == "Pilot" assert again.country_id == "qwen" assert again.npc_id == character.npc_id assert again.spawned is True assert again.pending_action == character.pending_action # Counter is preserved so new ids do not collide with restored ones. new_character = restored.create(name="Second", icon="", created_tick=5) assert new_character.npc_id != character.npc_id def test_snapshot_store_loads_newest_valid_slot(tmp_path) -> None: store = SnapshotStore(tmp_path) world = create_world(_config(npc_count=3)) store.save(build_snapshot(world, PlayerManager().snapshot())) world.tick = 7 store.save(build_snapshot(world, PlayerManager().snapshot())) loaded = store.load() assert loaded is not None assert loaded["tick"] == 7 restored_world, _ = restore_snapshot(loaded) assert restored_world.tick == 7 def test_snapshot_store_falls_back_when_newest_slot_is_corrupt(tmp_path) -> None: store = SnapshotStore(tmp_path) world = create_world(_config(npc_count=3)) store.save(build_snapshot(world, PlayerManager().snapshot())) # slot 0, tick 0 world.tick = 5 store.save(build_snapshot(world, PlayerManager().snapshot())) # slot 1, tick 5 # Corrupt the newest slot (slot 1) as if a write had been torn. (tmp_path / "world_state.1.json").write_text("{ broken", encoding="utf-8") fresh = SnapshotStore(tmp_path) loaded = fresh.load() assert loaded is not None assert loaded["tick"] == 0 # falls back to the older, intact slot def test_snapshot_store_clear_deletes_slots(tmp_path) -> None: store = SnapshotStore(tmp_path) world = create_world(_config(npc_count=3)) store.save(build_snapshot(world, PlayerManager().snapshot())) world.tick = 4 store.save(build_snapshot(world, PlayerManager().snapshot())) store.clear() assert not list(tmp_path.glob("world_state.*.json")) assert store.load() is None # Clearing again (nothing on disk) is a no-op, not an error. store.clear() # The next save starts the ping-pong from slot 0 again. store.save(build_snapshot(world, PlayerManager().snapshot())) assert (tmp_path / "world_state.0.json").exists() def test_background_writer_persists_latest(tmp_path) -> None: store = SnapshotStore(tmp_path) writer = BackgroundSnapshotWriter(store) world = create_world(_config(npc_count=2)) for tick in range(1, 6): world.tick = tick writer.submit(build_snapshot(world, PlayerManager().snapshot())) writer.close() loaded = SnapshotStore(tmp_path).load() assert loaded is not None assert loaded["tick"] == 5 def _config(*, npc_count: int) -> GameConfig: return GameConfig( world=WorldConfig(width=80, depth=80, terrain="plain_green", seed=42), npcs=NpcConfig(count=npc_count), simulation=SimulationConfig(tick_ms=500), server=ServerConfig(host="127.0.0.1", port=8000), )