File size: 7,371 Bytes
2c6b921
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741f475
2e67341
f511789
2c6b921
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
741f475
 
 
 
f511789
 
 
 
741f475
 
 
 
2c6b921
 
 
 
 
 
 
 
 
 
741f475
 
 
2e67341
 
 
 
 
 
f511789
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
"""
Devil's Dozen - Game Engine Base Classes

This module defines the foundational data structures and enums used throughout
the game engine. All classes are immutable (frozen dataclasses) to ensure
thread-safety and predictable behavior.
"""

from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Sequence


class DiceType(Enum):
    """Type of dice used in the game."""
    D6 = 6
    D20 = 20


class GameMode(Enum):
    """Available game modes."""
    PEASANTS_GAMBLE = "peasants_gamble"
    ALCHEMISTS_ASCENT = "alchemists_ascent"
    KNUCKLEBONES = "knucklebones"
    ALIEN_INVASION = "alien_invasion"
    PIG = "pig"


class Tier(Enum):
    """Tiers for Alchemist's Ascent mode."""
    RED = 1    # 0-100 points, 8 dice
    GREEN = 2  # 101-200 points, 3 dice
    BLUE = 3   # 201-250 points, 1 die


class ScoringCategory(Enum):
    """Categories of scoring combinations."""
    SINGLE_ONE = auto()
    SINGLE_FIVE = auto()
    THREE_OF_A_KIND = auto()
    FOUR_OF_A_KIND = auto()
    FIVE_OF_A_KIND = auto()
    SIX_OF_A_KIND = auto()
    LOW_STRAIGHT = auto()      # 1-2-3-4-5
    HIGH_STRAIGHT = auto()     # 2-3-4-5-6
    FULL_STRAIGHT = auto()     # 1-2-3-4-5-6
    PAIR = auto()              # D20 mode
    SEQUENCE = auto()          # D20 mode
    TIER_BONUS = auto()        # D20 mode multipliers


@dataclass(frozen=True)
class ScoringBreakdown:
    """
    A single scoring component within a roll.

    Attributes:
        category: The type of scoring combination
        dice_values: The dice that contributed to this score
        points: Points awarded for this combination
        description: Human-readable description
    """
    category: ScoringCategory
    dice_values: tuple[int, ...]
    points: int
    description: str


@dataclass(frozen=True)
class ScoringResult:
    """
    Complete scoring result for a dice roll.

    Attributes:
        points: Total points scored
        breakdown: List of individual scoring components
        scoring_dice_indices: Indices of dice that scored
        is_bust: Whether no dice scored (automatic bust)
    """
    points: int
    breakdown: tuple[ScoringBreakdown, ...]
    scoring_dice_indices: frozenset[int]
    is_bust: bool = False

    @property
    def has_scoring_dice(self) -> bool:
        """Returns True if at least one die scored."""
        return len(self.scoring_dice_indices) > 0

    def __str__(self) -> str:
        if self.is_bust:
            return "BUST! No scoring dice."
        lines = [f"Total: {self.points} points"]
        for item in self.breakdown:
            lines.append(f"  - {item.description}: {item.points}")
        return "\n".join(lines)


@dataclass(frozen=True)
class DiceRoll:
    """
    Immutable representation of a dice roll.

    Attributes:
        values: Tuple of dice face values
        dice_type: Type of dice (D6 or D20)
    """
    values: tuple[int, ...]
    dice_type: DiceType = DiceType.D6

    def __post_init__(self) -> None:
        """Validate dice values are within valid range."""
        max_value = self.dice_type.value
        for value in self.values:
            if not (1 <= value <= max_value):
                raise ValueError(
                    f"Invalid die value {value} for {self.dice_type.name}. "
                    f"Must be between 1 and {max_value}."
                )

    def __len__(self) -> int:
        return len(self.values)

    def __getitem__(self, index: int) -> int:
        return self.values[index]

    @classmethod
    def from_sequence(
        cls,
        values: Sequence[int],
        dice_type: DiceType = DiceType.D6
    ) -> "DiceRoll":
        """Create a DiceRoll from any sequence type."""
        return cls(values=tuple(values), dice_type=dice_type)


@dataclass(frozen=True)
class TurnState:
    """
    Complete state of a player's turn.

    Attributes:
        active_dice: Current dice values in play
        held_indices: Indices of dice that have been held
        turn_score: Points accumulated this turn (not yet banked)
        roll_count: Number of rolls taken this turn
        is_hot_dice: Whether all dice have scored (can roll all again)
        tier: Current tier (Alchemist's Ascent only)
        previous_dice: Previous roll for reroll comparison (Tier 2)
    """
    active_dice: tuple[int, ...]
    held_indices: frozenset[int] = field(default_factory=frozenset)
    turn_score: int = 0
    roll_count: int = 0
    is_hot_dice: bool = False
    tier: Tier = Tier.RED
    previous_dice: tuple[int, ...] = field(default_factory=tuple)

    @property
    def available_dice_count(self) -> int:
        """Number of dice that can still be rolled."""
        return len(self.active_dice) - len(self.held_indices)

    @property
    def held_dice_values(self) -> tuple[int, ...]:
        """Values of the held dice."""
        return tuple(self.active_dice[i] for i in sorted(self.held_indices))

    @property
    def unheld_dice_values(self) -> tuple[int, ...]:
        """Values of dice not yet held."""
        return tuple(
            v for i, v in enumerate(self.active_dice)
            if i not in self.held_indices
        )


@dataclass(frozen=True)
class GameConfig:
    """
    Configuration for a game session.

    Attributes:
        mode: Game mode (Peasant's Gamble or Alchemist's Ascent)
        target_score: Score needed to win
        num_players: Number of players (2-4)
    """
    mode: GameMode
    target_score: int
    num_players: int = 2

    def __post_init__(self) -> None:
        """Validate configuration."""
        if self.mode == GameMode.KNUCKLEBONES:
            # Knucklebones is strictly 2-player
            if self.num_players != 2:
                raise ValueError("Knucklebones requires exactly 2 players.")
        elif self.mode == GameMode.PIG:
            # Pig supports 2-10 players
            if not 2 <= self.num_players <= 10:
                raise ValueError("Number of players for Pig must be between 2 and 10.")
        else:
            # Other modes support 2-4 players
            if not 2 <= self.num_players <= 4:
                raise ValueError("Number of players must be between 2 and 4.")

        if self.mode == GameMode.PEASANTS_GAMBLE:
            valid_targets = {3000, 5000, 10000}
            if self.target_score not in valid_targets:
                raise ValueError(
                    f"Target score for Peasant's Gamble must be one of {valid_targets}."
                )
        elif self.mode == GameMode.ALCHEMISTS_ASCENT:
            if self.target_score != 250:
                raise ValueError("Target score for Alchemist's Ascent must be 250.")
        elif self.mode == GameMode.KNUCKLEBONES:
            # Target score is not used in Knucklebones (game ends on full grid)
            pass
        elif self.mode == GameMode.ALIEN_INVASION:
            valid_targets = {25, 50, 75}
            if self.target_score not in valid_targets:
                raise ValueError(
                    f"Target score for Alien Invasion must be one of {valid_targets}."
                )
        elif self.mode == GameMode.PIG:
            valid_targets = {50, 100, 250}
            if self.target_score not in valid_targets:
                raise ValueError(
                    f"Target score for Pig must be one of {valid_targets}."
                )