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.
- tests/__init__.py +0 -0
- tests/conftest.py +108 -0
- tests/core/__init__.py +2 -0
- tests/core/environment/README.md +47 -0
- tests/core/environment/__init__.py +2 -0
- tests/core/environment/test_action_codec.py +50 -0
- tests/core/environment/test_game_state.py +483 -0
- tests/core/environment/test_grid_data.py +205 -0
- tests/core/environment/test_grid_logic.py +362 -0
- tests/core/environment/test_shape_logic.py +171 -0
- tests/core/environment/test_step.py +372 -0
- tests/core/structs/__init__.py +0 -0
- tests/core/structs/test_shape.py +83 -0
- tests/core/structs/test_triangle.py +97 -0
- tests/utils/__init__.py +0 -0
- tests/utils/test_geometry.py +93 -0
- trianglengin/__init__.py +18 -0
- trianglengin/app.py +110 -0
- trianglengin/cli.py +134 -0
- trianglengin/config/__init__.py +9 -0
- trianglengin/config/display_config.py +47 -0
- trianglengin/config/env_config.py +103 -0
- trianglengin/core/__init__.py +8 -0
- trianglengin/core/environment/__init__.py +31 -0
- trianglengin/core/environment/action_codec.py +37 -0
- trianglengin/core/environment/game_state.py +217 -0
- trianglengin/core/environment/grid/README.md +46 -0
- trianglengin/core/environment/grid/__init__.py +18 -0
- trianglengin/core/environment/grid/grid_data.py +140 -0
- trianglengin/core/environment/grid/line_cache.py +189 -0
- trianglengin/core/environment/grid/logic.py +131 -0
- trianglengin/core/environment/logic/__init__.py +3 -0
- trianglengin/core/environment/logic/actions.py +38 -0
- trianglengin/core/environment/logic/step.py +134 -0
- trianglengin/core/environment/shapes/__init__.py +19 -0
- trianglengin/core/environment/shapes/logic.py +84 -0
- trianglengin/core/environment/shapes/templates.py +587 -0
- trianglengin/core/structs/__init__.py +27 -0
- trianglengin/core/structs/constants.py +28 -0
- trianglengin/core/structs/shape.py +61 -0
- trianglengin/core/structs/triangle.py +48 -0
- trianglengin/interaction/README.md +45 -0
- trianglengin/interaction/__init__.py +17 -0
- trianglengin/interaction/debug_mode_handler.py +96 -0
- trianglengin/interaction/event_processor.py +43 -0
- trianglengin/interaction/input_handler.py +82 -0
- trianglengin/interaction/play_mode_handler.py +141 -0
- trianglengin/utils/__init__.py +9 -0
- trianglengin/utils/geometry.py +73 -0
- trianglengin/utils/types.py +10 -0
- trianglengin/visualization/README.md +44 -0
- trianglengin/visualization/__init__.py +61 -0
- trianglengin/visualization/core/README.md +52 -0
- trianglengin/visualization/core/__init__.py +12 -0
- trianglengin/visualization/core/colors.py +117 -0
- trianglengin/visualization/core/coord_mapper.py +73 -0
- trianglengin/visualization/core/fonts.py +55 -0
- trianglengin/visualization/core/layout.py +101 -0
- trianglengin/visualization/core/visualizer.py +232 -0
- trianglengin/visualization/drawing/README.md +45 -0
- trianglengin/visualization/drawing/__init__.py +30 -0
- trianglengin/visualization/drawing/grid.py +156 -0
- trianglengin/visualization/drawing/highlight.py +30 -0
- trianglengin/visualization/drawing/hud.py +39 -0
- trianglengin/visualization/drawing/previews.py +172 -0
- trianglengin/visualization/drawing/shapes.py +36 -0
- trianglengin-1.0.6.dist-info/METADATA +367 -0
- trianglengin-1.0.6.dist-info/RECORD +72 -0
- trianglengin-1.0.6.dist-info/WHEEL +5 -0
- trianglengin-1.0.6.dist-info/entry_points.txt +2 -0
- trianglengin-1.0.6.dist-info/licenses/LICENSE +22 -0
- 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
|