Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions catanatron/catanatron/models/map.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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 = {
Expand Down
113 changes: 76 additions & 37 deletions catanatron/catanatron/models/spiral.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
74 changes: 73 additions & 1 deletion tests/models/test_spiral.py
Original file line number Diff line number Diff line change
@@ -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():
Expand Down Expand Up @@ -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)
59 changes: 31 additions & 28 deletions ui/src/pages/Tile.scss
Original file line number Diff line number Diff line change
@@ -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%;
}
}

5 changes: 4 additions & 1 deletion ui/src/pages/Tile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,10 @@ export function NumberToken({
return (
<Paper
elevation={3}
className={cn("number-token", className, { flashing: flashing })}
className={cn("number-token", className, {
flashing: flashing,
"number-token-red": number === 6 || number === 8,
})}
style={style}
>
<div>{number}</div>
Expand Down
Loading