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.
- i151_engine-0.1.0/ARCHITECTURE.md +985 -0
- i151_engine-0.1.0/PKG-INFO +994 -0
- i151_engine-0.1.0/README.md +59 -0
- i151_engine-0.1.0/i151_engine/__init__.py +6 -0
- i151_engine-0.1.0/i151_engine/bots/__init__.py +0 -0
- i151_engine-0.1.0/i151_engine/bots/protocol.py +20 -0
- i151_engine-0.1.0/i151_engine/bots/random_bot.py +31 -0
- i151_engine-0.1.0/i151_engine/core/__init__.py +15 -0
- i151_engine-0.1.0/i151_engine/core/deck.py +90 -0
- i151_engine-0.1.0/i151_engine/core/errors.py +2 -0
- i151_engine-0.1.0/i151_engine/core/legal_actions.py +49 -0
- i151_engine-0.1.0/i151_engine/core/reducer.py +220 -0
- i151_engine-0.1.0/i151_engine/core/rules.py +93 -0
- i151_engine-0.1.0/i151_engine/core/scoring.py +152 -0
- i151_engine-0.1.0/i151_engine/game/__init__.py +0 -0
- i151_engine-0.1.0/i151_engine/game/recorder.py +189 -0
- i151_engine-0.1.0/i151_engine/game/round.py +81 -0
- i151_engine-0.1.0/i151_engine/game/round_state.py +70 -0
- i151_engine-0.1.0/i151_engine/models/__init__.py +15 -0
- i151_engine-0.1.0/i151_engine/models/action.py +55 -0
- i151_engine-0.1.0/i151_engine/models/card.py +159 -0
- i151_engine-0.1.0/i151_engine/models/enums.py +11 -0
- i151_engine-0.1.0/i151_engine/models/player.py +37 -0
- i151_engine-0.1.0/i151_engine/opponents/__init__.py +0 -0
- i151_engine-0.1.0/i151_engine/opponents/catalog.py +121 -0
- i151_engine-0.1.0/i151_engine/opponents/registry.py +56 -0
- i151_engine-0.1.0/i151_engine/opponents/schedules.py +48 -0
- i151_engine-0.1.0/i151_engine/opponents/types.py +35 -0
- i151_engine-0.1.0/i151_engine/runner/config.py +15 -0
- i151_engine-0.1.0/i151_engine/runner/match_runner.py +298 -0
- i151_engine-0.1.0/i151_engine/view/builder.py +132 -0
- i151_engine-0.1.0/i151_engine/view/player_view.py +86 -0
- i151_engine-0.1.0/i151_engine.egg-info/PKG-INFO +994 -0
- i151_engine-0.1.0/i151_engine.egg-info/SOURCES.txt +46 -0
- i151_engine-0.1.0/i151_engine.egg-info/dependency_links.txt +1 -0
- i151_engine-0.1.0/i151_engine.egg-info/requires.txt +3 -0
- i151_engine-0.1.0/i151_engine.egg-info/top_level.txt +1 -0
- i151_engine-0.1.0/pyproject.toml +22 -0
- i151_engine-0.1.0/setup.cfg +4 -0
- i151_engine-0.1.0/tests/test_card.py +57 -0
- i151_engine-0.1.0/tests/test_deck.py +37 -0
- i151_engine-0.1.0/tests/test_match_runner.py +17 -0
- i151_engine-0.1.0/tests/test_match_timeout.py +67 -0
- i151_engine-0.1.0/tests/test_opponents.py +33 -0
- i151_engine-0.1.0/tests/test_recorder.py +55 -0
- i151_engine-0.1.0/tests/test_reducer.py +88 -0
- i151_engine-0.1.0/tests/test_rules.py +83 -0
- 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
|
+
|