Refresh web UI and make ML imports optional
All checks were successful
ci / tests (push) Successful in 21s

This commit is contained in:
dan
2025-12-25 09:15:10 +03:00
parent f542747d5e
commit 2499deb071
49 changed files with 4367 additions and 500 deletions

152
tests/test_rulebook.py Normal file
View File

@@ -0,0 +1,152 @@
from __future__ import annotations
from typing import List, Tuple
import pytest
from catan.data import Resource
from catan.game import Game, GameConfig, Phase
def _prepare_main_phase(game: Game) -> None:
game.phase = Phase.MAIN
game.has_rolled = True
game.pending_discards.clear()
game.robber_move_required = False
def _assign_settlement(game: Game, player, hex_id: int, corner_index: int = 0) -> None:
corner_id = game.board.hexes[hex_id].corners[corner_index]
corner = game.board.corners[corner_id]
corner.owner = player.name
corner.building = "settlement"
player.settlements.add(corner_id)
def _find_resource_hex(game: Game) -> Tuple[int, Resource, int]:
for tile in game.board.hexes.values():
if tile.resource != Resource.DESERT and tile.number_token:
return tile.id, tile.resource, tile.number_token
raise RuntimeError("No producible hex found")
def _find_edge_path(game: Game, length: int) -> Tuple[List[Tuple[int, int, int]], List[Tuple[int, int, int]]]:
board = game.board
def dfs(corner_id, path_edges, path_corners, visited_edges):
if len(path_edges) == length:
return list(path_edges), list(path_corners)
for edge_id in board.corners[corner_id].edges:
if edge_id in visited_edges:
continue
visited_edges.add(edge_id)
edge = board.edges[edge_id]
next_corner = next(c for c in edge.corners if c != corner_id)
path_edges.append(edge_id)
path_corners.append(next_corner)
result = dfs(next_corner, path_edges, path_corners, visited_edges)
if result:
return result
path_edges.pop()
path_corners.pop()
visited_edges.remove(edge_id)
return None
for start in board.corners:
result = dfs(start, [], [start], set())
if result:
return result
raise RuntimeError("Unable to find connected edge path")
def test_resource_shortage_blocks_multiple_players() -> None:
game = Game(GameConfig(player_names=["A", "B", "C"]))
hex_id, resource, token = _find_resource_hex(game)
_assign_settlement(game, game.players[0], hex_id, 0)
_assign_settlement(game, game.players[1], hex_id, 1)
game.bank[resource] = 1
game._distribute_resources(token) # noqa: SLF001 - internal rule helper
assert game.players[0].resources[resource] == 0
assert game.players[1].resources[resource] == 0
assert game.bank[resource] == 1
def test_resource_shortage_single_player_gets_remaining() -> None:
game = Game(GameConfig(player_names=["A", "B", "C"]))
hex_id, resource, token = _find_resource_hex(game)
_assign_settlement(game, game.players[0], hex_id, 0)
game.bank[resource] = 1
game._distribute_resources(token) # noqa: SLF001
assert game.players[0].resources[resource] == 1
assert game.bank[resource] == 0
def test_robber_requires_valid_victim() -> None:
game = Game(GameConfig(player_names=["A", "B", "C"]))
_prepare_main_phase(game)
other = game.players[1]
target_hex = next(h for h in game.board.hexes if h != game.board.robber_hex)
_assign_settlement(game, other, target_hex, 0)
other.resources[Resource.BRICK] = 1
game.robber_move_required = True
with pytest.raises(ValueError, match="victim"):
game.move_robber(target_hex)
with pytest.raises(ValueError, match="adjacent"):
game.move_robber(target_hex, victim=game.players[2].name)
empty_hex = next(
hid
for hid in game.board.hexes
if hid not in {target_hex, game.board.robber_hex}
and all(game.board.corners[c].owner is None for c in game.board.hexes[hid].corners)
)
game.robber_move_required = True
with pytest.raises(ValueError, match="No valid victim"):
game.move_robber(empty_hex, victim=other.name)
game.robber_move_required = True
game.move_robber(target_hex, victim=other.name)
assert not game.robber_move_required
def test_trade_restrictions_enforced() -> None:
game = Game(GameConfig(player_names=["A", "B", "C"]))
_prepare_main_phase(game)
player = game.current_player
opponent = game.players[1]
player.resources[Resource.BRICK] = 2
opponent.resources[Resource.WOOL] = 2
with pytest.raises(ValueError, match="yourself"):
game.trade_with_player(player.name, {Resource.BRICK: 1}, {Resource.WOOL: 1})
with pytest.raises(ValueError, match="both ways"):
game.trade_with_player(opponent.name, {Resource.BRICK: 1}, {})
with pytest.raises(ValueError, match="positive"):
game.trade_with_player(opponent.name, {Resource.BRICK: 0}, {Resource.WOOL: 1})
with pytest.raises(ValueError, match="matching resources"):
game.trade_with_player(opponent.name, {Resource.BRICK: 1}, {Resource.BRICK: 1})
game.trade_with_player(opponent.name, {Resource.BRICK: 1}, {Resource.WOOL: 1})
assert player.resources[Resource.WOOL] == 1
assert opponent.resources[Resource.BRICK] == 1
def test_settlement_breaks_longest_road() -> None:
game = Game(GameConfig(player_names=["A", "B", "C"]))
player = game.players[0]
rival = game.players[1]
edges, corners = _find_edge_path(game, 5)
for edge_id in edges:
game.board.edges[edge_id].owner = player.name
player.roads.add(edge_id)
game._update_longest_road() # noqa: SLF001
assert player.longest_road
block_corner = corners[2]
game._build_settlement(rival, block_corner, require_connection=False) # noqa: SLF001
assert not player.longest_road