# B1 — Motus : le jeu des mots, en pipelines

## Vous avez trouvé le niveau caché : bienvenue !

Si vous lisez ces lignes, c'est que vous avez tapé le bon mot au bon endroit.
`XYZZY` : ceux qui savent, savent — c'est le mot magique de *Colossal Cave
Adventure*, le tout premier jeu d'aventure textuel, sorti en 1976. Il ne fait
rien d'utile dans le jeu. Ici, il vous ouvre une porte.

**Ce sujet n'est pas un devoir.** Il permet de reprendre les collections,
le pipe et `Enum`, approfondis en AP4. Rien n'est ramassé d'office. Si je le
retiens comme bonus pour un cycle, je préciserai les exercices attendus,
le dossier de dépôt et l'échéance ; son absence ne retire aucun point.

Nous allons coder le **Motus** — le jeu de l'émission, l'ancêtre français du
*Wordle* que vous avez peut-être joué en 2022. La règle tient en trois lignes :
un mot secret de cinq lettres, vous proposez un mot, et le jeu vous répond
lettre par lettre. Bonne lettre à la bonne place, bonne lettre mais mal placée,
lettre absente. C'est tout.

Sauf que ce n'est pas tout. Il y a dans ce jeu un piège que la moitié des
implémentations qu'on trouve sur Internet ratent, et nous allons le regarder en
face à l'exercice 4. Je ne vous en dis pas plus tout de suite.

Durée indicative : **2 h**, davantage si vous vous prenez au jeu. Tout se fait
avec les outils approfondis en AP4 — listes, maps, tuples, pattern matching, le pipe
`|>` et le module `Enum`. Aucune récursion explicite n'est nécessaire : si vous
vous surprenez à écrire une fonction qui s'appelle elle-même, c'est sans doute
qu'un `Enum` fera mieux.

## Comment travailler ce notebook

Le format est celui de vos rendus, vous le connaissez :

* Chaque exercice contient une cellule de code avec des trous marqués
  `# À COMPLÉTER (n)`. Remplissez, puis **évaluez** (**Ctrl/Cmd + Entrée**).
* Sous chaque exercice, une cellule de **vérification** affiche `✅` si tout va
  bien, ou lève une erreur explicite sinon. **Ne les modifiez pas** : elles
  testent des **propriétés** — des invariants, des cas limites, des
  allers-retours — et pas seulement un résultat recopié.
* Les cellules s'évaluent **de haut en bas**. En cas de doute, menu du notebook
  → *Evaluate all*.
* **Aucun corrigé n'est caché ici.** La bonne question n'est jamais « quelle est
  la réponse ? » mais « comment je sais que mon jeu est juste ? ».

Chaque exercice définit son **propre module**, et les suivants ont le droit
d'appeler les précédents : à la fin, vous aurez un vrai petit jeu, assemblé de
vos mains, brique par brique.

Si vous êtes coincé, même en dehors des séances, écrivez-moi : je vous donne un
coup de pouce, pas la réponse.

## Exercice 1 — Découper un mot en lettres

Tout commence par là : pour comparer deux mots lettre à lettre, il faut d'abord
les **transformer en listes de lettres**. C'est le module `String` qui s'en
charge, et la fonction qui nous intéresse s'appelle `String.graphemes/1` — elle
découpe une chaîne en ses caractères « tels qu'on les voit ».

Au passage, nous voulons que le jeu soit tolérant sur la casse : proposer
`"table"` ou `"TABLE"` doit revenir au même. Nous allons donc **normaliser** en
majuscules avant de découper, avec `String.upcase/1`.

Écrivez `Mots.lettres/1` qui prend un mot et renvoie la liste de ses lettres,
**en majuscules**. Deux étapes, donc un pipeline à deux maillons.

```elixir
defmodule Mots do
  # À COMPLÉTER (1) : lettres("table") doit renvoyer ["T", "A", "B", "L", "E"].
  # Deux transformations à enchaîner avec |> : normaliser la casse, puis découper.
  def lettres(mot) do
    nil
  end
end

Mots.lettres("table")
```

Vérification :

```elixir
if Code.ensure_loaded?(Mots) do
  cond do
    Mots.lettres("table") != ["T", "A", "B", "L", "E"] ->
      raise """
      lettres("table") devrait valoir ["T", "A", "B", "L", "E"], \
      obtenu #{inspect(Mots.lettres("table"))}.
      Pensez à passer en majuscules AVANT (ou après) le découpage.
      """

    Mots.lettres("TABLE") != Mots.lettres("table") ->
      raise "lettres/1 doit être insensible à la casse : \"TABLE\" et \"table\" donnent la même liste"

    Mots.lettres("") != [] ->
      raise "lettres(\"\") devrait valoir [] (une chaîne vide n'a aucune lettre), obtenu #{inspect(Mots.lettres(""))}"

    length(Mots.lettres("PARIS")) != 5 ->
      raise "un mot de 5 lettres donne une liste de 5 éléments"

    true ->
      IO.puts("✅ Un mot devient une liste de lettres majuscules : #{inspect(Mots.lettres("motus"))}")
  end
else
  IO.puts("ℹ️  Définissez d'abord le module Mots, puis réévaluez cette vérification.")
end
```

## Exercice 2 — Comparer position par position

Voici la moitié facile du jeu. Deux mots de même longueur, et l'on veut savoir,
**pour chaque position**, si la lettre proposée est celle du secret.

La difficulté technique est de parcourir **deux** listes **en parallèle**.
`Enum.map/2` ne parcourt qu'une liste ; il nous faut d'abord les « agrafer »
deux à deux, ce que fait `Enum.zip/2` : à partir de `[1, 2]` et `[:a, :b]`, elle
produit `[{1, :a}, {2, :b}]`. Une liste de **paires**, que l'on peut ensuite
`map`per — et dans le `fn`, le pattern matching vous attend.

Écrivez `Places.places/2` qui renvoie une liste d'atomes de même longueur que
les mots : `:bien` quand la lettre proposée est exactement celle du secret à
cette position, `:autre` sinon. On ne cherche **pas encore** à distinguer
« mal placée » de « absente » — patience.

> 💡 Dans une fonction anonyme, vous avez droit à **plusieurs clauses**, chacune
> avec son motif — exactement comme dans un `case`. Et rappelez-vous qu'une même
> variable écrite **deux fois** dans un motif exige que les deux valeurs soient
> **égales** : `{x, x}` ne matche que les paires dont les deux membres sont
> identiques. C'est très exactement la question qu'on se pose ici.

```elixir
defmodule Places do
  # À COMPLÉTER (2) : places(secret, proposition) -> liste de :bien / :autre.
  # Agrafez les deux listes de lettres, puis transformez chaque paire en atome.
  def places(secret, proposition) do
    nil
  end
end

Places.places("PARIS", "RARES")
```

Vérification :

```elixir
if Code.ensure_loaded?(Places) do
  cond do
    Places.places("PARIS", "RARES") != [:autre, :bien, :bien, :autre, :bien] ->
      raise """
      places("PARIS", "RARES") devrait valoir [:autre, :bien, :bien, :autre, :bien], \
      obtenu #{inspect(Places.places("PARIS", "RARES"))}.
      Position par position : R/P non, A/A oui, R/R oui, E/I non, S/S oui.
      """

    Places.places("TABLE", "TABLE") != [:bien, :bien, :bien, :bien, :bien] ->
      raise "un mot comparé à lui-même ne donne que des :bien"

    Places.places("TABLE", "SUCRE") != [:autre, :autre, :autre, :autre, :bien] ->
      raise "places(\"TABLE\", \"SUCRE\") : seul le E final est à la bonne place. Obtenu #{inspect(Places.places("TABLE", "SUCRE"))}"

    Places.places("table", "TABLE") != [:bien, :bien, :bien, :bien, :bien] ->
      raise "places/2 doit rester insensible à la casse (passez par Mots.lettres/1)"

    Enum.any?(Places.places("PARIS", "RARES"), fn p -> p not in [:bien, :autre] end) ->
      raise "à ce stade, seuls les atomes :bien et :autre sont attendus"

    true ->
      # propriété : autant d'atomes que de lettres, quels que soient les mots
      for {s, p} <- [{"PARIS", "RARES"}, {"TABLE", "SUCRE"}, {"PLAGE", "SALLE"}] do
        if length(Places.places(s, p)) != 5 do
          raise "places/2 doit renvoyer autant d'atomes que le mot a de lettres"
        end
      end

      IO.puts("✅ Les bonnes places sont repérées : #{inspect(Places.places("PARIS", "RARES"))}")
  end
else
  IO.puts("ℹ️  Définissez d'abord le module Places, puis réévaluez cette vérification.")
end
```

## Exercice 3 — Ce qu'il reste du secret

Nous voici au pied du mur. Avant d'attaquer le morceau de bravoure, posons-nous
la bonne question — et pour cela, un exemple.

Le secret est `"PARIS"`. Vous proposez `"RARES"`. L'exercice 2 nous a donné
`[:autre, :bien, :bien, :autre, :bien]` : le `A`, le `R` du milieu et le `S`
sont à leur place. Restent deux positions à trancher, le `R` du début et le `E`.

Le `E` est vite réglé : il n'y a pas de `E` dans `"PARIS"`, donc `:absent`.
Mais le `R` du début ? Il y a bien un `R` dans `"PARIS"`… **sauf que ce `R`-là a
déjà servi** : c'est celui de la position 2, déjà marqué `:bien`. Le secret n'a
qu'un seul `R`, et il est déjà « consommé ». Le `R` du début est donc
`:absent`, alors même que la lettre figure dans le mot secret.

Voilà le cœur du problème : ce n'est pas « la lettre est-elle dans le secret ? »
mais « **reste-t-il** de cette lettre à distribuer ? ». Pour répondre, il faut
faire un **inventaire** : compter, parmi les lettres du secret qui ne sont
**pas** déjà appariées par un `:bien`, combien il y en a de chaque sorte.

C'est l'objet de cet exercice. Écrivez `Restantes.restantes/2` qui renvoie une
**map** associant chaque lettre encore disponible du secret au **nombre** de
fois où elle reste disponible. Sur notre exemple, `restantes("PARIS", "RARES")`
doit valoir `%{"P" => 1, "I" => 1}` : le `A`, le `R` et le `S` sont déjà pris
par des `:bien`, il ne reste que le `P` et le `I`.

Le chemin, en trois ou quatre maillons de pipeline :

1. agrafer les lettres du **secret** avec les places de l'exercice 2 (attention :
   ici on veut les lettres du **secret**, pas de la proposition — pourquoi ?) ;
2. ne garder que les positions marquées `:autre` (`Enum.filter/2`) ;
3. ne conserver que la lettre de chaque paire (`Enum.map/2`) ;
4. compter les occurrences. Cherchez dans la **documentation du module `Enum`**
   la fonction qui, à partir d'une liste, renvoie une map « valeur → nombre
   d'apparitions » — son nom commence par `frequ`.

> **Module `Enum`** : **https://hexdocs.pm/elixir/Enum.html**

```elixir
defmodule Restantes do
  # À COMPLÉTER (3) : restantes(secret, proposition) -> map lettre => combien
  # il en reste de disponible dans le SECRET, une fois retirées les lettres
  # déjà appariées par un :bien.
  def restantes(secret, proposition) do
    nil
  end
end

Restantes.restantes("PARIS", "RARES")
```

Vérification :

```elixir
if Code.ensure_loaded?(Restantes) do
  cond do
    Restantes.restantes("PARIS", "RARES") != %{"P" => 1, "I" => 1} ->
      raise """
      restantes("PARIS", "RARES") devrait valoir %{"P" => 1, "I" => 1}, \
      obtenu #{inspect(Restantes.restantes("PARIS", "RARES"))}.
      Les positions 1 (A), 2 (R) et 4 (S) sont des :bien : ces trois lettres du
      secret sont déjà consommées. Il ne reste que le P et le I.
      """

    Restantes.restantes("TABLE", "TABLE") != %{} ->
      raise """
      quand tout est :bien, il ne reste RIEN à distribuer : la map doit être vide. \
      Obtenu #{inspect(Restantes.restantes("TABLE", "TABLE"))}
      """

    Restantes.restantes("SALLE", "PARIS") != %{"S" => 1, "L" => 2, "E" => 1} ->
      raise """
      restantes("SALLE", "PARIS") devrait valoir %{"S" => 1, "L" => 2, "E" => 1}, \
      obtenu #{inspect(Restantes.restantes("SALLE", "PARIS"))}.
      Seul le A (position 1) est bien placé ; il reste donc S, L, L, E — et les
      DEUX L doivent être comptés, d'où "L" => 2.
      """

    true ->
      # propriété : le total des restantes + le nombre de :bien = la longueur du mot
      for {s, p} <- [{"PARIS", "RARES"}, {"PLAGE", "SALLE"}, {"SERRE", "ERRES"}, {"TABLE", "TABLE"}] do
        total = Restantes.restantes(s, p) |> Map.values() |> Enum.sum()
        bien = Places.places(s, p) |> Enum.count(fn place -> place == :bien end)

        if total + bien != 5 do
          raise """
          Sur #{s}/#{p} : #{total} lettres restantes + #{bien} bien placées = #{total + bien}, \
          au lieu de 5. Chaque lettre du secret est soit appariée par un :bien, soit
          encore disponible — jamais les deux, jamais ni l'une ni l'autre.
          """
        end
      end

      IO.puts("✅ L'inventaire est juste : #{inspect(Restantes.restantes("PARIS", "RARES"))}")
  end
else
  IO.puts("ℹ️  Définissez d'abord le module Restantes, puis réévaluez cette vérification.")
end
```

## Exercice 4 — Le morceau de bravoure : la comparaison complète

C'est ici que le sujet se mérite. Nous avons les deux ingrédients : les places
(exercice 2) et l'inventaire de ce qui reste (exercice 3). Il faut maintenant
les marier pour produire le verdict final, une liste de `:bien`, `:mal_place` et
`:absent`.

Le principe, vous venez de le poser : on parcourt les positions de la
**proposition**, et pour chacune…

* si elle est déjà marquée `:bien`, rien à décider ;
* sinon, on regarde l'inventaire. **S'il reste** de cette lettre, c'est
  `:mal_place` — et il faut alors **retirer une unité de l'inventaire**, puisque
  cette occurrence-là vient d'être utilisée. S'il n'en reste plus, c'est
  `:absent`.

Relisez la phrase en gras. Elle contient toute la difficulté : l'inventaire
**change au fur et à mesure** du parcours. La décision prise en position 0
modifie ce qui est disponible en position 3. Or `Enum.map/2` traite chaque
élément **indépendamment** des autres : il ne peut rien faire circuler d'une
étape à la suivante. C'est précisément pour cela qu'il ne suffira pas ici.

Avant de coder, prenez trois minutes et répondez à ces questions — de
préférence sur papier, elles valent tout l'exercice :

1. Le secret est `"PLAGE"`, la proposition `"SALLE"`. Le `E` final est bien
   placé. Il y a **deux** `L` dans la proposition (positions 2 et 3) et **un
   seul** `L` dans le secret. Quel verdict pour chacun des deux `L` ? Et si
   l'on avait parcouru les positions **de droite à gauche**, le résultat
   serait-il le même ? Lequel des deux est « le bon » ?
2. Que se passerait-il si l'on oubliait de décrémenter l'inventaire à chaque
   `:mal_place` ? Cherchez un couple de mots où cela donne une réponse
   visiblement fausse.
3. Que se passerait-il si l'on construisait l'inventaire à partir de **toutes**
   les lettres du secret, sans retirer celles déjà appariées par un `:bien` ?
   (Indice : reprenez `"PARIS"` / `"RARES"`.)

> **Votre boîte à outils.** Pour lire l'inventaire sans risquer une erreur sur
> une lettre absente : `Map.get(map, cle, 0)` renvoie `0` par défaut. Pour
> décrémenter une clé **présente** : `Map.update!(map, cle, fn n -> n - 1 end)`.
>
> Et pour le parcours qui fait circuler l'inventaire, deux routes s'offrent à
> vous — allez lire leur documentation, elles sont voisines :
>
> * `Enum.reduce/3`, la fonction à tout faire : elle promène un **accumulateur**
>   le long de la liste. Ici, l'accumulateur devra porter **deux** choses : les
>   verdicts déjà produits et l'inventaire à jour. Un tuple fait très bien
>   l'affaire.
> * `Enum.map_reduce/3`, taillée pour exactement ce besoin : elle **transforme**
>   chaque élément (comme `map`) **tout en** faisant circuler un accumulateur
>   (comme `reduce`), et renvoie les deux à la fin, dans un tuple
>   `{liste_transformée, accumulateur_final}`.
>
> Les deux marchent. La seconde est plus courte. Choisissez, et surtout sachez
> dire pourquoi.

```elixir
defmodule Motus do
  # À COMPLÉTER (4) : comparer(secret, proposition) -> liste de
  # :bien / :mal_place / :absent, une entrée par lettre de la proposition.
  #
  # Vous avez le droit (et intérêt) d'appeler Mots.lettres/1, Places.places/2
  # et Restantes.restantes/2 : les briques des exercices précédents sont là
  # pour ça. Le travail restant, c'est le parcours qui fait circuler
  # l'inventaire et tranche entre :mal_place et :absent.
  def comparer(secret, proposition) do
    nil
  end
end

Motus.comparer("PARIS", "RARES")
```

Vérification (lisez les messages d'erreur : ils vous disent où ça coince) :

```elixir
if Code.ensure_loaded?(Motus) do
  # --- Cas 1 : le piège du R déjà consommé par un :bien -------------------
  cas1 = Motus.comparer("PARIS", "RARES")

  if cas1 != [:absent, :bien, :bien, :absent, :bien] do
    raise """
    comparer("PARIS", "RARES") devrait valoir
      [:absent, :bien, :bien, :absent, :bien]
    obtenu #{inspect(cas1)}.

    Le point délicat est la position 0 : la proposition commence par un R, et il
    y a bien un R dans PARIS — mais c'est celui de la position 2, DÉJÀ compté
    comme :bien. Il ne reste donc plus de R à distribuer : c'est :absent.
    Si vous avez obtenu :mal_place ici, votre inventaire n'exclut pas les
    lettres déjà appariées (revoyez l'exercice 3, et la question 3).
    """
  end

  # --- Cas 2 : deux L dans la proposition, un seul dans le secret ---------
  cas2 = Motus.comparer("PLAGE", "SALLE")

  if cas2 != [:absent, :mal_place, :mal_place, :absent, :bien] do
    raise """
    comparer("PLAGE", "SALLE") devrait valoir
      [:absent, :mal_place, :mal_place, :absent, :bien]
    obtenu #{inspect(cas2)}.

    Décortiquons. Seul le E final est bien placé. Il reste alors P, L, A, G
    à distribuer. Position 0, le S : pas de S dans PLAGE, donc :absent.
    Position 1, le A : disponible, donc :mal_place (et le A est consommé).
    Position 2, le L : disponible, donc :mal_place (et le L est consommé).
    Position 3, le SECOND L : il n'en reste plus, donc :absent.
    Si vos DEUX L sont :mal_place, vous ne décrémentez pas l'inventaire.
    """
  end

  # --- Propriété : un mot comparé à lui-même ne donne que des :bien -------
  for mot <- ["PARIS", "SALLE", "SERRE", "AVION"] do
    if Motus.comparer(mot, mot) != [:bien, :bien, :bien, :bien, :bien] do
      raise "comparer(#{mot}, #{mot}) devrait ne donner que des :bien, obtenu #{inspect(Motus.comparer(mot, mot))}"
    end
  end

  # --- Propriété : une anagramme n'a AUCUNE lettre absente ----------------
  # SERRE et ERRES ont exactement les mêmes lettres : chacune trouve sa place,
  # bonne ou mauvaise, mais aucune ne peut être déclarée absente.
  for {s, p} <- [{"SERRE", "ERRES"}, {"ERRES", "SERRE"}, {"PARIS", "PRISA"}] do
    if :absent in Motus.comparer(s, p) do
      raise """
      #{p} est une anagramme de #{s} : toutes ses lettres sont dans le secret, et
      en quantité exacte. Aucune ne devrait être :absent.
      Obtenu #{inspect(Motus.comparer(s, p))} — vous décrémentez sans doute une
      lettre de trop.
      """
    end
  end

  # --- Propriété de conservation (la plus sévère) -------------------------
  # Pour chaque lettre, le nombre de verdicts « non absents » portant sur cette
  # lettre ne peut JAMAIS dépasser le nombre de fois où elle apparaît dans le
  # secret. C'est l'invariant que rate toute implémentation naïve.
  paires = [
    {"PARIS", "RARES"}, {"PLAGE", "SALLE"}, {"SALLE", "PLAGE"}, {"SERRE", "ERRES"},
    {"TABLE", "SALLE"}, {"POMME", "MOMIE"}, {"VERRE", "REVER"}, {"AVION", "OCEAN"}
  ]

  for {secret, proposition} <- paires do
    indices = Motus.comparer(secret, proposition)

    if length(indices) != 5 do
      raise "comparer/2 doit renvoyer 5 verdicts pour un mot de 5 lettres (#{secret}/#{proposition})"
    end

    if Enum.any?(indices, fn i -> i not in [:bien, :mal_place, :absent] end) do
      raise "seuls :bien, :mal_place et :absent sont admis, obtenu #{inspect(indices)}"
    end

    dans_le_secret = Mots.lettres(secret) |> Enum.frequencies()

    comptes_non_absents =
      Enum.zip(Mots.lettres(proposition), indices)
      |> Enum.filter(fn {_lettre, verdict} -> verdict != :absent end)
      |> Enum.map(fn {lettre, _} -> lettre end)
      |> Enum.frequencies()

    for {lettre, combien} <- comptes_non_absents do
      disponible = Map.get(dans_le_secret, lettre, 0)

      if combien > disponible do
        raise """
        Sur #{secret} / #{proposition}, vous annoncez #{combien} fois la lettre "#{lettre}"
        comme présente (:bien ou :mal_place), alors que le secret n'en contient
        que #{disponible}. Obtenu #{inspect(indices)}.
        Vous distribuez plus de lettres qu'il n'y en a : l'inventaire n'est pas
        décrémenté au bon moment, ou pas décrémenté du tout.
        """
      end
    end
  end

  IO.puts("✅ Comparaison complète, doublons compris — c'est le morceau le plus dur du sujet")
  IO.inspect(Motus.comparer("PLAGE", "SALLE"), label: "PLAGE vs SALLE")
else
  IO.puts("ℹ️  Définissez d'abord le module Motus, puis réévaluez cette vérification.")
end
```

> **Une question pour finir.** Votre `comparer/2` est-elle **symétrique** ?
> Autrement dit, `comparer(a, b)` et `comparer(b, a)` donnent-elles toujours la
> même chose ? Cherchez un contre-exemple parmi les couples ci-dessus — il y en
> a. Et dites en une phrase pourquoi c'est **normal** que le jeu ne soit pas
> symétrique.
>
> → …

## Exercice 5 — La grille, comme à la télévision

Le jeu est écrit ; il lui manque un visage. Dans l'émission, chaque proposition
s'affiche sur une ligne avec un code couleur — c'est ce que nous allons
reproduire, en texte, avec trois pastilles :

| verdict | pastille | à la télé |
|---|---|---|
| `:bien` | 🟥 | carré rouge : bonne lettre, bonne place |
| `:mal_place` | 🟨 | jaune : la lettre y est, mais ailleurs |
| `:absent` | ⬜ | rien du tout |

Écrivez deux fonctions :

* `Grille.ligne/2` renvoie la **chaîne** des trois pastilles pour une
  proposition, par ex. `"⬜🟥🟥⬜🟥"`. Un pipeline : comparer, puis traduire
  chaque verdict en pastille, puis recoller le tout (`Enum.join/1`).
* `Grille.partie/2` prend le secret et une **liste** de propositions, et
  renvoie la grille complète : une ligne par proposition, avec la proposition
  écrite en majuscules à droite des pastilles, séparée par **deux espaces**.
  Les lignes sont recollées par des retours à la ligne (`Enum.join/2` avec
  `"\n"`).

Pour traduire un verdict en pastille, le plus simple est une **map** définie une
fois pour toutes. L'attribut de module `@pastilles` ci-dessous est là pour ça :
c'est une constante de module, et `@pastilles[:bien]` vous donne `"🟥"`.

```elixir
defmodule Grille do
  @pastilles %{bien: "🟥", mal_place: "🟨", absent: "⬜"}

  # À COMPLÉTER (5) : ligne(secret, proposition) -> "⬜🟥🟥⬜🟥"
  def ligne(secret, proposition) do
    nil
  end

  # À COMPLÉTER (6) : partie(secret, propositions) -> la grille complète,
  # une ligne par proposition, sous la forme "<pastilles>  <PROPOSITION>",
  # les lignes séparées par "\n".
  def partie(secret, propositions) do
    nil
  end
end

IO.puts(Grille.partie("PARIS", ["table", "marin", "paris"]))
```

Vérification :

```elixir
if Code.ensure_loaded?(Grille) do
  cond do
    Grille.ligne("PARIS", "RARES") != "⬜🟥🟥⬜🟥" ->
      raise """
      ligne("PARIS", "RARES") devrait valoir "⬜🟥🟥⬜🟥", \
      obtenu #{inspect(Grille.ligne("PARIS", "RARES"))}.
      Vérifiez la correspondance : :bien → 🟥, :mal_place → 🟨, :absent → ⬜.
      """

    Grille.ligne("PLAGE", "SALLE") != "⬜🟨🟨⬜🟥" ->
      raise "ligne(\"PLAGE\", \"SALLE\") devrait valoir \"⬜🟨🟨⬜🟥\", obtenu #{inspect(Grille.ligne("PLAGE", "SALLE"))}"

    Grille.ligne("TABLE", "TABLE") != "🟥🟥🟥🟥🟥" ->
      raise "un mot trouvé, c'est cinq carrés rouges"

    true ->
      grille = Grille.partie("PARIS", ["table", "marin", "paris"])
      lignes = String.split(grille, "\n")

      cond do
        length(lignes) != 3 ->
          raise "partie/2 doit produire autant de lignes que de propositions (3 attendues, #{length(lignes)} obtenues)"

        Enum.at(lignes, 0) != "⬜🟥⬜⬜⬜  TABLE" ->
          raise """
          Première ligne attendue : "⬜🟥⬜⬜⬜  TABLE"
          obtenue : #{inspect(Enum.at(lignes, 0))}
          (les pastilles, DEUX espaces, puis la proposition en MAJUSCULES)
          """

        Enum.at(lignes, 2) != "🟥🟥🟥🟥🟥  PARIS" ->
          raise "Dernière ligne attendue : \"🟥🟥🟥🟥🟥  PARIS\", obtenue #{inspect(Enum.at(lignes, 2))}"

        Grille.partie("PARIS", []) != "" ->
          raise "partie/2 sur une liste vide de propositions doit renvoyer la chaîne vide"

        true ->
          IO.puts("✅ La grille s'affiche :")
          IO.puts(grille)
      end
  end
else
  IO.puts("ℹ️  Définissez d'abord le module Grille, puis réévaluez cette vérification.")
end
```

## Exercice 6 (ouvert) — Le solveur : jouer le jeu à l'envers

Jusqu'ici vous étiez le jeu. Devenez le joueur — ou plutôt, écrivez le programme
qui joue à votre place.

Voici un dictionnaire de trente-huit mots français de cinq lettres, sans accents
pour ne pas nous compliquer la vie. Évaluez cette cellule telle quelle :

```elixir
dictionnaire = ~w(
  ARBRE AVION BLEUE BOITE BRAVO CARTE CHIEN CIBLE CLOUS CRANE
  DOUCE ECRAN FLUTE FORET FRUIT GARDE GLACE GRAIN HIVER JOUER
  LAMPE LIVRE MARIN MONDE NEIGE OCEAN ORAGE PARIS PLAGE PLUIE
  POMME ROUTE SABLE SALLE TABLE TIGRE VERRE VOILE
)

length(dictionnaire)
```

Le raisonnement du solveur tient en une phrase, et c'est une jolie phrase :

> un mot `m` est **encore possible** après la proposition `p` si, en supposant
> que `m` soit le secret, le jeu aurait répondu **exactement** les indices que
> l'on a reçus.

Lisez-la deux fois. Elle dit que pour filtrer les candidats, nous n'avons besoin
d'**aucun** raisonnement nouveau : il suffit de rejouer `comparer/2` en mettant
le mot candidat à la place du secret, et de comparer le résultat aux indices
reçus. Toute la logique des doublons, si durement acquise à l'exercice 4, nous
est offerte une seconde fois.

Écrivez `Solveur.compatibles/3` : à partir d'une liste de mots, d'une
proposition et des indices reçus, elle renvoie la liste des mots encore
possibles. Un `Enum.filter/2` et une ligne de raisonnement.

```elixir
defmodule Solveur do
  # À COMPLÉTER (7) : compatibles(mots, proposition, indices) -> la liste des
  # mots de `mots` qui, s'ils étaient le secret, auraient produit exactement
  # ces indices face à cette proposition.
  def compatibles(mots, proposition, indices) do
    nil
  end
end
```

Vérification :

```elixir
if Code.ensure_loaded?(Solveur) do
  dictionnaire = ~w(
    ARBRE AVION BLEUE BOITE BRAVO CARTE CHIEN CIBLE CLOUS CRANE
    DOUCE ECRAN FLUTE FORET FRUIT GARDE GLACE GRAIN HIVER JOUER
    LAMPE LIVRE MARIN MONDE NEIGE OCEAN ORAGE PARIS PLAGE PLUIE
    POMME ROUTE SABLE SALLE TABLE TIGRE VERRE VOILE
  )

  secret = "PLAGE"

  # Partie 1 : on resserre l'étau, proposition après proposition.
  apres_route = Solveur.compatibles(dictionnaire, "ROUTE", Motus.comparer(secret, "ROUTE"))
  apres_sable = Solveur.compatibles(apres_route, "SABLE", Motus.comparer(secret, "SABLE"))
  apres_grain = Solveur.compatibles(apres_sable, "GRAIN", Motus.comparer(secret, "GRAIN"))

  attendu_route = ["CIBLE", "GLACE", "LAMPE", "NEIGE", "PLAGE", "SABLE", "SALLE"]

  cond do
    Enum.sort(apres_route) != attendu_route ->
      raise """
      Après avoir proposé ROUTE contre le secret PLAGE, il devrait rester
      #{inspect(attendu_route)},
      obtenu #{inspect(Enum.sort(apres_route))}.
      Les indices reçus sont #{inspect(Motus.comparer(secret, "ROUTE"))} : seul le E
      final est bien placé, et aucune des lettres R, O, U, T n'est dans le secret.
      """

    Enum.sort(apres_sable) != ["GLACE", "PLAGE"] ->
      raise "Après ROUTE puis SABLE, il ne devrait rester que [\"GLACE\", \"PLAGE\"], obtenu #{inspect(Enum.sort(apres_sable))}"

    apres_grain != ["PLAGE"] ->
      raise "Après ROUTE, SABLE et GRAIN, le secret est identifié : [\"PLAGE\"]. Obtenu #{inspect(apres_grain)}"

    true ->
      # Partie 2 : la propriété qui compte vraiment. Quels que soient le secret
      # et la proposition, le secret SURVIT toujours à son propre filtre — sinon
      # le solveur éliminerait la bonne réponse. 38 × 38 = 1444 couples testés.
      for secret <- dictionnaire, proposition <- dictionnaire do
        indices = Motus.comparer(secret, proposition)
        restants = Solveur.compatibles(dictionnaire, proposition, indices)

        if secret not in restants do
          raise """
          Le secret #{secret} a été éliminé par sa propre proposition #{proposition}
          (indices #{inspect(indices)}). Un solveur ne doit JAMAIS écarter la bonne
          réponse : relisez la définition de « compatible ».
          """
        end
      end

      # Et une proposition identique au secret ne laisse qu'un seul candidat.
      for mot <- ["PARIS", "SALLE", "ORAGE"] do
        seuls = Solveur.compatibles(dictionnaire, mot, Motus.comparer(mot, mot))

        if seuls != [mot] do
          raise "Proposer le secret lui-même (#{mot}) ne devrait laisser qu'un candidat, obtenu #{inspect(seuls)}"
        end
      end

      IO.puts("✅ Solveur correct sur 1444 couples : il resserre, et n'élimine jamais la bonne réponse")
      IO.puts("   Après ROUTE : #{length(apres_route)} candidats · puis SABLE : #{length(apres_sable)} · puis GRAIN : #{length(apres_grain)}")
  end
else
  IO.puts("ℹ️  Définissez d'abord le module Solveur, puis réévaluez cette vérification.")
end
```

### Pour aller plus loin (vraiment ouvert, aucune vérification)

Vous avez un jeu et un filtre. Il vous manque une **stratégie**. Voici trois
pistes, de la plus accessible à la plus vertigineuse — dans une cellule à vous,
et sans filet :

1. **Le solveur glouton.** Écrivez une fonction qui, partant du dictionnaire
   complet, joue automatiquement : elle propose le premier candidat de la liste,
   reçoit les indices, filtre, recommence, jusqu'à ne plus avoir qu'un mot.
   Combien de coups lui faut-il, en moyenne, sur les 38 mots du dictionnaire ?
2. **Le meilleur premier coup.** Tous les mots de départ ne se valent pas : à
   l'exercice ci-dessus, `ROUTE` laissait 7 candidats là où `CRANE` n'en
   laissait qu'un. Pour chaque mot `p` du dictionnaire, calculez le nombre
   **moyen** de candidats restants après l'avoir proposé (moyenne sur tous les
   secrets possibles). Quel est le meilleur mot d'ouverture ? Le pire ? Le
   résultat vous surprendra peut-être moins que sa raison : regardez quelles
   lettres composent le gagnant.
3. **L'entropie.** Ceux qui ont des souvenirs de théorie de l'information
   reconnaîtront que « le nombre moyen de candidats restants » est une
   approximation grossière. La vraie mesure du coup le plus informatif, c'est
   l'entropie de la distribution des indices possibles. À vous de voir.

## Et voilà

Vous avez écrit un jeu complet — la comparaison, ses doublons, l'affichage, et
même l'adversaire — avec les seuls outils de la séance 2 : des listes, des maps,
du pattern matching et des pipelines. Pas une boucle, pas une variable modifiée.

Le passage difficile, celui du `R` de `"RARES"`, n'était pas un problème
d'Elixir : c'était un problème de **spécification**. Formuler exactement ce
qu'on veut avant de savoir comment le calculer, c'est la moitié du métier. Vous
retrouverez ce moment-là souvent.

Enregistrez ce notebook complété (**Ctrl/Cmd + S**) : c'est ce fichier `.livemd`
que vous rendrez, si vous le souhaitez, selon les modalités décrites sur la page
**« Rendre un devoir »** du site. Signalez-moi simplement dans votre message que
c'est un rendu bonus.

Il existe un second niveau caché. Le code est un mot de six caractères, et c'est
un souvenir de 1993 — si vous avez déjà entendu quelqu'un dire qu'il « active le
mode Dieu », vous n'êtes pas loin.

Pour toute question, vous pouvez me contacter par email :
[aurelien.noce@minesparis.psl.eu](mailto:aurelien.noce@minesparis.psl.eu?cc=aurelien.noce@imagine-app.fr),
avec `aurelien.noce@imagine-app.fr` en copie.
