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.
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||