trianglengin 1.0.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. tests/__init__.py +0 -0
  2. tests/conftest.py +108 -0
  3. tests/core/__init__.py +2 -0
  4. tests/core/environment/README.md +47 -0
  5. tests/core/environment/__init__.py +2 -0
  6. tests/core/environment/test_action_codec.py +50 -0
  7. tests/core/environment/test_game_state.py +483 -0
  8. tests/core/environment/test_grid_data.py +205 -0
  9. tests/core/environment/test_grid_logic.py +362 -0
  10. tests/core/environment/test_shape_logic.py +171 -0
  11. tests/core/environment/test_step.py +372 -0
  12. tests/core/structs/__init__.py +0 -0
  13. tests/core/structs/test_shape.py +83 -0
  14. tests/core/structs/test_triangle.py +97 -0
  15. tests/utils/__init__.py +0 -0
  16. tests/utils/test_geometry.py +93 -0
  17. trianglengin/__init__.py +18 -0
  18. trianglengin/app.py +110 -0
  19. trianglengin/cli.py +134 -0
  20. trianglengin/config/__init__.py +9 -0
  21. trianglengin/config/display_config.py +47 -0
  22. trianglengin/config/env_config.py +103 -0
  23. trianglengin/core/__init__.py +8 -0
  24. trianglengin/core/environment/__init__.py +31 -0
  25. trianglengin/core/environment/action_codec.py +37 -0
  26. trianglengin/core/environment/game_state.py +217 -0
  27. trianglengin/core/environment/grid/README.md +46 -0
  28. trianglengin/core/environment/grid/__init__.py +18 -0
  29. trianglengin/core/environment/grid/grid_data.py +140 -0
  30. trianglengin/core/environment/grid/line_cache.py +189 -0
  31. trianglengin/core/environment/grid/logic.py +131 -0
  32. trianglengin/core/environment/logic/__init__.py +3 -0
  33. trianglengin/core/environment/logic/actions.py +38 -0
  34. trianglengin/core/environment/logic/step.py +134 -0
  35. trianglengin/core/environment/shapes/__init__.py +19 -0
  36. trianglengin/core/environment/shapes/logic.py +84 -0
  37. trianglengin/core/environment/shapes/templates.py +587 -0
  38. trianglengin/core/structs/__init__.py +27 -0
  39. trianglengin/core/structs/constants.py +28 -0
  40. trianglengin/core/structs/shape.py +61 -0
  41. trianglengin/core/structs/triangle.py +48 -0
  42. trianglengin/interaction/README.md +45 -0
  43. trianglengin/interaction/__init__.py +17 -0
  44. trianglengin/interaction/debug_mode_handler.py +96 -0
  45. trianglengin/interaction/event_processor.py +43 -0
  46. trianglengin/interaction/input_handler.py +82 -0
  47. trianglengin/interaction/play_mode_handler.py +141 -0
  48. trianglengin/utils/__init__.py +9 -0
  49. trianglengin/utils/geometry.py +73 -0
  50. trianglengin/utils/types.py +10 -0
  51. trianglengin/visualization/README.md +44 -0
  52. trianglengin/visualization/__init__.py +61 -0
  53. trianglengin/visualization/core/README.md +52 -0
  54. trianglengin/visualization/core/__init__.py +12 -0
  55. trianglengin/visualization/core/colors.py +117 -0
  56. trianglengin/visualization/core/coord_mapper.py +73 -0
  57. trianglengin/visualization/core/fonts.py +55 -0
  58. trianglengin/visualization/core/layout.py +101 -0
  59. trianglengin/visualization/core/visualizer.py +232 -0
  60. trianglengin/visualization/drawing/README.md +45 -0
  61. trianglengin/visualization/drawing/__init__.py +30 -0
  62. trianglengin/visualization/drawing/grid.py +156 -0
  63. trianglengin/visualization/drawing/highlight.py +30 -0
  64. trianglengin/visualization/drawing/hud.py +39 -0
  65. trianglengin/visualization/drawing/previews.py +172 -0
  66. trianglengin/visualization/drawing/shapes.py +36 -0
  67. trianglengin-1.0.6.dist-info/METADATA +367 -0
  68. trianglengin-1.0.6.dist-info/RECORD +72 -0
  69. trianglengin-1.0.6.dist-info/WHEEL +5 -0
  70. trianglengin-1.0.6.dist-info/entry_points.txt +2 -0
  71. trianglengin-1.0.6.dist-info/licenses/LICENSE +22 -0
  72. trianglengin-1.0.6.dist-info/top_level.txt +2 -0
@@ -0,0 +1,483 @@
1
+ # File: tests/core/environment/test_game_state.py
2
+ import logging
3
+
4
+ import numpy as np
5
+ import pytest
6
+ from pytest_mock import MockerFixture
7
+
8
+ import trianglengin.core.environment.grid.logic as GridLogic
9
+ from trianglengin.config.env_config import EnvConfig
10
+
11
+ # Import shapes module
12
+ from trianglengin.core.environment import shapes as ShapeLogic
13
+ from trianglengin.core.environment.action_codec import (
14
+ ActionType,
15
+ decode_action,
16
+ encode_action,
17
+ )
18
+ from trianglengin.core.environment.game_state import GameState
19
+ from trianglengin.core.structs.shape import Shape
20
+ from trianglengin.visualization.core.colors import Color
21
+
22
+ # Configure logging for tests
23
+ logging.basicConfig(level=logging.INFO)
24
+
25
+ # Define a default color for shapes in tests
26
+ DEFAULT_TEST_COLOR: Color = (100, 100, 100)
27
+
28
+
29
+ @pytest.fixture
30
+ def default_config() -> EnvConfig:
31
+ """Fixture for default environment configuration."""
32
+ return EnvConfig()
33
+
34
+
35
+ @pytest.fixture
36
+ def default_game_state(default_config: EnvConfig) -> GameState:
37
+ """Fixture for a default GameState."""
38
+ return GameState(config=default_config, initial_seed=123)
39
+
40
+
41
+ @pytest.fixture
42
+ def game_state_with_fixed_shapes() -> GameState:
43
+ """Fixture for a GameState with predictable shapes for testing."""
44
+ test_config = EnvConfig(
45
+ ROWS=3,
46
+ COLS=3,
47
+ PLAYABLE_RANGE_PER_ROW=[(0, 3), (0, 3), (0, 3)],
48
+ NUM_SHAPE_SLOTS=3,
49
+ )
50
+ gs = GameState(config=test_config, initial_seed=456)
51
+ shape1 = Shape([(0, 0, False)], color=DEFAULT_TEST_COLOR)
52
+ shape2 = Shape([(0, 0, True)], color=DEFAULT_TEST_COLOR)
53
+ shape3 = Shape([(0, 0, False), (1, 0, False)], color=DEFAULT_TEST_COLOR)
54
+ gs.shapes = [shape1.copy(), shape2.copy(), shape3.copy()]
55
+ gs.valid_actions(force_recalculate=True)
56
+ return gs
57
+
58
+
59
+ def test_game_state_initialization(default_game_state: GameState):
60
+ """Test basic initialization of GameState."""
61
+ gs = default_game_state
62
+ assert gs.env_config is not None
63
+ assert gs.grid_data is not None
64
+ assert len(gs.shapes) == gs.env_config.NUM_SHAPE_SLOTS
65
+ assert gs.game_score() == 0
66
+ is_initially_over = gs.is_over() # Check if game over
67
+ if is_initially_over:
68
+ reason = gs.get_game_over_reason()
69
+ assert reason is not None and "No valid actions available at start" in reason
70
+ else:
71
+ assert gs.get_game_over_reason() is None
72
+ assert len(gs.valid_actions()) > 0
73
+
74
+
75
+ def test_game_state_reset(default_game_state: GameState):
76
+ """Test resetting the GameState."""
77
+ gs = default_game_state
78
+ initial_shapes_before_step = [s.copy() if s else None for s in gs.shapes]
79
+ action = next(iter(gs.valid_actions()), None)
80
+
81
+ if action is not None:
82
+ gs.step(action)
83
+ assert gs.game_score() > 0 or not gs.valid_actions()
84
+ else:
85
+ pass
86
+
87
+ gs.reset()
88
+
89
+ assert gs.game_score() == 0
90
+ is_over_after_reset = gs.is_over()
91
+ if is_over_after_reset:
92
+ reason = gs.get_game_over_reason()
93
+ assert reason is not None and "No valid actions available at start" in reason
94
+ else:
95
+ assert gs.get_game_over_reason() is None
96
+
97
+ # Check grid is empty *within playable area*
98
+ playable_mask = ~gs.grid_data._death_np
99
+ assert not gs.grid_data._occupied_np[playable_mask].any()
100
+
101
+ assert len(gs.shapes) == gs.env_config.NUM_SHAPE_SLOTS
102
+ assert all(s is not None for s in gs.shapes)
103
+
104
+ shapes_after_reset = [s.copy() if s else None for s in gs.shapes]
105
+ if action is not None:
106
+ # Check if shapes are different (probabilistically)
107
+ # This might occasionally fail if the same shapes are generated by chance
108
+ shape_tuples_before = tuple(
109
+ tuple(sorted(s.triangles)) if s else None
110
+ for s in initial_shapes_before_step
111
+ )
112
+ shape_tuples_after = tuple(
113
+ tuple(sorted(s.triangles)) if s else None for s in shapes_after_reset
114
+ )
115
+ color_tuples_before = tuple(
116
+ s.color if s else None for s in initial_shapes_before_step
117
+ )
118
+ color_tuples_after = tuple(s.color if s else None for s in shapes_after_reset)
119
+
120
+ assert (
121
+ shape_tuples_before != shape_tuples_after
122
+ or color_tuples_before != color_tuples_after
123
+ ), "Shapes did not change after reset and step"
124
+
125
+ if not is_over_after_reset:
126
+ assert len(gs.valid_actions()) > 0
127
+
128
+
129
+ def test_game_state_step(default_game_state: GameState):
130
+ """Test a single valid step."""
131
+ gs = default_game_state
132
+ initial_score = gs.game_score()
133
+ initial_shapes = [s.copy() if s else None for s in gs.shapes]
134
+ initial_shape_count = sum(1 for s in initial_shapes if s is not None)
135
+
136
+ valid_actions = gs.valid_actions()
137
+ if not valid_actions:
138
+ pytest.skip("Cannot perform step test: no valid actions initially.")
139
+
140
+ action = next(iter(valid_actions))
141
+ shape_index, r, c = decode_action(action, gs.env_config)
142
+ shape_placed = gs.shapes[shape_index]
143
+ assert shape_placed is not None, "Action corresponds to an empty shape slot."
144
+ len(shape_placed.triangles)
145
+
146
+ logging.debug(
147
+ f"Before step: Action={action}, ShapeIdx={shape_index}, Pos=({r},{c})"
148
+ )
149
+ logging.debug(f"Before step: Score={initial_score}, Shapes={gs.shapes}")
150
+ logging.debug(f"Before step: Valid Actions Count={len(valid_actions)}")
151
+
152
+ reward, done = gs.step(action)
153
+
154
+ logging.debug(f"After step: Reward={reward}, Done={done}")
155
+ logging.debug(f"After step: Score={gs.game_score()}, Shapes={gs.shapes}")
156
+ logging.debug(f"After step: Valid Actions Count={len(gs.valid_actions())}")
157
+ logging.debug(f"After step: Grid Occupied Sum={np.sum(gs.grid_data._occupied_np)}")
158
+
159
+ assert done == gs.is_over()
160
+ assert reward is not None
161
+ assert gs.shapes[shape_index] is None # Shape slot should be cleared
162
+
163
+ # Check grid state - at least the placed cells should be occupied if no line clear
164
+ # This is tricky because a line clear might happen.
165
+ # A simpler check: if the game isn't over, the grid shouldn't be completely empty
166
+ # unless a full clear happened (which is rare).
167
+ if not done:
168
+ # It's possible to clear the entire board, so we can't assert occupied.any()
169
+ # assert gs.grid_data._occupied_np[playable_mask].any()
170
+ pass # Cannot make strong assertions about grid state easily
171
+
172
+ # Check shape refill logic
173
+ current_shape_count = sum(1 for s in gs.shapes if s is not None)
174
+ if initial_shape_count == 1 and not done: # If placed last shape and game not over
175
+ assert current_shape_count == gs.env_config.NUM_SHAPE_SLOTS # Should refill
176
+ elif initial_shape_count > 1: # If placed one of multiple shapes
177
+ expected_count = initial_shape_count - 1
178
+ assert current_shape_count == expected_count # Should just decrement
179
+
180
+ assert isinstance(gs.valid_actions(), set)
181
+
182
+
183
+ def test_game_state_step_invalid_action(default_game_state: GameState):
184
+ """Test stepping with an invalid action index."""
185
+ gs = default_game_state
186
+ invalid_action = ActionType(-1)
187
+ with pytest.raises(ValueError, match="Action is not in the set of valid actions"):
188
+ gs.step(invalid_action)
189
+
190
+ invalid_action_large = ActionType(int(gs.env_config.ACTION_DIM))
191
+ with pytest.raises(ValueError, match="Action is not in the set of valid actions"):
192
+ gs.step(invalid_action_large)
193
+
194
+ empty_slot_idx = -1
195
+ for i, shape in enumerate(gs.shapes):
196
+ if shape is None:
197
+ empty_slot_idx = i
198
+ break
199
+
200
+ if empty_slot_idx != -1:
201
+ r, c = 0, 0
202
+ found_playable = False
203
+ for r_try in range(gs.env_config.ROWS):
204
+ start_c, end_c = gs.env_config.PLAYABLE_RANGE_PER_ROW[r_try]
205
+ if start_c < end_c:
206
+ r, c = r_try, start_c
207
+ found_playable = True
208
+ break
209
+ if not found_playable:
210
+ pytest.skip("Cannot find any playable cell for empty slot test.")
211
+
212
+ action_for_empty_slot = encode_action(empty_slot_idx, r, c, gs.env_config)
213
+ # Ensure the action is actually invalid (it should be if slot is empty)
214
+ if action_for_empty_slot in gs.valid_actions():
215
+ pytest.skip(
216
+ f"Action {action_for_empty_slot} for empty slot {empty_slot_idx} unexpectedly valid."
217
+ )
218
+
219
+ with pytest.raises(
220
+ ValueError, match="Action is not in the set of valid actions"
221
+ ):
222
+ gs.step(action_for_empty_slot)
223
+
224
+
225
+ def test_game_state_step_invalid_placement(default_game_state: GameState):
226
+ """Test stepping with an action that is geometrically invalid."""
227
+ gs = default_game_state
228
+
229
+ valid_actions_initial = gs.valid_actions()
230
+ if not valid_actions_initial:
231
+ pytest.skip("No valid actions available to perform the first step.")
232
+
233
+ action1 = next(iter(valid_actions_initial))
234
+ shape_index1, r1, c1 = decode_action(action1, gs.env_config)
235
+ reward1, done1 = gs.step(action1)
236
+
237
+ if done1:
238
+ pytest.skip("Game ended after the first step, cannot test invalid placement.")
239
+
240
+ available_shape_idx = -1
241
+ for idx, shape in enumerate(gs.shapes):
242
+ if shape is not None:
243
+ available_shape_idx = idx
244
+ break
245
+
246
+ if available_shape_idx == -1:
247
+ pytest.skip(
248
+ "No shapes left available after first step (refill occurred?), cannot test invalid placement."
249
+ )
250
+
251
+ # Create an action targeting the *occupied* cell (r1, c1) with the next available shape
252
+ invalid_action = encode_action(available_shape_idx, r1, c1, gs.env_config)
253
+
254
+ current_valid_actions = gs.valid_actions()
255
+ # This action should definitely be invalid because (r1, c1) is occupied
256
+ if invalid_action in current_valid_actions:
257
+ logging.warning(
258
+ f"DEBUG: Grid occupied at ({r1},{c1}): {gs.grid_data.is_occupied(r1, c1)}"
259
+ )
260
+ pytest.skip(
261
+ "Test setup failed: Action for invalid placement is unexpectedly valid."
262
+ )
263
+
264
+ with pytest.raises(ValueError, match="Action is not in the set of valid actions"):
265
+ gs.step(invalid_action)
266
+
267
+
268
+ def test_game_state_is_over(default_game_state: GameState, mocker: MockerFixture):
269
+ """Test the is_over condition by mocking valid_actions."""
270
+ gs = default_game_state
271
+
272
+ initial_is_over = gs.is_over()
273
+ initial_outcome = gs.get_outcome()
274
+ if initial_is_over:
275
+ assert initial_outcome == -1.0
276
+ reason = gs.get_game_over_reason()
277
+ assert reason is not None and "No valid actions available at start" in reason
278
+ else:
279
+ assert initial_outcome == 0.0
280
+ assert gs.get_game_over_reason() is None
281
+
282
+ # Mock valid_actions to return an empty set
283
+ mocker.patch.object(gs, "valid_actions", return_value=set(), autospec=True)
284
+ # Force re-check of is_over which should now use the mocked value
285
+ gs._valid_actions_cache = None # Clear cache to force re-check
286
+
287
+ assert gs.is_over() # Should now return True
288
+ assert gs.get_outcome() == -1.0
289
+ # Check if the reason was set correctly by is_over()
290
+ reason = gs.get_game_over_reason()
291
+ assert reason is not None and "No valid actions available" in reason
292
+
293
+ # Test reset clears the mocked state
294
+ gs.reset() # Reset should un-mock and potentially find valid actions again
295
+
296
+ final_is_over = gs.is_over()
297
+ final_outcome = gs.get_outcome()
298
+
299
+ if final_is_over:
300
+ assert final_outcome == -1.0
301
+ reason = gs.get_game_over_reason()
302
+ assert reason is not None and "No valid actions available at start" in reason
303
+ logging.info("Note: Game is over immediately after reset (no valid actions).")
304
+ else:
305
+ assert final_outcome == 0.0
306
+ assert gs.get_game_over_reason() is None
307
+
308
+
309
+ def test_game_state_copy(default_game_state: GameState):
310
+ """Test the copy method of GameState."""
311
+ gs1 = default_game_state
312
+ action1 = next(iter(gs1.valid_actions()), None)
313
+
314
+ if action1:
315
+ gs1.step(action1)
316
+
317
+ gs2 = gs1.copy()
318
+
319
+ assert gs1.game_score() == gs2.game_score()
320
+ assert gs1.env_config == gs2.env_config
321
+ assert gs1.is_over() == gs2.is_over()
322
+ assert gs1.get_game_over_reason() == gs2.get_game_over_reason()
323
+ assert gs1.current_step == gs2.current_step
324
+
325
+ assert gs1.grid_data is not gs2.grid_data
326
+ assert np.array_equal(gs1.grid_data._occupied_np, gs2.grid_data._occupied_np)
327
+ assert np.array_equal(gs1.grid_data._color_id_np, gs2.grid_data._color_id_np)
328
+ assert np.array_equal(gs1.grid_data._death_np, gs2.grid_data._death_np)
329
+ assert gs1.grid_data._occupied_np is not gs2.grid_data._occupied_np
330
+ assert gs1.grid_data._color_id_np is not gs2.grid_data._color_id_np
331
+ assert gs1.grid_data._death_np is not gs2.grid_data._death_np
332
+
333
+ assert gs1.shapes is not gs2.shapes
334
+ assert len(gs1.shapes) == len(gs2.shapes)
335
+ for i in range(len(gs1.shapes)):
336
+ if gs1.shapes[i] is None:
337
+ assert gs2.shapes[i] is None
338
+ else:
339
+ assert gs1.shapes[i] is not None
340
+ assert gs2.shapes[i] is not None
341
+ assert gs1.shapes[i] == gs2.shapes[i]
342
+ assert gs1.shapes[i] is not gs2.shapes[i]
343
+
344
+ gs1_cache = gs1.valid_actions()
345
+ gs2_cache = gs2.valid_actions()
346
+ assert gs1_cache == gs2_cache
347
+ if gs1._valid_actions_cache is not None and gs2._valid_actions_cache is not None:
348
+ assert gs1._valid_actions_cache is not gs2._valid_actions_cache
349
+
350
+ action2 = next(iter(gs2.valid_actions()), None)
351
+ if not action2:
352
+ assert not gs1.valid_actions()
353
+ return
354
+
355
+ reward2, done2 = gs2.step(action2)
356
+ score1_after_gs2_step = gs1.game_score()
357
+ grid1_occupied_after_gs2_step = gs1.grid_data._occupied_np.copy()
358
+ shapes1_after_gs2_step = [s.copy() if s else None for s in gs1.shapes]
359
+
360
+ # Score should only differ if reward was non-zero
361
+ if reward2 != 0.0:
362
+ assert score1_after_gs2_step != gs2.game_score()
363
+ else:
364
+ assert score1_after_gs2_step == gs2.game_score()
365
+
366
+ assert np.array_equal(grid1_occupied_after_gs2_step, gs1.grid_data._occupied_np)
367
+ # Grid state should differ after gs2 step
368
+ assert not np.array_equal(gs1.grid_data._occupied_np, gs2.grid_data._occupied_np)
369
+ # Shapes list in gs1 should be unchanged
370
+ assert shapes1_after_gs2_step == gs1.shapes
371
+
372
+
373
+ def test_game_state_get_outcome_non_terminal(default_game_state: GameState):
374
+ """Test get_outcome when the game is not over."""
375
+ gs = default_game_state
376
+ if gs.is_over():
377
+ pytest.skip("Game is over initially, cannot test non-terminal outcome.")
378
+ assert not gs.is_over()
379
+ assert gs.get_outcome() == 0.0
380
+
381
+
382
+ def test_game_state_step_triggers_game_over(
383
+ game_state_with_fixed_shapes: GameState, mocker: MockerFixture
384
+ ):
385
+ """
386
+ Test that placing the last available shape triggers refill,
387
+ and check if valid actions exist afterwards.
388
+ """
389
+ gs = game_state_with_fixed_shapes # Uses 3x3 grid fixture
390
+
391
+ # Setup: Fill all EXCEPT (0,0) and (0,1). (0,2) remains filled.
392
+ # Grid: D U D
393
+ # U D U
394
+ # D U D
395
+ # (0,0) is Down, (0,1) is Up, (0,2) is Down
396
+ empty_r_down, empty_c_down = 0, 0
397
+ empty_r_up, empty_c_up = 0, 1
398
+
399
+ playable_mask = ~gs.grid_data._death_np
400
+ gs.grid_data._occupied_np[playable_mask] = True
401
+ gs.grid_data._color_id_np[playable_mask] = 0
402
+ gs.grid_data._occupied_np[empty_r_down, empty_c_down] = False
403
+ gs.grid_data._occupied_np[empty_r_up, empty_c_up] = False
404
+ gs.grid_data._color_id_np[empty_r_down, empty_c_down] = -1
405
+ gs.grid_data._color_id_np[empty_r_up, empty_c_up] = -1
406
+ # Ensure (0,2) is occupied to test line clear
407
+ gs.grid_data._occupied_np[0, 2] = True
408
+ gs.grid_data._color_id_np[0, 2] = 0
409
+
410
+ # Start with shapes 0 (Down) and 1 (Up), slot 2 is None
411
+ gs.shapes[2] = None
412
+ gs.valid_actions(force_recalculate=True) # Re-validate actions with setup
413
+
414
+ # Verify actions are valid *after* the final setup
415
+ assert gs.shapes[0] is not None and gs.shapes[0].triangles == [(0, 0, False)]
416
+ action1 = encode_action(0, empty_r_down, empty_c_down, gs.env_config)
417
+ assert GridLogic.can_place(gs.grid_data, gs.shapes[0], empty_r_down, empty_c_down)
418
+ assert action1 in gs.valid_actions()
419
+
420
+ assert gs.shapes[1] is not None and gs.shapes[1].triangles == [(0, 0, True)]
421
+ action2 = encode_action(1, empty_r_up, empty_c_up, gs.env_config)
422
+ assert GridLogic.can_place(gs.grid_data, gs.shapes[1], empty_r_up, empty_c_up)
423
+ assert action2 in gs.valid_actions()
424
+
425
+ # Mock refill logic to verify it's called
426
+ mock_refill = mocker.patch(
427
+ "trianglengin.core.environment.shapes.logic.refill_shape_slots",
428
+ wraps=ShapeLogic.refill_shape_slots,
429
+ )
430
+
431
+ # --- Step 1: Place shape 0 at (0,0) ---
432
+ reward1, done1 = gs.step(action1)
433
+ assert not done1, "Game should not be over after first placement"
434
+ # Check that the cell is occupied *after* the step (and potential clear check)
435
+ assert gs.grid_data.is_occupied(empty_r_down, empty_c_down), (
436
+ f"Cell ({empty_r_down},{empty_c_down}) should be occupied after step 1"
437
+ )
438
+ mock_refill.assert_not_called()
439
+ assert gs.shapes[0] is None
440
+ assert gs.shapes[1] is not None
441
+ assert gs.shapes[2] is None
442
+
443
+ # --- Step 2: Place shape 1 at (0,1) ---
444
+ # This completes the horizontal line (0,0), (0,1), (0,2)
445
+ # It also makes all slots empty, triggering refill.
446
+ reward2, done2 = gs.step(action2)
447
+
448
+ # Assertions after the second step (with line clear and refill):
449
+ mock_refill.assert_called_once() # Refill called because slots became empty
450
+
451
+ # Check that the line was actually cleared
452
+ assert not gs.grid_data.is_occupied(0, 0), "(0,0) should be cleared"
453
+ assert not gs.grid_data.is_occupied(0, 1), "(0,1) should be cleared"
454
+ assert not gs.grid_data.is_occupied(0, 2), "(0,2) should be cleared"
455
+
456
+ # Check shapes are refilled
457
+ assert gs.shapes[0] is not None
458
+ assert gs.shapes[1] is not None
459
+ assert gs.shapes[2] is not None
460
+
461
+ # Check if the game is over *after* the refill by checking valid actions
462
+ # The game should NOT be over because the grid is now mostly empty,
463
+ # allowing placements for the newly refilled shapes.
464
+ final_valid_actions = gs.valid_actions(force_recalculate=True)
465
+ assert len(final_valid_actions) > 0, "Game should have valid actions after refill"
466
+ assert not done2, "done flag should be False as valid actions exist after refill"
467
+ assert not gs.is_over(), "Game state should NOT be marked as over after refill"
468
+ assert gs.get_outcome() == 0.0
469
+
470
+
471
+ def test_game_state_forced_game_over(default_game_state: GameState):
472
+ """Test forcing game over manually."""
473
+ gs = default_game_state
474
+ if gs.is_over():
475
+ pytest.skip("Game is over initially, cannot test forcing.")
476
+
477
+ assert not gs.is_over()
478
+ gs.force_game_over("Test reason")
479
+ assert gs.is_over()
480
+ reason = gs.get_game_over_reason()
481
+ assert reason is not None and "Test reason" in reason
482
+ assert gs.get_outcome() == -1.0
483
+ assert not gs.valid_actions() # Force over should clear valid actions
@@ -0,0 +1,205 @@
1
+ # File: tests/core/environment/test_grid_data.py
2
+ import copy
3
+ import logging
4
+
5
+ import numpy as np
6
+ import pytest
7
+
8
+ from trianglengin.config.env_config import EnvConfig
9
+ from trianglengin.core.environment.grid.grid_data import GridData
10
+
11
+ logging.basicConfig(level=logging.DEBUG)
12
+
13
+
14
+ @pytest.fixture
15
+ def default_config() -> EnvConfig:
16
+ """Fixture for default environment configuration."""
17
+ return EnvConfig()
18
+
19
+
20
+ @pytest.fixture
21
+ def default_grid(default_config: EnvConfig) -> GridData:
22
+ """Fixture for a default GridData instance."""
23
+ grid = GridData(default_config)
24
+ return grid
25
+
26
+
27
+ def test_grid_data_initialization(default_grid: GridData, default_config: EnvConfig):
28
+ """Test GridData initialization matches config."""
29
+ assert default_grid.rows == default_config.ROWS
30
+ assert default_grid.cols == default_config.COLS
31
+ assert default_grid.config == default_config
32
+ assert default_grid._occupied_np.shape == (default_config.ROWS, default_config.COLS)
33
+ assert default_grid._color_id_np.shape == (default_config.ROWS, default_config.COLS)
34
+ assert default_grid._death_np.shape == (default_config.ROWS, default_config.COLS)
35
+ assert default_grid._occupied_np.dtype == bool
36
+ assert default_grid._color_id_np.dtype == np.int8
37
+ assert default_grid._death_np.dtype == bool
38
+ assert not default_grid._occupied_np.any()
39
+ assert (default_grid._color_id_np == -1).all()
40
+ for r in range(default_config.ROWS):
41
+ start_col, end_col = default_config.PLAYABLE_RANGE_PER_ROW[r]
42
+ for c in range(default_config.COLS):
43
+ expected_death = not (start_col <= c < end_col)
44
+ assert default_grid._death_np[r, c] == expected_death
45
+ assert hasattr(default_grid, "_lines")
46
+ assert default_grid._lines is not None
47
+ assert hasattr(default_grid, "_coord_to_lines_map")
48
+ assert default_grid._coord_to_lines_map is not None
49
+ assert len(default_grid._lines) >= 0
50
+
51
+
52
+ def test_grid_data_valid(default_grid: GridData, default_config: EnvConfig):
53
+ """Test the valid() method (checks bounds only)."""
54
+ assert default_grid.valid(0, 0)
55
+ assert default_grid.valid(default_config.ROWS - 1, 0)
56
+ assert default_grid.valid(0, default_config.COLS - 1)
57
+ assert default_grid.valid(default_config.ROWS - 1, default_config.COLS - 1)
58
+ assert not default_grid.valid(-1, 0)
59
+ assert not default_grid.valid(default_config.ROWS, 0)
60
+ assert not default_grid.valid(0, -1)
61
+ assert not default_grid.valid(0, default_config.COLS)
62
+
63
+
64
+ def test_grid_data_is_death(default_grid: GridData, default_config: EnvConfig):
65
+ """Test the is_death() method."""
66
+ for r in range(default_config.ROWS):
67
+ if r >= len(default_config.PLAYABLE_RANGE_PER_ROW):
68
+ start_col, end_col = 0, 0
69
+ else:
70
+ start_col, end_col = default_config.PLAYABLE_RANGE_PER_ROW[r]
71
+
72
+ for c in range(default_config.COLS):
73
+ expected_death = not (start_col <= c < end_col)
74
+ if 0 <= r < default_config.ROWS and 0 <= c < default_config.COLS:
75
+ assert default_grid.is_death(r, c) == expected_death
76
+ else:
77
+ with pytest.raises(IndexError):
78
+ default_grid.is_death(r, c)
79
+
80
+ with pytest.raises(IndexError):
81
+ default_grid.is_death(-1, 0)
82
+ with pytest.raises(IndexError):
83
+ default_grid.is_death(default_config.ROWS, 0)
84
+ with pytest.raises(IndexError):
85
+ default_grid.is_death(0, -1)
86
+ with pytest.raises(IndexError):
87
+ default_grid.is_death(0, default_config.COLS)
88
+
89
+
90
+ def test_grid_data_is_occupied(default_grid: GridData, default_config: EnvConfig):
91
+ """Test the is_occupied() method."""
92
+ live_r, live_c = -1, -1
93
+ for r in range(default_config.ROWS):
94
+ start_c, end_c = default_config.PLAYABLE_RANGE_PER_ROW[r]
95
+ if start_c < end_c:
96
+ live_r, live_c = r, start_c
97
+ break
98
+ if live_r == -1:
99
+ pytest.skip("Test requires at least one live cell.")
100
+
101
+ assert not default_grid.is_occupied(live_r, live_c)
102
+ default_grid._occupied_np[live_r, live_c] = True
103
+ default_grid._color_id_np[live_r, live_c] = 1
104
+ assert default_grid.is_occupied(live_r, live_c)
105
+
106
+ live_r2, live_c2 = -1, -1
107
+ for r in range(default_config.ROWS):
108
+ start_c, end_c = default_config.PLAYABLE_RANGE_PER_ROW[r]
109
+ for c in range(start_c, end_c):
110
+ if (r, c) != (live_r, live_c):
111
+ live_r2, live_c2 = r, c
112
+ break
113
+ if live_r2 != -1:
114
+ break
115
+ if live_r2 != -1:
116
+ assert not default_grid.is_occupied(live_r2, live_c2)
117
+
118
+ death_r, death_c = -1, -1
119
+ for r in range(default_grid.rows):
120
+ start_c, end_c = default_config.PLAYABLE_RANGE_PER_ROW[r]
121
+ if start_c > 0:
122
+ death_r, death_c = r, start_c - 1
123
+ break
124
+ if end_c < default_grid.cols:
125
+ death_r, death_c = r, end_c
126
+ break
127
+ if death_r == -1:
128
+ pytest.skip("Could not find a death zone cell.")
129
+
130
+ default_grid._occupied_np[death_r, death_c] = True
131
+ assert default_grid.is_death(death_r, death_c)
132
+ assert not default_grid.is_occupied(death_r, death_c)
133
+
134
+ with pytest.raises(IndexError):
135
+ default_grid.is_occupied(-1, 0)
136
+ with pytest.raises(IndexError):
137
+ default_grid.is_occupied(default_config.ROWS, 0)
138
+
139
+
140
+ def test_grid_data_deepcopy(default_grid: GridData):
141
+ """Test that deepcopy creates a truly independent copy."""
142
+ grid1 = default_grid
143
+ grid1._occupied_np.fill(False)
144
+ grid1._color_id_np.fill(-1)
145
+ mod_r, mod_c = -1, -1
146
+ for r in range(grid1.rows):
147
+ start_c, end_c = grid1.config.PLAYABLE_RANGE_PER_ROW[r]
148
+ if start_c < end_c:
149
+ mod_r, mod_c = r, start_c
150
+ break
151
+ if mod_r == -1:
152
+ pytest.skip("Cannot run deepcopy test without playable cells.")
153
+
154
+ grid1._occupied_np[mod_r, mod_c] = True
155
+ grid1._color_id_np[mod_r, mod_c] = 5
156
+
157
+ grid2 = copy.deepcopy(grid1)
158
+
159
+ assert grid1.rows == grid2.rows
160
+ assert grid1.cols == grid2.cols
161
+ assert grid1.config == grid2.config
162
+ assert grid1._occupied_np is not grid2._occupied_np
163
+ assert grid1._color_id_np is not grid2._color_id_np
164
+ assert grid1._death_np is not grid2._death_np
165
+ assert hasattr(grid1, "_lines") and hasattr(grid2, "_lines")
166
+ assert grid1._lines is not grid2._lines
167
+ assert hasattr(grid1, "_coord_to_lines_map") and hasattr(
168
+ grid2, "_coord_to_lines_map"
169
+ )
170
+ assert grid1._coord_to_lines_map is not grid2._coord_to_lines_map
171
+ assert np.array_equal(grid1._occupied_np, grid2._occupied_np)
172
+ assert np.array_equal(grid1._color_id_np, grid2._color_id_np)
173
+ assert np.array_equal(grid1._death_np, grid2._death_np)
174
+ assert grid1._lines == grid2._lines
175
+ assert grid1._coord_to_lines_map == grid2._coord_to_lines_map
176
+
177
+ mod_r2, mod_c2 = -1, -1
178
+ for r in range(grid2.rows):
179
+ start_c, end_c = grid2.config.PLAYABLE_RANGE_PER_ROW[r]
180
+ for c in range(start_c, end_c):
181
+ if (r, c) != (mod_r, mod_c):
182
+ mod_r2, mod_c2 = r, c
183
+ break
184
+ if mod_r2 != -1:
185
+ break
186
+
187
+ if mod_r2 != -1:
188
+ grid2._occupied_np[mod_r2, mod_c2] = True
189
+ grid2._color_id_np[mod_r2, mod_c2] = 3
190
+ assert not grid1._occupied_np[mod_r2, mod_c2]
191
+ assert grid1._color_id_np[mod_r2, mod_c2] == -1
192
+ else:
193
+ grid2._occupied_np[mod_r, mod_c] = False
194
+ grid2._color_id_np[mod_r, mod_c] = -1
195
+ assert grid1._occupied_np[mod_r, mod_c]
196
+ assert grid1._color_id_np[mod_r, mod_c] == 5
197
+
198
+ if grid2._lines:
199
+ grid2._lines.append(((99, 99),))
200
+ assert ((99, 99),) not in grid1._lines
201
+ dummy_coord = (99, 99)
202
+ dummy_line_fs = frozenset([dummy_coord])
203
+ if grid2._coord_to_lines_map:
204
+ grid2._coord_to_lines_map[dummy_coord] = {dummy_line_fs}
205
+ assert dummy_coord not in grid1._coord_to_lines_map