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,217 @@
|
|
1
|
+
# trianglengin/core/environment/game_state.py
|
2
|
+
import copy
|
3
|
+
import logging
|
4
|
+
import random
|
5
|
+
from typing import TYPE_CHECKING
|
6
|
+
|
7
|
+
from trianglengin.config.env_config import EnvConfig
|
8
|
+
from trianglengin.core.environment.action_codec import (
|
9
|
+
ActionType,
|
10
|
+
decode_action,
|
11
|
+
)
|
12
|
+
from trianglengin.core.environment.grid.grid_data import GridData
|
13
|
+
from trianglengin.core.environment.logic.actions import get_valid_actions
|
14
|
+
|
15
|
+
# Import calculate_reward and execute_placement from step logic
|
16
|
+
from trianglengin.core.environment.logic.step import calculate_reward, execute_placement
|
17
|
+
from trianglengin.core.environment.shapes import logic as ShapeLogic
|
18
|
+
|
19
|
+
if TYPE_CHECKING:
|
20
|
+
from trianglengin.core.structs.shape import Shape
|
21
|
+
|
22
|
+
|
23
|
+
log = logging.getLogger(__name__)
|
24
|
+
|
25
|
+
|
26
|
+
class GameState:
|
27
|
+
"""
|
28
|
+
Represents the mutable state of the game environment.
|
29
|
+
"""
|
30
|
+
|
31
|
+
def __init__(
|
32
|
+
self, config: EnvConfig | None = None, initial_seed: int | None = None
|
33
|
+
):
|
34
|
+
self.env_config: EnvConfig = config if config else EnvConfig()
|
35
|
+
self._rng = random.Random(initial_seed)
|
36
|
+
self.grid_data: GridData = GridData(self.env_config)
|
37
|
+
self.shapes: list[Shape | None] = [None] * self.env_config.NUM_SHAPE_SLOTS
|
38
|
+
self._game_score: float = 0.0
|
39
|
+
self._game_over: bool = False
|
40
|
+
self._game_over_reason: str | None = None
|
41
|
+
self.current_step: int = 0
|
42
|
+
self._valid_actions_cache: set[ActionType] | None = None
|
43
|
+
self.reset()
|
44
|
+
|
45
|
+
def reset(self) -> None:
|
46
|
+
"""Resets the game to an initial state."""
|
47
|
+
self.grid_data.reset()
|
48
|
+
self.shapes = [None] * self.env_config.NUM_SHAPE_SLOTS
|
49
|
+
self._game_score = 0.0
|
50
|
+
self._game_over = False
|
51
|
+
self._game_over_reason = None
|
52
|
+
self.current_step = 0
|
53
|
+
self._valid_actions_cache = None
|
54
|
+
ShapeLogic.refill_shape_slots(self, self._rng) # Initial fill
|
55
|
+
if not self.valid_actions(): # Check if initial state is game over
|
56
|
+
self._game_over = True
|
57
|
+
self._game_over_reason = "No valid actions available at start."
|
58
|
+
log.warning(self._game_over_reason)
|
59
|
+
|
60
|
+
def step(self, action_index: ActionType) -> tuple[float, bool]:
|
61
|
+
"""
|
62
|
+
Performs one game step based on the chosen action.
|
63
|
+
Handles placement, line clearing, scoring, refilling, and game over checks.
|
64
|
+
Returns: (reward, done)
|
65
|
+
Raises: ValueError if action is invalid or placement fails.
|
66
|
+
"""
|
67
|
+
if self.is_over():
|
68
|
+
log.warning("Attempted to step in a game that is already over.")
|
69
|
+
return 0.0, True
|
70
|
+
|
71
|
+
# Check action validity before execution
|
72
|
+
current_valid_actions = self.valid_actions()
|
73
|
+
if action_index not in current_valid_actions:
|
74
|
+
log.error(
|
75
|
+
f"Invalid action {action_index} provided. Valid: {current_valid_actions}"
|
76
|
+
)
|
77
|
+
raise ValueError("Action is not in the set of valid actions")
|
78
|
+
|
79
|
+
shape_idx, r, c = decode_action(action_index, self.env_config)
|
80
|
+
|
81
|
+
# --- Execute Placement and Get Stats ---
|
82
|
+
try:
|
83
|
+
# execute_placement now only returns counts and raises error on failure
|
84
|
+
cleared_count, placed_count = execute_placement(self, shape_idx, r, c)
|
85
|
+
except (ValueError, IndexError) as e:
|
86
|
+
# Catch errors from execute_placement (e.g., invalid placement attempt)
|
87
|
+
log.critical(
|
88
|
+
f"Critical error during placement execution: {e}", exc_info=True
|
89
|
+
)
|
90
|
+
# Force game over with a penalty
|
91
|
+
self.force_game_over(f"Placement execution error: {e}")
|
92
|
+
# Return penalty, game is over
|
93
|
+
return calculate_reward(0, 0, True, self.env_config), True
|
94
|
+
|
95
|
+
# --- Refill shapes IF AND ONLY IF all slots are now empty ---
|
96
|
+
if all(s is None for s in self.shapes):
|
97
|
+
log.debug("All shape slots empty after placement, triggering refill.")
|
98
|
+
ShapeLogic.refill_shape_slots(self, self._rng)
|
99
|
+
# Refill might make new actions available, so clear cache before final check
|
100
|
+
self._valid_actions_cache = None
|
101
|
+
else:
|
102
|
+
# Invalidate cache anyway as grid state or shapes list changed
|
103
|
+
self._valid_actions_cache = None
|
104
|
+
|
105
|
+
self.current_step += 1
|
106
|
+
|
107
|
+
# --- Final Game Over Check ---
|
108
|
+
# Check if any valid actions exist *after* placement and potential refill.
|
109
|
+
# Calling valid_actions() populates the cache and returns the set.
|
110
|
+
has_valid_moves_now = bool(self.valid_actions())
|
111
|
+
|
112
|
+
# Update the game over state based on the check
|
113
|
+
is_final_step_game_over = not has_valid_moves_now
|
114
|
+
if is_final_step_game_over and not self._game_over:
|
115
|
+
self._game_over = True
|
116
|
+
self._game_over_reason = "No valid actions available after step."
|
117
|
+
log.info(f"Game over at step {self.current_step}: {self._game_over_reason}")
|
118
|
+
# Note: If game was already over, self._game_over remains True
|
119
|
+
|
120
|
+
# --- Calculate Reward ---
|
121
|
+
# Reward depends on placement/clear counts and the final game over state for THIS step
|
122
|
+
reward = calculate_reward(
|
123
|
+
placed_count=placed_count,
|
124
|
+
cleared_count=cleared_count,
|
125
|
+
is_game_over=self._game_over, # Use the definitive game over state
|
126
|
+
config=self.env_config,
|
127
|
+
)
|
128
|
+
|
129
|
+
# Return the calculated reward and the definitive game over status
|
130
|
+
return reward, self._game_over
|
131
|
+
|
132
|
+
def valid_actions(self, force_recalculate: bool = False) -> set[ActionType]:
|
133
|
+
"""
|
134
|
+
Returns a set of valid encoded action indices for the current state.
|
135
|
+
Uses a cache for performance unless force_recalculate is True.
|
136
|
+
"""
|
137
|
+
if not force_recalculate and self._valid_actions_cache is not None:
|
138
|
+
return self._valid_actions_cache
|
139
|
+
|
140
|
+
# If game is already marked as over, no need to calculate, return empty set
|
141
|
+
if self._game_over:
|
142
|
+
if (
|
143
|
+
self._valid_actions_cache is None
|
144
|
+
): # Ensure cache is set if accessed after forced over
|
145
|
+
self._valid_actions_cache = set()
|
146
|
+
return set()
|
147
|
+
|
148
|
+
# Calculate fresh valid actions
|
149
|
+
current_valid_actions = get_valid_actions(self)
|
150
|
+
# Update cache before returning
|
151
|
+
self._valid_actions_cache = current_valid_actions
|
152
|
+
return current_valid_actions
|
153
|
+
|
154
|
+
def is_over(self) -> bool:
|
155
|
+
"""Checks if the game is over by checking for valid actions."""
|
156
|
+
if self._game_over: # If already marked, return true
|
157
|
+
return True
|
158
|
+
# If not marked, check if valid actions exist. This call populates cache.
|
159
|
+
has_valid_actions = bool(self.valid_actions())
|
160
|
+
if not has_valid_actions:
|
161
|
+
# If no actions found, mark game as over now
|
162
|
+
self._game_over = True
|
163
|
+
if not self._game_over_reason: # Set reason if not already set
|
164
|
+
self._game_over_reason = "No valid actions available."
|
165
|
+
log.info(
|
166
|
+
f"Game determined over by is_over() check: {self._game_over_reason}"
|
167
|
+
)
|
168
|
+
return True
|
169
|
+
# If actions exist, game is not over
|
170
|
+
return False
|
171
|
+
|
172
|
+
def get_outcome(self) -> float:
|
173
|
+
"""Returns terminal outcome: -1.0 for loss, 0.0 for ongoing."""
|
174
|
+
return -1.0 if self.is_over() else 0.0
|
175
|
+
|
176
|
+
def game_score(self) -> float:
|
177
|
+
"""Returns the current accumulated score."""
|
178
|
+
return self._game_score
|
179
|
+
|
180
|
+
def get_game_over_reason(self) -> str | None:
|
181
|
+
"""Returns the reason why the game ended, if it's over."""
|
182
|
+
return self._game_over_reason
|
183
|
+
|
184
|
+
def force_game_over(self, reason: str) -> None:
|
185
|
+
"""Forces the game to end immediately."""
|
186
|
+
self._game_over = True
|
187
|
+
self._game_over_reason = reason
|
188
|
+
self._valid_actions_cache = set() # Ensure cache is cleared
|
189
|
+
log.warning(f"Game forced over: {reason}")
|
190
|
+
|
191
|
+
def copy(self) -> "GameState":
|
192
|
+
"""Creates a deep copy for simulations (e.g., MCTS)."""
|
193
|
+
new_state = GameState.__new__(GameState)
|
194
|
+
memo = {id(self): new_state}
|
195
|
+
new_state.env_config = self.env_config
|
196
|
+
new_state._rng = random.Random()
|
197
|
+
new_state._rng.setstate(self._rng.getstate())
|
198
|
+
new_state.grid_data = copy.deepcopy(self.grid_data, memo)
|
199
|
+
new_state.shapes = [s.copy() if s else None for s in self.shapes]
|
200
|
+
new_state._game_score = self._game_score
|
201
|
+
new_state._game_over = self._game_over
|
202
|
+
new_state._game_over_reason = self._game_over_reason
|
203
|
+
new_state.current_step = self.current_step
|
204
|
+
new_state._valid_actions_cache = (
|
205
|
+
self._valid_actions_cache.copy()
|
206
|
+
if self._valid_actions_cache is not None
|
207
|
+
else None
|
208
|
+
)
|
209
|
+
return new_state
|
210
|
+
|
211
|
+
def __str__(self) -> str:
|
212
|
+
shape_strs = [str(s.color) if s else "None" for s in self.shapes]
|
213
|
+
status = "Over" if self.is_over() else "Ongoing"
|
214
|
+
return (
|
215
|
+
f"GameState(Step:{self.current_step}, Score:{self.game_score():.1f}, "
|
216
|
+
f"Status:{status}, Shapes:[{', '.join(shape_strs)}])"
|
217
|
+
)
|
@@ -0,0 +1,46 @@
|
|
1
|
+
|
2
|
+
# Environment Grid Submodule (`trianglengin.core.environment.grid`)
|
3
|
+
|
4
|
+
## Purpose and Architecture
|
5
|
+
|
6
|
+
This submodule manages the game's grid structure and related logic. It defines the triangular cells, their properties, relationships, and operations like placement validation and line clearing.
|
7
|
+
|
8
|
+
- **Cell Representation:** The actual state (occupied, death, color) is managed within `GridData` using NumPy arrays. The orientation (`is_up`) is implicit from the coordinates `(r + c) % 2 != 0`.
|
9
|
+
- **Grid Data Structure:** The [`GridData`](grid_data.py) class holds the grid state using efficient `numpy` arrays (`_occupied_np`, `_death_np`, `_color_id_np`). It initializes death zones based on the `PLAYABLE_RANGE_PER_ROW` setting in `EnvConfig`. It retrieves precomputed potential lines and a coordinate-to-line mapping from the [`line_cache`](line_cache.py) module during initialization.
|
10
|
+
- **Line Cache:** The [`line_cache`](line_cache.py) module precomputes **maximal** continuous lines (as coordinate tuples) of playable cells in the three directions (Horizontal, Diagonal TL-BR, Diagonal BL-TR). A maximal line is the longest possible continuous segment of playable cells in a given direction. It also creates a map from coordinates to the maximal lines they belong to (`_coord_to_lines_map`). This computation is done once per grid configuration (based on dimensions, playable ranges) and cached for efficiency. The `is_live` check within this module uses `PLAYABLE_RANGE_PER_ROW`.
|
11
|
+
- **Grid Logic:** The [`logic.py`](logic.py) module (exposed as `GridLogic`) contains functions operating on `GridData`. This includes:
|
12
|
+
- Checking if a shape can be placed (`can_place`), including matching triangle orientations and checking against death zones.
|
13
|
+
- Checking for and clearing completed lines (`check_and_clear_lines`) using the precomputed coordinate map for efficiency. It checks if *all* cells within a candidate maximal line (with a minimum length, typically 2) are occupied before marking it for clearing.
|
14
|
+
- **Grid Features:** Note: Any functions related to calculating scalar metrics (heights, holes, bumpiness) are expected to be handled outside this core engine library, likely in a separate features module or project.
|
15
|
+
|
16
|
+
## Exposed Interfaces
|
17
|
+
|
18
|
+
- **Classes:**
|
19
|
+
- `GridData`: Holds the grid state using NumPy arrays and cached line information.
|
20
|
+
- `__init__(config: EnvConfig)`
|
21
|
+
- `reset()`
|
22
|
+
- `valid(r: int, c: int) -> bool`
|
23
|
+
- `is_death(r: int, c: int) -> bool`
|
24
|
+
- `is_occupied(r: int, c: int) -> bool`
|
25
|
+
- `get_color_id(r: int, c: int) -> Optional[int]`
|
26
|
+
- `__deepcopy__(memo)`
|
27
|
+
- **Modules/Namespaces:**
|
28
|
+
- `logic` (often imported as `GridLogic`):
|
29
|
+
- `can_place(grid_data: GridData, shape: Shape, r: int, c: int) -> bool`
|
30
|
+
- `check_and_clear_lines(grid_data: GridData, newly_occupied_coords: Set[Tuple[int, int]]) -> Tuple[int, Set[Tuple[int, int]], Set[frozenset[Tuple[int, int]]]]` **(Returns: lines_cleared_count, unique_coords_cleared_set, set_of_cleared_lines_coord_sets)**
|
31
|
+
- `line_cache`:
|
32
|
+
- `get_precomputed_lines_and_map(config: EnvConfig) -> Tuple[List[Line], CoordMap]`
|
33
|
+
|
34
|
+
## Dependencies
|
35
|
+
|
36
|
+
- **[`trianglengin.config`](../../../config/README.md)**:
|
37
|
+
- `EnvConfig`: Used by `GridData` initialization and logic functions (specifically `PLAYABLE_RANGE_PER_ROW`).
|
38
|
+
- **[`trianglengin.structs`](../../../structs/README.md)**:
|
39
|
+
- Uses `Shape`, `NO_COLOR_ID`.
|
40
|
+
- **`numpy`**:
|
41
|
+
- Used extensively in `GridData`.
|
42
|
+
- **Standard Libraries:** `typing`, `logging`, `numpy`, `copy`.
|
43
|
+
|
44
|
+
---
|
45
|
+
|
46
|
+
**Note:** Please keep this README updated when changing the grid structure, cell properties, placement rules, or line clearing logic. Accurate documentation is crucial for maintainability.
|
@@ -0,0 +1,18 @@
|
|
1
|
+
# trianglengin/core/environment/grid/__init__.py
|
2
|
+
"""
|
3
|
+
Modules related to the game grid structure and logic.
|
4
|
+
|
5
|
+
Provides:
|
6
|
+
- GridData: Class representing the grid state.
|
7
|
+
- logic: Module containing grid-related logic (placement, line clearing).
|
8
|
+
- line_cache: Module for caching precomputed lines.
|
9
|
+
|
10
|
+
See GridData documentation: [grid_data.py](grid_data.py)
|
11
|
+
See Grid Logic documentation: [logic.py](logic.py)
|
12
|
+
See Line Cache documentation: [line_cache.py](line_cache.py)
|
13
|
+
"""
|
14
|
+
|
15
|
+
from . import line_cache, logic
|
16
|
+
from .grid_data import GridData
|
17
|
+
|
18
|
+
__all__ = ["GridData", "logic", "line_cache"]
|
@@ -0,0 +1,140 @@
|
|
1
|
+
# File: trianglengin/core/environment/grid/grid_data.py
|
2
|
+
import logging
|
3
|
+
|
4
|
+
import numpy as np
|
5
|
+
|
6
|
+
from trianglengin.config.env_config import EnvConfig
|
7
|
+
from trianglengin.core.environment.grid.line_cache import (
|
8
|
+
CoordMap,
|
9
|
+
Line,
|
10
|
+
get_precomputed_lines_and_map,
|
11
|
+
)
|
12
|
+
|
13
|
+
log = logging.getLogger(__name__)
|
14
|
+
|
15
|
+
|
16
|
+
class GridData:
|
17
|
+
"""Stores and manages the state of the game grid."""
|
18
|
+
|
19
|
+
__slots__ = (
|
20
|
+
"config",
|
21
|
+
"rows",
|
22
|
+
"cols",
|
23
|
+
"_occupied_np",
|
24
|
+
"_color_id_np",
|
25
|
+
"_death_np",
|
26
|
+
"_lines",
|
27
|
+
"_coord_to_lines_map",
|
28
|
+
)
|
29
|
+
|
30
|
+
def __init__(self, config: EnvConfig):
|
31
|
+
"""
|
32
|
+
Initializes the grid based on the provided configuration.
|
33
|
+
Death zones are set based on PLAYABLE_RANGE_PER_ROW.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
config: The environment configuration dataclass.
|
37
|
+
"""
|
38
|
+
self.config: EnvConfig = config
|
39
|
+
self.rows: int = config.ROWS
|
40
|
+
self.cols: int = config.COLS
|
41
|
+
|
42
|
+
self._occupied_np: np.ndarray = np.full(
|
43
|
+
(self.rows, self.cols), False, dtype=bool
|
44
|
+
)
|
45
|
+
self._color_id_np: np.ndarray = np.full(
|
46
|
+
(self.rows, self.cols), -1, dtype=np.int8
|
47
|
+
)
|
48
|
+
# Initialize all as death, then mark playable area as not death
|
49
|
+
self._death_np: np.ndarray = np.full((self.rows, self.cols), True, dtype=bool)
|
50
|
+
|
51
|
+
for r in range(self.rows):
|
52
|
+
start_col, end_col = config.PLAYABLE_RANGE_PER_ROW[r]
|
53
|
+
if start_col < end_col: # Ensure valid range
|
54
|
+
self._death_np[r, start_col:end_col] = False # Mark playable cols
|
55
|
+
|
56
|
+
# Get Precomputed Lines and Map from Cache
|
57
|
+
self._lines: list[Line]
|
58
|
+
self._coord_to_lines_map: CoordMap
|
59
|
+
self._lines, self._coord_to_lines_map = get_precomputed_lines_and_map(config)
|
60
|
+
|
61
|
+
log.debug(
|
62
|
+
f"GridData initialized: {self.rows}x{self.cols}, "
|
63
|
+
f"{np.sum(self._death_np)} death cells, "
|
64
|
+
f"{len(self._lines)} precomputed lines, "
|
65
|
+
f"{len(self._coord_to_lines_map)} mapped coords."
|
66
|
+
)
|
67
|
+
|
68
|
+
def reset(self) -> None:
|
69
|
+
"""Resets the grid to an empty state (occupied and colors)."""
|
70
|
+
self._occupied_np.fill(False)
|
71
|
+
self._color_id_np.fill(-1)
|
72
|
+
log.debug("GridData reset.")
|
73
|
+
|
74
|
+
def is_empty(self) -> bool:
|
75
|
+
"""Checks if the grid has any occupied cells (excluding death zones)."""
|
76
|
+
# Consider only non-death cells for emptiness check
|
77
|
+
playable_mask = ~self._death_np
|
78
|
+
return not self._occupied_np[playable_mask].any()
|
79
|
+
|
80
|
+
def valid(self, r: int, c: int) -> bool:
|
81
|
+
"""Checks if the coordinates (r, c) are within the grid bounds."""
|
82
|
+
return 0 <= r < self.rows and 0 <= c < self.cols
|
83
|
+
|
84
|
+
def is_death(self, r: int, c: int) -> bool:
|
85
|
+
"""
|
86
|
+
Checks if the cell (r, c) is a death zone.
|
87
|
+
|
88
|
+
Raises:
|
89
|
+
IndexError: If (r, c) is out of bounds.
|
90
|
+
"""
|
91
|
+
if not self.valid(r, c):
|
92
|
+
raise IndexError(
|
93
|
+
f"Coordinates ({r},{c}) out of bounds ({self.rows}x{self.cols})."
|
94
|
+
)
|
95
|
+
return bool(self._death_np[r, c])
|
96
|
+
|
97
|
+
def is_occupied(self, r: int, c: int) -> bool:
|
98
|
+
"""
|
99
|
+
Checks if the cell (r, c) is occupied by a triangle piece.
|
100
|
+
Returns False for death zones, regardless of the underlying array value.
|
101
|
+
|
102
|
+
Raises:
|
103
|
+
IndexError: If (r, c) is out of bounds.
|
104
|
+
"""
|
105
|
+
if not self.valid(r, c):
|
106
|
+
raise IndexError(
|
107
|
+
f"Coordinates ({r},{c}) out of bounds ({self.rows}x{self.cols})."
|
108
|
+
)
|
109
|
+
if self._death_np[r, c]:
|
110
|
+
return False
|
111
|
+
return bool(self._occupied_np[r, c])
|
112
|
+
|
113
|
+
def get_color_id(self, r: int, c: int) -> int | None:
|
114
|
+
"""
|
115
|
+
Gets the color ID of the triangle at (r, c).
|
116
|
+
|
117
|
+
Returns None if the cell is empty, a death zone, or out of bounds.
|
118
|
+
"""
|
119
|
+
if not self.valid(r, c) or self._death_np[r, c] or not self._occupied_np[r, c]:
|
120
|
+
return None
|
121
|
+
return int(self._color_id_np[r, c])
|
122
|
+
|
123
|
+
def __deepcopy__(self, memo):
|
124
|
+
"""Custom deepcopy implementation."""
|
125
|
+
new_grid = GridData.__new__(GridData)
|
126
|
+
memo[id(self)] = new_grid
|
127
|
+
|
128
|
+
new_grid.config = self.config
|
129
|
+
new_grid.rows = self.rows
|
130
|
+
new_grid.cols = self.cols
|
131
|
+
new_grid._occupied_np = self._occupied_np.copy()
|
132
|
+
new_grid._color_id_np = self._color_id_np.copy()
|
133
|
+
new_grid._death_np = self._death_np.copy()
|
134
|
+
|
135
|
+
# Lines list and map are obtained from cache, but need copying for the instance
|
136
|
+
new_grid._lines, new_grid._coord_to_lines_map = get_precomputed_lines_and_map(
|
137
|
+
self.config
|
138
|
+
)
|
139
|
+
|
140
|
+
return new_grid
|
@@ -0,0 +1,189 @@
|
|
1
|
+
# File: trianglengin/core/environment/grid/line_cache.py
|
2
|
+
import logging
|
3
|
+
from typing import Final, cast
|
4
|
+
|
5
|
+
import numpy as np
|
6
|
+
|
7
|
+
from trianglengin.config.env_config import EnvConfig
|
8
|
+
|
9
|
+
log = logging.getLogger(__name__)
|
10
|
+
|
11
|
+
# Type aliases
|
12
|
+
Coord = tuple[int, int]
|
13
|
+
Line = tuple[Coord, ...]
|
14
|
+
LineFsSet = set[frozenset[Coord]]
|
15
|
+
CoordMap = dict[Coord, LineFsSet]
|
16
|
+
ConfigKey = tuple[int, int, tuple[tuple[int, int], ...]]
|
17
|
+
CachedData = tuple[list[Line], CoordMap]
|
18
|
+
_LINE_CACHE: dict[ConfigKey, CachedData] = {}
|
19
|
+
|
20
|
+
# Directions
|
21
|
+
HORIZONTAL: Final = "h"
|
22
|
+
DIAGONAL_TL_BR: Final = "d1"
|
23
|
+
DIAGONAL_BL_TR: Final = "d2"
|
24
|
+
|
25
|
+
|
26
|
+
def _create_cache_key(config: EnvConfig) -> ConfigKey:
|
27
|
+
"""Creates an immutable cache key from relevant EnvConfig fields."""
|
28
|
+
playable_ranges_tuple: tuple[tuple[int, int], ...] = cast(
|
29
|
+
"tuple[tuple[int, int], ...]",
|
30
|
+
tuple(tuple(item) for item in config.PLAYABLE_RANGE_PER_ROW),
|
31
|
+
)
|
32
|
+
return (
|
33
|
+
config.ROWS,
|
34
|
+
config.COLS,
|
35
|
+
playable_ranges_tuple,
|
36
|
+
)
|
37
|
+
|
38
|
+
|
39
|
+
def get_precomputed_lines_and_map(config: EnvConfig) -> CachedData:
|
40
|
+
"""
|
41
|
+
Retrieves the precomputed maximal lines and the coordinate-to-lines map
|
42
|
+
for a given configuration, using a cache. Computes if not found.
|
43
|
+
"""
|
44
|
+
key = _create_cache_key(config)
|
45
|
+
if key not in _LINE_CACHE:
|
46
|
+
log.info(f"Cache miss for grid config: {key}. Computing maximal lines and map.")
|
47
|
+
# Use the computation function v4
|
48
|
+
_LINE_CACHE[key] = _compute_lines_and_map_v4(config)
|
49
|
+
|
50
|
+
lines, coord_map = _LINE_CACHE[key]
|
51
|
+
# Return copies to prevent modification of the cache
|
52
|
+
return list(lines), {coord: set(lineset) for coord, lineset in coord_map.items()}
|
53
|
+
|
54
|
+
|
55
|
+
def _is_live(r: int, c: int, config: EnvConfig, playable_mask: np.ndarray) -> bool:
|
56
|
+
"""Checks if a cell is within bounds and the playable range for its row using precomputed mask."""
|
57
|
+
# Bounds check first
|
58
|
+
if not (0 <= r < config.ROWS and 0 <= c < config.COLS):
|
59
|
+
return False
|
60
|
+
# Check precomputed mask and cast to bool for mypy
|
61
|
+
return bool(playable_mask[r, c])
|
62
|
+
|
63
|
+
|
64
|
+
def _get_neighbor(
|
65
|
+
r: int, c: int, direction: str, backward: bool, config: EnvConfig
|
66
|
+
) -> Coord | None:
|
67
|
+
"""
|
68
|
+
Gets the neighbor coordinate in a given direction (forward or backward).
|
69
|
+
Requires config for bounds checking.
|
70
|
+
"""
|
71
|
+
is_up = (r + c) % 2 != 0
|
72
|
+
|
73
|
+
if direction == HORIZONTAL:
|
74
|
+
dc = -1 if backward else 1
|
75
|
+
nr, nc = r, c + dc
|
76
|
+
elif direction == DIAGONAL_TL_BR: # Top-Left to Bottom-Right
|
77
|
+
if backward: # Moving Up-Left
|
78
|
+
nr, nc = (r - 1, c) if not is_up else (r, c - 1)
|
79
|
+
else: # Moving Down-Right
|
80
|
+
nr, nc = (r + 1, c) if is_up else (r, c + 1)
|
81
|
+
elif direction == DIAGONAL_BL_TR: # Bottom-Left to Top-Right
|
82
|
+
if backward: # Moving Down-Left
|
83
|
+
nr, nc = (r + 1, c) if is_up else (r, c - 1)
|
84
|
+
else: # Moving Up-Right
|
85
|
+
nr, nc = (r - 1, c) if not is_up else (r, c + 1)
|
86
|
+
else:
|
87
|
+
raise ValueError(f"Unknown direction: {direction}")
|
88
|
+
|
89
|
+
# Return None if neighbor is out of grid bounds (simplifies tracing loops)
|
90
|
+
if not (0 <= nr < config.ROWS and 0 <= nc < config.COLS):
|
91
|
+
return None
|
92
|
+
return (nr, nc)
|
93
|
+
|
94
|
+
|
95
|
+
def _compute_lines_and_map_v4(config: EnvConfig) -> CachedData:
|
96
|
+
"""
|
97
|
+
Generates all maximal potential horizontal and diagonal lines based on grid geometry
|
98
|
+
and playable ranges. Builds the coordinate map.
|
99
|
+
V4: Use trace-back-then-forward approach for each cell and direction.
|
100
|
+
"""
|
101
|
+
rows, cols = config.ROWS, config.COLS
|
102
|
+
maximal_lines_set: set[Line] = set()
|
103
|
+
processed_starts: set[tuple[Coord, str]] = set()
|
104
|
+
|
105
|
+
# --- Determine playable cells based on config ---
|
106
|
+
playable_mask = np.zeros((rows, cols), dtype=bool)
|
107
|
+
for r in range(rows):
|
108
|
+
if r < len(config.PLAYABLE_RANGE_PER_ROW):
|
109
|
+
start_col, end_col = config.PLAYABLE_RANGE_PER_ROW[r]
|
110
|
+
if start_col < end_col:
|
111
|
+
playable_mask[r, start_col:end_col] = True
|
112
|
+
# --- End Playable Mask ---
|
113
|
+
|
114
|
+
for r_init in range(rows):
|
115
|
+
for c_init in range(cols):
|
116
|
+
if not _is_live(r_init, c_init, config, playable_mask):
|
117
|
+
continue
|
118
|
+
|
119
|
+
current_coord = (r_init, c_init)
|
120
|
+
|
121
|
+
for direction in [HORIZONTAL, DIAGONAL_TL_BR, DIAGONAL_BL_TR]:
|
122
|
+
# 1. Trace backwards to find the true start of the segment
|
123
|
+
line_start_coord = current_coord
|
124
|
+
while True:
|
125
|
+
prev_coord_tuple = _get_neighbor(
|
126
|
+
line_start_coord[0],
|
127
|
+
line_start_coord[1],
|
128
|
+
direction,
|
129
|
+
backward=True,
|
130
|
+
config=config, # Pass config here
|
131
|
+
)
|
132
|
+
if prev_coord_tuple and _is_live(
|
133
|
+
prev_coord_tuple[0], prev_coord_tuple[1], config, playable_mask
|
134
|
+
):
|
135
|
+
line_start_coord = prev_coord_tuple
|
136
|
+
else:
|
137
|
+
break # Found the start or hit boundary/non-playable
|
138
|
+
|
139
|
+
# 2. Check if we've already processed this line from its start
|
140
|
+
if (line_start_coord, direction) in processed_starts:
|
141
|
+
continue
|
142
|
+
|
143
|
+
# 3. Trace forwards from the true start to build the maximal line
|
144
|
+
current_line: list[Coord] = []
|
145
|
+
trace_coord: Coord | None = line_start_coord
|
146
|
+
while trace_coord and _is_live(
|
147
|
+
trace_coord[0], trace_coord[1], config, playable_mask
|
148
|
+
):
|
149
|
+
current_line.append(trace_coord)
|
150
|
+
trace_coord = _get_neighbor(
|
151
|
+
trace_coord[0],
|
152
|
+
trace_coord[1],
|
153
|
+
direction,
|
154
|
+
backward=False,
|
155
|
+
config=config, # Pass config here
|
156
|
+
)
|
157
|
+
|
158
|
+
# 4. Store the maximal line if it's not empty
|
159
|
+
if current_line:
|
160
|
+
maximal_lines_set.add(tuple(current_line))
|
161
|
+
# Mark the start coordinate as processed for this direction
|
162
|
+
processed_starts.add((line_start_coord, direction))
|
163
|
+
|
164
|
+
# --- Build Final List and Map ---
|
165
|
+
final_lines_list = sorted(
|
166
|
+
maximal_lines_set,
|
167
|
+
key=lambda line: (
|
168
|
+
line[0][0],
|
169
|
+
line[0][1],
|
170
|
+
len(line),
|
171
|
+
line,
|
172
|
+
), # Sort for deterministic order
|
173
|
+
)
|
174
|
+
|
175
|
+
coord_map: CoordMap = {}
|
176
|
+
for line_tuple in final_lines_list:
|
177
|
+
line_fs = frozenset(line_tuple) # Use frozenset for map values
|
178
|
+
for coord in line_tuple:
|
179
|
+
if coord not in coord_map:
|
180
|
+
coord_map[coord] = set()
|
181
|
+
coord_map[coord].add(line_fs)
|
182
|
+
|
183
|
+
key = _create_cache_key(config)
|
184
|
+
log.info(
|
185
|
+
f"Computed {len(final_lines_list)} unique maximal lines (v4) "
|
186
|
+
f"and map for {len(coord_map)} coords for config {key}."
|
187
|
+
)
|
188
|
+
|
189
|
+
return final_lines_list, coord_map
|