# Encoder les règles d'un baby-foot dans un AST > Comment on a conçu un moteur de règles configurable pour le baby-foot Tekbak — un AST évalué en Ruby qui simule chaque coup possible pour décider si la balle doit être verrouillée. Date : 15/06/2014 Auteur : Aurélien N. Tags : Ruby, Architecture, Embarqué, IoT --- Dans les deux articles précédents, on a expliqué comment on a écrit une [extension C pour parler I2C](/blog/extension-c-ruby-embarque-i2c) depuis Ruby, puis comment on a [recodé un pipeline de rendu typographique](/blog/affichage-texte-led-custom-ruby-freetype) pour afficher du texte sur des écrans LED de 7 pixels de haut. Mais il manque la pièce centrale : **comment le baby-foot décide qu'un but est valide, qu'une partie est gagnée, ou que la balle doit être verrouillée**. Le client — Tekbak / Foosball Society — ne voulait pas un seul jeu de règles codé en dur. Chaque table est déployée dans un contexte différent (bar, événement d'entreprise, tournoi), et les règles changent : score à atteindre, gestion du tie-break, timeout, valeur d'un goal vs un inout. Il fallait un **moteur de règles configurable**, envoyé par le serveur au démarrage de chaque partie. ## Le problème : des règles qui changent à chaque partie Prenons un exemple concret. En mode "bar", une partie standard : - Le premier à **5 points** avec **2 points d'écart** gagne - Si personne n'y arrive, à **10 points** on passe en **tie-break** - En tie-break, **13 points** = victoire automatique - Un timeout à 15 minutes déclenche un départage au score En mode "tournoi", c'est différent : pas de timeout, tie-break à 7, goal de l'extérieur qui vaut double. Et le client voulait pouvoir inventer de *nouvelles* variantes sans toucher au code embarqué. On avait besoin d'un format de règles : 1. **Déclaratif** — pas de code Ruby dans la config 2. **Composable** — des conditions qu'on peut imbriquer 3. **Sérialisable** — envoyé en JSON via RabbitMQ depuis le serveur 4. **Évaluable** — interprété par le referee embarqué sur le BeagleBone ## L'inspiration MongoDB La syntaxe de requêtes MongoDB — des documents JSON avec des opérateurs `$gt`, `$and`, `$or` — est devenue un idiome familier pour exprimer des conditions composables dans un format de données. On s'en est directement inspiré. Une règle de victoire "5 points avec 2 d'écart" s'exprime : ```ruby win_rule: { :$and => [ , ] } ``` C'est un **arbre syntaxique** (AST) encodé dans une structure Ruby native (Hash + Array + Symbol). Chaque nœud est soit un **opérateur** (`$and`, `$geq`, `$or`…), soit une **variable** (`$score`, `$other_score`…), soit une **valeur littérale** (5, 2, true…). ## Les opérateurs Le moteur comprend 7 opérateurs, divisés en deux familles : **Comparaisons** — prennent deux arguments et retournent un booléen : | Opérateur | Sémantique | |-----------|------------| | `$eq` | Tous les arguments sont égaux | | `$gt` | Premier argument strictement supérieur | | `$geq` | Premier argument supérieur ou égal | | `$lt` | Premier argument strictement inférieur | | `$leq` | Premier argument inférieur ou égal | **Logiques** — combinent des sous-expressions : | Opérateur | Sémantique | |-----------|------------| | `$and` | Toutes les sous-expressions sont vraies | | `$or` | Au moins une sous-expression est vraie | Et 4 **variables** injectées au moment de l'évaluation, en fonction du côté évalué (hinge ou lock) : | Variable | Valeur | |----------|--------| | `$score` | Score de l'équipe évaluée | | `$other_score` | Score de l'adversaire | | `$score_delta` | Écart absolu entre les deux scores | | `$max_score` | Score le plus élevé des deux | ## L'évaluateur récursif L'évaluateur fait 40 lignes. C'est une descente récursive classique sur la structure de données : ```ruby def run_rule(side, scores, rule) return false if rule.nil? if rule.kind_of?(Hash) # nœud opérateur : operator, args = rule.first values = args.collect case operator.to_sym when :$eq then values.all? when :$gt then values[0] > values[1] when :$geq then values[0] >= values[1] when :$lt then values[0] < values[1] when :$leq then values[0] <= values[1] when :$and then values.all? when :$or then values.any? end elsif rule.to_s.start_with?('$') # feuille variable : résolution contextuelle other = other_side(side) case rule.to_sym when :$score then scores[side] when :$other_score then scores[other] when :$score_delta then (scores[side] - scores[other]).abs when :$max_score then [scores[side], scores[other]].max end else # feuille littérale : 5, 2, true... rule end end ``` Le point clé : chaque opérateur évalue **récursivement** ses arguments avant d'appliquer sa logique. `$and` appelle `run_rule` sur chaque sous-expression, qui peut elle-même contenir un `$or` imbriqué, etc. C'est un interpréteur d'arbre au sens classique du terme. Et comme le `side` est passé en paramètre, la **même règle** peut être évaluée pour les deux côtés : `run_rule(:hinge, scores, win_rule)` et `run_rule(:lock, scores, win_rule)` donnent des résultats différents parce que `$score` et `$other_score` sont inversés. ## La configuration complète d'une partie Le serveur envoie un JSON au démarrage de chaque partie via RabbitMQ. La structure complète : ```ruby rules: { timeout_s: 900, rehearsal_balls: 0, initial_score: , goal_strength: { default: { hinge: , lock: }, tie_break: { hinge: , lock: } }, conditions: { default: { win_rule: { :$and => [ , ] } }, tie_break: { start_rule: , win_rule: { :$or => [ , { :$and => [ , ]} ] } }, timeout: { win_rule: } } } ``` Trois phases de jeu, chacune avec ses propres règles : - **default** — le jeu normal. Ici : 5 points avec 2 d'écart. - **tie_break** — déclenché quand `start_rule` est vrai (max score ≥ 5). Règle de victoire plus souple : 7 points direct, ou 2 d'écart. - **timeout** — si la partie dépasse 15 minutes, celui qui mène gagne. Le `goal_strength` définit combien de points chaque type d'action rapporte (ou enlève). `[1, 0]` signifie +1 pour le buteur, 0 pour l'adversaire. On peut imaginer `[2, -1]` pour un mode agressif où un goal "de l'extérieur" (côté lock) vaut double et retire un point à l'adversaire. ## La simulation : prédire le prochain coup C'est la partie la plus intéressante du moteur. À chaque but, le referee doit décider si l'IBR (le mécanisme de remise en jeu) doit être verrouillé ou libéré de chaque côté. La règle : **si le prochain coup de ce côté peut déclencher une fin de partie, on verrouille**. Pour ça, on simule *tous les scénarios possibles* du prochain coup : ```ruby def potential_winners_with_next_goal winners = Set.new %i.product(%i) do |side, goal_type| # copier le score actuel scores = @score.dup # simuler le coup run_score_update(scores, side, goal_type) # évaluer les règles de victoire sur le score simulé winner = compute_winner(scores, side, current_rules) if winner winners.add(winner) end end winners.to_a end ``` On itère sur le **produit cartésien** de ` × ` — 4 scénarios au total. Pour chacun, on copie le score, on applique le `goal_strength`, puis on évalue les règles de victoire et de défaite. Si un scénario produit un gagnant, ce gagnant entre dans l'ensemble des "winners potentiels". Ensuite, la décision IBR est triviale : ```ruby def update_ibr_states potential_winners = potential_winners_with_next_goal %i.each do |side| if potential_winners.include?(side) lock_ibr(side) # → écriture I2C 0x51, 0x00 else release_ibr(side) # → écriture I2C 0x51, 0x01 end end end ``` Si un côté *pourrait* gagner au prochain coup — que ce soit par goal ou par inout — on verrouille sa balle. Si le côté ne peut pas gagner, on libère. C'est de la **théorie des jeux** appliquée à un solénoïde. ## Le cas subtil du tie-break La simulation tient aussi compte des transitions de phase. Si le score actuel est 4-4 et qu'un goal hinge donnerait 5-4, le moteur vérifie : 1. Est-ce que `win_rule` default (5 pts, 2 d'écart) est remplie ? Non, écart = 1. 2. Est-ce que `start_rule` tie-break (`$max_score >= 5`) est remplie ? Oui. 3. On bascule en tie-break. Les règles changent. Le `current_rules` bascule dynamiquement : ```ruby def current_rules if @game_timed_out @timeout_rules elsif @is_tie_break @tie_break_rules else @default_rules end end ``` Et le `goal_strength` aussi — en tie-break, les inouts ne rapportent plus de points au buteur (seulement une pénalité à l'adversaire). Ça change complètement la dynamique du jeu. ## Pourquoi un AST et pas du code ? On aurait pu écrire les règles en Ruby et les `eval`. On aurait pu utiliser des lambdas sérialisées. On a choisi un AST pour trois raisons : 1. **Sécurité.** Le BeagleBone est déployé dans des bars. On ne veut pas exécuter du code arbitraire envoyé par le réseau. L'AST ne peut exprimer que des comparaisons et des combinaisons logiques — pas d'effets de bord, pas de boucles infinies, pas d'accès fichier. 2. **Sérialisation.** La structure Hash/Array/Symbol de Ruby se convertit en JSON sans effort. Le serveur Rails (le "Core") construit les règles en Ruby, les sérialise en JSON, les envoie via RabbitMQ, et le referee les désérialise nativement. 3. **Inspectabilité.** On peut loguer la règle en clair, la visualiser, l'afficher dans une interface d'admin. C'est un *document*, pas du code opaque. Le trade-off, c'est que certaines règles complexes sont verbeuses. Un simple "le premier à 5 avec 2 d'écart" demande un `$and` imbriqué. Mais en pratique, les règles les plus complexes qu'on ait vues tiennent en 15 lignes de JSON — largement acceptable. ## Le bilan Le moteur de règles est la partie du code qu'on a le moins touchée après la mise en production. Le format AST a permis au client de modifier les règles *sans déploiement sur les tables* — un simple changement côté serveur, et la prochaine partie utilise les nouvelles règles. La simulation `potential_winners_with_next_goal` est appelée après chaque but. Sur un BeagleBone, elle prend moins d'une milliseconde (4 évaluations d'arbre sur des scores entiers). C'est négligeable comparé aux 70ms du timer de scrolling ou aux 100ms du polling I2C. Au final, tout le stack — extension C, rendu typographique, moteur de règles — vit dans le même processus Ruby sur un BeagleBone à 1 GHz. Ce n'est pas le hardware le plus puissant du monde, mais avec une architecture simple et des abstractions bien choisies, ça suffit largement pour arbitrer un baby-foot en temps réel.