diff --git a/core/simulation_engine.py b/core/simulation_engine.py index d47198e..6180a43 100644 --- a/core/simulation_engine.py +++ b/core/simulation_engine.py @@ -104,6 +104,7 @@ class SimulationEngine: def run(self): + print(self.world.current_buffer) while self.running: self._handle_frame() pygame.quit() @@ -200,11 +201,11 @@ class SimulationEngine: self.hud.manager.draw_ui(self.screen) self.hud.draw_splitters(self.screen) - self.hud.render_mouse_position(self.screen, self.camera, self.sim_view_rect) - self.hud.render_fps(self.screen, self.clock) - self.hud.render_tps(self.screen, self.actual_tps) - self.hud.render_tick_count(self.screen, self.total_ticks) - self.hud.render_selected_objects_info(self.screen, self.input_handler.selected_objects) + # self.hud.render_mouse_position(self.screen, self.camera, self.sim_view_rect) + # self.hud.render_fps(self.screen, self.clock) + # self.hud.render_tps(self.screen, self.actual_tps) + # self.hud.render_tick_count(self.screen, self.total_ticks) + # self.hud.render_selected_objects_info(self.screen, self.input_handler.selected_objects) self.hud.render_legend(self.screen, self.input_handler.show_legend) self.hud.render_pause_indicator(self.screen, self.input_handler.is_paused) if self.input_handler.selected_objects: diff --git a/pyproject.toml b/pyproject.toml index 0d21a59..285da37 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,5 +14,6 @@ dependencies = [ [dependency-groups] dev = [ + "psutil>=7.0.0", "ruff>=0.11.12", ] diff --git a/tests/benchmarking.py b/tests/benchmarking.py new file mode 100644 index 0000000..a9c5148 --- /dev/null +++ b/tests/benchmarking.py @@ -0,0 +1,93 @@ +import time +import random +import statistics +import hashlib +import pickle + +class HeadlessSimulationBenchmark: + def __init__(self, setup_world, random_seed=42): + """ + :param setup_world: Callable that returns a World instance. + :param random_seed: Seed for random number generation. + """ + self.setup_world = setup_world + self.random_seed = random_seed + self.world = None + self.tps_history = [] + self._running = False + self.ticks_elapsed_time = None # Track time for designated ticks + + def set_random_seed(self, seed): + self.random_seed = seed + random.seed(seed) + + def start(self, ticks=100, max_seconds=None): + self.set_random_seed(self.random_seed) + self.world = self.setup_world(self.random_seed) + self.tps_history.clear() + self._running = True + + tick_count = 0 + start_time = time.perf_counter() + last_time = start_time + + # For precise tick timing + tick_timing_start = None + + if ticks is not None: + tick_timing_start = time.perf_counter() + + while self._running and (ticks is None or tick_count < ticks): + self.world.tick_all() + tick_count += 1 + now = time.perf_counter() + elapsed = now - last_time + if elapsed > 0: + self.tps_history.append(1.0 / elapsed) + last_time = now + if max_seconds and (now - start_time) > max_seconds: + break + + if ticks is not None: + tick_timing_end = time.perf_counter() + self.ticks_elapsed_time = tick_timing_end - tick_timing_start + else: + self.ticks_elapsed_time = None + + self._running = False + + def stop(self): + self._running = False + + def get_tps_history(self): + return self.tps_history + + def get_tps_average(self): + return statistics.mean(self.tps_history) if self.tps_history else 0.0 + + def get_tps_stddev(self): + return statistics.stdev(self.tps_history) if len(self.tps_history) > 1 else 0.0 + + def get_simulation_hash(self): + # Serialize the world state and hash it for determinism checks + state = [] + for obj in self.world.get_objects(): + state.append(( + type(obj).__name__, + getattr(obj, "position", None), + getattr(obj, "rotation", None), + getattr(obj, "flags", None), + getattr(obj, "interaction_radius", None), + getattr(obj, "max_visual_width", None), + )) + state_bytes = pickle.dumps(state) + return hashlib.sha256(state_bytes).hexdigest() + + def get_summary(self): + return { + "tps_avg": self.get_tps_average(), + "tps_stddev": self.get_tps_stddev(), + "ticks": len(self.tps_history), + "simulation_hash": self.get_simulation_hash(), + "ticks_elapsed_time": self.ticks_elapsed_time, + } \ No newline at end of file diff --git a/tests/test_determinism.py b/tests/test_determinism.py new file mode 100644 index 0000000..ff5c37c --- /dev/null +++ b/tests/test_determinism.py @@ -0,0 +1,57 @@ +import pytest +import random + +from world.world import World, Position, Rotation +from world.objects import FoodObject, DefaultCell +from tests.benchmarking import HeadlessSimulationBenchmark + +# Hardcoded simulation parameters (copied from config/constants.py) +CELL_SIZE = 20 +GRID_WIDTH = 30 +GRID_HEIGHT = 25 +FOOD_OBJECTS_COUNT = 500 +RANDOM_SEED = 12345 + +def _setup_world(seed=RANDOM_SEED): + world = World(CELL_SIZE, (CELL_SIZE * GRID_WIDTH, CELL_SIZE * GRID_HEIGHT)) + random.seed(seed) + + half_width = GRID_WIDTH * CELL_SIZE // 2 + half_height = GRID_HEIGHT * CELL_SIZE // 2 + + for _ in range(FOOD_OBJECTS_COUNT): + x = random.randint(-half_width, half_width) + y = random.randint(-half_height, half_height) + world.add_object(FoodObject(Position(x=x, y=y))) + + for _ in range(300): + new_cell = DefaultCell( + Position(x=random.randint(-half_width, half_width), y=random.randint(-half_height, half_height)), + Rotation(angle=0) + ) + new_cell.behavioral_model = new_cell.behavioral_model.mutate(3) + world.add_object(new_cell) + + return world + +def test_simulation_determinism(): + bench1 = HeadlessSimulationBenchmark(lambda seed: _setup_world(seed), random_seed=RANDOM_SEED) + bench2 = HeadlessSimulationBenchmark(lambda seed: _setup_world(seed), random_seed=RANDOM_SEED) + + bench1.start(ticks=100) + bench2.start(ticks=100) + + hash1 = bench1.get_simulation_hash() + hash2 = bench2.get_simulation_hash() + + assert hash1 == hash2, f"Simulation hashes differ: {hash1} != {hash2}" + +def test_simulation_benchmark(): + bench = HeadlessSimulationBenchmark(lambda seed: _setup_world(seed), random_seed=RANDOM_SEED+1) + tick_count = 100 + bench.start(ticks=tick_count) + summary = bench.get_summary() + print(f"{tick_count} ticks took {summary.get('ticks_elapsed_time', 0):.4f} seconds, TPS avg: {summary['tps_avg']:.2f}, stddev: {summary['tps_stddev']:.2f}") + + assert summary['tps_avg'] > 0, "Average TPS should be greater than zero" + assert summary['ticks_elapsed_time'] > 0, "Elapsed time should be greater than zero" \ No newline at end of file diff --git a/tests/test_world.py b/tests/test_world.py index 744b850..a5825f9 100644 --- a/tests/test_world.py +++ b/tests/test_world.py @@ -1,10 +1,12 @@ import pytest -from world.world import World, Position, BaseEntity +from world.world import World, Position, BaseEntity, Rotation class DummyEntity(BaseEntity): - def __init__(self, position): - super().__init__(position) + def __init__(self, position, rotation=None): + if rotation is None: + rotation = Rotation(angle=0) + super().__init__(position, rotation) self.ticked = False self.rendered = False @@ -83,9 +85,6 @@ def test_tick_all_calls_tick(world): def test_add_object_out_of_bounds(world): entity = DummyEntity(Position(x=1000, y=1000)) - world.add_object(entity) - entity = world.get_objects()[0] - - assert entity.position.x == 49 and entity.position.y == 49 + assert entity.position.x == 49 and entity.position.y == 49 \ No newline at end of file diff --git a/uv.lock b/uv.lock index 411c1ef..5925d6a 100644 --- a/uv.lock +++ b/uv.lock @@ -53,6 +53,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "psutil" }, { name = "ruff" }, ] @@ -67,7 +68,10 @@ requires-dist = [ ] [package.metadata.requires-dev] -dev = [{ name = "ruff", specifier = ">=0.11.12" }] +dev = [ + { name = "psutil", specifier = ">=7.0.0" }, + { name = "ruff", specifier = ">=0.11.12" }, +] [[package]] name = "filelock" @@ -206,6 +210,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, ] +[[package]] +name = "psutil" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003, upload-time = "2025-02-13T21:54:07.946Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051, upload-time = "2025-02-13T21:54:12.36Z" }, + { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535, upload-time = "2025-02-13T21:54:16.07Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004, upload-time = "2025-02-13T21:54:18.662Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986, upload-time = "2025-02-13T21:54:21.811Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544, upload-time = "2025-02-13T21:54:24.68Z" }, + { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053, upload-time = "2025-02-13T21:54:34.31Z" }, + { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885, upload-time = "2025-02-13T21:54:37.486Z" }, +] + [[package]] name = "pydantic" version = "2.11.5"