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éterministedeal(players: int, cards_per_player: int = 7)→ mains + banquedraw(n)depuis la tête de banquerefill_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) -> boolis_valid_single_play(player, card, round_state) -> boolis_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_suitaprè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 restantesapply_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 :
start_round(match)→RoundState- Tant que manche en cours :
- construire
PlayerViewpour 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(...)
match = apply_round_result(match, winner)- 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 dedecide() opponent_specs: une entrée par autre joueur (player_id→OpponentSpecou spec étudiant)opponentsexclut toujoursyou; ordre =seat_indexcroissanttable.recent_cards=played_pile[-3:](max 3 cartes, sommet en dernier)turn.is_your_turnest toujoursTruequanddecide()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 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
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:randomestimplementedet 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")→ instanceRandomBotresolve_bot("student:abc123")→ lèveNotImplementedErroren v1 moteur ; implémenté dansarena/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épassedecision_timeout_ms- action retournée ∉
legal_actions - exception non gérée dans
decide() max_steps_per_roundatteint
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_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
models/card.py+models/action.py+ testscore/deck.py+core/rules.py+ testsgame/round_state.py+core/reducer.py+core/legal_actions.py+ testscore/scoring.py+game/match_state.py+game/match.py+ testsview/player_view.py+view/builder.py+ tests (visibilité partielle,opponent_id)opponents/types.py+catalog.py+registry.py+schedules.py(catalogue complet, v1 =builtin:randomseul actif)bots/protocol.py+random_bot.py(premier bot du catalogue)game/recorder.pyrunner/match_runner.py—setup→decide→teardown, support ZIP MLarena/sdk/features.py— encodagePlayerViewpour pipelines ML- 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6b1b356cfc08d2df010137762aef068bd3658c411d0b51a7ddc00bfe36b7f11f
|
|
| MD5 |
6bbee4fe36ba7251d8e551df186ef3bf
|
|
| BLAKE2b-256 |
303c4d15f9f6c37ca55cbb47fefbab50b44f02e21ec31163fe53fd60b2b2ae73
|
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
dbcad9479cf1463700b539440b76eb56c597f60fed36bb7d1f712f4347e9cb7a
|
|
| MD5 |
f3e2c959fc7221bae0fcdc77394c369f
|
|
| BLAKE2b-256 |
cc47e3bd8862818a1c52b66b628b79b8306419b443be6301745b6d9d2f3dfb11
|