i151-engine 0.1.0__tar.gz

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.
Files changed (48) hide show
  1. i151_engine-0.1.0/ARCHITECTURE.md +985 -0
  2. i151_engine-0.1.0/PKG-INFO +994 -0
  3. i151_engine-0.1.0/README.md +59 -0
  4. i151_engine-0.1.0/i151_engine/__init__.py +6 -0
  5. i151_engine-0.1.0/i151_engine/bots/__init__.py +0 -0
  6. i151_engine-0.1.0/i151_engine/bots/protocol.py +20 -0
  7. i151_engine-0.1.0/i151_engine/bots/random_bot.py +31 -0
  8. i151_engine-0.1.0/i151_engine/core/__init__.py +15 -0
  9. i151_engine-0.1.0/i151_engine/core/deck.py +90 -0
  10. i151_engine-0.1.0/i151_engine/core/errors.py +2 -0
  11. i151_engine-0.1.0/i151_engine/core/legal_actions.py +49 -0
  12. i151_engine-0.1.0/i151_engine/core/reducer.py +220 -0
  13. i151_engine-0.1.0/i151_engine/core/rules.py +93 -0
  14. i151_engine-0.1.0/i151_engine/core/scoring.py +152 -0
  15. i151_engine-0.1.0/i151_engine/game/__init__.py +0 -0
  16. i151_engine-0.1.0/i151_engine/game/recorder.py +189 -0
  17. i151_engine-0.1.0/i151_engine/game/round.py +81 -0
  18. i151_engine-0.1.0/i151_engine/game/round_state.py +70 -0
  19. i151_engine-0.1.0/i151_engine/models/__init__.py +15 -0
  20. i151_engine-0.1.0/i151_engine/models/action.py +55 -0
  21. i151_engine-0.1.0/i151_engine/models/card.py +159 -0
  22. i151_engine-0.1.0/i151_engine/models/enums.py +11 -0
  23. i151_engine-0.1.0/i151_engine/models/player.py +37 -0
  24. i151_engine-0.1.0/i151_engine/opponents/__init__.py +0 -0
  25. i151_engine-0.1.0/i151_engine/opponents/catalog.py +121 -0
  26. i151_engine-0.1.0/i151_engine/opponents/registry.py +56 -0
  27. i151_engine-0.1.0/i151_engine/opponents/schedules.py +48 -0
  28. i151_engine-0.1.0/i151_engine/opponents/types.py +35 -0
  29. i151_engine-0.1.0/i151_engine/runner/config.py +15 -0
  30. i151_engine-0.1.0/i151_engine/runner/match_runner.py +298 -0
  31. i151_engine-0.1.0/i151_engine/view/builder.py +132 -0
  32. i151_engine-0.1.0/i151_engine/view/player_view.py +86 -0
  33. i151_engine-0.1.0/i151_engine.egg-info/PKG-INFO +994 -0
  34. i151_engine-0.1.0/i151_engine.egg-info/SOURCES.txt +46 -0
  35. i151_engine-0.1.0/i151_engine.egg-info/dependency_links.txt +1 -0
  36. i151_engine-0.1.0/i151_engine.egg-info/requires.txt +3 -0
  37. i151_engine-0.1.0/i151_engine.egg-info/top_level.txt +1 -0
  38. i151_engine-0.1.0/pyproject.toml +22 -0
  39. i151_engine-0.1.0/setup.cfg +4 -0
  40. i151_engine-0.1.0/tests/test_card.py +57 -0
  41. i151_engine-0.1.0/tests/test_deck.py +37 -0
  42. i151_engine-0.1.0/tests/test_match_runner.py +17 -0
  43. i151_engine-0.1.0/tests/test_match_timeout.py +67 -0
  44. i151_engine-0.1.0/tests/test_opponents.py +33 -0
  45. i151_engine-0.1.0/tests/test_recorder.py +55 -0
  46. i151_engine-0.1.0/tests/test_reducer.py +88 -0
  47. i151_engine-0.1.0/tests/test_rules.py +83 -0
  48. i151_engine-0.1.0/tests/test_scoring.py +83 -0
@@ -0,0 +1,985 @@
1
+ # Architecture — moteur de jeu Python (`arena/engine`)
2
+
3
+ Moteur headless pour la compétition IA i151. Source de vérité des règles : `lib/models/game_manager.dart` et `test/game_rules_test.dart`.
4
+
5
+ **Périmètre v1** : 2 joueurs, partie complète jusqu'à 151 points, RNG seedé (reproductible), log d'actions pour replay.
6
+
7
+ **Hors périmètre** : dossier `agent/`, multijoueur Firebase, UI Flutter, échange de mains (`handExchangeEnabled`), parties 3–4 joueurs.
8
+
9
+ ---
10
+
11
+ ## Principes de conception
12
+
13
+
14
+ | Principe | Choix |
15
+ | -------------- | --------------------------------------------------------------------------------- |
16
+ | État | **Immuable** — chaque action retourne un nouvel état (replay, tests, fork MinMax) |
17
+ | Couches | Modèles → règles pures → réducteur → manche → match → runner |
18
+ | Visibilité bot | `PlayerView` — main propre + infos publiques uniquement (pas la main adverse) |
19
+ | Déterminisme | `seed: int` pour mélange et distribution |
20
+ | Erreurs | Action illégale → rejet côté runner (défaite par forfait en compétition) |
21
+ | Alignement | Tests Python calqués sur `test/game_rules_test.dart` |
22
+ | Adversaires | **`opponent_id` stable** via `opponents/catalog` — jamais de `Bot` en dur dans l'API |
23
+ | Approches bot | Heuristiques **ou** machine learning — entraînement hors arène, **inférence seule** en match |
24
+
25
+
26
+ ---
27
+
28
+
29
+
30
+ ## Arborescence
31
+
32
+ ```
33
+ arena/engine/
34
+ ├── ARCHITECTURE.md # Ce document
35
+ ├── pyproject.toml
36
+ ├── i151_engine/
37
+ │ ├── __init__.py
38
+ │ ├── models/
39
+ │ │ ├── card.py # Suit, Rank, Card, points, codes ("AS", "8H"…)
40
+ │ │ ├── action.py # ActionType, Action
41
+ │ │ ├── player.py # PlayerState (main, scores, flags tour)
42
+ │ │ └── enums.py # Phase, MatchStatus
43
+ │ ├── core/
44
+ │ │ ├── deck.py # Jeu 32 cartes, deal, pioche, reconstitution banque
45
+ │ │ ├── rules.py # can_play_on, validation (fonctions pures)
46
+ │ │ ├── legal_actions.py # legal_actions(round_state, player_idx) -> list[Action]
47
+ │ │ ├── reducer.py # apply_action(state, action) -> RoundState
48
+ │ │ └── scoring.py # points main, fin de manche, exclusion à 151, bonus -10
49
+ │ ├── game/
50
+ │ │ ├── round_state.py # État d'une manche en cours
51
+ │ │ ├── match_state.py # État d'une partie (scores cumulés, manche N)
52
+ │ │ ├── round.py # démarrer / terminer une manche
53
+ │ │ ├── match.py # boucle partie complète
54
+ │ │ └── recorder.py # journal structuré pour replay
55
+ │ ├── view/
56
+ │ │ ├── player_view.py # PlayerView + sous-vues
57
+ │ │ └── builder.py # build_player_view(round, match, perspective_id)
58
+ │ ├── bots/
59
+ │ │ ├── protocol.py # protocole Bot (typing.Protocol)
60
+ │ │ ├── random_bot.py
61
+ │ │ ├── greedy_bot.py
62
+ │ │ └── minmax_bot.py # implémentations concrètes
63
+ │ ├── opponents/
64
+ │ │ ├── types.py # OpponentSpec, OpponentKind, OpponentTier
65
+ │ │ ├── catalog.py # liste canonique des adversaires (IDs stables)
66
+ │ │ ├── registry.py # OpponentRegistry — résolution id → Bot
67
+ │ │ └── schedules.py # jeux d'adversaires par contexte (smoke, elo, ladder)
68
+ │ └── runner/
69
+ │ ├── match_runner.py # orchestration challenger vs opponent_id + timeouts
70
+ │ └── config.py # MatchConfig (seed, timeouts, target_score)
71
+ └── tests/
72
+ ├── test_card.py
73
+ ├── test_rules.py
74
+ ├── test_reducer.py
75
+ ├── test_round.py
76
+ ├── test_match.py
77
+ └── fixtures/ # états JSON pour régression
78
+ ```
79
+
80
+ Le package `arena/sdk/` (phase ultérieure) importera `i151_engine` et n'exposera aux étudiants que `PlayerView`, `Action` et `decide()`.
81
+
82
+ ---
83
+
84
+
85
+
86
+ ## Couches et responsabilités
87
+
88
+ ```mermaid
89
+ flowchart TB
90
+ subgraph runner ["runner/"]
91
+ MR[match_runner]
92
+ end
93
+
94
+ subgraph bots ["bots/"]
95
+ B[Bot.decide]
96
+ end
97
+
98
+ subgraph view ["view/"]
99
+ PV[PlayerView]
100
+ end
101
+
102
+ subgraph game ["game/"]
103
+ M[match.py]
104
+ R[round.py]
105
+ REC[recorder.py]
106
+ end
107
+
108
+ subgraph core ["core/"]
109
+ LA[legal_actions]
110
+ RED[reducer.apply_action]
111
+ RULES[rules]
112
+ DECK[deck]
113
+ SC[scoring]
114
+ end
115
+
116
+ subgraph models ["models/"]
117
+ CARD[card / action / player]
118
+ end
119
+
120
+ MR --> M
121
+ M --> R
122
+ MR --> B
123
+ B --> PV
124
+ PV --> R
125
+ MR --> LA
126
+ MR --> RED
127
+ LA --> RULES
128
+ RED --> RULES
129
+ RED --> DECK
130
+ R --> SC
131
+ M --> SC
132
+ MR --> REC
133
+ RULES --> CARD
134
+ RED --> CARD
135
+ ```
136
+
137
+
138
+
139
+
140
+
141
+ ### 1. `models/` — données immuables
142
+
143
+ Tous les types sont des `@dataclass(frozen=True)` (ou `NamedTuple` pour les cartes).
144
+
145
+ #### `Card`
146
+
147
+ ```python
148
+ @dataclass(frozen=True)
149
+ class Card:
150
+ rank: Rank # SEVEN … ACE
151
+ suit: Suit # SPADES, HEARTS, DIAMONDS, CLUBS
152
+
153
+ @property
154
+ def code(self) -> str: ... # "AS", "TH", "8D"
155
+ @property
156
+ def points(self) -> int: ... # 8→32, A→11, Q→3, K→4, J→2, 7→7, 9→9, 10→10
157
+ ```
158
+
159
+ Codes alignés sur `Card.fromCode` (Dart) : `A/K/Q/J/T/9/8/7` + `S/H/D/C`.
160
+
161
+ #### `Action` / `ActionType`
162
+
163
+ Aligné sur `lib/models/player_action.dart` :
164
+
165
+
166
+ | `ActionType` | Champs | Notes |
167
+ | --------------------- | ----------------------- | ----------------------------------------------- |
168
+ | `PLAY_CARD` | `card`, `chosen_suit?` | `chosen_suit` requis si carte 8 et fin de série |
169
+ | `PLAY_MULTIPLE_CARDS` | `cards`, `chosen_suit?` | même rang (ou règles Dame en 2J), max 8 cartes |
170
+ | `DRAW_CARD` | — | 1 carte, ou 2 si `ace_effect_active` |
171
+ | `PASS_TURN` | — | après pioche ou fin de chaîne |
172
+ | `CHOOSE_SUIT` | `chosen_suit` | après jeu d'un 8 sans couleur choisie inline |
173
+
174
+
175
+ Pas de `RESIGN` en v1 compétition (forfait géré par le runner sur timeout / action invalide).
176
+
177
+ #### `PlayerState`
178
+
179
+ ```python
180
+ @dataclass(frozen=True)
181
+ class PlayerState:
182
+ player_id: str # "p0" | "p1"
183
+ name: str
184
+ hand: tuple[Card, ...] # ordre stable pour le bot
185
+ total_score: int # cumul partie
186
+ is_excluded: bool
187
+ has_drawn_this_turn: bool
188
+ is_chaining: bool
189
+ ```
190
+
191
+ ---
192
+
193
+
194
+
195
+ ### 2. `core/` — logique pure
196
+
197
+
198
+
199
+ #### `deck.py`
200
+
201
+ - Jeu standard **32 cartes** (7, 8, 9, 10, V, D, R, A × 4 couleurs)
202
+ - `shuffle(seed)` → ordre déterministe
203
+ - `deal(players: int, cards_per_player: int = 7)` → mains + banque
204
+ - `draw(n)` depuis la tête de banque
205
+ - `refill_from_played(played: tuple[Card, ...])` — cartes jouées remises en banque **dans l'ordre FIFO**
206
+
207
+
208
+
209
+ #### `rules.py`
210
+
211
+ Fonctions sans effet de bord, portées depuis Dart :
212
+
213
+ - `can_play_on(card, top_card, required_suit) -> bool`
214
+ - `is_valid_single_play(player, card, round_state) -> bool`
215
+ - `is_valid_multiple_play(player, cards, round_state) -> bool`
216
+ - Règle **Dame en 2 joueurs** : impossible de terminer la manche sur une Dame (gérée dans le réducteur, pas dans la validation seule)
217
+
218
+
219
+
220
+ #### `legal_actions.py`
221
+
222
+ ```python
223
+ def legal_actions(state: RoundState) -> list[Action]:
224
+ """Actions légales pour state.current_player_index."""
225
+ ```
226
+
227
+ Reprend la logique de `Player.getAllSingleCardActions` + combinaisons multi-cartes (même rang jouable sur la table). Le runner **ne fait jamais confiance** au bot : toute action est revalidée ici avant `apply_action`.
228
+
229
+ #### `reducer.py`
230
+
231
+ ```python
232
+ def apply_action(state: RoundState, action: Action) -> RoundState:
233
+ """Lève IllegalActionError si action invalide."""
234
+ ```
235
+
236
+ Effets gérés (miroir `GameManager.processPlayerTurn`) :
237
+
238
+ - jeu simple / multiple, chaînage (`is_chaining`)
239
+ - pioche (1 ou 2 sur As), interdiction double pioche → passe auto
240
+ - `choose_suit` après 8
241
+ - effets : 8 (couleur imposée), As (`ace_effect_active`), Dame (`skip_next_player`)
242
+ - reconstitution banque si vide
243
+ - fin de manche (main vide) + cas Dame 2 joueurs (pioche auto)
244
+ - passage au joueur suivant
245
+
246
+
247
+
248
+ #### `scoring.py`
249
+
250
+ - `hand_score(hand) -> int` — somme des points des cartes restantes
251
+ - `apply_round_result(match, winner_id) -> MatchState` — perdants cumulent, exclusion à `target_score` (151)
252
+ - `apply_consecutive_win_bonus(match, winner_id)` — -10 après 3 victoires consécutives
253
+
254
+ ---
255
+
256
+
257
+
258
+ ### 3. `game/` — orchestration
259
+
260
+
261
+
262
+ #### `RoundState` — une manche
263
+
264
+ ```python
265
+ @dataclass(frozen=True)
266
+ class RoundState:
267
+ phase: Literal["playing", "ended"]
268
+ players: tuple[PlayerState, PlayerState]
269
+ current_player_index: int
270
+ top_card: Card | None
271
+ required_suit: Suit | None
272
+ choose_suit: bool # le joueur courant doit annoncer une couleur
273
+ ace_effect_active: bool
274
+ skip_next_player: bool
275
+ bank: tuple[Card, ...]
276
+ played_pile: tuple[Card, ...] # FIFO pour reconstitution
277
+ round_number: int
278
+ dealer_index: int
279
+ last_played_count: int
280
+ ```
281
+
282
+
283
+
284
+ #### `MatchState` — partie complète
285
+
286
+ ```python
287
+ @dataclass(frozen=True)
288
+ class MatchState:
289
+ status: Literal["in_progress", "finished"]
290
+ players: tuple[PlayerState, PlayerState]
291
+ round_number: int
292
+ dealer_index: int
293
+ target_score: int # 151
294
+ consecutive_wins: dict[str, int]
295
+ last_winner_id: str | None
296
+ winner_id: str | None # dernier non exclu
297
+ current_round: RoundState | None
298
+ ```
299
+
300
+
301
+
302
+ #### `match.py`
303
+
304
+ ```python
305
+ def play_match(
306
+ challenger: Bot,
307
+ opponent_id: str,
308
+ config: MatchConfig,
309
+ *,
310
+ registry: OpponentRegistry | None = None,
311
+ ) -> MatchResult:
312
+ """opponent_id résolu via OpponentRegistry (défaut : registre global)."""
313
+ ...
314
+ ```
315
+
316
+ Variante serveur (phase 2) :
317
+
318
+ ```python
319
+ def play_match_by_ids(
320
+ challenger_id: str, # "student:{team_id}" ou soumission
321
+ opponent_id: str,
322
+ config: MatchConfig,
323
+ ctx: ResolveContext,
324
+ ) -> MatchResult: ...
325
+ ```
326
+
327
+ Boucle :
328
+
329
+ 1. `start_round(match)` → `RoundState`
330
+ 2. Tant que manche en cours :
331
+ - construire `PlayerView` pour le joueur courant
332
+ - `legal = legal_actions(round)`
333
+ - `action = active_bot.decide(view, legal, time_left_ms)` (challenger ou adversaire résolu)
334
+ - `round = apply_action(round, action)`
335
+ - `recorder.record(...)`
336
+ 3. `match = apply_round_result(match, winner)`
337
+ 4. Si partie terminée → `MatchResult`, sinon manche suivante
338
+
339
+ ---
340
+
341
+
342
+
343
+ ### 4. `view/` — ce que voit un bot étudiant
344
+
345
+ Projection **partielle** depuis `RoundState` + `MatchState` pour le joueur dont c'est le tour (ou le joueur qui appelle `decide`). Construite par `build_player_view()` — jamais d'accès direct au moteur.
346
+
347
+ #### Ce qui est visible / invisible
348
+
349
+ | Visible | Invisible (interdit) |
350
+ |---------|----------------------|
351
+ | Sa main (`you.hand`) | Cartes des autres joueurs |
352
+ | Scores cumulés partie, tailles main / banque | Ordre exact de la banque |
353
+ | **3 dernières cartes** (`table.recent_cards`) + sommet via `top_card` | Reste du talon (`played_pile` au-delà de 3 cartes) |
354
+ | Couleur imposée, effet As | Code des autres bots |
355
+ | Flags de tour (pioche, chaîne…) | `MatchState` / `RoundState` bruts |
356
+ | Métadonnées adversaires (`opponents[].opponent_id`, tier) | Soumissions / code des autres bots |
357
+
358
+ #### Sous-vues
359
+
360
+ ```python
361
+ @dataclass(frozen=True)
362
+ class YouState:
363
+ player_id: str
364
+ hand: tuple[Card, ...] # tri stable (couleur puis rang)
365
+ hand_size: int
366
+ hand_points: int # valeur des cartes restantes si la manche s'arrêtait maintenant
367
+ total_score: int # cumul partie (151 = élimination)
368
+ is_excluded: bool
369
+ has_drawn_this_turn: bool
370
+ is_chaining: bool # peut enchaîner même rang / 8
371
+ consecutive_round_wins: int # victoires de manche consécutives (bonus -10 à 3)
372
+
373
+ @dataclass(frozen=True)
374
+ class OpponentState:
375
+ player_id: str
376
+ seat_index: int # position dans l'ordre de jeu (0..n-1)
377
+ opponent_id: str # ID catalogue, ex. "builtin:random" ou "student:abc"
378
+ name: str # libellé affiché / registre
379
+ tier: OpponentTier | None # None si adversaire étudiant
380
+ hand_size: int
381
+ total_score: int
382
+ is_excluded: bool
383
+ consecutive_round_wins: int # pour anticiper le bonus -10 à 3
384
+ turns_until_next: int # 0 si c'est son tour, 1 = joue juste après toi, etc.
385
+ is_next_to_play: bool # True si c'est le prochain joueur actif
386
+
387
+ @dataclass(frozen=True)
388
+ class TableState:
389
+ recent_cards: tuple[Card, ...] # 0 à 3 cartes, ordre chronologique (index -1 = sommet / top)
390
+ table_empty: bool # True si aucune carte sur la table
391
+ required_suit: Suit | None # couleur imposée après un 8
392
+ must_choose_suit: bool # True → CHOOSE_SUIT attendu (8 joué sans couleur)
393
+ ace_effect_active: bool # prochaine pioche = 2 cartes (joueur courant)
394
+ bank_size: int
395
+ played_pile_size: int # total cartes dans le talon (dont les non visibles)
396
+ last_play_count: int # nb cartes jouées lors de la dernière action
397
+
398
+ @property
399
+ def top_card(self) -> Card | None:
400
+ """Équivalent Dart `topCard` — dernière carte de `recent_cards`."""
401
+ return self.recent_cards[-1] if self.recent_cards else None
402
+ ```
403
+
404
+ `recent_cards` = les **3 dernières cartes** du talon `played_pile` (fin de la liste FIFO Dart `playedCards`). Si une action joue plusieurs cartes d'un coup, elles peuvent occuper plusieurs slots (ex. `[..., 9S, 9H, 9D]`). Après reconstitution de la banque le talon est vidé → `recent_cards` vide et `table_empty` True.
405
+
406
+ **Reconstitution sans mélange** (`game_manager.dart` / `reducer._draw_with_refill`) : quand la banque est épuisée, le talon est concaténé **tel quel** à la fin de la banque (`bank.extend(played_pile)`). Les bots peuvent accumuler `recent_cards` tour après tour pour reconstituer l'ordre FIFO du talon ; dès la première fusion, les pioches suivantes sur ce segment deviennent **déterministes** (stratégie documentée dans `arena/sdk/examples/minmax_bot/inference.py`).
407
+
408
+ ---
409
+ ```python
410
+ @dataclass(frozen=True)
411
+ class TurnState:
412
+ is_your_turn: bool
413
+ can_draw: bool # pas encore pioché ET pas en mode choose_suit
414
+ can_pass: bool # has_drawn_this_turn ou is_chaining
415
+ draw_count_if_draw: int # 1, ou 2 si ace_effect_active
416
+ step_index: int # numéro de l'action dans la manche (0-based)
417
+ current_player_id: str # joueur dont c'est le tour (== you.player_id si is_your_turn)
418
+ next_player_id: str | None # prochain joueur actif après l'action en cours
419
+
420
+ @dataclass(frozen=True)
421
+ class MatchContext:
422
+ round_number: int
423
+ target_score: int # 151
424
+ you_are_dealer: bool
425
+ player_count: int # nb de sièges (2 en v1, extensible 3–4)
426
+ active_player_count: int # joueurs non exclus
427
+ seats: tuple[str, ...] # player_ids dans l'ordre des sièges (sens horaire)
428
+
429
+ @dataclass(frozen=True)
430
+ class PlayerView:
431
+ """Seule interface d'état exposée aux bots étudiants."""
432
+
433
+ you: YouState
434
+ opponents: tuple[OpponentState, ...] # tous les autres joueurs, triés par seat_index
435
+ table: TableState
436
+ turn: TurnState
437
+ match: MatchContext
438
+
439
+ def sole_opponent(self) -> OpponentState:
440
+ """Helper v1 (2 joueurs). Lève ValueError si len(opponents) != 1."""
441
+ ...
442
+ ```
443
+
444
+ **v1** : `len(opponents) == 1`. **v2+** (3–4 joueurs) : `len(opponents) == player_count - 1`, même structure sans changer l'API.
445
+
446
+ #### Construction
447
+
448
+ ```python
449
+ def build_player_view(
450
+ match: MatchState,
451
+ round_state: RoundState,
452
+ perspective_player_id: str,
453
+ *,
454
+ opponent_specs: dict[str, OpponentSpec], # player_id → spec (catalogue ou student)
455
+ step_index: int,
456
+ ) -> PlayerView:
457
+ """Lève ValueError si perspective_player_id n'est pas un joueur actif."""
458
+ ...
459
+ ```
460
+
461
+ - Appelée par `match_runner` **à chaque** invocation de `decide()`
462
+ - `opponent_specs` : une entrée par **autre** joueur (`player_id` → `OpponentSpec` ou spec étudiant)
463
+ - `opponents` exclut toujours `you` ; ordre = `seat_index` croissant
464
+ - `table.recent_cards` = `played_pile[-3:]` (max 3 cartes, sommet en dernier)
465
+ - `turn.is_your_turn` est toujours `True` quand `decide()` est appelé ; conservé pour clarté SDK et tests
466
+
467
+ #### Exemple SDK (futur)
468
+
469
+ ```python
470
+ from arena_sdk import PlayerView, Action
471
+
472
+ def decide(view: PlayerView, legal_actions: list[Action], time_left_ms: int) -> Action:
473
+ if view.table.ace_effect_active and view.turn.can_draw:
474
+ ...
475
+
476
+ # v1 — 2 joueurs
477
+ opp = view.sole_opponent()
478
+ if opp.hand_size == 1 and view.you.hand_points > opp.total_score:
479
+ ...
480
+
481
+ # v2+ — plusieurs adversaires
482
+ # leader = max(view.opponents, key=lambda o: o.total_score)
483
+ # next_opp = next(o for o in view.opponents if o.is_next_to_play)
484
+
485
+ return legal_actions[0]
486
+ ```
487
+
488
+ Les étudiants reçoivent **`PlayerView` + `legal_actions`** uniquement. Les helpers optionnels du SDK (`hand_by_suit()`, `count_rank()`, etc.) dérivent de `view.you.hand` sans élargir la vue.
489
+
490
+ #### Sérialisation (replay / debug)
491
+
492
+ Snapshot JSON public aligné sur `PlayerView` (sans mains) :
493
+
494
+ ```json
495
+ {
496
+ "step_index": 12,
497
+ "current_player_id": "p0",
498
+ "table": {
499
+ "recent_cards": ["9S", "9H", "TH"],
500
+ "table_empty": false,
501
+ "required_suit": null,
502
+ "must_choose_suit": false,
503
+ "ace_effect_active": false,
504
+ "bank_size": 14,
505
+ "played_pile_size": 8,
506
+ "last_play_count": 1
507
+ },
508
+ "hands_size": { "p0": 4, "p1": 6 },
509
+ "scores": { "p0": 45, "p1": 72 },
510
+ "opponents": [
511
+ { "player_id": "p1", "opponent_id": "builtin:random", "seat_index": 1, "hand_size": 6 }
512
+ ]
513
+ }
514
+ ```
515
+
516
+ La main du joueur courant peut être incluse dans les replays **post-match** pour analyse, mais **jamais** envoyée à l'adversaire en cours de partie.
517
+
518
+ ---
519
+
520
+
521
+
522
+ ### 5. `bots/` — protocole compétition
523
+
524
+ ```python
525
+ class Bot(Protocol):
526
+ def setup(self, submission_dir: Path) -> None:
527
+ """Appelé une fois avant le premier match (chargement modèle ML, etc.)."""
528
+ ...
529
+
530
+ def decide(
531
+ self,
532
+ view: PlayerView,
533
+ legal_actions: list[Action],
534
+ time_left_ms: int,
535
+ ) -> Action: ...
536
+
537
+ def teardown(self) -> None:
538
+ """Optionnel — libération mémoire après le match."""
539
+ ...
540
+ ```
541
+
542
+ `setup` / `teardown` sont no-op pour les bots heuristiques simples.
543
+
544
+ #### Soumission étudiante — heuristique (`arena/sdk/`)
545
+
546
+ ```python
547
+ # bot.py
548
+ from arena_sdk import PlayerView, Action
549
+
550
+ def setup(submission_dir): # optionnel
551
+ pass
552
+
553
+ def decide(view: PlayerView, legal_actions: list[Action], time_left_ms: int) -> Action:
554
+ return legal_actions[0]
555
+ ```
556
+
557
+ #### Soumission étudiante — machine learning
558
+
559
+ Les équipes **entraînent en local** (leurs machines, notebooks, GPU perso) et soumettent **code d'inférence + poids** :
560
+
561
+ ```
562
+ submission.zip
563
+ ├── bot.py # setup() charge le modèle ; decide() infère
564
+ ├── requirements.txt # optionnel — libs whitelist uniquement
565
+ ├── model.joblib # ex. scikit-learn (optionnel)
566
+ ├── model.onnx # ex. ONNX (optionnel)
567
+ ├── weights.pt # ex. PyTorch state_dict (optionnel)
568
+ └── assets/ # sous-dossiers autorisés, pas d'exécution auto
569
+ ```
570
+
571
+ Exemple :
572
+
573
+ ```python
574
+ # bot.py
575
+ from pathlib import Path
576
+ import joblib
577
+ import numpy as np
578
+ from arena_sdk import PlayerView, Action
579
+ from arena_sdk.features import encode_view
580
+
581
+ _model = None
582
+
583
+ def setup(submission_dir: Path) -> None:
584
+ global _model
585
+ _model = joblib.load(submission_dir / "model.joblib")
586
+
587
+ def decide(view: PlayerView, legal_actions: list[Action], time_left_ms: int) -> Action:
588
+ x = encode_view(view, legal_actions)
589
+ idx = int(_model.predict(x.reshape(1, -1))[0])
590
+ return legal_actions[idx]
591
+ ```
592
+
593
+ **Règles ML en arène** :
594
+
595
+ | Autorisé | Interdit |
596
+ |----------|----------|
597
+ | Inférence (`predict`, `forward`, ONNX Runtime) | Entraînement (`fit`, `train`, `backward`) |
598
+ | Chargement poids dans `setup()` | Téléchargement réseau de modèles / datasets |
599
+ | numpy / sklearn / onnxruntime / torch **CPU** | `requests`, accès Internet, GPU sandbox |
600
+ | Features dérivées de `PlayerView` + `legal_actions` | Lecture main adverse, état moteur brut |
601
+
602
+ **Limites soumission** (serveur) :
603
+
604
+ | Limite | Valeur indicative |
605
+ |--------|-------------------|
606
+ | Taille ZIP | 50 Mo |
607
+ | Fichier modèle unique | 30 Mo |
608
+ | Temps `setup()` | 30 s |
609
+ | Temps `decide()` | `decision_timeout_ms` (2 s par défaut) |
610
+ | RAM processus | 512 Mo |
611
+
612
+ Le runner charge le bot une fois par match (`setup` → boucle `decide` → `teardown`).
613
+
614
+ Bots de référence internes vivent dans `i151_engine/bots/` ; leur **identité stable** pour matchs et classement passe par `opponents/catalog.py`.
615
+
616
+ ---
617
+
618
+ ### 5b. `opponents/` — catalogue d'adversaires
619
+
620
+ Registre central des adversaires. **Tous les matchs référencent un `opponent_id`** (chaîne stable), jamais une instance `Bot` en dur côté serveur ou CLI — même en v1 où un seul adversaire est activé.
621
+
622
+ #### Pourquoi dès maintenant
623
+
624
+ - API serveur (`POST /matches/request`) et CLI (`arena match --vs <id>`) stables
625
+ - Classement ELO par paire `(challenger, opponent_id)`
626
+ - Extension sans refactor : activer un adversaire = `implemented: True` + factory
627
+ - Soumissions étudiantes et pools dynamiques utilisent le **même schéma d'ID**
628
+
629
+ #### Types
630
+
631
+ ```python
632
+ class OpponentKind(StrEnum):
633
+ BUILTIN = "builtin" # bot interne (random, minmax…)
634
+ STUDENT = "student" # soumission équipe (serveur)
635
+ POOL = "pool" # résolution dynamique (top 5, secret…)
636
+
637
+ class OpponentTier(StrEnum):
638
+ CALIBRATION = "calibration" # smoke test, tutoriel
639
+ EASY = "easy"
640
+ MEDIUM = "medium"
641
+ HARD = "hard"
642
+ EXPERT = "expert"
643
+ SECRET = "secret" # bot final non publié
644
+
645
+ @dataclass(frozen=True)
646
+ class OpponentSpec:
647
+ id: str # ex. "builtin:random"
648
+ name: str # libellé UI : "Aléatoire"
649
+ kind: OpponentKind
650
+ tier: OpponentTier
651
+ description: str
652
+ tags: frozenset[str] # ex. {"smoke_test", "elo_rating", "tutorial"}
653
+ implemented: bool # False tant que le bot n'existe pas
654
+ # factory None si résolution dynamique (student / pool)
655
+ ```
656
+
657
+ #### Catalogue canonique (`catalog.py`)
658
+
659
+ IDs **immuables** une fois publiés. `implemented` indique ce qui est codé ; la **v1 n'active qu'un sous-ensemble** via `schedules.V1_ENABLED_OPPONENTS`.
660
+
661
+ | `opponent_id` | Nom | Tier | Tags | v1 impl. | v1 actif | Rôle |
662
+ |---------------|-----|------|------|----------|----------|------|
663
+ | `builtin:random` | Aléatoire | calibration | smoke_test, tutorial, elo_rating | ✅ | ✅ | Smoke test, premier adversaire |
664
+ | `builtin:greedy` | Glouton | easy | elo_rating, tutorial | ⬜ | ⬜ | Heuristique simple |
665
+ | `builtin:medium` | Moyen | medium | elo_rating | ⬜ | ⬜ | Proche `AIPlayer` Dart |
666
+ | `builtin:minmax_d3` | MinMax d3 | hard | elo_rating | ⬜ | ⬜ | Référence faible |
667
+ | `builtin:minmax_d5` | MinMax d5 | expert | elo_rating, champion | ⬜ | ⬜ | **Bot champion** classement |
668
+ | `builtin:minmax_d6` | MinMax d6 | expert | elo_rating | ⬜ | ⬜ | Référence forte (cf. benchmarks Dart) |
669
+ | `student:{team_id}` | Équipe | — | student, elo_rating | ⬜ | ⬜ | Soumission active d'une équipe |
670
+ | `student:{team_id}:{version}` | Équipe vN | — | student, replay | ⬜ | ⬜ | Version précise (historique) |
671
+ | `pool:leaderboard_top1` | #1 classement | expert | pool, elo_rating | ⬜ | ⬜ | Adversaire = meilleur bot actuel |
672
+ | `pool:leaderboard_top5` | Top 5 | hard | pool, elo_rating | ⬜ | ⬜ | Matchs auto à la soumission |
673
+ | `pool:secret_final` | Bot secret | secret | pool, final_only | ⬜ | ⬜ | Classement final (non listé UI) |
674
+
675
+ > **v1** : seul `builtin:random` est `implemented` et actif. Les autres entrées existent dans le catalogue pour typage, migrations et UI « à venir » sans changer les contrats.
676
+
677
+ #### Registre (`registry.py`)
678
+
679
+ ```python
680
+ @dataclass(frozen=True)
681
+ class ResolveContext:
682
+ """Paramètres pour adversaires dynamiques (student / pool)."""
683
+ team_id: str | None = None
684
+ submission_version: int | None = None
685
+ leaderboard_snapshot_id: str | None = None
686
+
687
+ class OpponentRegistry:
688
+ def get_spec(self, opponent_id: str) -> OpponentSpec: ...
689
+ def list_specs(
690
+ self,
691
+ *,
692
+ implemented_only: bool = False,
693
+ enabled_only: bool = False, # filtre V1_ENABLED_OPPONENTS
694
+ tags: frozenset[str] | None = None,
695
+ ) -> list[OpponentSpec]: ...
696
+ def resolve_bot(self, opponent_id: str, ctx: ResolveContext | None = None) -> Bot: ...
697
+ ```
698
+
699
+ - `resolve_bot("builtin:random")` → instance `RandomBot`
700
+ - `resolve_bot("student:abc123")` → lève `NotImplementedError` en v1 moteur ; implémenté dans `arena/server/`
701
+ - ID inconnu → `UnknownOpponentError`
702
+
703
+ #### Schedules (`schedules.py`)
704
+
705
+ Ensembles nommés d'`opponent_id` pour les workflows serveur — **définis maintenant**, exécutés progressivement.
706
+
707
+ ```python
708
+ # Adversaires autorisés en v1 (sous-ensemble strict)
709
+ V1_ENABLED_OPPONENTS: frozenset[str] = frozenset({"builtin:random"})
710
+
711
+ # Jeux prévus (référence future — pas tous actifs en v1)
712
+ SMOKE_TEST_OPPONENTS = ("builtin:random",)
713
+ ON_SUBMIT_OPPONENTS = (
714
+ "builtin:random",
715
+ "builtin:medium",
716
+ "builtin:minmax_d5",
717
+ "pool:leaderboard_top5",
718
+ )
719
+ ELO_RATING_OPPONENTS = (
720
+ "builtin:random",
721
+ "builtin:medium",
722
+ "builtin:minmax_d5",
723
+ )
724
+ FULL_LADDER_OPPONENTS = (
725
+ "builtin:random",
726
+ "builtin:greedy",
727
+ "builtin:medium",
728
+ "builtin:minmax_d3",
729
+ "builtin:minmax_d5",
730
+ "builtin:minmax_d6",
731
+ )
732
+ FINAL_RANKING_OPPONENTS = ("pool:secret_final", "builtin:minmax_d6")
733
+ ```
734
+
735
+ Le serveur appelle `schedule_for_event("on_submit")` → liste d'IDs → un match par ID (quand implémenté).
736
+
737
+ #### Impact sur le replay et la base
738
+
739
+ Chaque match enregistre :
740
+
741
+ ```json
742
+ {
743
+ "challenger": { "kind": "student", "team_id": "…", "version": 2 },
744
+ "opponent_id": "builtin:random",
745
+ "opponent_spec": { "name": "Aléatoire", "tier": "calibration" }
746
+ }
747
+ ```
748
+
749
+ Le classement stocke des stats **par paire** `(challenger_id, opponent_id)` en plus de l'ELO global.
750
+
751
+ ---
752
+
753
+ ### 5c. SDK ML (`arena/sdk/features.py`)
754
+
755
+ Helpers **optionnels** pour faciliter les pipelines ML (sans imposer sklearn/torch au moteur) :
756
+
757
+ ```python
758
+ def encode_view(view: PlayerView, legal_actions: list[Action]) -> np.ndarray:
759
+ """Vecteur de features fixes (dimension documentée, ex. 128)."""
760
+
761
+ def encode_action(action: Action) -> int:
762
+ """Index stable d'une action parmi legal_actions du même tour."""
763
+
764
+ def action_from_index(legal_actions: list[Action], index: int) -> Action:
765
+ """Inverse — avec clamp si index hors bornes."""
766
+ ```
767
+
768
+ Les étudiants peuvent aussi encoder eux-mêmes `PlayerView` (one-hot main, scores, `recent_cards`, etc.). Le SDK fournit un **schéma de référence** pour comparer des approches et pour les notebooks de cours.
769
+
770
+ **Génération de données d'entraînement** (hors sandbox) :
771
+
772
+ ```python
773
+ # arena_sdk/simulation.py — usage local uniquement
774
+ def self_play_random(seed: int, n_games: int) -> list[TrainingSample]: ...
775
+ ```
776
+
777
+ Les parties générées localement n'alimentent pas le classement ; seules les soumissions sur la plateforme comptent.
778
+
779
+ ---
780
+
781
+
782
+
783
+ ### 6. `runner/` — exécution compétition
784
+
785
+ ```python
786
+ @dataclass(frozen=True)
787
+ class MatchConfig:
788
+ seed: int
789
+ target_score: int = 151
790
+ decision_timeout_ms: int = 2000
791
+ match_timeout_ms: int = 600_000
792
+ max_steps_per_round: int = 500 # garde-fou anti-boucle
793
+
794
+ @dataclass(frozen=True)
795
+ class MatchResult:
796
+ winner_id: str | None
797
+ opponent_id: str # ex. "builtin:random"
798
+ reason: Literal["normal", "forfeit", "timeout", "max_steps"]
799
+ forfeited_player_id: str | None
800
+ final_scores: dict[str, int]
801
+ rounds_played: int
802
+ replay: ReplayLog
803
+ ```
804
+
805
+ **Forfait** si :
806
+
807
+ - `decide()` dépasse `decision_timeout_ms`
808
+ - action retournée ∉ `legal_actions`
809
+ - exception non gérée dans `decide()`
810
+ - `max_steps_per_round` atteint
811
+
812
+ ---
813
+
814
+
815
+
816
+ ## Format replay (`recorder.py`)
817
+
818
+ JSON sérialisable, consommé par `arena/web/` :
819
+
820
+ ```json
821
+ {
822
+ "version": 1,
823
+ "seed": 42,
824
+ "config": { "target_score": 151 },
825
+ "players": [
826
+ { "id": "p0", "name": "Team Alpha", "role": "challenger" },
827
+ { "id": "p1", "name": "Aléatoire", "role": "opponent", "opponent_id": "builtin:random" }
828
+ ],
829
+ "rounds": [
830
+ {
831
+ "round_number": 1,
832
+ "winner_id": null,
833
+ "steps": [
834
+ {
835
+ "index": 0,
836
+ "player_id": "p0",
837
+ "action": { "type": "PLAY_CARD", "card": "7H", "chosen_suit": null },
838
+ "snapshot": {
839
+ "step_index": 0,
840
+ "current_player_id": "p0",
841
+ "table": {
842
+ "recent_cards": [],
843
+ "table_empty": true,
844
+ "required_suit": null,
845
+ "must_choose_suit": false,
846
+ "ace_effect_active": false,
847
+ "bank_size": 18,
848
+ "played_pile_size": 0,
849
+ "last_play_count": 0
850
+ },
851
+ "hands_size": { "p0": 7, "p1": 7 },
852
+ "scores": { "p0": 0, "p1": 0 },
853
+ "opponent_id": "builtin:random"
854
+ }
855
+ }
856
+ ]
857
+ }
858
+ ],
859
+ "result": {
860
+ "winner_id": null,
861
+ "final_scores": { "p0": 0, "p1": 0 },
862
+ "reason": "normal"
863
+ }
864
+ }
865
+ ```
866
+
867
+ Chaque `snapshot` contient uniquement des **infos publiques** (+ tailles de mains, pas les cartes adverses) pour le viewer web.
868
+
869
+ ---
870
+
871
+
872
+
873
+ ## Flux d'une décision
874
+
875
+ ```mermaid
876
+ sequenceDiagram
877
+ participant R as match_runner
878
+ participant M as match/round
879
+ participant V as PlayerView
880
+ participant B as Bot
881
+ participant L as legal_actions
882
+ participant A as reducer
883
+
884
+ R->>M: état manche courante
885
+ M->>V: projection joueur courant
886
+ M->>L: legal_actions(round)
887
+ R->>B: decide(view, legal, timeout)
888
+ B-->>R: action
889
+ R->>L: action in legal?
890
+ alt illégale ou timeout
891
+ R->>R: forfait
892
+ else ok
893
+ R->>A: apply_action(round, action)
894
+ A-->>M: nouvel état
895
+ R->>R: recorder.record()
896
+ end
897
+ ```
898
+
899
+
900
+
901
+ ---
902
+
903
+
904
+
905
+ ## Alignement avec le code Dart
906
+
907
+
908
+ | Python | Dart / TS | Rôle |
909
+ | ---------------------- | ------------------------------------------- | --------------------- |
910
+ | `Card`, `can_play_on` | `lib/models/card.dart` | Cartes et jouabilité |
911
+ | `Action`, `ActionType` | `player_action.dart`, `Action.ts` | Actions joueur |
912
+ | `legal_actions` | `Player.getAllSingleCardActions` + multi | Énumération |
913
+ | `reducer.apply_action` | `GameManager.processPlayerTurn` | Transition d'état |
914
+ | `scoring` | `_actuallyEndRound`, `trackConsecutiveWins` | Scores et exclusion |
915
+ | `deck` | `lib/models/deck.dart` | Distribution / banque |
916
+ | `MatchConfig.seed` | `Random(seed)` dans `startNewRound` | Reproductibilité |
917
+ | `opponents/catalog` | — | IDs adversaires stables |
918
+ | `opponents/registry` | — | Résolution `opponent_id` → `Bot` |
919
+ | `view/player_view.py` | — | `PlayerView`, sous-vues, `build_player_view()` |
920
+
921
+ | `opponents/schedules` | — | Listes par événement (smoke, elo…) |
922
+
923
+ Tests de non-régression : porter les cas de `test/game_rules_test.dart` et `test/game_manager_test.dart` en pytest.
924
+
925
+ ---
926
+
927
+
928
+
929
+ ## Dépendances Python
930
+
931
+ ### Moteur `i151_engine`
932
+
933
+ | Package | Usage |
934
+ |---------|--------|
935
+ | stdlib (`dataclasses`, `enum`, `typing`) | moteur uniquement |
936
+ | `pytest` | tests |
937
+
938
+ Pas de ML dans le moteur — dépendances lourdes isolées dans le **sandbox étudiant**.
939
+
940
+ ### Whitelist sandbox — soumissions étudiantes (`requirements.txt`)
941
+
942
+ | Package | Usage ML |
943
+ |---------|----------|
944
+ | `numpy` | features, tenseurs |
945
+ | `scikit-learn` | modèles classiques, `joblib` |
946
+ | `joblib` | sérialisation modèles sklearn |
947
+ | `onnxruntime` | inférence ONNX (CPU) |
948
+ | `torch` | inférence PyTorch **CPU only** (`torch.cuda` interdit) |
949
+ | `pandas` | optionnel — préprocessing léger en `setup` |
950
+
951
+ Tout autre package → **rejet à la soumission**. Pas de `requests`, `httpx`, `tensorflow` (v1), pas de compilation JIT arbitraire.
952
+
953
+ Pas de dépendance réseau, pas de Flask/FastAPI dans le moteur — le serveur `arena/server/` appellera `match_runner` comme librairie.
954
+
955
+ ---
956
+
957
+
958
+
959
+ ## Ordre d'implémentation
960
+
961
+ 1. `models/card.py` + `models/action.py` + tests
962
+ 2. `core/deck.py` + `core/rules.py` + tests
963
+ 3. `game/round_state.py` + `core/reducer.py` + `core/legal_actions.py` + tests
964
+ 4. `core/scoring.py` + `game/match_state.py` + `game/match.py` + tests
965
+ 5. `view/player_view.py` + `view/builder.py` + tests (visibilité partielle, `opponent_id`)
966
+ 6. `opponents/types.py` + `catalog.py` + `registry.py` + `schedules.py` (catalogue complet, v1 = `builtin:random` seul actif)
967
+ 7. `bots/protocol.py` + `random_bot.py` (premier bot du catalogue)
968
+ 8. `game/recorder.py`
969
+ 9. `runner/match_runner.py` — `setup` → `decide` → `teardown`, support ZIP ML
970
+ 10. `arena/sdk/features.py` — encodage `PlayerView` pour pipelines ML
971
+ 11. Autres bots du catalogue (`greedy`, `medium`, `minmax_d*`)
972
+
973
+ ---
974
+
975
+
976
+
977
+ ## Journal
978
+
979
+
980
+ | Date | Note |
981
+ | ---------- | ----------------------------- |
982
+ | 2026-07-03 | Architecture initiale définie |
983
+ | 2026-07-03 | Soumissions ML : `setup`/`decide`, whitelist pip, assets modèle, inférence seule en arène |
984
+
985
+