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,131 @@
1
+ # File: trianglengin/core/environment/grid/logic.py
2
+ import logging
3
+ from typing import TYPE_CHECKING, Final
4
+
5
+ from trianglengin.core.structs.constants import NO_COLOR_ID
6
+
7
+ if TYPE_CHECKING:
8
+ from trianglengin.core.environment.grid.grid_data import GridData
9
+ from trianglengin.core.structs.shape import Shape
10
+
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ # Minimum length a line must have to be eligible for clearing
15
+ MIN_LINE_LENGTH_TO_CLEAR: Final = 2
16
+
17
+
18
+ def can_place(grid_data: "GridData", shape: "Shape", r: int, c: int) -> bool:
19
+ """
20
+ Checks if a shape can be placed at the specified (r, c) top-left position.
21
+
22
+ Args:
23
+ grid_data: The current grid state.
24
+ shape: The shape to place.
25
+ r: Target row for the shape's origin (relative 0,0).
26
+ c: Target column for the shape's origin (relative 0,0).
27
+
28
+ Returns:
29
+ True if the placement is valid, False otherwise.
30
+ """
31
+ if not shape or not shape.triangles:
32
+ log.warning("Attempted to check placement for an empty or invalid shape.")
33
+ return False
34
+
35
+ for dr, dc, is_up_shape in shape.triangles:
36
+ place_r, place_c = r + dr, c + dc
37
+
38
+ # 1. Check bounds
39
+ if not grid_data.valid(place_r, place_c):
40
+ return False
41
+
42
+ # 2. Check death zone
43
+ if grid_data.is_death(place_r, place_c):
44
+ return False
45
+
46
+ # 3. Check occupancy
47
+ if grid_data.is_occupied(place_r, place_c):
48
+ return False
49
+
50
+ # 4. Check orientation match
51
+ is_up_grid = (place_r + place_c) % 2 != 0
52
+ if is_up_shape != is_up_grid:
53
+ return False
54
+
55
+ return True
56
+
57
+
58
+ def check_and_clear_lines(
59
+ grid_data: "GridData", newly_occupied_coords: set[tuple[int, int]]
60
+ ) -> tuple[int, set[tuple[int, int]], set[frozenset[tuple[int, int]]]]:
61
+ """
62
+ Checks for completed lines involving the newly occupied coordinates and clears them.
63
+
64
+ Uses the precomputed coordinate-to-lines map for efficiency. Only checks
65
+ maximal lines that contain at least one of the newly occupied cells.
66
+
67
+ Args:
68
+ grid_data: The grid data object (will be modified if lines are cleared).
69
+ newly_occupied_coords: A set of (r, c) tuples that were just filled.
70
+
71
+ Returns:
72
+ A tuple containing:
73
+ - lines_cleared_count (int): The number of maximal lines cleared.
74
+ - unique_coords_cleared_set (set[tuple[int, int]]): A set of unique (r, c)
75
+ coordinates of all triangles that were cleared.
76
+ - set_of_cleared_lines_coord_sets (set[frozenset[tuple[int, int]]]): A set
77
+ containing the frozensets of coordinates for each cleared maximal line.
78
+ """
79
+ if not newly_occupied_coords:
80
+ return 0, set(), set()
81
+
82
+ # Find all candidate maximal lines that include any of the new coordinates
83
+ candidate_lines_fs: set[frozenset[tuple[int, int]]] = set()
84
+ for r_new, c_new in newly_occupied_coords:
85
+ # Check if the coordinate exists in the precomputed map
86
+ if (r_new, c_new) in grid_data._coord_to_lines_map:
87
+ candidate_lines_fs.update(grid_data._coord_to_lines_map[(r_new, c_new)])
88
+
89
+ if not candidate_lines_fs:
90
+ return 0, set(), set()
91
+
92
+ cleared_lines_fs: set[frozenset[tuple[int, int]]] = set()
93
+ unique_coords_cleared: set[tuple[int, int]] = set()
94
+
95
+ # Check each candidate line for completion
96
+ for line_fs in candidate_lines_fs:
97
+ line_coords = tuple(line_fs) # Convert back for iteration/indexing if needed
98
+
99
+ # --- Added Check: Ensure line has minimum length ---
100
+ if len(line_coords) < MIN_LINE_LENGTH_TO_CLEAR:
101
+ continue
102
+ # --- End Added Check ---
103
+
104
+ # Check if ALL coordinates in this line are now occupied
105
+ is_line_complete = True
106
+ for r_line, c_line in line_coords:
107
+ # Use is_occupied which correctly handles death zones
108
+ if not grid_data.is_occupied(r_line, c_line):
109
+ is_line_complete = False
110
+ break # No need to check further coords in this line
111
+
112
+ if is_line_complete:
113
+ cleared_lines_fs.add(line_fs)
114
+ unique_coords_cleared.update(line_coords)
115
+
116
+ # If any lines were completed, clear the cells
117
+ if unique_coords_cleared:
118
+ log.debug(
119
+ f"Clearing {len(cleared_lines_fs)} lines involving {len(unique_coords_cleared)} unique cells."
120
+ )
121
+ for r_clear, c_clear in unique_coords_cleared:
122
+ # Check bounds just in case, though precomputed lines should be valid
123
+ if grid_data.valid(r_clear, c_clear):
124
+ grid_data._occupied_np[r_clear, c_clear] = False
125
+ grid_data._color_id_np[r_clear, c_clear] = NO_COLOR_ID
126
+ else:
127
+ log.warning(
128
+ f"Attempted to clear out-of-bounds coordinate ({r_clear}, {c_clear}) from line."
129
+ )
130
+
131
+ return len(cleared_lines_fs), unique_coords_cleared, cleared_lines_fs
@@ -0,0 +1,3 @@
1
+ # File: trianglengin/trianglengin/core/environment/logic/__init__.py
2
+ # Moved from alphatriangle/environment/logic/__init__.py
3
+ # No code changes needed, only file location.
@@ -0,0 +1,38 @@
1
+ # trianglengin/core/environment/logic/actions.py
2
+ """
3
+ Logic for determining valid actions in the game state.
4
+ """
5
+
6
+ import logging
7
+ from typing import TYPE_CHECKING # Changed List to Set
8
+
9
+ from trianglengin.core.environment.action_codec import ActionType, encode_action
10
+ from trianglengin.core.environment.grid import logic as GridLogic
11
+
12
+ if TYPE_CHECKING:
13
+ from trianglengin.core.environment.game_state import GameState
14
+
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def get_valid_actions(state: "GameState") -> set[ActionType]: # Return Set
20
+ """
21
+ Calculates and returns a set of all valid encoded action indices
22
+ for the current game state.
23
+ """
24
+ valid_actions: set[ActionType] = set() # Use set directly
25
+ for shape_idx, shape in enumerate(state.shapes):
26
+ if shape is None:
27
+ continue
28
+
29
+ # Iterate through potential placement locations (r, c)
30
+ # Optimization: Could potentially limit r, c range based on shape bbox
31
+ for r in range(state.env_config.ROWS):
32
+ for c in range(state.env_config.COLS):
33
+ # Check if placement is valid using GridLogic
34
+ if GridLogic.can_place(state.grid_data, shape, r, c):
35
+ action_index = encode_action(shape_idx, r, c, state.env_config)
36
+ valid_actions.add(action_index) # Add to set
37
+
38
+ return valid_actions
@@ -0,0 +1,134 @@
1
+ # trianglengin/core/environment/logic/step.py
2
+ import logging
3
+ from typing import TYPE_CHECKING
4
+
5
+ # Import shapes module itself for ShapeLogic reference if needed later,
6
+ # though direct calls are used here.
7
+ from trianglengin.core.environment.grid import logic as GridLogic
8
+ from trianglengin.core.structs.constants import COLOR_TO_ID_MAP, NO_COLOR_ID
9
+
10
+ if TYPE_CHECKING:
11
+ # Keep EnvConfig import for reward calculation
12
+ from trianglengin.config import EnvConfig
13
+ from trianglengin.core.environment.game_state import GameState
14
+ from trianglengin.core.environment.grid.line_cache import Coord
15
+
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def calculate_reward(
21
+ placed_count: int,
22
+ cleared_count: int,
23
+ is_game_over: bool,
24
+ config: "EnvConfig",
25
+ ) -> float:
26
+ """
27
+ Calculates the step reward based on the new specification (v3).
28
+
29
+ Args:
30
+ placed_count: Number of triangles successfully placed.
31
+ cleared_count: Number of unique triangles cleared this step.
32
+ is_game_over: Boolean indicating if the game ended *after* this step.
33
+ config: Environment configuration containing reward constants.
34
+
35
+ Returns:
36
+ The calculated step reward.
37
+ """
38
+ reward = 0.0
39
+
40
+ # 1. Placement Reward
41
+ reward += placed_count * config.REWARD_PER_PLACED_TRIANGLE
42
+
43
+ # 2. Line Clear Reward
44
+ reward += cleared_count * config.REWARD_PER_CLEARED_TRIANGLE
45
+
46
+ # 3. Survival Reward OR Game Over Penalty
47
+ if is_game_over:
48
+ # Apply penalty only if game ended THIS step
49
+ reward += config.PENALTY_GAME_OVER
50
+ else:
51
+ reward += config.REWARD_PER_STEP_ALIVE
52
+
53
+ logger.debug(
54
+ f"Calculated Reward: Placement({placed_count * config.REWARD_PER_PLACED_TRIANGLE:.3f}) "
55
+ f"+ LineClear({cleared_count * config.REWARD_PER_CLEARED_TRIANGLE:.3f}) "
56
+ f"+ {'GameOver' if is_game_over else 'Survival'}({config.PENALTY_GAME_OVER if is_game_over else config.REWARD_PER_STEP_ALIVE:.3f}) "
57
+ f"= {reward:.3f}"
58
+ )
59
+ return reward
60
+
61
+
62
+ def execute_placement(
63
+ game_state: "GameState", shape_idx: int, r: int, c: int
64
+ ) -> tuple[int, int]:
65
+ """
66
+ Places a shape, clears lines, and updates grid/score state.
67
+ Refill logic and reward calculation are handled by the caller (GameState.step).
68
+
69
+ Args:
70
+ game_state: The current game state (will be modified).
71
+ shape_idx: Index of the shape to place.
72
+ r: Target row for placement.
73
+ c: Target column for placement.
74
+
75
+ Returns:
76
+ Tuple[int, int]: (cleared_triangle_count, placed_triangle_count)
77
+ Stats needed for reward calculation.
78
+ Raises:
79
+ ValueError: If placement is invalid (should be pre-checked).
80
+ IndexError: If shape_idx is invalid.
81
+ """
82
+ if not (0 <= shape_idx < len(game_state.shapes)):
83
+ raise IndexError(f"Invalid shape index: {shape_idx}")
84
+
85
+ shape = game_state.shapes[shape_idx]
86
+ if not shape:
87
+ # This case should ideally be prevented by GameState.step checking valid_actions first.
88
+ logger.error(f"Attempted to place an empty shape slot: {shape_idx}")
89
+ raise ValueError("Cannot place an empty shape slot.")
90
+
91
+ # Check placement validity using GridLogic - raise error if invalid
92
+ if not GridLogic.can_place(game_state.grid_data, shape, r, c):
93
+ # This case should ideally be prevented by GameState.step checking valid_actions first.
94
+ logger.error(
95
+ f"Invalid placement attempted in execute_placement: Shape {shape_idx} at ({r},{c}). "
96
+ "This should have been caught earlier."
97
+ )
98
+ raise ValueError("Invalid placement location.")
99
+
100
+ # --- Place the shape ---
101
+ placed_coords: set[Coord] = set()
102
+ placed_count = 0
103
+ color_id = COLOR_TO_ID_MAP.get(shape.color, NO_COLOR_ID)
104
+ if color_id == NO_COLOR_ID:
105
+ # Use default color ID 0 if the test color isn't found
106
+ logger.warning(
107
+ f"Shape color {shape.color} not found in COLOR_TO_ID_MAP! Using ID 0."
108
+ )
109
+ color_id = 0
110
+
111
+ for dr, dc, _ in shape.triangles:
112
+ tri_r, tri_c = r + dr, c + dc
113
+ # Assume valid coordinates as can_place passed and raised error otherwise
114
+ game_state.grid_data._occupied_np[tri_r, tri_c] = True
115
+ game_state.grid_data._color_id_np[tri_r, tri_c] = color_id
116
+ placed_coords.add((tri_r, tri_c))
117
+ placed_count += 1
118
+
119
+ game_state.shapes[shape_idx] = None # Remove shape from slot
120
+
121
+ # --- Check and clear lines ---
122
+ lines_cleared_count, unique_coords_cleared, _ = GridLogic.check_and_clear_lines(
123
+ game_state.grid_data, placed_coords
124
+ )
125
+ cleared_count = len(unique_coords_cleared)
126
+
127
+ # --- Update Score ---
128
+ game_state._game_score += placed_count + cleared_count * 2
129
+
130
+ # --- REMOVED REFILL LOGIC ---
131
+ # --- REMOVED REWARD CALCULATION ---
132
+
133
+ # Return stats needed by GameState.step for reward calculation
134
+ return cleared_count, placed_count
@@ -0,0 +1,19 @@
1
+ """
2
+ Shapes submodule handling shape generation and management.
3
+ """
4
+
5
+ from .logic import (
6
+ generate_random_shape,
7
+ get_neighbors,
8
+ is_shape_connected,
9
+ refill_shape_slots,
10
+ )
11
+ from .templates import PREDEFINED_SHAPE_TEMPLATES
12
+
13
+ __all__ = [
14
+ "generate_random_shape",
15
+ "refill_shape_slots",
16
+ "is_shape_connected",
17
+ "get_neighbors",
18
+ "PREDEFINED_SHAPE_TEMPLATES",
19
+ ]
@@ -0,0 +1,84 @@
1
+ # File: trianglengin/trianglengin/core/environment/shapes/logic.py
2
+ import logging
3
+ import random
4
+ from typing import TYPE_CHECKING
5
+
6
+ from ...structs import SHAPE_COLORS, Shape # Import from library's structs
7
+ from .templates import PREDEFINED_SHAPE_TEMPLATES
8
+
9
+ if TYPE_CHECKING:
10
+ # Use relative import for GameState within the library
11
+ from ..game_state import GameState
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ def generate_random_shape(rng: random.Random) -> Shape:
17
+ """Generates a random shape from predefined templates and colors."""
18
+ template = rng.choice(PREDEFINED_SHAPE_TEMPLATES)
19
+ color = rng.choice(SHAPE_COLORS)
20
+ return Shape(template, color)
21
+
22
+
23
+ def refill_shape_slots(game_state: "GameState", rng: random.Random) -> None:
24
+ """
25
+ Refills ALL empty shape slots in the GameState, but ONLY if ALL slots are currently empty.
26
+ This implements batch refilling.
27
+ """
28
+ if all(shape is None for shape in game_state.shapes):
29
+ logger.debug("All shape slots are empty. Refilling all slots.")
30
+ for i in range(game_state.env_config.NUM_SHAPE_SLOTS):
31
+ game_state.shapes[i] = generate_random_shape(rng)
32
+ logger.debug(f"Refilled slot {i} with {game_state.shapes[i]}")
33
+ else:
34
+ logger.debug("Not all shape slots are empty. Skipping refill.")
35
+
36
+
37
+ def get_neighbors(r: int, c: int, is_up: bool) -> list[tuple[int, int]]:
38
+ """Gets potential neighbor coordinates for connectivity check."""
39
+ if is_up:
40
+ # Up triangle neighbors: (r, c-1), (r, c+1), (r+1, c)
41
+ return [(r, c - 1), (r, c + 1), (r + 1, c)]
42
+ else:
43
+ # Down triangle neighbors: (r, c-1), (r, c+1), (r-1, c)
44
+ return [(r, c - 1), (r, c + 1), (r - 1, c)]
45
+
46
+
47
+ def is_shape_connected(shape: Shape) -> bool:
48
+ """Checks if all triangles in a shape are connected using BFS."""
49
+ if not shape.triangles or len(shape.triangles) <= 1:
50
+ return True
51
+
52
+ # --- CORRECTED BFS LOGIC V2 ---
53
+ # Store the actual triangle tuples (r, c, is_up) in a set for quick lookup
54
+ all_triangles_set = set(shape.triangles)
55
+ # Also store just the coordinates for quick neighbor checking
56
+ all_coords_set = {(r, c) for r, c, _ in shape.triangles}
57
+
58
+ start_triangle = shape.triangles[0] # (r, c, is_up)
59
+
60
+ visited: set[tuple[int, int, bool]] = set()
61
+ queue: list[tuple[int, int, bool]] = [start_triangle]
62
+ visited.add(start_triangle)
63
+
64
+ while queue:
65
+ current_r, current_c, current_is_up = queue.pop(0)
66
+
67
+ # Check neighbors based on the current triangle's orientation
68
+ for nr, nc in get_neighbors(current_r, current_c, current_is_up):
69
+ # Check if the neighbor *coordinate* exists in the shape
70
+ if (nr, nc) in all_coords_set:
71
+ # Find the full neighbor triangle tuple (r, c, is_up)
72
+ neighbor_triangle: tuple[int, int, bool] | None = None
73
+ for tri_tuple in all_triangles_set:
74
+ if tri_tuple[0] == nr and tri_tuple[1] == nc:
75
+ neighbor_triangle = tri_tuple
76
+ break
77
+
78
+ # If the neighbor exists in the shape and hasn't been visited
79
+ if neighbor_triangle and neighbor_triangle not in visited:
80
+ visited.add(neighbor_triangle)
81
+ queue.append(neighbor_triangle)
82
+ # --- END CORRECTED BFS LOGIC V2 ---
83
+
84
+ return len(visited) == len(all_triangles_set)