Skip to content

Commit 20e15c8

Browse files
committed
🐲 Better state constructors, pydantic fixes
1 parent b298c4f commit 20e15c8

File tree

9 files changed

+76
-63
lines changed

9 files changed

+76
-63
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ cover-html: cover-base
6161
coverage report
6262

6363
cover: cover-html
64-
$(OPEN_FILE_COMMAND) htmlcov/index.html &
64+
$(OPEN_FILE_COMMAND) htmlcov/index.html > /dev/null 2>&1 &
6565
$(DEL_COMMAND) .coverage*
6666

6767
cover-fast:

codenames/classic/board.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
import logging
44
import random
5+
from typing import Self
56

67
from codenames.classic.color import ClassicColor, ClassicTeam
78
from codenames.classic.types import ClassicCard, ClassicCards
89
from codenames.generic.board import Board, Vocabulary
910
from codenames.utils.builder import extract_random_subset
11+
from codenames.utils.vocabulary.languages import get_vocabulary
1012

1113
log = logging.getLogger(__name__)
1214

@@ -29,6 +31,11 @@ def neutral_cards(self) -> ClassicCards:
2931
def assassin_cards(self) -> ClassicCards:
3032
return self.cards_for_color(ClassicColor.ASSASSIN)
3133

34+
@classmethod
35+
def from_language(cls, language: str) -> Self:
36+
vocabulary = get_vocabulary(language=language)
37+
return cls.from_vocabulary(vocabulary=vocabulary)
38+
3239
@classmethod
3340
def from_vocabulary(
3441
cls,
@@ -37,7 +44,7 @@ def from_vocabulary(
3744
assassin_amount: int = 1,
3845
first_team: ClassicTeam | None = None,
3946
seed: int | None = None,
40-
) -> ClassicBoard:
47+
) -> Self:
4148
if seed:
4249
random.seed(seed)
4350

codenames/classic/runner.py

Lines changed: 6 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@
55
from typing import Collection, Iterator
66

77
from codenames.classic.board import ClassicBoard
8-
from codenames.classic.color import ClassicColor, ClassicTeam
9-
from codenames.classic.score import Score, TeamScore
8+
from codenames.classic.color import ClassicTeam
109
from codenames.classic.state import ClassicGameState
1110
from codenames.classic.winner import Winner
1211
from codenames.generic.exceptions import InvalidGuess
@@ -19,7 +18,6 @@
1918
TeamPlayers,
2019
)
2120
from codenames.utils.formatting import wrap
22-
from codenames.utils.vocabulary.languages import get_vocabulary
2321

2422
log = logging.getLogger(__name__)
2523

@@ -64,9 +62,11 @@ def __init__(
6462
self, players: ClassicGamePlayers, state: ClassicGameState | None = None, board: ClassicBoard | None = None
6563
):
6664
self.players = players
67-
if (not state and not board) or (state and board):
68-
raise ValueError("Exactly one of state or board must be provided.")
69-
self.state = state or new_game_state(board=board)
65+
if not state:
66+
if not board:
67+
raise ValueError("Exactly one of state or board must be provided.")
68+
state = ClassicGameState.from_board(board=board)
69+
self.state = state
7070
self.clue_given_subscribers: list[ClueGivenSubscriber] = []
7171
self.guess_given_subscribers: list[GuessGivenSubscriber] = []
7272

@@ -144,42 +144,6 @@ def _get_guess_until_valid(self, operative: Operative) -> GivenGuess | None:
144144
pass
145145

146146

147-
def new_game_state(board: ClassicBoard | None = None, language: str | None = None) -> ClassicGameState:
148-
board = _get_board(board=board, language=language)
149-
if not board.is_clean:
150-
raise ValueError("Board must be clean.")
151-
first_team = _determine_first_team(board)
152-
score = build_score(board)
153-
return ClassicGameState(
154-
board=board,
155-
score=score,
156-
current_team=first_team,
157-
current_player_role=PlayerRole.SPYMASTER,
158-
)
159-
160-
161-
def _get_board(board: ClassicBoard | None, language: str | None) -> ClassicBoard:
162-
if board is not None:
163-
return board
164-
if language is None:
165-
raise ValueError("Either board or language must be provided.")
166-
vocabulary = get_vocabulary(language=language)
167-
return ClassicBoard.from_vocabulary(vocabulary=vocabulary)
168-
169-
170-
def build_score(board: ClassicBoard) -> Score:
171-
blue_score = TeamScore(total=len(board.blue_cards), revealed=len(board.revealed_cards_for_color(ClassicColor.BLUE)))
172-
red_score = TeamScore(total=len(board.red_cards), revealed=len(board.revealed_cards_for_color(ClassicColor.RED)))
173-
score = Score(blue=blue_score, red=red_score)
174-
return score
175-
176-
177-
def _determine_first_team(board: ClassicBoard) -> ClassicTeam:
178-
if len(board.blue_cards) >= len(board.red_cards):
179-
return ClassicTeam.BLUE
180-
return ClassicTeam.RED
181-
182-
183147
def find_team(players: Collection[Player], team: ClassicTeam) -> TeamPlayers:
184148
spymaster = operative = None
185149
for player in players:

codenames/classic/state.py

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import logging
2-
from typing import Any
2+
from typing import Any, Self
33

44
from pydantic import field_validator
55

@@ -18,7 +18,12 @@
1818
)
1919
from codenames.generic.move import PASS_GUESS, QUIT_GAME, Clue, GivenClue, Guess
2020
from codenames.generic.player import PlayerRole
21-
from codenames.generic.state import OperativeState, PlayerState, SpymasterState
21+
from codenames.generic.state import (
22+
OperativeState,
23+
PlayerState,
24+
SpymasterState,
25+
TeamScore,
26+
)
2227
from codenames.utils.formatting import wrap
2328

2429
log = logging.getLogger(__name__)
@@ -63,6 +68,24 @@ class ClassicGameState(ClassicSpymasterState):
6368
left_guesses: int = 0
6469
winner: Winner | None = None
6570

71+
@classmethod
72+
def from_language(cls, language: str) -> Self:
73+
board = ClassicBoard.from_language(language)
74+
return cls.from_board(board=board)
75+
76+
@classmethod
77+
def from_board(cls, board: ClassicBoard) -> Self:
78+
if not board.is_clean:
79+
raise ValueError("Board must be clean.")
80+
first_team = _determine_first_team(board)
81+
score = _build_score(board)
82+
return cls(
83+
board=board,
84+
score=score,
85+
current_team=first_team,
86+
current_player_role=PlayerRole.SPYMASTER,
87+
)
88+
6689
@property
6790
def spymaster_state(self) -> ClassicSpymasterState:
6891
return ClassicSpymasterState(
@@ -181,3 +204,16 @@ def _update_score(self, given_guess: ClassicGivenGuess):
181204
game_ended = self.score.add_point(score_team)
182205
if game_ended:
183206
self.winner = Winner(team=score_team, reason=WinningReason.TARGET_SCORE_REACHED)
207+
208+
209+
def _determine_first_team(board: ClassicBoard) -> ClassicTeam:
210+
if len(board.blue_cards) >= len(board.red_cards):
211+
return ClassicTeam.BLUE
212+
return ClassicTeam.RED
213+
214+
215+
def _build_score(board: ClassicBoard) -> Score:
216+
blue_score = TeamScore(total=len(board.blue_cards), revealed=len(board.revealed_cards_for_color(ClassicColor.BLUE)))
217+
red_score = TeamScore(total=len(board.red_cards), revealed=len(board.revealed_cards_for_color(ClassicColor.RED)))
218+
score = Score(blue=blue_score, red=red_score)
219+
return score

codenames/duet/board.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import logging
44
import random
5+
from typing import Self
56

67
from codenames.duet.card import DuetColor
78
from codenames.duet.types import DuetCard, DuetCards
@@ -34,6 +35,10 @@ def assassin_cards(self) -> DuetCards:
3435
def irrelevant_cards(self) -> DuetCards:
3536
return self.cards_for_color(DuetColor.IRRELEVANT)
3637

38+
@classmethod
39+
def cast_board(cls, board: Board[DuetColor]) -> Self:
40+
return cls(language=board.language, cards=board.cards)
41+
3742
@classmethod
3843
def from_vocabulary(
3944
cls,
@@ -42,7 +47,7 @@ def from_vocabulary(
4247
green_amount: int = 9,
4348
assassin_amount: int = 3,
4449
seed: int | None = None,
45-
) -> DuetBoard:
50+
) -> Self:
4651
if seed:
4752
random.seed(seed)
4853

@@ -60,11 +65,7 @@ def from_vocabulary(
6065
return cls(language=vocabulary.language, cards=all_cards)
6166

6267
@classmethod
63-
def from_board(cls, board: Board[DuetColor]) -> DuetBoard:
64-
return cls(language=board.language, cards=board.cards)
65-
66-
@classmethod
67-
def dual_board(cls, board: DuetBoard, overlap_ratio: float = 3, seed: int | None = None) -> DuetBoard:
68+
def dual_board(cls, board: DuetBoard, overlap_ratio: float = 3, seed: int | None = None) -> Self:
6869
if seed:
6970
random.seed(seed)
7071
# Given board analysis

codenames/duet/state.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from enum import StrEnum
55
from typing import Any, Self
66

7-
from pydantic import BaseModel, field_validator, model_validator
7+
from pydantic import BaseModel, Field, field_validator, model_validator
88

99
from codenames.duet.board import DuetBoard
1010
from codenames.duet.card import DuetColor
@@ -41,7 +41,7 @@ class DuetPlayerState(PlayerState[DuetColor, DuetTeam]):
4141
score: Score
4242
current_player_role: PlayerRole = PlayerRole.SPYMASTER
4343
current_team: DuetTeam = DuetTeam.MAIN
44-
dual_given_words: list[str] = []
44+
dual_given_words: list[str] = Field(default_factory=list)
4545

4646
@property
4747
def illegal_clue_words(self) -> WordGroup:
@@ -165,7 +165,7 @@ def get_spymaster_state(self, dual_state: DuetSideState | None) -> DuetSpymaster
165165
def get_operative_state(self, dual_state: DuetSideState | None) -> DuetOperativeState:
166166
dual_player_state = dual_state.get_spymaster_state(None) if dual_state else None
167167
return DuetOperativeState(
168-
board=DuetBoard.from_board(self.board.censored),
168+
board=DuetBoard.cast_board(self.board.censored),
169169
given_clues=self.given_clues,
170170
given_guesses=self.given_guesses,
171171
score=self.score,

codenames/generic/state.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import logging
44
from typing import Generic
55

6-
from pydantic import BaseModel
6+
from pydantic import BaseModel, Field
77

88
from codenames.generic.board import Board, WordGroup
99
from codenames.generic.card import C
@@ -20,8 +20,8 @@ class PlayerState(BaseModel, Generic[C, T]):
2020

2121
board: Board[C]
2222
current_team: T
23-
given_clues: list[GivenClue[T]] = []
24-
given_guesses: list[GivenGuess[C, T]] = []
23+
given_clues: list[GivenClue[T]] = Field(default_factory=list)
24+
given_guesses: list[GivenGuess[C, T]] = Field(default_factory=list)
2525

2626
@property
2727
def given_clue_words(self) -> WordGroup:
@@ -37,7 +37,7 @@ class SpymasterState(PlayerState, Generic[C, T]):
3737
Represents all the information that is available to a Spymaster.
3838
"""
3939

40-
clues: list[Clue] = []
40+
clues: list[Clue] = Field(default_factory=list)
4141

4242

4343
class OperativeState(PlayerState, Generic[C, T]):

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ minor_tags = [
7575
"",
7676
"🔥",
7777
"🐲",
78+
"🎡",
7879
]
7980
patch_tags = [
8081
"📝",
@@ -87,7 +88,6 @@ patch_tags = [
8788
"🌴",
8889
"🎢",
8990
"🏖️",
90-
"🎡",
9191
]
9292

9393
# Test

tests/classic/test_game_state.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,18 @@
44

55
from codenames.classic.board import ClassicBoard
66
from codenames.classic.color import ClassicColor, ClassicTeam
7-
from codenames.classic.runner import new_game_state
87
from codenames.classic.state import ClassicGameState, ClassicPlayerState
98
from codenames.classic.types import ClassicCard, ClassicGivenClue, ClassicGivenGuess
109
from codenames.classic.winner import Winner, WinningReason
1110
from codenames.generic.exceptions import InvalidGuess, InvalidTurn
1211
from codenames.generic.move import PASS_GUESS, Clue, Guess
1312
from codenames.generic.player import PlayerRole
13+
from codenames.utils.vocabulary.languages import SupportedLanguage
1414
from tests.utils.moves import ClueMove, GuessMove, Move, PassMove, get_moves
1515

1616

1717
def test_game_state_flow(board_10: ClassicBoard):
18-
game_state = new_game_state(board=board_10)
18+
game_state = ClassicGameState.from_board(board=board_10)
1919
assert game_state.current_team == ClassicTeam.BLUE
2020
assert game_state.current_player_role == PlayerRole.SPYMASTER
2121
assert _get_moves(game_state) == []
@@ -138,7 +138,7 @@ def test_game_state_flow(board_10: ClassicBoard):
138138

139139

140140
def test_game_state_json_serialization_and_load(board_10: ClassicBoard):
141-
game_state = new_game_state(board=board_10)
141+
game_state = ClassicGameState.from_board(board=board_10)
142142
game_state.process_clue(Clue(word="A", card_amount=2))
143143
game_state.process_guess(Guess(card_index=0))
144144
game_state.process_guess(Guess(card_index=1))
@@ -156,3 +156,8 @@ def _get_moves(state: ClassicPlayerState) -> list[Move]:
156156
given_guesses=state.given_guesses,
157157
current_turn=state.current_player_role,
158158
)
159+
160+
161+
def test_game_state_from_language():
162+
game_state = ClassicGameState.from_language(language=SupportedLanguage.ENGLISH)
163+
assert len(game_state.board.cards) == 25

0 commit comments

Comments
 (0)