Metadata-Version: 2.4
Name: i151-engine
Version: 0.1.0
Summary: Headless game engine for the i151 student AI competition
Requires-Python: >=3.11
Description-Content-Type: text/markdown
Provides-Extra: dev
Requires-Dist: pytest>=8.0; extra == "dev"

# Architecture — moteur de jeu Python (`arena/engine`)

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`.

**Périmètre v1** : 2 joueurs, partie complète jusqu'à 151 points, RNG seedé (reproductible), log d'actions pour replay.

**Hors périmètre** : dossier `agent/`, multijoueur Firebase, UI Flutter, échange de mains (`handExchangeEnabled`), parties 3–4 joueurs.

---

## Principes de conception


| Principe       | Choix                                                                             |
| -------------- | --------------------------------------------------------------------------------- |
| État           | **Immuable** — chaque action retourne un nouvel état (replay, tests, fork MinMax) |
| Couches        | Modèles → règles pures → réducteur → manche → match → runner                      |
| Visibilité bot | `PlayerView` — main propre + infos publiques uniquement (pas la main adverse)     |
| Déterminisme   | `seed: int` pour mélange et distribution                                          |
| Erreurs        | Action illégale → rejet côté runner (défaite par forfait en compétition)          |
| Alignement     | Tests Python calqués sur `test/game_rules_test.dart`                              |
| Adversaires    | **`opponent_id` stable** via `opponents/catalog` — jamais de `Bot` en dur dans l'API |
| Approches bot  | Heuristiques **ou** machine learning — entraînement hors arène, **inférence seule** en match |


---



## Arborescence

```
arena/engine/
├── ARCHITECTURE.md          # Ce document
├── pyproject.toml
├── i151_engine/
│   ├── __init__.py
│   ├── models/
│   │   ├── card.py          # Suit, Rank, Card, points, codes ("AS", "8H"…)
│   │   ├── action.py        # ActionType, Action
│   │   ├── player.py        # PlayerState (main, scores, flags tour)
│   │   └── enums.py         # Phase, MatchStatus
│   ├── core/
│   │   ├── deck.py          # Jeu 32 cartes, deal, pioche, reconstitution banque
│   │   ├── rules.py         # can_play_on, validation (fonctions pures)
│   │   ├── legal_actions.py # legal_actions(round_state, player_idx) -> list[Action]
│   │   ├── reducer.py       # apply_action(state, action) -> RoundState
│   │   └── scoring.py       # points main, fin de manche, exclusion à 151, bonus -10
│   ├── game/
│   │   ├── round_state.py   # État d'une manche en cours
│   │   ├── match_state.py   # État d'une partie (scores cumulés, manche N)
│   │   ├── round.py         # démarrer / terminer une manche
│   │   ├── match.py         # boucle partie complète
│   │   └── recorder.py      # journal structuré pour replay
│   ├── view/
│   │   ├── player_view.py   # PlayerView + sous-vues
│   │   └── builder.py       # build_player_view(round, match, perspective_id)
│   ├── bots/
│   │   ├── protocol.py      # protocole Bot (typing.Protocol)
│   │   ├── random_bot.py
│   │   ├── greedy_bot.py
│   │   └── minmax_bot.py    # implémentations concrètes
│   ├── opponents/
│   │   ├── types.py         # OpponentSpec, OpponentKind, OpponentTier
│   │   ├── catalog.py       # liste canonique des adversaires (IDs stables)
│   │   ├── registry.py      # OpponentRegistry — résolution id → Bot
│   │   └── schedules.py     # jeux d'adversaires par contexte (smoke, elo, ladder)
│   └── runner/
│       ├── match_runner.py  # orchestration challenger vs opponent_id + timeouts
│       └── config.py        # MatchConfig (seed, timeouts, target_score)
└── tests/
    ├── test_card.py
    ├── test_rules.py
    ├── test_reducer.py
    ├── test_round.py
    ├── test_match.py
    └── fixtures/            # états JSON pour régression
```

Le package `arena/sdk/` (phase ultérieure) importera `i151_engine` et n'exposera aux étudiants que `PlayerView`, `Action` et `decide()`.

---



## Couches et responsabilités

```mermaid
flowchart TB
    subgraph runner ["runner/"]
        MR[match_runner]
    end

    subgraph bots ["bots/"]
        B[Bot.decide]
    end

    subgraph view ["view/"]
        PV[PlayerView]
    end

    subgraph game ["game/"]
        M[match.py]
        R[round.py]
        REC[recorder.py]
    end

    subgraph core ["core/"]
        LA[legal_actions]
        RED[reducer.apply_action]
        RULES[rules]
        DECK[deck]
        SC[scoring]
    end

    subgraph models ["models/"]
        CARD[card / action / player]
    end

    MR --> M
    M --> R
    MR --> B
    B --> PV
    PV --> R
    MR --> LA
    MR --> RED
    LA --> RULES
    RED --> RULES
    RED --> DECK
    R --> SC
    M --> SC
    MR --> REC
    RULES --> CARD
    RED --> CARD
```





### 1. `models/` — données immuables

Tous les types sont des `@dataclass(frozen=True)` (ou `NamedTuple` pour les cartes).

#### `Card`

```python
@dataclass(frozen=True)
class Card:
    rank: Rank   # SEVEN … ACE
    suit: Suit   # SPADES, HEARTS, DIAMONDS, CLUBS

    @property
    def code(self) -> str: ...      # "AS", "TH", "8D"
    @property
    def points(self) -> int: ...    # 8→32, A→11, Q→3, K→4, J→2, 7→7, 9→9, 10→10
```

Codes alignés sur `Card.fromCode` (Dart) : `A/K/Q/J/T/9/8/7` + `S/H/D/C`.

#### `Action` / `ActionType`

Aligné sur `lib/models/player_action.dart` :


| `ActionType`          | Champs                  | Notes                                           |
| --------------------- | ----------------------- | ----------------------------------------------- |
| `PLAY_CARD`           | `card`, `chosen_suit?`  | `chosen_suit` requis si carte 8 et fin de série |
| `PLAY_MULTIPLE_CARDS` | `cards`, `chosen_suit?` | même rang (ou règles Dame en 2J), max 8 cartes  |
| `DRAW_CARD`           | —                       | 1 carte, ou 2 si `ace_effect_active`            |
| `PASS_TURN`           | —                       | après pioche ou fin de chaîne                   |
| `CHOOSE_SUIT`         | `chosen_suit`           | après jeu d'un 8 sans couleur choisie inline    |


Pas de `RESIGN` en v1 compétition (forfait géré par le runner sur timeout / action invalide).

#### `PlayerState`

```python
@dataclass(frozen=True)
class PlayerState:
    player_id: str          # "p0" | "p1"
    name: str
    hand: tuple[Card, ...]  # ordre stable pour le bot
    total_score: int        # cumul partie
    is_excluded: bool
    has_drawn_this_turn: bool
    is_chaining: bool
```

---



### 2. `core/` — logique pure



#### `deck.py`

- Jeu standard **32 cartes** (7, 8, 9, 10, V, D, R, A × 4 couleurs)
- `shuffle(seed)` → ordre déterministe
- `deal(players: int, cards_per_player: int = 7)` → mains + banque
- `draw(n)` depuis la tête de banque
- `refill_from_played(played: tuple[Card, ...])` — cartes jouées remises en banque **dans l'ordre FIFO**



#### `rules.py`

Fonctions sans effet de bord, portées depuis Dart :

- `can_play_on(card, top_card, required_suit) -> bool`
- `is_valid_single_play(player, card, round_state) -> bool`
- `is_valid_multiple_play(player, cards, round_state) -> bool`
- 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)



#### `legal_actions.py`

```python
def legal_actions(state: RoundState) -> list[Action]:
    """Actions légales pour state.current_player_index."""
```

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`.

#### `reducer.py`

```python
def apply_action(state: RoundState, action: Action) -> RoundState:
    """Lève IllegalActionError si action invalide."""
```

Effets gérés (miroir `GameManager.processPlayerTurn`) :

- jeu simple / multiple, chaînage (`is_chaining`)
- pioche (1 ou 2 sur As), interdiction double pioche → passe auto
- `choose_suit` après 8
- effets : 8 (couleur imposée), As (`ace_effect_active`), Dame (`skip_next_player`)
- reconstitution banque si vide
- fin de manche (main vide) + cas Dame 2 joueurs (pioche auto)
- passage au joueur suivant



#### `scoring.py`

- `hand_score(hand) -> int` — somme des points des cartes restantes
- `apply_round_result(match, winner_id) -> MatchState` — perdants cumulent, exclusion à `target_score` (151)
- `apply_consecutive_win_bonus(match, winner_id)` — -10 après 3 victoires consécutives

---



### 3. `game/` — orchestration



#### `RoundState` — une manche

```python
@dataclass(frozen=True)
class RoundState:
  phase: Literal["playing", "ended"]
  players: tuple[PlayerState, PlayerState]
  current_player_index: int
  top_card: Card | None
  required_suit: Suit | None
  choose_suit: bool              # le joueur courant doit annoncer une couleur
  ace_effect_active: bool
  skip_next_player: bool
  bank: tuple[Card, ...]
  played_pile: tuple[Card, ...]   # FIFO pour reconstitution
  round_number: int
  dealer_index: int
  last_played_count: int
```



#### `MatchState` — partie complète

```python
@dataclass(frozen=True)
class MatchState:
  status: Literal["in_progress", "finished"]
  players: tuple[PlayerState, PlayerState]
  round_number: int
  dealer_index: int
  target_score: int              # 151
  consecutive_wins: dict[str, int]
  last_winner_id: str | None
  winner_id: str | None          # dernier non exclu
  current_round: RoundState | None
```



#### `match.py`

```python
def play_match(
    challenger: Bot,
    opponent_id: str,
    config: MatchConfig,
    *,
    registry: OpponentRegistry | None = None,
) -> MatchResult:
    """opponent_id résolu via OpponentRegistry (défaut : registre global)."""
    ...
```

Variante serveur (phase 2) :

```python
def play_match_by_ids(
    challenger_id: str,          # "student:{team_id}" ou soumission
    opponent_id: str,
    config: MatchConfig,
    ctx: ResolveContext,
) -> MatchResult: ...
```

Boucle :

1. `start_round(match)` → `RoundState`
2. Tant que manche en cours :
  - construire `PlayerView` pour le joueur courant
  - `legal = legal_actions(round)`
  - `action = active_bot.decide(view, legal, time_left_ms)` (challenger ou adversaire résolu)
  - `round = apply_action(round, action)`
  - `recorder.record(...)`
3. `match = apply_round_result(match, winner)`
4. Si partie terminée → `MatchResult`, sinon manche suivante

---



### 4. `view/` — ce que voit un bot étudiant

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.

#### Ce qui est visible / invisible

| Visible | Invisible (interdit) |
|---------|----------------------|
| Sa main (`you.hand`) | Cartes des autres joueurs |
| Scores cumulés partie, tailles main / banque | Ordre exact de la banque |
| **3 dernières cartes** (`table.recent_cards`) + sommet via `top_card` | Reste du talon (`played_pile` au-delà de 3 cartes) |
| Couleur imposée, effet As | Code des autres bots |
| Flags de tour (pioche, chaîne…) | `MatchState` / `RoundState` bruts |
| Métadonnées adversaires (`opponents[].opponent_id`, tier) | Soumissions / code des autres bots |

#### Sous-vues

```python
@dataclass(frozen=True)
class YouState:
    player_id: str
    hand: tuple[Card, ...]           # tri stable (couleur puis rang)
    hand_size: int
    hand_points: int                 # valeur des cartes restantes si la manche s'arrêtait maintenant
    total_score: int                 # cumul partie (151 = élimination)
    is_excluded: bool
    has_drawn_this_turn: bool
    is_chaining: bool                # peut enchaîner même rang / 8
    consecutive_round_wins: int      # victoires de manche consécutives (bonus -10 à 3)

@dataclass(frozen=True)
class OpponentState:
    player_id: str
    seat_index: int                  # position dans l'ordre de jeu (0..n-1)
    opponent_id: str                 # ID catalogue, ex. "builtin:random" ou "student:abc"
    name: str                        # libellé affiché / registre
    tier: OpponentTier | None        # None si adversaire étudiant
    hand_size: int
    total_score: int
    is_excluded: bool
    consecutive_round_wins: int      # pour anticiper le bonus -10 à 3
    turns_until_next: int            # 0 si c'est son tour, 1 = joue juste après toi, etc.
    is_next_to_play: bool            # True si c'est le prochain joueur actif

@dataclass(frozen=True)
class TableState:
    recent_cards: tuple[Card, ...]   # 0 à 3 cartes, ordre chronologique (index -1 = sommet / top)
    table_empty: bool                # True si aucune carte sur la table
    required_suit: Suit | None       # couleur imposée après un 8
    must_choose_suit: bool           # True → CHOOSE_SUIT attendu (8 joué sans couleur)
    ace_effect_active: bool          # prochaine pioche = 2 cartes (joueur courant)
    bank_size: int
    played_pile_size: int            # total cartes dans le talon (dont les non visibles)
    last_play_count: int             # nb cartes jouées lors de la dernière action

    @property
    def top_card(self) -> Card | None:
        """Équivalent Dart `topCard` — dernière carte de `recent_cards`."""
        return self.recent_cards[-1] if self.recent_cards else None
```

`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.

**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`).

---
```python
@dataclass(frozen=True)
class TurnState:
    is_your_turn: bool
    can_draw: bool                   # pas encore pioché ET pas en mode choose_suit
    can_pass: bool                   # has_drawn_this_turn ou is_chaining
    draw_count_if_draw: int          # 1, ou 2 si ace_effect_active
    step_index: int                  # numéro de l'action dans la manche (0-based)
    current_player_id: str           # joueur dont c'est le tour (== you.player_id si is_your_turn)
    next_player_id: str | None       # prochain joueur actif après l'action en cours

@dataclass(frozen=True)
class MatchContext:
    round_number: int
    target_score: int                # 151
    you_are_dealer: bool
    player_count: int                # nb de sièges (2 en v1, extensible 3–4)
    active_player_count: int         # joueurs non exclus
    seats: tuple[str, ...]           # player_ids dans l'ordre des sièges (sens horaire)

@dataclass(frozen=True)
class PlayerView:
    """Seule interface d'état exposée aux bots étudiants."""

    you: YouState
    opponents: tuple[OpponentState, ...]   # tous les autres joueurs, triés par seat_index
    table: TableState
    turn: TurnState
    match: MatchContext

    def sole_opponent(self) -> OpponentState:
        """Helper v1 (2 joueurs). Lève ValueError si len(opponents) != 1."""
        ...
```

**v1** : `len(opponents) == 1`. **v2+** (3–4 joueurs) : `len(opponents) == player_count - 1`, même structure sans changer l'API.

#### Construction

```python
def build_player_view(
    match: MatchState,
    round_state: RoundState,
    perspective_player_id: str,
    *,
    opponent_specs: dict[str, OpponentSpec],  # player_id → spec (catalogue ou student)
    step_index: int,
) -> PlayerView:
    """Lève ValueError si perspective_player_id n'est pas un joueur actif."""
    ...
```

- Appelée par `match_runner` **à chaque** invocation de `decide()`
- `opponent_specs` : une entrée par **autre** joueur (`player_id` → `OpponentSpec` ou spec étudiant)
- `opponents` exclut toujours `you` ; ordre = `seat_index` croissant
- `table.recent_cards` = `played_pile[-3:]` (max 3 cartes, sommet en dernier)
- `turn.is_your_turn` est toujours `True` quand `decide()` est appelé ; conservé pour clarté SDK et tests

#### Exemple SDK (futur)

```python
from arena_sdk import PlayerView, Action

def decide(view: PlayerView, legal_actions: list[Action], time_left_ms: int) -> Action:
    if view.table.ace_effect_active and view.turn.can_draw:
        ...

    # v1 — 2 joueurs
    opp = view.sole_opponent()
    if opp.hand_size == 1 and view.you.hand_points > opp.total_score:
        ...

    # v2+ — plusieurs adversaires
    # leader = max(view.opponents, key=lambda o: o.total_score)
    # next_opp = next(o for o in view.opponents if o.is_next_to_play)

    return legal_actions[0]
```

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.

#### Sérialisation (replay / debug)

Snapshot JSON public aligné sur `PlayerView` (sans mains) :

```json
{
  "step_index": 12,
  "current_player_id": "p0",
  "table": {
    "recent_cards": ["9S", "9H", "TH"],
    "table_empty": false,
    "required_suit": null,
    "must_choose_suit": false,
    "ace_effect_active": false,
    "bank_size": 14,
    "played_pile_size": 8,
    "last_play_count": 1
  },
  "hands_size": { "p0": 4, "p1": 6 },
  "scores": { "p0": 45, "p1": 72 },
  "opponents": [
    { "player_id": "p1", "opponent_id": "builtin:random", "seat_index": 1, "hand_size": 6 }
  ]
}
```

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.

---



### 5. `bots/` — protocole compétition

```python
class Bot(Protocol):
    def setup(self, submission_dir: Path) -> None:
        """Appelé une fois avant le premier match (chargement modèle ML, etc.)."""
        ...

    def decide(
        self,
        view: PlayerView,
        legal_actions: list[Action],
        time_left_ms: int,
    ) -> Action: ...

    def teardown(self) -> None:
        """Optionnel — libération mémoire après le match."""
        ...
```

`setup` / `teardown` sont no-op pour les bots heuristiques simples.

#### Soumission étudiante — heuristique (`arena/sdk/`)

```python
# bot.py
from arena_sdk import PlayerView, Action

def setup(submission_dir):  # optionnel
    pass

def decide(view: PlayerView, legal_actions: list[Action], time_left_ms: int) -> Action:
    return legal_actions[0]
```

#### Soumission étudiante — machine learning

Les équipes **entraînent en local** (leurs machines, notebooks, GPU perso) et soumettent **code d'inférence + poids** :

```
submission.zip
├── bot.py                 # setup() charge le modèle ; decide() infère
├── requirements.txt       # optionnel — libs whitelist uniquement
├── model.joblib           # ex. scikit-learn (optionnel)
├── model.onnx             # ex. ONNX (optionnel)
├── weights.pt             # ex. PyTorch state_dict (optionnel)
└── assets/                # sous-dossiers autorisés, pas d'exécution auto
```

Exemple :

```python
# bot.py
from pathlib import Path
import joblib
import numpy as np
from arena_sdk import PlayerView, Action
from arena_sdk.features import encode_view

_model = None

def setup(submission_dir: Path) -> None:
    global _model
    _model = joblib.load(submission_dir / "model.joblib")

def decide(view: PlayerView, legal_actions: list[Action], time_left_ms: int) -> Action:
    x = encode_view(view, legal_actions)
    idx = int(_model.predict(x.reshape(1, -1))[0])
    return legal_actions[idx]
```

**Règles ML en arène** :

| Autorisé | Interdit |
|----------|----------|
| Inférence (`predict`, `forward`, ONNX Runtime) | Entraînement (`fit`, `train`, `backward`) |
| Chargement poids dans `setup()` | Téléchargement réseau de modèles / datasets |
| numpy / sklearn / onnxruntime / torch **CPU** | `requests`, accès Internet, GPU sandbox |
| Features dérivées de `PlayerView` + `legal_actions` | Lecture main adverse, état moteur brut |

**Limites soumission** (serveur) :

| Limite | Valeur indicative |
|--------|-------------------|
| Taille ZIP | 50 Mo |
| Fichier modèle unique | 30 Mo |
| Temps `setup()` | 30 s |
| Temps `decide()` | `decision_timeout_ms` (2 s par défaut) |
| RAM processus | 512 Mo |

Le runner charge le bot une fois par match (`setup` → boucle `decide` → `teardown`).

Bots de référence internes vivent dans `i151_engine/bots/` ; leur **identité stable** pour matchs et classement passe par `opponents/catalog.py`.

---

### 5b. `opponents/` — catalogue d'adversaires

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é.

#### Pourquoi dès maintenant

- API serveur (`POST /matches/request`) et CLI (`arena match --vs <id>`) stables
- Classement ELO par paire `(challenger, opponent_id)`
- Extension sans refactor : activer un adversaire = `implemented: True` + factory
- Soumissions étudiantes et pools dynamiques utilisent le **même schéma d'ID**

#### Types

```python
class OpponentKind(StrEnum):
    BUILTIN = "builtin"              # bot interne (random, minmax…)
    STUDENT = "student"              # soumission équipe (serveur)
    POOL = "pool"                    # résolution dynamique (top 5, secret…)

class OpponentTier(StrEnum):
    CALIBRATION = "calibration"      # smoke test, tutoriel
    EASY = "easy"
    MEDIUM = "medium"
    HARD = "hard"
    EXPERT = "expert"
    SECRET = "secret"                # bot final non publié

@dataclass(frozen=True)
class OpponentSpec:
    id: str                          # ex. "builtin:random"
    name: str                        # libellé UI : "Aléatoire"
    kind: OpponentKind
    tier: OpponentTier
    description: str
    tags: frozenset[str]             # ex. {"smoke_test", "elo_rating", "tutorial"}
    implemented: bool                # False tant que le bot n'existe pas
    # factory None si résolution dynamique (student / pool)
```

#### Catalogue canonique (`catalog.py`)

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`.

| `opponent_id` | Nom | Tier | Tags | v1 impl. | v1 actif | Rôle |
|---------------|-----|------|------|----------|----------|------|
| `builtin:random` | Aléatoire | calibration | smoke_test, tutorial, elo_rating | ✅ | ✅ | Smoke test, premier adversaire |
| `builtin:greedy` | Glouton | easy | elo_rating, tutorial | ⬜ | ⬜ | Heuristique simple |
| `builtin:medium` | Moyen | medium | elo_rating | ⬜ | ⬜ | Proche `AIPlayer` Dart |
| `builtin:minmax_d3` | MinMax d3 | hard | elo_rating | ⬜ | ⬜ | Référence faible |
| `builtin:minmax_d5` | MinMax d5 | expert | elo_rating, champion | ⬜ | ⬜ | **Bot champion** classement |
| `builtin:minmax_d6` | MinMax d6 | expert | elo_rating | ⬜ | ⬜ | Référence forte (cf. benchmarks Dart) |
| `student:{team_id}` | Équipe | — | student, elo_rating | ⬜ | ⬜ | Soumission active d'une équipe |
| `student:{team_id}:{version}` | Équipe vN | — | student, replay | ⬜ | ⬜ | Version précise (historique) |
| `pool:leaderboard_top1` | #1 classement | expert | pool, elo_rating | ⬜ | ⬜ | Adversaire = meilleur bot actuel |
| `pool:leaderboard_top5` | Top 5 | hard | pool, elo_rating | ⬜ | ⬜ | Matchs auto à la soumission |
| `pool:secret_final` | Bot secret | secret | pool, final_only | ⬜ | ⬜ | Classement final (non listé UI) |

> **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.

#### Registre (`registry.py`)

```python
@dataclass(frozen=True)
class ResolveContext:
    """Paramètres pour adversaires dynamiques (student / pool)."""
    team_id: str | None = None
    submission_version: int | None = None
    leaderboard_snapshot_id: str | None = None

class OpponentRegistry:
    def get_spec(self, opponent_id: str) -> OpponentSpec: ...
    def list_specs(
        self,
        *,
        implemented_only: bool = False,
        enabled_only: bool = False,   # filtre V1_ENABLED_OPPONENTS
        tags: frozenset[str] | None = None,
    ) -> list[OpponentSpec]: ...
    def resolve_bot(self, opponent_id: str, ctx: ResolveContext | None = None) -> Bot: ...
```

- `resolve_bot("builtin:random")` → instance `RandomBot`
- `resolve_bot("student:abc123")` → lève `NotImplementedError` en v1 moteur ; implémenté dans `arena/server/`
- ID inconnu → `UnknownOpponentError`

#### Schedules (`schedules.py`)

Ensembles nommés d'`opponent_id` pour les workflows serveur — **définis maintenant**, exécutés progressivement.

```python
# Adversaires autorisés en v1 (sous-ensemble strict)
V1_ENABLED_OPPONENTS: frozenset[str] = frozenset({"builtin:random"})

# Jeux prévus (référence future — pas tous actifs en v1)
SMOKE_TEST_OPPONENTS = ("builtin:random",)
ON_SUBMIT_OPPONENTS = (
    "builtin:random",
    "builtin:medium",
    "builtin:minmax_d5",
    "pool:leaderboard_top5",
)
ELO_RATING_OPPONENTS = (
    "builtin:random",
    "builtin:medium",
    "builtin:minmax_d5",
)
FULL_LADDER_OPPONENTS = (
    "builtin:random",
    "builtin:greedy",
    "builtin:medium",
    "builtin:minmax_d3",
    "builtin:minmax_d5",
    "builtin:minmax_d6",
)
FINAL_RANKING_OPPONENTS = ("pool:secret_final", "builtin:minmax_d6")
```

Le serveur appelle `schedule_for_event("on_submit")` → liste d'IDs → un match par ID (quand implémenté).

#### Impact sur le replay et la base

Chaque match enregistre :

```json
{
  "challenger": { "kind": "student", "team_id": "…", "version": 2 },
  "opponent_id": "builtin:random",
  "opponent_spec": { "name": "Aléatoire", "tier": "calibration" }
}
```

Le classement stocke des stats **par paire** `(challenger_id, opponent_id)` en plus de l'ELO global.

---

### 5c. SDK ML (`arena/sdk/features.py`)

Helpers **optionnels** pour faciliter les pipelines ML (sans imposer sklearn/torch au moteur) :

```python
def encode_view(view: PlayerView, legal_actions: list[Action]) -> np.ndarray:
    """Vecteur de features fixes (dimension documentée, ex. 128)."""

def encode_action(action: Action) -> int:
    """Index stable d'une action parmi legal_actions du même tour."""

def action_from_index(legal_actions: list[Action], index: int) -> Action:
    """Inverse — avec clamp si index hors bornes."""
```

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.

**Génération de données d'entraînement** (hors sandbox) :

```python
# arena_sdk/simulation.py — usage local uniquement
def self_play_random(seed: int, n_games: int) -> list[TrainingSample]: ...
```

Les parties générées localement n'alimentent pas le classement ; seules les soumissions sur la plateforme comptent.

---



### 6. `runner/` — exécution compétition

```python
@dataclass(frozen=True)
class MatchConfig:
    seed: int
    target_score: int = 151
    decision_timeout_ms: int = 2000
    match_timeout_ms: int = 600_000
    max_steps_per_round: int = 500   # garde-fou anti-boucle

@dataclass(frozen=True)
class MatchResult:
    winner_id: str | None
    opponent_id: str               # ex. "builtin:random"
    reason: Literal["normal", "forfeit", "timeout", "max_steps"]
    forfeited_player_id: str | None
    final_scores: dict[str, int]
    rounds_played: int
    replay: ReplayLog
```

**Forfait** si :

- `decide()` dépasse `decision_timeout_ms`
- action retournée ∉ `legal_actions`
- exception non gérée dans `decide()`
- `max_steps_per_round` atteint

---



## Format replay (`recorder.py`)

JSON sérialisable, consommé par `arena/web/` :

```json
{
  "version": 1,
  "seed": 42,
  "config": { "target_score": 151 },
  "players": [
    { "id": "p0", "name": "Team Alpha", "role": "challenger" },
    { "id": "p1", "name": "Aléatoire", "role": "opponent", "opponent_id": "builtin:random" }
  ],
  "rounds": [
    {
      "round_number": 1,
      "winner_id": null,
      "steps": [
        {
          "index": 0,
          "player_id": "p0",
          "action": { "type": "PLAY_CARD", "card": "7H", "chosen_suit": null },
          "snapshot": {
            "step_index": 0,
            "current_player_id": "p0",
            "table": {
              "recent_cards": [],
              "table_empty": true,
              "required_suit": null,
              "must_choose_suit": false,
              "ace_effect_active": false,
              "bank_size": 18,
              "played_pile_size": 0,
              "last_play_count": 0
            },
            "hands_size": { "p0": 7, "p1": 7 },
            "scores": { "p0": 0, "p1": 0 },
            "opponent_id": "builtin:random"
          }
        }
      ]
    }
  ],
  "result": {
    "winner_id": null,
    "final_scores": { "p0": 0, "p1": 0 },
    "reason": "normal"
  }
}
```

Chaque `snapshot` contient uniquement des **infos publiques** (+ tailles de mains, pas les cartes adverses) pour le viewer web.

---



## Flux d'une décision

```mermaid
sequenceDiagram
    participant R as match_runner
    participant M as match/round
    participant V as PlayerView
    participant B as Bot
    participant L as legal_actions
    participant A as reducer

    R->>M: état manche courante
    M->>V: projection joueur courant
    M->>L: legal_actions(round)
    R->>B: decide(view, legal, timeout)
    B-->>R: action
    R->>L: action in legal?
    alt illégale ou timeout
        R->>R: forfait
    else ok
        R->>A: apply_action(round, action)
        A-->>M: nouvel état
        R->>R: recorder.record()
    end
```



---



## Alignement avec le code Dart


| Python                 | Dart / TS                                   | Rôle                  |
| ---------------------- | ------------------------------------------- | --------------------- |
| `Card`, `can_play_on`  | `lib/models/card.dart`                      | Cartes et jouabilité  |
| `Action`, `ActionType` | `player_action.dart`, `Action.ts`           | Actions joueur        |
| `legal_actions`        | `Player.getAllSingleCardActions` + multi    | Énumération           |
| `reducer.apply_action` | `GameManager.processPlayerTurn`             | Transition d'état     |
| `scoring`              | `_actuallyEndRound`, `trackConsecutiveWins` | Scores et exclusion   |
| `deck`                 | `lib/models/deck.dart`                      | Distribution / banque |
| `MatchConfig.seed`     | `Random(seed)` dans `startNewRound`         | Reproductibilité      |
| `opponents/catalog`    | —                                           | IDs adversaires stables |
| `opponents/registry`   | —                                           | Résolution `opponent_id` → `Bot` |
| `view/player_view.py` | — | `PlayerView`, sous-vues, `build_player_view()` |

| `opponents/schedules`  | —                                           | Listes par événement (smoke, elo…) |

Tests de non-régression : porter les cas de `test/game_rules_test.dart` et `test/game_manager_test.dart` en pytest.

---



## Dépendances Python

### Moteur `i151_engine`

| Package | Usage |
|---------|--------|
| stdlib (`dataclasses`, `enum`, `typing`) | moteur uniquement |
| `pytest` | tests |

Pas de ML dans le moteur — dépendances lourdes isolées dans le **sandbox étudiant**.

### Whitelist sandbox — soumissions étudiantes (`requirements.txt`)

| Package | Usage ML |
|---------|----------|
| `numpy` | features, tenseurs |
| `scikit-learn` | modèles classiques, `joblib` |
| `joblib` | sérialisation modèles sklearn |
| `onnxruntime` | inférence ONNX (CPU) |
| `torch` | inférence PyTorch **CPU only** (`torch.cuda` interdit) |
| `pandas` | optionnel — préprocessing léger en `setup` |

Tout autre package → **rejet à la soumission**. Pas de `requests`, `httpx`, `tensorflow` (v1), pas de compilation JIT arbitraire.

Pas de dépendance réseau, pas de Flask/FastAPI dans le moteur — le serveur `arena/server/` appellera `match_runner` comme librairie.

---



## Ordre d'implémentation

1. `models/card.py` + `models/action.py` + tests
2. `core/deck.py` + `core/rules.py` + tests
3. `game/round_state.py` + `core/reducer.py` + `core/legal_actions.py` + tests
4. `core/scoring.py` + `game/match_state.py` + `game/match.py` + tests
5. `view/player_view.py` + `view/builder.py` + tests (visibilité partielle, `opponent_id`)
6. `opponents/types.py` + `catalog.py` + `registry.py` + `schedules.py` (catalogue complet, v1 = `builtin:random` seul actif)
7. `bots/protocol.py` + `random_bot.py` (premier bot du catalogue)
8. `game/recorder.py`
9. `runner/match_runner.py` — `setup` → `decide` → `teardown`, support ZIP ML
10. `arena/sdk/features.py` — encodage `PlayerView` pour pipelines ML
11. Autres bots du catalogue (`greedy`, `medium`, `minmax_d*`)

---



## Journal


| Date       | Note                          |
| ---------- | ----------------------------- |
| 2026-07-03 | Architecture initiale définie |
| 2026-07-03 | Soumissions ML : `setup`/`decide`, whitelist pip, assets modèle, inférence seule en arène |


