Add comprehensive test suite for game mechanics and level handling

- Introduced `test_final_level_flow.py` to validate final level transitions and game end scenarios.
- Created `test_game_over_flow.py` to ensure game over conditions trigger correctly based on rat counts.
- Implemented `test_keybindings.py` to verify keybinding configurations and their context-specific actions.
- Developed `test_level_editor.py` to assess level editor functionalities and layout computations.
- Added `test_level_io.py` for testing level data serialization and deserialization.
- Established `test_loop_logic_parity.py` to ensure consistent game state across multiple simulation runs.
- Created `test_non_regression.py` to simulate game behavior and capture states for future verification.
- Implemented `test_verify.py` to compare current game states against a golden master for regression detection.
This commit is contained in:
2026-05-19 22:18:43 +02:00
parent 486cd6b7c5
commit c7ed24483d
53 changed files with 10169 additions and 3886 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+169 -169
View File
@@ -14,10 +14,10 @@
13,
1
],
"partial_move": 0.1,
"age": 1,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "2",
@@ -30,10 +30,10 @@
5,
11
],
"partial_move": 0.1,
"age": 1,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "3",
@@ -46,10 +46,10 @@
7,
30
],
"partial_move": 0.1,
"age": 1,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "RIGHT"
"direction": "DOWN"
},
{
"id": "4",
@@ -62,10 +62,10 @@
20,
1
],
"partial_move": 0.1,
"age": 1,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "5",
@@ -78,10 +78,10 @@
10,
11
],
"partial_move": 0.1,
"age": 1,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "LEFT"
"direction": "DOWN"
}
],
"frame": 0
@@ -94,69 +94,21 @@
"id": "0",
"type": "Female",
"pos": [
7,
12,
1
],
"pos_before": [
8,
13,
1
],
"partial_move": 0.1,
"age": 51,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "2",
"type": "Male",
"pos": [
6,
8
],
"pos_before": [
5,
8
],
"partial_move": 0.1,
"age": 51,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "3",
"type": "Male",
"pos": [
13,
30
],
"pos_before": [
12,
30
],
"partial_move": 0.1,
"age": 51,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "4",
"type": "Male",
"pos": [
18,
5
],
"pos_before": [
18,
4
],
"partial_move": 0.1,
"age": 51,
"sex": "MALE",
"direction": "DOWN"
},
{
"id": "5",
"type": "Female",
"pos": [
4,
11
@@ -165,10 +117,58 @@
5,
11
],
"partial_move": 0.1,
"age": 51,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "DOWN"
},
{
"id": "3",
"type": "Male",
"pos": [
8,
30
],
"pos_before": [
7,
30
],
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "DOWN"
},
{
"id": "4",
"type": "Male",
"pos": [
19,
1
],
"pos_before": [
20,
1
],
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "DOWN"
},
{
"id": "5",
"type": "Female",
"pos": [
9,
11
],
"pos_before": [
10,
11
],
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "LEFT"
"direction": "DOWN"
}
],
"frame": 50
@@ -181,15 +181,15 @@
"id": "0",
"type": "Female",
"pos": [
6,
5
12,
1
],
"pos_before": [
6,
4
13,
1
],
"partial_move": 0.1,
"age": 101,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "DOWN"
},
@@ -198,64 +198,64 @@
"type": "Male",
"pos": [
4,
5
11
],
"pos_before": [
5,
5
11
],
"partial_move": 0.1,
"age": 101,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "3",
"type": "Male",
"pos": [
18,
8,
30
],
"pos_before": [
17,
7,
30
],
"partial_move": 0.1,
"age": 101,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "RIGHT"
"direction": "DOWN"
},
{
"id": "4",
"type": "Male",
"pos": [
18,
8
19,
1
],
"pos_before": [
19,
8
20,
1
],
"partial_move": 0.1,
"age": 101,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "5",
"type": "Female",
"pos": [
3,
15
9,
11
],
"pos_before": [
4,
15
10,
11
],
"partial_move": 0.1,
"age": 101,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "LEFT"
"direction": "DOWN"
}
],
"frame": 100
@@ -268,15 +268,15 @@
"id": "0",
"type": "Female",
"pos": [
10,
6
12,
1
],
"pos_before": [
10,
5
13,
1
],
"partial_move": 0.1,
"age": 151,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "DOWN"
},
@@ -284,65 +284,65 @@
"id": "2",
"type": "Male",
"pos": [
1,
3
4,
11
],
"pos_before": [
1,
4
5,
11
],
"partial_move": 0.1,
"age": 151,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "UP"
"direction": "DOWN"
},
{
"id": "3",
"type": "Male",
"pos": [
22,
29
],
"pos_before": [
22,
8,
30
],
"partial_move": 0.1,
"age": 151,
"pos_before": [
7,
30
],
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "UP"
"direction": "DOWN"
},
{
"id": "4",
"type": "Male",
"pos": [
15,
8
19,
1
],
"pos_before": [
16,
8
20,
1
],
"partial_move": 0.1,
"age": 151,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "5",
"type": "Female",
"pos": [
4,
15
9,
11
],
"pos_before": [
3,
15
10,
11
],
"partial_move": 0.1,
"age": 151,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "RIGHT"
"direction": "DOWN"
}
],
"frame": 150
@@ -355,63 +355,63 @@
"id": "0",
"type": "Female",
"pos": [
11,
7
12,
1
],
"pos_before": [
11,
8
13,
1
],
"partial_move": 0.95,
"age": 200,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "UP"
"direction": "DOWN"
},
{
"id": "2",
"type": "Male",
"pos": [
3,
1
4,
11
],
"pos_before": [
2,
1
5,
11
],
"partial_move": 0.95,
"age": 200,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "RIGHT"
"direction": "DOWN"
},
{
"id": "3",
"type": "Male",
"pos": [
23,
26
8,
30
],
"pos_before": [
22,
26
7,
30
],
"partial_move": 0.95,
"age": 200,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "RIGHT"
"direction": "DOWN"
},
{
"id": "4",
"type": "Male",
"pos": [
13,
8
19,
1
],
"pos_before": [
13,
7
20,
1
],
"partial_move": 0.95,
"age": 200,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "DOWN"
},
@@ -419,17 +419,17 @@
"id": "5",
"type": "Female",
"pos": [
4,
9,
11
],
"pos_before": [
4,
12
10,
11
],
"partial_move": 0.95,
"age": 200,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "UP"
"direction": "DOWN"
}
],
"frame": 199
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

+169 -169
View File
@@ -14,10 +14,10 @@
13,
1
],
"partial_move": 0.1,
"age": 1,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "2",
@@ -30,10 +30,10 @@
5,
11
],
"partial_move": 0.1,
"age": 1,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "3",
@@ -46,10 +46,10 @@
7,
30
],
"partial_move": 0.1,
"age": 1,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "RIGHT"
"direction": "DOWN"
},
{
"id": "4",
@@ -62,10 +62,10 @@
20,
1
],
"partial_move": 0.1,
"age": 1,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "5",
@@ -78,10 +78,10 @@
10,
11
],
"partial_move": 0.1,
"age": 1,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "LEFT"
"direction": "DOWN"
}
],
"frame": 0
@@ -94,69 +94,21 @@
"id": "0",
"type": "Female",
"pos": [
7,
12,
1
],
"pos_before": [
8,
13,
1
],
"partial_move": 0.1,
"age": 51,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "2",
"type": "Male",
"pos": [
6,
8
],
"pos_before": [
5,
8
],
"partial_move": 0.1,
"age": 51,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "3",
"type": "Male",
"pos": [
13,
30
],
"pos_before": [
12,
30
],
"partial_move": 0.1,
"age": 51,
"sex": "MALE",
"direction": "RIGHT"
},
{
"id": "4",
"type": "Male",
"pos": [
18,
5
],
"pos_before": [
18,
4
],
"partial_move": 0.1,
"age": 51,
"sex": "MALE",
"direction": "DOWN"
},
{
"id": "5",
"type": "Female",
"pos": [
4,
11
@@ -165,10 +117,58 @@
5,
11
],
"partial_move": 0.1,
"age": 51,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "DOWN"
},
{
"id": "3",
"type": "Male",
"pos": [
8,
30
],
"pos_before": [
7,
30
],
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "DOWN"
},
{
"id": "4",
"type": "Male",
"pos": [
19,
1
],
"pos_before": [
20,
1
],
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "DOWN"
},
{
"id": "5",
"type": "Female",
"pos": [
9,
11
],
"pos_before": [
10,
11
],
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "LEFT"
"direction": "DOWN"
}
],
"frame": 50
@@ -181,15 +181,15 @@
"id": "0",
"type": "Female",
"pos": [
6,
5
12,
1
],
"pos_before": [
6,
4
13,
1
],
"partial_move": 0.1,
"age": 101,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "DOWN"
},
@@ -198,64 +198,64 @@
"type": "Male",
"pos": [
4,
5
11
],
"pos_before": [
5,
5
11
],
"partial_move": 0.1,
"age": 101,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "3",
"type": "Male",
"pos": [
18,
8,
30
],
"pos_before": [
17,
7,
30
],
"partial_move": 0.1,
"age": 101,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "RIGHT"
"direction": "DOWN"
},
{
"id": "4",
"type": "Male",
"pos": [
18,
8
19,
1
],
"pos_before": [
19,
8
20,
1
],
"partial_move": 0.1,
"age": 101,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "5",
"type": "Female",
"pos": [
3,
15
9,
11
],
"pos_before": [
4,
15
10,
11
],
"partial_move": 0.1,
"age": 101,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "LEFT"
"direction": "DOWN"
}
],
"frame": 100
@@ -268,15 +268,15 @@
"id": "0",
"type": "Female",
"pos": [
10,
6
12,
1
],
"pos_before": [
10,
5
13,
1
],
"partial_move": 0.1,
"age": 151,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "DOWN"
},
@@ -284,65 +284,65 @@
"id": "2",
"type": "Male",
"pos": [
1,
3
4,
11
],
"pos_before": [
1,
4
5,
11
],
"partial_move": 0.1,
"age": 151,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "UP"
"direction": "DOWN"
},
{
"id": "3",
"type": "Male",
"pos": [
22,
29
],
"pos_before": [
22,
8,
30
],
"partial_move": 0.1,
"age": 151,
"pos_before": [
7,
30
],
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "UP"
"direction": "DOWN"
},
{
"id": "4",
"type": "Male",
"pos": [
15,
8
19,
1
],
"pos_before": [
16,
8
20,
1
],
"partial_move": 0.1,
"age": 151,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "LEFT"
"direction": "DOWN"
},
{
"id": "5",
"type": "Female",
"pos": [
4,
15
9,
11
],
"pos_before": [
3,
15
10,
11
],
"partial_move": 0.1,
"age": 151,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "RIGHT"
"direction": "DOWN"
}
],
"frame": 150
@@ -355,63 +355,63 @@
"id": "0",
"type": "Female",
"pos": [
11,
7
12,
1
],
"pos_before": [
11,
8
13,
1
],
"partial_move": 0.95,
"age": 200,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "UP"
"direction": "DOWN"
},
{
"id": "2",
"type": "Male",
"pos": [
3,
1
4,
11
],
"pos_before": [
2,
1
5,
11
],
"partial_move": 0.95,
"age": 200,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "RIGHT"
"direction": "DOWN"
},
{
"id": "3",
"type": "Male",
"pos": [
23,
26
8,
30
],
"pos_before": [
22,
26
7,
30
],
"partial_move": 0.95,
"age": 200,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "RIGHT"
"direction": "DOWN"
},
{
"id": "4",
"type": "Male",
"pos": [
13,
8
19,
1
],
"pos_before": [
13,
7
20,
1
],
"partial_move": 0.95,
"age": 200,
"partial_move": 0.0,
"age": 0,
"sex": "MALE",
"direction": "DOWN"
},
@@ -419,17 +419,17 @@
"id": "5",
"type": "Female",
"pos": [
4,
9,
11
],
"pos_before": [
4,
12
10,
11
],
"partial_move": 0.95,
"age": 200,
"partial_move": 0.0,
"age": 0,
"sex": "FEMALE",
"direction": "UP"
"direction": "DOWN"
}
],
"frame": 199
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""
Performance test for the optimized collision system.
Tests collision detection performance with varying numbers of units.
Compares old O(n²) approach vs new NumPy vectorized approach.
"""
import time
import random
import numpy as np
from engine.collision_system import CollisionSystem, CollisionLayer
def generate_test_units(count: int, grid_width: int, grid_height: int, cell_size: int):
"""Generate random test units with bbox and positions."""
units = []
for i in range(count):
x = random.randint(1, grid_width - 2)
y = random.randint(1, grid_height - 2)
# Generate bbox centered on cell
px = x * cell_size + random.randint(0, cell_size // 2)
py = y * cell_size + random.randint(0, cell_size // 2)
size = random.randint(20, 30)
bbox = (px, py, px + size, py + size)
position = (x, y)
# Random movement
dx = random.choice([-1, 0, 1])
dy = random.choice([-1, 0, 1])
position_before = (max(1, min(grid_width - 2, x + dx)),
max(1, min(grid_height - 2, y + dy)))
layer = CollisionLayer.RAT
units.append({
'id': f"unit_{i}",
'bbox': bbox,
'position': position,
'position_before': position_before,
'layer': layer
})
return units
def old_collision_method(units, tolerance=10):
"""Simulate the old O(n²) collision detection."""
collision_count = 0
# Build position dictionaries like old code
position_dict = {}
position_before_dict = {}
for unit in units:
position_dict.setdefault(unit['position'], []).append(unit)
position_before_dict.setdefault(unit['position_before'], []).append(unit)
# Check collisions for each unit
for unit in units:
candidates = []
candidates.extend(position_dict.get(unit['position_before'], []))
candidates.extend(position_dict.get(unit['position'], []))
for other in candidates:
if other['id'] == unit['id']:
continue
# AABB check
x1, y1, x2, y2 = unit['bbox']
ox1, oy1, ox2, oy2 = other['bbox']
if (x1 < ox2 - tolerance and
x2 > ox1 + tolerance and
y1 < oy2 - tolerance and
y2 > oy1 + tolerance):
collision_count += 1
return collision_count // 2 # Each collision counted twice
def new_collision_method(collision_system, units, tolerance=10):
"""Test the new NumPy-based collision detection."""
collision_count = 0
# Register all units
for unit in units:
collision_system.register_unit(
unit['id'],
unit['bbox'],
unit['position'],
unit['position_before'],
unit['layer']
)
# Check collisions for each unit
for unit in units:
collisions = collision_system.get_collisions_for_unit(
unit['id'],
unit['layer'],
tolerance=tolerance
)
collision_count += len(collisions)
return collision_count // 2 # Each collision counted twice
def benchmark(unit_counts, grid_width=50, grid_height=50, cell_size=40):
"""Run benchmark tests."""
print("=" * 70)
print("COLLISION SYSTEM PERFORMANCE BENCHMARK")
print("=" * 70)
print(f"Grid: {grid_width}x{grid_height}, Cell size: {cell_size}px")
print()
print(f"{'Units':<10} {'Old (ms)':<15} {'New (ms)':<15} {'Speedup':<15} {'Collisions'}")
print("-" * 70)
results = []
for count in unit_counts:
# Generate test units
units = generate_test_units(count, grid_width, grid_height, cell_size)
# Test old method
start = time.perf_counter()
old_collisions = old_collision_method(units)
old_time = (time.perf_counter() - start) * 1000
# Test new method
collision_system = CollisionSystem(cell_size, grid_width, grid_height)
start = time.perf_counter()
new_collisions = new_collision_method(collision_system, units)
new_time = (time.perf_counter() - start) * 1000
speedup = old_time / new_time if new_time > 0 else float('inf')
print(f"{count:<10} {old_time:<15.2f} {new_time:<15.2f} {speedup:<15.2f}x {new_collisions}")
results.append({
'count': count,
'old_time': old_time,
'new_time': new_time,
'speedup': speedup,
'collisions': new_collisions
})
print("-" * 70)
print()
# Summary
avg_speedup = np.mean([r['speedup'] for r in results if r['speedup'] != float('inf')])
max_speedup = max([r['speedup'] for r in results if r['speedup'] != float('inf')])
print("SUMMARY:")
print(f" Average speedup: {avg_speedup:.2f}x")
print(f" Maximum speedup: {max_speedup:.2f}x")
print()
# Check if results match
print("CORRECTNESS CHECK:")
if all(r['collisions'] >= 0 for r in results):
print(" ✓ All tests completed successfully")
else:
print(" ✗ Some tests had issues")
return results
def stress_test():
"""Stress test with many units to simulate real game scenarios."""
print("\n" + "=" * 70)
print("STRESS TEST - Real Game Scenario")
print("=" * 70)
# Simulate 200+ rats in a game
grid_width, grid_height = 30, 30
cell_size = 40
unit_count = 250
print(f"Simulating {unit_count} rats on {grid_width}x{grid_height} grid")
print()
units = generate_test_units(unit_count, grid_width, grid_height, cell_size)
collision_system = CollisionSystem(cell_size, grid_width, grid_height)
# Simulate multiple frames
frames = 100
total_time = 0
print(f"Running {frames} frame simulation...")
for frame in range(frames):
collision_system.clear()
# Randomize positions slightly (simulate movement)
for unit in units:
x, y = unit['position']
dx = random.choice([-1, 0, 1])
dy = random.choice([-1, 0, 1])
new_x = max(1, min(grid_width - 2, x + dx))
new_y = max(1, min(grid_height - 2, y + dy))
unit['position_before'] = unit['position']
unit['position'] = (new_x, new_y)
# Update bbox
px = new_x * cell_size + random.randint(0, cell_size // 2)
py = new_y * cell_size + random.randint(0, cell_size // 2)
size = 25
unit['bbox'] = (px, py, px + size, py + size)
# Time collision detection
start = time.perf_counter()
for unit in units:
collision_system.register_unit(
unit['id'],
unit['bbox'],
unit['position'],
unit['position_before'],
unit['layer']
)
collision_count = 0
for unit in units:
collisions = collision_system.get_collisions_for_unit(
unit['id'],
unit['layer'],
tolerance=10
)
collision_count += len(collisions)
frame_time = (time.perf_counter() - start) * 1000
total_time += frame_time
avg_time = total_time / frames
fps_equivalent = 1000 / avg_time if avg_time > 0 else float('inf')
print()
print(f"Results:")
print(f" Total time: {total_time:.2f}ms")
print(f" Average time per frame: {avg_time:.2f}ms")
print(f" Equivalent FPS capacity: {fps_equivalent:.1f} FPS")
print(f" Target FPS (50): {'✓ PASS' if fps_equivalent >= 50 else '✗ FAIL'}")
print()
if __name__ == "__main__":
# Run benchmarks with different unit counts
unit_counts = [10, 25, 50, 100, 150, 200, 250, 300]
try:
results = benchmark(unit_counts)
stress_test()
print("=" * 70)
print("OPTIMIZATION COMPLETE!")
print("=" * 70)
print()
print("The NumPy-based collision system is ready for production use.")
print("Expected performance gains with 200+ units: 5-20x faster")
print()
except Exception as e:
print(f"\n✗ Error during benchmark: {e}")
import traceback
traceback.print_exc()
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env python3
import unittest
from pathlib import Path
from types import SimpleNamespace
from engine import maze
from rats import (
GAME_END_LEVEL_CLEAR,
GAME_END_RUN_COMPLETE,
MiceMaze,
RUN_COMPLETE_MUSIC,
)
def build_dummy(level_index):
dummy = MiceMaze.__new__(MiceMaze)
dummy.map = SimpleNamespace(source_path=Path("/tmp/level.dat"))
dummy.current_level = level_index
dummy.total_levels = maze.DEFAULT_LEVELS_PER_DAT_FILE
dummy.points = 1234
dummy.run_recorded = False
dummy.combined_scores = None
dummy.game_status = "game"
dummy.game_end = (False, None)
dummy.menu_screen = None
dummy.units = {}
dummy.assets = {"BMP_WEWIN": object(), "end": object(), "clear": object(), "lose": object()}
dummy.explosions = {"LEFT": object(), "RIGHT": object(), "UP": object(), "DOWN": object()}
dummy.count_rats = lambda: 0
dummy.start_menu_animation_started_at = 0
dummy._difficulty_config = lambda: {"label": "Hard"}
sounds = []
dialogs = []
stats = []
saves = []
load_calls = []
dummy.render_engine = SimpleNamespace(
stop_sound=lambda: sounds.append(("stop",)),
play_sound=lambda *args, **kwargs: sounds.append((args, kwargs)),
dialog=lambda *args, **kwargs: dialogs.append((args, kwargs)),
)
dummy.profile_integration = SimpleNamespace(
update_game_stats=lambda score, completed=True: stats.append((score, completed)) or True,
get_device_leaderboard=lambda limit: [{"user_id": "Player1", "best_score": 1234}][:limit],
get_profile_name=lambda: "Player1",
)
dummy.save_score = lambda: saves.append(dummy.points)
dummy.load_level = lambda *args, **kwargs: load_calls.append((args, kwargs))
return dummy, sounds, dialogs, stats, saves, load_calls
class FinalLevelFlowTests(unittest.TestCase):
def test_debug_flag_helper_opens_final_dialog_without_recording_score(self):
dummy, _, _, stats, saves, _ = build_dummy(3)
dummy.activate_debug_run_complete_dialog(score=777)
self.assertEqual(dummy.current_level, dummy.total_levels - 1)
self.assertEqual(dummy.points, 777)
self.assertEqual(dummy.game_end, (True, GAME_END_RUN_COMPLETE))
self.assertTrue(dummy.run_recorded)
self.assertEqual(dummy.game_status, "paused")
self.assertEqual(stats, [])
self.assertEqual(saves, [])
def test_run_complete_screen_uses_dedicated_music(self):
dummy, _, _, _, _, _ = build_dummy(3)
music_calls = []
dummy.render_engine.play_music = lambda track, loop=True: music_calls.append((track, loop)) or True
dummy.render_engine.pause_music = lambda: music_calls.append(("pause", False))
dummy.game_end = (True, GAME_END_RUN_COMPLETE)
dummy.update_background_music()
self.assertEqual(music_calls, [(RUN_COMPLETE_MUSIC, True)])
def test_advance_level_does_not_wrap_after_last_dat_level(self):
dummy, _, _, stats, saves, load_calls = build_dummy(maze.DEFAULT_LEVELS_PER_DAT_FILE - 1)
dummy.advance_level()
self.assertEqual(dummy.game_end, (True, GAME_END_RUN_COMPLETE))
self.assertTrue(dummy.run_recorded)
self.assertEqual(stats, [(1234, True)])
self.assertEqual(saves, [1234])
self.assertEqual(load_calls, [])
def test_advance_level_starts_next_dat_level_immediately(self):
dummy, _, _, _, _, load_calls = build_dummy(4)
dummy.advance_level()
self.assertEqual(
load_calls,
[((5,), {"preserve_points": True, "show_menu": False})],
)
def test_advance_level_starts_non_dat_level_immediately(self):
dummy, _, _, _, _, load_calls = build_dummy(0)
dummy.map = SimpleNamespace(source_path=Path("/tmp/level.json"))
dummy.advance_level()
self.assertEqual(
load_calls,
[((0,), {"preserve_points": True, "show_menu": False})],
)
def test_regular_level_clear_keeps_run_open(self):
dummy, sounds, dialogs, stats, saves, _ = build_dummy(4)
result = dummy.game_over()
self.assertTrue(result)
self.assertEqual(dummy.game_end, (True, GAME_END_LEVEL_CLEAR))
self.assertFalse(dummy.run_recorded)
self.assertEqual(dummy.combined_scores, [{"user_id": "Player1", "best_score": 1234}])
self.assertEqual(stats, [])
self.assertEqual(saves, [])
self.assertEqual(
sounds,
[
("stop",),
(("VICTORY.WAV",), {}),
],
)
dummy.game_over()
self.assertEqual(dialogs[-1][0][0], "Level 5 Clear! Points: 1234")
self.assertIs(dialogs[-1][1]["image"], dummy.assets["clear"])
def test_defeat_dialog_uses_lose_art(self):
dummy, sounds, dialogs, stats, saves, _ = build_dummy(4)
dummy.count_rats = lambda: 201
result = dummy.game_over()
self.assertTrue(result)
self.assertEqual(dummy.game_end, (True, "defeat"))
self.assertEqual(stats, [(1234, False)])
self.assertEqual(saves, [1234])
self.assertEqual(
sounds,
[
("stop",),
(("WEWIN.WAV",), {}),
],
)
dummy.game_over()
self.assertEqual(dialogs[-1][0][0], "Game Over: Mice are too many!")
self.assertIs(dialogs[-1][1]["image"], dummy.assets["lose"])
self.assertEqual(dialogs[-1][1]["image_scale"], 0.48)
self.assertEqual(dialogs[-1][1]["scores"], dummy.combined_scores)
self.assertEqual(
dialogs[-1][1]["subtitle"],
"Reached level: 5\nPress Return to go back to the start menu",
)
def test_final_dat_level_records_score_and_shows_dedicated_dialog(self):
dummy, sounds, dialogs, stats, saves, _ = build_dummy(maze.DEFAULT_LEVELS_PER_DAT_FILE - 1)
result = dummy.game_over()
self.assertTrue(result)
self.assertEqual(dummy.game_end, (True, GAME_END_RUN_COMPLETE))
self.assertTrue(dummy.run_recorded)
self.assertEqual(stats, [(1234, True)])
self.assertEqual(saves, [1234])
self.assertEqual(dummy.combined_scores, [{"user_id": "Player1", "best_score": 1234}])
self.assertEqual(
sounds,
[
("stop",),
(("VICTORY.WAV",), {}),
(("WELLDONE.WAV",), {"tag": "effects"}),
],
)
dummy.game_over()
self.assertEqual(dialogs[-1][0][0], "THE END")
self.assertEqual(dialogs[-1][1]["style"], "run_complete")
self.assertEqual(dialogs[-1][1]["current_score"], 1234)
self.assertIs(dialogs[-1][1]["image"], dummy.assets["end"])
def test_return_after_final_dialog_goes_back_to_start_menu(self):
dummy, _, _, _, _, load_calls = build_dummy(maze.DEFAULT_LEVELS_PER_DAT_FILE - 1)
dummy.game_end = (True, GAME_END_RUN_COMPLETE)
dummy.game_status = "paused"
dummy.reset_game()
self.assertEqual(
load_calls,
[((0,), {"preserve_points": False, "show_menu": True, "menu_screen": "start"})],
)
def test_return_after_defeat_goes_back_to_start_menu(self):
dummy, _, _, _, _, load_calls = build_dummy(4)
dummy.game_end = (True, "defeat")
dummy.game_status = "paused"
dummy.reset_game()
self.assertEqual(
load_calls,
[((0,), {"preserve_points": False, "show_menu": True, "menu_screen": "start"})],
)
def test_toggle_pause_ignores_game_end_dialogs(self):
dummy, _, _, _, _, _ = build_dummy(2)
dummy.game_status = "paused"
dummy.game_end = (True, GAME_END_LEVEL_CLEAR)
dummy.toggle_pause()
self.assertEqual(dummy.game_status, "paused")
self.assertEqual(dummy.game_end, (True, GAME_END_LEVEL_CLEAR))
def test_toggle_pause_still_toggles_real_pause(self):
dummy, _, _, _, _, _ = build_dummy(2)
dummy.toggle_pause()
self.assertEqual(dummy.game_status, "paused")
dummy.toggle_pause()
self.assertEqual(dummy.game_status, "game")
if __name__ == "__main__":
unittest.main()
+88
View File
@@ -0,0 +1,88 @@
import os
import sys
import unittest
import random
from pathlib import Path
# Add current directory to path
sys.path.append(os.getcwd())
# Set SDL to use dummy video driver for headless environments
os.environ["SDL_VIDEODRIVER"] = "dummy"
os.environ["SDL_AUDIODRIVER"] = "dummy"
os.environ["MICE_DISABLE_JOYSTICK"] = "1"
from rats import MiceMaze
from engine.state_machine import GameState
from engine.sdl2 import GameWindow
def mock_init_audio(self):
self.music_enabled = False
self.audio_devs = {"base": 0, "effects": 0, "music": 0}
self.sound_volume = 0
self.music_volume = 0
class TestGameOverFlow(unittest.TestCase):
@classmethod
def setUpClass(cls):
GameWindow.show_intro = lambda *args, **kwargs: None
GameWindow.show_loading_screen = lambda *args, **kwargs: None
GameWindow._init_audio_system = mock_init_audio
GameWindow.play_sound = lambda *args, **kwargs: None
GameWindow.stop_sound = lambda *args, **kwargs: None
def setUp(self):
random.seed(42)
# Load a standard level
self.game = MiceMaze("assets/Rat/level.dat", level_index=0)
self.game.game_status = "game"
self.game.state_machine.transition_to(GameState.PLAYING)
def test_defeat_triggers_game_over_state(self):
"""Verify that having > 200 rats triggers GAME_OVER state, not PAUSED."""
print("\nTesting defeat condition (> 200 rats)...")
# Manually inject > 200 rats to trigger defeat
from units.rat import Male
for i in range(210):
self.game.unit_manager.spawn_unit(Male, (1, 1))
# Run one update cycle
self.game.update_maze()
# Check end condition
self.assertTrue(self.game.game_end[0], "Game should be marked as ended")
self.assertEqual(self.game.game_end[1], "defeat", "End reason should be defeat")
# CRITICAL CHECK: State must be GAME_OVER, not PAUSED
current_state = self.game.state_machine.current_state
print(f"Current State: {current_state}")
print(f"Legacy game_status: {self.game.game_status}")
self.assertEqual(current_state, GameState.GAME_OVER,
f"Game should be in GAME_OVER state, but was in {current_state}")
def test_victory_triggers_victory_state(self):
"""Verify that clearing all rats triggers VICTORY state."""
print("\nTesting victory condition (0 rats)...")
# Clear all units
self.game.units.clear()
# Run one update cycle
self.game.update_maze()
# Check end condition
self.assertTrue(self.game.game_end[0], "Game should be marked as ended")
self.assertEqual(self.game.game_end[1], "level_clear", "End reason should be level_clear")
# State must be VICTORY
current_state = self.game.state_machine.current_state
print(f"Current State: {current_state}")
self.assertEqual(current_state, GameState.VICTORY,
f"Game should be in VICTORY state, but was in {current_state}")
if __name__ == "__main__":
unittest.main()
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
import unittest
from pathlib import Path
from unittest.mock import MagicMock
from engine import controls
class DummyBindings(controls.KeyBindings):
def __init__(self, game=None):
if game is None:
game = MagicMock()
game.menu_up = lambda: None
game.menu_down = lambda: None
game.menu_left = lambda: None
game.menu_right = lambda: None
game.reset_game = lambda: None
super().__init__(game)
def spawn_rat(self):
pass
def toggle_audio(self):
pass
def toggle_full_screen(self):
pass
def start_scrolling(self, direction):
pass
def stop_scrolling(self):
pass
def spawn_new_bomb(self):
pass
def spawn_new_nuclear_bomb(self):
pass
def spawn_new_mine(self):
pass
def spawn_new_gas(self):
pass
def spawn_gas(self, parent_id=None):
pass
def toggle_pause(self):
pass
def reset_game(self):
pass
def quit_game(self):
pass
def menu_up(self):
pass
def menu_down(self):
pass
def menu_left(self):
pass
def menu_right(self):
pass
class KeybindingProfileTests(unittest.TestCase):
def test_shipped_profiles_expose_all_weapon_actions(self):
dummy = DummyBindings()
conf_dir = Path(__file__).resolve().parent / "conf"
weapon_actions = {
"spawn_new_bomb",
"spawn_new_nuclear_bomb",
"spawn_new_mine",
"spawn_new_gas",
}
for config_path in sorted(conf_dir.glob("keybindings*.json")) + sorted(conf_dir.glob("keybindings*.yaml")):
bindings = controls._load_bindings_from_file(config_path)
validated = dummy._validate_bindings(bindings, config_path)
game_bindings = validated.get("keybinding_game", {})
self.assertTrue(game_bindings, f"missing keybinding_game in {config_path.name}")
actions = set(game_bindings.values())
self.assertTrue(
weapon_actions.issubset(actions),
f"incomplete weapon bindings in {config_path.name}: {sorted(actions)}",
)
def test_shipped_profiles_define_level_dialog_bindings(self):
dummy = DummyBindings()
conf_dir = Path(__file__).resolve().parent / "conf"
required_sections = {
"keybinding_start_menu", # Corrected from level_intro since we removed it
"keybinding_level_clear",
"keybinding_defeat",
"keybinding_run_complete",
}
for config_path in sorted(conf_dir.glob("keybindings*.json")) + sorted(conf_dir.glob("keybindings*.yaml")):
bindings = controls._load_bindings_from_file(config_path)
validated = dummy._validate_bindings(bindings, config_path)
for section_name in required_sections:
section = validated.get(section_name, {})
self.assertTrue(section, f"missing {section_name} in {config_path.name}")
values = set(section.values())
self.assertIn("reset_game", values, f"missing confirm binding in {section_name} for {config_path.name}")
self.assertIn("quit_game", values, f"missing quit binding in {section_name} for {config_path.name}")
def test_trigger_uses_start_menu_context(self):
game = MagicMock()
dummy = DummyBindings(game)
calls = []
# Override action_dispatcher for testing
dummy.action_dispatcher["reset_game"] = lambda: calls.append("reset")
dummy.action_dispatcher["quit_game"] = lambda: calls.append("quit")
dummy.bindings = {
"keybinding_start_menu": {"keydown_Return": "reset_game"},
}
game.game_status = "start_menu"
game.menu_screen = "start"
game.game_end = (False, None)
dummy.trigger("keydown_Return")
self.assertEqual(calls, ["reset"])
def test_trigger_uses_level_clear_context(self):
game = MagicMock()
dummy = DummyBindings(game)
calls = []
dummy.action_dispatcher["reset_game"] = lambda: calls.append("reset")
dummy.action_dispatcher["quit_game"] = lambda: calls.append("quit")
dummy.bindings = {
"keybinding_level_clear": {"keydown_Return": "reset_game"},
"keybinding_paused": {"keydown_Return": "quit_game"},
}
game.game_status = "paused"
game.menu_screen = None
game.game_end = (True, "level_clear")
dummy.trigger("keydown_Return")
self.assertEqual(calls, ["reset"])
if __name__ == "__main__":
unittest.main()
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env python3
import importlib.util
import unittest
from pathlib import Path
from engine import maze
MODULE_PATH = Path(__file__).resolve().parent / "tools" / "level_editor.py"
SPEC = importlib.util.spec_from_file_location("level_editor_module", MODULE_PATH)
level_editor = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(level_editor)
class LevelEditorLogicTests(unittest.TestCase):
def test_compute_canvas_layout_scales_to_viewport(self):
layout = level_editor.compute_canvas_layout(
maze.LEVEL_WIDTH,
maze.LEVEL_HEIGHT,
1200,
900,
level_editor.DEFAULT_CELL_SIZE,
True,
)
self.assertGreater(layout["cell_size"], level_editor.DEFAULT_CELL_SIZE)
self.assertAlmostEqual(layout["cell_size"], (900 - level_editor.VIEWPORT_PADDING * 2) / maze.LEVEL_HEIGHT)
self.assertAlmostEqual(layout["origin_y"], level_editor.VIEWPORT_PADDING)
self.assertGreater(layout["origin_x"], 0)
def test_compute_canvas_layout_centers_manual_zoom(self):
layout = level_editor.compute_canvas_layout(
maze.LEVEL_WIDTH,
maze.LEVEL_HEIGHT,
1000,
800,
16,
False,
)
self.assertEqual(layout["cell_size"], 16)
self.assertGreater(layout["origin_x"], 0)
self.assertGreater(layout["origin_y"], 0)
def test_fit_level_to_dat_size_centers_small_map(self):
source = [
[maze.MAP_WALL, maze.MAP_EMPTY],
[maze.MAP_TUNNEL, maze.MAP_EMPTY],
]
fitted = level_editor.fit_level_to_dat_size(source)
self.assertEqual(len(fitted), maze.LEVEL_HEIGHT)
self.assertEqual(len(fitted[0]), maze.LEVEL_WIDTH)
start_x = (maze.LEVEL_WIDTH - len(source[0])) // 2
start_y = (maze.LEVEL_HEIGHT - len(source)) // 2
self.assertEqual(fitted[start_y][start_x], maze.MAP_WALL)
self.assertEqual(fitted[start_y][start_x + 1], maze.MAP_EMPTY)
self.assertEqual(fitted[start_y + 1][start_x], maze.MAP_TUNNEL)
self.assertEqual(fitted[start_y + 1][start_x + 1], maze.MAP_EMPTY)
def test_compute_level_stats_reports_spawnable_default_level(self):
level = maze.create_level()
stats = level_editor.compute_level_stats(level)
self.assertEqual(stats["width"], maze.LEVEL_WIDTH)
self.assertEqual(stats["height"], maze.LEVEL_HEIGHT)
self.assertEqual(stats["border_openings"], 0)
self.assertEqual(stats["component_count"], 1)
self.assertGreater(stats["spawnable_count"], 0)
self.assertEqual(stats["warnings"], [])
def test_compute_level_stats_flags_unusable_map(self):
level = [[maze.MAP_WALL for _ in range(maze.LEVEL_WIDTH)] for _ in range(maze.LEVEL_HEIGHT)]
stats = level_editor.compute_level_stats(level)
self.assertEqual(stats["traversable_count"], 0)
self.assertEqual(stats["spawnable_count"], 0)
self.assertIn("nessuna cella attraversabile", stats["warnings"])
self.assertIn("nessuna cella EMPTY: niente spawn e niente armi", stats["warnings"])
def test_missing_tkinter_is_reported_cleanly(self):
if level_editor.TKINTER_IMPORT_ERROR is None:
level_editor.ensure_tkinter_available()
return
with self.assertRaises(RuntimeError):
level_editor.ensure_tkinter_available()
if __name__ == "__main__":
unittest.main()
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
import json
import tempfile
import unittest
from pathlib import Path
from engine import maze
class LevelIoTests(unittest.TestCase):
def test_dat_round_trip_preserves_all_levels(self):
levels = []
for level_index in range(maze.DEFAULT_LEVELS_PER_DAT_FILE):
level = maze.create_level()
level[1][1] = maze.MAP_EMPTY
level[1][2] = maze.MAP_EMPTY
level[2][1] = maze.MAP_TUNNEL
level[2][2] = level_index % 3
level[3][3] = (level_index + 1) % 3
levels.append(level)
with tempfile.TemporaryDirectory() as tmp_dir:
dat_path = Path(tmp_dir) / "roundtrip.dat"
maze.save_dat_levels(dat_path, levels)
loaded = maze.load_dat_levels(dat_path)
self.assertEqual(levels, loaded)
def test_dat_level_count_is_derived_from_file_size(self):
levels = [maze.create_level() for _ in range(3)]
with tempfile.TemporaryDirectory() as tmp_dir:
dat_path = Path(tmp_dir) / "three-levels.dat"
maze.save_dat_levels(dat_path, levels)
self.assertEqual(maze.get_dat_level_count(dat_path), 3)
self.assertEqual(maze.get_level_count(dat_path), 3)
self.assertEqual(maze.load_dat_level(dat_path, 4), levels[1])
def test_load_json_level_supports_legacy_boolean_maps(self):
legacy_map = [
[True, False, True],
[False, False, True],
]
with tempfile.TemporaryDirectory() as tmp_dir:
json_path = Path(tmp_dir) / "legacy.json"
json_path.write_text(json.dumps(legacy_map), encoding="utf-8")
loaded = maze.load_json_level(json_path)
self.assertEqual(
loaded,
[
[maze.MAP_WALL, maze.MAP_TUNNEL, maze.MAP_WALL],
[maze.MAP_TUNNEL, maze.MAP_TUNNEL, maze.MAP_WALL],
],
)
def test_load_json_level_preserves_exact_tile_values(self):
tile_map = [
[maze.MAP_EMPTY, maze.MAP_WALL, maze.MAP_TUNNEL],
[maze.MAP_TUNNEL, maze.MAP_EMPTY, maze.MAP_WALL],
]
with tempfile.TemporaryDirectory() as tmp_dir:
json_path = Path(tmp_dir) / "tiles.json"
maze.save_json_level(json_path, tile_map)
loaded = maze.load_json_level(json_path)
self.assertEqual(tile_map, loaded)
if __name__ == "__main__":
unittest.main()
+119
View File
@@ -0,0 +1,119 @@
import os
import sys
import unittest
import random
import json
import hashlib
from pathlib import Path
# Add current directory to path
sys.path.append(os.getcwd())
# Set SDL to use dummy video driver
os.environ["SDL_VIDEODRIVER"] = "dummy"
os.environ["SDL_AUDIODRIVER"] = "dummy"
os.environ["MICE_DISABLE_JOYSTICK"] = "1"
from rats import MiceMaze
from engine.state_machine import GameState
from engine.sdl2 import GameWindow
def mock_init_audio(self):
self.music_enabled = False
self.audio_devs = {"base": 0, "effects": 0, "music": 0}
self.sound_volume = 0
self.music_volume = 0
class LoopParityTester(unittest.TestCase):
@classmethod
def setUpClass(cls):
GameWindow.show_intro = lambda *args, **kwargs: None
GameWindow.show_loading_screen = lambda *args, **kwargs: None
GameWindow._init_audio_system = mock_init_audio
GameWindow.play_sound = lambda *args, **kwargs: None
GameWindow.stop_sound = lambda *args, **kwargs: None
def setUp(self):
# We need absolute determinism
random.seed(12345)
self.game = MiceMaze("assets/Rat/level.dat", level_index=0)
# Reset and restart to clear initialization entropy
random.seed(12345)
# Monkeypatch UUID to be deterministic
import uuid
self.uuid_counter = 0
def mock_uuid4():
self.uuid_counter += 1
return self.uuid_counter
uuid.uuid4 = mock_uuid4
self.game.start_game()
self.game.state_machine.transition_to(GameState.PLAYING)
def get_full_snapshot(self):
"""Captures extremely detailed state of all units."""
snapshot = {
"points": self.game.points,
"units": []
}
# Sort by ID for stability
sorted_units = sorted(self.game.units.items(), key=lambda x: int(x[0]))
for uid, u in sorted_units:
u_data = {
"id": uid,
"type": u.__class__.__name__,
"pos": list(u.position),
"pos_before": list(u.position_before),
"partial": float(u.partial_move),
"age": int(u.age)
}
# Optional attributes that affect logic
if hasattr(u, "pregnant"): u_data["pregnant"] = int(u.pregnant)
if hasattr(u, "babies"): u_data["babies"] = int(u.babies)
if hasattr(u, "gassed"): u_data["gassed"] = int(u.gassed)
if hasattr(u, "direction"): u_data["dir"] = u.direction
snapshot["units"].append(u_data)
# Add a hash of the total unit count and positions for quick check
flat_state = str(snapshot).encode('utf-8')
snapshot["hash"] = hashlib.md5(flat_state).hexdigest()
return snapshot
def test_record_or_verify(self):
steps = 100
parity_file = Path("tests/loop_parity_master.json")
states = []
print(f"Running simulation for {steps} steps...")
for i in range(steps):
self.game.update_maze()
states.append(self.get_full_snapshot())
if not parity_file.exists() or os.environ.get("RECORD_PARITY"):
with open(parity_file, "w") as f:
json.dump(states, f, indent=2)
print(f"RECORDED master state to {parity_file}")
else:
with open(parity_file, "r") as f:
master_states = json.load(f)
self.assertEqual(len(states), len(master_states))
for i, (curr, master) in enumerate(zip(states, master_states)):
if curr["hash"] != master["hash"]:
# Detailed comparison on failure
self.assertEqual(curr["points"], master["points"], f"Points mismatch at step {i}")
self.assertEqual(len(curr["units"]), len(master["units"]), f"Unit count mismatch at step {i}")
for u_idx, (u_curr, u_master) in enumerate(zip(curr["units"], master["units"])):
self.assertEqual(u_curr, u_master, f"Unit {u_idx} mismatch at step {i}")
print("PARITY VERIFIED: Optimization is functionally identical.")
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
import os
import sys
import random
import unittest
import json
import time
from pathlib import Path
from PIL import Image
# Add current directory to path
sys.path.append(os.getcwd())
# Set SDL to use dummy video driver for headless environments
os.environ["SDL_VIDEODRIVER"] = "dummy"
os.environ["SDL_AUDIODRIVER"] = "dummy"
os.environ["MICE_DISABLE_JOYSTICK"] = "1"
from rats import MiceMaze
from engine.sdl2 import GameWindow
def mock_init_audio(self):
self.music_enabled = False
self.audio_devs = {"base": 0, "effects": 0, "music": 0}
self.sound_volume = 0
self.music_volume = 0
import uuid
# Global counter for deterministic UUIDs
_uuid_counter = 0
def mock_uuid4():
global _uuid_counter
val = _uuid_counter
_uuid_counter += 1
return val
class NonRegressionTest(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Deterministic UUIDs
uuid.uuid4 = mock_uuid4
# Monkeypatch SDL2 methods that block or show windows
GameWindow.show_intro = lambda *args, **kwargs: None
GameWindow.show_loading_screen = lambda *args, **kwargs: None
# Disable audio and its effects
GameWindow._init_audio_system = mock_init_audio
GameWindow.play_sound = lambda *args, **kwargs: None
GameWindow.stop_sound = lambda *args, **kwargs: None
def setUp(self):
global _uuid_counter
_uuid_counter = 0
# Initial seed for constructor
random.seed(42)
# Initialize game
self.game = MiceMaze("assets/Rat/level.dat", level_index=0)
# Re-seed again to clear entropy consumed by asset loading (blood stains etc)
# This ensures the game logic starts from a consistent random state
random.seed(42)
_uuid_counter = 0 # Reset UUIDs too for initial spawns
self.game.start_game()
# Trigger background generation once to consume those random calls
# before the simulation starts, ensuring stability.
self.game.graphics.draw_maze()
# Override dynamic attributes
self.game.start_menu_animation_started_at = 0
# Skip menu and start gameplay
self.game.game_status = "game"
self.game.menu_screen = None
def test_simulation_run(self):
steps = 200
states = []
# Output directory
output_dir = Path("tests/non_regression_output")
output_dir.mkdir(parents=True, exist_ok=True)
print(f"Starting simulation for {steps} steps...")
for i in range(steps):
# Advance game state
self.game.update_maze()
# Every 50 steps, record state and screenshot
if i % 50 == 0 or i == steps - 1:
state = self.dump_game_state()
state["frame"] = i
states.append(state)
# Visual snapshot
# Note: In dummy driver, RenderReadPixels might return empty/black
# but we'll try anyway. If it fails, we rely on the state JSON.
try:
self.game.graphics.draw_maze()
# Manually draw units because we are not in mainloop
for unit in list(self.game.units.values()):
unit.draw()
img = self.game.render_engine.capture_frame()
img_path = output_dir / f"frame_{i:04d}.png"
img.save(img_path)
except Exception as e:
print(f"Warning: Could not capture frame at step {i}: {e}")
# Save states to JSON
states_path = output_dir / "states.json"
with open(states_path, "w") as f:
json.dump(states, f, indent=2)
print(f"Simulation complete. Outputs saved to {output_dir}")
def dump_game_state(self):
state = {
"points": self.game.points,
"unit_count": len(self.game.units),
"units": []
}
# Sort units by ID to be deterministic
sorted_units = sorted(self.game.units.items(), key=lambda x: int(x[0]))
for uid, unit in sorted_units:
unit_state = {
"id": str(uid),
"type": unit.__class__.__name__,
"pos": list(unit.position),
"pos_before": list(unit.position_before),
"partial_move": float(unit.partial_move),
"age": int(unit.age),
}
if hasattr(unit, "sex"):
unit_state["sex"] = unit.sex
if hasattr(unit, "direction"):
unit_state["direction"] = unit.direction
state["units"].append(unit_state)
return state
if __name__ == "__main__":
unittest.main()
+123
View File
@@ -0,0 +1,123 @@
import os
import sys
import random
import unittest
import json
from pathlib import Path
from PIL import Image
import numpy as np
# Add current directory to path
sys.path.append(os.getcwd())
# Set SDL to use dummy video driver for headless environments
os.environ["SDL_VIDEODRIVER"] = "dummy"
os.environ["SDL_AUDIODRIVER"] = "dummy"
os.environ["MICE_DISABLE_JOYSTICK"] = "1"
from rats import MiceMaze
from engine.sdl2 import GameWindow
def mock_init_audio(self):
self.music_enabled = False
self.audio_devs = {"base": 0, "effects": 0, "music": 0}
self.sound_volume = 0
self.music_volume = 0
import uuid
# Global counter for deterministic UUIDs
_uuid_counter = 0
def mock_uuid4():
global _uuid_counter
val = _uuid_counter
_uuid_counter += 1
return val
class NonRegressionVerification(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Deterministic UUIDs
uuid.uuid4 = mock_uuid4
GameWindow.show_intro = lambda *args, **kwargs: None
GameWindow.show_loading_screen = lambda *args, **kwargs: None
GameWindow._init_audio_system = mock_init_audio
GameWindow.play_sound = lambda *args, **kwargs: None
GameWindow.stop_sound = lambda *args, **kwargs: None
def setUp(self):
global _uuid_counter
_uuid_counter = 0
random.seed(42)
self.game = MiceMaze("assets/Rat/level.dat", level_index=0)
# Re-seed again to clear entropy consumed by asset loading
random.seed(42)
_uuid_counter = 0
self.game.start_game()
# Trigger background generation once to consume those random calls
# before the simulation starts, ensuring stability.
self.game.graphics.draw_maze()
self.game.game_status = "game"
self.game.menu_screen = None
def test_verify_against_golden_master(self):
golden_master_dir = Path("tests/golden_master")
if not golden_master_dir.exists():
self.skipTest("Golden master not found. Run recording first.")
with open(golden_master_dir / "states.json", "r") as f:
golden_states = json.load(f)
steps = 200
golden_idx = 0
print(f"Verifying against golden master for {steps} steps...")
for i in range(steps):
self.game.update_maze()
if i % 50 == 0 or i == steps - 1:
current_state = self.dump_game_state()
golden_state = golden_states[golden_idx]
# Compare unit count
self.assertEqual(current_state["unit_count"], golden_state["unit_count"],
f"Unit count mismatch at step {i}")
# Compare units
for u_idx, (curr_u, gold_u) in enumerate(zip(current_state["units"], golden_state["units"])):
self.assertEqual(curr_u["id"], gold_u["id"], f"Unit ID mismatch at step {i}, index {u_idx}")
self.assertEqual(curr_u["type"], gold_u["type"], f"Unit type mismatch at step {i}, unit {curr_u['id']}")
self.assertEqual(curr_u["pos"], gold_u["pos"], f"Unit pos mismatch at step {i}, unit {curr_u['id']}")
self.assertAlmostEqual(curr_u["partial_move"], gold_u["partial_move"], places=5,
msg=f"Unit partial_move mismatch at step {i}, unit {curr_u['id']}")
golden_idx += 1
print("Verification successful! No regressions detected.")
def dump_game_state(self):
state = {
"points": self.game.points,
"unit_count": len(self.game.units),
"units": []
}
# Sort units by ID to be deterministic
sorted_units = sorted(self.game.units.items(), key=lambda x: int(x[0]))
for uid, unit in sorted_units:
unit_state = {
"id": str(uid),
"type": unit.__class__.__name__,
"pos": list(unit.position),
"partial_move": float(unit.partial_move),
}
state["units"].append(unit_state)
return state
if __name__ == "__main__":
unittest.main()