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.
- i151_engine/__init__.py +6 -0
- i151_engine/bots/__init__.py +0 -0
- i151_engine/bots/protocol.py +20 -0
- i151_engine/bots/random_bot.py +31 -0
- i151_engine/core/__init__.py +15 -0
- i151_engine/core/deck.py +90 -0
- i151_engine/core/errors.py +2 -0
- i151_engine/core/legal_actions.py +49 -0
- i151_engine/core/reducer.py +220 -0
- i151_engine/core/rules.py +93 -0
- i151_engine/core/scoring.py +152 -0
- i151_engine/game/__init__.py +0 -0
- i151_engine/game/recorder.py +189 -0
- i151_engine/game/round.py +81 -0
- i151_engine/game/round_state.py +70 -0
- i151_engine/models/__init__.py +15 -0
- i151_engine/models/action.py +55 -0
- i151_engine/models/card.py +159 -0
- i151_engine/models/enums.py +11 -0
- i151_engine/models/player.py +37 -0
- i151_engine/opponents/__init__.py +0 -0
- i151_engine/opponents/catalog.py +121 -0
- i151_engine/opponents/registry.py +56 -0
- i151_engine/opponents/schedules.py +48 -0
- i151_engine/opponents/types.py +35 -0
- i151_engine/runner/config.py +15 -0
- i151_engine/runner/match_runner.py +298 -0
- i151_engine/view/builder.py +132 -0
- i151_engine/view/player_view.py +86 -0
- i151_engine-0.1.0.dist-info/METADATA +994 -0
- i151_engine-0.1.0.dist-info/RECORD +33 -0
- i151_engine-0.1.0.dist-info/WHEEL +5 -0
- i151_engine-0.1.0.dist-info/top_level.txt +1 -0
i151_engine/__init__.py
ADDED
|
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
|
+
]
|
i151_engine/core/deck.py
ADDED
|
@@ -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,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
|