- 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>
22 KiB
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 depuisreport/rapport.htmlparpython tools/make_report_pdf.py(PyMuPDF, sans dépendance externe).
Sommaire
- Présentation et règles
- Analyse des caractéristiques du jeu (Q1 à Q7)
- Modélisation : la classe
EscampeBoard - Intégration au tournoi : protocole de l'arbitre
- Placement d'ouverture
- Moteur de décision
- Heuristique d'évaluation
- Gestion du temps réel
- Performances et tests
- Compilation, exécution et livrables
- Sources et bibliographie
- 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 :
IJoueurraisonne en entiers (NOIR=1,BLANC=-1) alors queEscampeBoardutilise les chaînes"noir"et"blanc". - Le pass se note
"E", pas"PASSE": le Javadoc d'IJoueurannonce"PASSE", mais le serveur teste bel et bienmove.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 :
- La licorne dans un coin. Un coin n'a que deux voisines, donc seulement deux cases d'où l'adversaire peut venir la prendre.
- 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.
- 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/unmakesans 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'
EscampeBoarden chaînes. Pour être sûr qu'elles ne divergent pas en silence, le testVerifMoves(§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·Σ(10−d_attaque) − 2·Σ(10−d_défense)
+ 8·(10−min d_attaque) − 8·(10−min 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 | ~4–5 M nœuds/s ; prof. 12–15 ; 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
Partie1et 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.