Aller au contenu

Réseau & file de commandes

La couche network/ est le transport du client : elle ouvre la socket, déroule le handshake, découpe le flux TCP en lignes et fait respecter la règle des 10 commandes en vol. Elle ne connaît rien à la sémantique du jeu — elle manipule des chaînes brutes et délègue le parsing à la couche protocole.

Quatre composants collaborent :

Composant Fichier Rôle
Connection ai/src/network/connection.py Socket TCP non bloquante + handshake initial
Client ai/src/network/client.py Orchestration envoi/réception
CommandQueue ai/src/network/command_queue.py File FIFO des commandes en vol (plafond de 10)
InputBuffer / OutputBuffer ai/src/network/buffers.py Découpage en lignes du flux TCP

Connection — socket & handshake

Connexion

connect() (ai/src/network/connection.py:32) crée une socket AF_INET / SOCK_STREAM, se connecte en mode bloquant, puis bascule en non bloquant :

self._sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
    self.sock.connect((self._host, self._port))
except ConnectionRefusedError as e:
    ...
    raise Exception(f"Failed to connect to {self._host}:{self._port}") from e
self._sock.setblocking(False)

Connecter en bloquant puis passer non bloquant évite de gérer EINPROGRESS. Une connexion refusée est convertie en Exception, ce qui remonte jusqu'à run() et fait quitter avec le code 84.

Handshake

do_handshake() (ai/src/network/connection.py:65) suit la séquence du protocole et renvoie (client_num, (width, height)) :

sequenceDiagram
    participant S as Serveur
    participant C as Connection

    S->>C: WELCOME
    C->>S: <team_name>\n
    S->>C: <CLIENT-NUM>\n
    S->>C: <X> <Y>\n
    Note over C: renvoie (client_num, (width, height))
  1. Boucle de select.select([sock], [], [], 0.1) jusqu'à recevoir un paquet ; si la première ligne décodée vaut WELCOME, on sort de la boucle.
  2. Envoi de team_name + "\n".
  3. Lecture d'un paquet, supposé contenir le CLIENT-NUM et le X Y : splitlines()[0] doit être un entier (nombre de slots libres pour l'équipe), splitlines()[1] doit contenir une espace (width height).

Handshake fragile

Le handshake suppose que CLIENT-NUM et X Y arrivent dans le même paquet TCP. S'ils sont fragmentés, splitlines()[1] lève un IndexError. Voir Limitations connues.

Client — orchestration

Client (ai/src/network/client.py) détient une CommandQueue, un InputBuffer, un OutputBuffer et la Connection. Il expose deux opérations clés.

Envoi : send_command

send_command(command, callback) (ai/src/network/client.py:55) :

if not self.command_queue.can_send():
    ...                                       # plafond de 10 atteint -> ignore
    return
self.output_buffer.push(command + "\n")       # ajoute le \n final
self.command_queue.enqueue(command, callback) # enregistre (command, callback)
...
self.output_buffer.flush_to(self.connection.sock)  # envoi synchrone immédiat

Trois étapes : (1) vérifier le plafond ; (2) pousser command + "\n" dans l'OutputBuffer (c'est ici, et non dans les constructeurs de commandes, qu'on ajoute le \n) et enregistrer le callback dans la CommandQueue ; (3) vider le tampon immédiatement vers la socket. L'envoi est donc synchrone : pas de phase d'écriture différée dans la boucle principale.

flush_output n'est pas utilisé

Client.flush_output (ai/src/network/client.py:126) est implémenté mais jamais appelé depuis la boucle principale : l'écriture passe toujours par le flush_to synchrone de send_command.

Réception : receive

receive() (ai/src/network/client.py:84) interroge la socket par select.select([sock], [], [], 0.1) (poll de 100 ms). Un recv vide signale une fermeture serveur (on annule connection). Sinon, les octets passent à receive_data :

self.input_buffer.feed(data)
while True:
    line = self.input_buffer.pop_line()
    if line is None:
        break
    ...
    self.command_queue.on_response(line)      # lignes BRUTES, non parsées ici

Le Client transmet des lignes brutes à CommandQueue.on_response ; le parsing en messages typés est fait plus tard, dans les callbacks (cf. Protocole).

Buffers — du flux aux lignes

Le protocole Zappy est orienté ligne (\n), mais TCP ne préserve pas les frontières de message. Les buffers (ai/src/network/buffers.py) réconcilient les deux.

  • InputBuffer.feed accumule les octets ; InputBuffer.pop_line extrait la première ligne complète (split(b"\n", 1), décodée UTF-8) ou renvoie None si aucune ligne n'est encore complète.
  • OutputBuffer.push encode et empile une chaîne ; flush_to envoie autant d'octets que possible et conserve le reliquat en cas d'envoi partiel (self._buffer = self._buffer[sent:]), en absorbant les BlockingIOError.

CommandQueue — les 10 commandes en vol

Le serveur Zappy accepte au plus 10 requêtes sans réponse par client (au-delà elles sont ignorées) et répond toujours dans l'ordre de réception. La CommandQueue (ai/src/network/command_queue.py) exploite cette garantie FIFO.

  • Plafond : can_send() renvoie pending() < MAX_PENDING_COMMANDS (MAX_PENDING_COMMANDS = 10, ai/src/config.py:29). C'est ce que teste la boucle principale avant de demander une commande à la FSM.
  • Appariement FIFO : enqueue(command, callback) empile dans _inflight ; on_response dépile la tête de file et invoque le callback associé avec la ligne brute.
  • Désambiguïsation Look / Inventory : après chaque réponse, la commande est mémorisée dans _last_command. peek_last_command() permet au parser de savoir si un [...] correspond à un Look ou à un Inventory.
  • Suivis non sollicités : si _inflight est vide mais que _last_command existe, on_response ré-invoque le dernier callback (ai/src/network/command_queue.py:128-134). C'est ainsi qu'est traité le Current level: k qui suit une incantation réussie.
  • Mort : si la ligne reçue vaut exactement "dead", on_response appelle sys.exit(0) (ai/src/network/command_queue.py:116-120).

Liste _callbacks morte

add_callback empile dans _callbacks, mais cette liste n'est jamais relue dans on_response : seul le mécanisme _last_command ci-dessus est effectif. Voir Limitations connues.

Séquence envoi puis réception

sequenceDiagram
    participant FSM
    participant Cl as Client
    participant OB as OutputBuffer
    participant CQ as CommandQueue
    participant S as Serveur
    participant IB as InputBuffer

    Note over FSM,S: Envoi
    FSM->>Cl: send_command("Look", cb)
    Cl->>CQ: can_send() ? (pending < 10)
    Cl->>OB: push("Look\n")
    Cl->>CQ: enqueue("Look", cb)
    Cl->>OB: flush_to(sock)
    OB->>S: Look\n

    Note over FSM,S: Réception
    S-->>Cl: "[ player, food, ... ]"
    Cl->>IB: feed(data)
    IB-->>Cl: pop_line() -> "[ ... ]"
    Cl->>CQ: on_response("[ ... ]")
    CQ->>CQ: pop _inflight (FIFO) -> ("Look", cb)
    CQ->>cb: cb("[ ... ]")

Pour aller plus loin