From de83af99a119c0c72f97587c17418637049a1c00 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Fri, 17 Jul 2026 14:59:14 +0530 Subject: [PATCH 01/11] Rebuild the 4P Ludo board as a clockwise classic cross Replace the misaligned base-track rotation with one seat-0 quarter template rotated 90 degrees per seat, so every seat's start square, home column, yard, and finish wedge stay aligned by construction and tokens travel clockwise like classic Ludo apps. - settings.py: per-count SEAT_COLORS/SEAT_COLOR_NAMES tables (Player 1 is always blue and always the bottom seat; seats continue clockwise) replacing PLAYER_COLORS/PLAYER_NAMES. - game.py: seat colours are stamped onto players on construction, so restored saves repaint to match the current board palette. - board_render.py: DisplayLayout now carries seat_colors and cell_size; yards reordered to (BL, TL, TR, BR); yard token slots centered; centre wedges face their owners; name banners moved outside the board edge instead of covering track cells. - tests: new test_board_geometry.py locks track continuity, clockwise winding, start/yard adjacency, loop-end-to-home-entry handoff, home lanes marching inward, clockwise seating, and the seat colour table. Co-Authored-By: Claude Fable 5 --- Python Ludo Game/board_render.py | 144 +++++++++------ Python Ludo Game/game.py | 13 +- Python Ludo Game/main.py | 12 +- Python Ludo Game/settings.py | 47 +++-- Python Ludo Game/tests/test_board_geometry.py | 167 ++++++++++++++++++ .../tests/test_visual_overhaul.py | 4 +- Python Ludo Game/visual_theme.py | 2 +- 7 files changed, 316 insertions(+), 73 deletions(-) create mode 100644 Python Ludo Game/tests/test_board_geometry.py diff --git a/Python Ludo Game/board_render.py b/Python Ludo Game/board_render.py index 1a5c478..9ab3528 100644 --- a/Python Ludo Game/board_render.py +++ b/Python Ludo Game/board_render.py @@ -9,14 +9,16 @@ import visual_theme as theme from models import Move -from settings import GOLD, INK, PLAYER_COLORS, WHITE +from settings import GOLD, INK, WHITE, seat_colors Point = tuple[float, float] +Color = tuple[int, int, int] +GRID_CELLS = 15 SQUARE_CELL = 42 SQUARE_LEFT = 135 SQUARE_TOP = 105 -SQUARE_SIZE = SQUARE_CELL * 15 +SQUARE_SIZE = SQUARE_CELL * GRID_CELLS RADIAL_CENTER = (450.0, 410.0) RADIAL_RADIUS = 245.0 RADIAL_INNER_DISTANCE = 78.0 @@ -38,18 +40,21 @@ class DisplayCell: class DisplayLayout: """Screen coordinates used by the renderer for one board shape. - The rules engine keeps its compact step indexes. This display layout lets - the four-player game look like a traditional square board while the five- - and six-player games keep the generalized radial coordinates. + The rules engine keeps its compact step indexes. This display layout maps + every index onto the screen: a traditional square cross for four players + and compact radial boards for five and six players. ``track_positions`` + is index-aligned with the engine's track, so its ordering alone decides + the on-screen movement direction (clockwise). """ total_players: int - polygon_sides: int + seat_colors: tuple[Color, ...] track_positions: tuple[Point, ...] home_lanes: tuple[tuple[Point, ...], ...] yard_positions: tuple[tuple[Point, ...], ...] center: Point radius: float + cell_size: float track_cells: tuple[DisplayCell, ...] = () home_lane_cells: tuple[tuple[DisplayCell, ...], ...] = () arm_backplates: tuple[tuple[Point, ...], ...] = () @@ -146,7 +151,7 @@ def _draw_square_homes(self, surface: pygame.Surface, game, fonts: dict[str, pyg for player_index, yard in enumerate(_square_home_rects()): positions = self.display.yard_positions[player_index] - color = PLAYER_COLORS[player_index] + color = self.display.seat_colors[player_index] theme.draw_shadowed_rect(surface, yard, color, border=theme.BOARD_EDGE, radius=8) inner = yard.inflate(-92, -92) pygame.draw.rect(surface, WHITE, inner, border_radius=5) @@ -159,7 +164,7 @@ def _draw_radial_homes(self, surface: pygame.Surface, game, fonts: dict[str, pyg cx, cy = self.display.center for player_index, positions in enumerate(self.display.yard_positions): - color = PLAYER_COLORS[player_index] + color = self.display.seat_colors[player_index] yard_center = _average_point(positions) outward = _normal((yard_center[0] - cx, yard_center[1] - cy)) tangent = (-outward[1], outward[0]) @@ -206,10 +211,15 @@ def _draw_player_tab( ) -> None: """Draw the player's name tag near their home area.""" - color = PLAYER_COLORS[player_index] + color = self.display.seat_colors[player_index] name = game.players[player_index].name[:16] if point is None: - label_y = yard.bottom + 10 if yard.centery < self.display.center[1] else yard.y - 34 + # Banners live on the outer edge of the board: above the two top + # yards and below the two bottom yards, never over track cells. + if yard.centery < self.display.center[1]: + label_y = yard.y - 24 + else: + label_y = yard.bottom + 24 rect = pygame.Rect(0, 0, 150, 28) rect.center = (yard.centerx, label_y) else: @@ -230,7 +240,7 @@ def _draw_track(self, surface: pygame.Surface) -> None: fill = WHITE border = theme.BOARD_EDGE if owner is not None: - fill = theme.brighten(PLAYER_COLORS[owner], 8) + fill = theme.brighten(self.display.seat_colors[owner], 8) if self.display.track_cells: self._draw_display_cell(surface, self.display.track_cells[index], fill, border) else: @@ -248,13 +258,13 @@ def _draw_start_arrow(self, surface: pygame.Surface, player_index: int, point: t next_index = (self.layout.start_indices[player_index] + 1) % self.layout.track_length next_point = self.display.track_positions[next_index] angle = math.atan2(next_point[1] - point[1], next_point[0] - point[0]) - theme.draw_arrow(surface, point, angle, PLAYER_COLORS[player_index], length=22) + theme.draw_arrow(surface, point, angle, self.display.seat_colors[player_index], length=22) def _draw_home_lanes(self, surface: pygame.Surface) -> None: """Draw each player's private colored path into the center.""" for player_index, lane in enumerate(self.display.home_lanes): - color = PLAYER_COLORS[player_index] + color = self.display.seat_colors[player_index] for lane_index, point in enumerate(lane): if self.display.home_lane_cells: self._draw_display_cell( @@ -297,7 +307,7 @@ def _draw_center_home(self, surface: pygame.Surface) -> None: for step in range(8): angle = start + (end - start) * step / 7 points.append(_ipoint((cx + math.cos(angle) * radius, cy + math.sin(angle) * radius))) - pygame.draw.polygon(surface, PLAYER_COLORS[player_index], points) + pygame.draw.polygon(surface, self.display.seat_colors[player_index], points) pygame.draw.circle(surface, theme.HUD_DARK, _ipoint(self.display.center), 38) pygame.draw.circle(surface, theme.WALLPAPER_BLUE, _ipoint(self.display.center), 28) pygame.draw.circle(surface, WHITE, _ipoint(self.display.center), 7) @@ -308,20 +318,17 @@ def _draw_square_center_home(self, surface: pygame.Surface) -> None: center_rect = _square_center_rect() cx, cy = center_rect.center - corners = [ - center_rect.topleft, - center_rect.topright, - center_rect.bottomright, - center_rect.bottomleft, - ] + # Each finish wedge points at the seat that owns it: seat 0 arrives + # from the bottom edge, then clockwise seats 1..3 arrive from the + # left, top, and right edges. wedges = [ - [corners[0], corners[1], (cx, cy)], - [corners[1], corners[2], (cx, cy)], - [corners[2], corners[3], (cx, cy)], - [corners[3], corners[0], (cx, cy)], + [center_rect.bottomleft, center_rect.bottomright, (cx, cy)], + [center_rect.topleft, center_rect.bottomleft, (cx, cy)], + [center_rect.topleft, center_rect.topright, (cx, cy)], + [center_rect.topright, center_rect.bottomright, (cx, cy)], ] for player_index, points in enumerate(wedges): - pygame.draw.polygon(surface, PLAYER_COLORS[player_index], points) + pygame.draw.polygon(surface, self.display.seat_colors[player_index], points) pygame.draw.polygon(surface, theme.BOARD_EDGE, points, 2) pygame.draw.circle(surface, theme.HUD_DARK, (cx, cy), 24) pygame.draw.circle(surface, theme.WALLPAPER_BLUE, (cx, cy), 17) @@ -370,38 +377,70 @@ def _display_layout_for(layout) -> DisplayLayout: return _radial_display_layout(layout) +# One quarter of the classic 15x15 cross, written for seat 0 (Player 1, blue, +# bottom-left yard). Cell 0 is the seat's colored start square, directly beside +# its yard, and the cells continue CLOCKWISE around the board: up the bottom +# arm's left column, west along the west arm's bottom row, then up the west +# tip. Rotating this template 90 degrees clockwise per seat builds the full +# 52-cell loop, which keeps every seat's start square, home column, and yard +# aligned by construction. +SQUARE_QUARTER_TRACK: tuple[tuple[int, int], ...] = ( + (6, 13), (6, 12), (6, 11), (6, 10), (6, 9), + (5, 8), (4, 8), (3, 8), (2, 8), (1, 8), (0, 8), + (0, 7), (0, 6), +) + +# Seat 0's private home column: entered from the bottom edge, climbing the +# middle column toward the center. The final cell sits inside the 3x3 center +# square, underneath that seat's colored finish wedge. +SQUARE_HOME_LANE: tuple[tuple[int, int], ...] = ( + (7, 13), (7, 12), (7, 11), (7, 10), (7, 9), (7, 8), +) + + +def _rotate_cell_cw(cell: tuple[int, int], quarter_turns: int) -> tuple[int, int]: + """Rotate a 15x15 grid cell clockwise by 90-degree steps around the center.""" + + col, row = cell + for _ in range(quarter_turns % 4): + col, row = GRID_CELLS - 1 - row, col + return col, row + + def _square_display_layout(layout) -> DisplayLayout: """Build classic 15-by-15 Ludo coordinates for the four-player board.""" - # These grid cells trace the standard outer cross path. Rotating by one - # segment places player 1 at the top, then the other players clockwise. - base_track = [ - (6, 1), (6, 2), (6, 3), (6, 4), (6, 5), (5, 6), (4, 6), (3, 6), (2, 6), (1, 6), (0, 6), (0, 7), (0, 8), - (1, 8), (2, 8), (3, 8), (4, 8), (5, 8), (6, 9), (6, 10), (6, 11), (6, 12), (6, 13), (6, 14), (7, 14), (8, 14), - (8, 13), (8, 12), (8, 11), (8, 10), (8, 9), (9, 8), (10, 8), (11, 8), (12, 8), (13, 8), (14, 8), (14, 7), (14, 6), - (13, 6), (12, 6), (11, 6), (10, 6), (9, 6), (8, 5), (8, 4), (8, 3), (8, 2), (8, 1), (8, 0), (7, 0), (6, 0), + track_cells = [ + _rotate_cell_cw(cell, seat) + for seat in range(layout.total_players) + for cell in SQUARE_QUARTER_TRACK ] - rotated = base_track[13:] + base_track[:13] - home_lanes = ( - tuple(_grid_center(7, row) for row in range(1, 7)), - tuple(_grid_center(col, 7) for col in range(13, 7, -1)), - tuple(_grid_center(7, row) for row in range(13, 7, -1)), - tuple(_grid_center(col, 7) for col in range(1, 7)), - ) - yard_grids = ( - ((2, 2), (4, 2), (2, 4), (4, 4)), - ((10, 2), (12, 2), (10, 4), (12, 4)), - ((10, 10), (12, 10), (10, 12), (12, 12)), - ((2, 10), (4, 10), (2, 12), (4, 12)), + home_lanes = tuple( + tuple(_grid_center(*_rotate_cell_cw(cell, seat)) for cell in SQUARE_HOME_LANE) + for seat in range(layout.total_players) ) + yard_positions = tuple(_square_yard_slots(yard) for yard in _square_home_rects()) return DisplayLayout( total_players=layout.total_players, - polygon_sides=layout.polygon_sides, - track_positions=tuple(_grid_center(col, row) for col, row in rotated), + seat_colors=seat_colors(layout.total_players), + track_positions=tuple(_grid_center(col, row) for col, row in track_cells), home_lanes=home_lanes, - yard_positions=tuple(tuple(_grid_center(col, row) for col, row in group) for group in yard_grids), + yard_positions=yard_positions, center=_grid_center(7, 7), radius=SQUARE_SIZE / 2, + cell_size=SQUARE_CELL, + ) + + +def _square_yard_slots(yard: pygame.Rect) -> tuple[Point, ...]: + """Return four token parking spots centered inside one corner yard.""" + + spread = 36 + return ( + (yard.centerx - spread, yard.centery - spread), + (yard.centerx + spread, yard.centery - spread), + (yard.centerx - spread, yard.centery + spread), + (yard.centerx + spread, yard.centery + spread), ) @@ -441,12 +480,13 @@ def _radial_display_layout(layout) -> DisplayLayout: return DisplayLayout( total_players=layout.total_players, - polygon_sides=layout.polygon_sides, + seat_colors=seat_colors(layout.total_players), track_positions=tuple(track_positions), home_lanes=tuple(home_lanes), yard_positions=tuple(yard_positions), center=center, radius=radius, + cell_size=RADIAL_CELL_SIZE, track_cells=tuple(track_cells), home_lane_cells=tuple(home_lane_cells), arm_backplates=tuple(arm_backplates), @@ -463,14 +503,18 @@ def _grid_center(col: int, row: int) -> Point: def _square_home_rects() -> tuple[pygame.Rect, ...]: - """Return the four large corner yards for the square board.""" + """Return the four large corner yards, in clockwise seat order. + + Seat 0 (Player 1) owns the bottom-left corner, then seats continue + clockwise: top-left, top-right, bottom-right. + """ cell = SQUARE_CELL return ( + pygame.Rect(SQUARE_LEFT, SQUARE_TOP + cell * 9, cell * 6, cell * 6), pygame.Rect(SQUARE_LEFT, SQUARE_TOP, cell * 6, cell * 6), pygame.Rect(SQUARE_LEFT + cell * 9, SQUARE_TOP, cell * 6, cell * 6), pygame.Rect(SQUARE_LEFT + cell * 9, SQUARE_TOP + cell * 9, cell * 6, cell * 6), - pygame.Rect(SQUARE_LEFT, SQUARE_TOP + cell * 9, cell * 6, cell * 6), ) diff --git a/Python Ludo Game/game.py b/Python Ludo Game/game.py index e93fcd0..9f4d746 100644 --- a/Python Ludo Game/game.py +++ b/Python Ludo Game/game.py @@ -7,7 +7,7 @@ from board import BoardLayout from models import Move, MoveResult, PlayerState -from settings import PLAYER_COLORS, TOKENS_PER_PLAYER +from settings import TOKENS_PER_PLAYER, seat_colors @dataclass(frozen=True) @@ -57,11 +57,12 @@ def __init__( self.seed = seed self.ai_profile = ai_profile + colors = seat_colors(rules.total_players) self.players: list[PlayerState] = [] for index, player in enumerate(players): if isinstance(player, PlayerState): - # Restored games already have token positions, counters, and - # colors, so keep those objects instead of creating new ones. + # Restored games already have token positions and counters, so + # keep those objects instead of creating new ones. self.players.append(player) else: name, is_human = player @@ -69,9 +70,13 @@ def __init__( PlayerState( name=name, is_human=is_human, - color=PLAYER_COLORS[index], + color=colors[index], ) ) + for index, player_state in enumerate(self.players): + # Seat colours are authoritative: they always match the painted + # board, even for saves written before a palette change. + player_state.color = colors[index] self.current = 0 # ``awaiting`` is the small state machine the UI and AI both follow: diff --git a/Python Ludo Game/main.py b/Python Ludo Game/main.py index 35e2a86..6df102a 100644 --- a/Python Ludo Game/main.py +++ b/Python Ludo Game/main.py @@ -32,8 +32,6 @@ PANEL_WIDTH, PANEL_X, INK, - PLAYER_COLORS, - PLAYER_NAMES, SAVE_DIR, SAVEGAME_PATH, SCREEN_HEIGHT, @@ -43,6 +41,8 @@ TOKEN_ANIM_SPEED, WHITE, WINDOW_TITLE, + seat_colors, + seat_name, ) SETUP_CONTROL_X = PANEL_X + 24 @@ -578,7 +578,7 @@ def _start_new_game(self) -> None: else: # AI names include the color so players can map the sidebar to # the matching yard on the board. - players.append((f"AI {ai_number} ({PLAYER_NAMES[index]})", False)) + players.append((f"AI {ai_number} ({seat_name(self.total_players, index)})", False)) ai_number += 1 self.start_game(LudoGame(players, rules, ai_profile=self.ai_profiles[self.ai_profile_index])) save_game(self.game) @@ -746,7 +746,7 @@ def _draw_setup(self) -> None: # Name fields are lightweight hand-drawn rectangles. The active # one gets a colored border so typing focus is visible. rect = _name_rect(index) - color = PLAYER_COLORS[index] + color = seat_colors(self.total_players)[index] pygame.draw.rect(self.window, PANEL_CARD, rect, border_radius=6) pygame.draw.rect(self.window, color if self.active_field == index else PANEL_EDGE, rect, 2, border_radius=6) ui.draw_text(self.window, self.fonts["body"], self.name_fields[index], (rect.x + 10, rect.y + 4), WHITE) @@ -1026,7 +1026,7 @@ def _make_icon() -> pygame.Surface: icon = pygame.Surface((64, 64), pygame.SRCALPHA) pygame.draw.rect(icon, (238, 232, 209), (4, 4, 56, 56), border_radius=12) - for index, color in enumerate(PLAYER_COLORS[:4]): + for index, color in enumerate(seat_colors(4)): x = 22 + (index % 2) * 20 y = 22 + (index // 2) * 20 pygame.draw.circle(icon, color, (x, y), 9) @@ -1046,7 +1046,7 @@ def _draw_preview_board(surface: pygame.Surface) -> None: angle = -math.pi / 2 + math.tau * index / sides points.append((int(cx + math.cos(angle) * radius * 0.42), int(cy + math.sin(angle) * radius * 0.42))) pygame.draw.polygon(surface, (238, 232, 209), points) - pygame.draw.polygon(surface, PLAYER_COLORS[sides - 4], points, 4) + pygame.draw.polygon(surface, seat_colors(sides)[0], points, 4) ui.draw_text(surface, pygame.font.SysFont("arial", 24, bold=True), f"{sides}P", (cx, cy), WHITE, center=True) diff --git a/Python Ludo Game/settings.py b/Python Ludo Game/settings.py index 0b2b1f9..6ebc733 100644 --- a/Python Ludo Game/settings.py +++ b/Python Ludo Game/settings.py @@ -67,16 +67,43 @@ DANGER = (224, 84, 84) DISABLED = (88, 98, 102) -PLAYER_COLORS = [ - (215, 55, 55), # red - (46, 101, 218), # blue - (52, 162, 88), # green - (224, 173, 48), # yellow - (155, 86, 206), # purple - (236, 118, 54), # orange -] - -PLAYER_NAMES = ["Red", "Blue", "Green", "Yellow", "Purple", "Orange"] +# Base token colours. Individual boards pick from these via the seat tables +# below instead of indexing one shared list, because the classic colour wheel +# differs between the 4-, 5-, and 6-player reference boards. +COLOR_BLUE = (46, 101, 218) +COLOR_RED = (215, 55, 55) +COLOR_GREEN = (52, 162, 88) +COLOR_YELLOW = (224, 173, 48) +COLOR_PURPLE = (155, 86, 206) +COLOR_ORANGE = (236, 118, 54) + +# Seat 0 is always Player 1 and always sits at the bottom of the screen +# (bottom-left on the square board). The remaining seats proceed clockwise, +# matching the classic Ludo apps this UI is modelled on. Tokens also travel +# clockwise, so seating order and movement direction agree everywhere. +SEAT_COLORS: dict[int, tuple[tuple[int, int, int], ...]] = { + 4: (COLOR_BLUE, COLOR_RED, COLOR_GREEN, COLOR_YELLOW), + 5: (COLOR_BLUE, COLOR_ORANGE, COLOR_GREEN, COLOR_RED, COLOR_YELLOW), + 6: (COLOR_BLUE, COLOR_YELLOW, COLOR_PURPLE, COLOR_RED, COLOR_GREEN, COLOR_ORANGE), +} + +SEAT_COLOR_NAMES: dict[int, tuple[str, ...]] = { + 4: ("Blue", "Red", "Green", "Yellow"), + 5: ("Blue", "Orange", "Green", "Red", "Yellow"), + 6: ("Blue", "Yellow", "Purple", "Red", "Green", "Orange"), +} + + +def seat_colors(total_players: int) -> tuple[tuple[int, int, int], ...]: + """Return the clockwise seat colours for one supported player count.""" + + return SEAT_COLORS[total_players] + + +def seat_name(total_players: int, seat: int) -> str: + """Return the colour name for one seat, used in default AI player names.""" + + return SEAT_COLOR_NAMES[total_players][seat] # --------------------------------------------------------------------------- diff --git a/Python Ludo Game/tests/test_board_geometry.py b/Python Ludo Game/tests/test_board_geometry.py new file mode 100644 index 0000000..9b717b7 --- /dev/null +++ b/Python Ludo Game/tests/test_board_geometry.py @@ -0,0 +1,167 @@ +"""Regression tests for the on-screen board geometry. + +The rules engine only tracks abstract step indexes, so the visual layout in +``board_render.DisplayLayout`` fully decides where cells appear and which way +tokens travel. These tests lock in the authentic-Ludo properties: + +- the shared track is one connected loop (no teleporting cells), +- tokens travel CLOCKWISE around the board, +- every seat's start square sits beside its own yard, +- every seat's final loop cell hands over cleanly to its home column, +- home columns run inward and finish at the board center, +- Player 1 (seat 0) sits at the bottom and seats continue clockwise. +""" + +from __future__ import annotations + +import math +import os +import sys +import unittest +from pathlib import Path + + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + +GAME_DIR = Path(__file__).resolve().parents[1] +if str(GAME_DIR) not in sys.path: + sys.path.insert(0, str(GAME_DIR)) + +from board import BoardLayout +from board_render import BoardRenderer +from settings import SEAT_COLORS + +# Player counts whose display layout follows the authentic clockwise rules. +SUPPORTED_COUNTS = (4,) + +# A step between two consecutive track cells is at most one grid cell plus the +# occasional corner turn, which measures sqrt(2) cells. 1.6 gives headroom for +# rounding without letting real gaps slip through. +ADJACENT_CELLS = 1.6 + + +def make_display(total_players: int): + """Return the display layout the renderer would use for one player count.""" + + return BoardRenderer(BoardLayout.for_player_count(total_players)).display + + +def centroid(points) -> tuple[float, float]: + """Return the average point of a small coordinate cluster.""" + + xs = [point[0] for point in points] + ys = [point[1] for point in points] + return (sum(xs) / len(xs), sum(ys) / len(ys)) + + +def distance(a: tuple[float, float], b: tuple[float, float]) -> float: + """Return the straight-line distance between two points.""" + + return math.hypot(a[0] - b[0], a[1] - b[1]) + + +class TrackContinuityTests(unittest.TestCase): + """The shared loop must be connected and must run clockwise.""" + + def test_consecutive_track_cells_are_adjacent(self) -> None: + """Every step around the loop, including the wrap, is one cell long.""" + + for count in SUPPORTED_COUNTS: + display = make_display(count) + limit = ADJACENT_CELLS * display.cell_size + with self.subTest(total_players=count): + track = display.track_positions + for index, point in enumerate(track): + following = track[(index + 1) % len(track)] + self.assertLessEqual( + distance(point, following), + limit, + f"track cells {index} and {(index + 1) % len(track)} are not adjacent", + ) + + def test_track_runs_clockwise_on_screen(self) -> None: + """The shoelace sum is positive, which means clockwise in y-down coordinates.""" + + for count in SUPPORTED_COUNTS: + display = make_display(count) + with self.subTest(total_players=count): + track = display.track_positions + area = 0.0 + for index, (x1, y1) in enumerate(track): + x2, y2 = track[(index + 1) % len(track)] + area += x1 * y2 - x2 * y1 + self.assertGreater(area, 0.0, "track winds anti-clockwise") + + +class SeatAlignmentTests(unittest.TestCase): + """Each seat's start, yard, home column, and colors must stay in lockstep.""" + + def test_start_cells_sit_beside_their_own_yards(self) -> None: + """The closest yard to every start square belongs to the same seat.""" + + for count in SUPPORTED_COUNTS: + display = make_display(count) + layout = BoardLayout.for_player_count(count) + yard_centroids = [centroid(slots) for slots in display.yard_positions] + for seat, start_index in enumerate(layout.start_indices): + with self.subTest(total_players=count, seat=seat): + start = display.track_positions[start_index] + nearest = min( + range(count), key=lambda other: distance(start, yard_centroids[other]) + ) + self.assertEqual(nearest, seat) + self.assertLessEqual(distance(start, yard_centroids[seat]), 5 * display.cell_size) + + def test_final_loop_cell_reaches_the_home_column(self) -> None: + """The last shared cell is adjacent to that seat's first home cell.""" + + for count in SUPPORTED_COUNTS: + display = make_display(count) + layout = BoardLayout.for_player_count(count) + limit = ADJACENT_CELLS * display.cell_size + for seat, start_index in enumerate(layout.start_indices): + with self.subTest(total_players=count, seat=seat): + final_index = (start_index - 1) % layout.track_length + final_cell = display.track_positions[final_index] + home_entry = display.home_lanes[seat][0] + self.assertLessEqual(distance(final_cell, home_entry), limit) + + def test_home_lanes_march_inward_to_the_center(self) -> None: + """Home cells get strictly closer to the center and end beside it.""" + + for count in SUPPORTED_COUNTS: + display = make_display(count) + for seat, lane in enumerate(display.home_lanes): + with self.subTest(total_players=count, seat=seat): + distances = [distance(cell, display.center) for cell in lane] + self.assertEqual(distances, sorted(distances, reverse=True)) + self.assertLessEqual(distances[-1], 2.2 * display.cell_size) + + def test_seat_zero_sits_at_the_bottom_and_seats_continue_clockwise(self) -> None: + """Player 1 is the bottom seat; the rest follow in clockwise order.""" + + for count in SUPPORTED_COUNTS: + display = make_display(count) + with self.subTest(total_players=count): + yard_centroids = [centroid(slots) for slots in display.yard_positions] + self.assertGreater( + yard_centroids[0][1], display.center[1], "seat 0 is not in the bottom half" + ) + angles = [ + math.atan2(y - display.center[1], x - display.center[0]) + for x, y in yard_centroids + ] + for seat in range(count): + step = (angles[(seat + 1) % count] - angles[seat]) % math.tau + self.assertLess(step, math.pi, f"seat {seat + 1} is not clockwise of seat {seat}") + + def test_display_uses_the_seat_color_table(self) -> None: + """The renderer paints with the same palette the engine assigns.""" + + for count in SUPPORTED_COUNTS: + with self.subTest(total_players=count): + self.assertEqual(make_display(count).seat_colors, SEAT_COLORS[count]) + + +if __name__ == "__main__": + unittest.main() diff --git a/Python Ludo Game/tests/test_visual_overhaul.py b/Python Ludo Game/tests/test_visual_overhaul.py index 177c2a7..5514c96 100644 --- a/Python Ludo Game/tests/test_visual_overhaul.py +++ b/Python Ludo Game/tests/test_visual_overhaul.py @@ -19,7 +19,7 @@ from board_render import BoardRenderer from game import LudoGame, LudoRules -from settings import PANEL_X, PLAYER_COLORS, SCREEN_HEIGHT, SCREEN_WIDTH +from settings import PANEL_X, SCREEN_HEIGHT, SCREEN_WIDTH, seat_colors def make_game(total_players: int) -> LudoGame: @@ -113,7 +113,7 @@ def test_boards_for_all_player_counts_show_wallpaper_and_player_colours(self) -> BoardRenderer(game.layout).draw(surface, game, self.fonts) self.assertGreater(count_near_color(surface, visual_theme.WALLPAPER_BLUE), 140) - for color in PLAYER_COLORS[:total_players]: + for color in seat_colors(total_players): self.assertGreater(count_near_color(surface, color), 20) def test_radial_boards_use_arm_grids_instead_of_single_track_lines(self) -> None: diff --git a/Python Ludo Game/visual_theme.py b/Python Ludo Game/visual_theme.py index e9470ca..87b1d04 100644 --- a/Python Ludo Game/visual_theme.py +++ b/Python Ludo Game/visual_theme.py @@ -11,7 +11,7 @@ import pygame -from settings import INK, PLAYER_COLORS, WHITE +from settings import INK, WHITE WALLPAPER_BLUE = (30, 123, 197) From cd8bfe21ebe334c088d0907fc5d93f85a84cf001 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Fri, 17 Jul 2026 15:13:00 +0530 Subject: [PATCH 02/11] Rebuild 5P/6P Ludo as compact convex radial boards Replace the star-shaped radial layout (yards at the arm tips) with the compact reference layout: yard triangles wedged between arms, one cream convex silhouette, and a central polygon hub. - Per-count RadialSpec tuning tables; arms sit at pi/2 - pi/n + tau*k/n so seat 0's yard points straight down and seats continue clockwise. - Track per arm: outbound lane away from the hub, tip cell, inbound lane back to the hub, giving a clockwise loop whose start cells sit beside their owners' yards and whose loop-ends (the arm tips) hand over orthogonally to the home columns marching into the hub. - Yards: colored triangles with white inner triangles, 2x2 token clusters, and name banners anchored outside the silhouette. - Hub: n-gon with one colored wedge per seat facing its home column and the live die rendered at the center via the new shared visual_theme.draw_die_face helper (also used by the HUD tray). - Geometry tests now cover 4, 5, and 6 players; the two superseded radial shape tests were removed along with dead radial helpers. Co-Authored-By: Claude Fable 5 --- Python Ludo Game/board_render.py | 547 ++++++++++-------- Python Ludo Game/tests/test_board_geometry.py | 7 +- .../tests/test_visual_overhaul.py | 61 -- Python Ludo Game/visual_theme.py | 31 +- 4 files changed, 347 insertions(+), 299 deletions(-) diff --git a/Python Ludo Game/board_render.py b/Python Ludo Game/board_render.py index 9ab3528..ec9ccea 100644 --- a/Python Ludo Game/board_render.py +++ b/Python Ludo Game/board_render.py @@ -20,12 +20,56 @@ SQUARE_TOP = 105 SQUARE_SIZE = SQUARE_CELL * GRID_CELLS RADIAL_CENTER = (450.0, 410.0) -RADIAL_RADIUS = 245.0 -RADIAL_INNER_DISTANCE = 78.0 -RADIAL_CELL_STEP = 28.0 -RADIAL_CELL_SIZE = 30.0 -RADIAL_LANE_OFFSET = 34.0 -RADIAL_YARD_GAP = 70.0 + + +@dataclass(frozen=True) +class RadialSpec: + """Tuning knobs for one compact radial board (five or six players). + + Every distance is measured in pixels from the board center. The values + are chosen so neighbouring arms just meet at the hub ring, the tip + diagonal stays within one comfortable token step, and the whole board + plus name banners fits the fixed logical canvas. + """ + + inner_radius: float # distance to the innermost track cells (hub ring) + cell_step: float # radial distance between neighbouring lane cells + cell_size: float # side length of one rotated square cell + lane_offset: float # sideways distance from the arm axis to each lane + yard_apex_radius: float # inward point of the yard triangle + yard_base_radius: float # outer edge of the yard triangle + yard_half_angle: float # angular half-width of the yard triangle base + token_radius: float # distance to the middle of the 2x2 token cluster + token_spread: float # half-spacing between yard token slots + banner_radius: float # distance to the player name banner + + +RADIAL_SPECS: dict[int, RadialSpec] = { + 5: RadialSpec( + inner_radius=92.0, + cell_step=36.0, + cell_size=33.0, + lane_offset=37.0, + yard_apex_radius=136.0, + yard_base_radius=330.0, + yard_half_angle=math.radians(22.0), + token_radius=258.0, + token_spread=24.0, + banner_radius=362.0, + ), + 6: RadialSpec( + inner_radius=98.0, + cell_step=33.0, + cell_size=30.0, + lane_offset=34.0, + yard_apex_radius=138.0, + yard_base_radius=318.0, + yard_half_angle=math.radians(17.0), + token_radius=250.0, + token_spread=22.0, + banner_radius=350.0, + ), +} @dataclass(frozen=True) @@ -57,7 +101,10 @@ class DisplayLayout: cell_size: float track_cells: tuple[DisplayCell, ...] = () home_lane_cells: tuple[tuple[DisplayCell, ...], ...] = () - arm_backplates: tuple[tuple[Point, ...], ...] = () + yard_frames: tuple[tuple[Point, ...], ...] = () + silhouette: tuple[Point, ...] = () + hub_polygon: tuple[Point, ...] = () + banner_anchors: tuple[Point, ...] = () class BoardRenderer: @@ -95,7 +142,7 @@ def draw( self._draw_radial_homes(surface, game, fonts) self._draw_track(surface) self._draw_home_lanes(surface) - self._draw_center_home(surface) + self._draw_center_home(surface, game) self._draw_move_highlights(surface, legal_moves) self._draw_tokens(surface, game, fonts, animated_positions) @@ -135,16 +182,15 @@ def _draw_table(self, surface: pygame.Surface) -> None: theme.draw_shadowed_rect(surface, board, theme.CREAM, border=theme.BOARD_EDGE, radius=8) return - for backplate in self.display.arm_backplates: - theme.draw_shadowed_polygon( - surface, - [_ipoint(point) for point in backplate], - theme.CREAM, - border=theme.BOARD_EDGE, - border_width=3, - ) - pygame.draw.circle(surface, theme.CREAM, _ipoint(self.display.center), 108) - pygame.draw.circle(surface, theme.BOARD_EDGE, _ipoint(self.display.center), 108, 3) + # One convex cream slab behind the whole radial board, so the arms and + # yard wedges read as a single physical game board. + theme.draw_shadowed_polygon( + surface, + [_ipoint(point) for point in self.display.silhouette], + theme.CREAM, + border=theme.BOARD_EDGE, + border_width=4, + ) def _draw_square_homes(self, surface: pygame.Surface, game, fonts: dict[str, pygame.font.Font]) -> None: """Draw four large square home yards like a classic Ludo board.""" @@ -160,31 +206,38 @@ def _draw_square_homes(self, surface: pygame.Surface, game, fonts: dict[str, pyg self._draw_player_tab(surface, game, fonts, player_index, yard) def _draw_radial_homes(self, surface: pygame.Surface, game, fonts: dict[str, pygame.font.Font]) -> None: - """Draw triangular homes for the five- and six-player boards.""" + """Draw the triangular yard wedges between the radial board arms.""" - cx, cy = self.display.center for player_index, positions in enumerate(self.display.yard_positions): color = self.display.seat_colors[player_index] - yard_center = _average_point(positions) - outward = _normal((yard_center[0] - cx, yard_center[1] - cy)) - tangent = (-outward[1], outward[0]) - base = (yard_center[0] + outward[0] * 48, yard_center[1] + outward[1] * 48) - tip = (yard_center[0] - outward[0] * 98, yard_center[1] - outward[1] * 98) - points = [ - _ipoint((base[0] + tangent[0] * 92, base[1] + tangent[1] * 92)), - _ipoint(tip), - _ipoint((base[0] - tangent[0] * 92, base[1] - tangent[1] * 92)), - ] - theme.draw_shadowed_polygon(surface, points, color, border=theme.BOARD_EDGE, border_width=4) + frame = self.display.yard_frames[player_index] + theme.draw_shadowed_polygon( + surface, + [_ipoint(point) for point in frame], + color, + border=theme.BOARD_EDGE, + border_width=4, + ) + # A smaller white triangle inside the coloured wedge holds the + # 2x2 token parking cluster, like the reference boards. + centroid = _average_point(frame) inner = [ - _ipoint((yard_center[0] + tangent[0] * 54, yard_center[1] + tangent[1] * 54)), - _ipoint((yard_center[0] - outward[0] * 54, yard_center[1] - outward[1] * 54)), - _ipoint((yard_center[0] - tangent[0] * 54, yard_center[1] - tangent[1] * 54)), + _ipoint( + ( + centroid[0] + (point[0] - centroid[0]) * 0.62, + centroid[1] + (point[1] - centroid[1]) * 0.62, + ) + ) + for point in frame ] pygame.draw.polygon(surface, WHITE, inner) pygame.draw.polygon(surface, theme.BOARD_EDGE, inner, 2) self._draw_yard_slots(surface, positions, color) - self._draw_player_tab(surface, game, fonts, player_index, pygame.Rect(0, 0, 1, 1), yard_center, outward) + banner = pygame.Rect(0, 0, 150, 28) + banner.center = _ipoint(self.display.banner_anchors[player_index]) + theme.draw_player_banner( + surface, banner, color, game.players[player_index].name[:16], fonts + ) def _draw_yard_slots( self, @@ -206,30 +259,21 @@ def _draw_player_tab( fonts: dict[str, pygame.font.Font], player_index: int, yard: pygame.Rect, - point: tuple[float, float] | None = None, - outward: tuple[float, float] | None = None, ) -> None: - """Draw the player's name tag near their home area.""" + """Draw the player's name tag on the outer edge of a square yard. + + Banners live outside the board: above the two top yards and below the + two bottom yards, never over track cells. + """ color = self.display.seat_colors[player_index] name = game.players[player_index].name[:16] - if point is None: - # Banners live on the outer edge of the board: above the two top - # yards and below the two bottom yards, never over track cells. - if yard.centery < self.display.center[1]: - label_y = yard.y - 24 - else: - label_y = yard.bottom + 24 - rect = pygame.Rect(0, 0, 150, 28) - rect.center = (yard.centerx, label_y) + if yard.centery < self.display.center[1]: + label_y = yard.y - 24 else: - outward = outward or (0.0, 1.0) - rect = pygame.Rect(0, 0, 150, 28) - # Radial homes can sit close to the screen edge, especially the - # top seat. Clamp the banner so the player's name remains readable. - label_x = int(point[0] + outward[0] * 88) - label_y = int(point[1] + outward[1] * 88) - rect.center = (max(78, min(832, label_x)), max(36, min(764, label_y))) + label_y = yard.bottom + 24 + rect = pygame.Rect(0, 0, 150, 28) + rect.center = (yard.centerx, label_y) theme.draw_player_banner(surface, rect, color, name, fonts) def _draw_track(self, surface: pygame.Surface) -> None: @@ -291,27 +335,31 @@ def _draw_display_cell( pygame.draw.polygon(surface, fill, points) pygame.draw.polygon(surface, border, points, 2) - def _draw_center_home(self, surface: pygame.Surface) -> None: - """Draw the multicolor home medallion in the board center.""" + def _draw_center_home(self, surface: pygame.Surface, game) -> None: + """Draw the multicolor finish area in the board center.""" if self.layout.total_players == 4: self._draw_square_center_home(surface) return - cx, cy = self.display.center - radius = 74 - for player_index in range(self.layout.total_players): - start = -math.pi / 2 + math.tau * player_index / self.layout.total_players - end = -math.pi / 2 + math.tau * (player_index + 1) / self.layout.total_players - points = [_ipoint((cx, cy))] - for step in range(8): - angle = start + (end - start) * step / 7 - points.append(_ipoint((cx + math.cos(angle) * radius, cy + math.sin(angle) * radius))) - pygame.draw.polygon(surface, self.display.seat_colors[player_index], points) - pygame.draw.circle(surface, theme.HUD_DARK, _ipoint(self.display.center), 38) - pygame.draw.circle(surface, theme.WALLPAPER_BLUE, _ipoint(self.display.center), 28) - pygame.draw.circle(surface, WHITE, _ipoint(self.display.center), 7) - pygame.draw.circle(surface, theme.BOARD_EDGE, _ipoint(self.display.center), radius, 3) + # Radial hub: one coloured wedge per seat, each facing the arm whose + # home column ends on that hub edge, with the live die on top. + hub = self.display.hub_polygon + center = _ipoint(self.display.center) + shadow = [(x + 0, y + 6) for x, y in (_ipoint(point) for point in hub)] + pygame.draw.polygon(surface, theme.SHADOW, shadow) + for seat in range(self.layout.total_players): + wedge = [ + center, + _ipoint(hub[seat - 1]), + _ipoint(hub[seat]), + ] + pygame.draw.polygon(surface, self.display.seat_colors[seat], wedge) + pygame.draw.polygon(surface, theme.BOARD_EDGE, [_ipoint(point) for point in hub], 3) + die_size = 44 + die_rect = pygame.Rect(0, 0, die_size, die_size) + die_rect.center = center + theme.draw_die_face(surface, die_rect, game.last_roll) def _draw_square_center_home(self, surface: pygame.Surface) -> None: """Draw the four colored triangles in the classic square board center.""" @@ -445,54 +493,214 @@ def _square_yard_slots(yard: pygame.Rect) -> tuple[Point, ...]: def _radial_display_layout(layout) -> DisplayLayout: - """Build fitted pentagon/hexagon arm grids for the desktop play area.""" + """Build a compact convex radial board for five or six players. + + Layout rules, mirroring the classic square board: + + - Seat 0's yard triangle points at the bottom of the screen and seats + continue clockwise, one yard wedge between every pair of arms. + - Each arm carries two white lanes plus a coloured home column. A token + arrives near the hub, runs OUT along the lane away from its owner's + yard, crosses the tip cell, and runs back IN along the lane beside the + owner's yard, so the overall loop is clockwise. + - The start cell is the inbound lane's outermost cell, right beside the + seat's own yard, and the loop-end (the tip) hands over orthogonally to + the home column, which marches inward and finishes at the hub. + """ + total = layout.total_players + spec = RADIAL_SPECS[total] center = RADIAL_CENTER - radius = RADIAL_RADIUS - segment_length = len(layout.track_positions) // layout.total_players home_length = len(layout.home_lanes[0]) - track_cells: list[DisplayCell] = [] - track_positions: list[Point] = [] + path_cells: list[DisplayCell] = [] home_lane_cells: list[tuple[DisplayCell, ...]] = [] - home_lanes: list[tuple[Point, ...]] = [] yard_positions: list[tuple[Point, ...]] = [] - arm_backplates: list[tuple[Point, ...]] = [] + yard_frames: list[tuple[Point, ...]] = [] + banner_anchors: list[Point] = [] - for player_index in range(layout.total_players): - outward = _player_outward(player_index, layout.total_players) + for seat in range(total): + arm_angle = _seat_arm_angle(seat, total) + outward = _unit(arm_angle) tangent = (-outward[1], outward[0]) - arm_backplates.append(_radial_arm_backplate(center, outward, tangent)) - - # A radial Ludo arm has two white race lanes flanking one coloured home - # lane. The main track walks down the left lane, crosses near the hub, - # then walks back up the right lane. - segment_cells = _radial_track_cells(center, outward, tangent) - if len(segment_cells) != segment_length: - raise ValueError("radial visual track must match the rules segment length") - track_cells.extend(segment_cells) - track_positions.extend(cell.center for cell in segment_cells) - - home_cells = _radial_home_cells(center, outward, tangent, home_length) - home_lane_cells.append(tuple(home_cells)) - home_lanes.append(tuple(cell.center for cell in home_cells)) - yard_positions.append(tuple(_radial_yard_cluster(center, outward, tangent))) + + # Outbound lane (away from the hub), tip, then inbound lane back to + # the hub. The +tangent side faces this seat's own yard wedge. + arm: list[DisplayCell] = [] + for step in range(home_length): + arm.append( + _radial_cell(center, outward, tangent, spec.inner_radius + step * spec.cell_step, -spec.lane_offset, spec.cell_size) + ) + arm.append( + _radial_cell(center, outward, tangent, spec.inner_radius + home_length * spec.cell_step, 0.0, spec.cell_size) + ) + for step in range(home_length): + arm.append( + _radial_cell( + center, + outward, + tangent, + spec.inner_radius + (home_length - 1 - step) * spec.cell_step, + spec.lane_offset, + spec.cell_size, + ) + ) + path_cells.extend(arm) + + home_lane_cells.append( + tuple( + _radial_cell( + center, + outward, + tangent, + spec.inner_radius + (home_length - 1 - step) * spec.cell_step, + 0.0, + spec.cell_size, + ) + for step in range(home_length) + ) + ) + + yard_angle = _seat_yard_angle(seat, total) + yard_frames.append(_radial_yard_frame(center, yard_angle, spec)) + yard_positions.append(_radial_yard_slots(center, yard_angle, spec)) + banner_anchors.append(_radial_banner_anchor(center, yard_angle, spec)) + + # Rotate the concatenated arm path so index 0 is seat 0's start cell (the + # inbound lane's outermost cell). Engine start indices are 13 * seat, and + # the same rotation aligns every other seat by symmetry. + entry_offset = home_length + 1 + track_cells = path_cells[entry_offset:] + path_cells[:entry_offset] + + hub_polygon = tuple( + _polar(center, _seat_yard_angle(seat, total), spec.inner_radius - 0.55 * spec.cell_step) + for seat in range(total) + ) + hull_points: list[Point] = [] + for cell in track_cells: + hull_points.extend(cell.corners) + for frame in yard_frames: + hull_points.extend(frame) + silhouette = tuple(_inflate_from(center, _convex_hull(hull_points), 14.0)) return DisplayLayout( - total_players=layout.total_players, - seat_colors=seat_colors(layout.total_players), - track_positions=tuple(track_positions), - home_lanes=tuple(home_lanes), + total_players=total, + seat_colors=seat_colors(total), + track_positions=tuple(cell.center for cell in track_cells), + home_lanes=tuple(tuple(cell.center for cell in lane) for lane in home_lane_cells), yard_positions=tuple(yard_positions), center=center, - radius=radius, - cell_size=RADIAL_CELL_SIZE, + radius=spec.yard_base_radius, + cell_size=spec.cell_size, track_cells=tuple(track_cells), home_lane_cells=tuple(home_lane_cells), - arm_backplates=tuple(arm_backplates), + yard_frames=tuple(yard_frames), + silhouette=silhouette, + hub_polygon=hub_polygon, + banner_anchors=tuple(banner_anchors), ) +def _seat_arm_angle(seat: int, total_players: int) -> float: + """Return the direction of one seat's arm, in radians. + + Seat 0's yard points straight down (pi/2 in y-down screen coordinates), + and its arm sits one half-sector anti-clockwise of the yard. Increasing + the angle moves clockwise on screen. + """ + + return math.pi / 2 - math.pi / total_players + math.tau * seat / total_players + + +def _seat_yard_angle(seat: int, total_players: int) -> float: + """Return the direction of one seat's yard wedge, between two arms.""" + + return math.pi / 2 + math.tau * seat / total_players + + +def _unit(angle: float) -> Point: + """Return the unit vector for ``angle`` in y-down screen coordinates.""" + + return math.cos(angle), math.sin(angle) + + +def _polar(center: Point, angle: float, radius: float) -> Point: + """Return the point ``radius`` pixels from ``center`` along ``angle``.""" + + return center[0] + math.cos(angle) * radius, center[1] + math.sin(angle) * radius + + +def _radial_yard_frame(center: Point, yard_angle: float, spec: RadialSpec) -> tuple[Point, ...]: + """Return the colored yard triangle: apex toward the hub, base outward.""" + + return ( + _polar(center, yard_angle, spec.yard_apex_radius), + _polar(center, yard_angle - spec.yard_half_angle, spec.yard_base_radius), + _polar(center, yard_angle + spec.yard_half_angle, spec.yard_base_radius), + ) + + +def _radial_yard_slots(center: Point, yard_angle: float, spec: RadialSpec) -> tuple[Point, ...]: + """Return four token parking spots in a 2x2 grid inside one yard.""" + + base = _polar(center, yard_angle, spec.token_radius) + outward = _unit(yard_angle) + tangent = (-outward[1], outward[0]) + spread = spec.token_spread + offsets = ((-spread, -spread), (spread, -spread), (-spread, spread), (spread, spread)) + return tuple( + ( + base[0] + tangent[0] * side + outward[0] * depth, + base[1] + tangent[1] * side + outward[1] * depth, + ) + for side, depth in offsets + ) + + +def _radial_banner_anchor(center: Point, yard_angle: float, spec: RadialSpec) -> Point: + """Return the name-banner center on the outer edge of one yard.""" + + x, y = _polar(center, yard_angle, spec.banner_radius) + # Keep the whole 150x28 banner on the logical canvas with a small margin. + return (max(92.0, min(1188.0, x)), max(28.0, min(872.0, y))) + + +def _convex_hull(points: list[Point]) -> list[Point]: + """Return the convex hull of ``points`` using the monotone chain scan.""" + + unique = sorted(set(points)) + if len(unique) <= 2: + return unique + + def cross(o: Point, a: Point, b: Point) -> float: + return (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]) + + lower: list[Point] = [] + for point in unique: + while len(lower) >= 2 and cross(lower[-2], lower[-1], point) <= 0: + lower.pop() + lower.append(point) + upper: list[Point] = [] + for point in reversed(unique): + while len(upper) >= 2 and cross(upper[-2], upper[-1], point) <= 0: + upper.pop() + upper.append(point) + return lower[:-1] + upper[:-1] + + +def _inflate_from(center: Point, points: list[Point], padding: float) -> list[Point]: + """Push hull points radially away from ``center`` by ``padding`` pixels.""" + + inflated: list[Point] = [] + for x, y in points: + dx = x - center[0] + dy = y - center[1] + length = math.hypot(dx, dy) or 1.0 + scale = (length + padding) / length + inflated.append((center[0] + dx * scale, center[1] + dy * scale)) + return inflated + + def _grid_center(col: int, row: int) -> Point: """Convert a 15-by-15 Ludo grid cell into a screen coordinate.""" @@ -531,54 +739,21 @@ def _player_outward(player_index: int, total_players: int) -> Point: return math.cos(angle), math.sin(angle) -def _radial_track_cells(center: Point, outward: Point, tangent: Point) -> list[DisplayCell]: - """Return the 13 visible race cells for one 5P/6P arm. - - Seven cells move inward on one side of the arm, then six cells move back - outward on the other side. That creates the same two-sided lane structure - visible in the reference boards instead of a single polygon-edge line. - """ - - left_lane = [ - _radial_cell(center, outward, tangent, RADIAL_RADIUS - step * RADIAL_CELL_STEP, -RADIAL_LANE_OFFSET) - for step in range(7) - ] - right_lane = [ - _radial_cell( - center, - outward, - tangent, - RADIAL_INNER_DISTANCE + step * RADIAL_CELL_STEP, - RADIAL_LANE_OFFSET, - ) - for step in range(6) - ] - return left_lane + right_lane - - -def _radial_home_cells(center: Point, outward: Point, tangent: Point, home_length: int) -> list[DisplayCell]: - """Return the coloured center-lane cells that lead a player home.""" - - return [ - _radial_cell( - center, - outward, - tangent, - RADIAL_RADIUS - (step + 1) * RADIAL_CELL_STEP, - 0.0, - ) - for step in range(home_length) - ] - - -def _radial_cell(center: Point, outward: Point, tangent: Point, distance: float, lane_offset: float) -> DisplayCell: +def _radial_cell( + center: Point, + outward: Point, + tangent: Point, + distance: float, + lane_offset: float, + size: float, +) -> DisplayCell: """Build one rotated square cell from radial distance and lane offset.""" cell_center = ( center[0] + outward[0] * distance + tangent[0] * lane_offset, center[1] + outward[1] * distance + tangent[1] * lane_offset, ) - half = RADIAL_CELL_SIZE / 2 + half = size / 2 corners = ( _offset_point(cell_center, outward, tangent, -half, -half), _offset_point(cell_center, outward, tangent, half, -half), @@ -588,29 +763,6 @@ def _radial_cell(center: Point, outward: Point, tangent: Point, distance: float, return DisplayCell(center=cell_center, corners=corners) -def _radial_arm_backplate(center: Point, outward: Point, tangent: Point) -> tuple[Point, ...]: - """Return the cream board arm behind a 3-column radial lane.""" - - half_width = RADIAL_LANE_OFFSET + RADIAL_CELL_SIZE / 2 + 9 - inner = RADIAL_INNER_DISTANCE - RADIAL_CELL_SIZE / 2 - 8 - outer = RADIAL_RADIUS + RADIAL_CELL_SIZE / 2 + 8 - return ( - _radial_point(center, outward, tangent, outer, -half_width), - _radial_point(center, outward, tangent, outer, half_width), - _radial_point(center, outward, tangent, inner, half_width), - _radial_point(center, outward, tangent, inner, -half_width), - ) - - -def _radial_point(center: Point, outward: Point, tangent: Point, distance: float, offset: float) -> Point: - """Convert radial arm coordinates into screen coordinates.""" - - return ( - center[0] + outward[0] * distance + tangent[0] * offset, - center[1] + outward[1] * distance + tangent[1] * offset, - ) - - def _offset_point(base: Point, outward: Point, tangent: Point, along: float, across: float) -> Point: """Offset a point along the radial and tangent axes.""" @@ -620,53 +772,6 @@ def _offset_point(base: Point, outward: Point, tangent: Point, along: float, acr ) -def _regular_polygon_points(sides: int, center: Point, radius: float) -> list[Point]: - """Return vertices for a regular polygon that starts at the top.""" - - cx, cy = center - return [ - ( - cx + math.cos(-math.pi / 2 + math.tau * index / sides) * radius, - cy + math.sin(-math.pi / 2 + math.tau * index / sides) * radius, - ) - for index in range(sides) - ] - - -def _radial_yard_cluster(center: Point, outward: Point, tangent: Point) -> list[Point]: - """Return four token parking spots inside one triangular radial base.""" - - base = _radial_point(center, outward, tangent, RADIAL_RADIUS + RADIAL_YARD_GAP, 0.0) - offsets = [(-20, -20), (20, -20), (-20, 20), (20, 20)] - return [ - ( - base[0] + tangent[0] * side + outward[0] * depth, - base[1] + tangent[1] * side + outward[1] * depth, - ) - for side, depth in offsets - ] - - -def _lerp(start: Point, end: Point, amount: float) -> Point: - """Linearly interpolate between two screen points.""" - - return ( - start[0] + (end[0] - start[0]) * amount, - start[1] + (end[1] - start[1]) * amount, - ) - - -def _outer_polygon(layout, padding: int) -> list[tuple[int, int]]: - """Return the board outline, slightly larger than the playable track.""" - - points = [] - cx, cy = layout.center - for i in range(layout.polygon_sides): - angle = -math.pi / 2 + math.tau * i / layout.polygon_sides - points.append((int(cx + math.cos(angle) * (layout.radius + padding)), int(cy + math.sin(angle) * (layout.radius + padding)))) - return points - - def _owner_for_start(index: int, start_indices: tuple[int, ...]) -> int | None: """Return which player owns a start square, or ``None``.""" @@ -682,14 +787,6 @@ def _cell_rect(point: tuple[float, float], radius: int) -> pygame.Rect: return pygame.Rect(int(point[0] - radius), int(point[1] - radius), radius * 2, radius * 2) -def _bounds_for_points(points: tuple[tuple[float, float], ...]) -> pygame.Rect: - """Return a rectangle that tightly contains a group of points.""" - - xs = [point[0] for point in points] - ys = [point[1] for point in points] - return pygame.Rect(int(min(xs)), int(min(ys)), int(max(xs) - min(xs)), int(max(ys) - min(ys))) - - def _average_point(points: tuple[tuple[float, float], ...]) -> tuple[float, float]: """Return the center point of a small cluster of coordinates.""" @@ -699,14 +796,6 @@ def _average_point(points: tuple[tuple[float, float], ...]) -> tuple[float, floa ) -def _normal(vector: tuple[float, float]) -> tuple[float, float]: - """Return a length-one version of ``vector``.""" - - x, y = vector - length = math.hypot(x, y) or 1.0 - return x / length, y / length - - def _ipoint(point: tuple[float, float]) -> tuple[int, int]: """Convert floating layout coordinates to integer pixels.""" diff --git a/Python Ludo Game/tests/test_board_geometry.py b/Python Ludo Game/tests/test_board_geometry.py index 9b717b7..7777587 100644 --- a/Python Ludo Game/tests/test_board_geometry.py +++ b/Python Ludo Game/tests/test_board_geometry.py @@ -32,7 +32,7 @@ from settings import SEAT_COLORS # Player counts whose display layout follows the authentic clockwise rules. -SUPPORTED_COUNTS = (4,) +SUPPORTED_COUNTS = (4, 5, 6) # A step between two consecutive track cells is at most one grid cell plus the # occasional corner turn, which measures sqrt(2) cells. 1.6 gives headroom for @@ -135,7 +135,10 @@ def test_home_lanes_march_inward_to_the_center(self) -> None: with self.subTest(total_players=count, seat=seat): distances = [distance(cell, display.center) for cell in lane] self.assertEqual(distances, sorted(distances, reverse=True)) - self.assertLessEqual(distances[-1], 2.2 * display.cell_size) + # The square lane ends one cell from the center; radial + # lanes end on the hub ring, roughly three cells out. Both + # are far inside the track, which starts 8+ cells away. + self.assertLessEqual(distances[-1], 3.4 * display.cell_size) def test_seat_zero_sits_at_the_bottom_and_seats_continue_clockwise(self) -> None: """Player 1 is the bottom seat; the rest follow in clockwise order.""" diff --git a/Python Ludo Game/tests/test_visual_overhaul.py b/Python Ludo Game/tests/test_visual_overhaul.py index 5514c96..44d1d0f 100644 --- a/Python Ludo Game/tests/test_visual_overhaul.py +++ b/Python Ludo Game/tests/test_visual_overhaul.py @@ -5,7 +5,6 @@ import os import sys import unittest -import math from pathlib import Path @@ -41,28 +40,6 @@ def count_near_color(surface: pygame.Surface, color: tuple[int, int, int], toler return matches -def distance_from_line(point: tuple[float, float], start: tuple[float, float], end: tuple[float, float]) -> float: - """Return the shortest distance from ``point`` to an infinite line.""" - - dx = end[0] - start[0] - dy = end[1] - start[1] - length = math.hypot(dx, dy) or 1.0 - return abs(dx * (point[1] - start[1]) - dy * (point[0] - start[0])) / length - - -def signed_distance_from_line( - point: tuple[float, float], - start: tuple[float, float], - end: tuple[float, float], -) -> float: - """Return side-aware distance from ``point`` to an infinite line.""" - - dx = end[0] - start[0] - dy = end[1] - start[1] - length = math.hypot(dx, dy) or 1.0 - return (dx * (point[1] - start[1]) - dy * (point[0] - start[0])) / length - - class VisualOverhaulTests(unittest.TestCase): """Checks that the game now renders as a themed Ludo board, not a plain diagram.""" @@ -116,44 +93,6 @@ def test_boards_for_all_player_counts_show_wallpaper_and_player_colours(self) -> for color in seat_colors(total_players): self.assertGreater(count_near_color(surface, color), 20) - def test_radial_boards_use_arm_grids_instead_of_single_track_lines(self) -> None: - """Five- and six-player tracks should bend through two lanes per arm.""" - - for total_players in (5, 6): - with self.subTest(total_players=total_players): - game = make_game(total_players) - renderer = BoardRenderer(game.layout) - segment_length = len(renderer.display.track_positions) // total_players - - for player_index in range(total_players): - segment = renderer.display.track_positions[ - player_index * segment_length : (player_index + 1) * segment_length - ] - widest_offset = max(distance_from_line(point, segment[0], segment[-1]) for point in segment) - - self.assertGreater(widest_offset, 22.0) - - def test_radial_track_cells_flank_each_coloured_home_lane(self) -> None: - """Each radial arm should have white track cells on both sides of home.""" - - for total_players in (5, 6): - with self.subTest(total_players=total_players): - game = make_game(total_players) - renderer = BoardRenderer(game.layout) - segment_length = len(renderer.display.track_positions) // total_players - - for player_index, home_lane in enumerate(renderer.display.home_lanes): - segment = renderer.display.track_positions[ - player_index * segment_length : (player_index + 1) * segment_length - ] - signed_offsets = [ - signed_distance_from_line(point, home_lane[0], home_lane[-1]) - for point in segment - ] - - self.assertLess(min(signed_offsets), -20.0) - self.assertGreater(max(signed_offsets), 20.0) - def test_play_screen_buttons_live_in_world_hud_not_right_sidebar(self) -> None: """Gameplay controls should sit in the board world instead of the old sidebar.""" diff --git a/Python Ludo Game/visual_theme.py b/Python Ludo Game/visual_theme.py index 87b1d04..566fcb4 100644 --- a/Python Ludo Game/visual_theme.py +++ b/Python Ludo Game/visual_theme.py @@ -173,6 +173,29 @@ def draw_token_pin( surface.blit(text, text.get_rect(center=(cx, cy))) +def draw_die_face( + surface: pygame.Surface, + rect: pygame.Rect, + value: int | None, + fonts: dict[str, pygame.font.Font] | None = None, +) -> None: + """Draw one rounded white die face showing ``value`` pips. + + ``None`` means no roll has happened yet: the face shows a dash when fonts + are available and stays blank otherwise. This is the single die renderer + shared by the HUD tray and the radial board hub. + """ + + pygame.draw.rect(surface, WHITE, rect, border_radius=10) + pygame.draw.rect(surface, BOARD_EDGE, rect, 3, border_radius=10) + if value is None: + if fonts is not None: + text = fonts["header"].render("-", True, BOARD_EDGE) + surface.blit(text, text.get_rect(center=rect.center)) + return + _draw_pips(surface, rect, value) + + def draw_dice_tray( surface: pygame.Surface, rect: pygame.Rect, @@ -186,13 +209,7 @@ def draw_dice_tray( draw_shadowed_rect(surface, rect, HUD_DARK, border=accent, radius=14, shadow_offset=(0, 5)) die_size = min(rect.height - 18, rect.width // 3) die_rect = pygame.Rect(rect.x + 14, rect.centery - die_size // 2, die_size, die_size) - pygame.draw.rect(surface, WHITE, die_rect, border_radius=10) - pygame.draw.rect(surface, BOARD_EDGE, die_rect, 3, border_radius=10) - if value is None: - text = fonts["header"].render("-", True, BOARD_EDGE) - surface.blit(text, text.get_rect(center=die_rect.center)) - else: - _draw_pips(surface, die_rect, value) + draw_die_face(surface, die_rect, value, fonts) def draw_round_icon_button( From 3fde024505db9b7874335d074dc092bca3da8112 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Fri, 17 Jul 2026 15:19:38 +0530 Subject: [PATCH 03/11] Polish Ludo: token hops, real setup previews, icon, dead code - Tokens now hop cell-by-cell along the track at constant speed instead of gliding in a straight line across the board. Captures and yard exits stay as single direct slides, matching how players physically move tokens. Speed lives in settings.TOKEN_HOP_SPEED. - The setup screen's abstract polygon previews are replaced with cached true-to-life thumbnails rendered via the new BoardRenderer.draw_static, with the selected player count highlighted. - The window icon now loads the bundled ludo_icon.ico (with the drawn fallback kept) and the PyInstaller spec bundles the file. - Removed dead code: the pre-overhaul sidebar drawers (_draw_score_panel, _draw_action_panel) and their now-orphaned ui helpers (draw_dice, _pip_offsets, draw_panel). Co-Authored-By: Claude Fable 5 --- Python Ludo Game/Ludo Game.spec | 3 +- Python Ludo Game/board_render.py | 54 +++++-- Python Ludo Game/main.py | 245 +++++++++++++++++++------------ Python Ludo Game/settings.py | 4 +- Python Ludo Game/ui.py | 55 +------ 5 files changed, 201 insertions(+), 160 deletions(-) diff --git a/Python Ludo Game/Ludo Game.spec b/Python Ludo Game/Ludo Game.spec index f3237b3..af346e5 100644 --- a/Python Ludo Game/Ludo Game.spec +++ b/Python Ludo Game/Ludo Game.spec @@ -9,7 +9,8 @@ a = Analysis( ['main.py'], pathex=[], binaries=[], - datas=[], + # Bundle the icon so the running window can load it via _resource_path. + datas=[('ludo_icon.ico', '.')], hiddenimports=[], hookspath=[], hooksconfig={}, diff --git a/Python Ludo Game/board_render.py b/Python Ludo Game/board_render.py index ec9ccea..2c7b62f 100644 --- a/Python Ludo Game/board_render.py +++ b/Python Ludo Game/board_render.py @@ -192,7 +192,30 @@ def _draw_table(self, surface: pygame.Surface) -> None: border_width=4, ) + def draw_static(self, surface: pygame.Surface) -> None: + """Draw the empty board without tokens, banners, or highlights. + + The setup screen uses this to render true-to-life preview thumbnails + of each board shape before a game exists. + """ + + self._draw_table(surface) + if self.layout.total_players == 4: + self._draw_square_yards(surface) + else: + self._draw_radial_yards(surface) + self._draw_track(surface) + self._draw_home_lanes(surface) + self._draw_center_home(surface, None) + def _draw_square_homes(self, surface: pygame.Surface, game, fonts: dict[str, pygame.font.Font]) -> None: + """Draw the four square yards plus each player's name banner.""" + + self._draw_square_yards(surface) + for player_index, yard in enumerate(_square_home_rects()): + self._draw_player_tab(surface, game, fonts, player_index, yard) + + def _draw_square_yards(self, surface: pygame.Surface) -> None: """Draw four large square home yards like a classic Ludo board.""" for player_index, yard in enumerate(_square_home_rects()): @@ -203,9 +226,23 @@ def _draw_square_homes(self, surface: pygame.Surface, game, fonts: dict[str, pyg pygame.draw.rect(surface, WHITE, inner, border_radius=5) pygame.draw.rect(surface, theme.BOARD_EDGE, inner, 2, border_radius=5) self._draw_yard_slots(surface, positions, color) - self._draw_player_tab(surface, game, fonts, player_index, yard) def _draw_radial_homes(self, surface: pygame.Surface, game, fonts: dict[str, pygame.font.Font]) -> None: + """Draw the radial yard wedges plus each player's name banner.""" + + self._draw_radial_yards(surface) + for player_index in range(self.layout.total_players): + banner = pygame.Rect(0, 0, 150, 28) + banner.center = _ipoint(self.display.banner_anchors[player_index]) + theme.draw_player_banner( + surface, + banner, + self.display.seat_colors[player_index], + game.players[player_index].name[:16], + fonts, + ) + + def _draw_radial_yards(self, surface: pygame.Surface) -> None: """Draw the triangular yard wedges between the radial board arms.""" for player_index, positions in enumerate(self.display.yard_positions): @@ -233,11 +270,6 @@ def _draw_radial_homes(self, surface: pygame.Surface, game, fonts: dict[str, pyg pygame.draw.polygon(surface, WHITE, inner) pygame.draw.polygon(surface, theme.BOARD_EDGE, inner, 2) self._draw_yard_slots(surface, positions, color) - banner = pygame.Rect(0, 0, 150, 28) - banner.center = _ipoint(self.display.banner_anchors[player_index]) - theme.draw_player_banner( - surface, banner, color, game.players[player_index].name[:16], fonts - ) def _draw_yard_slots( self, @@ -335,8 +367,12 @@ def _draw_display_cell( pygame.draw.polygon(surface, fill, points) pygame.draw.polygon(surface, border, points, 2) - def _draw_center_home(self, surface: pygame.Surface, game) -> None: - """Draw the multicolor finish area in the board center.""" + def _draw_center_home(self, surface: pygame.Surface, game=None) -> None: + """Draw the multicolor finish area in the board center. + + ``game`` may be ``None`` for static preview renders; the hub die then + shows a blank face instead of the last roll. + """ if self.layout.total_players == 4: self._draw_square_center_home(surface) @@ -359,7 +395,7 @@ def _draw_center_home(self, surface: pygame.Surface, game) -> None: die_size = 44 die_rect = pygame.Rect(0, 0, die_size, die_size) die_rect.center = center - theme.draw_die_face(surface, die_rect, game.last_roll) + theme.draw_die_face(surface, die_rect, game.last_roll if game is not None else None) def _draw_square_center_home(self, surface: pygame.Surface) -> None: """Draw the four colored triangles in the classic square board center.""" diff --git a/Python Ludo Game/main.py b/Python Ludo Game/main.py index 6df102a..993a99e 100644 --- a/Python Ludo Game/main.py +++ b/Python Ludo Game/main.py @@ -17,6 +17,7 @@ import ai import ui import visual_theme as theme +from board import BoardLayout from board_render import BoardRenderer from game import LudoGame, LudoRules from settings import ( @@ -38,7 +39,7 @@ SCREEN_WIDTH, SOFT, STATS_PATH, - TOKEN_ANIM_SPEED, + TOKEN_HOP_SPEED, WHITE, WINDOW_TITLE, seat_colors, @@ -75,6 +76,8 @@ PLAY_LOG_RECT = pygame.Rect(674, 742, 206, 132) PLAY_ROSTER_X = 932 PLAY_ROSTER_Y = 90 +PREVIEW_CENTERS = ((200, 470), (450, 470), (700, 470)) +PREVIEW_SIZE = 216 @dataclass(frozen=True) @@ -299,7 +302,7 @@ def __init__(self) -> None: self.window_size = _fit_window_size(_desktop_work_area_size(), _window_chrome_size()) self.screen_surface = pygame.display.set_mode(self.window_size) pygame.display.set_caption(WINDOW_TITLE) - pygame.display.set_icon(_make_icon()) + pygame.display.set_icon(_load_icon()) self.window = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)).convert() self.clock = pygame.time.Clock() self.fonts = ui.make_fonts() @@ -328,7 +331,12 @@ def __init__(self) -> None: self.stats_recorded = False self.ai_timer = 0 self.dice_anim_remaining = 0 + # Animation state per (player_index, token_index): the drawn pixel, + # the engine steps it reflects, and the queue of cells still to hop. self.token_pixels: dict[tuple[int, int], tuple[float, float]] = {} + self.token_steps: dict[tuple[int, int], int] = {} + self.token_waypoints: dict[tuple[int, int], list[tuple[float, float]]] = {} + self.preview_cache: dict[int, pygame.Surface] = {} self.message = "Choose a table and start the race." self.saved_turns_taken: int | None = None @@ -434,6 +442,8 @@ def start_game(self, game: LudoGame) -> None: self.ai_timer = 0 self.dice_anim_remaining = 0 self.token_pixels = {} + self.token_steps = {} + self.token_waypoints = {} self.saved_turns_taken = game.turns_taken self.message = "Roll a 6 to bring a token out." @@ -648,34 +658,88 @@ def _perform_ai_step(self) -> None: result = self.game.apply_move(move) self.message = result.message + def _token_position(self, player_index: int, steps: int, token_index: int) -> tuple[float, float]: + """Return the drawn coordinate for a token's step value. + + The renderer may display a friendlier board shape than the engine's + raw coordinate grid, so animation targets come from the renderer when + one is active. + """ + + if self.renderer is not None: + return self.renderer.position_for(player_index, steps, token_index) + assert self.game is not None + return self.game.layout.position_for(player_index, steps, token_index) + + def _token_waypoints( + self, player_index: int, token_index: int, old_steps: int, new_steps: int + ) -> list[tuple[float, float]]: + """Return the cells a token visibly travels through for one move. + + Forward moves hop through every intermediate cell like a real Ludo + token counting its die roll. Captures (back to the yard) and yard + exits are single direct slides, matching how players physically pick + the token up and place it. + """ + + if 0 <= old_steps < new_steps: + return [ + self._token_position(player_index, steps, token_index) + for steps in range(old_steps + 1, new_steps + 1) + ] + return [self._token_position(player_index, new_steps, token_index)] + def _update_token_pixels(self, elapsed_ms: int) -> None: - """Ease drawn token positions toward their true board coordinates.""" + """Advance drawn token positions along their pending waypoints.""" if self.game is None: return - amount = min(1.0, elapsed_ms / 1000 * TOKEN_ANIM_SPEED) + budget = TOKEN_HOP_SPEED * elapsed_ms / 1000 for player_index, player in enumerate(self.game.players): for token_index, token in enumerate(player.tokens): key = (player_index, token_index) - # The renderer may display a friendlier board shape than the - # engine's raw coordinate grid, so animation targets come from - # the renderer when one is active. - if self.renderer is not None: - target = self.renderer.position_for(player_index, token.steps, token_index) - else: - target = self.game.layout.position_for(player_index, token.steps, token_index) - current = self.token_pixels.get(key) - if current is None: + target = self._token_position(player_index, token.steps, token_index) + if key not in self.token_pixels: # First frame: snap tokens into place so they do not glide # in from the top-left corner of the window. self.token_pixels[key] = target + self.token_steps[key] = token.steps + self.token_waypoints[key] = [] continue - dx = target[0] - current[0] - dy = target[1] - current[1] - if dx * dx + dy * dy < 1.0: - self.token_pixels[key] = target - else: - self.token_pixels[key] = (current[0] + dx * amount, current[1] + dy * amount) + if token.steps != self.token_steps.get(key): + self.token_waypoints[key] = self._token_waypoints( + player_index, token_index, self.token_steps.get(key, -1), token.steps + ) + self.token_steps[key] = token.steps + + # Walk the waypoint queue at constant speed. One frame may + # consume several waypoints when the frame rate dips. + current = self.token_pixels[key] + queue = self.token_waypoints.setdefault(key, []) + remaining = budget + while queue and remaining > 0: + head = queue[0] + dx = head[0] - current[0] + dy = head[1] - current[1] + length = math.hypot(dx, dy) + if length <= remaining: + current = head + queue.pop(0) + remaining -= length + else: + current = (current[0] + dx / length * remaining, current[1] + dy / length * remaining) + remaining = 0 + if not queue and current != target and remaining > 0: + # No pending hops: keep the token glued to its cell (this + # also covers loading a save or renderer changes). + dx = target[0] - current[0] + dy = target[1] - current[1] + length = math.hypot(dx, dy) + current = target if length <= remaining else ( + current[0] + dx / length * remaining, + current[1] + dy / length * remaining, + ) + self.token_pixels[key] = current def _save_if_turn_completed(self) -> None: """Autosave after completed turns, not after every animation frame.""" @@ -731,7 +795,7 @@ def _draw_setup(self) -> None: pygame.Rect(170, 138, 560, 80), SOFT, ) - _draw_preview_board(self.window) + self._draw_board_previews() panel = pygame.Rect(PANEL_X, 0, PANEL_WIDTH + 30, SCREEN_HEIGHT) theme.draw_shadowed_rect(self.window, panel.inflate(-14, -18), PANEL_BG, border=PANEL_EDGE, radius=16) @@ -761,6 +825,46 @@ def _setup_value(self, label: str, value: str, row_index: int) -> None: ui.draw_text(self.window, self.fonts["body"], label, (SETUP_CONTROL_X, row.label_y), SOFT) ui.draw_text(self.window, self.fonts["header"], value, row.value_center, WHITE, center=True) + def _board_preview(self, total_players: int) -> pygame.Surface: + """Return a cached true-to-life thumbnail of one board shape.""" + + cached = self.preview_cache.get(total_players) + if cached is None: + # Render the real board once at full logical size, then crop the + # board area and shrink it. The preview is therefore always an + # honest picture of the layout the player is about to get. + canvas = pygame.Surface((SCREEN_WIDTH, SCREEN_HEIGHT)) + theme.draw_ludo_wallpaper(canvas) + renderer = BoardRenderer(BoardLayout.for_player_count(total_players)) + renderer.draw_static(canvas) + if total_players == 4: + crop = pygame.Rect(120, 90, 660, 660) + else: + crop = pygame.Rect(95, 55, 710, 710) + board = canvas.subsurface(crop).copy() + cached = pygame.transform.smoothscale(board, (PREVIEW_SIZE, PREVIEW_SIZE)) + self.preview_cache[total_players] = cached + return cached + + def _draw_board_previews(self) -> None: + """Draw the 4P/5P/6P board thumbnails, highlighting the selection.""" + + for count, center in zip((4, 5, 6), PREVIEW_CENTERS): + thumb = self._board_preview(count) + rect = thumb.get_rect(center=center) + self.window.blit(thumb, rect) + selected = count == self.total_players + border = GOLD if selected else PANEL_EDGE + pygame.draw.rect(self.window, border, rect.inflate(10, 10), 4 if selected else 2, border_radius=10) + ui.draw_text( + self.window, + self.fonts["small"], + f"{count} Players", + (rect.centerx, rect.bottom + 18), + WHITE if selected else SOFT, + center=True, + ) + def _draw_playing(self) -> None: """Draw the board, themed HUD, action buttons, and legal highlights.""" @@ -862,60 +966,6 @@ def _draw_play_button(self, button: ui.Button, mouse: tuple[int, int]) -> None: text_color = INK if hovered else WHITE ui.draw_text(self.window, self.fonts["button"], button.text, button.rect.center, text_color, center=True) - def _draw_score_panel(self) -> None: - """Draw one compact status card per player.""" - - if self.game is None: - return - y = 22 - for index, player in enumerate(self.game.players): - rect = pygame.Rect(PANEL_X + 14, y, PANEL_WIDTH + 2, 60) - border = player.color if index == self.game.current else PANEL_EDGE - ui.draw_panel(self.window, rect, border=border) - pygame.draw.circle(self.window, player.color, (rect.x + 26, rect.y + 26), 14) - ui.draw_text(self.window, self.fonts["body"], player.name[:20], (rect.x + 50, rect.y + 12), WHITE) - tag = "Human" if player.is_human else self.game.ai_profile.title() - done = player.finished_count(self.game.layout.finish_steps) - ui.draw_text( - self.window, - self.fonts["tiny"], - f"{tag} Home {done}/4 Captures {player.captures}", - (rect.x + 50, rect.y + 42), - SOFT, - ) - y += 66 - - def _draw_action_panel(self) -> None: - """Draw the current-turn prompt, die, and recent event log.""" - - if self.game is None: - return - action = pygame.Rect(PANEL_X + 14, 610, PANEL_WIDTH + 2, 264) - ui.draw_panel(self.window, action) - ui.draw_text(self.window, self.fonts["header"], "Turn", (action.x + 18, action.y + 16), WHITE) - current = self.game.current_player - ui.draw_text(self.window, self.fonts["body"], current.name, (action.x + 18, action.y + 50), current.color) - die_value = self.game.last_roll - if self.dice_anim_remaining > 0: - die_value = ((pygame.time.get_ticks() // 70) % 6) + 1 - ui.draw_dice(self.window, self.fonts, die_value, pygame.Rect(action.right - 102, action.y + 24, 72, 72)) - - if current.is_human and self.game.awaiting == "choose_move": - prompt = "Pick a highlighted token or use the move buttons." - elif current.is_human: - prompt = "Roll the die." - else: - prompt = "AI is thinking..." - ui.draw_wrapped(self.window, self.fonts["small"], prompt, pygame.Rect(action.x + 18, action.y + 106, action.width - 36, 42), SOFT) - - log_rect = pygame.Rect(action.x + 18, action.y + 150, action.width - 36, 96) - pygame.draw.rect(self.window, (26, 34, 38), log_rect, border_radius=8) - pygame.draw.rect(self.window, PANEL_EDGE, log_rect, 1, border_radius=8) - y = log_rect.y + 10 - for line in self.game.event_log[-6:]: - ui.draw_text(self.window, self.fonts["tiny"], line[:54], (log_rect.x + 10, y), ui.status_color(line)) - y += 24 - def _buttons_for_screen(self) -> list[ui.Button]: """Return the buttons that are valid on the current screen.""" @@ -1017,13 +1067,35 @@ def _toggle_label(label: str, enabled: bool) -> str: return f"{label}: {'On' if enabled else 'Off'}" -def _make_icon() -> pygame.Surface: - """Create a tiny procedural window icon. +def _resource_path(name: str) -> Path: + """Return the path to a bundled read-only resource file, such as the icon. + + PyInstaller unpacks bundled data into ``sys._MEIPASS`` at runtime; running + from source the file simply sits next to this script. + """ + + base = getattr(sys, "_MEIPASS", None) + if base: + return Path(base) / name + return Path(__file__).resolve().parent / name + + +def _load_icon() -> pygame.Surface: + """Load the window icon, falling back to a procedural one. - No image file is required; this keeps the Ludo folder self-contained for - both source runs and PyInstaller builds. + The .ico file gives Windows a crisp multi-resolution icon; the drawn + fallback keeps source checkouts working even if the file goes missing. """ + try: + return pygame.image.load(str(_resource_path("ludo_icon.ico"))) + except (OSError, pygame.error): + return _make_icon() + + +def _make_icon() -> pygame.Surface: + """Create a tiny procedural window icon as a fallback.""" + icon = pygame.Surface((64, 64), pygame.SRCALPHA) pygame.draw.rect(icon, (238, 232, 209), (4, 4, 56, 56), border_radius=12) for index, color in enumerate(seat_colors(4)): @@ -1033,23 +1105,6 @@ def _make_icon() -> pygame.Surface: return icon -def _draw_preview_board(surface: pygame.Surface) -> None: - """Draw the small 4P/5P/6P board-shape preview on setup.""" - - center = (450, 476) - radius = 210 - for sides, x_offset in ((4, -250), (5, 0), (6, 250)): - points = [] - cx = center[0] + x_offset - cy = center[1] - for index in range(sides): - angle = -math.pi / 2 + math.tau * index / sides - points.append((int(cx + math.cos(angle) * radius * 0.42), int(cy + math.sin(angle) * radius * 0.42))) - pygame.draw.polygon(surface, (238, 232, 209), points) - pygame.draw.polygon(surface, seat_colors(sides)[0], points, 4) - ui.draw_text(surface, pygame.font.SysFont("arial", 24, bold=True), f"{sides}P", (cx, cy), WHITE, center=True) - - def main() -> None: """Initialize pygame, run the app, and always shut pygame down.""" diff --git a/Python Ludo Game/settings.py b/Python Ludo Game/settings.py index 6ebc733..105e9d0 100644 --- a/Python Ludo Game/settings.py +++ b/Python Ludo Game/settings.py @@ -31,7 +31,9 @@ # instead of seeing computer turns flash by instantly. AI_TURN_DELAY_MS = 650 DICE_ANIM_MS = 350 -TOKEN_ANIM_SPEED = 9.0 +# Tokens hop cell-by-cell along the track. This is the travel speed in pixels +# per second: fast enough not to drag, slow enough to follow each hop. +TOKEN_HOP_SPEED = 460.0 # --------------------------------------------------------------------------- diff --git a/Python Ludo Game/ui.py b/Python Ludo Game/ui.py index e984484..f9826fe 100644 --- a/Python Ludo Game/ui.py +++ b/Python Ludo Game/ui.py @@ -1,4 +1,4 @@ -"""Pygame UI helpers for buttons, labels, panels, and dice.""" +"""Pygame UI helpers for buttons, text labels, and log colors.""" from __future__ import annotations @@ -87,19 +87,6 @@ def draw_text( surface.blit(image, rect) -def draw_panel( - surface: pygame.Surface, - rect: pygame.Rect, - *, - border: tuple[int, int, int] = PANEL_EDGE, - fill: tuple[int, int, int] = PANEL_CARD, -) -> None: - """Draw a rounded rectangular panel used behind sidebar content.""" - - pygame.draw.rect(surface, fill, rect, border_radius=10) - pygame.draw.rect(surface, border, rect, 2, border_radius=10) - - def draw_wrapped( surface: pygame.Surface, font: pygame.font.Font, @@ -138,46 +125,6 @@ def draw_wrapped( y += font.get_height() + line_gap -def draw_dice(surface: pygame.Surface, fonts: dict[str, pygame.font.Font], value: int | None, rect: pygame.Rect) -> None: - """Draw one die face. - - ``None`` means no roll is visible yet, so the center shows a dash instead - of pips. - """ - - pygame.draw.rect(surface, WHITE, rect, border_radius=12) - pygame.draw.rect(surface, INK, rect, 3, border_radius=12) - if value is None: - draw_text(surface, fonts["header"], "-", rect.center, INK, center=True) - return - - spots = _pip_offsets(value) - radius = max(4, rect.width // 13) - for ox, oy in spots: - pygame.draw.circle( - surface, - INK, - (rect.centerx + int(ox * rect.width * 0.24), rect.centery + int(oy * rect.height * 0.24)), - radius, - ) - - -def _pip_offsets(value: int) -> list[tuple[int, int]]: - """Return normalized pip positions for a die value from 1 to 6.""" - - if value == 1: - return [(0, 0)] - if value == 2: - return [(-1, -1), (1, 1)] - if value == 3: - return [(-1, -1), (0, 0), (1, 1)] - if value == 4: - return [(-1, -1), (1, -1), (-1, 1), (1, 1)] - if value == 5: - return [(-1, -1), (1, -1), (0, 0), (-1, 1), (1, 1)] - return [(-1, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (1, 1)] - - def status_color(text: str) -> tuple[int, int, int]: """Choose an event-log text color from a short message.""" From d1d3ac18a2356ea63fed99c68227678f2316390d Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Fri, 17 Jul 2026 20:01:00 +0530 Subject: [PATCH 04/11] Improve Monopoly: token hops, typing, named AI constants, dispatcher split - Tokens now hop space-by-space after a dice roll (constant speed via settings.TOKEN_HOP_SPEED); jumps that are not a normal forward roll, such as Go To Jail, still slide directly. The existing AI pause while animations play is preserved. - ai.py: the bare valuation multipliers (2.35/1.32/0.28/1.12/0.95/0.08) are now named module constants with rationale comments, and every function is typed against MonopolyGame/Player via TYPE_CHECKING. - ui.Button and the main.py click/typing handlers gained type hints; _atomic_write's path parameter is now typed as Path. - The two long dispatchers were split behaviour-preserving: _on_button into setup/turn/asset/trade handlers with disjoint key families, and _playing_buttons into one small builder per dialog. - requirements.txt pins pygame-ce>=2.5.2 to match the other games. Co-Authored-By: Claude Fable 5 --- Python Monopoly Game/ai.py | 85 ++++--- Python Monopoly Game/main.py | 331 +++++++++++++++++--------- Python Monopoly Game/requirements.txt | 3 +- Python Monopoly Game/settings.py | 5 + Python Monopoly Game/ui.py | 13 +- 5 files changed, 284 insertions(+), 153 deletions(-) diff --git a/Python Monopoly Game/ai.py b/Python Monopoly Game/ai.py index 8d8c0b1..c12b4fc 100644 --- a/Python Monopoly Game/ai.py +++ b/Python Monopoly Game/ai.py @@ -14,16 +14,37 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING import board_data from settings import JAIL_FINE +if TYPE_CHECKING: + from game import MonopolyGame + from player import Player + # Colour groups roughly ordered by how rewarding they are to develop -- the # orange and red streets are landed on most often, so the AI builds them first. GROUP_BUILD_PRIORITY = [ "orange", "red", "yellow", "light_blue", "pink", "green", "dark_blue", "brown", ] +# Valuation multipliers used by `property_value`. They are rules of thumb, not +# derived numbers, tuned by watching AI-vs-AI simulation batches: +# - Completing a monopoly unlocks building, which is where Monopoly money is +# made, so such a title is worth well over double its list price. +# - A partial group is a step toward that, worth a smaller premium. +# - Each railroad already owned raises the value of the next one because the +# collection's rent doubles with every member. +# - A second utility mildly beats a first; a lone utility is slightly below +# list price because its rent rarely pays back the investment. +STREET_TOP_RENT_WEIGHT = 0.08 +GROUP_COMPLETION_MULT = 2.35 +PARTIAL_GROUP_MULT = 1.32 +RAILROAD_OWNED_STEP = 0.28 +UTILITY_PAIR_MULT = 1.12 +UTILITY_SINGLE_MULT = 0.95 + @dataclass(frozen=True) class AIProfile: @@ -69,18 +90,19 @@ class AIProfile: } -def _profile(game) -> AIProfile: +def _profile(game: MonopolyGame) -> AIProfile: """Return the named game-wide AI profile, falling back to standard.""" return AI_PROFILES.get(getattr(game, "ai_profile", "standard"), AI_PROFILES["standard"]) -def _trace(game, player, message: str) -> None: +def _trace(game: MonopolyGame, player: Player, message: str) -> None: """Leave one compact explainable AI decision in the shared event log.""" game._note(f"{player.name}: {message}") -def property_value(game, player, position, profile: AIProfile | None = None) -> int: +def property_value(game: MonopolyGame, player: Player, position: int, + profile: AIProfile | None = None) -> int: """Estimate a title's strategic value to `player`. Valuation stays deliberately readable: title price starts the estimate, @@ -93,18 +115,18 @@ def property_value(game, player, position, profile: AIProfile | None = None) -> value = float(space.price) if space.kind == "street": - value += space.rent[-1] * 0.08 + value += space.rent[-1] * STREET_TOP_RENT_WEIGHT if _completes_group(game, player, position): - value *= 2.35 + value *= GROUP_COMPLETION_MULT elif _owns_some_of_group(game, player, position): - value *= 1.32 + value *= PARTIAL_GROUP_MULT elif any(_completes_group(game, opponent, position) for opponent in game.other_players(player)): value *= 1.0 + profile.blocker_bonus elif space.kind == "railroad": - value *= 1.0 + 0.28 * game.count_railroads(player) + value *= 1.0 + RAILROAD_OWNED_STEP * game.count_railroads(player) elif space.kind == "utility": - value *= 1.12 if game.count_utilities(player) else 0.95 + value *= UTILITY_PAIR_MULT if game.count_utilities(player) else UTILITY_SINGLE_MULT return max(space.mortgage, int(round(value * profile.value_scale))) @@ -112,7 +134,7 @@ def property_value(game, player, position, profile: AIProfile | None = None) -> # -------------------------------------------------------------------------- # Entry point # -------------------------------------------------------------------------- -def take_action(game) -> None: +def take_action(game: MonopolyGame) -> None: """Make the acting AI player perform one engine action.""" if game.phase == "game_over": return @@ -137,7 +159,7 @@ def take_action(game) -> None: # -------------------------------------------------------------------------- # Pre-roll and post-roll: building, jail, ending the turn # -------------------------------------------------------------------------- -def _do_pre_roll(game, player) -> None: +def _do_pre_roll(game: MonopolyGame, player: Player) -> None: """Before rolling: handle jail, build a house if worthwhile, then roll.""" if player.in_jail: _handle_jail(game, player) @@ -150,7 +172,7 @@ def _do_pre_roll(game, player) -> None: game.roll_dice() -def _do_post_roll(game, player) -> None: +def _do_post_roll(game: MonopolyGame, player: Player) -> None: """After moving: build, lift a mortgage if flush, otherwise end the turn.""" target = _best_build(game, player) if target is not None: @@ -171,7 +193,7 @@ def _do_post_roll(game, player) -> None: game.end_turn() -def _handle_jail(game, player) -> None: +def _handle_jail(game: MonopolyGame, player: Player) -> None: """Choose how to leave jail: a free card, the fine, or rolling for doubles.""" if player.jail_cards > 0: game.use_jail_card() @@ -185,7 +207,7 @@ def _handle_jail(game, player) -> None: game.roll_dice() -def _best_build(game, player): +def _best_build(game: MonopolyGame, player: Player) -> int | None: """Return the best space to build one house/hotel on now, or None. The AI only builds when it owns a complete colour group, can do so legally @@ -206,7 +228,7 @@ def _best_build(game, player): return None -def _best_unmortgage(game, player): +def _best_unmortgage(game: MonopolyGame, player: Player) -> int | None: """Return a mortgaged property worth lifting now, or None. Only done when comfortably rich; properties inside a monopoly come first @@ -228,7 +250,7 @@ def _best_unmortgage(game, player): # -------------------------------------------------------------------------- # Buying a property you landed on # -------------------------------------------------------------------------- -def _do_buy_decision(game, player) -> None: +def _do_buy_decision(game: MonopolyGame, player: Player) -> None: """Decide whether to buy the space just landed on, or send it to auction.""" position = game.pending_purchase if _wants_to_buy(game, player, position): @@ -239,7 +261,7 @@ def _do_buy_decision(game, player) -> None: game.decline_property() -def _wants_to_buy(game, player, position) -> bool: +def _wants_to_buy(game: MonopolyGame, player: Player, position: int) -> bool: """True if the AI should buy `position` at its list price.""" space = game.board[position] profile = _profile(game) @@ -264,7 +286,7 @@ def _wants_to_buy(game, player, position) -> bool: # -------------------------------------------------------------------------- # Auctions # -------------------------------------------------------------------------- -def _do_auction(game, player) -> None: +def _do_auction(game: MonopolyGame, player: Player) -> None: """Place one bid or pass in the current auction.""" auction = game.auction position = auction["position"] @@ -280,7 +302,7 @@ def _do_auction(game, player) -> None: game.auction_pass() -def _auction_ceiling(game, player, position) -> int: +def _auction_ceiling(game: MonopolyGame, player: Player, position: int) -> int: """The most this AI is willing to pay for `position` at auction.""" profile = _profile(game) value = property_value(game, player, position, profile) @@ -291,7 +313,7 @@ def _auction_ceiling(game, player, position) -> int: # -------------------------------------------------------------------------- # Trading # -------------------------------------------------------------------------- -def propose_trade(game, player): +def propose_trade(game: MonopolyGame, player: Player) -> dict | None: """Look for a trade that completes one of this AI's colour groups. Returns an offer dict, or None. The AI prefers a swap that *also* completes @@ -313,7 +335,7 @@ def propose_trade(game, player): return None -def _missing_one(game, player, group): +def _missing_one(game: MonopolyGame, player: Player, group: str) -> int | None: """If `player` owns every space of `group` except one, return that space.""" members = board_data.COLOR_GROUPS[group] owned = [p for p in members if game.owners.get(p) == player.index] @@ -324,7 +346,7 @@ def _missing_one(game, player, group): return None -def _make_offer(game, player, partner, wanted): +def _make_offer(game: MonopolyGame, player: Player, partner: Player, wanted: int) -> dict | None: """Build an offer to win `wanted` that both `player` and `partner` accept.""" give = {"props": [], "cash": 0, "jail": 0} get = {"props": [wanted], "cash": 0, "jail": 0} @@ -354,7 +376,8 @@ def _make_offer(game, player, partner, wanted): return None -def _property_completing_for(game, giver, partner, avoid_group=None): +def _property_completing_for(game: MonopolyGame, giver: Player, partner: Player, + avoid_group: str | None = None) -> int | None: """Return a house-free property `giver` owns that completes a monopoly for `partner`, or None. Such a property is safe to give away -- the giver holds only that single space of the group. @@ -376,7 +399,7 @@ def _property_completing_for(game, giver, partner, avoid_group=None): return None -def evaluate_offer(game, player, offer) -> bool: +def evaluate_offer(game: MonopolyGame, player: Player, offer: dict | None) -> bool: """Return True if `player` should accept the trade `offer`.""" if offer is None or not game.trade_is_legal(offer): return False @@ -385,7 +408,7 @@ def evaluate_offer(game, player, offer) -> bool: return _trade_swing(game, player, offer) >= _profile(game).trade_accept_margin -def _trade_swing(game, evaluator, offer) -> int: +def _trade_swing(game: MonopolyGame, evaluator: Player, offer: dict) -> int: """Estimate how much the trade is worth to `evaluator` (may be negative). Raw asset values are compared, then big adjustments are applied: gaining a @@ -413,7 +436,7 @@ def _trade_swing(game, evaluator, offer) -> int: return swing -def _bundle_value(game, bundle) -> int: +def _bundle_value(game: MonopolyGame, bundle: dict) -> int: """The plain asset value of one side of a trade (cash + property + cards).""" total = bundle["cash"] + bundle["jail"] * 50 for pos in bundle["props"]: @@ -429,13 +452,13 @@ def _bundle_value(game, bundle) -> int: # -------------------------------------------------------------------------- # Small shared helpers # -------------------------------------------------------------------------- -def _group_of(game, position): +def _group_of(game: MonopolyGame, position: int) -> str | None: """The colour group of a street, or None for non-streets.""" space = game.board[position] return space.group if space.kind == "street" else None -def _owns_some_of_group(game, player, position) -> bool: +def _owns_some_of_group(game: MonopolyGame, player: Player, position: int) -> bool: """True if `player` already owns at least one space in this street's group.""" group = _group_of(game, position) if not group: @@ -444,13 +467,13 @@ def _owns_some_of_group(game, player, position) -> bool: for pos in board_data.COLOR_GROUPS[group]) -def _in_monopoly(game, player, position) -> bool: +def _in_monopoly(game: MonopolyGame, player: Player, position: int) -> bool: """True if `position` is part of a colour group `player` fully owns.""" group = _group_of(game, position) return bool(group) and game.has_monopoly(player, group) -def _completes_group(game, player, position) -> bool: +def _completes_group(game: MonopolyGame, player: Player, position: int) -> bool: """True if buying `position` would complete a colour group for `player`.""" group = _group_of(game, position) if not group: @@ -460,8 +483,8 @@ def _completes_group(game, player, position) -> bool: return owned == len(members) - 1 -def _completes_group_with(game, player, position, extra_positions, - removed_positions=()) -> bool: +def _completes_group_with(game: MonopolyGame, player: Player, position: int, + extra_positions, removed_positions=()) -> bool: """Like `_completes_group`, but for a trade in flight. `extra_positions` are titles coming IN to `player` (not owned yet, but diff --git a/Python Monopoly Game/main.py b/Python Monopoly Game/main.py index a2e7f28..f2007ed 100644 --- a/Python Monopoly Game/main.py +++ b/Python Monopoly Game/main.py @@ -16,6 +16,8 @@ import random import sys import time +from pathlib import Path +from typing import Callable import pygame @@ -27,7 +29,7 @@ from board_data import THEME_ORDER from settings import ( AI_TURN_DELAY_MS, BG, FPS, SAVE_DIR, SAVEGAME_PATH, SCREEN_HEIGHT, - SCREEN_WIDTH, STATS_PATH, WINDOW_TITLE, + SCREEN_WIDTH, STATS_PATH, TOKEN_HOP_SPEED, WINDOW_TITLE, ) # Action-bar button slots: two columns by four rows inside the bottom panel. @@ -67,7 +69,7 @@ def _log_error(error: Exception) -> None: pass -def _atomic_write(path, payload: dict) -> None: +def _atomic_write(path: Path, payload: dict) -> None: """Write JSON via a temp file + os.replace, so a crash cannot corrupt it.""" try: SAVE_DIR.mkdir(parents=True, exist_ok=True) @@ -121,7 +123,7 @@ def _clear_saved_game() -> None: class MonopolyApp: """Owns the window and run loop and routes input to the game engine.""" - def __init__(self): + def __init__(self) -> None: # `SCALED` lets pygame stretch our logical 1280x880 surface to fit the # physical screen when we toggle into fullscreen (with letterboxing # for aspect-ratio mismatch). It also makes `event.pos` and @@ -161,10 +163,12 @@ def __init__(self): self.buttons: list = [] self._autosave_current: int | None = None # last-seen turn marker - # Visual polish: tokens slide between spaces and the dice shuffle on - # a roll instead of snapping to their final value. The AI pauses - # while either animation is running so a person can follow play. + # Visual polish: tokens hop space-by-space after a roll and the dice + # shuffle before settling on their real value. The AI pauses while + # either animation is running so a person can follow play. self.token_px: dict = {} # player index -> (x, y) float pixels + self.token_pos: dict = {} # player index -> last-seen board space + self.token_route: dict = {} # player index -> pending waypoint pixels self.dice_show = (0, 0) # what the UI is currently drawing self.dice_anim_remaining_ms = 0 # >0 while the dice shuffle self.last_game_dice = (0, 0) # used to spot a fresh roll @@ -184,6 +188,8 @@ def start_game(self, game: MonopolyGame) -> None: self.message = "" # Reset the visuals so the next frame places tokens / dice from scratch. self.token_px = {} + self.token_pos = {} + self.token_route = {} self.dice_show = (0, 0) self.dice_anim_remaining_ms = 0 self.last_game_dice = (0, 0) @@ -253,21 +259,54 @@ def _update(self, autotest: bool) -> None: elapsed = self.clock.get_time() - # Token sliding: each token's drawn pixel position eases toward the - # centre of its actual board space. On the first frame after the game - # starts the table is empty, so seed each token at its target. + # Token hopping: after a roll a token visits every space it passes, + # like a physical token counting out the dice. Jumps that are not a + # normal forward roll (jail, some cards) slide directly instead. On + # the first frame after the game starts, seed each token at its space. if not self.token_px: for player in self.game.players: self.token_px[player.index] = board_render.token_center( player.position, player.index) + self.token_pos[player.index] = player.position + self.token_route[player.index] = [] animating_tokens = False + budget = TOKEN_HOP_SPEED * elapsed / 1000.0 for player in self.game.players: - target = board_render.token_center(player.position, player.index) - cx, cy = self.token_px[player.index] - nx = cx + (target[0] - cx) * 0.22 - ny = cy + (target[1] - cy) * 0.22 - self.token_px[player.index] = (nx, ny) - if abs(nx - target[0]) > 1.5 or abs(ny - target[1]) > 1.5: + idx = player.index + if player.position != self.token_pos.get(idx): + old = self.token_pos.get(idx, player.position) + forward = (player.position - old) % 40 + if 1 <= forward <= 12: + # A dice roll moves at most 12 spaces, so walk them all. + self.token_route[idx] = [ + board_render.token_center((old + step) % 40, idx) + for step in range(1, forward + 1) + ] + else: + self.token_route[idx] = [ + board_render.token_center(player.position, idx)] + self.token_pos[idx] = player.position + target = board_render.token_center(player.position, idx) + current = self.token_px[idx] + route = self.token_route.setdefault(idx, []) + remaining = budget + # Walk the waypoint queue at constant speed; one frame may finish + # several short hops when the frame rate dips. + while route and remaining > 0: + head = route[0] + dx, dy = head[0] - current[0], head[1] - current[1] + length = (dx * dx + dy * dy) ** 0.5 + if length <= remaining: + current = head + route.pop(0) + remaining -= length + else: + current = (current[0] + dx / length * remaining, + current[1] + dy / length * remaining) + remaining = 0 + self.token_px[idx] = current + if route or abs(current[0] - target[0]) > 1.5 \ + or abs(current[1] - target[1]) > 1.5: animating_tokens = True # Dice shuffle: when the engine reports a new roll, randomise the @@ -295,7 +334,7 @@ def _update(self, autotest: bool) -> None: # ------------------------------------------------------------------ # Input # ------------------------------------------------------------------ - def _handle_click(self, pos) -> None: + def _handle_click(self, pos: tuple[int, int]) -> None: """Route a left-click to the right handler for the current screen.""" if self.screen == "setup": self._click_setup(pos) @@ -315,7 +354,7 @@ def _handle_click(self, pos) -> None: else: self._click_button(pos) - def _click_button(self, pos) -> bool: + def _click_button(self, pos: tuple[int, int]) -> bool: """Check the current buttons; act on the first one hit.""" for button in self.buttons: if button.hit(pos): @@ -323,7 +362,7 @@ def _click_button(self, pos) -> bool: return True return False - def _click_setup(self, pos) -> None: + def _click_setup(self, pos: tuple[int, int]) -> None: """Route a setup-screen click: modal buttons, buttons, or a name box.""" if self.confirm_new_game: self._click_button(pos) # only the modal Yes/Cancel react @@ -337,7 +376,7 @@ def _click_setup(self, pos) -> None: self.active_field = i return - def _type_into_name_field(self, event) -> None: + def _type_into_name_field(self, event: pygame.event.Event) -> None: """Apply one keystroke to the focused setup-screen name box.""" i = self.active_field if i is None: @@ -351,7 +390,7 @@ def _type_into_name_field(self, event) -> None: and len(self.name_fields[i]) < 14: self.name_fields[i] += event.unicode - def _click_board(self, pos) -> None: + def _click_board(self, pos: tuple[int, int]) -> None: """In build/sell/mortgage mode, act on the board space clicked.""" for position in range(40): if board_render.space_rect(position).collidepoint(pos): @@ -366,7 +405,7 @@ def _click_board(self, pos) -> None: self.game.mortgage(position) return - def _click_trade(self, pos) -> None: + def _click_trade(self, pos: tuple[int, int]) -> None: """Handle clicks inside the trade-building dialog.""" if self._click_button(pos): return @@ -382,8 +421,28 @@ def _click_trade(self, pos) -> None: return def _on_button(self, key: str) -> None: - """Carry out the action bound to a button `key`.""" - game = self.game + """Carry out the action bound to a button `key`. + + The button keys form disjoint families (setup screen, turn actions, + the asset manager, and the trade dialog), so each family lives in its + own handler and the first one that recognises the key wins. + """ + if self._on_setup_button(key): + return + if self._on_turn_button(key): + return + if self._on_asset_button(key): + return + if self._on_trade_button(key): + return + if key == "new_game": + self.screen = "setup" + self.game = None + elif key == "quit": + self.running = False + + def _on_setup_button(self, key: str) -> bool: + """Handle setup-screen controls; True if `key` belonged here.""" if key == "humans_down": self.human_count = max(1, self.human_count - 1) elif key == "humans_up": @@ -411,7 +470,14 @@ def _on_button(self, key: str) -> None: saved = load_saved_game() if saved is not None: self.start_game(saved) - elif key == "roll": + else: + return False + return True + + def _on_turn_button(self, key: str) -> bool: + """Handle in-turn actions and auctions; True if `key` belonged here.""" + game = self.game + if key == "roll": game.roll_dice() elif key == "jail_pay": game.pay_jail_fine() @@ -440,9 +506,14 @@ def _on_button(self, key: str) -> None: game.auction_bid(game.auction["high_bid"] + step) elif key == "pass": game.auction_pass() - elif key == "trade": - self._open_trade() - elif key == "assets": + else: + return False + return True + + def _on_asset_button(self, key: str) -> bool: + """Handle the title/asset manager; True if `key` belonged here.""" + game = self.game + if key == "assets": self._open_assets() elif key.startswith("asset_title_"): self.asset_position = int(key.rsplit("_", 1)[1]) @@ -458,6 +529,15 @@ def _on_button(self, key: str) -> None: elif key == "asset_close": self.mode = "normal" self.message = "" + else: + return False + return True + + def _on_trade_button(self, key: str) -> bool: + """Handle the trade dialog; True if `key` belonged here.""" + game = self.game + if key == "trade": + self._open_trade() elif key == "trade_partner_prev": self._cycle_trade_partner(-1) elif key == "trade_partner_next": @@ -481,11 +561,9 @@ def _on_button(self, key: str) -> None: game.respond_trade(True) elif key == "trade_reject": game.respond_trade(False) - elif key == "new_game": - self.screen = "setup" - self.game = None - elif key == "quit": - self.running = False + else: + return False + return True def _begin_new_game(self) -> None: """Create a fresh game from the setup-screen choices.""" @@ -533,7 +611,7 @@ def _open_assets(self) -> None: self.asset_position = owned[0] self.message = "" - def _manage_asset(self, action: str, operation) -> None: + def _manage_asset(self, action: str, operation: Callable[[int], bool]) -> None: """Run one engine asset action or show the engine's blocker reason.""" if self.asset_position is None: self.message = "Choose a title first." @@ -569,7 +647,7 @@ def _adjust_trade_cash(self, side: str, delta: int) -> None: cap = self.game.players[idx].cash self.trade[side]["cash"] = max(0, min(cap, self.trade[side]["cash"] + delta)) - def _scroll_trade(self, mouse_pos, wheel_dy: int) -> None: + def _scroll_trade(self, mouse_pos: tuple[int, int], wheel_dy: int) -> None: """Scroll one side of the trade dialog under the mouse cursor. Wheeling up (`wheel_dy > 0`) moves the visible window UP the list @@ -611,7 +689,7 @@ def _handle_shortcut(self, key: int) -> None: if action in available: self._on_button(action) - def _hovered_board_space(self, pos) -> int | None: + def _hovered_board_space(self, pos: tuple[int, int]) -> int | None: """Return the board space under `pos`, if the mouse is on the board.""" for position in range(40): if board_render.space_rect(position).collidepoint(pos): @@ -641,7 +719,7 @@ def _draw(self) -> None: elif self.screen == "playing": self._draw_playing(mouse) - def _draw_playing(self, mouse) -> None: + def _draw_playing(self, mouse: tuple[int, int]) -> None: """Draw the board, side panel, action bar and any open dialog.""" self.window.fill(BG) @@ -741,99 +819,116 @@ def _build_buttons(self) -> list: return self._playing_buttons() def _playing_buttons(self) -> list: - """Build the in-game buttons for the current player and state.""" + """Build the in-game buttons for the current player and state. + + Each dialog owns a small builder; this dispatcher just picks the one + matching the current mode or engine state. + """ game = self.game actor = game.players[game.actor()] if not actor.is_human: return [] - - # Trade dialog buttons. if self.mode == "trade" and self.trade is not None: - panel = ui.trade_layout(game, self.trade)["panel"] - return [ - # Arrows are placed symmetrically and well clear of the - # centred "With: NAME" label, however wide the name is. - ui.Button((panel.centerx - 200, panel.y + 54, 30, 26), "<", - "trade_partner_prev"), - ui.Button((panel.centerx + 170, panel.y + 54, 30, 26), ">", - "trade_partner_next"), - # Cash buttons sit directly under each column so the "Give" - # pair aligns with the give-side row list (x = panel.x + 30) - # and the "Get" pair aligns with the get-side rows - # (x = panel.x + 420 in the widened panel). - ui.Button((panel.x + 30, panel.bottom - 104, 130, 30), "Give -$50", - "give_cash_down"), - ui.Button((panel.x + 170, panel.bottom - 104, 130, 30), "Give +$50", - "give_cash_up"), - ui.Button((panel.x + 420, panel.bottom - 104, 130, 30), "Get -$50", - "get_cash_down"), - ui.Button((panel.x + 560, panel.bottom - 104, 130, 30), "Get +$50", - "get_cash_up"), - ui.Button((panel.centerx - 210, panel.bottom - 56, 190, 40), - "Propose", "trade_propose", color=(46, 116, 78)), - ui.Button((panel.centerx + 20, panel.bottom - 56, 190, 40), - "Cancel", "trade_cancel"), - ] - - # Asset manager buttons: every title row selects a deed; the action - # buttons below use engine-provided availability and blocker reasons. + return self._trade_dialog_buttons() if self.mode == "assets": - layout = ui.asset_layout(game, actor) - buttons = [] - for row, position in layout["title_rows"]: - color = (70, 96, 64) if position == self.asset_position else None - buttons.append(ui.Button( - row, game.board[position].name[:24], f"asset_title_{position}", - color=color)) - panel = layout["panel"] - actions = (game.asset_actions_for(actor, self.asset_position) - if self.asset_position is not None else {}) - action_specs = ( - ("Build", "asset_build", "build", (panel.x + 492, panel.bottom - 154)), - ("Sell", "asset_sell", "sell", (panel.x + 654, panel.bottom - 154)), - ("Mortgage", "asset_mortgage", "mortgage", - (panel.x + 492, panel.bottom - 104)), - ("Lift Mortgage", "asset_unmortgage", "unmortgage", - (panel.x + 654, panel.bottom - 104)), - ) - for label, key, action, (x, y) in action_specs: - status = actions.get(action, {"allowed": False}) - buttons.append(ui.Button((x, y, 148, 38), label, key, - enabled=bool(status["allowed"]))) - buttons.append(ui.Button((panel.right - 116, panel.y + 18, 84, 32), - "Close", "asset_close")) - return buttons - - # Trade-response dialog buttons. + return self._asset_dialog_buttons(actor) if game.awaiting == "trade_response": - panel = pygame.Rect(0, 0, 560, 360) - panel.center = (SCREEN_HEIGHT // 2, SCREEN_HEIGHT // 2) - return [ - ui.Button((panel.centerx - 210, panel.bottom - 56, 190, 40), - "Accept", "trade_accept", color=(46, 116, 78)), - ui.Button((panel.centerx + 20, panel.bottom - 56, 190, 40), - "Reject", "trade_reject", color=(150, 60, 60)), - ] - - # Auction dialog buttons. + return self._trade_response_buttons() if game.awaiting == "auction": - panel = pygame.Rect(0, 0, 460, 280) - panel.center = (SCREEN_HEIGHT // 2, SCREEN_HEIGHT // 2) - step = max(10, game.board[game.auction["position"]].price // 20) - can_bid = (game.auction["high_bid"] + step) <= actor.cash - return [ - ui.Button((panel.centerx - 200, panel.bottom - 52, 190, 40), - f"Bid +${step}", "bid", enabled=can_bid, - color=(46, 116, 78)), - ui.Button((panel.centerx + 10, panel.bottom - 52, 190, 40), - "Pass", "pass", color=(150, 60, 60)), - ] - - # Build / sell / mortgage mode: just a Done button. + return self._auction_buttons(actor) if self.mode in ("build", "sell", "mortgage"): + # Board-click modes need only a way back out. return [ui.Button(_bar_rect(0), "Done", "done", color=(46, 116, 78))] + return self._action_bar_buttons(actor) + + def _trade_dialog_buttons(self) -> list: + """Buttons for the trade-building dialog.""" + game = self.game + panel = ui.trade_layout(game, self.trade)["panel"] + return [ + # Arrows are placed symmetrically and well clear of the + # centred "With: NAME" label, however wide the name is. + ui.Button((panel.centerx - 200, panel.y + 54, 30, 26), "<", + "trade_partner_prev"), + ui.Button((panel.centerx + 170, panel.y + 54, 30, 26), ">", + "trade_partner_next"), + # Cash buttons sit directly under each column so the "Give" + # pair aligns with the give-side row list (x = panel.x + 30) + # and the "Get" pair aligns with the get-side rows + # (x = panel.x + 420 in the widened panel). + ui.Button((panel.x + 30, panel.bottom - 104, 130, 30), "Give -$50", + "give_cash_down"), + ui.Button((panel.x + 170, panel.bottom - 104, 130, 30), "Give +$50", + "give_cash_up"), + ui.Button((panel.x + 420, panel.bottom - 104, 130, 30), "Get -$50", + "get_cash_down"), + ui.Button((panel.x + 560, panel.bottom - 104, 130, 30), "Get +$50", + "get_cash_up"), + ui.Button((panel.centerx - 210, panel.bottom - 56, 190, 40), + "Propose", "trade_propose", color=(46, 116, 78)), + ui.Button((panel.centerx + 20, panel.bottom - 56, 190, 40), + "Cancel", "trade_cancel"), + ] + + def _asset_dialog_buttons(self, actor) -> list: + """Asset-manager buttons: every title row selects a deed; the action + buttons use engine-provided availability and blocker reasons.""" + game = self.game + layout = ui.asset_layout(game, actor) + buttons = [] + for row, position in layout["title_rows"]: + color = (70, 96, 64) if position == self.asset_position else None + buttons.append(ui.Button( + row, game.board[position].name[:24], f"asset_title_{position}", + color=color)) + panel = layout["panel"] + actions = (game.asset_actions_for(actor, self.asset_position) + if self.asset_position is not None else {}) + action_specs = ( + ("Build", "asset_build", "build", (panel.x + 492, panel.bottom - 154)), + ("Sell", "asset_sell", "sell", (panel.x + 654, panel.bottom - 154)), + ("Mortgage", "asset_mortgage", "mortgage", + (panel.x + 492, panel.bottom - 104)), + ("Lift Mortgage", "asset_unmortgage", "unmortgage", + (panel.x + 654, panel.bottom - 104)), + ) + for label, key, action, (x, y) in action_specs: + status = actions.get(action, {"allowed": False}) + buttons.append(ui.Button((x, y, 148, 38), label, key, + enabled=bool(status["allowed"]))) + buttons.append(ui.Button((panel.right - 116, panel.y + 18, 84, 32), + "Close", "asset_close")) + return buttons - # Normal action bar. + def _trade_response_buttons(self) -> list: + """Accept/Reject buttons for an incoming trade offer.""" + panel = pygame.Rect(0, 0, 560, 360) + panel.center = (SCREEN_HEIGHT // 2, SCREEN_HEIGHT // 2) + return [ + ui.Button((panel.centerx - 210, panel.bottom - 56, 190, 40), + "Accept", "trade_accept", color=(46, 116, 78)), + ui.Button((panel.centerx + 20, panel.bottom - 56, 190, 40), + "Reject", "trade_reject", color=(150, 60, 60)), + ] + + def _auction_buttons(self, actor) -> list: + """Bid/Pass buttons for the auction dialog.""" + game = self.game + panel = pygame.Rect(0, 0, 460, 280) + panel.center = (SCREEN_HEIGHT // 2, SCREEN_HEIGHT // 2) + step = max(10, game.board[game.auction["position"]].price // 20) + can_bid = (game.auction["high_bid"] + step) <= actor.cash + return [ + ui.Button((panel.centerx - 200, panel.bottom - 52, 190, 40), + f"Bid +${step}", "bid", enabled=can_bid, + color=(46, 116, 78)), + ui.Button((panel.centerx + 10, panel.bottom - 52, 190, 40), + "Pass", "pass", color=(150, 60, 60)), + ] + + def _action_bar_buttons(self, actor) -> list: + """The normal action bar for pre-roll, buy-or-auction, and post-roll.""" + game = self.game buttons = [] state = game.awaiting can_sell = any(game.can_sell_building(actor, pos) diff --git a/Python Monopoly Game/requirements.txt b/Python Monopoly Game/requirements.txt index dcd1f4a..2f1f025 100644 --- a/Python Monopoly Game/requirements.txt +++ b/Python Monopoly Game/requirements.txt @@ -1,3 +1,4 @@ # Monopoly uses pygame-ce (the community-maintained fork). The code is # compatible with the regular pygame import path, so either package works. -pygame-ce +# The floor matches the other games in this repo. +pygame-ce>=2.5.2 diff --git a/Python Monopoly Game/settings.py b/Python Monopoly Game/settings.py index 5a31948..ae0d8b1 100644 --- a/Python Monopoly Game/settings.py +++ b/Python Monopoly Game/settings.py @@ -65,6 +65,11 @@ # -------------------------------------------------------------------------- AI_TURN_DELAY_MS = 750 # pause between AI actions so a human can watch +# Tokens hop space-by-space around the board after a dice roll. This is the +# travel speed in pixels per second; teleports (jail, some cards) slide +# directly instead of hopping. +TOKEN_HOP_SPEED = 540.0 + # -------------------------------------------------------------------------- # Colours -- plain (red, green, blue) tuples, each channel 0-255 diff --git a/Python Monopoly Game/ui.py b/Python Monopoly Game/ui.py index 2586479..69e137a 100644 --- a/Python Monopoly Game/ui.py +++ b/Python Monopoly Game/ui.py @@ -38,14 +38,21 @@ def make_fonts() -> dict: class Button: """A clickable rectangle with a label and a string `key` identifying it.""" - def __init__(self, rect, label, key, enabled=True, color=None): + def __init__( + self, + rect: pygame.Rect | tuple[int, int, int, int], + label: str, + key: str, + enabled: bool = True, + color: tuple[int, int, int] | None = None, + ) -> None: self.rect = pygame.Rect(rect) self.label = label self.key = key # e.g. "roll", "buy", "end_turn" self.enabled = enabled self.color = color or (54, 86, 72) - def draw(self, surface, fonts, mouse) -> None: + def draw(self, surface: pygame.Surface, fonts: dict, mouse: tuple[int, int]) -> None: """Draw the button, brightening it slightly while hovered.""" hovered = self.enabled and self.rect.collidepoint(mouse) fill = self.color if self.enabled else (44, 52, 48) @@ -57,7 +64,7 @@ def draw(self, surface, fonts, mouse) -> None: glyph = fonts["small"].render(self.label, True, ink) surface.blit(glyph, glyph.get_rect(center=self.rect.center)) - def hit(self, pos) -> bool: + def hit(self, pos: tuple[int, int]) -> bool: """True if `pos` is inside an enabled button.""" return self.enabled and self.rect.collidepoint(pos) From 1d451f5d7ec8246e0089ebc3e7c59b0361e65354 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Sat, 18 Jul 2026 09:19:27 +0530 Subject: [PATCH 05/11] Orbital Orchard: named tuning constants + engine test suite - The bare magic numbers moved into documented settings.py constants: STRESS_PREVIEW_SHARE/STRESS_TIMER_SHARE (HUD stress phases), POSITION_CORRECTION_SOFTNESS (overlap solver), and MERGE_VELOCITY_DAMP (merged-body velocity). - New tests/ directory (the game previously had none): merge detection rules (tier matching, contact range, arming delay, max tier, one merge per body per frame, mass-weighted spawn, damped velocity), container physics guarantees (clamping, wall reflection, spawn span, trajectory preview bounds, symmetric separation), and data-table sanity for the tier ladder, spawn weights, and stress shares. All engine-only and headless. Co-Authored-By: Claude Fable 5 --- Python Sputnika Game/game.py | 12 +- Python Sputnika Game/merge_logic.py | 4 +- Python Sputnika Game/physics.py | 9 +- Python Sputnika Game/settings.py | 13 ++ .../tests/test_merge_logic.py | 140 ++++++++++++++++++ Python Sputnika Game/tests/test_physics.py | 128 ++++++++++++++++ .../tests/test_progression.py | 69 +++++++++ 7 files changed, 363 insertions(+), 12 deletions(-) create mode 100644 Python Sputnika Game/tests/test_merge_logic.py create mode 100644 Python Sputnika Game/tests/test_physics.py create mode 100644 Python Sputnika Game/tests/test_progression.py diff --git a/Python Sputnika Game/game.py b/Python Sputnika Game/game.py index f6718aa..2d75d56 100644 --- a/Python Sputnika Game/game.py +++ b/Python Sputnika Game/game.py @@ -50,6 +50,8 @@ SPAWN_WEIGHTS, SPAWN_Y, STRESS_PREVIEW_RANGE, + STRESS_PREVIEW_SHARE, + STRESS_TIMER_SHARE, TIERS, WARNING, WINDOW_TITLE, @@ -627,12 +629,12 @@ def _update_fail_state(self, dt: float) -> None: # Reset the beep timer so the next danger spell starts fresh. self._next_warning_beep = 0.0 - # Split the stress bar into two phases: - # - up to 78% from live stack height, - # - final 22% from the true fail countdown once the line is crossed. + # Split the stress bar into two phases: live stack height fills the + # preview share, then the true fail countdown fills the timer share + # once the line is crossed (shares are defined in settings.py). timer_ratio = self.fail_timer / FAIL_GRACE if FAIL_GRACE > 0.0 else 0.0 - preview_stress = preview_ratio * 0.78 - danger_stress = 0.78 + timer_ratio * 0.22 if warning else 0.0 + preview_stress = preview_ratio * STRESS_PREVIEW_SHARE + danger_stress = STRESS_PREVIEW_SHARE + timer_ratio * STRESS_TIMER_SHARE if warning else 0.0 self.container_stress = max(preview_stress, danger_stress) if self.fail_timer >= FAIL_GRACE: self._trigger_game_over() diff --git a/Python Sputnika Game/merge_logic.py b/Python Sputnika Game/merge_logic.py index 4faa1e1..c4a05d0 100644 --- a/Python Sputnika Game/merge_logic.py +++ b/Python Sputnika Game/merge_logic.py @@ -15,7 +15,7 @@ import pygame -from settings import MERGE_CONTACT_SLOP, TIERS +from settings import MERGE_CONTACT_SLOP, MERGE_VELOCITY_DAMP, TIERS @dataclass(slots=True) @@ -110,7 +110,7 @@ def find_merge_events(bodies, now: float) -> list[MergeEvent]: position = (first.position * first.mass + second.position * second.mass) / total_mass # Damp the combined velocity so chain merges stay exciting but readable. - velocity = (first.velocity + second.velocity) * 0.42 + velocity = (first.velocity + second.velocity) * MERGE_VELOCITY_DAMP # `score_gain` is taken from the resulting tier, not the consumed tier. # That makes higher evolutions dramatically more rewarding. diff --git a/Python Sputnika Game/physics.py b/Python Sputnika Game/physics.py index 664970c..8fdfff8 100644 --- a/Python Sputnika Game/physics.py +++ b/Python Sputnika Game/physics.py @@ -33,6 +33,7 @@ ORBITAL_PULL, PHYSICS_MAX_SUBSTEP, PLAYFIELD_CENTER, + POSITION_CORRECTION_SOFTNESS, RESTITUTION, SPAWN_Y, SOLVER_ITERATIONS, @@ -330,11 +331,9 @@ def _solve_pairs(self, bodies: list["CelestialBody"], emit_events: bool) -> list continue # Move both bodies apart proportionally to their inverse masses. - # Lighter bodies move more than heavier bodies. - # - # `0.92` is a slight softness factor. Full correction can - # sometimes feel too sharp with a simple solver. - correction = normal * (penetration / total_inv_mass) * 0.92 + # Lighter bodies move more than heavier bodies. The softness + # factor is explained where it is defined in settings.py. + correction = normal * (penetration / total_inv_mass) * POSITION_CORRECTION_SOFTNESS first.position -= correction * first.inv_mass second.position += correction * second.inv_mass diff --git a/Python Sputnika Game/settings.py b/Python Sputnika Game/settings.py index 469e68c..aa166ab 100644 --- a/Python Sputnika Game/settings.py +++ b/Python Sputnika Game/settings.py @@ -63,6 +63,10 @@ WALL_BOUNCE = 0.22 WALL_FRICTION = 0.28 SOLVER_ITERATIONS = 8 +# How much of a detected overlap the solver corrects per iteration. Full +# correction (1.0) can feel too sharp with a simple solver; slightly under +# lets stacks settle softly instead of popping apart. +POSITION_CORRECTION_SOFTNESS = 0.92 # Player input and launcher tuning. # These values shape how aiming feels. @@ -86,6 +90,9 @@ MERGE_ARM_TIME = 0.18 POST_MERGE_LOCK = 0.08 MERGE_CONTACT_SLOP = 4.0 +# The merged body keeps this fraction of the two parents' combined velocity, +# so chain merges stay exciting but readable instead of ricocheting. +MERGE_VELOCITY_DAMP = 0.42 # Combo scoring: merges that happen within this many seconds of the previous # merge build an escalating score multiplier (see `_apply_merges` in game.py). @@ -105,6 +112,12 @@ FAIL_AGE_GATE = 0.75 STRESS_PREVIEW_RANGE = 280.0 +# The HUD stress meter is split into two phases that add up to 1.0: +# stack height drives the first share, and once the fail line is actually +# crossed, the countdown timer drives the remaining share. +STRESS_PREVIEW_SHARE = 0.78 +STRESS_TIMER_SHARE = 0.22 + # Miscellaneous gameplay limits. # `IMPACT_EVENT_SPEED` controls how hard a collision must be before it creates # particles or other feedback. diff --git a/Python Sputnika Game/tests/test_merge_logic.py b/Python Sputnika Game/tests/test_merge_logic.py new file mode 100644 index 0000000..54e883f --- /dev/null +++ b/Python Sputnika Game/tests/test_merge_logic.py @@ -0,0 +1,140 @@ +"""Engine tests for the merge-detection rules in ``merge_logic.py``. + +These tests build bodies directly and never open a window, so they run +headless in CI. They lock in the core "same tier + valid state = evolve" +rules that make the puzzle work. +""" + +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + +GAME_DIR = Path(__file__).resolve().parents[1] +if str(GAME_DIR) not in sys.path: + sys.path.insert(0, str(GAME_DIR)) + +import pygame + +from entities import CelestialBody +from merge_logic import find_merge_events +from settings import MERGE_VELOCITY_DAMP, TIERS + +NOW = 100.0 + + +def make_body( + body_id: int, + tier: int, + x: float, + y: float, + vx: float = 0.0, + vy: float = 0.0, + age: float = 10.0, +) -> CelestialBody: + """Create one body that is old enough to merge unless ``age`` says otherwise.""" + + return CelestialBody( + body_id=body_id, + tier=tier, + position=pygame.Vector2(x, y), + velocity=pygame.Vector2(vx, vy), + created_at=NOW - age, + ) + + +class MergeDetectionTests(unittest.TestCase): + """Which pairs of bodies are allowed to merge.""" + + def test_touching_same_tier_bodies_merge_upward(self) -> None: + """Two overlapping tier-0 bodies produce one tier-1 merge event.""" + + radius = TIERS[0].radius + bodies = [make_body(1, 0, 0.0, 0.0), make_body(2, 0, radius * 1.5, 0.0)] + + events = find_merge_events(bodies, NOW) + + self.assertEqual(len(events), 1) + self.assertEqual(events[0].source_tier, 0) + self.assertEqual(events[0].target_tier, 1) + self.assertEqual(events[0].score_gain, TIERS[1].score) + self.assertEqual({events[0].first_id, events[0].second_id}, {1, 2}) + + def test_different_tiers_do_not_merge(self) -> None: + """A tier-0 and a tier-1 body touching is not a merge.""" + + bodies = [make_body(1, 0, 0.0, 0.0), make_body(2, 1, 5.0, 0.0)] + + self.assertEqual(find_merge_events(bodies, NOW), []) + + def test_bodies_out_of_contact_range_do_not_merge(self) -> None: + """Same-tier bodies far apart stay separate.""" + + far = TIERS[0].radius * 4 + bodies = [make_body(1, 0, 0.0, 0.0), make_body(2, 0, far, 0.0)] + + self.assertEqual(find_merge_events(bodies, NOW), []) + + def test_fresh_bodies_wait_before_merging(self) -> None: + """A just-created body is not merge-eligible yet.""" + + bodies = [make_body(1, 0, 0.0, 0.0), make_body(2, 0, 5.0, 0.0, age=0.0)] + + self.assertEqual(find_merge_events(bodies, NOW), []) + + def test_highest_tier_bodies_never_merge(self) -> None: + """The final evolution cannot evolve further.""" + + top = len(TIERS) - 1 + bodies = [make_body(1, top, 0.0, 0.0), make_body(2, top, 5.0, 0.0)] + + self.assertEqual(find_merge_events(bodies, NOW), []) + + def test_each_body_participates_in_one_merge_per_frame(self) -> None: + """Three overlapping bodies yield one merge; the third is left alone.""" + + bodies = [ + make_body(1, 0, 0.0, 0.0), + make_body(2, 0, 5.0, 0.0), + make_body(3, 0, 10.0, 0.0), + ] + + events = find_merge_events(bodies, NOW) + + self.assertEqual(len(events), 1) + + +class MergeResultTests(unittest.TestCase): + """What the resulting merged body looks like.""" + + def test_merge_spawns_at_mass_weighted_center(self) -> None: + """Equal-tier parents have equal mass, so the child spawns midway.""" + + radius = TIERS[0].radius + bodies = [make_body(1, 0, 0.0, 0.0), make_body(2, 0, radius, 0.0)] + + event = find_merge_events(bodies, NOW)[0] + + self.assertAlmostEqual(event.position.x, radius / 2, places=5) + self.assertAlmostEqual(event.position.y, 0.0, places=5) + + def test_merged_velocity_is_damped(self) -> None: + """The child keeps a damped fraction of the combined parent velocity.""" + + bodies = [ + make_body(1, 0, 0.0, 0.0, vx=100.0), + make_body(2, 0, 5.0, 0.0, vx=50.0), + ] + + event = find_merge_events(bodies, NOW)[0] + + self.assertAlmostEqual(event.velocity.x, 150.0 * MERGE_VELOCITY_DAMP, places=5) + self.assertAlmostEqual(event.velocity.y, 0.0, places=5) + + +if __name__ == "__main__": + unittest.main() diff --git a/Python Sputnika Game/tests/test_physics.py b/Python Sputnika Game/tests/test_physics.py new file mode 100644 index 0000000..2a9e7aa --- /dev/null +++ b/Python Sputnika Game/tests/test_physics.py @@ -0,0 +1,128 @@ +"""Engine tests for the circular-container physics in ``physics.py``. + +The solver is intentionally game-feel physics, so these tests check the +stability guarantees the rest of the game relies on (bodies stay inside the +bubble, overlaps separate) rather than exact trajectories. +""" + +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + +GAME_DIR = Path(__file__).resolve().parents[1] +if str(GAME_DIR) not in sys.path: + sys.path.insert(0, str(GAME_DIR)) + +import pygame + +from entities import CelestialBody +from physics import PhysicsWorld + +NOW = 100.0 + + +def make_body(body_id: int, tier: int, x: float, y: float, vx: float = 0.0, vy: float = 0.0) -> CelestialBody: + """Create one body at an absolute playfield position.""" + + return CelestialBody( + body_id=body_id, + tier=tier, + position=pygame.Vector2(x, y), + velocity=pygame.Vector2(vx, vy), + created_at=NOW - 10.0, + ) + + +class ContainerTests(unittest.TestCase): + """Bodies must always stay inside the circular bubble.""" + + def test_keep_inside_clamps_a_body_placed_outside(self) -> None: + """A body spawned beyond the wall is pulled back to the legal edge.""" + + world = PhysicsWorld() + body = make_body(1, 0, world.center.x + world.radius * 2, world.center.y) + + world.keep_inside(body) + + distance = (body.position - world.center).length() + self.assertLessEqual(distance, world.radius - body.radius) + + def test_step_pushes_an_escaping_body_back_inside(self) -> None: + """A body flying outward is reflected off the wall by one step.""" + + world = PhysicsWorld() + body = make_body(1, 0, world.center.x + world.radius - 5, world.center.y, vx=500.0) + + world.step([body], 1 / 60) + + distance = (body.position - world.center).length() + self.assertLessEqual(distance, world.radius - body.radius) + + def test_spawn_span_is_symmetric_and_inside_the_bubble(self) -> None: + """The legal spawn range is centered and narrower than the bubble.""" + + world = PhysicsWorld() + left, right = world.allowed_spawn_x(20.0) + + self.assertLess(left, right) + self.assertAlmostEqual(world.center.x - left, right - world.center.x, places=5) + self.assertGreater(left, world.center.x - world.radius) + self.assertLess(right, world.center.x + world.radius) + + def test_preview_trajectory_never_leaves_the_bubble(self) -> None: + """Every preview point stays within the container wall.""" + + world = PhysicsWorld() + radius = 18.0 + points = world.preview_trajectory( + pygame.Vector2(world.center.x, world.center.y - world.radius * 0.7), + pygame.Vector2(300.0, -200.0), + radius, + seed=0.5, + ) + + self.assertTrue(points) + limit = world.radius - radius - 3.0 + 1e-6 + for point in points: + self.assertLessEqual((point - world.center).length(), limit) + + +class PairSolverTests(unittest.TestCase): + """Overlapping bodies must separate instead of sinking into each other.""" + + def test_overlapping_bodies_separate(self) -> None: + """Two heavily overlapped equal bodies end one step farther apart.""" + + world = PhysicsWorld() + offset = 10.0 # far less than two tier-0 radii + first = make_body(1, 0, world.center.x - offset / 2, world.center.y) + second = make_body(2, 0, world.center.x + offset / 2, world.center.y) + before = (second.position - first.position).length() + + world.step([first, second], 1 / 60) + + after = (second.position - first.position).length() + self.assertGreater(after, before) + + def test_equal_bodies_are_pushed_apart_symmetrically(self) -> None: + """Equal masses share the separation correction evenly.""" + + world = PhysicsWorld() + offset = 10.0 + first = make_body(1, 0, world.center.x - offset / 2, world.center.y) + second = make_body(2, 0, world.center.x + offset / 2, world.center.y) + + world.step([first, second], 1 / 60) + + left_push = world.center.x - first.position.x + right_push = second.position.x - world.center.x + self.assertAlmostEqual(left_push, right_push, delta=0.5) + + +if __name__ == "__main__": + unittest.main() diff --git a/Python Sputnika Game/tests/test_progression.py b/Python Sputnika Game/tests/test_progression.py new file mode 100644 index 0000000..bcfb7dd --- /dev/null +++ b/Python Sputnika Game/tests/test_progression.py @@ -0,0 +1,69 @@ +"""Sanity checks for the tier ladder and spawn tuning in ``settings.py``. + +These tests keep the data tables honest: the evolution chain must grow, the +spawn odds must reference real low tiers, and the HUD stress phases must +cover the whole meter. +""" + +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path + +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + +GAME_DIR = Path(__file__).resolve().parents[1] +if str(GAME_DIR) not in sys.path: + sys.path.insert(0, str(GAME_DIR)) + +from settings import ( + SPAWN_WEIGHTS, + STRESS_PREVIEW_SHARE, + STRESS_TIMER_SHARE, + TIERS, +) + + +class TierLadderTests(unittest.TestCase): + """The evolution chain that drives the whole game.""" + + def test_tiers_grow_in_size_and_reward(self) -> None: + """Each evolution is bigger and scores more than the previous one.""" + + radii = [tier.radius for tier in TIERS] + scores = [tier.score for tier in TIERS] + self.assertEqual(radii, sorted(radii)) + self.assertEqual(scores, sorted(scores)) + self.assertLess(radii[0], radii[-1]) + self.assertLess(scores[0], scores[-1]) + + def test_tier_names_are_unique(self) -> None: + """Milestone popups and the HUD rely on distinct tier names.""" + + names = [tier.name for tier in TIERS] + self.assertEqual(len(names), len(set(names))) + + +class SpawnTuningTests(unittest.TestCase): + """The queue only offers low tiers, with sensible odds.""" + + def test_spawn_weights_reference_low_tiers_only(self) -> None: + """There must be a weight per spawnable tier, all positive.""" + + self.assertLessEqual(len(SPAWN_WEIGHTS), len(TIERS) - 1) + self.assertTrue(all(weight > 0 for weight in SPAWN_WEIGHTS)) + + +class StressMeterTests(unittest.TestCase): + """The two HUD stress phases must exactly fill the meter.""" + + def test_stress_shares_sum_to_one(self) -> None: + """Preview share plus timer share covers the full 0..1 bar.""" + + self.assertAlmostEqual(STRESS_PREVIEW_SHARE + STRESS_TIMER_SHARE, 1.0, places=9) + + +if __name__ == "__main__": + unittest.main() From 03f96bb8c929bb083b882e82b1cb5dae080e2806 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Sat, 18 Jul 2026 09:19:44 +0530 Subject: [PATCH 06/11] Solitaire: document the hint engine + add Klondike rules tests - The ~650-line hint cluster in solitaire.py now opens with a pipeline map (generate -> filter -> rank -> describe), documents the wrapper-vs-_on_board naming convention for simulated boards, and warns that over-aggressive filters cause false loss verdicts. Stage banners mark the filter, ranking, and description sections. No behaviour change. - New tests/test_klondike_rules.py (the game previously had none): deal shape, full-deck integrity, draw/recycle cycle, tableau and foundation legality, scoring for foundation plays and reveals, snapshot/restore round trip, win detection, and three hint-engine checks (reveal-first ranking, hint-to-selection legality, and the empty-king-shuffle filter). KlondikeGame never touches Tk, so the suite runs headless. Co-Authored-By: Claude Fable 5 --- Python Solitaire Game/solitaire.py | 37 +++ .../tests/test_klondike_rules.py | 222 ++++++++++++++++++ 2 files changed, 259 insertions(+) create mode 100644 Python Solitaire Game/tests/test_klondike_rules.py diff --git a/Python Solitaire Game/solitaire.py b/Python Solitaire Game/solitaire.py index e72c202..f027c6d 100644 --- a/Python Solitaire Game/solitaire.py +++ b/Python Solitaire Game/solitaire.py @@ -571,6 +571,34 @@ def next_auto_foundation_move(self) -> Optional[Tuple[Selection, int]]: return None + # ------------------------------------------------------------------ + # The hint engine + # ------------------------------------------------------------------ + # Everything from here to `follow_up_creates_concrete_progress` is one + # subsystem: the hint engine that also powers loss detection. It is a + # pipeline of four stages: + # + # 1. GENERATE - `current_legal_moves` lists every legal move on the + # board right now; `future_waste_moves` simulates clicking through + # the stock to find waste cards that become playable later. + # 2. FILTER - `is_meaningful_move` throws away legal-but-pointless + # shuffles (ace diversions, king shuffles between empty columns, + # redundant transfers, unproductive foundation returns). + # 3. RANK - `hint_sort_key` / `hint_priority` order what survived; + # the lowest priority number wins. + # 4. DESCRIBE - `describe_hint` turns the winning move into the text + # the player reads. + # + # Naming convention: many checks exist twice, as a thin wrapper that + # reads the REAL board (`is_redundant_tableau_transfer`) plus an + # `..._on_board` version that takes tableau/foundations as parameters so + # the same rule can run against SIMULATED "what if" boards. When editing + # a rule, edit the `_on_board` version - the wrapper only forwards. + # + # If no move survives the pipeline (and the stock look-ahead), the game + # declares the deal lost - so a filter that is too aggressive can cause + # false "no more moves" verdicts. The filters deliberately allow any + # move that reveals a hidden card or unlocks a foundation play. def find_best_move(self) -> Optional[HintMove]: """ Find the best move for the hint system and loss detection. @@ -811,6 +839,9 @@ def build_hint_move( description=description, ) + # ------------------------------------------------------------------ + # Hint engine stage 4: player-facing descriptions + # ------------------------------------------------------------------ def describe_hint( self, source_type: str, @@ -856,6 +887,9 @@ def describe_cards(self, cards: List[Card]) -> str: return cards[0].label() return f"{len(cards)} cards starting with {cards[0].label()}" + # ------------------------------------------------------------------ + # Hint engine stage 3: ranking + # ------------------------------------------------------------------ def hint_sort_key(self, move: HintMove) -> Tuple[int, int, int, int]: """Build the sort key that decides which hint move is shown. @@ -924,6 +958,9 @@ def move_reveals_hidden(self, move: HintMove) -> bool: return False return not self.tableau[move.source_index][move.source_card_index - 1].face_up + # ------------------------------------------------------------------ + # Hint engine stage 2: meaningfulness filters + # ------------------------------------------------------------------ def is_meaningful_move(self, move: HintMove) -> bool: """ Filter out technically legal moves that are strategically pointless. diff --git a/Python Solitaire Game/tests/test_klondike_rules.py b/Python Solitaire Game/tests/test_klondike_rules.py new file mode 100644 index 0000000..6639055 --- /dev/null +++ b/Python Solitaire Game/tests/test_klondike_rules.py @@ -0,0 +1,222 @@ +"""Rules tests for the Tk-free ``KlondikeGame`` model in ``solitaire.py``. + +``KlondikeGame`` never touches tkinter, so these tests run headless: they +deal boards, craft specific pile layouts, and check the classic Klondike +rules, scoring, undo snapshots, and the hint engine's judgement. +""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path + +GAME_DIR = Path(__file__).resolve().parents[1] +if str(GAME_DIR) not in sys.path: + sys.path.insert(0, str(GAME_DIR)) + +from solitaire import ( + FOUNDATION_COUNT, + FOUNDATION_SUITS, + TABLEAU_COLUMNS, + Card, + KlondikeGame, +) + + +def make_game(tmp_dir: str) -> KlondikeGame: + """Create a game whose score file lives in a throwaway folder.""" + + return KlondikeGame(high_score_path=str(Path(tmp_dir) / "scores.json")) + + +def clear_board(game: KlondikeGame) -> None: + """Empty every pile so a test can craft an exact board.""" + + game.stock = [] + game.waste = [] + game.foundations = [[] for _ in range(FOUNDATION_COUNT)] + game.tableau = [[] for _ in range(TABLEAU_COLUMNS)] + game.score = 0 + + +class DealTests(unittest.TestCase): + """The standard Klondike opening layout.""" + + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.game = make_game(self.tmp.name) + + def test_deal_shape_matches_klondike(self) -> None: + """Columns hold 1..7 cards, 24 remain in stock, foundations start empty.""" + + for column_index, column in enumerate(self.game.tableau): + self.assertEqual(len(column), column_index + 1) + for row_index, card in enumerate(column): + self.assertEqual(card.face_up, row_index == column_index) + self.assertEqual(len(self.game.stock), 24) + self.assertTrue(all(not card.face_up for card in self.game.stock)) + self.assertTrue(all(not pile for pile in self.game.foundations)) + self.assertEqual(self.game.score, 0) + + def test_deck_is_a_complete_52_card_pack(self) -> None: + """Every suit/rank combination appears exactly once across all piles.""" + + cards = list(self.game.stock) + for column in self.game.tableau: + cards.extend(column) + identities = {(card.suit, card.rank) for card in cards} + self.assertEqual(len(cards), 52) + self.assertEqual(len(identities), 52) + + def test_draw_and_recycle_cycle(self) -> None: + """Drawing empties the stock into the waste, then recycling reverses it.""" + + drawn = 0 + while self.game.stock: + self.assertEqual(self.game.draw_from_stock(), "draw") + drawn += 1 + self.assertTrue(self.game.waste[-1].face_up) + self.assertEqual(drawn, 24) + + self.assertEqual(self.game.draw_from_stock(), "reset") + self.assertEqual(len(self.game.stock), 24) + self.assertEqual(self.game.waste, []) + self.assertTrue(all(not card.face_up for card in self.game.stock)) + + +class MoveRuleTests(unittest.TestCase): + """Tableau and foundation placement legality.""" + + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.game = make_game(self.tmp.name) + clear_board(self.game) + + def test_tableau_requires_alternating_colors_descending(self) -> None: + """A red 6 fits a black 7; same color or wrong rank is rejected.""" + + black_seven = [Card("spades", 7, face_up=True)] + self.assertTrue(self.game.can_move_to_tableau_cards([Card("hearts", 6, True)], black_seven)) + self.assertFalse(self.game.can_move_to_tableau_cards([Card("clubs", 6, True)], black_seven)) + self.assertFalse(self.game.can_move_to_tableau_cards([Card("hearts", 5, True)], black_seven)) + + def test_only_kings_start_empty_columns(self) -> None: + """An empty tableau column accepts a king and nothing else.""" + + self.assertTrue(self.game.can_move_to_tableau_cards([Card("hearts", 13, True)], [])) + self.assertFalse(self.game.can_move_to_tableau_cards([Card("hearts", 12, True)], [])) + + def test_foundations_build_up_by_suit_from_the_ace(self) -> None: + """Foundation piles demand the right suit and sequential ranks.""" + + hearts = FOUNDATION_SUITS.index("hearts") + self.assertTrue(self.game.can_move_to_foundation(Card("hearts", 1, True), hearts)) + self.assertFalse(self.game.can_move_to_foundation(Card("spades", 1, True), hearts)) + self.assertFalse(self.game.can_move_to_foundation(Card("hearts", 2, True), hearts)) + + self.game.foundations[hearts] = [Card("hearts", 1, True)] + self.assertTrue(self.game.can_move_to_foundation(Card("hearts", 2, True), hearts)) + self.assertFalse(self.game.can_move_to_foundation(Card("hearts", 3, True), hearts)) + + def test_foundation_move_scores_and_reveal_scores(self) -> None: + """Foundation plays and card reveals both award their point values.""" + + hearts = FOUNDATION_SUITS.index("hearts") + self.game.tableau[0] = [Card("spades", 9, face_up=False), Card("hearts", 1, face_up=True)] + + selection = self.game.selection_from_tableau(0, 1) + self.assertIsNotNone(selection) + moved = self.game.move_selection_to_foundation(selection, hearts) + + self.assertTrue(moved) + # Moving to the foundation also revealed the buried spade beneath. + self.assertTrue(self.game.tableau[0][-1].face_up) + self.assertEqual( + self.game.score, self.game.FOUNDATION_POINTS + self.game.REVEAL_POINTS + ) + + def test_has_won_requires_full_foundations(self) -> None: + """The win check demands 13 cards on all four foundations.""" + + self.assertFalse(self.game.has_won()) + for index, suit in enumerate(FOUNDATION_SUITS): + self.game.foundations[index] = [Card(suit, rank, True) for rank in range(1, 14)] + self.assertTrue(self.game.has_won()) + + +class UndoTests(unittest.TestCase): + """Snapshot/restore powers the undo stack.""" + + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.game = make_game(self.tmp.name) + + def test_snapshot_round_trip_restores_the_board(self) -> None: + """A move followed by a restore puts every pile and score back.""" + + before = self.game.snapshot() + self.game.draw_from_stock() + self.game.add_score(25) + + self.game.restore_snapshot(before) + + after = self.game.snapshot() + for key in ("score", "stock", "waste", "foundations", "tableau", "won", "lost"): + self.assertEqual(after[key], before[key]) + + +class HintEngineTests(unittest.TestCase): + """The hint pipeline: generate, filter, rank.""" + + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.game = make_game(self.tmp.name) + clear_board(self.game) + + def test_hint_prefers_the_move_that_reveals_a_hidden_card(self) -> None: + """Uncovering a face-down card outranks everything else.""" + + self.game.tableau[0] = [Card("diamonds", 9, face_up=False), Card("hearts", 6, face_up=True)] + self.game.tableau[1] = [Card("spades", 7, face_up=True)] + + move = self.game.find_best_move() + + self.assertIsNotNone(move) + self.assertEqual(move.source_type, "tableau") + self.assertEqual(move.source_index, 0) + self.assertEqual(move.destination_type, "tableau") + self.assertEqual(move.destination_index, 1) + + def test_hint_translates_back_into_a_legal_selection(self) -> None: + """A produced hint always maps to a live, legal selection.""" + + self.game.waste = [Card("hearts", 1, face_up=True)] + + move = self.game.find_best_move() + + self.assertIsNotNone(move) + self.assertEqual(move.destination_type, "foundation") + selection = self.game.selection_from_hint(move) + self.assertIsNotNone(selection) + self.assertTrue( + self.game.can_move_to_foundation(selection.cards[0], move.destination_index) + ) + + def test_pointless_king_shuffle_is_not_suggested(self) -> None: + """Sliding a bare king between empty columns is filtered out entirely.""" + + self.game.tableau[0] = [Card("spades", 13, face_up=True)] + # Every other column stays empty, so the king shuffle is the only + # legal move -- and the filter should still reject it, meaning the + # deal is correctly judged as having no useful moves. + self.assertIsNone(self.game.find_best_move()) + + +if __name__ == "__main__": + unittest.main() From 937ec8bf5f193a565743239adb94c02119c94543 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Sat, 18 Jul 2026 09:22:24 +0530 Subject: [PATCH 07/11] Docs pass: test docstrings, README refresh, repo-wide Tests section - Monopoly's four terse test modules now carry the same docstring style as the rest of the repo (class summaries plus one-line test intents). - The Ludo READMEs describe the new boards accurately: classic square cross for 4P, compact radial boards for 5P/6P, clockwise movement, Player 1 blue at the bottom, and the geometry test suite. - Orbital Orchard and Solitaire READMEs document their new tests/ folders and how to run them headless. - The root README gains a Tests section covering all four suites, the SDL dummy driver, and the three autotest modes. Co-Authored-By: Claude Fable 5 --- Python Ludo Game/README.md | 23 ++++++++++++------- .../tests/test_engine_regressions.py | 9 ++++++++ .../tests/test_foundation_improvements.py | 15 ++++++++++++ .../tests/test_ui_readability.py | 5 ++++ Python Monopoly Game/tests/test_ui_wiring.py | 7 ++++++ Python Solitaire Game/README.md | 10 ++++++++ Python Sputnika Game/README.md | 11 +++++++++ README.md | 18 ++++++++++++++- 8 files changed, 89 insertions(+), 9 deletions(-) diff --git a/Python Ludo Game/README.md b/Python Ludo Game/README.md index a2a7e6e..5a38117 100644 --- a/Python Ludo Game/README.md +++ b/Python Ludo Game/README.md @@ -1,8 +1,10 @@ # Ludo A desktop **Ludo** game built in Python with `pygame-ce`. It supports 4 to 6 -total players, 1 to 6 local human players, AI seats for the rest, and generated -square, pentagonal, or hexagonal boards. +total players, 1 to 6 local human players, and AI seats for the rest. Four +players get the classic square cross board; five and six players get compact +radial boards with yard triangles between the arms. Tokens travel clockwise, +hopping cell by cell like the popular mobile Ludo apps. ## Project Structure @@ -15,7 +17,7 @@ square, pentagonal, or hexagonal boards. - `board_render.py` - procedural board and token drawing. - `ui.py` - buttons, labels, panels, wrapped text, and dice drawing. - `simulation.py` - seeded all-AI runs for tuning. -- `tests/` - engine, AI, layout, save/load, and Pygame smoke coverage. +- `tests/` - engine, AI, board-geometry, save/load, and Pygame smoke coverage. - `requirements.txt`, `Ludo Game.spec` - dependency and PyInstaller recipe. ## Requirements @@ -38,9 +40,11 @@ python main.py ## How to Play On the setup screen, choose the total player count, human player count, AI -profile, and optional house-rule toggles. The board shape follows the total -player count: 4 players use a square board, 5 players use a pentagonal board, -and 6 players use a hexagonal board. +profile, and optional house-rule toggles. The preview thumbnails show the real +board for each count: 4 players use the classic 15x15 square cross, while 5 +and 6 players use compact radial boards whose arms meet at a central hub. +Player 1 always sits at the bottom (blue) and seats continue clockwise, the +same direction the tokens move. Classic competitive rules are enabled by default: @@ -116,5 +120,8 @@ The finished executable is written to `dist\Ludo Game.exe`. ## Notes -All visuals are procedural. The 5- and 6-player boards generalize Ludo into -13 track cells per player segment and 6 home-lane cells per player. +All visuals are procedural. Every board keeps the same engine numbers -- 13 +track cells per player segment and 6 home-lane cells per player -- so the +rules are identical whichever board shape is on screen. The geometry test +suite locks the visual layout to those rules: track continuity, clockwise +movement, and start/yard/home alignment are all asserted per player count. diff --git a/Python Monopoly Game/tests/test_engine_regressions.py b/Python Monopoly Game/tests/test_engine_regressions.py index 629de8a..c2467b3 100644 --- a/Python Monopoly Game/tests/test_engine_regressions.py +++ b/Python Monopoly Game/tests/test_engine_regressions.py @@ -16,6 +16,7 @@ def make_game() -> MonopolyGame: + """Create a deterministic four-player game with one human.""" return MonopolyGame( [("P1", True), ("P2", False), ("P3", False), ("P4", False)], seed=3, @@ -23,12 +24,16 @@ def make_game() -> MonopolyGame: def rig_rolls(game: MonopolyGame, *rolls: int) -> None: + """Force the next dice values so a test can land on chosen spaces.""" pending = list(rolls) game.rng.randint = lambda _start, _end: pending.pop(0) class EngineRegressionTests(unittest.TestCase): + """Core turn-flow, rent, mortgage, save, and bankruptcy rules.""" + def test_turn_flow_pauses_to_buy_then_advances_after_end_turn(self) -> None: + """Landing on an unowned title waits for buy/auction before moving on.""" game = make_game() rig_rolls(game, 1, 2) @@ -43,6 +48,7 @@ def test_turn_flow_pauses_to_buy_then_advances_after_end_turn(self) -> None: self.assertEqual(game.awaiting, "pre_roll") def test_rent_transfers_cash_on_owned_unmortgaged_property(self) -> None: + """Landing on an opponent's title moves rent from payer to owner.""" game = make_game() game.current_player.position = 39 game.owners[1] = 1 @@ -59,6 +65,7 @@ def test_rent_transfers_cash_on_owned_unmortgaged_property(self) -> None: self.assertEqual(game.players[1].cash, owner_before + game.board[1].rent[0]) def test_mortgage_and_unmortgage_round_trip_uses_interest(self) -> None: + """Lifting a mortgage costs the mortgage value plus 10% interest.""" game = make_game() game.owners[5] = 0 player = game.current_player @@ -77,6 +84,7 @@ def test_mortgage_and_unmortgage_round_trip_uses_interest(self) -> None: ) def test_save_load_round_trip_keeps_property_state_and_ai_profile(self) -> None: + """Serialising and restoring a game preserves ownership and settings.""" game = MonopolyGame( [("P1", True), ("P2", False), ("P3", False), ("P4", False)], ai_profile="sharp", @@ -94,6 +102,7 @@ def test_save_load_round_trip_keeps_property_state_and_ai_profile(self) -> None: self.assertEqual(restored.mortgaged, game.mortgaged) def test_bankruptcy_win_check_names_last_solvent_player(self) -> None: + """When only one player remains solvent, the game ends with them winning.""" game = make_game() for player in game.players[1:]: player.bankrupt = True diff --git a/Python Monopoly Game/tests/test_foundation_improvements.py b/Python Monopoly Game/tests/test_foundation_improvements.py index df779d8..b3507e3 100644 --- a/Python Monopoly Game/tests/test_foundation_improvements.py +++ b/Python Monopoly Game/tests/test_foundation_improvements.py @@ -17,6 +17,7 @@ def make_game(seed: int = 7) -> MonopolyGame: + """Create a deterministic four-player game with one human.""" return MonopolyGame( [("Human", True), ("Iris", False), ("Knox", False), ("Mira", False)], seed=seed, @@ -24,12 +25,16 @@ def make_game(seed: int = 7) -> MonopolyGame: def give_group(game: MonopolyGame, player_index: int, positions: tuple[int, ...]) -> None: + """Hand a set of board positions to one player directly.""" for position in positions: game.owners[position] = player_index class OfficialRuleFoundationTests(unittest.TestCase): + """Classic-rule fixes: asset actions, bankruptcy auctions, trade interest.""" + def test_asset_actions_explain_what_one_owned_title_can_do(self) -> None: + """The engine reports allowed actions and blocker reasons per title.""" game = make_game() player = game.current_player give_group(game, player.index, (1, 3)) @@ -44,6 +49,7 @@ def test_asset_actions_explain_what_one_owned_title_can_do(self) -> None: self.assertIn("not mortgaged", actions["unmortgage"]["reason"].lower()) def test_sell_building_action_respects_even_selling(self) -> None: + """Buildings must come down evenly across a colour group.""" game = make_game() give_group(game, 0, (1, 3)) game.houses = {1: 2, 3: 1} @@ -55,6 +61,7 @@ def test_sell_building_action_respects_even_selling(self) -> None: self.assertEqual(game.houses[1], 1) def test_bankruptcy_to_bank_starts_auction_for_released_assets(self) -> None: + """Titles released by a bank bankruptcy go straight to auction.""" game = make_game() player = game.current_player give_group(game, player.index, (1, 3)) @@ -71,6 +78,7 @@ def test_bankruptcy_to_bank_starts_auction_for_released_assets(self) -> None: self.assertNotIn(1, game.owners) def test_bankruptcy_auctions_continue_until_every_bank_asset_is_offered(self) -> None: + """A queue re-auctions each released title one after another.""" game = make_game() player = game.current_player give_group(game, player.index, (1, 3)) @@ -84,6 +92,7 @@ def test_bankruptcy_auctions_continue_until_every_bank_asset_is_offered(self) -> self.assertEqual(game.auction["position"], 3) def test_mortgaged_trade_property_charges_transfer_interest_immediately(self) -> None: + """Receiving a mortgaged title costs the classic 10% transfer interest.""" game = make_game() game.owners[39] = 0 game.mortgaged.add(39) @@ -104,6 +113,7 @@ def test_mortgaged_trade_property_charges_transfer_interest_immediately(self) -> self.assertIn(39, game.mortgaged) def test_trade_consequence_summary_names_mortgage_interest_before_acceptance(self) -> None: + """The trade preview warns the receiver about interest due.""" game = make_game() game.owners[39] = 0 game.mortgaged.add(39) @@ -122,6 +132,7 @@ def test_trade_consequence_summary_names_mortgage_interest_before_acceptance(sel ) def test_save_payload_is_versioned_and_unknown_versions_are_rejected(self) -> None: + """Saves carry a version number and future versions refuse to load.""" game = make_game() payload = game.to_dict() invalid = copy.deepcopy(payload) @@ -133,7 +144,10 @@ def test_save_payload_is_versioned_and_unknown_versions_are_rejected(self) -> No class AiGrowthTests(unittest.TestCase): + """AI profiles and the seeded simulation harness.""" + def test_named_ai_profiles_feed_property_valuation(self) -> None: + """Profile value scales change how much the AI thinks a title is worth.""" game = make_game() standard = ai.property_value(game, game.players[1], 1, ai.AI_PROFILES["standard"]) cautious = ai.property_value(game, game.players[1], 1, ai.AI_PROFILES["cautious"]) @@ -142,6 +156,7 @@ def test_named_ai_profiles_feed_property_valuation(self) -> None: self.assertGreater(standard, cautious) def test_seeded_ai_simulation_reports_strategy_metrics(self) -> None: + """A headless all-AI game finishes and reports tuning metrics.""" from simulation import run_ai_game metrics = run_ai_game(seed=11, max_actions=1000, profile_key="standard") diff --git a/Python Monopoly Game/tests/test_ui_readability.py b/Python Monopoly Game/tests/test_ui_readability.py index a2b06d2..4a11ad7 100644 --- a/Python Monopoly Game/tests/test_ui_readability.py +++ b/Python Monopoly Game/tests/test_ui_readability.py @@ -16,6 +16,7 @@ def make_game() -> MonopolyGame: + """Create a deterministic four-player game with one human.""" return MonopolyGame( [("Human", True), ("Iris", False), ("Knox", False), ("Mira", False)], seed=5, @@ -23,7 +24,10 @@ def make_game() -> MonopolyGame: class ReadabilityHelperTests(unittest.TestCase): + """Text helpers the panels rely on to explain game state.""" + def test_property_detail_lines_describe_owner_rent_and_mortgage_state(self) -> None: + """Hovering a title lists its owner, rent, and mortgage status.""" game = make_game() game.owners[1] = 1 game.mortgaged.add(1) @@ -35,6 +39,7 @@ def test_property_detail_lines_describe_owner_rent_and_mortgage_state(self) -> N self.assertTrue(any(line.startswith("Rent:") for line in lines)) def test_trade_error_explains_why_developed_group_cannot_move(self) -> None: + """Illegal trades come back with a human-readable blocker reason.""" game = make_game() game.owners[1] = 0 game.owners[3] = 0 diff --git a/Python Monopoly Game/tests/test_ui_wiring.py b/Python Monopoly Game/tests/test_ui_wiring.py index 14cdf04..779ff6b 100644 --- a/Python Monopoly Game/tests/test_ui_wiring.py +++ b/Python Monopoly Game/tests/test_ui_wiring.py @@ -22,21 +22,27 @@ class UiWiringTests(unittest.TestCase): + """Buttons and app plumbing stay reachable as the UI evolves.""" + @classmethod def setUpClass(cls) -> None: + """Initialize pygame once for the headless UI checks.""" pygame.init() @classmethod def tearDownClass(cls) -> None: + """Release pygame after the UI checks finish.""" pygame.quit() def test_setup_buttons_include_ai_profile_stepper(self) -> None: + """The setup screen exposes the AI-difficulty stepper.""" keys = {button.key for button in ui.setup_buttons(has_save=False)} self.assertIn("ai_profile_prev", keys) self.assertIn("ai_profile_next", keys) def test_human_with_buildings_gets_sell_button_in_action_bar(self) -> None: + """Owning a developed street unlocks the Sell Buildings button.""" app = MonopolyApp() game = MonopolyGame( [("Human", True), ("Iris", False), ("Knox", False), ("Mira", False)], @@ -52,6 +58,7 @@ def test_human_with_buildings_gets_sell_button_in_action_bar(self) -> None: self.assertIn("sell", keys) def test_asset_manager_opens_and_dispatches_engine_actions(self) -> None: + """The Assets dialog opens, builds via the engine, and closes cleanly.""" app = MonopolyApp() game = MonopolyGame( [("Human", True), ("Iris", False), ("Knox", False), ("Mira", False)], diff --git a/Python Solitaire Game/README.md b/Python Solitaire Game/README.md index 743ec13..365a67e 100644 --- a/Python Solitaire Game/README.md +++ b/Python Solitaire Game/README.md @@ -9,6 +9,7 @@ auto-solve, undo, and a confetti win celebration along the way. - `solitaire.py` is the whole game. The rules model (`KlondikeGame`) and the tkinter interface (`SolitaireApp`) both live in this single file. +- `tests/` holds the headless rules test suite for `KlondikeGame`. - `solitaire_icon.ico` is the window and executable icon. - `Solitaire Game.spec` is the PyInstaller build recipe for the `.exe`. @@ -25,6 +26,15 @@ auto-solve, undo, and a confetti win celebration along the way. python solitaire.py ``` +## Tests + +The rules model (`KlondikeGame`) is independent of tkinter, so the test +suite runs headless without opening a window: + +```bash +python -m unittest discover -s tests +``` + ## How to Play The goal is to move all 52 cards onto the four **foundation** piles. Each diff --git a/Python Sputnika Game/README.md b/Python Sputnika Game/README.md index 71e2caf..adbd747 100644 --- a/Python Sputnika Game/README.md +++ b/Python Sputnika Game/README.md @@ -13,6 +13,7 @@ Orbital Orchard is a desktop Python puzzle game built with `pygame-ce`. You drop - `effects.py` handles particles, score popups, ring pulses, and screen shake. - `ui.py` draws panels, buttons, overlays, and the container HUD. - `assets.py` generates placeholder art, background stars, fonts, and simple procedural sounds. +- `tests/` holds the headless engine test suite (merge rules, physics, data tables). - `requirements.txt` lists the runtime dependency. ## Beginner Reading Notes @@ -38,6 +39,16 @@ python main.py The game saves your high score and lifetime stats to `%APPDATA%\Orbital Orchard\save_data.json`. This per-user location is used whether you run the script or a compiled `.exe`, so progress survives across sessions, even if you move the `.exe` elsewhere. +## Tests + +The merge rules, physics guarantees, and data tables have a headless test suite that never opens a window: + +```bash +python -m unittest discover -s tests +``` + +For a quick full-app smoke test (engine + rendering together), set `ORBITAL_ORCHARD_AUTOTEST=1` and run `python main.py`; the game plays a few frames in a hidden window and exits. + ## Build a Standalone .exe The game can be packaged into a single Windows executable with [PyInstaller](https://pyinstaller.org/). diff --git a/README.md b/README.md index f7c6c98..808ce5e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ keeps its progress under `%APPDATA%` so saves survive moving the executable. | `Python Sputnika Game/` | **[Orbital Orchard](Python%20Sputnika%20Game/README.md)** — a Suika-style merge puzzle. Drop celestial bodies into a bubble, bounce and merge them up to a Quasar Crown. | pygame | | `Python Solitaire Game/` | **[Klondike Solitaire](Python%20Solitaire%20Game/README.md)** — the classic single-player card game with hints, auto-solve, undo, and a confetti win celebration. | tkinter | | `Python Monopoly Game/` | **[Monopoly](Python%20Monopoly%20Game/README.md)** — full standard rules, 1-4 humans + AI to fill out four players, thirteen themed boards, auctions, trading, and autosave + resume. | pygame | -| `Python Ludo Game/` | **[Ludo](Python%20Ludo%20Game/README.md)** — classic competitive Ludo for 4-6 total players, 1-6 humans, heuristic AI, and square/pentagonal/hexagonal boards. | pygame | +| `Python Ludo Game/` | **[Ludo](Python%20Ludo%20Game/README.md)** — classic competitive Ludo for 4-6 total players, 1-6 humans, heuristic AI, clockwise token movement, and a classic square board (4P) plus compact radial boards (5P/6P). | pygame | Click into a folder for the full per-game README with rules, controls, features and build notes. @@ -30,6 +30,22 @@ python main.py # python solitaire.py for the Solitaire game Python 3.11 or newer is recommended for all four. +## Tests + +Every game has a headless test suite under its own `tests/` folder. Run one +from inside that game's folder: + +```bash +cd "Python Ludo Game" # or another game's folder +python -m unittest discover -s tests +``` + +The pygame games read `SDL_VIDEODRIVER=dummy` for fully headless runs, and +each has an autotest mode (`LUDO_AUTOTEST=1`, `MONOPOLY_AUTOTEST=1`, +`ORBITAL_ORCHARD_AUTOTEST=1`) that plays a short hidden-window session as a +smoke test. Solitaire's rules model never touches tkinter, so its suite needs +no display at all. + ## Build a standalone .exe Every game ships with a PyInstaller spec, so packaging is a one-liner from From 0eb56b51838a471608c04cbc54f1878e358e6c63 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Sat, 18 Jul 2026 09:45:51 +0530 Subject: [PATCH 08/11] Add ruff/mypy/bandit quality gates and make the whole repo pass them Tooling (mirroring the house style of the sibling repos): - pyproject.toml: ruff (line-length 120, py311, E/W/F/I/B/UP/C4/SIM/RUF, per-file ignores for the tests' sys.path shim and the two Monopoly data-table modules), shared mypy flags (check_untyped_defs, no_implicit_optional, warn_redundant_casts, warn_unused_ignores), and bandit with documented B101/B311/B110 skips. The four game folders share module names, so mypy runs once per folder. - .pre-commit-config.yaml: check-only ruff plus merge-conflict/yaml/ large-file/debug-statement hooks. - requirements-dev.txt: exact-pinned ruff/mypy/bandit/pytest/pytest-cov/ pip-audit/pre-commit toolchain. Code changes to reach a clean bill: - ~180 auto-fixes (pep585/604 annotations, import order, pyupgrade) plus hand fixes for long lines, collapsible branches, contextlib.suppress, zip strict=True, and one unused variable. - Ludo: TokenState now declares _finished_steps as a real field instead of an injected attribute; LudoGame accepts a Sequence of seat specs; color helpers return explicit RGB 3-tuples; roll handling narrows the die value; UI smoke tests import helpers directly. - Monopoly: an explicit _require_game() invariant helper replaces Optional derefs across the app layer; auction/trade/pending-purchase states are narrowed where dialogs guarantee them. - Orbital Orchard: merge candidates are typed against CelestialBody; nebula loop variables no longer shadow a differently-shaped tuple. - Solitaire: annotations for the hint cache and tkinter label style. All four test suites, the three headless autotests, ruff, mypy (x4), bandit, and compileall pass. Co-Authored-By: Claude Fable 5 --- .pre-commit-config.yaml | 17 ++ Python Ludo Game/board.py | 3 +- Python Ludo Game/board_render.py | 21 +- Python Ludo Game/game.py | 9 +- Python Ludo Game/main.py | 68 ++--- Python Ludo Game/models.py | 18 +- Python Ludo Game/settings.py | 1 - Python Ludo Game/tests/test_board_geometry.py | 1 - Python Ludo Game/tests/test_engine_rules.py | 5 +- Python Ludo Game/tests/test_layout_and_ai.py | 13 +- Python Ludo Game/tests/test_ui_smoke.py | 40 ++- .../tests/test_visual_overhaul.py | 3 +- Python Ludo Game/visual_theme.py | 13 +- Python Monopoly Game/ai.py | 12 +- Python Monopoly Game/board_render.py | 20 +- Python Monopoly Game/cards.py | 1 - Python Monopoly Game/game.py | 23 +- Python Monopoly Game/main.py | 236 +++++++++++------- Python Monopoly Game/player.py | 2 +- Python Monopoly Game/settings.py | 1 - Python Monopoly Game/tests/test_ai_trades.py | 1 - .../tests/test_engine_regressions.py | 6 +- .../tests/test_foundation_improvements.py | 3 +- .../tests/test_ui_readability.py | 1 - Python Monopoly Game/tests/test_ui_wiring.py | 1 - Python Monopoly Game/ui.py | 23 +- Python Solitaire Game/solitaire.py | 218 ++++++++-------- .../tests/test_klondike_rules.py | 8 +- Python Sputnika Game/assets.py | 33 ++- Python Sputnika Game/effects.py | 6 +- Python Sputnika Game/entities.py | 43 ++-- Python Sputnika Game/game.py | 20 +- Python Sputnika Game/main.py | 7 +- Python Sputnika Game/merge_logic.py | 6 +- Python Sputnika Game/physics.py | 23 +- Python Sputnika Game/settings.py | 18 +- Python Sputnika Game/ui.py | 14 +- pyproject.toml | 79 ++++++ requirements-dev.txt | 10 + 39 files changed, 609 insertions(+), 418 deletions(-) create mode 100644 .pre-commit-config.yaml create mode 100644 pyproject.toml create mode 100644 requirements-dev.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..045e934 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,17 @@ +# Check-only hooks: they report problems but never rewrite files. +# Keep the ruff rev aligned with the pin in requirements-dev.txt. +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.15.1 + hooks: + - id: ruff + name: ruff check (no fixes) + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-merge-conflict + - id: check-yaml + - id: check-added-large-files + args: ["--maxkb=1024"] + - id: debug-statements diff --git a/Python Ludo Game/board.py b/Python Ludo Game/board.py index ccbe14f..334e535 100644 --- a/Python Ludo Game/board.py +++ b/Python Ludo Game/board.py @@ -7,7 +7,6 @@ from settings import BOARD_CENTER, BOARD_RADIUS, HOME_LENGTH, SEGMENT_LENGTH - Point = tuple[float, float] @@ -32,7 +31,7 @@ class BoardLayout: radius: float @classmethod - def for_player_count(cls, total_players: int) -> "BoardLayout": + def for_player_count(cls, total_players: int) -> BoardLayout: """Build a square, pentagonal, or hexagonal board. A standard four-player Ludo board has four repeated arms. The five- diff --git a/Python Ludo Game/board_render.py b/Python Ludo Game/board_render.py index 2c7b62f..2ae2b45 100644 --- a/Python Ludo Game/board_render.py +++ b/Python Ludo Game/board_render.py @@ -9,8 +9,7 @@ import visual_theme as theme from models import Move -from settings import GOLD, INK, WHITE, seat_colors - +from settings import GOLD, WHITE, seat_colors Point = tuple[float, float] Color = tuple[int, int, int] @@ -165,7 +164,8 @@ def position_for(self, player_index: int, steps: int, token_index: int = 0) -> P """ if steps < 0: - return self.display.yard_positions[player_index][token_index % len(self.display.yard_positions[player_index])] + slots = self.display.yard_positions[player_index] + return slots[token_index % len(slots)] track_index = self.layout.track_index(player_index, steps) if track_index is not None: return self.display.track_positions[track_index] @@ -300,10 +300,7 @@ def _draw_player_tab( color = self.display.seat_colors[player_index] name = game.players[player_index].name[:16] - if yard.centery < self.display.center[1]: - label_y = yard.y - 24 - else: - label_y = yard.bottom + 24 + label_y = yard.y - 24 if yard.centery < self.display.center[1] else yard.bottom + 24 rect = pygame.Rect(0, 0, 150, 28) rect.center = (yard.centerx, label_y) theme.draw_player_banner(surface, rect, color, name, fonts) @@ -564,12 +561,10 @@ def _radial_display_layout(layout) -> DisplayLayout: # the hub. The +tangent side faces this seat's own yard wedge. arm: list[DisplayCell] = [] for step in range(home_length): - arm.append( - _radial_cell(center, outward, tangent, spec.inner_radius + step * spec.cell_step, -spec.lane_offset, spec.cell_size) - ) - arm.append( - _radial_cell(center, outward, tangent, spec.inner_radius + home_length * spec.cell_step, 0.0, spec.cell_size) - ) + distance = spec.inner_radius + step * spec.cell_step + arm.append(_radial_cell(center, outward, tangent, distance, -spec.lane_offset, spec.cell_size)) + tip_distance = spec.inner_radius + home_length * spec.cell_step + arm.append(_radial_cell(center, outward, tangent, tip_distance, 0.0, spec.cell_size)) for step in range(home_length): arm.append( _radial_cell( diff --git a/Python Ludo Game/game.py b/Python Ludo Game/game.py index 9f4d746..1a362db 100644 --- a/Python Ludo Game/game.py +++ b/Python Ludo Game/game.py @@ -3,6 +3,7 @@ from __future__ import annotations import random +from collections.abc import Sequence from dataclasses import asdict, dataclass from board import BoardLayout @@ -42,7 +43,7 @@ class LudoGame: def __init__( self, - players: list[tuple[str, bool]] | list[PlayerState], + players: Sequence[tuple[str, bool] | PlayerState], rules: LudoRules, seed: int | None = None, ai_profile: str = "tactical", @@ -302,7 +303,7 @@ def to_dict(self) -> dict: } @classmethod - def from_dict(cls, data: dict) -> "LudoGame": + def from_dict(cls, data: dict) -> LudoGame: """Rebuild a game from save-file data.""" rules = LudoRules(**data["rules"]) @@ -400,8 +401,8 @@ def _finish_game(self, winner_index: int) -> None: def _mark_finished_tokens(self) -> None: """Teach token helper properties what this layout's finish step is.""" - for player in self.players: - for token in player.tokens: + for player_state in self.players: + for token in player_state.tokens: token._finished_steps = self.layout.finish_steps def _note(self, message: str) -> None: diff --git a/Python Ludo Game/main.py b/Python Ludo Game/main.py index 993a99e..0ddca4e 100644 --- a/Python Ludo Game/main.py +++ b/Python Ludo Game/main.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import ctypes import json import math @@ -20,19 +21,19 @@ from board import BoardLayout from board_render import BoardRenderer from game import LudoGame, LudoRules +from models import Move from settings import ( AI_TURN_DELAY_MS, - BG, DICE_ANIM_MS, FPS, GOLD, + INK, MAX_PLAYERS, PANEL_BG, PANEL_CARD, PANEL_EDGE, PANEL_WIDTH, PANEL_X, - INK, SAVE_DIR, SAVEGAME_PATH, SCREEN_HEIGHT, @@ -246,7 +247,10 @@ def load_stats() -> dict: defaults.update(data) # Older stats files may not have every player-count bucket. Merge them # over a full default set so the game-over screen can index safely. - defaults["by_player_count"] = {**{"4": 0, "5": 0, "6": 0}, **defaults.get("by_player_count", {})} + counts = defaults.get("by_player_count") + if not isinstance(counts, dict): + counts = {} + defaults["by_player_count"] = {**{"4": 0, "5": 0, "6": 0}, **counts} except (OSError, TypeError, ValueError, json.JSONDecodeError): pass return defaults @@ -282,10 +286,8 @@ def has_saved_game() -> bool: def clear_saved_game() -> None: """Delete the in-progress save after a completed game.""" - try: + with contextlib.suppress(OSError): SAVEGAME_PATH.unlink(missing_ok=True) - except OSError: - pass class LudoApp: @@ -314,7 +316,7 @@ def __init__(self) -> None: self.buttons: list[ui.Button] = [] # ``visible_moves`` is rebuilt each frame for human turns. Click # handling uses it so only currently highlighted tokens are clickable. - self.visible_moves = [] + self.visible_moves: list[Move] = [] self.total_players = 4 self.human_count = 1 @@ -457,14 +459,19 @@ def _handle_click(self, pos: tuple[int, int]) -> None: return self.active_field = None - if self.screen == "playing" and self.game and self.renderer: - if self.game.current_player.is_human and self.game.awaiting == "choose_move": - # Human token clicks are only accepted when the engine is - # waiting for a move after a roll. - move = self.renderer.move_at_pos(pos, self.visible_moves) - if move is not None: - self._apply_move(move) - return + if ( + self.screen == "playing" + and self.game + and self.renderer + and self.game.current_player.is_human + and self.game.awaiting == "choose_move" + ): + # Human token clicks are only accepted when the engine is + # waiting for a move after a roll. + move = self.renderer.move_at_pos(pos, self.visible_moves) + if move is not None: + self._apply_move(move) + return for button in self._buttons_for_screen(): if button.contains(pos): @@ -590,18 +597,20 @@ def _start_new_game(self) -> None: # the matching yard on the board. players.append((f"AI {ai_number} ({seat_name(self.total_players, index)})", False)) ai_number += 1 - self.start_game(LudoGame(players, rules, ai_profile=self.ai_profiles[self.ai_profile_index])) - save_game(self.game) + game = LudoGame(players, rules, ai_profile=self.ai_profiles[self.ai_profile_index]) + self.start_game(game) + save_game(game) def _roll_for_human(self) -> None: """Roll for the current human player and handle no-move rolls.""" if self.game is None or self.game.awaiting != "pre_roll": return - forfeited = self.game.record_roll(self.game.rng.randint(1, 6)) + die = self.game.rng.randint(1, 6) + forfeited = self.game.record_roll(die) self.dice_anim_remaining = DICE_ANIM_MS - if not forfeited and not self.game.legal_moves(self.game.last_roll): - self.game.note_no_move(self.game.last_roll or 1) + if not forfeited and not self.game.legal_moves(die): + self.game.note_no_move(die) self._save_if_turn_completed() def _apply_move(self, move) -> None: @@ -791,7 +800,8 @@ def _draw_setup(self) -> None: ui.draw_wrapped( self.window, self.fonts["body"], - "Classic competitive Ludo for 4 to 6 players. Choose how many seats are human; the remaining seats use heuristic AI.", + "Classic competitive Ludo for 4 to 6 players. " + "Choose how many seats are human; the remaining seats use heuristic AI.", pygame.Rect(170, 138, 560, 80), SOFT, ) @@ -837,10 +847,7 @@ def _board_preview(self, total_players: int) -> pygame.Surface: theme.draw_ludo_wallpaper(canvas) renderer = BoardRenderer(BoardLayout.for_player_count(total_players)) renderer.draw_static(canvas) - if total_players == 4: - crop = pygame.Rect(120, 90, 660, 660) - else: - crop = pygame.Rect(95, 55, 710, 710) + crop = pygame.Rect(120, 90, 660, 660) if total_players == 4 else pygame.Rect(95, 55, 710, 710) board = canvas.subsurface(crop).copy() cached = pygame.transform.smoothscale(board, (PREVIEW_SIZE, PREVIEW_SIZE)) self.preview_cache[total_players] = cached @@ -849,7 +856,7 @@ def _board_preview(self, total_players: int) -> pygame.Surface: def _draw_board_previews(self) -> None: """Draw the 4P/5P/6P board thumbnails, highlighting the selection.""" - for count, center in zip((4, 5, 6), PREVIEW_CENTERS): + for count, center in zip((4, 5, 6), PREVIEW_CENTERS, strict=True): thumb = self._board_preview(count) rect = thumb.get_rect(center=center) self.window.blit(thumb, rect) @@ -871,7 +878,8 @@ def _draw_playing(self) -> None: if self.game is None or self.renderer is None: return self.visible_moves = [] - if self.game.current_player.is_human and self.game.awaiting == "choose_move" and self.game.last_roll is not None: + choosing = self.game.current_player.is_human and self.game.awaiting == "choose_move" + if choosing and self.game.last_roll is not None: # Recompute visible moves before drawing so the highlights and # click targets always match the latest engine state. self.visible_moves = self.game.legal_moves(self.game.last_roll) @@ -1030,19 +1038,21 @@ def _draw_game_over(self) -> None: ui.draw_text(self.window, self.fonts["header"], "Lifetime Stats", (stats.x + 18, stats.y + 18), WHITE) games = max(1, int(self.stats.get("games", 0))) average_turns = int(self.stats.get("turn_total", 0)) / games + by_count = {**{"4": 0, "5": 0, "6": 0}, **self.stats.get("by_player_count", {})} lines = [ f"Games: {self.stats.get('games', 0)}", f"Human wins: {self.stats.get('human_wins', 0)}", f"AI wins: {self.stats.get('ai_wins', 0)}", f"Average turns: {average_turns:.1f}", - f"4P / 5P / 6P: {self.stats.get('by_player_count', {}).get('4', 0)} / {self.stats.get('by_player_count', {}).get('5', 0)} / {self.stats.get('by_player_count', {}).get('6', 0)}", + f"4P / 5P / 6P: {by_count['4']} / {by_count['5']} / {by_count['6']}", ] for offset, line in enumerate(lines): ui.draw_text(self.window, self.fonts["body"], line, (stats.x + 18, stats.y + 70 + offset * 42), SOFT) ui.draw_wrapped( self.window, self.fonts["small"], - "Captures, exact home rolls, bonus turns, and safe squares all used the same engine rules as the live game.", + "Captures, exact home rolls, bonus turns, and safe squares " + "all used the same engine rules as the live game.", pygame.Rect(stats.x + 18, stats.y + 316, stats.width - 36, 88), SOFT, ) diff --git a/Python Ludo Game/models.py b/Python Ludo Game/models.py index d357a91..4044acb 100644 --- a/Python Ludo Game/models.py +++ b/Python Ludo Game/models.py @@ -17,6 +17,11 @@ class TokenState: """ steps: int = -1 + # The board length depends on the player count, so the game stamps the + # current layout's finish step onto each token (see + # ``LudoGame._mark_finished_tokens``) before ``finished`` is used. It is + # not serialized; loading a save re-stamps it. + _finished_steps: int | None = None @property def in_yard(self) -> bool: @@ -26,14 +31,9 @@ def in_yard(self) -> bool: @property def finished(self) -> bool: - """True when the token has reached the final home cell. + """True when the token has reached the final home cell.""" - ``TokenState`` does not know the board length by itself, so the game - stores the current layout's finish step on the token before this - property is used. - """ - - return getattr(self, "_finished_steps", None) == self.steps + return self._finished_steps is not None and self._finished_steps == self.steps def to_dict(self) -> dict: """Serialize just the token fields that belong in save files.""" @@ -41,7 +41,7 @@ def to_dict(self) -> dict: return {"steps": self.steps} @classmethod - def from_dict(cls, data: dict) -> "TokenState": + def from_dict(cls, data: dict) -> TokenState: """Rebuild a token from saved JSON data.""" return cls(steps=int(data.get("steps", -1))) @@ -90,7 +90,7 @@ def to_dict(self) -> dict: } @classmethod - def from_dict(cls, data: dict) -> "PlayerState": + def from_dict(cls, data: dict) -> PlayerState: """Rebuild a player and all four tokens from saved JSON data.""" return cls( diff --git a/Python Ludo Game/settings.py b/Python Ludo Game/settings.py index 105e9d0..a861021 100644 --- a/Python Ludo Game/settings.py +++ b/Python Ludo Game/settings.py @@ -10,7 +10,6 @@ import os from pathlib import Path - # --------------------------------------------------------------------------- # Window and timing # --------------------------------------------------------------------------- diff --git a/Python Ludo Game/tests/test_board_geometry.py b/Python Ludo Game/tests/test_board_geometry.py index 7777587..1bb18a7 100644 --- a/Python Ludo Game/tests/test_board_geometry.py +++ b/Python Ludo Game/tests/test_board_geometry.py @@ -20,7 +20,6 @@ import unittest from pathlib import Path - os.environ.setdefault("SDL_VIDEODRIVER", "dummy") GAME_DIR = Path(__file__).resolve().parents[1] diff --git a/Python Ludo Game/tests/test_engine_rules.py b/Python Ludo Game/tests/test_engine_rules.py index d0dcff3..ea0cc78 100644 --- a/Python Ludo Game/tests/test_engine_rules.py +++ b/Python Ludo Game/tests/test_engine_rules.py @@ -6,7 +6,6 @@ import unittest from pathlib import Path - GAME_DIR = Path(__file__).resolve().parents[1] if str(GAME_DIR) not in sys.path: sys.path.insert(0, str(GAME_DIR)) @@ -67,7 +66,9 @@ def test_capture_sends_opponent_home_and_grants_bonus(self) -> None: game = make_game() game.players[0].tokens[0].steps = 0 - game.players[1].tokens[0].steps = game.layout.steps_to_reach(1, game.layout.track_index(0, 3)) + landing = game.layout.track_index(0, 3) + assert landing is not None + game.players[1].tokens[0].steps = game.layout.steps_to_reach(1, landing) result = game.apply_move(game.legal_moves(3)[0]) diff --git a/Python Ludo Game/tests/test_layout_and_ai.py b/Python Ludo Game/tests/test_layout_and_ai.py index b3ba524..77eebfc 100644 --- a/Python Ludo Game/tests/test_layout_and_ai.py +++ b/Python Ludo Game/tests/test_layout_and_ai.py @@ -6,7 +6,6 @@ import unittest from pathlib import Path - GAME_DIR = Path(__file__).resolve().parents[1] if str(GAME_DIR) not in sys.path: sys.path.insert(0, str(GAME_DIR)) @@ -59,6 +58,7 @@ def test_ai_prefers_finishing_move(self) -> None: move = choose_move(game, "tactical", 1) + assert move is not None self.assertEqual(move.token_index, 0) def test_ai_prefers_capture_over_neutral_progress(self) -> None: @@ -69,10 +69,12 @@ def test_ai_prefers_capture_over_neutral_progress(self) -> None: game.players[1].tokens[0].steps = 0 game.players[1].tokens[1].steps = 10 landing = game.layout.track_index(1, 4) + assert landing is not None game.players[0].tokens[0].steps = game.layout.steps_to_reach(0, landing) move = choose_move(game, "aggressive", 4) + assert move is not None self.assertEqual(move.token_index, 0) def test_ai_choice_is_deterministic_for_same_seed(self) -> None: @@ -85,10 +87,11 @@ def test_ai_choice_is_deterministic_for_same_seed(self) -> None: game.players[2].tokens[0].steps = 3 game.players[2].tokens[1].steps = 3 - self.assertEqual( - choose_move(game_a, "defensive", 2).token_index, - choose_move(game_b, "defensive", 2).token_index, - ) + move_a = choose_move(game_a, "defensive", 2) + move_b = choose_move(game_b, "defensive", 2) + + assert move_a is not None and move_b is not None + self.assertEqual(move_a.token_index, move_b.token_index) if __name__ == "__main__": diff --git a/Python Ludo Game/tests/test_ui_smoke.py b/Python Ludo Game/tests/test_ui_smoke.py index 7c4a565..a31139e 100644 --- a/Python Ludo Game/tests/test_ui_smoke.py +++ b/Python Ludo Game/tests/test_ui_smoke.py @@ -7,7 +7,6 @@ import unittest from pathlib import Path - os.environ.setdefault("SDL_VIDEODRIVER", "dummy") GAME_DIR = Path(__file__).resolve().parents[1] @@ -16,9 +15,15 @@ import pygame -import main as ludo_main from game import LudoGame, LudoRules -from main import LudoApp, _name_rect +from main import ( + LudoApp, + _fit_window_size, + _logical_to_window_point, + _name_rect, + _setup_option_row, + _window_to_logical_point, +) class UiSmokeTests(unittest.TestCase): @@ -53,9 +58,6 @@ def test_setup_buttons_expose_player_and_rule_controls(self) -> None: def test_setup_option_rows_keep_labels_values_and_buttons_aligned(self) -> None: """Setup option labels, values, and buttons should share row geometry.""" - row_helper = getattr(ludo_main, "_setup_option_row", None) - self.assertIsNotNone(row_helper) - app = LudoApp() try: buttons = {button.key: button for button in app.setup_buttons()} @@ -66,7 +68,7 @@ def test_setup_option_rows_keep_labels_values_and_buttons_aligned(self) -> None: ] for offset, (row_index, left_key, right_key) in enumerate(rows): with self.subTest(row=row_index): - row = row_helper(row_index) + row = _setup_option_row(row_index) left_button = buttons[left_key].rect right_button = buttons[right_key].rect @@ -77,7 +79,7 @@ def test_setup_option_rows_keep_labels_values_and_buttons_aligned(self) -> None: self.assertEqual(row.value_center[1], right_button.centery) if offset + 1 < len(rows): - next_row = row_helper(row_index + 1) + next_row = _setup_option_row(row_index + 1) self.assertLessEqual(left_button.bottom, next_row.label_y) self.assertLessEqual(right_button.bottom, next_row.label_y) finally: @@ -107,10 +109,7 @@ def test_setup_name_fields_do_not_overlap_buttons_for_large_human_counts(self) - def test_scaled_window_size_fits_short_work_area(self) -> None: """The real window should shrink when the desktop is shorter than the logical layout.""" - fit_window = getattr(ludo_main, "_fit_window_size", None) - self.assertIsNotNone(fit_window) - - window_size = fit_window((1536, 816), (0, 0)) + window_size = _fit_window_size((1536, 816), (0, 0)) self.assertLessEqual(window_size[0], 1536) self.assertLessEqual(window_size[1], 816) @@ -119,21 +118,14 @@ def test_scaled_window_size_fits_short_work_area(self) -> None: def test_scaled_mouse_coordinates_can_reach_bottom_buttons(self) -> None: """Scaled display clicks should map back to logical bottom-button rectangles.""" - fit_window = getattr(ludo_main, "_fit_window_size", None) - logical_to_window = getattr(ludo_main, "_logical_to_window_point", None) - window_to_logical = getattr(ludo_main, "_window_to_logical_point", None) - self.assertIsNotNone(fit_window) - self.assertIsNotNone(logical_to_window) - self.assertIsNotNone(window_to_logical) - - display_size = fit_window((1536, 816), (0, 0)) + display_size = _fit_window_size((1536, 816), (0, 0)) app = LudoApp() try: setup_buttons = {button.key: button for button in app.setup_buttons()} for key in ("resume", "start"): with self.subTest(screen="setup", button=key): - window_point = logical_to_window(setup_buttons[key].rect.center, display_size) - logical_point = window_to_logical(window_point, display_size) + window_point = _logical_to_window_point(setup_buttons[key].rect.center, display_size) + logical_point = _window_to_logical_point(window_point, display_size) self.assertTrue(setup_buttons[key].rect.collidepoint(logical_point)) app.screen = "playing" @@ -142,8 +134,8 @@ def test_scaled_mouse_coordinates_can_reach_bottom_buttons(self) -> None: play_buttons = {button.key: button for button in app._buttons_for_screen()} for key in ("save_quit", "new_game"): with self.subTest(screen="playing", button=key): - window_point = logical_to_window(play_buttons[key].rect.center, display_size) - logical_point = window_to_logical(window_point, display_size) + window_point = _logical_to_window_point(play_buttons[key].rect.center, display_size) + logical_point = _window_to_logical_point(window_point, display_size) self.assertTrue(play_buttons[key].rect.collidepoint(logical_point)) finally: app.running = False diff --git a/Python Ludo Game/tests/test_visual_overhaul.py b/Python Ludo Game/tests/test_visual_overhaul.py index 44d1d0f..7ed3c33 100644 --- a/Python Ludo Game/tests/test_visual_overhaul.py +++ b/Python Ludo Game/tests/test_visual_overhaul.py @@ -7,7 +7,6 @@ import unittest from pathlib import Path - os.environ.setdefault("SDL_VIDEODRIVER", "dummy") GAME_DIR = Path(__file__).resolve().parents[1] @@ -43,6 +42,8 @@ def count_near_color(surface: pygame.Surface, color: tuple[int, int, int], toler class VisualOverhaulTests(unittest.TestCase): """Checks that the game now renders as a themed Ludo board, not a plain diagram.""" + fonts: dict[str, pygame.font.Font] + @classmethod def setUpClass(cls) -> None: """Initialize pygame once for visual smoke tests.""" diff --git a/Python Ludo Game/visual_theme.py b/Python Ludo Game/visual_theme.py index 566fcb4..c9c8538 100644 --- a/Python Ludo Game/visual_theme.py +++ b/Python Ludo Game/visual_theme.py @@ -13,7 +13,6 @@ from settings import INK, WHITE - WALLPAPER_BLUE = (30, 123, 197) WALLPAPER_DEEP = (12, 52, 93) WALLPAPER_TEAL = (18, 126, 142) @@ -34,13 +33,15 @@ def brighten(color: tuple[int, int, int], amount: int) -> tuple[int, int, int]: highlight colors easier to read at call sites. """ - return tuple(min(255, channel + amount) for channel in color) + red, green, blue = color + return (min(255, red + amount), min(255, green + amount), min(255, blue + amount)) def darken(color: tuple[int, int, int], amount: int) -> tuple[int, int, int]: """Return ``color`` with every channel pulled toward black.""" - return tuple(max(0, channel - amount) for channel in color) + red, green, blue = color + return (max(0, red - amount), max(0, green - amount), max(0, blue - amount)) def draw_ludo_wallpaper(surface: pygame.Surface) -> None: @@ -278,7 +279,11 @@ def _draw_pips(surface: pygame.Surface, rect: pygame.Rect, value: int) -> None: def _blend(a: tuple[int, int, int], b: tuple[int, int, int], amount: float) -> tuple[int, int, int]: """Mix two colors by ``amount`` where 0 is all ``a`` and 1 is all ``b``.""" - return tuple(int(a[i] + (b[i] - a[i]) * amount) for i in range(3)) + return ( + int(a[0] + (b[0] - a[0]) * amount), + int(a[1] + (b[1] - a[1]) * amount), + int(a[2] + (b[2] - a[2]) * amount), + ) def _is_light(color: tuple[int, int, int]) -> bool: diff --git a/Python Monopoly Game/ai.py b/Python Monopoly Game/ai.py index c12b4fc..f454d79 100644 --- a/Python Monopoly Game/ai.py +++ b/Python Monopoly Game/ai.py @@ -128,7 +128,7 @@ def property_value(game: MonopolyGame, player: Player, position: int, elif space.kind == "utility": value *= UTILITY_PAIR_MULT if game.count_utilities(player) else UTILITY_SINGLE_MULT - return max(space.mortgage, int(round(value * profile.value_scale))) + return max(space.mortgage, round(value * profile.value_scale)) # -------------------------------------------------------------------------- @@ -241,7 +241,7 @@ def _best_unmortgage(game: MonopolyGame, player: Player) -> int | None: return None mortgaged.sort(key=lambda pos: not _in_monopoly(game, player, pos)) for pos in mortgaged: - cost = int(round(game.board[pos].mortgage * 1.1)) + cost = round(game.board[pos].mortgage * 1.1) if player.cash - cost >= _profile(game).cash_cushion: return pos return None @@ -253,6 +253,7 @@ def _best_unmortgage(game: MonopolyGame, player: Player) -> int | None: def _do_buy_decision(game: MonopolyGame, player: Player) -> None: """Decide whether to buy the space just landed on, or send it to auction.""" position = game.pending_purchase + assert position is not None, "buy decisions only happen with a pending purchase" if _wants_to_buy(game, player, position): _trace(game, player, f"buys {game.board[position].name}; value wins.") game.buy_property() @@ -289,6 +290,7 @@ def _wants_to_buy(game: MonopolyGame, player: Player, position: int) -> bool: def _do_auction(game: MonopolyGame, player: Player) -> None: """Place one bid or pass in the current auction.""" auction = game.auction + assert auction is not None, "only called while an auction is running" position = auction["position"] high = auction["high_bid"] ceiling = _auction_ceiling(game, player, position) @@ -348,8 +350,8 @@ def _missing_one(game: MonopolyGame, player: Player, group: str) -> int | None: def _make_offer(game: MonopolyGame, player: Player, partner: Player, wanted: int) -> dict | None: """Build an offer to win `wanted` that both `player` and `partner` accept.""" - give = {"props": [], "cash": 0, "jail": 0} - get = {"props": [wanted], "cash": 0, "jail": 0} + give: dict = {"props": [], "cash": 0, "jail": 0} + get: dict = {"props": [wanted], "cash": 0, "jail": 0} # The best sweetener is a property that completes a monopoly for the # partner -- but never one from the group the AI is itself completing, # or the AI would trade away the very tile it is bargaining for. @@ -470,7 +472,7 @@ def _owns_some_of_group(game: MonopolyGame, player: Player, position: int) -> bo def _in_monopoly(game: MonopolyGame, player: Player, position: int) -> bool: """True if `position` is part of a colour group `player` fully owns.""" group = _group_of(game, position) - return bool(group) and game.has_monopoly(player, group) + return group is not None and game.has_monopoly(player, group) def _completes_group(game: MonopolyGame, player: Player, position: int) -> bool: diff --git a/Python Monopoly Game/board_render.py b/Python Monopoly Game/board_render.py index e0e6240..42e4b8d 100644 --- a/Python Monopoly Game/board_render.py +++ b/Python Monopoly Game/board_render.py @@ -10,11 +10,22 @@ import pygame -import board_data from settings import ( - BOARD_MARGIN, BOARD_SIZE, CORNER_SIZE, GROUP_COLORS, HIGHLIGHT, INK, - RAIL_COLOR, TAX_COLOR, TILE_FACE, BOARD_FACE, UTILITY_COLOR, WHITE, - TOKEN_COLORS, HOUSE_COLOR, HOTEL_COLOR, SOFT, + BOARD_FACE, + BOARD_MARGIN, + BOARD_SIZE, + CORNER_SIZE, + GROUP_COLORS, + HIGHLIGHT, + HOTEL_COLOR, + HOUSE_COLOR, + INK, + RAIL_COLOR, + TAX_COLOR, + TILE_FACE, + TOKEN_COLORS, + UTILITY_COLOR, + WHITE, ) # Pre-computed board geometry (the board never moves or resizes). @@ -185,6 +196,7 @@ def draw(self, target: pygame.Surface, game, fonts: dict, """ if self._base is None: self.build_base(fonts) + assert self._base is not None # build_base always fills the cache target.blit(self._base, (_OX, _OY)) # Ownership: a thin border in the owner's colour, plus mortgage shading. diff --git a/Python Monopoly Game/cards.py b/Python Monopoly Game/cards.py index e65e5d3..949c425 100644 --- a/Python Monopoly Game/cards.py +++ b/Python Monopoly Game/cards.py @@ -15,7 +15,6 @@ from board_data import RAILROAD_POSITIONS, UTILITY_POSITIONS - # -------------------------------------------------------------------------- # Card definitions -- the standard 16 Chance and 16 Community Chest cards # -------------------------------------------------------------------------- diff --git a/Python Monopoly Game/game.py b/Python Monopoly Game/game.py index d4ca0fe..9d3f5d2 100644 --- a/Python Monopoly Game/game.py +++ b/Python Monopoly Game/game.py @@ -26,8 +26,14 @@ import cards as cards_module from player import Player from settings import ( - BANK_HOTELS, BANK_HOUSES, GO_SALARY, INCOME_TAX, JAIL_FINE, JAIL_POSITION, - LUXURY_TAX, MAX_JAIL_TURNS, STARTING_CASH, + BANK_HOTELS, + BANK_HOUSES, + GO_SALARY, + INCOME_TAX, + JAIL_FINE, + JAIL_POSITION, + LUXURY_TAX, + MAX_JAIL_TURNS, ) # Rent a railroad charges for 1/2/3/4 railroads owned by the same player. @@ -245,9 +251,8 @@ def _raise_funds(self, player: Player, target: int) -> None: while player.cash < target: best = None for pos in self.properties_of(player): - if self.houses.get(pos, 0) > 0: - if best is None or self.houses[pos] > self.houses[best]: - best = pos + if self.houses.get(pos, 0) > 0 and (best is None or self.houses[pos] > self.houses[best]): + best = pos if best is None: break self.sell_house(best, forced=True) @@ -668,6 +673,7 @@ def auction_pass(self) -> None: def _advance_auction(self) -> None: """Move the auction to the next bidder, or finish it if it is settled.""" auction = self.auction + assert auction is not None, "only called while an auction is running" high_bidder = auction["high_bidder"] # Find the next active bidder who is not already the high bidder. contenders = [idx for idx in auction["active"] if idx != high_bidder] @@ -690,6 +696,7 @@ def _advance_auction(self) -> None: def _finish_auction(self) -> None: """Award the auctioned property and return play to the current player.""" auction = self.auction + assert auction is not None, "only called while an auction is running" position = auction["position"] winner_index = auction["high_bidder"] if winner_index is not None and auction["high_bid"] > 0: @@ -815,7 +822,7 @@ def _unmortgage_blocker(self, player: Player, position: int) -> str | None: return blocker if position not in self.mortgaged: return "This title is not mortgaged." - cost = int(round(self.board[position].mortgage * 1.1)) + cost = round(self.board[position].mortgage * 1.1) if player.cash < cost: return f"Need ${cost} to lift this mortgage." return None @@ -889,7 +896,7 @@ def unmortgage(self, position: int) -> bool: player = self.current_player if not self.can_unmortgage(player, position): return False - cost = int(round(self.board[position].mortgage * 1.1)) + cost = round(self.board[position].mortgage * 1.1) if player.cash < cost: return False player.cash -= cost @@ -1031,7 +1038,7 @@ def to_dict(self) -> dict: } @classmethod - def from_dict(cls, data: dict) -> "MonopolyGame": + def from_dict(cls, data: dict) -> MonopolyGame: """Rebuild a game from a `to_dict()` snapshot (used to resume a game).""" save_version = int(data.get("save_version", 1)) if save_version not in (1, SAVE_VERSION): diff --git a/Python Monopoly Game/main.py b/Python Monopoly Game/main.py index f2007ed..ac8d214 100644 --- a/Python Monopoly Game/main.py +++ b/Python Monopoly Game/main.py @@ -11,25 +11,33 @@ from __future__ import annotations +import contextlib import json import os import random import sys import time +from collections.abc import Callable from pathlib import Path -from typing import Callable import pygame import ai -import board_data import board_render import ui -from game import MonopolyGame from board_data import THEME_ORDER +from game import MonopolyGame from settings import ( - AI_TURN_DELAY_MS, BG, FPS, SAVE_DIR, SAVEGAME_PATH, SCREEN_HEIGHT, - SCREEN_WIDTH, STATS_PATH, TOKEN_HOP_SPEED, WINDOW_TITLE, + AI_TURN_DELAY_MS, + BG, + FPS, + SAVE_DIR, + SAVEGAME_PATH, + SCREEN_HEIGHT, + SCREEN_WIDTH, + STATS_PATH, + TOKEN_HOP_SPEED, + WINDOW_TITLE, ) # Action-bar button slots: two columns by four rows inside the bottom panel. @@ -111,10 +119,8 @@ def has_saved_game() -> bool: def _clear_saved_game() -> None: """Delete the saved game (called when a game finishes).""" - try: + with contextlib.suppress(OSError): SAVEGAME_PATH.unlink(missing_ok=True) - except OSError: - pass # -------------------------------------------------------------------------- @@ -196,11 +202,24 @@ def start_game(self, game: MonopolyGame) -> None: # Autosave baseline: a save fires when the turn (current player) moves. self._autosave_current = game.current + def _require_game(self) -> MonopolyGame: + """Return the active game on code paths that only run mid-game. + + ``self.game`` is Optional because the setup screen has no game yet; + the playing/over screens always do. Binding through this helper keeps + that invariant explicit (and visible to the type checker) instead of + sprinkling ``self.game`` derefs that could hide a real None bug. + """ + game = self.game + assert game is not None, "no active game on this screen" + return game + def _end_game(self) -> None: """Record the result and move to the end screen.""" + game = self._require_game() self.screen = "over" self.stats["games"] += 1 - if self.game.winner is not None and self.game.players[self.game.winner].is_human: + if game.winner is not None and game.players[game.winner].is_human: self.stats["human_wins"] += 1 _atomic_write(STATS_PATH, self.stats) _clear_saved_game() @@ -247,15 +266,16 @@ def _update(self, autotest: bool) -> None: """Advance the game: animate visuals, then step the AI if it is its turn.""" if self.screen != "playing" or self.game is None: return - if self.game.phase == "game_over": + game = self.game + if game.phase == "game_over": self._end_game() return # Autosave once per completed turn: the engine advances `current` to # the next player when a turn ends, for both humans and AI. - if self.game.current != self._autosave_current: - self._autosave_current = self.game.current - save_game(self.game) + if game.current != self._autosave_current: + self._autosave_current = game.current + save_game(game) elapsed = self.clock.get_time() @@ -264,14 +284,14 @@ def _update(self, autotest: bool) -> None: # normal forward roll (jail, some cards) slide directly instead. On # the first frame after the game starts, seed each token at its space. if not self.token_px: - for player in self.game.players: + for player in game.players: self.token_px[player.index] = board_render.token_center( player.position, player.index) self.token_pos[player.index] = player.position self.token_route[player.index] = [] animating_tokens = False budget = TOKEN_HOP_SPEED * elapsed / 1000.0 - for player in self.game.players: + for player in game.players: idx = player.index if player.position != self.token_pos.get(idx): old = self.token_pos.get(idx, player.position) @@ -311,17 +331,17 @@ def _update(self, autotest: bool) -> None: # Dice shuffle: when the engine reports a new roll, randomise the # displayed dice for a short moment before settling on the real value. - if self.game.dice != self.last_game_dice and self.game.dice != (0, 0): + if game.dice != self.last_game_dice and game.dice != (0, 0): self.dice_anim_remaining_ms = 480 - self.last_game_dice = self.game.dice + self.last_game_dice = game.dice if self.dice_anim_remaining_ms > 0: self.dice_anim_remaining_ms -= elapsed self.dice_show = (random.randint(1, 6), random.randint(1, 6)) else: - self.dice_show = self.game.dice + self.dice_show = game.dice # AI driver: pace it, and pause while an animation is still playing. - actor = self.game.players[self.game.actor()] + actor = game.players[game.actor()] if actor.is_human: return # wait for the human's clicks if not autotest and (animating_tokens or self.dice_anim_remaining_ms > 0): @@ -329,7 +349,7 @@ def _update(self, autotest: bool) -> None: self.ai_timer += elapsed if autotest or self.ai_timer >= AI_TURN_DELAY_MS: self.ai_timer = 0 - ai.take_action(self.game) + ai.take_action(game) # ------------------------------------------------------------------ # Input @@ -341,7 +361,8 @@ def _handle_click(self, pos: tuple[int, int]) -> None: elif self.screen == "over": self._click_button(pos) elif self.screen == "playing": - actor = self.game.players[self.game.actor()] + game = self._require_game() + actor = game.players[game.actor()] if not actor.is_human: return # ignore clicks during AI turns if self.mode in ("build", "sell", "mortgage"): @@ -392,28 +413,32 @@ def _type_into_name_field(self, event: pygame.event.Event) -> None: def _click_board(self, pos: tuple[int, int]) -> None: """In build/sell/mortgage mode, act on the board space clicked.""" + game = self._require_game() for position in range(40): if board_render.space_rect(position).collidepoint(pos): if self.mode == "build": - self.game.build_house(position) + game.build_house(position) elif self.mode == "sell": - self.game.sell_house(position) + game.sell_house(position) elif self.mode == "mortgage": - if position in self.game.mortgaged: - self.game.unmortgage(position) + if position in game.mortgaged: + game.unmortgage(position) else: - self.game.mortgage(position) + game.mortgage(position) return def _click_trade(self, pos: tuple[int, int]) -> None: """Handle clicks inside the trade-building dialog.""" if self._click_button(pos): return - layout = ui.trade_layout(self.game, self.trade) + trade = self.trade + if trade is None: + return + layout = ui.trade_layout(self._require_game(), trade) for key, side in (("give_rows", "give"), ("get_rows", "get")): for row, position in layout[key]: if row.collidepoint(pos): - props = self.trade[side]["props"] + props = trade[side]["props"] if position in props: props.remove(position) else: @@ -476,7 +501,7 @@ def _on_setup_button(self, key: str) -> bool: def _on_turn_button(self, key: str) -> bool: """Handle in-turn actions and auctions; True if `key` belonged here.""" - game = self.game + game = self._require_game() if key == "roll": game.roll_dice() elif key == "jail_pay": @@ -502,8 +527,10 @@ def _on_turn_button(self, key: str) -> bool: self.screen = "setup" self.game = None elif key == "bid": - step = max(10, game.board[game.auction["position"]].price // 20) - game.auction_bid(game.auction["high_bid"] + step) + auction = game.auction + assert auction is not None, "bid button only exists during an auction" + step = max(10, game.board[auction["position"]].price // 20) + game.auction_bid(auction["high_bid"] + step) elif key == "pass": game.auction_pass() else: @@ -512,7 +539,7 @@ def _on_turn_button(self, key: str) -> bool: def _on_asset_button(self, key: str) -> bool: """Handle the title/asset manager; True if `key` belonged here.""" - game = self.game + game = self._require_game() if key == "assets": self._open_assets() elif key.startswith("asset_title_"): @@ -535,7 +562,7 @@ def _on_asset_button(self, key: str) -> bool: def _on_trade_button(self, key: str) -> bool: """Handle the trade dialog; True if `key` belonged here.""" - game = self.game + game = self._require_game() if key == "trade": self._open_trade() elif key == "trade_partner_prev": @@ -551,10 +578,12 @@ def _on_trade_button(self, key: str) -> bool: elif key == "get_cash_down": self._adjust_trade_cash("get", -50) elif key == "trade_propose": - if game.propose_trade(self.trade): + trade = self.trade + assert trade is not None, "propose button only exists inside the dialog" + if game.propose_trade(trade): self.mode = "normal" else: - self.message = game.trade_error(self.trade) or "That trade is not legal." + self.message = game.trade_error(trade) or "That trade is not legal." elif key == "trade_cancel": self.mode = "normal" elif key == "trade_accept": @@ -572,7 +601,7 @@ def _begin_new_game(self) -> None: specs = [] for i in range(4): human = i < self.human_count - if human: + if human: # noqa: SIM108 -- the ternary form buries the `or` fallback # Fall back to "Player N" if the name box was left blank. name = self.name_fields[i].strip() or f"Player {i + 1}" else: @@ -584,8 +613,9 @@ def _begin_new_game(self) -> None: def _open_trade(self) -> None: """Start building a trade offer from the current human player.""" - me = self.game.current - others = [p.index for p in self.game.players + game = self._require_game() + me = game.current + others = [p.index for p in game.players if p.index != me and not p.bankrupt] if not others: self.message = "There is no one to trade with." @@ -603,7 +633,8 @@ def _open_trade(self) -> None: def _open_assets(self) -> None: """Open the title manager with the first owned title selected.""" - owned = sorted(self.game.properties_of(self.game.current_player)) + game = self._require_game() + owned = sorted(game.properties_of(game.current_player)) if not owned: self.message = "You do not own any titles yet." return @@ -616,8 +647,9 @@ def _manage_asset(self, action: str, operation: Callable[[int], bool]) -> None: if self.asset_position is None: self.message = "Choose a title first." return - player = self.game.current_player - status = self.game.asset_actions_for(player, self.asset_position)[action] + game = self._require_game() + player = game.current_player + status = game.asset_actions_for(player, self.asset_position)[action] if not status["allowed"]: self.message = status["reason"] return @@ -628,24 +660,32 @@ def _manage_asset(self, action: str, operation: Callable[[int], bool]) -> None: def _cycle_trade_partner(self, step: int) -> None: """Move the trade to another eligible partner; clear chosen items.""" - others = [p.index for p in self.game.players - if p.index != self.trade["from"] and not p.bankrupt] + game = self._require_game() + trade = self.trade + if trade is None: + return + others = [p.index for p in game.players + if p.index != trade["from"] and not p.bankrupt] if not others: return - current = others.index(self.trade["to"]) if self.trade["to"] in others else 0 - self.trade["to"] = others[(current + step) % len(others)] - self.trade["give"]["props"].clear() - self.trade["get"]["props"].clear() + current = others.index(trade["to"]) if trade["to"] in others else 0 + trade["to"] = others[(current + step) % len(others)] + trade["give"]["props"].clear() + trade["get"]["props"].clear() # A new partner usually has a different property list -- reset scroll # so the next dialog opens at the top of both columns. - self.trade["give_scroll"] = 0 - self.trade["get_scroll"] = 0 + trade["give_scroll"] = 0 + trade["get_scroll"] = 0 def _adjust_trade_cash(self, side: str, delta: int) -> None: """Step the cash on one side of the in-progress trade offer.""" - idx = self.trade["from"] if side == "give" else self.trade["to"] - cap = self.game.players[idx].cash - self.trade[side]["cash"] = max(0, min(cap, self.trade[side]["cash"] + delta)) + game = self._require_game() + trade = self.trade + if trade is None: + return + idx = trade["from"] if side == "give" else trade["to"] + cap = game.players[idx].cash + trade[side]["cash"] = max(0, min(cap, trade[side]["cash"] + delta)) def _scroll_trade(self, mouse_pos: tuple[int, int], wheel_dy: int) -> None: """Scroll one side of the trade dialog under the mouse cursor. @@ -654,24 +694,27 @@ def _scroll_trade(self, mouse_pos: tuple[int, int], wheel_dy: int) -> None: (decreases the scroll offset). The offset is clamped so the player cannot scroll past the last full window. """ - if self.trade is None: + trade = self.trade + if trade is None: return - panel = ui.trade_layout(self.game, self.trade)["panel"] + game = self._require_game() + panel = ui.trade_layout(game, trade)["panel"] side = "give" if mouse_pos[0] < panel.centerx else "get" - owner_idx = self.trade["from"] if side == "give" else self.trade["to"] - owner = self.game.players[owner_idx] - max_scroll = max(0, len(self.game.properties_of(owner)) + owner_idx = trade["from"] if side == "give" else trade["to"] + owner = game.players[owner_idx] + max_scroll = max(0, len(game.properties_of(owner)) - ui.TRADE_VISIBLE_ROWS) key = f"{side}_scroll" - current = self.trade.get(key, 0) + current = trade.get(key, 0) step = -1 if wheel_dy > 0 else 1 - self.trade[key] = max(0, min(current + step, max_scroll)) + trade[key] = max(0, min(current + step, max_scroll)) def _handle_shortcut(self, key: int) -> None: """Run common human actions from the keyboard when they are available.""" - if self.game is None: + game = self.game + if game is None: return - actor = self.game.players[self.game.actor()] + actor = game.players[game.actor()] if not actor.is_human or self.mode in ("assets", "trade"): return key_to_action = { @@ -721,56 +764,59 @@ def _draw(self) -> None: def _draw_playing(self, mouse: tuple[int, int]) -> None: """Draw the board, side panel, action bar and any open dialog.""" + game = self._require_game() + renderer = self.renderer + assert renderer is not None, "renderer exists whenever a game does" self.window.fill(BG) # Yellow rings on the spaces the human can act on in board-click modes. highlights: list = [] if self.mode == "build": - human = self.game.current_player + human = game.current_player highlights = [pos for pos in range(40) - if self.game.can_build(human, pos)] + if game.can_build(human, pos)] elif self.mode == "mortgage": - human = self.game.current_player - highlights = [pos for pos in self.game.properties_of(human) - if self.game.houses.get(pos, 0) == 0] + human = game.current_player + highlights = [pos for pos in game.properties_of(human) + if game.houses.get(pos, 0) == 0] elif self.mode == "sell": - human = self.game.current_player - highlights = [pos for pos in self.game.properties_of(human) - if self.game.can_sell_building(human, pos)] + human = game.current_player + highlights = [pos for pos in game.properties_of(human) + if game.can_sell_building(human, pos)] - self.renderer.draw(self.window, self.game, self.fonts, - token_pixels=self.token_px, - highlight_positions=highlights) - ui.draw_panel(self.window, self.fonts, self.game, mouse) + renderer.draw(self.window, game, self.fonts, + token_pixels=self.token_px, + highlight_positions=highlights) + ui.draw_panel(self.window, self.fonts, game, mouse) board_centre = (20 + (SCREEN_HEIGHT - 40) // 2, 20 + (SCREEN_HEIGHT - 40) // 2) - ui.draw_center_card(self.window, self.fonts, self.game) + ui.draw_center_card(self.window, self.fonts, game) if self.dice_show != (0, 0): ui.draw_dice(self.window, self.fonts, self.dice_show, board_centre) hovered = self._hovered_board_space(mouse) if hovered is not None: - ui.draw_property_detail(self.window, self.fonts, self.game, hovered) + ui.draw_property_detail(self.window, self.fonts, game, hovered) ui.draw_action_bar(self.window, self.fonts, self.buttons, self._prompt(), mouse) # Modal dialogs on top of everything else. - if self.game.awaiting == "auction": - ui.draw_auction(self.window, self.fonts, self.game, self.buttons, mouse) + if game.awaiting == "auction": + ui.draw_auction(self.window, self.fonts, game, self.buttons, mouse) elif self.mode == "assets": - ui.draw_assets(self.window, self.fonts, self.game, self.asset_position, + ui.draw_assets(self.window, self.fonts, game, self.asset_position, self.buttons, mouse) elif self.mode == "trade" and self.trade is not None: - ui.draw_trade(self.window, self.fonts, self.game, self.trade, + ui.draw_trade(self.window, self.fonts, game, self.trade, self.buttons, mouse) - elif self.game.awaiting == "trade_response" and \ - self.game.players[self.game.actor()].is_human: - ui.draw_trade_response(self.window, self.fonts, self.game, + elif game.awaiting == "trade_response" and \ + game.players[game.actor()].is_human: + ui.draw_trade_response(self.window, self.fonts, game, self.buttons, mouse) def _prompt(self) -> str: """A short instruction line for the action bar.""" - game = self.game + game = self._require_game() actor = game.players[game.actor()] if not actor.is_human: # AI player names already carry an "(AI)" suffix -- don't add it @@ -791,7 +837,7 @@ def _prompt(self) -> str: if actor.in_jail: return f"{actor.name}: you are in Jail. Pay, use a card, or roll." return f"{actor.name}: your turn. Roll the dice." - if state == "buy_or_auction": + if state == "buy_or_auction" and game.pending_purchase is not None: space = game.board[game.pending_purchase] return f"You landed on {space.name} (${space.price}). Buy it or auction it." if state == "auction": @@ -824,7 +870,7 @@ def _playing_buttons(self) -> list: Each dialog owns a small builder; this dispatcher just picks the one matching the current mode or engine state. """ - game = self.game + game = self._require_game() actor = game.players[game.actor()] if not actor.is_human: return [] @@ -843,7 +889,7 @@ def _playing_buttons(self) -> list: def _trade_dialog_buttons(self) -> list: """Buttons for the trade-building dialog.""" - game = self.game + game = self._require_game() panel = ui.trade_layout(game, self.trade)["panel"] return [ # Arrows are placed symmetrically and well clear of the @@ -873,7 +919,7 @@ def _trade_dialog_buttons(self) -> list: def _asset_dialog_buttons(self, actor) -> list: """Asset-manager buttons: every title row selects a deed; the action buttons use engine-provided availability and blocker reasons.""" - game = self.game + game = self._require_game() layout = ui.asset_layout(game, actor) buttons = [] for row, position in layout["title_rows"]: @@ -913,11 +959,13 @@ def _trade_response_buttons(self) -> list: def _auction_buttons(self, actor) -> list: """Bid/Pass buttons for the auction dialog.""" - game = self.game + game = self._require_game() + auction = game.auction + assert auction is not None, "auction buttons only build during an auction" panel = pygame.Rect(0, 0, 460, 280) panel.center = (SCREEN_HEIGHT // 2, SCREEN_HEIGHT // 2) - step = max(10, game.board[game.auction["position"]].price // 20) - can_bid = (game.auction["high_bid"] + step) <= actor.cash + step = max(10, game.board[auction["position"]].price // 20) + can_bid = (auction["high_bid"] + step) <= actor.cash return [ ui.Button((panel.centerx - 200, panel.bottom - 52, 190, 40), f"Bid +${step}", "bid", enabled=can_bid, @@ -928,7 +976,7 @@ def _auction_buttons(self, actor) -> list: def _action_bar_buttons(self, actor) -> list: """The normal action bar for pre-roll, buy-or-auction, and post-roll.""" - game = self.game + game = self._require_game() buttons = [] state = game.awaiting can_sell = any(game.can_sell_building(actor, pos) @@ -951,7 +999,7 @@ def _action_bar_buttons(self, actor) -> list: buttons.append(ui.Button(_bar_rect(4), "Trade", "trade")) buttons.append(ui.Button(_bar_rect(5), "Save & Quit", "save_quit")) buttons.append(ui.Button(_bar_rect(6), "Assets", "assets")) - elif state == "buy_or_auction": + elif state == "buy_or_auction" and game.pending_purchase is not None: space = game.board[game.pending_purchase] buttons.append(ui.Button(_bar_rect(0), f"Buy (${space.price})", "buy", enabled=actor.cash >= space.price, @@ -978,10 +1026,8 @@ def main() -> None: try: icon_path = _resource_path("monopoly_icon.png") if os.path.exists(icon_path): - try: + with contextlib.suppress(pygame.error): pygame.display.set_icon(pygame.image.load(icon_path)) - except pygame.error: - pass app = MonopolyApp() autotest = os.environ.get("MONOPOLY_AUTOTEST") diff --git a/Python Monopoly Game/player.py b/Python Monopoly Game/player.py index 4541dc1..e9ab829 100644 --- a/Python Monopoly Game/player.py +++ b/Python Monopoly Game/player.py @@ -60,7 +60,7 @@ def to_dict(self) -> dict: } @classmethod - def from_dict(cls, data: dict) -> "Player": + def from_dict(cls, data: dict) -> Player: """Rebuild a Player from a `to_dict()` snapshot (used when resuming).""" return cls( index=int(data["index"]), diff --git a/Python Monopoly Game/settings.py b/Python Monopoly Game/settings.py index ae0d8b1..3c308d3 100644 --- a/Python Monopoly Game/settings.py +++ b/Python Monopoly Game/settings.py @@ -16,7 +16,6 @@ import os from pathlib import Path - # -------------------------------------------------------------------------- # Window and timing # -------------------------------------------------------------------------- diff --git a/Python Monopoly Game/tests/test_ai_trades.py b/Python Monopoly Game/tests/test_ai_trades.py index 6252bbf..ab8001c 100644 --- a/Python Monopoly Game/tests/test_ai_trades.py +++ b/Python Monopoly Game/tests/test_ai_trades.py @@ -11,7 +11,6 @@ import unittest from pathlib import Path - GAME_DIR = Path(__file__).resolve().parents[1] if str(GAME_DIR) not in sys.path: sys.path.insert(0, str(GAME_DIR)) diff --git a/Python Monopoly Game/tests/test_engine_regressions.py b/Python Monopoly Game/tests/test_engine_regressions.py index c2467b3..79d4287 100644 --- a/Python Monopoly Game/tests/test_engine_regressions.py +++ b/Python Monopoly Game/tests/test_engine_regressions.py @@ -6,7 +6,6 @@ import unittest from pathlib import Path - GAME_DIR = Path(__file__).resolve().parents[1] if str(GAME_DIR) not in sys.path: sys.path.insert(0, str(GAME_DIR)) @@ -26,7 +25,8 @@ def make_game() -> MonopolyGame: def rig_rolls(game: MonopolyGame, *rolls: int) -> None: """Force the next dice values so a test can land on chosen spaces.""" pending = list(rolls) - game.rng.randint = lambda _start, _end: pending.pop(0) + # Deliberate monkeypatch of the game's RNG; tests own this game object. + game.rng.randint = lambda _start, _end: pending.pop(0) # type: ignore[method-assign, assignment] class EngineRegressionTests(unittest.TestCase): @@ -80,7 +80,7 @@ def test_mortgage_and_unmortgage_round_trip_uses_interest(self) -> None: self.assertEqual( player.cash, cash_before + game.board[5].mortgage - - int(round(game.board[5].mortgage * 1.1)), + - round(game.board[5].mortgage * 1.1), ) def test_save_load_round_trip_keeps_property_state_and_ai_profile(self) -> None: diff --git a/Python Monopoly Game/tests/test_foundation_improvements.py b/Python Monopoly Game/tests/test_foundation_improvements.py index b3507e3..517e5b0 100644 --- a/Python Monopoly Game/tests/test_foundation_improvements.py +++ b/Python Monopoly Game/tests/test_foundation_improvements.py @@ -7,7 +7,6 @@ import unittest from pathlib import Path - GAME_DIR = Path(__file__).resolve().parents[1] if str(GAME_DIR) not in sys.path: sys.path.insert(0, str(GAME_DIR)) @@ -71,6 +70,7 @@ def test_bankruptcy_to_bank_starts_auction_for_released_assets(self) -> None: self.assertTrue(player.bankrupt) self.assertEqual(game.awaiting, "auction") + assert game.auction is not None self.assertEqual(game.auction["position"], 1) self.assertEqual(game.auction["context"], "bankruptcy") self.assertIn("Bank bankruptcy auction", game.auction_context_message()) @@ -89,6 +89,7 @@ def test_bankruptcy_auctions_continue_until_every_bank_asset_is_offered(self) -> game.auction_pass() self.assertEqual(game.awaiting, "auction") + assert game.auction is not None self.assertEqual(game.auction["position"], 3) def test_mortgaged_trade_property_charges_transfer_interest_immediately(self) -> None: diff --git a/Python Monopoly Game/tests/test_ui_readability.py b/Python Monopoly Game/tests/test_ui_readability.py index 4a11ad7..bb4393e 100644 --- a/Python Monopoly Game/tests/test_ui_readability.py +++ b/Python Monopoly Game/tests/test_ui_readability.py @@ -6,7 +6,6 @@ import unittest from pathlib import Path - GAME_DIR = Path(__file__).resolve().parents[1] if str(GAME_DIR) not in sys.path: sys.path.insert(0, str(GAME_DIR)) diff --git a/Python Monopoly Game/tests/test_ui_wiring.py b/Python Monopoly Game/tests/test_ui_wiring.py index 779ff6b..c8e47ad 100644 --- a/Python Monopoly Game/tests/test_ui_wiring.py +++ b/Python Monopoly Game/tests/test_ui_wiring.py @@ -7,7 +7,6 @@ import unittest from pathlib import Path - os.environ.setdefault("SDL_VIDEODRIVER", "dummy") GAME_DIR = Path(__file__).resolve().parents[1] diff --git a/Python Monopoly Game/ui.py b/Python Monopoly Game/ui.py index 69e137a..a82d711 100644 --- a/Python Monopoly Game/ui.py +++ b/Python Monopoly Game/ui.py @@ -13,9 +13,21 @@ import board_data from cards import format_card_text from settings import ( - BG, DANGER, GOLD, GROUP_COLORS, HIGHLIGHT, INK, PANEL_BG, PANEL_CARD, - PANEL_WIDTH, PANEL_X, SCREEN_HEIGHT, SCREEN_WIDTH, SOFT, SUCCESS, - TOKEN_COLORS, TOKEN_NAMES, WHITE, + BG, + DANGER, + GOLD, + GROUP_COLORS, + HIGHLIGHT, + INK, + PANEL_BG, + PANEL_CARD, + PANEL_WIDTH, + PANEL_X, + SCREEN_HEIGHT, + SCREEN_WIDTH, + SOFT, + SUCCESS, + WHITE, ) @@ -57,7 +69,7 @@ def draw(self, surface: pygame.Surface, fonts: dict, mouse: tuple[int, int]) -> hovered = self.enabled and self.rect.collidepoint(mouse) fill = self.color if self.enabled else (44, 52, 48) if hovered: - fill = tuple(min(255, c + 28) for c in fill) + fill = (min(255, fill[0] + 28), min(255, fill[1] + 28), min(255, fill[2] + 28)) pygame.draw.rect(surface, fill, self.rect, border_radius=8) pygame.draw.rect(surface, INK, self.rect, 2, border_radius=8) ink = WHITE if self.enabled else SOFT @@ -563,7 +575,7 @@ def trade_layout(game, trade) -> dict: panel.center = (SCREEN_HEIGHT // 2, SCREEN_HEIGHT // 2) human = game.players[trade["from"]] partner = game.players[trade["to"]] - layout = { + layout: dict = { "panel": panel, "give_rows": [], "get_rows": [], @@ -596,7 +608,6 @@ def draw_trade(surface, fonts, game, trade, buttons, mouse) -> None: panel = layout["panel"] pygame.draw.rect(surface, PANEL_BG, panel, border_radius=12) pygame.draw.rect(surface, GOLD, panel, 3, border_radius=12) - human = game.players[trade["from"]] partner = game.players[trade["to"]] _text(surface, fonts, "big", "PROPOSE A TRADE", diff --git a/Python Solitaire Game/solitaire.py b/Python Solitaire Game/solitaire.py index f027c6d..98ad05d 100644 --- a/Python Solitaire Game/solitaire.py +++ b/Python Solitaire Game/solitaire.py @@ -7,6 +7,7 @@ 2. A tkinter app that draws the cards and handles input. """ +import contextlib import json import os import random @@ -15,8 +16,7 @@ import tkinter as tk from dataclasses import dataclass from tkinter import messagebox -from typing import List, Optional, Tuple - +from typing import Any # Card size in pixels. CARD_WIDTH = 88 @@ -158,7 +158,7 @@ class Selection: source_type: str pile_index: int card_index: int - cards: List[Card] + cards: list[Card] @dataclass @@ -168,7 +168,7 @@ class HintMove: source_type: str source_index: int source_card_index: int - cards: List[Card] + cards: list[Card] destination_type: str destination_index: int stock_clicks: int @@ -179,9 +179,9 @@ class HintMove: class AnimationState: """Information needed to draw cards moving between two positions.""" - cards: List[Card] - start_positions: List[Tuple[float, float]] - end_positions: List[Tuple[float, float]] + cards: list[Card] + start_positions: list[tuple[float, float]] + end_positions: list[tuple[float, float]] destination_type: str destination_index: int completion_message: str @@ -211,10 +211,10 @@ def __init__(self, high_score_path: str) -> None: # These values are reset for each new deal. self.score = 0 - self.stock: List[Card] = [] - self.waste: List[Card] = [] - self.foundations: List[List[Card]] = [[] for _ in range(FOUNDATION_COUNT)] - self.tableau: List[List[Card]] = [[] for _ in range(TABLEAU_COLUMNS)] + self.stock: list[Card] = [] + self.waste: list[Card] = [] + self.foundations: list[list[Card]] = [[] for _ in range(FOUNDATION_COUNT)] + self.tableau: list[list[Card]] = [[] for _ in range(TABLEAU_COLUMNS)] self.won = False self.lost = False @@ -252,7 +252,7 @@ def new_game(self) -> None: for card in self.stock: card.face_up = False - def create_shuffled_deck(self) -> List[Card]: + def create_shuffled_deck(self) -> list[Card]: """Create a normal 52-card deck and shuffle it in place.""" deck = [Card(suit=suit, rank=rank) for suit in FOUNDATION_SUITS for rank in range(1, 14)] random.shuffle(deck) @@ -261,7 +261,7 @@ def create_shuffled_deck(self) -> List[Card]: def load_saved_data(self) -> dict: """Read saved high score and statistics from JSON, if possible.""" try: - with open(self.high_score_path, "r", encoding="utf-8") as file: + with open(self.high_score_path, encoding="utf-8") as file: data = json.load(file) except (OSError, ValueError, TypeError, json.JSONDecodeError): # If the file is missing or broken, silently start from zeros. @@ -347,7 +347,7 @@ def restore_snapshot(self, snapshot: dict) -> None: self.tableau = [self.deserialize_cards(column) for column in snapshot["tableau"]] self.save_high_score() - def serialize_cards(self, cards: List[Card]) -> List[dict]: + def serialize_cards(self, cards: list[Card]) -> list[dict]: """Turn Card objects into plain dictionaries that JSON can store.""" return [ { @@ -358,7 +358,7 @@ def serialize_cards(self, cards: List[Card]) -> List[dict]: for card in cards ] - def deserialize_cards(self, serialized_cards: List[dict]) -> List[Card]: + def deserialize_cards(self, serialized_cards: list[dict]) -> list[Card]: """Rebuild Card objects from saved dictionaries.""" return [ Card( @@ -389,26 +389,26 @@ def draw_from_stock(self) -> str: # No cards anywhere means no draw action is possible. return "empty" - def selection_from_waste(self) -> Optional[Selection]: + def selection_from_waste(self) -> Selection | None: """Return the selectable top waste card, if one exists.""" if not self.waste: return None return Selection("waste", 0, len(self.waste) - 1, [self.waste[-1]]) - def selection_from_foundation(self, foundation_index: int) -> Optional[Selection]: + def selection_from_foundation(self, foundation_index: int) -> Selection | None: """Return the top card of a foundation as a Selection.""" pile = self.foundations[foundation_index] if not pile: return None return Selection("foundation", foundation_index, len(pile) - 1, [pile[-1]]) - def selection_from_tableau(self, column_index: int, card_index: int) -> Optional[Selection]: + def selection_from_tableau(self, column_index: int, card_index: int) -> Selection | None: """Build a selection from the real tableau currently on the board.""" return self.selection_from_tableau_cards(self.tableau, column_index, card_index) def selection_from_tableau_cards( - self, tableau: List[List[Card]], column_index: int, card_index: int - ) -> Optional[Selection]: + self, tableau: list[list[Card]], column_index: int, card_index: int + ) -> Selection | None: """ Build a movable tableau stack from any supplied tableau-like board. @@ -427,7 +427,7 @@ def selection_from_tableau_cards( return None return Selection("tableau", column_index, card_index, list(stack)) - def selection_from_hint(self, move: HintMove) -> Optional[Selection]: + def selection_from_hint(self, move: HintMove) -> Selection | None: """Translate a stored hint back into a live selectable stack.""" if move.source_type == "waste": return self.selection_from_waste() @@ -437,7 +437,7 @@ def selection_from_hint(self, move: HintMove) -> Optional[Selection]: return self.selection_from_tableau(move.source_index, move.source_card_index) return None - def is_valid_tableau_stack(self, cards: List[Card]) -> bool: + def is_valid_tableau_stack(self, cards: list[Card]) -> bool: """Check the red-black descending order required in tableau stacks.""" if not cards or not all(card.face_up for card in cards): return False @@ -450,11 +450,11 @@ def is_valid_tableau_stack(self, cards: List[Card]) -> bool: return False return True - def can_move_to_tableau(self, cards: List[Card], column_index: int) -> bool: + def can_move_to_tableau(self, cards: list[Card], column_index: int) -> bool: """Convenience wrapper that checks a move against one real tableau column.""" return self.can_move_to_tableau_cards(cards, self.tableau[column_index]) - def can_move_to_tableau_cards(self, cards: List[Card], destination: List[Card]) -> bool: + def can_move_to_tableau_cards(self, cards: list[Card], destination: list[Card]) -> bool: """Check the standard tableau rule for any destination pile.""" if not cards: return False @@ -477,7 +477,7 @@ def can_move_to_foundation_piles( self, card: Card, foundation_index: int, - foundations: List[List[Card]], + foundations: list[list[Card]], ) -> bool: """Check whether a card can be placed on a given foundation pile.""" expected_suit = FOUNDATION_SUITS[foundation_index] @@ -513,7 +513,7 @@ def move_selection_to_foundation(self, selection: Selection, foundation_index: i self.add_score(self.FOUNDATION_POINTS) return True - def remove_selection(self, selection: Selection) -> List[Card]: + def remove_selection(self, selection: Selection) -> list[Card]: """ Remove the selected cards from their source pile and return them. @@ -545,7 +545,7 @@ def all_tableau_cards_face_up(self) -> bool: """Auto-solve begins only after no hidden tableau cards remain.""" return all(card.face_up for column in self.tableau for card in column) - def next_auto_foundation_move(self) -> Optional[Tuple[Selection, int]]: + def next_auto_foundation_move(self) -> tuple[Selection, int] | None: """ Return the next direct move to a foundation for auto-complete mode. @@ -599,7 +599,7 @@ def next_auto_foundation_move(self) -> Optional[Tuple[Selection, int]]: # declares the deal lost - so a filter that is too aggressive can cause # false "no more moves" verdicts. The filters deliberately allow any # move that reveals a hidden card or unlocks a foundation play. - def find_best_move(self) -> Optional[HintMove]: + def find_best_move(self) -> HintMove | None: """ Find the best move for the hint system and loss detection. @@ -619,9 +619,9 @@ def find_best_move(self) -> Optional[HintMove]: return None - def current_legal_moves(self) -> List[HintMove]: + def current_legal_moves(self) -> list[HintMove]: """Collect all useful moves that can be made immediately.""" - moves: List[HintMove] = [] + moves: list[HintMove] = [] if self.waste: # The player may only move the top waste card. @@ -699,15 +699,17 @@ def current_legal_moves(self) -> List[HintMove]: return [move for move in moves if self.is_meaningful_move(move)] - def future_waste_moves(self) -> List[HintMove]: + def future_waste_moves(self) -> list[HintMove]: """ Simulate future stock clicks and collect useful waste-based moves. This helps the hint system say things like "click the stock twice, then move 5♥ to the Hearts foundation." """ - future_moves: List[HintMove] = [] - best_seen = {} + future_moves: list[HintMove] = [] + # Keyed by (suit, rank, destination type, destination index); the + # value is the cheapest (fewest stock clicks) move for that card. + best_seen: dict[tuple[str, int, str, int], HintMove] = {} # Work on copies so the real game state is not changed. simulated_stock = list(self.stock) @@ -771,9 +773,9 @@ def single_card_moves( source_index: int, source_card_index: int, stock_clicks: int, - ) -> List[HintMove]: + ) -> list[HintMove]: """Build every one-card move available for a given card.""" - moves: List[HintMove] = [] + moves: list[HintMove] = [] if source_type != "foundation": # Cards already on a foundation are not suggested back to another foundation. @@ -814,7 +816,7 @@ def build_hint_move( source_type: str, source_index: int, source_card_index: int, - cards: List[Card], + cards: list[Card], destination_type: str, destination_index: int, stock_clicks: int, @@ -846,7 +848,7 @@ def describe_hint( self, source_type: str, source_index: int, - cards: List[Card], + cards: list[Card], destination_type: str, destination_index: int, stock_clicks: int, @@ -856,10 +858,7 @@ def describe_hint( target_text = self.describe_destination(destination_type, destination_index) if source_type == "future_waste": - if stock_clicks == 1: - prefix = "Click the stock once" - else: - prefix = f"Click the stock {stock_clicks} times" + prefix = "Click the stock once" if stock_clicks == 1 else f"Click the stock {stock_clicks} times" return f"Hint: {prefix}, then move {card_text} to {target_text}." source_text = self.describe_source(source_type, source_index) @@ -881,7 +880,7 @@ def describe_destination(self, destination_type: str, destination_index: int) -> return f"the {suit_name} foundation" return f"tableau column {destination_index + 1}" - def describe_cards(self, cards: List[Card]) -> str: + def describe_cards(self, cards: list[Card]) -> str: """Describe either one card or the front card of a moving stack.""" if len(cards) == 1: return cards[0].label() @@ -890,7 +889,7 @@ def describe_cards(self, cards: List[Card]) -> str: # ------------------------------------------------------------------ # Hint engine stage 3: ranking # ------------------------------------------------------------------ - def hint_sort_key(self, move: HintMove) -> Tuple[int, int, int, int]: + def hint_sort_key(self, move: HintMove) -> tuple[int, int, int, int]: """Build the sort key that decides which hint move is shown. Python sorts tuples item by item, so this orders moves by priority @@ -974,9 +973,7 @@ def is_meaningful_move(self, move: HintMove) -> bool: return False if self.is_redundant_tableau_transfer(move): return False - if self.is_unproductive_foundation_return(move): - return False - return True + return not self.is_unproductive_foundation_return(move) def is_tableau_ace_diversion(self, move: HintMove) -> bool: """Never prefer moving a tableau Ace to another tableau.""" @@ -1011,10 +1008,10 @@ def is_redundant_tableau_transfer_on_board( self, source_index: int, source_card_index: int, - cards: List[Card], + cards: list[Card], destination_index: int, - tableau: List[List[Card]], - foundations: List[List[Card]], + tableau: list[list[Card]], + foundations: list[list[Card]], ) -> bool: """ Detect tableau shuffles that do not create new opportunities. @@ -1050,13 +1047,10 @@ def is_redundant_tableau_transfer_on_board( if len(equivalent_destinations) < 2: return False - if any( + return not any( self.tableau_top_can_move_to_foundation_on_board(column_index, tableau, foundations) for column_index in equivalent_destinations - ): - return False - - return True + ) def is_equivalent_anchor_loop(self, move: HintMove) -> bool: """Wrapper for detecting reversible stack swaps between equal anchors.""" @@ -1073,8 +1067,8 @@ def is_equivalent_anchor_loop_on_board( source_index: int, source_card_index: int, destination_index: int, - tableau: List[List[Card]], - foundations: List[List[Card]], + tableau: list[list[Card]], + foundations: list[list[Card]], ) -> bool: """ Detect moving a tableau stack from one equivalent anchor to another. @@ -1099,10 +1093,7 @@ def is_equivalent_anchor_loop_on_board( if self.card_can_move_to_any_foundation_in_piles(source_anchor, foundations): return False - if self.card_can_move_to_any_foundation_in_piles(destination_anchor, foundations): - return False - - return True + return not self.card_can_move_to_any_foundation_in_piles(destination_anchor, foundations) def cards_are_equivalent_anchors(self, first: Card, second: Card) -> bool: """Two anchors are equivalent when rank and color match.""" @@ -1117,8 +1108,8 @@ def tableau_top_can_move_to_foundation(self, column_index: int) -> bool: def tableau_top_can_move_to_foundation_on_board( self, column_index: int, - tableau: List[List[Card]], - foundations: List[List[Card]], + tableau: list[list[Card]], + foundations: list[list[Card]], ) -> bool: """Check whether the top tableau card can move to any foundation.""" column = tableau[column_index] @@ -1131,7 +1122,7 @@ def card_can_move_to_any_foundation(self, card: Card) -> bool: return self.card_can_move_to_any_foundation_in_piles(card, self.foundations) def card_can_move_to_any_foundation_in_piles( - self, card: Card, foundations: List[List[Card]] + self, card: Card, foundations: list[list[Card]] ) -> bool: """Return True if this card fits on at least one foundation pile.""" return any( @@ -1143,7 +1134,7 @@ def move_reveals_hidden_on_board( self, source_index: int, source_card_index: int, - tableau: List[List[Card]], + tableau: list[list[Card]], ) -> bool: """Version of the reveal check that works on a simulated tableau.""" if source_card_index == 0: @@ -1160,10 +1151,10 @@ def is_meaningful_tableau_transfer_on_board( self, source_index: int, source_card_index: int, - cards: List[Card], + cards: list[Card], destination_index: int, - tableau: List[List[Card]], - foundations: List[List[Card]], + tableau: list[list[Card]], + foundations: list[list[Card]], ) -> bool: """Check whether a simulated tableau transfer would still be useful.""" if source_index == destination_index: @@ -1172,16 +1163,9 @@ def is_meaningful_tableau_transfer_on_board( return False if source_card_index == 0 and cards[0].rank == 13 and not tableau[destination_index]: return False - if self.is_redundant_tableau_transfer_on_board( - source_index, - source_card_index, - cards, - destination_index, - tableau, - foundations, - ): - return False - return True + return not self.is_redundant_tableau_transfer_on_board( + source_index, source_card_index, cards, destination_index, tableau, foundations + ) def foundation_return_enables_tableau_follow_up(self, move: HintMove) -> bool: """ @@ -1240,9 +1224,9 @@ def follow_up_creates_concrete_progress( self, source_index: int, source_card_index: int, - cards: List[Card], - tableau: List[List[Card]], - foundations: List[List[Card]], + cards: list[Card], + tableau: list[list[Card]], + foundations: list[list[Card]], ) -> bool: """Check whether a follow-up move creates immediate visible progress.""" if self.move_reveals_hidden_on_board(source_index, source_card_index, tableau): @@ -1253,10 +1237,7 @@ def follow_up_creates_concrete_progress( source_after = list(tableau[source_index]) del source_after[source_card_index:] - if source_after and self.card_can_move_to_any_foundation_in_piles(source_after[-1], foundations): - return True - - return False + return bool(source_after and self.card_can_move_to_any_foundation_in_piles(source_after[-1], foundations)) class SolitaireApp: @@ -1279,21 +1260,19 @@ def __init__(self, root: tk.Tk) -> None: # Load the custom window icon when it is available. if os.path.exists(icon_path): - try: + with contextlib.suppress(tk.TclError): self.root.iconbitmap(icon_path) - except tk.TclError: - pass # Create the game logic object. self.game = KlondikeGame(high_score_path) # Selection/hint/animation state belongs to the UI layer. - self.selection: Optional[Selection] = None - self.hint_move: Optional[HintMove] = None - self.animation: Optional[AnimationState] = None + self.selection: Selection | None = None + self.hint_move: HintMove | None = None + self.animation: AnimationState | None = None self.auto_solving = False - self.auto_solve_seen_states = set() - self.history: List[dict] = [] + self.auto_solve_seen_states: set = set() + self.history: list[dict] = [] # tkinter StringVar objects let labels update automatically. self.score_var = tk.StringVar() @@ -1327,7 +1306,9 @@ def build_ui(self) -> None: top_frame = tk.Frame(self.root, bg=BACKGROUND_COLOR, padx=12, pady=10) top_frame.pack(fill="x") - label_style = { + # `Any` values let the same dict splat into every tk.Label call below + # without fighting tkinter's very specific per-option stub types. + label_style: dict[str, Any] = { "bg": BACKGROUND_COLOR, "fg": "white", "font": ("Arial", 12, "bold"), @@ -1436,11 +1417,10 @@ def start_new_game(self) -> None: """Deal a fresh game and clear all UI-only state.""" # Guard an in-progress game: a non-empty history with no win/loss yet # means real moves would be discarded. Covers the button and Ctrl+N. - if self.history and not self.game.won and not self.game.lost: - if not messagebox.askyesno( - "New Game", - "Start a new game? Your current progress will be lost."): - return + if self.history and not self.game.won and not self.game.lost and not messagebox.askyesno( + "New Game", + "Start a new game? Your current progress will be lost."): + return self.game.new_game() self.selection = None self.hint_move = None @@ -1472,7 +1452,7 @@ def remember_history_state(self, history_state: dict) -> None: if len(self.history) > UNDO_HISTORY_LIMIT: self.history = self.history[-UNDO_HISTORY_LIMIT:] - def undo_last_action(self, event: Optional[tk.Event] = None) -> None: + def undo_last_action(self, event: tk.Event | None = None) -> None: """Restore the most recent saved snapshot.""" if self.animation is not None: self.status_var.set("Please wait for the card animation to finish.") @@ -1593,7 +1573,7 @@ def on_canvas_double_click(self, event: tk.Event) -> None: ) return - def _selection_under_point(self, x: int, y: int) -> Optional[Selection]: + def _selection_under_point(self, x: int, y: int) -> Selection | None: """Return a Selection for the card under a click, or None if there is none. Used by the double-click shortcut to find which card was clicked. Only @@ -1659,7 +1639,7 @@ def handle_foundation_click(self, foundation_index: int) -> None: ) self.redraw() - def handle_tableau_click(self, column_index: int, card_index: Optional[int]) -> None: + def handle_tableau_click(self, column_index: int, card_index: int | None) -> None: """Handle selection, deselection, and placement inside the tableau.""" if self.selection is not None: current_selection = self.selection @@ -1899,7 +1879,7 @@ def advance_auto_solve(self) -> None: self.status_var.set("Auto-solve stopped because no direct foundation move is available.") self.check_for_loss() - def auto_solve_state_key(self) -> Tuple: + def auto_solve_state_key(self) -> tuple: """Build a hashable summary of the board for loop detection. Auto-solve cycles the stock looking for cards it can send to a @@ -2465,13 +2445,14 @@ def draw_animation(self) -> None: self.animation.cards, self.animation.start_positions, self.animation.end_positions, + strict=True, ): x = start[0] + (end[0] - start[0]) * eased_progress y = start[1] + (end[1] - start[1]) * eased_progress y -= (1.0 - eased_progress) * 6 self.draw_card(x, y, card, face_up=card.face_up, selected=False) - def visible_foundation_pile(self, foundation_index: int) -> List[Card]: + def visible_foundation_pile(self, foundation_index: int) -> list[Card]: """Hide destination cards that are being animated on top of a foundation.""" pile = self.game.foundations[foundation_index] hidden_count = self.animation_hidden_count("foundation", foundation_index) @@ -2479,7 +2460,7 @@ def visible_foundation_pile(self, foundation_index: int) -> List[Card]: return pile return pile[:-hidden_count] - def visible_tableau_column(self, column_index: int) -> List[Card]: + def visible_tableau_column(self, column_index: int) -> list[Card]: """Hide destination cards that are being animated on top of a tableau.""" column = self.game.tableau[column_index] hidden_count = self.animation_hidden_count("tableau", column_index) @@ -2514,7 +2495,7 @@ def is_tableau_card_selected(self, column_index: int, card_index: int) -> bool: return False return card_index >= self.selection.card_index - def selection_positions(self, selection: Selection) -> List[Tuple[float, float]]: + def selection_positions(self, selection: Selection) -> list[tuple[float, float]]: """Find the on-screen positions of the currently selected cards.""" if selection.source_type == "waste": x1, y1, _, _ = self.waste_rect() @@ -2530,10 +2511,10 @@ def selection_positions(self, selection: Selection) -> List[Tuple[float, float]] def destination_positions( self, - cards: List[Card], + cards: list[Card], destination_type: str, destination_index: int, - ) -> List[Tuple[float, float]]: + ) -> list[tuple[float, float]]: """Compute where each moving card should end up after a move.""" if destination_type == "foundation": x1, y1, _, _ = self.foundation_rect(destination_index) @@ -2552,21 +2533,21 @@ def destination_positions( return [(x, start_y + FACE_UP_SPACING * offset) for offset in range(len(cards))] - def point_in_rect(self, x: int, y: int, rect: Tuple[int, int, int, int]) -> bool: + def point_in_rect(self, x: int, y: int, rect: tuple[int, int, int, int]) -> bool: """Basic hit-test helper for rectangular areas.""" x1, y1, x2, y2 = rect return x1 <= x <= x2 and y1 <= y <= y2 - def stock_rect(self) -> Tuple[int, int, int, int]: + def stock_rect(self) -> tuple[int, int, int, int]: """Return the stock pile rectangle.""" return (MARGIN_X, TOP_ROW_Y, MARGIN_X + CARD_WIDTH, TOP_ROW_Y + CARD_HEIGHT) - def waste_rect(self) -> Tuple[int, int, int, int]: + def waste_rect(self) -> tuple[int, int, int, int]: """Return the waste pile rectangle.""" x = MARGIN_X + TABLEAU_STEP return (x, TOP_ROW_Y, x + CARD_WIDTH, TOP_ROW_Y + CARD_HEIGHT) - def foundation_rect(self, foundation_index: int) -> Tuple[int, int, int, int]: + def foundation_rect(self, foundation_index: int) -> tuple[int, int, int, int]: """Return the rectangle for one foundation pile.""" x = FOUNDATION_START_X + foundation_index * TABLEAU_STEP return (x, TOP_ROW_Y, x + CARD_WIDTH, TOP_ROW_Y + CARD_HEIGHT) @@ -2575,11 +2556,11 @@ def tableau_x(self, column_index: int) -> int: """Return the left x-position of a tableau column.""" return MARGIN_X + column_index * TABLEAU_STEP - def tableau_positions(self, column_index: int) -> List[int]: + def tableau_positions(self, column_index: int) -> list[int]: """Return every y-position in one real tableau column.""" return self.tableau_positions_for_cards(self.game.tableau[column_index]) - def tableau_positions_for_cards(self, cards: List[Card]) -> List[int]: + def tableau_positions_for_cards(self, cards: list[Card]) -> list[int]: """Return stacked y-positions for any supplied list of cards. Face-down cards can overlap more tightly because nothing on them needs @@ -2587,14 +2568,14 @@ def tableau_positions_for_cards(self, cards: List[Card]) -> List[int]: and suit still show at the top edge. That is why two different spacings (FACE_UP_SPACING and FACE_DOWN_SPACING) are used here. """ - y_positions: List[int] = [] + y_positions: list[int] = [] y = TABLEAU_Y for card in cards: y_positions.append(y) y += FACE_UP_SPACING if card.face_up else FACE_DOWN_SPACING return y_positions - def find_tableau_hit(self, x: int, y: int) -> Tuple[Optional[int], Optional[int]]: + def find_tableau_hit(self, x: int, y: int) -> tuple[int | None, int | None]: """ Figure out which tableau column/card a mouse click landed on. @@ -2621,10 +2602,7 @@ def find_tableau_hit(self, x: int, y: int) -> Tuple[Optional[int], Optional[int] # Walk backward so the topmost visible card wins the hit test. for card_index in range(len(column) - 1, -1, -1): top = positions[card_index] - if card_index == len(column) - 1: - bottom = top + CARD_HEIGHT - else: - bottom = positions[card_index + 1] + bottom = top + CARD_HEIGHT if card_index == len(column) - 1 else positions[card_index + 1] if top <= y <= bottom: return column_index, card_index @@ -2643,7 +2621,7 @@ def is_same_selection(self, other: Selection) -> bool: and self.selection.card_index == other.card_index ) - def describe_cards(self, cards: List[Card]) -> str: + def describe_cards(self, cards: list[Card]) -> str: """Small UI wrapper around the model helper.""" return self.game.describe_cards(cards) diff --git a/Python Solitaire Game/tests/test_klondike_rules.py b/Python Solitaire Game/tests/test_klondike_rules.py index 6639055..533b3f8 100644 --- a/Python Solitaire Game/tests/test_klondike_rules.py +++ b/Python Solitaire Game/tests/test_klondike_rules.py @@ -129,7 +129,7 @@ def test_foundation_move_scores_and_reveal_scores(self) -> None: self.game.tableau[0] = [Card("spades", 9, face_up=False), Card("hearts", 1, face_up=True)] selection = self.game.selection_from_tableau(0, 1) - self.assertIsNotNone(selection) + assert selection is not None moved = self.game.move_selection_to_foundation(selection, hearts) self.assertTrue(moved) @@ -187,7 +187,7 @@ def test_hint_prefers_the_move_that_reveals_a_hidden_card(self) -> None: move = self.game.find_best_move() - self.assertIsNotNone(move) + assert move is not None self.assertEqual(move.source_type, "tableau") self.assertEqual(move.source_index, 0) self.assertEqual(move.destination_type, "tableau") @@ -200,10 +200,10 @@ def test_hint_translates_back_into_a_legal_selection(self) -> None: move = self.game.find_best_move() - self.assertIsNotNone(move) + assert move is not None self.assertEqual(move.destination_type, "foundation") selection = self.game.selection_from_hint(move) - self.assertIsNotNone(selection) + assert selection is not None self.assertTrue( self.game.can_move_to_foundation(selection.cards[0], move.destination_index) ) diff --git a/Python Sputnika Game/assets.py b/Python Sputnika Game/assets.py index d07ad2d..5ccfbcc 100644 --- a/Python Sputnika Game/assets.py +++ b/Python Sputnika Game/assets.py @@ -13,9 +13,9 @@ from __future__ import annotations -from array import array import math import random +from array import array import pygame @@ -230,12 +230,12 @@ def _build_background(self) -> pygame.Surface: ((860, 320), 220, (102, 225, 255, 18)), ((760, 980), 320, (255, 190, 120, 12)), ] - for center, radius, color in nebulae: + for nebula_center, nebula_radius, nebula_color in nebulae: # Several translucent circles layered together make a soft nebula blob. for ring in range(5, 0, -1): - alpha = max(0, color[3] - ring * 2) - draw_radius = radius - ring * 18 - pygame.draw.circle(cloud, (*color[:3], alpha), center, draw_radius) + alpha = max(0, nebula_color[3] - ring * 2) + draw_radius = nebula_radius - ring * 18 + pygame.draw.circle(cloud, (*nebula_color[:3], alpha), nebula_center, draw_radius) # Add a few distant decorative planets so the world feels bigger than # the single puzzle container on screen. @@ -360,9 +360,12 @@ def _build_body_surface(self, tier: int) -> pygame.Surface: # Large top highlight plus a smaller specular dot give the "toy-like" # shine typical of charming merge-game art. - pygame.draw.circle(surface, shade(info.color, 1.11), center - pygame.Vector2(radius * 0.25, radius * 0.30), int(radius * 0.68)) - pygame.draw.circle(surface, (*SOFT_WHITE, 76), center - pygame.Vector2(radius * 0.40, radius * 0.44), int(radius * 0.35)) - pygame.draw.circle(surface, (*WHITE, 118), center - pygame.Vector2(radius * 0.24, radius * 0.33), max(3, int(radius * 0.09))) + highlight_center = center - pygame.Vector2(radius * 0.25, radius * 0.30) + pygame.draw.circle(surface, shade(info.color, 1.11), highlight_center, int(radius * 0.68)) + soft_center = center - pygame.Vector2(radius * 0.40, radius * 0.44) + pygame.draw.circle(surface, (*SOFT_WHITE, 76), soft_center, int(radius * 0.35)) + specular_center = center - pygame.Vector2(radius * 0.24, radius * 0.33) + pygame.draw.circle(surface, (*WHITE, 118), specular_center, max(3, int(radius * 0.09))) pygame.draw.circle(surface, shade(info.accent, 0.82), center, radius, width=3) # A thin rim light around the upper-left edge helps separate the body @@ -400,20 +403,26 @@ def _decorate_body( for ox, oy in offsets: crater_center = center + pygame.Vector2(radius * ox, radius * oy) pygame.draw.circle(surface, dark, crater_center, max(4, int(radius * 0.16))) - pygame.draw.circle(surface, shade(info.color, 0.92), crater_center + pygame.Vector2(-2, -2), max(2, int(radius * 0.08))) + inner_center = crater_center + pygame.Vector2(-2, -2) + pygame.draw.circle(surface, shade(info.color, 0.92), inner_center, max(2, int(radius * 0.08))) elif info.icon == "ocean": # Ocean worlds use repeating arc bands as stylized waves. for row in range(3): wave_rect = pygame.Rect(0, 0, int(radius * 1.1), int(radius * 0.5)) wave_rect.center = (center.x, center.y + radius * (-0.18 + row * 0.22)) pygame.draw.arc(surface, accent, wave_rect, 0.35, math.pi - 0.35, 3) - pygame.draw.circle(surface, (*WHITE, 36), center - pygame.Vector2(radius * 0.08, radius * 0.02), int(radius * 0.78), width=2) + glow_center = center - pygame.Vector2(radius * 0.08, radius * 0.02) + pygame.draw.circle(surface, (*WHITE, 36), glow_center, int(radius * 0.78), width=2) elif info.icon == "continent": # Earth-like worlds get simple landmass blobs. blobs = [(-0.24, -0.05, 0.24), (0.18, 0.16, 0.2), (-0.02, 0.28, 0.15)] for ox, oy, scale in blobs: - pygame.draw.circle(surface, accent, center + pygame.Vector2(radius * ox, radius * oy), int(radius * scale)) - pygame.draw.arc(surface, (*WHITE, 32), pygame.Rect(center.x - radius * 0.76, center.y - radius * 0.54, radius * 1.2, radius * 0.7), 0.2, 2.6, 2) + blob_center = center + pygame.Vector2(radius * ox, radius * oy) + pygame.draw.circle(surface, accent, blob_center, int(radius * scale)) + cloud_rect = pygame.Rect( + center.x - radius * 0.76, center.y - radius * 0.54, radius * 1.2, radius * 0.7 + ) + pygame.draw.arc(surface, (*WHITE, 32), cloud_rect, 0.2, 2.6, 2) elif info.icon == "ring": # Gas giants use ellipses to fake a ring system. ring_rect = pygame.Rect(0, 0, int(radius * 2.2), int(radius * 0.72)) diff --git a/Python Sputnika Game/effects.py b/Python Sputnika Game/effects.py index ccebc9c..b258e25 100644 --- a/Python Sputnika Game/effects.py +++ b/Python Sputnika Game/effects.py @@ -7,9 +7,9 @@ from __future__ import annotations -from dataclasses import dataclass import math import random +from dataclasses import dataclass import pygame @@ -289,5 +289,5 @@ def draw(self, target: pygame.Surface, assets, camera_offset: pygame.Vector2) -> height = max(1, int(base.get_height() * popup.scale)) base = pygame.transform.smoothscale(base, (width, height)) base.set_alpha(alpha) - rect = base.get_rect(center=(int(popup.position.x + camera_offset.x), int(popup.position.y + camera_offset.y))) - target.blit(base, rect) + center = (int(popup.position.x + camera_offset.x), int(popup.position.y + camera_offset.y)) + target.blit(base, base.get_rect(center=center)) diff --git a/Python Sputnika Game/entities.py b/Python Sputnika Game/entities.py index 8a0b842..dafa896 100644 --- a/Python Sputnika Game/entities.py +++ b/Python Sputnika Game/entities.py @@ -13,9 +13,9 @@ from __future__ import annotations -from dataclasses import dataclass, field import math import random +from dataclasses import dataclass, field import pygame @@ -175,7 +175,8 @@ def draw( (max(12, int(self.radius * scale * 2.0)), max(8, int(self.radius * scale * 0.72))), pygame.SRCALPHA, ) - shadow_rect = shadow_surface.get_rect(center=(round(draw_pos.x), round(draw_pos.y + self.radius * scale * 0.78))) + shadow_center = (round(draw_pos.x), round(draw_pos.y + self.radius * scale * 0.78)) + shadow_rect = shadow_surface.get_rect(center=shadow_center) pygame.draw.ellipse( shadow_surface, (4, 8, 18, 58 + int(self.radius * 0.45)), @@ -243,8 +244,10 @@ def _draw_face( # Closed eyes are drawn as short lines. line_y = int(eye_y) line_half = max(3, int(radius * 0.09)) - pygame.draw.line(target, eye_color, (int(cx - eye_dx - line_half), line_y), (int(cx - eye_dx + line_half), line_y), 2) - pygame.draw.line(target, eye_color, (int(cx + eye_dx - line_half), line_y), (int(cx + eye_dx + line_half), line_y), 2) + for eye_x in (cx - eye_dx, cx + eye_dx): + start = (int(eye_x - line_half), line_y) + end = (int(eye_x + line_half), line_y) + pygame.draw.line(target, eye_color, start, end, 2) else: # Open eyes use a white sclera, dark pupil, and highlight. # Slightly taller eyes help the expressions read more clearly. @@ -260,7 +263,8 @@ def _draw_face( int(eye_y + eyelid_drop + gaze.y), ) pygame.draw.circle(target, eye_color, pupil_center, pupil_radius) - pygame.draw.circle(target, WHITE, (pupil_center[0] - 1, pupil_center[1] - 1), max(1, pupil_radius // 3)) + glint_center = (pupil_center[0] - 1, pupil_center[1] - 1) + pygame.draw.circle(target, WHITE, glint_center, max(1, pupil_radius // 3)) self._draw_brows(target, center, radius, expression) @@ -281,7 +285,8 @@ def _draw_face( elif expression["delighted"]: happy_rect = mouth_rect.inflate(int(radius * 0.08), int(radius * 0.06)) pygame.draw.arc(target, mouth_color, happy_rect, 0.0, math.pi, 3) - pygame.draw.circle(target, (*WHITE, 90), (int(cx - radius * 0.08), int(cy + radius * 0.2)), max(1, int(radius * 0.035))) + sparkle_center = (int(cx - radius * 0.08), int(cy + radius * 0.2)) + pygame.draw.circle(target, (*WHITE, 90), sparkle_center, max(1, int(radius * 0.035))) # Different tiers use different mouth shapes so they feel like distinct characters. elif mood in {"cheery", "smile", "spark", "bright"}: pygame.draw.arc(target, mouth_color, mouth_rect, 0.1, math.pi - 0.1, 3) @@ -373,21 +378,27 @@ def _draw_brows( brow_half = radius * 0.13 brow_color = shade(self.info.accent, 0.42) + # Brow endpoints: the outer/inner x positions are shared by every + # expression; only the y tilt of each end changes per mood. + left_x0 = cx - brow_dx - brow_half + left_x1 = cx - brow_dx + brow_half + right_x0 = cx + brow_dx - brow_half + right_x1 = cx + brow_dx + brow_half if expression["sleepy"]: - left = ((cx - brow_dx - brow_half), brow_y, (cx - brow_dx + brow_half), brow_y + radius * 0.02) - right = ((cx + brow_dx - brow_half), brow_y + radius * 0.02, (cx + brow_dx + brow_half), brow_y) + left = (left_x0, brow_y, left_x1, brow_y + radius * 0.02) + right = (right_x0, brow_y + radius * 0.02, right_x1, brow_y) elif expression["startled"]: - left = ((cx - brow_dx - brow_half), brow_y + radius * 0.02, (cx - brow_dx + brow_half), brow_y - radius * 0.06) - right = ((cx + brow_dx - brow_half), brow_y - radius * 0.06, (cx + brow_dx + brow_half), brow_y + radius * 0.02) + left = (left_x0, brow_y + radius * 0.02, left_x1, brow_y - radius * 0.06) + right = (right_x0, brow_y - radius * 0.06, right_x1, brow_y + radius * 0.02) elif expression["worried"]: - left = ((cx - brow_dx - brow_half), brow_y - radius * 0.02, (cx - brow_dx + brow_half), brow_y + radius * 0.07) - right = ((cx + brow_dx - brow_half), brow_y + radius * 0.07, (cx + brow_dx + brow_half), brow_y - radius * 0.02) + left = (left_x0, brow_y - radius * 0.02, left_x1, brow_y + radius * 0.07) + right = (right_x0, brow_y + radius * 0.07, right_x1, brow_y - radius * 0.02) elif expression["focused"]: - left = ((cx - brow_dx - brow_half), brow_y + radius * 0.03, (cx - brow_dx + brow_half), brow_y - radius * 0.05) - right = ((cx + brow_dx - brow_half), brow_y - radius * 0.05, (cx + brow_dx + brow_half), brow_y + radius * 0.03) + left = (left_x0, brow_y + radius * 0.03, left_x1, brow_y - radius * 0.05) + right = (right_x0, brow_y - radius * 0.05, right_x1, brow_y + radius * 0.03) else: - left = ((cx - brow_dx - brow_half), brow_y, (cx - brow_dx + brow_half), brow_y - radius * 0.03) - right = ((cx + brow_dx - brow_half), brow_y - radius * 0.03, (cx + brow_dx + brow_half), brow_y) + left = (left_x0, brow_y, left_x1, brow_y - radius * 0.03) + right = (right_x0, brow_y - radius * 0.03, right_x1, brow_y) pygame.draw.line(target, brow_color, left[:2], left[2:], max(2, int(radius * 0.05))) pygame.draw.line(target, brow_color, right[:2], right[2:], max(2, int(radius * 0.05))) diff --git a/Python Sputnika Game/game.py b/Python Sputnika Game/game.py index 2d75d56..23a1803 100644 --- a/Python Sputnika Game/game.py +++ b/Python Sputnika Game/game.py @@ -40,8 +40,8 @@ FPS, KEYBOARD_AIM_SPEED, LAUNCH_SPEED, - MAX_LAUNCH_ANGLE, MAX_BODIES, + MAX_LAUNCH_ANGLE, MOUSE_WHEEL_ANGLE_STEP, SAVE_DATA_DIR, SAVE_DATA_PATH, @@ -442,8 +442,10 @@ def drop_current_body(self) -> None: def _update(self, dt: float) -> None: """Advance one frame of non-drawing game logic.""" - # Mouse-driven background drift adds a small amount of depth. - logical_mouse = self._current_canvas_mouse() + # Mouse-driven background drift adds a small amount of depth. With + # clamping on, the helper never actually returns None; the fallback + # here just repeats its own center default for the type checker. + logical_mouse = self._current_canvas_mouse() or (SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2) drift = pygame.Vector2(logical_mouse) - pygame.Vector2(self.world.center.x, SCREEN_HEIGHT / 2) self.background_drift = drift * 0.06 @@ -560,7 +562,7 @@ def _apply_merges(self, merge_events) -> None: self._combo_count = 1 self._last_merge_time = self.now combo_multiplier = min(3.0, 1.0 + 0.5 * (self._combo_count - 1)) - gained = int(round(event.score_gain * combo_multiplier)) + gained = round(event.score_gain * combo_multiplier) # Reward the player and fire appropriate celebratory effects. self.score += gained @@ -676,10 +678,7 @@ def _roll_spawn_choice(self) -> tuple[int, float]: def _load_progress(self) -> dict: """Read the saved high score and lifetime stats, falling back safely.""" try: - if SAVE_DATA_PATH.exists(): - data = json.loads(SAVE_DATA_PATH.read_text(encoding="utf-8")) - else: - data = {} + data = json.loads(SAVE_DATA_PATH.read_text(encoding="utf-8")) if SAVE_DATA_PATH.exists() else {} return { "high_score": max(0, int(data.get("high_score", 0))), "games_played": max(0, int(data.get("games_played", 0))), @@ -950,9 +949,8 @@ def _window_to_canvas(self, position: tuple[int, int], keep_inside: bool = False """ if self.render_rect.width <= 0 or self.render_rect.height <= 0: return None - if not self.render_rect.collidepoint(position): - if not keep_inside: - return None + if not self.render_rect.collidepoint(position) and not keep_inside: + return None # Convert to percentages inside the render rectangle, then scale that # percentage into internal 1024x1280 coordinates. diff --git a/Python Sputnika Game/main.py b/Python Sputnika Game/main.py index a116486..336eb8c 100644 --- a/Python Sputnika Game/main.py +++ b/Python Sputnika Game/main.py @@ -14,6 +14,7 @@ from __future__ import annotations +import contextlib import os import sys from pathlib import Path @@ -59,11 +60,9 @@ def main() -> None: # the game should still open normally instead of crashing at startup. icon_path = _resource_path("orbital_orchard_icon.png") if icon_path.exists(): - try: + # Icon-loading failures are cosmetic, so we ignore them safely. + with contextlib.suppress(pygame.error): pygame.display.set_icon(pygame.image.load(str(icon_path))) - except pygame.error: - # Icon-loading failures are cosmetic, so we ignore them safely. - pass # Build the main game object, which owns the window, world state, and loop. # From this point on, almost all interesting game behavior lives in that object. diff --git a/Python Sputnika Game/merge_logic.py b/Python Sputnika Game/merge_logic.py index c4a05d0..3348b6c 100644 --- a/Python Sputnika Game/merge_logic.py +++ b/Python Sputnika Game/merge_logic.py @@ -12,11 +12,15 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING import pygame from settings import MERGE_CONTACT_SLOP, MERGE_VELOCITY_DAMP, TIERS +if TYPE_CHECKING: + from entities import CelestialBody + @dataclass(slots=True) class MergeEvent: @@ -53,7 +57,7 @@ def find_merge_events(bodies, now: float) -> list[MergeEvent]: # - relative speed squared, # - first body, # - second body. - candidates: list[tuple[int, float, float, object, object]] = [] + candidates: list[tuple[int, float, float, CelestialBody, CelestialBody]] = [] total_tiers = len(TIERS) for index, first in enumerate(bodies): diff --git a/Python Sputnika Game/physics.py b/Python Sputnika Game/physics.py index 8fdfff8..2173dbd 100644 --- a/Python Sputnika Game/physics.py +++ b/Python Sputnika Game/physics.py @@ -16,8 +16,8 @@ from __future__ import annotations -from dataclasses import dataclass import math +from dataclasses import dataclass from typing import TYPE_CHECKING import pygame @@ -35,8 +35,8 @@ PLAYFIELD_CENTER, POSITION_CORRECTION_SOFTNESS, RESTITUTION, - SPAWN_Y, SOLVER_ITERATIONS, + SPAWN_Y, WALL_BOUNCE, WALL_FRICTION, ) @@ -55,8 +55,8 @@ class CollisionEvent: """Information about one impactful contact that the rest of the game can react to.""" # `body_b` is optional because wall hits have only one real body involved. - body_a: "CelestialBody" - body_b: "CelestialBody | None" + body_a: CelestialBody + body_b: CelestialBody | None point: pygame.Vector2 normal: pygame.Vector2 impact_speed: float @@ -74,7 +74,7 @@ def __init__(self) -> None: # `time` lets gravity/orbit math use smooth oscillation over time. self.time = 0.0 - def step(self, bodies: list["CelestialBody"], dt: float) -> list[CollisionEvent]: + def step(self, bodies: list[CelestialBody], dt: float) -> list[CollisionEvent]: """Advance the physics simulation by one frame. The order is: @@ -110,7 +110,7 @@ def step(self, bodies: list["CelestialBody"], dt: float) -> list[CollisionEvent] return events - def _step_once(self, bodies: list["CelestialBody"], dt: float) -> list[CollisionEvent]: + def _step_once(self, bodies: list[CelestialBody], dt: float) -> list[CollisionEvent]: """Run one small slice of the simulation. `step()` may call this once or multiple times per visible frame. @@ -147,7 +147,7 @@ def _step_once(self, bodies: list["CelestialBody"], dt: float) -> list[Collision return events - def keep_inside(self, body: "CelestialBody") -> None: + def keep_inside(self, body: CelestialBody) -> None: """Clamp one body back inside the circular container immediately.""" # This is mainly used after spawning a merged result so it does not end # up slightly outside the bubble because of rounding or overlap math. @@ -171,7 +171,8 @@ def allowed_spawn_x(self, radius: float) -> tuple[float, float]: # From the circle equation x^2 + y^2 = r^2, if we know `y` we can solve # for the maximum `x` still inside the circle. - horizontal = max(0.0, math.sqrt(max(0.0, self.radius * self.radius - spawn_offset * spawn_offset)) - radius - 18.0) + chord_half = math.sqrt(max(0.0, self.radius * self.radius - spawn_offset * spawn_offset)) + horizontal = max(0.0, chord_half - radius - 18.0) return self.center.x - horizontal, self.center.x + horizontal def preview_trajectory( @@ -230,7 +231,7 @@ def preview_trajectory( return points - def _gravity_for(self, body: "CelestialBody") -> pygame.Vector2: + def _gravity_for(self, body: CelestialBody) -> pygame.Vector2: """Build the final acceleration vector applied to one body.""" return self._gravity_at(body.position, body.seed, self.time) @@ -262,7 +263,7 @@ def _gravity_at(self, position: pygame.Vector2, seed: float, world_time: float) acceleration += tangent * orbit_amount * math.sin(world_time * 0.85 + seed * math.tau) return acceleration - def _solve_boundaries(self, bodies: list["CelestialBody"], emit_events: bool) -> list[CollisionEvent]: + def _solve_boundaries(self, bodies: list[CelestialBody], emit_events: bool) -> list[CollisionEvent]: """Resolve collisions between bodies and the circular wall.""" events: list[CollisionEvent] = [] for body in bodies: @@ -299,7 +300,7 @@ def _solve_boundaries(self, bodies: list["CelestialBody"], emit_events: bool) -> ) return events - def _solve_pairs(self, bodies: list["CelestialBody"], emit_events: bool) -> list[CollisionEvent]: + def _solve_pairs(self, bodies: list[CelestialBody], emit_events: bool) -> list[CollisionEvent]: """Resolve body-vs-body overlaps and collision impulses.""" events: list[CollisionEvent] = [] count = len(bodies) diff --git a/Python Sputnika Game/settings.py b/Python Sputnika Game/settings.py index aa166ab..ea63852 100644 --- a/Python Sputnika Game/settings.py +++ b/Python Sputnika Game/settings.py @@ -13,11 +13,10 @@ from __future__ import annotations -from dataclasses import dataclass -from pathlib import Path import colorsys import os - +from dataclasses import dataclass +from pathlib import Path # Window and render-space size. # The game internally renders to this portrait canvas even when the desktop @@ -201,7 +200,11 @@ def shade(color: tuple[int, int, int], factor: float) -> tuple[int, int, int]: # Example: # - factor > 1.0 makes the color brighter, # - factor < 1.0 makes the color darker. - return tuple(int(clamp(channel * factor, 0, 255)) for channel in color) + return ( + int(clamp(color[0] * factor, 0, 255)), + int(clamp(color[1] * factor, 0, 255)), + int(clamp(color[2] * factor, 0, 255)), + ) def lerp_color( @@ -213,9 +216,10 @@ def lerp_color( # `amount = 0.0` returns `start`, # `amount = 1.0` returns `end`, # values in between interpolate linearly. - return tuple( - int(start[idx] + (end[idx] - start[idx]) * amount) - for idx in range(3) + return ( + int(start[0] + (end[0] - start[0]) * amount), + int(start[1] + (end[1] - start[1]) * amount), + int(start[2] + (end[2] - start[2]) * amount), ) diff --git a/Python Sputnika Game/ui.py b/Python Sputnika Game/ui.py index 1078b14..400b32f 100644 --- a/Python Sputnika Game/ui.py +++ b/Python Sputnika Game/ui.py @@ -7,14 +7,13 @@ from __future__ import annotations -from dataclasses import dataclass import math +from dataclasses import dataclass import pygame from settings import ( CONTAINER_RADIUS, - FAIL_GRACE, FAIL_LINE_Y, GLOW, PLAYFIELD_CENTER, @@ -178,7 +177,8 @@ def draw_playfield(surface: pygame.Surface, now: float, danger_ratio: float, war # area feels like a distinct chamber rather than just empty screen space. interior = pygame.Surface(bubble.get_size(), pygame.SRCALPHA) pygame.draw.circle(interior, (8, 16, 36, 88), local_center, CONTAINER_RADIUS - 3) - pygame.draw.circle(interior, (255, 255, 255, 18), local_center - pygame.Vector2(CONTAINER_RADIUS * 0.22, CONTAINER_RADIUS * 0.28), int(CONTAINER_RADIUS * 0.48)) + glow_center = local_center - pygame.Vector2(CONTAINER_RADIUS * 0.22, CONTAINER_RADIUS * 0.28) + pygame.draw.circle(interior, (255, 255, 255, 18), glow_center, int(CONTAINER_RADIUS * 0.48)) pygame.draw.ellipse( interior, (22, 48, 92, 42), @@ -283,8 +283,9 @@ def draw_hud(surface: pygame.Surface, game, assets) -> None: current_hint = assets.tiny_font.render("Current", True, GLOW) surface.blit(current_hint, current_hint.get_rect(center=(queue_rect.centerx, queue_rect.y + 196))) - draw_preview_backplate(surface, (queue_rect.centerx, queue_rect.y + 228), 54, shade(GLOW, 0.88)) - assets.draw_preview_orb(surface, game.next_tier, (queue_rect.centerx, queue_rect.y + 228), game.now, scale=0.82, alpha=215) + next_orb_center = (queue_rect.centerx, queue_rect.y + 228) + draw_preview_backplate(surface, next_orb_center, 54, shade(GLOW, 0.88)) + assets.draw_preview_orb(surface, game.next_tier, next_orb_center, game.now, scale=0.82, alpha=215) next_name = assets.small_font.render(game.next_body_name, True, SOFT_WHITE) surface.blit(next_name, next_name.get_rect(center=(queue_rect.centerx, queue_rect.y + 264))) next_hint = assets.tiny_font.render("Next", True, GLOW) @@ -352,7 +353,8 @@ def draw_menu_overlay( surface.blit(title, title.get_rect(center=(modal.centerx, modal.y + 88))) surface.blit(title2, title2.get_rect(center=(modal.centerx, modal.y + 164))) - subtitle = assets.small_font.render("Drop, bounce, and merge celestial cuties into a Quasar Crown.", True, SOFT_WHITE) + subtitle_text = "Drop, bounce, and merge celestial cuties into a Quasar Crown." + subtitle = assets.small_font.render(subtitle_text, True, SOFT_WHITE) surface.blit(subtitle, subtitle.get_rect(center=(modal.centerx, modal.y + 228))) tips = [ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2af8326 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,79 @@ +# Quality-tooling configuration for the whole repository. +# +# There is no [project] build section on purpose: the four games are +# standalone folders that run from source and ship as PyInstaller +# executables, not an installable Python distribution. +# +# The four game folders use flat module layouts with overlapping module +# names (each has its own main.py, game.py, settings.py, ...), so mypy must +# be invoked once per game folder — see the CI workflow and AGENTS.md. +# Ruff and Bandit handle the whole tree in one pass. + +[tool.ruff] +line-length = 120 +target-version = "py311" +src = [ + "Python Ludo Game", + "Python Monopoly Game", + "Python Solitaire Game", + "Python Sputnika Game", +] + +[tool.ruff.lint] +select = ["E", "W", "F", "I", "B", "UP", "C4", "SIM", "RUF"] + +[tool.ruff.lint.per-file-ignores] +# Tests insert the game folder into sys.path before importing game modules, +# so imports legitimately follow statements (E402). B017/RUF012 are the usual +# unittest idioms. +"**/tests/*" = ["B017", "E402", "RUF012"] +# These two modules are hand-maintained data tables; the dict(kind=..., ...) +# keyword style keeps their long rows readable, so C408 is noise there. +"Python Monopoly Game/board_data.py" = ["C408"] +"Python Monopoly Game/cards.py" = ["C408"] + +[tool.ruff.lint.isort] +# Within one game folder its flat modules are first-party. +known-first-party = [ + "ai", + "assets", + "board", + "board_data", + "board_render", + "cards", + "effects", + "entities", + "game", + "main", + "merge_logic", + "models", + "physics", + "player", + "settings", + "simulation", + "solitaire", + "ui", + "visual_theme", +] + +[tool.mypy] +python_version = "3.11" +check_untyped_defs = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true + +[[tool.mypy.overrides]] +module = ["pygame.*"] +ignore_missing_imports = true + +[tool.bandit] +# B101: asserts here are type-narrowing invariants (mypy-verified states like +# "an auction dict exists while the auction dialog is open"), and the +# games are never shipped with `python -O`. +# B311: the games use `random` for dice, shuffles, and cosmetic jitter — +# there is no security context, so the CSPRNG warning is noise here. +# B110: deliberate try/except/pass fallbacks around save-file logging, where +# a failing log write must never crash the game window. +skips = ["B101", "B311", "B110"] +exclude_dirs = ["build", "dist", ".git"] diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..946eefb --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,10 @@ +# Development / CI toolchain, exact-pinned so local runs match CI. +# Runtime deps live in each game's own requirements.txt (pygame-ce); +# Solitaire needs only the standard library. +ruff==0.15.1 +mypy==1.19.1 +bandit==1.9.4 +pytest==9.0.3 +pytest-cov==7.1.0 +pip-audit==2.10.0 +pre-commit==4.6.0 From c5be77b1eb2c18477869a0f4862ae7d8a21a9f96 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Sat, 18 Jul 2026 09:48:32 +0530 Subject: [PATCH 09/11] Add quality-and-security CI workflow GitHub Actions gate on every PR and push to main, Python 3.11 + 3.13: pre-commit config validation; the four test suites run from inside each game folder with per-game engine coverage floors (Ludo 75, Monopoly 80, Orbital Orchard 90, Solitaire 32 -- each ~5 points under the measured value at introduction); the three headless pygame autotests; compileall; ruff; mypy once per game folder (the folders share flat module names); bandit; and pip-audit over the pinned dev toolchain and game runtime deps. SDL dummy drivers keep everything headless. Co-Authored-By: Claude Fable 5 --- .github/workflows/quality-and-security.yml | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 .github/workflows/quality-and-security.yml diff --git a/.github/workflows/quality-and-security.yml b/.github/workflows/quality-and-security.yml new file mode 100644 index 0000000..6089fd5 --- /dev/null +++ b/.github/workflows/quality-and-security.yml @@ -0,0 +1,92 @@ +# Quality & Security gate for the four games. +# +# Notes for maintainers (and agents): +# - The four game folders use flat module layouts with overlapping module +# names, so mypy runs once per folder and pytest runs from inside each +# folder (which also isolates the tests' sys.path shim). +# - The pygame games run fully headless via the SDL dummy drivers; the +# Solitaire suite only exercises the Tk-free KlondikeGame model. +# - Coverage floors are per game, measured over the engine modules listed in +# each pytest command, and set ~5 points below the measured value when the +# floor was introduced. Raise them as coverage grows; never lower them to +# make a failing change pass. +name: Quality & Security + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + quality: + runs-on: ubuntu-latest + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.13"] + env: + SDL_VIDEODRIVER: dummy + SDL_AUDIODRIVER: dummy + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt -r "Python Ludo Game/requirements.txt" + + - name: Validate pre-commit config + run: pre-commit validate-config + + - name: Ludo tests (coverage floor 75) + working-directory: Python Ludo Game + run: python -m pytest tests -q --cov=game --cov=board --cov=ai --cov=models --cov-fail-under=75 + + - name: Monopoly tests (coverage floor 80) + working-directory: Python Monopoly Game + run: python -m pytest tests -q --cov=game --cov=ai --cov=cards --cov=board_data --cov=player --cov-fail-under=80 + + - name: Orbital Orchard tests (coverage floor 90) + working-directory: Python Sputnika Game + run: python -m pytest tests -q --cov=merge_logic --cov=physics --cov-fail-under=90 + + - name: Solitaire tests (coverage floor 32) + working-directory: Python Solitaire Game + run: python -m pytest tests -q --cov=solitaire --cov-fail-under=32 + + - name: Headless autotests (full app smoke) + run: | + (cd "Python Ludo Game" && LUDO_AUTOTEST=1 python main.py) + (cd "Python Monopoly Game" && MONOPOLY_AUTOTEST=1 python main.py) + (cd "Python Sputnika Game" && ORBITAL_ORCHARD_AUTOTEST=1 python main.py) + + - name: Byte-compile everything + run: python -m compileall -q . + + - name: Ruff + run: python -m ruff check . + + - name: Mypy (one run per game folder) + run: | + python -m mypy "Python Ludo Game" + python -m mypy "Python Monopoly Game" + python -m mypy "Python Sputnika Game" + python -m mypy "Python Solitaire Game" + + - name: Bandit + run: python -m bandit -c pyproject.toml -r . -q + + - name: pip-audit (dev pins + game runtime deps) + run: > + python -m pip_audit + -r requirements-dev.txt + -r "Python Ludo Game/requirements.txt" From ada29bbadb70a5cef45c783c673b2660ba7d9c71 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Sat, 18 Jul 2026 09:49:56 +0530 Subject: [PATCH 10/11] Add AGENTS.md agent guide with a thin CLAUDE.md importer AGENTS.md is the single source of truth for any coding agent: what the four games are, the mandatory workflow skills, the repository map, the UI-free-engine + thin-shell architecture pattern, coding conventions (self-contained game folders, flat imports, settings-owned tuning knobs, docstring style, %APPDATA% persistence, narrowing asserts, seeded randomness), the exact quality-gate commands CI runs, Windows/PowerShell gotchas, testing conventions, PyInstaller builds, and git/PR rules. CLAUDE.md simply @-imports it so the two can never drift. Co-Authored-By: Claude Fable 5 --- AGENTS.md | 210 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ CLAUDE.md | 8 +++ 2 files changed, 218 insertions(+) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..4a708ec --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,210 @@ +# AGENTS.md — guide for AI coding agents working in this repository + +This file is the single source of truth for ANY coding agent (Claude Code, +Codex, Copilot, or anything else) touching this repo. `CLAUDE.md` simply +imports it. If repo conventions change, update THIS file in the same commit. + +## 1. What this project is + +Four self-contained Python desktop games, each in its own folder, each +shipping as a single Windows `.exe` via PyInstaller: + +| Folder | Game | Stack | +|---|---|---| +| `Python Sputnika Game/` | **Orbital Orchard** — Suika-style merge puzzle in a circular bubble | pygame-ce | +| `Python Solitaire Game/` | **Klondike Solitaire** — hints, auto-solve, undo | tkinter (stdlib only) | +| `Python Monopoly Game/` | **Monopoly** — full standard rules, AI seats, trading, auctions | pygame-ce | +| `Python Ludo Game/` | **Ludo** — 4-6 players, clockwise movement, classic 4P cross + radial 5P/6P boards | pygame-ce | + +These are hobby games, but they are maintained to production hygiene: typed, +linted, security-scanned, and tested in CI. All art and sound is procedural — +there are no image/audio assets except the window-icon `.ico` files. + +## 2. Mandatory workflow skills + +Before starting ANY task in this repo, activate (if your environment provides +them) the skills the owner uses everywhere: + +| When | Skill | +|---|---| +| First, always | `using-superpowers` (skill-discovery discipline) | +| Writing/reviewing/refactoring code | `karpathy-guidelines` (surgical changes, no speculative complexity) | +| Any feature or bugfix | `test-driven-development` | +| Before claiming something works | `verification-before-completion` (run the gates; evidence before assertions) | + +The Karpathy rules matter here specifically: each game deliberately duplicates +small helpers instead of sharing a package (see §5) — do not "helpfully" +deduplicate across game folders. + +## 3. Repository map + +``` +Games/ +├── AGENTS.md / CLAUDE.md -- this guide (CLAUDE.md just imports it) +├── README.md -- player-facing overview +├── pyproject.toml -- ruff + mypy + bandit config (repo-wide) +├── requirements-dev.txt -- exact-pinned dev/CI toolchain +├── .pre-commit-config.yaml -- check-only hooks (never rewrite files) +├── .github/workflows/quality-and-security.yml +└── Python Game/ -- one self-contained game per folder + ├── main.py -- entry point (Solitaire: solitaire.py) + ├── game.py -- UI-free rules engine (pygame games) + ├── settings.py -- ALL tuning knobs and constants + ├── tests/ -- headless test suite + ├── requirements.txt -- runtime deps (pygame-ce or nothing) + └── .spec -- PyInstaller build recipe +``` + +Per-game module patterns (pygame games): `models.py` (dataclasses), +`ai.py` (heuristics), `board_render.py`/`ui.py`/`visual_theme.py`/`assets.py` +(drawing), `simulation.py` (seeded all-AI batches for tuning). Solitaire is a +single file: `KlondikeGame` (rules) + `SolitaireApp` (tkinter shell). + +## 4. Architecture pattern (all four games) + +**UI-free engine + thin shell.** The rules engine (`game.py` / +`KlondikeGame`) never imports pygame/tkinter and is driven entirely through +methods; humans (clicks), AI (`ai.py`), tests, and simulations all call the +same engine API. The shell (`main.py`) owns the window, input routing, timers, +animation state, and persistence. Keep it that way: + +- New rules go in the engine with tests; the shell only translates input. +- Engine state machines are explicit (`awaiting`/`phase` strings). The UI + builds buttons from engine state each frame instead of caching decisions. +- Animation is presentation-only: engines update instantly; the shell's + waypoint queues (token hops) merely ease pixels toward engine truth. +- Ludo specifics: `board.py` owns the authoritative track *indices*; + `board_render.py`'s `DisplayLayout` owns every on-screen coordinate. The + ordering of `DisplayLayout.track_positions` alone decides movement + direction (clockwise). `tests/test_board_geometry.py` locks continuity, + clockwise winding, and start/yard/home alignment — change geometry only + with those tests green and a headless PNG render inspected. + +## 5. Coding conventions + +- Python 3.11+; `from __future__ import annotations`; modern typing + (`X | None`, pep585 builtins). Line length 120 (ruff enforces). +- **Self-contained games.** Never import across game folders and never + create a shared package. Small helpers (`_atomic_write`, `_log_error`, + `_resource_path`, window scaling) are deliberately duplicated per game so + each folder builds into a standalone exe; keep the copies textually + consistent when you touch one. +- The folder names contain spaces — always quote paths in commands, specs, + and CI. +- Flat module layout: game code does `from game import ...`; tests insert + the game folder into `sys.path` first (the shim at the top of every test + file). Because all four games share module names (`main`, `game`, + `settings`, ...), tools that build one module graph (mypy, pytest) must be + run per game folder — never across the whole repo at once. +- `settings.py` owns every tuning knob, named and commented with the "why". + No bare magic numbers in logic code. +- Docstring style: every module and def carries a docstring; beginner-facing + explanations use full-sentence prose, with "Beginner note:" blocks for + concepts a newcomer would trip on. Match this in new code. +- Persistence: saves live under `%APPDATA%\\` (never next to the + source — PyInstaller one-file builds unpack to a temp dir), written via + the atomic temp-file + `os.replace` pattern, with failures logged to + `error.log` rather than crashing the game. +- Type-narrowing `assert`s are the house pattern for Optional state whose + invariant the UI guarantees (e.g. Monopoly's `_require_game()`); bandit's + B101 is skipped for this reason. Do not use asserts for input validation. +- Randomness always flows through each game's seeded `random.Random` (or + `random` for cosmetics only) — that is what makes simulations and tests + reproducible. B311 is skipped: dice are not cryptography. + +## 6. Quality gates (run before claiming done) + +Install once: `pip install -r requirements-dev.txt` plus +`pip install -r "Python Ludo Game/requirements.txt"` (any one pygame game's +requirements file provides pygame-ce for all three). + +From the repo root, all of these must pass — CI runs exactly this set on +Python 3.11 and 3.13: + +```powershell +# Headless env for the pygame games (PowerShell syntax) +$env:SDL_VIDEODRIVER = 'dummy'; $env:SDL_AUDIODRIVER = 'dummy' + +# Tests with coverage floors, run FROM INSIDE each game folder +cd "Python Ludo Game"; python -m pytest tests -q --cov=game --cov=board --cov=ai --cov=models --cov-fail-under=75; cd .. +cd "Python Monopoly Game"; python -m pytest tests -q --cov=game --cov=ai --cov=cards --cov=board_data --cov=player --cov-fail-under=80; cd .. +cd "Python Sputnika Game"; python -m pytest tests -q --cov=merge_logic --cov=physics --cov-fail-under=90; cd .. +cd "Python Solitaire Game"; python -m pytest tests -q --cov=solitaire --cov-fail-under=32; cd .. + +# Static gates (repo root) +python -m compileall -q . +python -m ruff check . +python -m mypy "Python Ludo Game" +python -m mypy "Python Monopoly Game" +python -m mypy "Python Sputnika Game" +python -m mypy "Python Solitaire Game" +python -m bandit -c pyproject.toml -r . -q +``` + +Hard rules: +- Coverage floors may be raised as coverage grows; **never lower one** to + make a failing change pass. +- If you change a CI command, change the identical command here in §6 in the + same commit. +- Tool versions are pinned in `requirements-dev.txt` and mirrored in + `.pre-commit-config.yaml` (the ruff rev) — bump them together. +- Pre-commit hooks are check-only; never add hooks that rewrite files. + +## 7. Windows / PowerShell gotchas + +The owner develops on Windows 11 with PowerShell 5.1: + +- No `&&` / `||` chaining in PowerShell 5.1 — use `;` or `if ($?) { ... }`. +- `Out-File`/`Set-Content` default to UTF-16; pass `-Encoding utf8` (and + note PS 5.1 writes a BOM — for files other tools parse, prefer + `[System.IO.File]::WriteAllText(...)`, which writes BOM-free UTF-8). + Multi-line commit messages: write to a temp file, then `git commit -F`. +- Always quote the space-containing game folder paths. +- Headless runs need `SDL_VIDEODRIVER=dummy` (and `SDL_AUDIODRIVER=dummy`); + in CI these are job-level env vars. + +## 8. Testing conventions + +- Framework: `unittest`-style classes, run via pytest (CI) or + `python -m unittest discover -s tests` (both work). Every test module, + class, and test carries a docstring stating the behaviour under test. +- Tests are engine-first and fully headless: pygame under the SDL dummy + driver; Solitaire tests exercise only the Tk-free `KlondikeGame` (never + instantiate `Tk()` in a test). +- Full-app smoke tests: `LUDO_AUTOTEST=1`, `MONOPOLY_AUTOTEST=1`, and + `ORBITAL_ORCHARD_AUTOTEST=1` each play a short hidden-window session of + `main.py` and exit 0. +- Visual changes to the Ludo board: render each mode headless to a PNG and + inspect it (create a small scratch script that calls + `BoardRenderer.draw`/`draw_static` and `pygame.image.save`), in addition + to keeping `tests/test_board_geometry.py` green. +- Determinism: seed every game/simulation in tests; never sleep or rely on + wall-clock time. + +## 9. Building the executables + +From inside a game's folder: + +```powershell +pip install pyinstaller +pyinstaller "Ludo Game.spec" # or the matching spec in that folder +``` + +Output lands in `dist\`. The specs produce single-file, windowed exes. +Bundled read-only resources (window icons) are declared in the spec's +`datas` and resolved at runtime through that game's `_resource_path()` +(which handles `sys._MEIPASS`). Save data is NOT bundled — it lives in +`%APPDATA%` so it survives rebuilds and moves. + +## 10. Git and PR conventions + +- Branch names: `claude/` or `codex/` by agent, kebab-case. +- Commits: imperative, single-purpose subject; body explains what and why. + Stage related work into separate commits rather than one mega-commit. +- Never commit generated output: `build/`, `dist/`, `*.exe`, `__pycache__`, + save-data JSON (all already gitignored). +- PRs target `main`, one topic per PR, imperative title, body summarising + the change plus how it was verified. All §6 gates must be green locally + before pushing; the `Quality & Security` workflow must be green before + merging. +- Do not amend or force-push shared history; add follow-up commits instead. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7876aac --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,8 @@ +# CLAUDE.md + +`AGENTS.md` is the single source of truth for how agents work in this +repository — architecture, conventions, quality gates, and workflow. It is +imported below so Claude Code loads it automatically; edit AGENTS.md, not +this file. + +@AGENTS.md From 393aa0404095cd3af746f7e4194746486cf87638 Mon Sep 17 00:00:00 2001 From: DoRmAmMu1997 Date: Sat, 18 Jul 2026 09:51:04 +0530 Subject: [PATCH 11/11] Ignore tool caches and coverage data files Co-Authored-By: Claude Fable 5 --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 977ee48..2fb258a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,9 @@ __pycache__/ # Local game save data save_data.json solitaire_high_score.json + +# Tool caches and coverage data +.coverage +.pytest_cache/ +.mypy_cache/ +.ruff_cache/