Aller au contenu

Stratégie (FSM)

Le cerveau de l'IA est une machine à états finis (FSM) : à chaque tour de boucle, l'état courant produit une (ou plusieurs) commande(s), et la FSM décide s'il faut basculer vers un autre état. La survie prime toujours sur tout le reste.

Cette page décrit le contrôleur (ai/src/strategy/fsm.py), la convention de commande multi-étapes « pointée », les mises à jour locales optimistes, puis le comportement réel de chaque état. Elle couvre enfin les deux modules de support : incantation.py (prérequis) et pathfinding.py (géométrie torique).

Le contrôleur FSM

À la construction, la FSM instancie immédiatement les 7 états et leur donne une référence retour vers elle-même (ai/src/strategy/fsm.py:47) :

self._states: dict[str, State] = {
    "survive": SurviveState(drone, client),
    "rally": RallyState(drone, client),
    "collect": CollectState(drone, client),
    "explore": ExploreState(drone, client),
    "fork": ForkState(drone, client),
    "incant_prep": IncantPrepState(drone, client),
    "incant": IncantState(drone, client),
}

L'état initial est survive : main.run() appelle fsm.transition_to("survive") juste après la création du drone (ai/src/main.py:125).

transition_to et le cas spécial explore

transition_to(name) (ai/src/strategy/fsm.py:62) valide le nom, journalise la transition, puis appelle on_enter() du nouvel état. Il traite explore de façon particulière : avant d'y entrer, il déduit quelle ressource chercher et vers quel état revenir via _resource_for_return() (ai/src/strategy/fsm.py:84), puis configure l'instance ExploreState par set_target(resource, return_state) :

  • état courant survive → chercher "food", revenir à survive ;
  • état courant collect → chercher la pierre cible, revenir à collect ;
  • sinon → ("food", "survive") par défaut.

C'est nécessaire parce que ExploreState est un état paramétrable réutilisé par plusieurs demandeurs : il ne sait pas seul ce qu'il cherche.

decide_next_command : le moteur central

decide_next_command() (ai/src/strategy/fsm.py:101) est appelé en boucle tant que la file de commandes peut accepter des envois. Son déroulé :

  1. demander à l'état courant should_transition() ; si un nom est renvoyé, transiter immédiatement (ai/src/strategy/fsm.py:105-108) ;
  2. demander la prochaine commande via next_command(). Si elle est vide, retourner False (rien à envoyer ce tour-ci) ;
  3. découper la commande sur le point . et envoyer chaque sous-commande.
flowchart TD
    A[decide_next_command] --> B{should_transition ?}
    B -- oui --> C[transition_to]
    B -- non --> D[next_command]
    C --> D
    D --> E{commande vide ?}
    E -- oui --> F[return False]
    E -- non --> G["split sur '.'"]
    G --> H[Pour chaque sous-commande :<br/>MAJ optimiste + send_command]
    H --> I[return True]

La convention de commande « pointée »

Un état ne renvoie jamais qu'une seule action atomique : il peut renvoyer une séquence pointée "cmd1.cmd2.cmd3". Par exemple, pour partir vers l'Est quand on regarde au Nord, RallyState renvoie Right.Forward (ai/src/strategy/states/rally.py:184). La FSM fait command.split(".") (ai/src/strategy/fsm.py:115) et envoie chaque morceau comme une commande serveur distincte.

Pourquoi enchaîner plusieurs commandes ?

Le serveur tolère jusqu'à 10 requêtes en vol (voir Réseau & file de commandes). Émettre Right.Forward d'un seul coup remplit le pipeline et évite un aller-retour de boucle par micro-action, ce qui accélère la réactivité du drone.

Les mises à jour locales optimistes

Avant que le serveur ne confirme quoi que ce soit, la FSM anticipe l'effet de chaque sous-commande sur l'état local du drone (ai/src/strategy/fsm.py:121-167). C'est le match subcommand :

Sous-commande Effet optimiste appliqué tout de suite
Right rotation horaire de drone.direction dans ORIENTATIONS = ["N","E","S","W"]
Left rotation antihoraire de drone.direction
Forward avance drone.pos sur le tore (E → x+1, W → x−1, S → y+1, N → y−1), avec modulo largeur/hauteur
Set <r> décrémente inventory[r] (borné à 0) et dépose la ressource sur la carte mentale à la position courante

Pourquoi anticiper ?

Le drone doit décider la commande suivante (par ex. un Look) avec la bonne position/direction, sans attendre l'aller-retour réseau. L'état local sert de modèle prédictif ; la réponse serveur reste la source de vérité et corrige toute divergence. C'est cohérent avec le principe « le serveur est autoritaire » (voir Vue d'ensemble).

Le callback par commande : ré-injection de pos/direction

Pour chaque sous-commande, la FSM enregistre un callback capturant un instantané de la position et de la direction au moment de l'envoi (ai/src/strategy/fsm.py:169-182) :

def make_callback(pos, direction):
    def callback(line):
        msg = parse_server_line(line, self._client)
        if isinstance(msg, LookResult):
            msg = LookResult(tiles=msg.tiles, pos=pos, direction=direction)
        if self._state:
            self._state.on_response(msg)
    return callback

Quand la réponse arrive, le callback re-parse la ligne brute puis, s'il s'agit d'un LookResult, y réinjecte la pos/direction capturées au moment de l'émission du Look. C'est indispensable : entre l'envoi du Look et sa réponse, plusieurs Forward/Right optimistes peuvent avoir déplacé drone.pos. Sans cet instantané, la vision serait intégrée à la carte au mauvais endroit.

Cas Incantation et rafraîchissement périodique

  • Après une Incantation, la FSM ajoute un callback supplémentaire via add_callback (ai/src/strategy/fsm.py:184-190) pour traiter le message non sollicité de suivi (Current level: k).
  • Toutes les INVENTORY_UPDATE_TU (= 10, ai/src/config.py:98) sous-commandes, un Inventory est injecté automatiquement (ai/src/strategy/fsm.py:192-201) pour resynchroniser le stock de food et les pierres avant qu'une ressource ne réapparaisse.

Les états, un par un

survive — manger avant tout

SurviveState (ai/src/strategy/states/survive.py) est l'état par défaut et la priorité absolue. Il cherche la food connue la plus proche (carte mentale + vision via nearest_food), Take food quand il est dessus, et délègue le déplacement à rally en lui fixant une destination (ai/src/strategy/states/survive.py:154-159). Si aucune food n'est connue, il bascule en explore. La transition de sortie (should_transition, ai/src/strategy/states/survive.py:138) vise collect une fois nourri.

Probable bug d'unité dans le seuil de food

should_transition compare drone.food (un compte d'unités) à FOOD_SURVIVAL_THRESHOLD * LIFE_PER_FOOD (= 5 × 126 = 630, des unités de temps). Le drone n'ayant jamais 630 unités de food, la sortie de survive n'est en pratique jamais atteinte. Le même mélange compte/temps se retrouve dans collect, incant_prep et incant. Voir Limitations connues.

rally — le moteur de déplacement réutilisable

RallyState (ai/src/strategy/states/rally.py) est la brique de navigation générique. Un état demandeur l'arme par set_destination(target, return_state, on_arrived, take_resource) puis demande la transition rally. À chaque tour, il calcule le déplacement signé optimal avec toric_signed_delta (ai/src/strategy/states/rally.py:137), puis _navigate(dx, dy) (ai/src/strategy/states/rally.py:170) traduit ce delta + la direction courante en une séquence pointée de Left/Right/Forward. Arrivé sur la cible, il émet Take + Inventory (si take_resource est fourni), invoque on_arrived, puis transite vers return_state.

Priorité aux axes

_navigate traite d'abord l'axe X (Est/Ouest), puis l'axe Y (Nord/Sud) : le drone s'aligne horizontalement avant de monter/descendre. Simple et déterministe, mais non optimal en nombre de virages.

collect — viser une pierre manquante

CollectState (ai/src/strategy/states/collect.py) calcule les pierres manquantes pour la prochaine incantation (missing_stones), choisit une ressource cible, cherche sa position connue la plus proche et délègue à rally. Si la pierre est inconnue → explore ; s'il ne manque plus rien → incant_prep ; si la food chute → survive.

Logs de débogage résiduels

collect.py contient des print() oubliés (ai/src/strategy/states/collect.py:50 et :95), de même que incant_prep.py:77. Voir Limitations connues.

explore — spirale carrée cartographiante

ExploreState (ai/src/strategy/states/explore.py) parcourt le monde en spirale carrée : des tronçons de Forward, un Right en fin de tronçon, et la longueur du tronçon augmente tous les 2 virages (ai/src/strategy/states/explore.py:122-130). Un Look est intercalé tous les _LOOK_EVERY = 3 pas (ai/src/strategy/states/explore.py:22), intégré à la carte mentale. Dès que la ressource cible devient connue, l'état retourne vers _return_state (should_transition, ai/src/strategy/states/explore.py:132).

incant_prep — préparer la tuile de rituel

IncantPrepState (ai/src/strategy/states/incant_prep.py) fixe à l'entrée les pierres requises (requirements_for), puis next_command dépose (Set) les pierres manquantes une à une tant qu'il en a en inventaire, sinon il émet un Look de vérification (ai/src/strategy/states/incant_prep.py:56-64). Il passe à incant dès que can_incant(...) est satisfait. Implémentation volontairement simple (pas de coordination multi-joueurs ici).

incant — déclencher l'incantation

IncantState (ai/src/strategy/states/incant.py) envoie Incantation une seule fois (drapeau incant_send, ai/src/strategy/states/incant.py:57-59), attend les messages serveur (Elevation underway puis Current level: k ou ko), met à jour drone.level au succès, puis transite : explore au succès, incant_prep à l'échec.

Pas de coordination multi-joueurs

incant envoie l'incantation sans élection de meneur ni synchronisation du nombre de joueurs présents sur la tuile. Voir Limitations connues.

fork — un stub complet (jamais atteint)

ForkState (ai/src/strategy/states/fork.py) est un stub intégral : ses quatre méthodes lèvent NotImplementedError (ai/src/strategy/states/fork.py:27,33,37,41). Il est enregistré dans la FSM mais aucune transition n'y mène ; y entrer ferait planter le drone. La reproduction (Fork / Connect_nbr) n'est donc pas implémentée.

Reproduction non implémentée

ForkState est inerte : aucun état n'appelle transition_to("fork"). Voir Limitations connues.

Diagramme d'états (transitions implémentées)

stateDiagram-v2
    [*] --> survive
    survive --> explore: aucune food connue
    survive --> rally: food repérée (s'y rendre)
    survive --> collect: nourri (seuil, cf. bug d'unité)

    collect --> rally: pierre repérée
    collect --> explore: pierre inconnue
    collect --> incant_prep: rien ne manque
    collect --> survive: food basse

    explore --> survive: food trouvée
    explore --> collect: pierre trouvée

    rally --> survive: arrivée (retour demandeur)
    rally --> collect: arrivée (retour demandeur)

    incant_prep --> incant: conditions réunies
    incant_prep --> survive: food basse

    incant --> explore: succès
    incant --> incant_prep: échec
    incant --> survive: food basse

    fork: fork (STUB inatteignable)

rally est un état de transit

rally n'est jamais une destination finale : il revient toujours vers le return_state que lui a passé le demandeur (survive ou collect).

Module incantation.py — prérequis

ai/src/strategy/incantation.py ne contient que des fonctions pures sur la table INCANTATION_REQUIREMENTS (ai/src/config.py:73) :

  • requirements_for(level)(nb_joueurs, {pierre: quantité}) (ai/src/strategy/incantation.py:15) ;
  • can_incant(tile_objects, players_on_tile, level) → vérifie joueurs et pierres au sol (ai/src/strategy/incantation.py:19) ;
  • missing_stones(inventory_on_tile, level) → pierres encore manquantes (ai/src/strategy/incantation.py:34).

Niveau 8 non couvert

La table s'arrête au niveau 7 ; requirements_for(8) lève KeyError. Aucun garde-fou n'empêche cet appel. Voir Limitations connues.

Module pathfinding.py — géométrie torique

ai/src/strategy/pathfinding.py, fonctions pures sur un monde torique :

  • toric_distance(p1, p2, w, h) (ai/src/strategy/pathfinding.py:14) renvoie un tuple (min(dx, w−dx), min(dy, h−dy)), pas un scalaire. Utilisé pour comparer des distances (nearest_food/nearest_resource), jamais pour décider d'un sens.
  • toric_signed_delta(p1, p2, w, h) (ai/src/strategy/pathfinding.py:31) est la vraie primitive de navigation : il renvoie le déplacement signé le plus court par axe (dx>0 = Est, dy>0 = Sud). C'est lui qu'utilise RallyState.

Code implémenté mais inutilisé

bfs_toric (BFS torique 4-voisins, ai/src/strategy/pathfinding.py:73) et direction_from_sound (ai/src/strategy/pathfinding.py:121) sont pleinement implémentés mais n'ont aucun appelant. La navigation réelle se fait par toric_signed_delta, et l'écoute des broadcasts (qui nourrirait direction_from_sound) n'est pas branchée. Voir Limitations connues.

Pour aller plus loin