From 89e9be8809cf62ad5870acbddfaca03180b38738 Mon Sep 17 00:00:00 2001 From: Bryan Collazo Date: Sun, 22 Mar 2026 10:42:23 -0400 Subject: [PATCH 1/2] Paint pips Red in UI --- ui/src/pages/Tile.scss | 59 ++++++++++++++++++++++-------------------- ui/src/pages/Tile.tsx | 5 +++- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/ui/src/pages/Tile.scss b/ui/src/pages/Tile.scss index a510f9755..7c0f36dbe 100644 --- a/ui/src/pages/Tile.scss +++ b/ui/src/pages/Tile.scss @@ -1,38 +1,41 @@ @use "../variables.scss"; .number-token { - --scale-factor: 0.65; - --base-font-size: 100%; - height: 4ch; - width: 4ch; - text-align: center; - padding: 5px; - line-height: .8rem; - position: relative; - display: flex; - flex-direction: column; - .pips { - font-size: calc(var(--base-font-size) * 60%); - } + --scale-factor: 0.65; + --base-font-size: 100%; + height: 4ch; + width: 4ch; + text-align: center; + padding: 5px; + line-height: 0.8rem; + position: relative; + display: flex; + flex-direction: column; + .pips { + font-size: calc(var(--base-font-size) * 60%); + } - &.flashing { - animation: pulse 0.5s ease-in-out infinite alternate; - cursor: pointer; - } + &.number-token-red { + color: #ff5555; + } + + &.flashing { + animation: pulse 0.5s ease-in-out infinite alternate; + cursor: pointer; + } } @keyframes pulse { - 0% { - box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.3); - } - 100% { - box-shadow: 0 0 0 4px rgba(200, 200, 200, 0.6); - } + 0% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.3); + } + 100% { + box-shadow: 0 0 0 4px rgba(200, 200, 200, 0.6); + } } -@media(max-width: variables.$sm-breakpoint) { - .number-token { - --base-font-size: 75%; - } +@media (max-width: variables.$sm-breakpoint) { + .number-token { + --base-font-size: 75%; + } } - diff --git a/ui/src/pages/Tile.tsx b/ui/src/pages/Tile.tsx index 4a757bccc..0d4a0c346 100644 --- a/ui/src/pages/Tile.tsx +++ b/ui/src/pages/Tile.tsx @@ -32,7 +32,10 @@ export function NumberToken({ return (
{number}
From 5c9ba8b373cff62cd75c2d467d989ec9926adcdd Mon Sep 17 00:00:00 2001 From: Bryan Collazo Date: Sun, 22 Mar 2026 11:13:46 -0400 Subject: [PATCH 2/2] Allow random starting spiral position --- catanatron/catanatron/models/map.py | 11 ++- catanatron/catanatron/models/spiral.py | 113 +++++++++++++++++-------- tests/models/test_spiral.py | 74 +++++++++++++++- 3 files changed, 158 insertions(+), 40 deletions(-) diff --git a/catanatron/catanatron/models/map.py b/catanatron/catanatron/models/map.py index cdb30a591..117ebf685 100644 --- a/catanatron/catanatron/models/map.py +++ b/catanatron/catanatron/models/map.py @@ -15,7 +15,7 @@ FastResource, NodeRef, ) -from catanatron.models.spiral import spiral_land_coordinates +from catanatron.models.spiral import outer_land_coordinates, spiral_land_coordinates from catanatron.models.tiles import EdgeId, LandTile, NodeId, Port, Tile, Water NUM_NODES = 54 @@ -380,7 +380,7 @@ def initialize_tiles( ) # iterate in order of official spiral and assign numbers, skipping desert tile - start = (2, -2, 0) if map_template == BASE_MAP_TEMPLATE else (1, -1, 0) + start = get_starting_spiral_coordinate(all_tiles) i = 0 for coordinate in spiral_land_coordinates(all_tiles, start): tile = all_tiles[coordinate] @@ -395,6 +395,13 @@ def initialize_tiles( return all_tiles +def get_starting_spiral_coordinate(all_tiles: Dict[Coordinate, Tile]) -> Coordinate: + outer_ring = outer_land_coordinates(all_tiles) + if not outer_ring: + raise ValueError("official_spiral number placement requires outer land tiles") + return random.choice(outer_ring) + + def get_nodes_and_edges(tiles, coordinate: Coordinate, node_autoinc): """Get pre-existing nodes and edges in board for given tile coordinate""" nodes = { diff --git a/catanatron/catanatron/models/spiral.py b/catanatron/catanatron/models/spiral.py index b44eed2de..3568b43f4 100644 --- a/catanatron/catanatron/models/spiral.py +++ b/catanatron/catanatron/models/spiral.py @@ -5,23 +5,64 @@ from catanatron.models.tiles import LandTile, Tile +COUNTERCLOCKWISE_RING_DIRECTIONS = ( + Direction.NORTHWEST, + Direction.WEST, + Direction.SOUTHWEST, + Direction.SOUTHEAST, + Direction.EAST, + Direction.NORTHEAST, +) + + +def cube_radius(coord: Coordinate) -> int: + return max(abs(component) for component in coord) + + +def ring_coordinates(radius: int) -> tuple[Coordinate, ...]: + if radius == 0: + return ((0, 0, 0),) + + coord = (radius, -radius, 0) + ring = [] + for direction in COUNTERCLOCKWISE_RING_DIRECTIONS: + for _ in range(radius): + ring.append(coord) + coord = add(coord, UNIT_VECTORS[direction]) + return tuple(ring) + + +def outer_land_coordinates( + all_tiles: Mapping[Coordinate, Tile], +) -> tuple[Coordinate, ...]: + """Return outer-ring land coordinates in deterministic coast-following order.""" + land_coords = { + coord for coord, tile in all_tiles.items() if isinstance(tile, LandTile) + } + if not land_coords: + return tuple() + + radius = max(cube_radius(coord) for coord in land_coords) + return tuple(coord for coord in ring_coordinates(radius) if coord in land_coords) + + def spiral_land_coordinates( all_tiles: Mapping[Coordinate, Tile], start: Coordinate ) -> Generator[Coordinate, None, None]: """ - Yield land-tile coordinates in coast-following spiral order from an outer-edge - start coordinate toward the center. + Yield land-tile coordinates ring by ring, from the outer ring to the center. + + The walk is based on cube-coordinate radius, not coastline tracing: it rotates + each ring so it starts at ``start`` and then steps inward to the next smaller + ring until reaching the center. - Requirements: - - ``start`` must be a land tile. - - ``start`` must be on the outer ring of the land mass. - - The land mass must be a single contiguous spiral-walkable shape, such as - the standard Catan board layouts. + This works for the standard convex Catan layouts, including the base and mini + boards. It is not intended for land masses with holes or interior water tiles, + because the algorithm assumes each radius forms a continuous ring of land. Raises: - ValueError: If ``start`` is not land. - - ValueError: If ``start`` is not on the outer edge. - - ValueError: If an initial coast-following direction cannot be determined. + - ValueError: If ``start`` is not on the outer ring. """ def is_land(coord: Coordinate) -> bool: @@ -33,35 +74,33 @@ def is_land(coord: Coordinate) -> bool: if all(is_land(add(start, UNIT_VECTORS[direction])) for direction in Direction): raise ValueError("start must be on the outer edge of the land mass") - directions = list(Direction) - directions.reverse() - - direction = None - for i, candidate in enumerate(directions): - previous = directions[i - 1] - if is_land(add(start, UNIT_VECTORS[candidate])) and not is_land( - add(start, UNIT_VECTORS[previous]) - ): - direction = candidate - break - - if direction is None: + # The spiral is defined by concentric cube-coordinate rings of land tiles. + land_coords = { + coord for coord, tile in all_tiles.items() if isinstance(tile, LandTile) + } + max_radius = max(cube_radius(coord) for coord in land_coords) + if start not in ring_coordinates(max_radius): raise ValueError("could not determine an initial coast-following direction") - total_land_tiles = sum( - 1 for tile in all_tiles.values() if isinstance(tile, LandTile) - ) - visited = set() - coord = start - - while len(visited) < total_land_tiles: - if coord not in visited: - visited.add(coord) - yield coord + current_start = start + for radius in range(max_radius, 0, -1): + ring = tuple( + coord for coord in ring_coordinates(radius) if coord in land_coords + ) + # Rotate each ring so traversal begins at the requested start for that ring. + start_index = ring.index(current_start) + yield from ring[start_index:] + yield from ring[:start_index] - next_coord = add(coord, UNIT_VECTORS[direction]) - if is_land(next_coord) and next_coord not in visited: - coord = next_coord - continue + if radius > 1: + inner_ring = tuple( + coord for coord in ring_coordinates(radius - 1) if coord in land_coords + ) + # Project the current ring index inward to the matching inner-ring index. + # Example: on the base map, outer index 0..11 maps to inner index 0..5, + # so outer 0/1 -> inner 0, outer 2/3 -> inner 1, and so on. + current_start = inner_ring[start_index * (radius - 1) // radius] - direction = directions[(directions.index(direction) + 1) % len(directions)] + # Emit the center after all non-zero-radius rings have been walked. + if (0, 0, 0) in land_coords: + yield (0, 0, 0) diff --git a/tests/models/test_spiral.py b/tests/models/test_spiral.py index 347425e1c..f89bca5f8 100644 --- a/tests/models/test_spiral.py +++ b/tests/models/test_spiral.py @@ -1,12 +1,60 @@ import pytest +from catanatron.models.coordinate_system import Direction, UNIT_VECTORS, add from catanatron.models.map import ( BASE_MAP_TEMPLATE, MINI_MAP_TEMPLATE, LandTile, initialize_tiles, ) -from catanatron.models.spiral import spiral_land_coordinates +from catanatron.models.spiral import outer_land_coordinates, spiral_land_coordinates + + +def test_outer_land_coordinates_base_map(): + all_tiles = initialize_tiles(BASE_MAP_TEMPLATE, number_placement="random") + + assert outer_land_coordinates(all_tiles) == ( + (2, -2, 0), + (2, -1, -1), + (2, 0, -2), + (1, 1, -2), + (0, 2, -2), + (-1, 2, -1), + (-2, 2, 0), + (-2, 1, 1), + (-2, 0, 2), + (-1, -1, 2), + (0, -2, 2), + (1, -2, 1), + ) + + +def test_outer_land_coordinates_mini_map(): + all_tiles = initialize_tiles(MINI_MAP_TEMPLATE, number_placement="random") + + assert outer_land_coordinates(all_tiles) == ( + (1, -1, 0), + (1, 0, -1), + (0, 1, -1), + (-1, 1, 0), + (-1, 0, 1), + (0, -1, 1), + ) + + +def test_outer_land_coordinates_excludes_interior_land_tiles(): + all_tiles = initialize_tiles(BASE_MAP_TEMPLATE, number_placement="random") + outer_coords = outer_land_coordinates(all_tiles) + + assert (0, 0, 0) not in outer_coords + assert all(isinstance(all_tiles[coord], LandTile) for coord in outer_coords) + assert all( + any( + not isinstance(all_tiles.get(add(coord, UNIT_VECTORS[direction])), LandTile) + for direction in Direction + ) + for coord in outer_coords + ) def test_spiral_land_coordinates_rejects_non_land_start(): @@ -51,3 +99,27 @@ def test_spiral_land_coordinates_mini_map_order_from_tile_one(): ids = [all_tiles[coord].id for coord in spiral_land_coordinates(all_tiles, start)] assert ids == [1, 6, 5, 4, 3, 2, 0] + + +def test_spiral_land_coordinates_accepts_all_base_outer_starts(): + all_tiles = initialize_tiles(BASE_MAP_TEMPLATE, number_placement="random") + + for start in outer_land_coordinates(all_tiles): + coords = tuple(spiral_land_coordinates(all_tiles, start)) + + assert len(coords) == 19 + assert len(set(coords)) == 19 + assert coords[0] == start + assert coords[-1] == (0, 0, 0) + + +def test_spiral_land_coordinates_accepts_all_mini_outer_starts(): + all_tiles = initialize_tiles(MINI_MAP_TEMPLATE, number_placement="random") + + for start in outer_land_coordinates(all_tiles): + coords = tuple(spiral_land_coordinates(all_tiles, start)) + + assert len(coords) == 7 + assert len(set(coords)) == 7 + assert coords[0] == start + assert coords[-1] == (0, 0, 0)