i151-engine 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ """i151 competition headless game engine."""
2
+
3
+ from i151_engine.models.card import Card, Rank, Suit
4
+ from i151_engine.models.action import Action, ActionType
5
+
6
+ __all__ = ["Card", "Rank", "Suit", "Action", "ActionType"]
File without changes
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Protocol
5
+
6
+ from i151_engine.models.action import Action
7
+ from i151_engine.view.player_view import PlayerView
8
+
9
+
10
+ class Bot(Protocol):
11
+ def setup(self, submission_dir: Path) -> None: ...
12
+
13
+ def decide(
14
+ self,
15
+ view: PlayerView,
16
+ legal_actions: list[Action],
17
+ time_left_ms: int,
18
+ ) -> Action: ...
19
+
20
+ def teardown(self) -> None: ...
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from pathlib import Path
5
+
6
+ from i151_engine.bots.protocol import Bot, PlayerView
7
+ from i151_engine.models.action import Action
8
+
9
+
10
+ class RandomBot:
11
+ def __init__(self, *, seed: int | None = None) -> None:
12
+ self._seed = seed
13
+ self._rng = random.Random(seed)
14
+
15
+ def setup(self, submission_dir: Path) -> None:
16
+ del submission_dir
17
+ self._rng = random.Random(self._seed)
18
+
19
+ def decide(
20
+ self,
21
+ view: PlayerView,
22
+ legal_actions: list[Action],
23
+ time_left_ms: int,
24
+ ) -> Action:
25
+ del view, time_left_ms
26
+ if not legal_actions:
27
+ raise RuntimeError("No legal actions")
28
+ return self._rng.choice(legal_actions)
29
+
30
+ def teardown(self) -> None:
31
+ return None
@@ -0,0 +1,15 @@
1
+ from i151_engine.core.deck import Deck
2
+ from i151_engine.core.errors import IllegalActionError
3
+ from i151_engine.core.rules import (
4
+ hand_score,
5
+ is_valid_multiple_play,
6
+ is_valid_single_play,
7
+ )
8
+
9
+ __all__ = [
10
+ "Deck",
11
+ "IllegalActionError",
12
+ "hand_score",
13
+ "is_valid_multiple_play",
14
+ "is_valid_single_play",
15
+ ]
@@ -0,0 +1,90 @@
1
+ from __future__ import annotations
2
+
3
+ import random
4
+ from dataclasses import dataclass, field
5
+
6
+ from i151_engine.models.card import Card, standard_deck
7
+
8
+ STANDARD_DECK_SIZE = 32
9
+
10
+
11
+ def validate_cards_per_player(cards_per_player: int, *, player_count: int) -> None:
12
+ """Vérifie qu'une distribution initiale est possible avec un jeu de 32 cartes."""
13
+ if cards_per_player < 1:
14
+ raise ValueError(f"cards_per_player doit être >= 1 (reçu {cards_per_player})")
15
+ max_deal = STANDARD_DECK_SIZE // player_count
16
+ if cards_per_player > max_deal:
17
+ raise ValueError(
18
+ f"cards_per_player={cards_per_player} trop élevé pour {player_count} joueur(s) "
19
+ f"(maximum {max_deal})"
20
+ )
21
+
22
+
23
+ @dataclass
24
+ class Deck:
25
+ """Mutable deck + bank — copied when building immutable round states."""
26
+
27
+ _draw_pile: list[Card] = field(default_factory=list)
28
+ _bank: list[Card] = field(default_factory=list)
29
+ cards_per_player: int = 8
30
+
31
+ def reset(self, seed: int | None = None) -> None:
32
+ self._draw_pile = standard_deck()
33
+ self.shuffle(seed)
34
+
35
+ def shuffle(self, seed: int | None = None) -> None:
36
+ rng = random.Random(seed)
37
+ rng.shuffle(self._draw_pile)
38
+
39
+ def deal(self, player_count: int, *, seed: int | None = None) -> list[list[Card]]:
40
+ if not self._draw_pile:
41
+ self.reset(seed)
42
+ elif seed is not None:
43
+ self.shuffle(seed)
44
+
45
+ cards_per_player = self.cards_per_player
46
+ if player_count == 4:
47
+ cards_per_player = 7
48
+
49
+ max_cards = min(cards_per_player, len(self._draw_pile) // player_count)
50
+ total_to_deal = player_count * max_cards
51
+ if len(self._draw_pile) < total_to_deal:
52
+ per_player = len(self._draw_pile) // player_count
53
+ total_to_deal = per_player * player_count
54
+
55
+ hands: list[list[Card]] = [[] for _ in range(player_count)]
56
+ current = 0
57
+ for i in range(total_to_deal):
58
+ hands[current].append(self._draw_pile[i])
59
+ current = (current + 1) % player_count
60
+
61
+ self._bank = self._draw_pile[total_to_deal:]
62
+ self._draw_pile = []
63
+ return hands
64
+
65
+ def draw(self, count: int) -> list[Card]:
66
+ drawn: list[Card] = []
67
+ for _ in range(count):
68
+ if not self._bank:
69
+ break
70
+ drawn.append(self._bank.pop(0))
71
+ return drawn
72
+
73
+ @property
74
+ def bank_size(self) -> int:
75
+ return len(self._bank)
76
+
77
+ def add_to_bank(self, cards: list[Card] | tuple[Card, ...]) -> None:
78
+ self._bank.extend(cards)
79
+
80
+ def bank_tuple(self) -> tuple[Card, ...]:
81
+ return tuple(self._bank)
82
+
83
+ def set_bank(self, bank: list[Card] | tuple[Card, ...]) -> None:
84
+ self._bank = list(bank)
85
+
86
+ def copy(self) -> Deck:
87
+ other = Deck(cards_per_player=self.cards_per_player)
88
+ other._draw_pile = self._draw_pile.copy()
89
+ other._bank = self._bank.copy()
90
+ return other
@@ -0,0 +1,2 @@
1
+ class IllegalActionError(ValueError):
2
+ """Raised when a player attempts an illegal action."""
@@ -0,0 +1,49 @@
1
+ from __future__ import annotations
2
+
3
+ from i151_engine.models.action import Action, ActionType
4
+ from i151_engine.models.card import Card, Rank, Suit
5
+ from i151_engine.models.player import PlayerState
6
+ from i151_engine.game.round_state import RoundState
7
+
8
+
9
+ def legal_actions(state: RoundState) -> list[Action]:
10
+ if state.phase.value != "playing":
11
+ return []
12
+
13
+ player = state.current_player
14
+ actions: list[Action] = []
15
+
16
+ playable = _playable_cards(player, state)
17
+ if player.is_chaining and state.top_card is not None:
18
+ playable = [c for c in playable if c.rank == state.top_card.rank]
19
+
20
+ for card in playable:
21
+ actions.append(Action.play_card(card))
22
+
23
+ if state.choose_suit and state.top_card is not None and state.top_card.rank == Rank.EIGHT:
24
+ for suit in Suit:
25
+ actions.append(Action.choose_suit(suit))
26
+
27
+ if state.top_card is not None and not state.choose_suit:
28
+ if player.has_drawn_this_turn or player.is_chaining:
29
+ actions.append(Action.pass_turn())
30
+ else:
31
+ actions.append(Action.draw_card())
32
+
33
+ if state.top_card is None and not state.choose_suit:
34
+ if player.has_drawn_this_turn or player.is_chaining:
35
+ actions.append(Action.pass_turn())
36
+ elif not actions:
37
+ actions.append(Action.draw_card())
38
+
39
+ return actions
40
+
41
+
42
+ def _playable_cards(player: PlayerState, state: RoundState) -> list[Card]:
43
+ result: list[Card] = []
44
+ for card in player.hand:
45
+ if state.top_card is None and state.required_suit is None:
46
+ result.append(card)
47
+ elif card.can_play_on_with_required_suit(state.top_card, state.required_suit):
48
+ result.append(card)
49
+ return result
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import replace
4
+
5
+ from i151_engine.core.deck import Deck
6
+ from i151_engine.core.errors import IllegalActionError
7
+ from i151_engine.core.rules import is_valid_multiple_play, is_valid_single_play
8
+ from i151_engine.game.round_state import RoundState
9
+ from i151_engine.models.action import Action, ActionType
10
+ from i151_engine.models.card import Rank
11
+ from i151_engine.models.enums import RoundPhase
12
+ from i151_engine.models.player import PlayerState
13
+
14
+
15
+ def apply_action(state: RoundState, action: Action) -> RoundState:
16
+ if state.phase != RoundPhase.PLAYING:
17
+ raise IllegalActionError("Round is not in playing phase")
18
+
19
+ player = state.current_player
20
+ if action.type == ActionType.PLAY_CARD:
21
+ if action.card is None:
22
+ raise IllegalActionError("Missing card")
23
+ return _handle_play(state, player, [action.card], action.chosen_suit)
24
+ if action.type == ActionType.PLAY_MULTIPLE_CARDS:
25
+ if not action.cards:
26
+ raise IllegalActionError("Missing cards")
27
+ return _handle_play(state, player, list(action.cards), action.chosen_suit)
28
+ if action.type == ActionType.DRAW_CARD:
29
+ return _handle_draw(state, player)
30
+ if action.type == ActionType.PASS_TURN:
31
+ return _handle_pass(state, player)
32
+ if action.type == ActionType.CHOOSE_SUIT:
33
+ if action.chosen_suit is None:
34
+ raise IllegalActionError("Missing chosen suit")
35
+ return _handle_choose_suit(state, player, action.chosen_suit)
36
+ raise IllegalActionError(f"Unsupported action: {action.type}")
37
+
38
+
39
+ def _handle_play(
40
+ state: RoundState,
41
+ player: PlayerState,
42
+ cards: list,
43
+ chosen_suit,
44
+ ) -> RoundState:
45
+ if len(cards) == 1:
46
+ if not is_valid_single_play(player, cards[0], state):
47
+ raise IllegalActionError("Invalid single play")
48
+ else:
49
+ if not is_valid_multiple_play(player, cards, state):
50
+ raise IllegalActionError("Invalid multiple play")
51
+
52
+ idx = state.current_player_index
53
+ updated = player.remove_cards(cards)
54
+ played_pile = state.played_pile + tuple(cards)
55
+
56
+ same_rank_remaining = any(c.rank == cards[-1].rank for c in updated.hand)
57
+ chaining = (
58
+ (
59
+ (state.top_card is not None and state.top_card.rank == cards[0].rank)
60
+ or cards[-1].rank == Rank.EIGHT
61
+ )
62
+ and _same_rank(cards)
63
+ and same_rank_remaining
64
+ )
65
+
66
+ updated = replace(
67
+ updated,
68
+ has_drawn_this_turn=False,
69
+ is_chaining=chaining,
70
+ )
71
+
72
+ new_state = _replace_player(state, idx, updated)
73
+ new_state = replace(
74
+ new_state,
75
+ top_card=cards[-1],
76
+ played_pile=played_pile,
77
+ last_play_count=len(cards),
78
+ ace_effect_active=False,
79
+ required_suit=None,
80
+ choose_suit=False,
81
+ )
82
+
83
+ new_state = _apply_special_card(new_state, cards[-1], chosen_suit, idx)
84
+
85
+ if not updated.hand:
86
+ active = [p for p in new_state.players if not p.is_excluded]
87
+ if len(active) == 2 and cards[-1].rank == Rank.QUEEN:
88
+ bank, drawn = _draw_from_bank(new_state.bank, new_state.played_pile, 1)
89
+ updated = updated.add_cards(drawn)
90
+ updated = replace(updated, has_drawn_this_turn=True, is_chaining=False)
91
+ new_state = _replace_player(new_state, idx, updated)
92
+ new_state = replace(new_state, bank=bank, played_pile=new_state.played_pile)
93
+ return _next_player(new_state, drawn=True)
94
+
95
+ return replace(
96
+ new_state,
97
+ phase=RoundPhase.ENDED,
98
+ round_winner_id=player.player_id,
99
+ )
100
+
101
+ if not updated.is_chaining and not new_state.choose_suit:
102
+ return _next_player(new_state)
103
+ return new_state
104
+
105
+
106
+ def _handle_draw(state: RoundState, player: PlayerState) -> RoundState:
107
+ idx = state.current_player_index
108
+ if player.has_drawn_this_turn:
109
+ return _next_player(state)
110
+
111
+ count = 2 if state.ace_effect_active else 1
112
+ bank, played_pile = state.bank, state.played_pile
113
+ bank, played_pile, drawn = _draw_with_refill(bank, played_pile, count)
114
+
115
+ updated = player.add_cards(drawn)
116
+ new_state = replace(
117
+ _replace_player(state, idx, updated),
118
+ bank=bank,
119
+ played_pile=played_pile,
120
+ last_play_count=0,
121
+ )
122
+
123
+ if state.ace_effect_active:
124
+ new_state = replace(new_state, ace_effect_active=False)
125
+ return _next_player(new_state)
126
+
127
+ updated = replace(updated, has_drawn_this_turn=True)
128
+ new_state = _replace_player(new_state, idx, updated)
129
+
130
+ if new_state.bank_size == 0 and not new_state.played_pile:
131
+ new_state = replace(new_state, top_card=None, required_suit=None)
132
+ return new_state
133
+
134
+
135
+ def _handle_pass(state: RoundState, player: PlayerState) -> RoundState:
136
+ if not (player.has_drawn_this_turn or player.is_chaining):
137
+ raise IllegalActionError("Must draw before passing")
138
+ return _next_player(state)
139
+
140
+
141
+ def _handle_choose_suit(state: RoundState, player: PlayerState, suit) -> RoundState:
142
+ idx = state.current_player_index
143
+ updated = replace(player, is_chaining=False)
144
+ new_state = _replace_player(state, idx, updated)
145
+ new_state = replace(
146
+ new_state,
147
+ required_suit=suit,
148
+ choose_suit=False,
149
+ )
150
+ return _next_player(new_state)
151
+
152
+
153
+ def _apply_special_card(state: RoundState, card, chosen_suit, player_idx: int):
154
+ if card.rank == Rank.EIGHT:
155
+ if chosen_suit is not None:
156
+ return replace(
157
+ state,
158
+ required_suit=chosen_suit,
159
+ choose_suit=False,
160
+ )
161
+ return replace(state, choose_suit=True)
162
+ if card.rank == Rank.ACE:
163
+ return replace(state, ace_effect_active=True)
164
+ if card.rank == Rank.QUEEN:
165
+ return replace(state, skip_next_player=True)
166
+ return state
167
+
168
+
169
+ def _next_player(state: RoundState, *, drawn: bool = False) -> RoundState:
170
+ idx = state.current_player_index
171
+ current = state.players[idx]
172
+ current = replace(current, has_drawn_this_turn=drawn, is_chaining=False)
173
+ state = _replace_player(state, idx, current)
174
+ state = replace(state, choose_suit=False)
175
+
176
+ n = len(state.players)
177
+ next_idx = (idx + 1) % n
178
+ while state.players[next_idx].is_excluded:
179
+ next_idx = (next_idx + 1) % n
180
+
181
+ if state.skip_next_player:
182
+ next_idx = (next_idx + 1) % n
183
+ while state.players[next_idx].is_excluded:
184
+ next_idx = (next_idx + 1) % n
185
+ state = replace(state, skip_next_player=False)
186
+
187
+ return replace(state, current_player_index=next_idx)
188
+
189
+
190
+ def _draw_with_refill(
191
+ bank: tuple,
192
+ played_pile: tuple,
193
+ count: int,
194
+ ) -> tuple[tuple, tuple, list]:
195
+ bank_list = list(bank)
196
+ pile = list(played_pile)
197
+ if len(bank_list) < count and pile:
198
+ bank_list.extend(pile)
199
+ pile = []
200
+ drawn: list = []
201
+ for _ in range(count):
202
+ if not bank_list:
203
+ break
204
+ drawn.append(bank_list.pop(0))
205
+ return tuple(bank_list), tuple(pile), drawn
206
+
207
+
208
+ def _draw_from_bank(bank: tuple, played_pile: tuple, count: int) -> tuple[tuple, list]:
209
+ bank, pile, drawn = _draw_with_refill(bank, played_pile, count)
210
+ return bank, drawn
211
+
212
+
213
+ def _replace_player(state: RoundState, index: int, player: PlayerState) -> RoundState:
214
+ players = list(state.players)
215
+ players[index] = player
216
+ return state.with_players(tuple(players))
217
+
218
+
219
+ def _same_rank(cards: list) -> bool:
220
+ return all(c.rank == cards[0].rank for c in cards)
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ from i151_engine.models.card import Card, Rank
6
+ from i151_engine.models.player import PlayerState
7
+
8
+ if TYPE_CHECKING:
9
+ from i151_engine.game.round_state import RoundState
10
+
11
+
12
+ def hand_score(hand: list[Card] | tuple[Card, ...]) -> int:
13
+ return sum(card.points for card in hand)
14
+
15
+
16
+ def _same_rank_cards(cards: list[Card] | tuple[Card, ...]) -> bool:
17
+ if not cards:
18
+ return False
19
+ first = cards[0].rank
20
+ return all(card.rank == first for card in cards)
21
+
22
+
23
+ def is_valid_single_play(player: PlayerState, card: Card, state: RoundState) -> bool:
24
+ if card not in player.hand:
25
+ return False
26
+ if player.is_chaining and state.top_card is not None and card.rank != state.top_card.rank:
27
+ return False
28
+ if state.top_card is not None and card.rank != Rank.EIGHT:
29
+ if not card.can_play_on_with_required_suit(state.top_card, state.required_suit):
30
+ return False
31
+ return True
32
+
33
+
34
+ def is_valid_multiple_play(
35
+ player: PlayerState,
36
+ cards: list[Card] | tuple[Card, ...],
37
+ state: RoundState,
38
+ ) -> bool:
39
+ if not cards or len(cards) > 8:
40
+ return False
41
+
42
+ for card in cards:
43
+ if card not in player.hand:
44
+ return False
45
+
46
+ if player.is_chaining and (
47
+ state.top_card is None
48
+ or cards[0].rank != state.top_card.rank
49
+ or not _same_rank_cards(cards)
50
+ ):
51
+ return False
52
+
53
+ if not cards[0].can_play_on_with_required_suit(state.top_card, state.required_suit):
54
+ return False
55
+
56
+ active_count = sum(1 for p in state.players if not p.is_excluded)
57
+ if active_count == 2 and cards[0].rank == Rank.QUEEN:
58
+ last_queen_index = max(i for i, c in enumerate(cards) if c.rank == Rank.QUEEN)
59
+ if not all(cards[i].rank == Rank.QUEEN for i in range(last_queen_index + 1)):
60
+ return False
61
+ if last_queen_index == len(cards) - 1:
62
+ return True
63
+ next_card = cards[last_queen_index + 1]
64
+ if next_card.rank == Rank.EIGHT:
65
+ return all(c.rank == Rank.EIGHT for c in cards[last_queen_index + 1 :])
66
+ if next_card is not cards[-1]:
67
+ return False
68
+ return cards[-1].can_play_on(cards[last_queen_index])
69
+
70
+ if not _same_rank_cards(cards):
71
+ return False
72
+
73
+ if state.top_card is not None and cards[0].rank != Rank.EIGHT:
74
+ for card in cards:
75
+ if not card.can_play_on_with_required_suit(state.top_card, state.required_suit):
76
+ return False
77
+
78
+ if state.top_card is None and len(cards) > 1:
79
+ if any(card.rank != Rank.EIGHT for card in cards):
80
+ return False
81
+
82
+ return True
83
+
84
+
85
+ def recent_table_cards(played_pile: tuple[Card, ...], *, limit: int = 3) -> tuple[Card, ...]:
86
+ return played_pile[-limit:]
87
+
88
+
89
+ def last_play_cards(played_pile: tuple[Card, ...], last_play_count: int) -> tuple[Card, ...]:
90
+ """Cartes posées par la dernière action (multi-jeu inclus, max 4 en pratique)."""
91
+ if last_play_count <= 0 or not played_pile:
92
+ return ()
93
+ return played_pile[-last_play_count:]
@@ -0,0 +1,152 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from i151_engine.core.rules import hand_score
6
+ from i151_engine.game.round_state import RoundState
7
+ from i151_engine.models.enums import MatchStatus, RoundPhase
8
+ from i151_engine.models.player import PlayerState
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class MatchState:
13
+ status: MatchStatus
14
+ players: tuple[PlayerState, ...]
15
+ round_number: int
16
+ dealer_index: int
17
+ target_score: int
18
+ last_winner_id: str | None
19
+ winner_id: str | None
20
+ current_round: RoundState | None
21
+ seed: int
22
+ cards_per_player: int = 8
23
+
24
+ def player_by_id(self, player_id: str) -> PlayerState:
25
+ for player in self.players:
26
+ if player.player_id == player_id:
27
+ return player
28
+ raise KeyError(player_id)
29
+
30
+ def replace_players(self, players: tuple[PlayerState, ...]) -> MatchState:
31
+ from dataclasses import replace
32
+
33
+ return replace(self, players=players)
34
+
35
+
36
+ def apply_round_scoring(match: MatchState, winner_id: str) -> MatchState:
37
+ """Apply hand scores to losers and consecutive-win bonus to winner."""
38
+ round_state = match.current_round
39
+ if round_state is None:
40
+ raise ValueError("Fin de manche sans état de manche")
41
+
42
+ meta = {p.player_id: p for p in match.players}
43
+ players_list: list[PlayerState] = []
44
+ for round_player in round_state.players:
45
+ match_player = meta[round_player.player_id]
46
+ players_list.append(
47
+ PlayerState(
48
+ player_id=round_player.player_id,
49
+ name=match_player.name,
50
+ hand=round_player.hand,
51
+ total_score=match_player.total_score,
52
+ is_excluded=match_player.is_excluded,
53
+ has_drawn_this_turn=False,
54
+ is_chaining=False,
55
+ consecutive_round_wins=match_player.consecutive_round_wins,
56
+ )
57
+ )
58
+
59
+ winner_idx = next(i for i, p in enumerate(players_list) if p.player_id == winner_id)
60
+ winner = players_list[winner_idx]
61
+
62
+ for i, player in enumerate(players_list):
63
+ if player.player_id == winner_id:
64
+ continue
65
+ new_score = player.total_score + hand_score(player.hand)
66
+ players_list[i] = PlayerState(
67
+ player_id=player.player_id,
68
+ name=player.name,
69
+ hand=player.hand,
70
+ total_score=new_score,
71
+ is_excluded=player.is_excluded or new_score >= match.target_score,
72
+ has_drawn_this_turn=False,
73
+ is_chaining=False,
74
+ consecutive_round_wins=player.consecutive_round_wins,
75
+ )
76
+
77
+ winner, last_winner_id = _track_consecutive_wins(
78
+ winner,
79
+ match.last_winner_id,
80
+ )
81
+ players_list[winner_idx] = winner
82
+
83
+ active = [p for p in players_list if not p.is_excluded]
84
+ if len(active) <= 1:
85
+ winner_id_final = active[0].player_id if active else None
86
+ return MatchState(
87
+ status=MatchStatus.FINISHED,
88
+ players=tuple(players_list),
89
+ round_number=match.round_number,
90
+ dealer_index=match.dealer_index,
91
+ target_score=match.target_score,
92
+ last_winner_id=last_winner_id,
93
+ winner_id=winner_id_final,
94
+ current_round=None,
95
+ seed=match.seed,
96
+ )
97
+
98
+ dealer_index, _ = _next_dealer_and_starter(players_list, match.dealer_index)
99
+ return MatchState(
100
+ status=MatchStatus.IN_PROGRESS,
101
+ players=tuple(players_list),
102
+ round_number=match.round_number + 1,
103
+ dealer_index=dealer_index,
104
+ target_score=match.target_score,
105
+ last_winner_id=last_winner_id,
106
+ winner_id=None,
107
+ current_round=None,
108
+ seed=match.seed,
109
+ )
110
+
111
+
112
+ def _track_consecutive_wins(
113
+ winner: PlayerState,
114
+ last_winner_id: str | None,
115
+ ) -> tuple[PlayerState, str]:
116
+ winner_id = winner.player_id
117
+ consecutive = winner.consecutive_round_wins
118
+ total_score = winner.total_score
119
+
120
+ if last_winner_id == winner_id:
121
+ consecutive += 1
122
+ if consecutive == 3:
123
+ total_score -= 10
124
+ consecutive = 0
125
+ else:
126
+ consecutive = 1
127
+
128
+ updated = PlayerState(
129
+ player_id=winner.player_id,
130
+ name=winner.name,
131
+ hand=winner.hand,
132
+ total_score=total_score,
133
+ is_excluded=winner.is_excluded,
134
+ has_drawn_this_turn=False,
135
+ is_chaining=False,
136
+ consecutive_round_wins=consecutive,
137
+ )
138
+ return updated, winner_id
139
+
140
+
141
+ def _next_dealer_and_starter(
142
+ players: list[PlayerState],
143
+ current_dealer: int,
144
+ ) -> tuple[int, int]:
145
+ n = len(players)
146
+ dealer_index = (current_dealer + 1) % n
147
+ while players[dealer_index].is_excluded:
148
+ dealer_index = (dealer_index + 1) % n
149
+ starter_index = (dealer_index + 1) % n
150
+ while players[starter_index].is_excluded:
151
+ starter_index = (starter_index + 1) % n
152
+ return dealer_index, starter_index
File without changes