Files
escampe-ia/RAPPORT.md
Ethan Puyaubreau 052a3bf978 Rapport : intègre les ajouts du binôme + passe de relecture
- Sommaire, exemple tactique Q4 (figure 6) et note « pas de bibliothèque
  d'ouvertures » repris du commit d'Antonin et portés dans report/rapport.html
  (source du PDF), jusque-là seulement dans RAPPORT.md.
- Exemple Q4 vérifié contre TILE_MAP : liserés D4/F6=2, E5=1, A2/C2=3 et chemin
  de capture C2→D2→D1→C1 (3 pas = liseré de C2) tous corrects.
- Relecture du style sur tout le rapport ; correction de deux coquilles
  (« énnoncé », ancre de sommaire). HTML et RAPPORT.md tenus en miroir.
- PDF régénéré (9 pages, sommaire inclus) ; chiffres mesurés inchangés.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 20:39:15 +02:00

425 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Escampe — Rapport (version finale)
**Université Paris-Saclay — Polytech APP5 — Année 2025-2026 — « IA et contraintes »**
**Binôme : Ethan Puyaubreau & Antonin Russac — 30 mai 2026**
Joueur : `escampe.JoueurPuyaubreauRussac`
> Ce fichier est le miroir Markdown du rapport. La version PDF mise en page
> (`dist/Puyaubreau_Russac_rapport.pdf`) est générée depuis `report/rapport.html`
> par `python tools/make_report_pdf.py` (PyMuPDF, sans dépendance externe).
---
## Sommaire
1. [Présentation et règles](#1-présentation-et-règles)
2. [Analyse des caractéristiques du jeu (Q1 à Q7)](#2-analyse-des-caractéristiques-du-jeu)
3. [Modélisation : la classe `EscampeBoard`](#3-modélisation--la-classe-escampeboard)
4. [Intégration au tournoi : protocole de l'arbitre](#4-intégration-au-tournoi--protocole-de-larbitre)
5. [Placement d'ouverture](#5-placement-douverture)
6. [Moteur de décision](#6-moteur-de-décision)
7. [Heuristique d'évaluation](#7-heuristique-dévaluation)
8. [Gestion du temps réel](#8-gestion-du-temps-réel)
9. [Performances et tests](#9-performances-et-tests)
10. [Compilation, exécution et livrables](#10-compilation-exécution-et-livrables)
11. [Sources et bibliographie](#11-sources-et-bibliographie)
12. [Conclusion et difficultés rencontrées](#12-conclusion-et-difficultés-rencontrées)
---
## 1. Présentation et règles
Escampe se joue sur un plateau de 36 cases (6×6). Chaque case porte un liseré
*simple*, *double* ou *triple*. Chaque joueur dispose d'une **licorne** et de cinq
**paladins** (noirs ou blancs). Les lignes vont de 1 à 6, les colonnes de A à F, et
le but est de **prendre la licorne adverse**.
Ce qui fait l'originalité du jeu, c'est la **contrainte de liseré** : la pièce qu'on
joue doit partir d'une case dont le liseré est le même que celui de la case où
l'adversaire vient de poser sa pièce. Ce liseré de départ fixe aussi le nombre de pas
(1, 2 ou 3), orthogonaux, sans traverser ni repasser sur une case déjà visitée. On ne
capture qu'en s'arrêtant, au dernier pas, sur la licorne adverse ; les paladins, eux,
sont imprenables. Un joueur qui ne peut rien jouer passe son tour. Toute la difficulté
revient donc à coincer l'adversaire en lui imposant des liserés qui le bloquent.
Pour le déroulement, Noir place d'abord ses six pièces sur les deux lignes d'un bord
de son choix (haut ou bas), puis Blanc fait de même sur le bord opposé, et c'est
**Blanc qui joue le premier coup**.
---
## 2. Analyse des caractéristiques du jeu
Nous reprenons les sept questions de la première partie, cette fois à la lumière du
code que nous avons réellement écrit.
### Q1 — Modélisation d'un état
Le plateau est un tableau `int[6][6]` (`board[ligne][colonne]`, ligne 0 = ligne 1 en
bas, colonne 0 = A). Chaque case vaut `EMPTY`, `WHITE_LICORNE`, `WHITE_PALADIN`,
`BLACK_LICORNE` ou `BLACK_PALADIN`. Quatre informations que le tableau ne porte pas,
mais dont la règle a besoin, sont gardées à côté : `lastTileType` (le liseré imposé,
`-1` quand il n'y a pas de contrainte), `currentPlayer`, les drapeaux
`blackPlaced`/`whitePlaced`, et `blackRows` (le bord de Noir, qui détermine celui de
Blanc).
Le tableau d'entiers donne un accès en O(1) à n'importe quelle case et se copie sans
effort, ce qui compte pour l'arbre de recherche ; il se sérialise aussi directement
vers le format de fichier. Surtout, il autorise un schéma `make`/`unmake` qui n'alloue
rien (voir §6). Le seul point gênant est que la contrainte de liseré vit en dehors du
tableau : il faut penser à la mettre à jour à chaque coup, ce que nous centralisons
dans `play`.
Carte des liserés `TILE_MAP` (figure 4, ligne 1 en bas) :
```
A B C D E F
6 3 2 2 1 3 2
5 1 3 1 3 1 2
4 2 1 3 2 3 1
3 2 3 1 2 1 3
2 3 1 3 1 3 2
1 1 2 2 3 1 2
```
> Nous avons extrait par réflexion la carte qu'utilise l'arbitre dans sa propre classe
> de jeu, et elle coïncide case pour case avec la nôtre (elle colle aussi à l'exemple
> de la figure 6). La vérification valait le coup : une carte fausse aurait fait
> rejeter nos coups par l'arbitre.
### Q2 — Détection de fin de partie
La partie s'arrête dès qu'une des deux licornes quitte le plateau ; il n'y a pas
d'autre cas, donc pas de nul. Le test (`gameOver`) est un simple balayage en O(1). En
recherche, le moteur n'attend même pas ce balayage : il repère la capture à l'instant
où le coup la produit.
### Q3 — Sources de difficulté et facteur de branchement
Quatre choses rendent le jeu retors : la contrainte de liseré, qui fait varier
fortement la mobilité ; la dépendance entre tours, puisque la case d'arrivée qu'on
choisit dicte les pièces que l'adversaire pourra bouger ; l'asymétrie du plateau, avec
des zones riches en liserés triples (mobiles) et d'autres en liserés simples ; et le
risque qu'une pièce, voire un joueur entier, se retrouve bloqué et doive passer.
Côté **facteur de branchement**, nous avions avancé en première partie une borne
théorique de l'ordre de 120 (six pièces, jusqu'à une vingtaine de destinations sur un
liseré triple). En pratique c'est beaucoup moins, parce que la contrainte de liseré ne
laisse jouables que les pièces du bon type. Une simulation de 30 000 parties aléatoires
(utilitaire `escampe.Branching`) donne :
| Situation | Branchement max observé |
|---|---|
| Coup contraint (un liseré imposé) | **45** |
| Coup libre (1er coup ou après un pass) | **49** |
| Branchement moyen (toutes positions) | **≈ 8,9** |
Avec une moyenne sous 10, l'alpha-bêta descend profond en quelques secondes (§6).
### Q4 — Coups imparables
Il n'y a pas de coup gagnant à coup sûr dès le départ : comme l'adversaire choisit sa
case d'arrivée, donc le liseré qu'il nous impose, il peut toujours désamorcer une
menace au mauvais moment. Ce qui existe, en revanche, ce sont des positions de
**zugzwang partiel**, où il est forcé d'imposer précisément le liseré qui ouvre la
capture.
L'énoncé en donne un cas net (figure 6). Noir vient de jouer en D4, une case à liseré
double, donc Blanc doit partir d'une case double : il choisit **F6 E5** (F6 est
double). Noir est alors contraint de jouer depuis un liseré simple comme E5, et son
seul coup raisonnable est **A1 A2**. Or A2 est à liseré triple : Blanc enchaîne
**C2 × C1**, son paladin en C2 parcourant les trois pas C2 → D2 → D1 → C1 pour prendre
la licorne noire. La séquence est imparable localement : une fois Noir poussé en A2,
il ne peut plus empêcher la prise.
Ce genre de combinaison ne se construit pas mécaniquement depuis l'ouverture, il y a
trop de degrés de liberté ; mais notre alpha-bêta la trouve et la joue dès qu'elle
entre dans son horizon de recherche.
### Q5 — Critères pour l'heuristique
Cinq critères nous semblaient pertinents : la distance à la licorne adverse, la
mobilité différentielle, le contrôle du liseré qu'on impose, la protection de sa
propre licorne et l'avancée des pièces. Au final (§7), l'évaluation retenue tient
surtout à deux d'entre eux, la proximité de nos paladins à la licorne adverse (attaque)
et l'éloignement des paladins adverses de la nôtre (défense) ; le reste, la recherche
s'en charge assez bien toute seule.
### Q6 — Stratégie selon la phase
- **Ouverture (placement)** : c'est irréversible, donc on sécurise d'emblée la licorne
et on s'arrange pour pouvoir toujours jouer (§5).
- **Milieu** : on manœuvre pour menacer la licorne adverse tout en gardant la main sur
le liseré qu'on impose, en visant le zugzwang partiel.
- **Finale** : dès qu'une capture est en vue, c'est le calcul tactique qui décide.
### Q7 — Majorant du nombre de coups et gestion du temps
Aucune pièce ne disparaît avant la prise finale, donc une partie peut traîner. En
bornant le branchement par tour sur quelques dizaines de tours, on arrive à un ordre de
grandeur de 400 à 600 demi-coups. Pour rester dans les 300 s par joueur, on s'appuie sur
l'approfondissement itératif, l'élagage alpha-bêta et un budget par coup calculé à
partir du temps restant (§8).
---
## 3. Modélisation : la classe `EscampeBoard`
`EscampeBoard` implémente l'interface fournie `Partie1` (`setFromFile`/`saveToFile`,
`isValidMove`, `possiblesMoves`, `play`, `gameOver`) et suit les conventions de
l'arbitre : coup régulier `"B1-D1"`, placement `"C6/A6/B5/D5/E6/F5"` avec la licorne
en tête puis les cinq paladins, et pass `"E"`.
Le format de fichier reprend celui de l'énoncé : six lignes de plateau du bas vers le
haut, avec `N/n` pour le noir, `B/b` pour le blanc et `-` pour le vide, chaque ligne
encadrée de son numéro ; les lignes en `%` sont des commentaires. Nous y rangeons
justement l'état hors-plateau (liseré courant, joueur, bord de Noir), de sorte qu'une
sauvegarde se recharge à l'identique.
Pour générer les coups, on part d'une case et on énumère les arrivées par un parcours
en profondeur avec retour arrière : exactement N pas (N = liseré de départ), cases
intermédiaires vides, et case finale soit vide soit occupée par la licorne adverse,
auquel cas c'est une capture. `possiblesMoves` ne garde que les pièces sur le bon
liseré et renvoie `["E"]` quand plus rien n'est jouable. Une méthode `main` fait la
démonstration sur des exemples : placements, contrainte de liseré, pass, aller-retour
fichier et capture.
> Un bug s'était glissé là et nous l'avons corrigé en partie 3 : un placement légal
> mais aligné sur une seule ligne faisait planter le calcul du bord de Noir, qui
> supposait toujours deux lignes. Le bord se déduit maintenant de la ligne de la
> licorne.
---
## 4. Intégration au tournoi : protocole de l'arbitre
`JoueurPuyaubreauRussac implements IJoueur` garde à jour un `EscampeBoard` à chaque
coup, le nôtre via `play`, celui de l'adversaire reçu par `mouvementEnnemi`. Trois
détails ont demandé une adaptation, et deux d'entre eux ont dû être confirmés en
regardant dans le jar de l'arbitre, qui est obfusqué :
- **Les couleurs** : `IJoueur` raisonne en entiers (`NOIR=1`, `BLANC=-1`) alors que
`EscampeBoard` utilise les chaînes `"noir"` et `"blanc"`.
- **Le pass se note `"E"`, pas `"PASSE"`** : le Javadoc d'`IJoueur` annonce `"PASSE"`,
mais le serveur teste bel et bien `move.equals("E")`, et `"PASSE"` n'apparaît nulle
part dans le jar. Suivre le Javadoc nous aurait coûté la partie sur coup illégal.
- **La carte des liserés** doit être celle du serveur (cf. Q1).
Placement et coups passent par le même canal : le premier `choixMouvement` renvoie un
placement, les suivants des coups, la phase se lisant sur `blackPlaced`/`whitePlaced`.
En lisant la classe `Solo` fournie, on reconstitue l'ordre des appels :
```
Noir : choixMouvement(placement) -> mvtEnnemi(placement Blanc)
-> mvtEnnemi(1er coup Blanc) -> choixMouvement(coup) -> ...
Blanc : mvtEnnemi(placement Noir) -> choixMouvement(placement)
-> choixMouvement(1er coup, Blanc rejoue) -> mvtEnnemi(coup Noir) -> ...
```
Comme on rejoue chaque coup sur l'`EscampeBoard` interne dans cet ordre, le joueur au
trait reste synchronisé avec l'arbitre sans traitement particulier.
Côté lancement, il faut trois processus, le serveur et deux clients :
```
java -cp escampeobf.jar escampe.ServeurJeu 1234 1
java -cp Puyaubreau_Russac.jar escampe.ClientJeu escampe.JoueurPuyaubreauRussac localhost 1234
java -cp escampeobf.jar escampe.ClientJeu escampe.JoueurAleatoire localhost 1234
```
---
## 5. Placement d'ouverture
Le placement ne se rejoue pas, donc autant le soigner. Le constat est venu de
l'auto-jeu : une licorne mal posée peut devenir la seule pièce jouable sur le liseré
imposé, et se retrouver bloquée, ce qui force des passes à répétition et abandonne
l'initiative. Trois principes répondent à ça :
1. **La licorne dans un coin.** Un coin n'a que deux voisines, donc seulement deux
cases d'où l'adversaire peut venir la prendre.
2. **Deux murs.** On occupe ces deux voisines avec des paladins, et la licorne devient
imprenable tant que les murs tiennent, puisqu'on ne peut pas finir son dernier pas
sur une case occupée.
3. **Trois liserés couverts.** Les trois paladins restants se posent sur des cases de
liserés 1, 2 et 3 différents. Quel que soit le liseré imposé, il reste une pièce
mobile, et on n'a jamais à passer ni à déranger un mur ou la licorne.
Voici les deux dispositions retenues (Blanc prend le bord opposé à Noir) ; nous en
avons vérifié la légalité et les trois propriétés ci-dessus :
```
Bord bas A1/A2/B1/E1/F1/C2 Bord haut A6/A5/B6/C5/F5/E6
licorne A1, murs A2/B1, licorne A6, murs A5/B6,
mobiles E1·F1·C2 = liserés 1·2·3 mobiles C5·F5·E6 = liserés 1·2·3
```
Nous n'avons pas de bibliothèque d'ouvertures. Elle aurait peu de valeur ici : la
contrainte de liseré rend toute séquence pré-calculée caduque dès que le placement
adverse diffère du cas prévu, souvent au deuxième coup. Et comme le branchement moyen
(~8,9) laisse l'alpha-bêta atteindre la profondeur 12 à 15 d'entrée, le placement fixe
ci-dessus suffit à démarrer sur une position saine et reproductible.
---
## 6. Moteur de décision
Le choix du coup repose sur un negamax avec élagage alpha-bêta et approfondissement
itératif (classe `Moteur`). La recherche travaille sur une copie du plateau, jamais sur
l'état réel. Une capture de licorne compte comme une feuille de valeur `WIN - ply`, ce
qui pousse à gagner tôt plutôt que tard.
Plusieurs choix tirent la vitesse vers le haut :
- **Coups codés sur un entier** (case = `ligne*6+colonne`, coup = `départ*36+arrivée`),
pour ne manipuler aucune chaîne dans la boucle chaude.
- **DFS sur masque de bits** : les 36 cases tiennent dans un `long`, et les ensembles
« visité » et « atteignable » sont de simples masques, sans tableau alloué à chaque
appel.
- **`make`/`unmake` sans allocation** : un petit jeton suffit à défaire un coup, donc
on explore des millions de nœuds sans solliciter le ramasse-miettes.
- **Buffers de coups réservés** à l'avance, un par profondeur.
- **Ordre des coups** : on essaie d'abord toute prise de licorne (coupure immédiate),
et on remet en tête le meilleur coup de l'itération précédente.
> Le moteur a sa propre génération de coups en entiers, en parallèle de celle, vérifiée,
> d'`EscampeBoard` en chaînes. Pour être sûr qu'elles ne divergent pas en silence, le
> test `VerifMoves` (§9) confronte les deux et exige les mêmes coups et les mêmes états :
> c'est ce qui nous garantit qu'optimiser n'a pas modifié les règles au passage.
En pratique, le moteur explore de l'ordre de **4 à 5 M nœuds/s**. En milieu de partie,
l'approfondissement itératif atteint 12 à 15 demi-coups en 6 s, davantage dans les
positions étroites. Quand il annonce un gain forcé, la capture a bien lieu dans les
parties de contrôle.
---
## 7. Heuristique d'évaluation
Le matériel ne bouge pas (paladins imprenables, licornes en place jusqu'à la prise),
donc évaluer une position non terminale revient à juger un placement. L'évaluation se
fait du point de vue du joueur au trait, à partir de distances de Manhattan, et combine
deux idées :
- la **pression d'attaque**, c'est-à-dire la proximité de nos paladins à la licorne
adverse, avec un terme de somme (pression d'ensemble) et un terme de minimum (le
paladin le plus proche pèse plus lourd) ;
- la **sécurité**, soit l'éloignement des paladins adverses de notre licorne, avec les
deux mêmes termes mais de signe opposé.
Avec les poids retenus (2 pour les sommes, 8 pour les minimums) :
```
eval = 2·Σ(10d_attaque) 2·Σ(10d_défense)
+ 8·(10min d_attaque) 8·(10min d_défense)
```
Pour régler ces poids, nous avons fait jouer le moteur contre lui-même et contre le
joueur aléatoire fourni, en comparant trois variantes. Avec la somme seule, le jeu
restait trop diffus et le moteur tardait à concentrer une menace. La somme plus le
minimum, que nous avons gardée, recentre les paladins vers la licorne adverse grâce au
fort poids du minimum et fait monter le taux de capture. L'ajout d'un terme défensif
symétrique a été conservé aussi : il évite d'exposer notre licorne sans pénaliser
l'attaque. Ce poids élevé sur le minimum traduit une réalité du jeu, où c'est le paladin
le plus avancé qui conclut une prise.
> Une limite que nous assumons : faute d'autres IA disponibles avant le tournoi, ces
> poids sont calés contre l'aléatoire et en auto-jeu, pas contre des joueurs forts. Cela
> dit, les prises à courte échéance relèvent de la recherche, ce qui rend le joueur
> solide même avec une évaluation aussi simple.
---
## 8. Gestion du temps réel
L'arbitre laisse 300 s par joueur et par partie. Nous travaillons sous une enveloppe
interne de **280 s**, soit une vingtaine de secondes de marge. Le budget d'un coup est
une fraction du temps restant, bornée des deux côtés :
```
tranche = clamp( temps_restant / 12 , 120 ms , 6000 ms )
```
Diviser le temps restant le fait décroître géométriquement, si bien que le budget ne
peut pas s'épuiser, même sur une partie qui s'éternise. Le plafond de 6 s évite de
gaspiller du temps en ouverture, le plancher de 120 ms garantit un minimum de réflexion,
et un mode « panique » couvre les toutes dernières secondes. Comme la recherche est
itérative, le meilleur coup déjà trouvé est disponible dès que la tranche expire, le
temps étant relu toutes les 2048 explorations de nœuds.
En mesure (auto-jeu équilibré, plein budget), le coup le plus long approche le plafond,
environ 6 s, et le cumul sur une partie entière plafonne vers 36 s par joueur, loin des
300 s. Le réglage est prudent et on pourrait l'ouvrir davantage sans risque.
---
## 9. Performances et tests
Chaque maillon de la chaîne est contrôlé contre une référence indépendante.
| Test | Garantit | Résultat |
|---|---|---|
| `VerifMoves` | génération en entiers (moteur) identique à la génération en chaînes (vérifiée), coups + make/unmake | 3 000 parties · 142 165 positions · 1 281 985 contrôles · **0 divergence** |
| `RulesTest` | règles vérifiées directement (pas/liseré, capture, imprenabilité, non-traversée, pass, fin, placement) | **21 / 21** |
| Matchs arbitrés vs `JoueurAleatoire` | protocole de bout en bout, légalité | **7 / 7 victoires**, 0 illégal, 0 exception (2 couleurs) |
| Démo IA vs IA (serveur réel) | partie complète moteur contre moteur, pass | 21 coups, fin propre par capture |
| `Bench` / `Branching` | vitesse, profondeur, branchement | ~45 M nœuds/s ; prof. 1215 ; branchement max 49, moyen ≈ 8,9 |
Les rôles ne se recouvrent pas : `VerifMoves` montre que le moteur colle à
`EscampeBoard`, `RulesTest` que `EscampeBoard` respecte les règles, et les parties
arbitrées que l'ensemble dialogue correctement avec le vrai arbitre. Sur toutes les
parties jouées, aucun coup illégal n'a été produit.
---
## 10. Compilation, exécution et livrables
`build.sh` fabrique dans `dist/` les trois livrables de la version finale :
```
Puyaubreau_Russac.jar jar exécutable (Main-Class : escampe.ClientJeu)
mainClass jar:Puyaubreau_Russac.jar
clientClass:escampe.ClientJeu
mainClass:escampe.JoueurPuyaubreauRussac
Puyaubreau_Russac.tgz archive : Puyaubreau_Russac/ { src/escampe/*.java, mainClass, jar }
```
Le jar ne contient que les classes de production ; les utilitaires de test
(`VerifMoves`, `RulesTest`, `Bench`, `Branching`) restent dehors. Le jeu en
multijoueur (humain contre humain, humain contre notre IA, en local comme à distance)
est décrit dans `MULTIJOUEUR.md`.
---
## 11. Sources et bibliographie
- L'**énoncé du cours** (Université Paris-Saclay, Polytech APP5, 2025-2026) pour les
règles, la carte des liserés (figure 4), l'interface `Partie1` et les classes fournies
(`IJoueur`, `ClientJeu`, `Solo`, `Applet`, serveur).
- Des **algorithmes classiques**, comme inspiration et sans copie de code : l'élagage
alpha-bêta (Knuth et Moore, 1975), le minimax, le negamax et l'approfondissement
itératif (Russell et Norvig, *AIMA*), ainsi que les masques de bits et l'ordonnancement
de coups (*Chess Programming Wiki*).
- Pour être clairs : nous n'avons recopié aucun programme d'Escampe existant. La seule
rétro-ingénierie a porté sur le jar d'arbitre fourni avec le sujet, et uniquement pour
confirmer le protocole (le pass `"E"`) et la carte des liserés, deux points que la
documentation laissait dans le flou.
---
## 12. Conclusion et difficultés rencontrées
Le joueur mène une partie tout seul, dialogue correctement avec l'arbitre, ne joue
jamais de coup illégal et tient le temps très largement. Les principaux obstacles ont
été les suivants :
- **L'obfuscation du serveur.** Trancher l'ambiguïté du pass (`"E"` et non `"PASSE"`)
et confirmer la carte des liserés a demandé de fouiller le jar, sans quoi on perdait
sur coup illégal.
- **L'interface obfusquée face à nos sources.** Le joueur aléatoire du jar n'implémente
pas notre `IJoueur`, donc les tests contre lui passent par le réseau, où seules des
chaînes circulent.
- **L'avantage du trait.** En miroir, Blanc, qui joue le premier, garde l'initiative via
la contrainte de liseré ; c'est une propriété du jeu, pas une question de force du
moteur.
- **Le réglage de l'heuristique sans sparring-partner**, calé faute de mieux contre
l'aléatoire et en auto-jeu.
Si nous devions continuer, plusieurs pistes se présentent : une table de transposition
(hachage de Zobrist), une bibliothèque d'ouvertures de placement, un terme de mobilité
différentielle dans l'évaluation et une recherche de quiescence sur les menaces de
capture.