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,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