Skip to main content

Headless game engine for the i151 student AI competition

Project description

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

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

@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

@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

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

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

@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

@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

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) :

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(...)
  1. match = apply_round_result(match, winner)
  2. 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

@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).


@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

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_idOpponentSpec 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)

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) :

{
  "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

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/)

# 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 :

# 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 decideteardown).

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

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)

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

# 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 :

{
  "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) :

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) :

# 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

@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/ :

{
  "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

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_idBot
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.pysetupdecideteardown, 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

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

i151_engine-0.1.0.tar.gz (55.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

i151_engine-0.1.0-py3-none-any.whl (36.7 kB view details)

Uploaded Python 3

File details

Details for the file i151_engine-0.1.0.tar.gz.

File metadata

  • Download URL: i151_engine-0.1.0.tar.gz
  • Upload date:
  • Size: 55.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.2

File hashes

Hashes for i151_engine-0.1.0.tar.gz
Algorithm Hash digest
SHA256 6b1b356cfc08d2df010137762aef068bd3658c411d0b51a7ddc00bfe36b7f11f
MD5 6bbee4fe36ba7251d8e551df186ef3bf
BLAKE2b-256 303c4d15f9f6c37ca55cbb47fefbab50b44f02e21ec31163fe53fd60b2b2ae73

See more details on using hashes here.

File details

Details for the file i151_engine-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: i151_engine-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 36.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.13.2

File hashes

Hashes for i151_engine-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 dbcad9479cf1463700b539440b76eb56c597f60fed36bb7d1f712f4347e9cb7a
MD5 f3e2c959fc7221bae0fcdc77394c369f
BLAKE2b-256 cc47e3bd8862818a1c52b66b628b79b8306419b443be6301745b6d9d2f3dfb11

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page