commit e508efa14f442e59b5fcbd74e0a1e3d037135c13 Author: Ethan Puyaubreau Date: Sat May 30 16:00:29 2026 +0200 Joueur IA Escampe (Puyaubreau/Russac) — version finale Joueur alpha-bêta + iterative deepening pour le tournoi APP5 « IA et contraintes ». - src/escampe/ : joueur (IJoueur), moteur (alpha-bêta + DFS bitmask, make/unmake sans allocation), modèle EscampeBoard (Partie1), utilitaires de test. - Protocole arbitre vérifié (pass="E", carte des liserés identique au serveur, machine à états placement/jeu) ; 7/7 victoires vs joueur aléatoire, 0 illégal. - Vérifications : VerifMoves (int≡String, 0 divergence/142k positions), RulesTest (21/21), Branching (facteur de branchement mesuré). - Rapport : report/rapport.html + tools/make_report_pdf.py (PyMuPDF) → PDF, RAPPORT.md. - Livrables buildés inclus (dist/ : jar, mainClass, tgz, rapport PDF) + lib/escampeobf.jar. Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d84aa06 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +# Artefacts de compilation (régénérables) +out/ +build/ +**/*.class + +# Logs de tests +scripts/logs/ + +# Brouillons / notes hors-sujet +tempo.md + +# OS / éditeurs +.DS_Store +Thumbs.db +*.swp + +# NB : dist/ est VOLONTAIREMENT versionné (livrables buildés demandés). diff --git a/MULTIJOUEUR.md b/MULTIJOUEUR.md new file mode 100644 index 0000000..b01272d --- /dev/null +++ b/MULTIJOUEUR.md @@ -0,0 +1,93 @@ +# Jouer à Escampe en multijoueur + +Le jeu est **réseau** : un **serveur** (l'arbitre) + **deux clients** qui s'y +connectent. Chaque client charge soit un humain (`escampe.JoueurHumain`, en +console), soit une IA (`escampe.JoueurPuyaubreauRussac`, la nôtre, ou +`escampe.JoueurAleatoire`). Les clients peuvent être sur la **même machine** ou +sur **deux machines différentes** : seules des chaînes de caractères circulent. + +Prérequis sur chaque machine : **Java** et le fichier **`escampeobf.jar`** +(le serveur + les joueurs de référence). Pour jouer contre notre IA, il faut +aussi **`Puyaubreau_Russac.jar`** (produit par `build.sh`, dans `dist/`). + +--- + +## 1. Sur le même PC — le plus simple + +Double-cliquez sur : + +- **`jouer-vs-pote.bat`** → deux humains (3 fenêtres : serveur + 2 joueurs). +- **`jouer-vs-IA.bat`** → vous (humain) contre notre IA. + +(Ou, à la main, dans 3 terminaux PowerShell :) + +```powershell +java -cp escampeobf.jar escampe.ServeurJeu 1234 1 +java -cp escampeobf.jar escampe.ClientJeu escampe.JoueurHumain localhost 1234 +java -cp escampeobf.jar escampe.ClientJeu escampe.JoueurHumain localhost 1234 +``` + +--- + +## 2. À distance, avec un pote (deux PC sur le même réseau / Wi-Fi) + +**Vous (l'hôte)** lancez le serveur et trouvez votre adresse IP locale : + +```powershell +java -cp escampeobf.jar escampe.ServeurJeu 1234 1 +ipconfig # repérez « Adresse IPv4 », ex. 192.168.1.42 +``` + +Puis lancez votre propre client (sur l'hôte, `localhost` suffit) : + +```powershell +java -cp escampeobf.jar escampe.ClientJeu escampe.JoueurHumain localhost 1234 +``` + +**Votre pote**, sur son PC, se connecte à **votre IP** (remplacez `localhost`) : + +```powershell +java -cp escampeobf.jar escampe.ClientJeu escampe.JoueurHumain 192.168.1.42 1234 +``` + +Notes : +- Le **pare-feu Windows** de l'hôte doit autoriser Java sur le port 1234 (une + fenêtre de demande apparaît au 1ᵉʳ lancement — cliquez « Autoriser »). +- Il faut être sur le **même réseau local** (même box/Wi-Fi). +- **Par Internet** (réseaux différents) : il faut une redirection de port sur la + box de l'hôte (port 1234 → IP de l'hôte) **ou** un VPN type Tailscale/Hamachi + (plus simple et sûr). Sinon le pote ne peut pas atteindre votre machine. + +--- + +## 3. Comment on joue (client console `JoueurHumain`) + +À votre tour, le client affiche le plateau et vous demande de taper : + +- au **placement** : le bord `H`/`B` (Noir choisit ; Blanc est forcé au bord + opposé), puis la case de la **licorne**, puis les **5 paladins** (ex. `A1`, + `B2`, …) ; +- en **jeu** : la case de **départ** puis la case d'**arrivée** (ex. `C2`, `D2`). + +Rappel des règles : la pièce doit partir d'une case du **même liseré** que la +case où l'adversaire vient d'arriver, et avance d'autant de cases que le liseré +(1, 2 ou 3), sans traverser ni revenir sur une case. On gagne en se posant sur +la **licorne** adverse. Si vous ne pouvez rien jouer, le tour est passé +automatiquement. + +> Le serveur ouvre aussi une **fenêtre graphique** du plateau (attention : d'après +> l'énoncé, le tout dernier coup n'y est pas affiché). Le client humain en console +> reste un peu rustique, mais fonctionne. + +--- + +## 4. Variantes utiles + +```powershell +# Vous (humain) contre l'IA : +java -cp Puyaubreau_Russac.jar escampe.ClientJeu escampe.JoueurPuyaubreauRussac localhost 1234 +java -cp escampeobf.jar escampe.ClientJeu escampe.JoueurHumain localhost 1234 + +# Joueur aléatoire de référence (pour tester) : +java -cp escampeobf.jar escampe.ClientJeu escampe.JoueurAleatoire localhost 1234 +``` diff --git a/Puyaubreau_Russac.pdf b/Puyaubreau_Russac.pdf new file mode 100644 index 0000000..2bbd976 Binary files /dev/null and b/Puyaubreau_Russac.pdf differ diff --git a/Puyaubreau_Russac.tgz b/Puyaubreau_Russac.tgz new file mode 100644 index 0000000..a0529c7 Binary files /dev/null and b/Puyaubreau_Russac.tgz differ diff --git a/RAPPORT.md b/RAPPORT.md new file mode 100644 index 0000000..ba159cf --- /dev/null +++ b/RAPPORT.md @@ -0,0 +1,331 @@ +# 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). + +--- + +## 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). Lignes numérotées de 1 à 6, colonnes de A à F. Le +but est de **prendre la licorne adverse**. + +Règle caractéristique — la **contrainte de liseré** : la pièce jouée doit partir +d'une case dont le liseré est *identique* à celui de la case d'arrivée du coup +adverse précédent. Le liseré de départ fixe le nombre de pas (1, 2 ou 3), +orthogonaux, sans traverser ni revisiter de case. On ne capture qu'en se posant, +au dernier pas, sur la licorne adverse (paladins imprenables). Sans coup possible, +on passe son tour. + +Déroulement : Noir place ses six pièces sur les deux lignes d'un bord ; Blanc sur +le bord opposé ; **Blanc joue le premier coup**. + +--- + +## 2. Analyse des caractéristiques du jeu (Q1–Q7) + +### Q1 — Modélisation d'un état +Plateau `int[6][6]` (`board[ligne][colonne]`, ligne 0 = ligne 1 en bas, colonne +0 = A). Chaque case : `EMPTY`, `WHITE_LICORNE`, `WHITE_PALADIN`, `BLACK_LICORNE`, +`BLACK_PALADIN`. État hors-plateau : `lastTileType` (liseré imposé, `-1` = libre), +`currentPlayer`, `blackPlaced`/`whitePlaced`, `blackRows` (bord de Noir). + +- **Avantages** : accès O(1), copie immédiate pour l'arbre de recherche, + sérialisation triviale, et surtout `make/unmake` sans allocation (clé de la + vitesse, §6). +- **Inconvénient** : la contrainte de liseré est un état séparé à maintenir + (encapsulé 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 +``` + +> **Vérifié** : cette carte est identique, case pour case, à celle utilisée en +> interne par l'arbitre (extraite par réflexion de la classe de jeu du serveur), +> et cohérente avec l'exemple tactique de la figure 6. Point critique : une carte +> divergente aurait produit des coups jugés illégaux. + +### Q2 — Détection de fin de partie +Partie finie dès qu'une licorne disparaît (seul cas, pas de nul). Balayage O(1) +(`gameOver`) ; le moteur détecte la capture au moment où elle est jouée. + +### Q3 — Sources de difficulté et facteur de branchement +Difficultés : contrainte de liseré (mobilité variable), dépendance entre tours +(la case d'arrivée détermine les options adverses), asymétrie du plateau, risque +de blocage / pass forcé. + +**Facteur de branchement.** Borne théorique lâche estimée en partie 1 : ~120. La +mesure réelle (utilitaire `escampe.Branching`, 30 000 parties aléatoires) est bien +plus basse car la contrainte de liseré ne laisse jouables que les pièces du bon +liseré : + +| Situation | Branchement max observé | +|---|---| +| Coup contraint (un liseré imposé) | **45** | +| Coup libre (1er coup ou après pass) | **49** | +| Branchement moyen (toutes positions) | **≈ 8,9** | + +Le branchement effectif modeste explique les profondeurs élevées atteintes par +l'alpha-bêta (§6). + +### Q4 — Coups imparables +Pas d'« imparable » universel garanti dès le départ (la contrainte de liseré peut +toujours bloquer une menace). Mais des configurations créent un **zugzwang +partiel** (exemple figure 6 : C2 prend C1 dès que Noir est forcé d'imposer un +liseré double). Notre recherche les exploite quand ils sont à portée d'horizon. + +### Q5 — Critères pour l'heuristique +Cinq critères identifiés : distance à la licorne adverse, mobilité différentielle, +contrôle du liseré imposé, protection de sa licorne, avancée. Retenu en pratique +(§7) : proximité des paladins à la licorne adverse (attaque) et éloignement des +paladins adverses de notre licorne (défense) — le reste est largement pris en +charge par la recherche. + +### Q6 — Stratégie selon la phase +- **Début (placement)** : irréversible ; protéger la licorne, garantir de toujours + pouvoir jouer (§5). +- **Milieu** : manœuvrer pour menacer la licorne adverse en contrôlant le liseré + imposé ; chercher le zugzwang partiel. +- **Fin** : dès qu'une capture est à portée, le calcul tactique prime. + +### Q7 — Majorant du nombre de coups et gestion du temps +Aucune pièce ne disparaît avant la capture finale : borne raisonnable ~400–600 +demi-coups. Pour tenir les 300 s/joueur : approfondissement itératif, alpha-bêta, +budget par coup dérivé du temps restant (§8). + +--- + +## 3. Modélisation : la classe `EscampeBoard` + +`EscampeBoard` (~860 lignes) implémente `Partie1` (`setFromFile`/`saveToFile`, +`isValidMove`, `possiblesMoves`, `play`, `gameOver`). Conventions de l'arbitre : +coup `"B1-D1"`, placement `"C6/A6/B5/D5/E6/F5"` (licorne en tête), pass `"E"`. + +**Format fichier** : 6 lignes de plateau (bas→haut), `N/n` `B/b` `-`, encadrées +d'un numéro ; lignes `%` = commentaires (où l'on stocke l'état hors-plateau pour +un rechargement fidèle). + +**Génération des coups** : DFS avec retour arrière (exactement N pas, intermédiaires +vides, dernière case vide ou licorne adverse). `possiblesMoves` filtre le bon +liseré et renvoie `["E"]` si bloqué. Une méthode `main` illustre placements, +liseré, pass, round-trip fichier, capture. + +> Bug latent corrigé en partie 3 : un placement légal sur une seule ligne faisait +> planter le calcul du bord de Noir (supposait deux lignes). Le bord est désormais +> déduit de la ligne de la licorne. + +--- + +## 4. Intégration au tournoi : protocole de l'arbitre + +`JoueurPuyaubreauRussac implements IJoueur` enveloppe un `EscampeBoard` tenu à jour +à chaque coup (le nôtre via `play`, l'adverse via `mouvementEnnemi`). Trois +adaptations, dont deux **vérifiées par analyse du jar obfusqué** : + +- **Couleurs** : `IJoueur` en entiers (`NOIR=1`, `BLANC=-1`), `EscampeBoard` en + `"noir"`/`"blanc"`. +- **Pass = `"E"`, pas `"PASSE"`** : le Javadoc d'`IJoueur` dit `"PASSE"`, mais la + classe serveur teste `move.equals("E")` (et `"PASSE"` est absent du jar). + Envoyer `"PASSE"` = défaite sur coup illégal. +- **Carte des liserés** identique au serveur (cf. Q1). + +**Machine à états** : placement et coups passent par le même canal. Premier +`choixMouvement` = placement, suivants = coups ; phase détectée via +`blackPlaced`/`whitePlaced`. Séquence (déduite de `Solo`) : + +``` +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) -> ... +``` + +**Exécution** (3 processus) : +``` +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 + +Constat issu de l'auto-jeu : une licorne mal placée peut se retrouver seule pièce +jouable et bloquée sur le liseré imposé → passes successifs → perte d'initiative. +Trois principes : + +1. **Licorne dans un coin** — un coin n'a que 2 voisines, donc 2 cases d'attaque. +2. **Murs** — on occupe ces 2 voisines par des paladins : licorne incapturable + tant que les murs tiennent. +3. **Couverture des liserés** — les 3 paladins restants sur des liserés 1, 2 et 3 + distincts : jamais de pass forcé, jamais besoin de bouger un mur ou la licorne. + +Dispositions retenues (Blanc joue le bord complémentaire de Noir) : + +``` +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 +``` + +--- + +## 6. Moteur de décision + +Negamax + élagage alpha-bêta + approfondissement itératif (`Moteur`), sur une +**copie** du plateau. Capture de licorne = nœud terminal `WIN - ply` (gagner vite). + +**Astuces de performance :** +- **Coups en entier** (case = `ligne*6+colonne`, coup = `départ*36+arrivée`) : pas + de chaîne dans la boucle chaude. +- **DFS sur masque de bits `long`** (36 cases ⊂ 64 bits) : ensembles visité/ + atteignable en masques, sans allocation par appel. +- **`make`/`unmake` sans allocation** : un petit jeton d'annulation → millions de + nœuds sans pression GC. +- **Buffers de coups pré-alloués**, un par profondeur. +- **Ordonnancement** : capture de licorne essayée en premier ; meilleur coup + d'une itération replacé en tête de la suivante. + +> Cohérence : le chemin « entier » du moteur double le chemin « chaîne » vérifié. +> `VerifMoves` (§9) prouve qu'ils produisent les mêmes coups et états — optimiser +> n'a pas changé les règles. + +**Performance mesurée** : ~4–5 M nœuds/s ; profondeur 12–15 demi-coups en 6 s +(plus dans les positions étroites). Les gains forcés annoncés se concrétisent par +une capture. + +--- + +## 7. Heuristique d'évaluation + +Matériel constant → évaluation purement positionnelle, du point de vue du joueur +au trait, à partir des distances de Manhattan : + +- **Attaque** : proximité de nos paladins à la licorne adverse — terme *somme* + (pression globale) + terme *minimum* (l'attaquant le plus proche pèse plus) ; +- **Défense** : éloignement des paladins adverses de notre licorne — mêmes termes, + signe opposé. + +Avec les poids retenus (somme = 2, minimum = 8) : +``` +eval = 2·Σ(10−d_attaque) − 2·Σ(10−d_défense) + + 8·(10−min d_attaque) − 8·(10−min d_défense) +``` + +**Heuristiques testées et choix** (réglage par auto-jeu déterministe + matchs vs +aléatoire) : (a) somme seule → jeu trop diffus ; (b) **somme + minimum (retenue)** +→ le terme minimum fortement pondéré oriente les paladins vers la licorne adverse +et améliore le taux de capture ; (c) terme défensif symétrique conservé (évite +d'exposer notre licorne). Le fort poids du minimum reflète que c'est l'attaquant +le plus avancé qui décide d'une prise. + +> Limite assumée : poids validés contre l'aléatoire et en auto-jeu, faute +> d'adversaires IA tiers. Les tactiques à court terme sont gérées par la recherche, +> ce qui rend le joueur robuste malgré une évaluation simple. + +--- + +## 8. Gestion du temps réel + +Limite arbitre 300 s/joueur/partie → **enveloppe interne 280 s** (~20 s de marge). +Budget par coup : +``` +tranche = clamp( temps_restant / 12 , 120 ms , 6000 ms ) +``` +La division par le temps restant décroît géométriquement : budget **jamais +épuisable**. Plafond 6 s (pas de surinvestissement en ouverture), plancher 120 ms, +mode « panique » pour les dernières secondes. L'approfondissement itératif rend le +meilleur coup déjà trouvé dès l'expiration de la tranche (temps contrôlé toutes les +2048 explorations de nœuds). + +**Mesures** (auto-jeu équilibré, plein budget) : max ≈ 6,0 s/coup (le plafond), +cumul max ≈ 36 s/joueur sur une partie complète — très loin des 300 s. Réglage +conservateur, augmentable sans risque. + +--- + +## 9. Performances et tests + +| Test | Garantit | Résultat | +|---|---|---| +| `VerifMoves` | chemin entier ≡ chemin chaîne (coups + make/unmake) | 3 000 parties · 142 165 positions · 1 281 985 contrôles · **0 divergence** | +| `RulesTest` | règles directes (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 vs 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 | + +Séparation des rôles : `VerifMoves` (moteur ≡ `EscampeBoard`), `RulesTest` +(`EscampeBoard` ≡ règles), parties arbitrées (dialogue correct avec l'arbitre +réel). Aucun coup illégal sur l'ensemble des parties jouées. + +--- + +## 10. Compilation, exécution et livrables + +`build.sh` produit 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 } +``` + +Seules les classes de production entrent dans le jar ; les utilitaires de test +(`VerifMoves`, `RulesTest`, `Bench`, `Branching`) en sont exclus. Le multijoueur +(humain vs humain, humain vs IA, local ou distant) est documenté dans +`MULTIJOUEUR.md`. + +--- + +## 11. Sources et bibliographie + +- **Énoncé du cours** (Université Paris-Saclay, Polytech APP5, 2025-2026) : règles, + carte des liserés (figure 4), interface `Partie1`, classes fournies (`IJoueur`, + `ClientJeu`, `Solo`, `Applet`, serveur). +- **Algorithmes classiques**, pour inspiration sans copie de code : alpha-bêta + (Knuth & Moore, 1975) ; minimax/negamax/approfondissement itératif (Russell & + Norvig, *AIMA*) ; masques de bits et ordonnancement de coups (*Chess Programming + Wiki*). +- **Déclaration** : aucun programme d'Escampe externe recopié. La seule + rétro-ingénierie porte sur le jar d'arbitre *fourni avec le sujet*, pour confirmer + le protocole (pass `"E"`) et la carte des liserés (documentation ambiguë). + +--- + +## 12. Conclusion et difficultés rencontrées + +Le joueur conduit une partie de façon autonome, dialogue correctement avec +l'arbitre, ne produit jamais de coup illégal et respecte très confortablement la +contrainte de temps. Difficultés principales : + +- **Obfuscation du serveur** : lever l'ambiguïté du pass (`"E"` vs `"PASSE"`) et + confirmer la carte des liserés a nécessité l'analyse du jar — décisif pour ne pas + perdre sur coup illégal. +- **Interface obfusquée vs nos sources** : le joueur aléatoire du jar n'implémente + pas notre `IJoueur` ; les tests contre lui passent par le réseau. +- **Avantage du trait** : en miroir, Blanc garde l'initiative via la contrainte de + liseré — propriété du jeu. +- **Réglage de l'heuristique sans adversaires** : validé contre l'aléatoire et en + auto-jeu. + +**Pistes d'amélioration** : table de transposition (Zobrist), bibliothèque +d'ouvertures de placement, terme de mobilité différentielle, recherche de +quiescence sur les menaces de capture. diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ad5b7c --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Escampe — Joueur IA (Puyaubreau / Russac) + +Joueur artificiel pour le jeu **Escampe**, devoir « IA et contraintes » +(Polytech Paris-Saclay, APP5, 2025-2026). Le joueur dialogue avec l'arbitre du +tournoi via une interface réseau et choisit ses coups par recherche **alpha-bêta ++ approfondissement itératif**. + +## Démarrage rapide + +```bash +bash build.sh # compile, produit le jar, mainClass, l'archive et le rapport PDF +``` + +Tout est produit dans `dist/`. Pour jouer ou tester : + +```bash +# Une partie arbitrée : notre IA contre le joueur aléatoire fourni +bash scripts/match.sh + +# Sous Windows, en local (double-clic) : +jouer-vs-IA.bat # vous (humain) contre notre IA +jouer-vs-pote.bat # deux humains +``` + +Le serveur de jeu et les joueurs de référence sont dans `lib/escampeobf.jar` +(fourni avec le sujet). Voir [MULTIJOUEUR.md](MULTIJOUEUR.md) pour le jeu à distance. + +## Structure + +| Chemin | Rôle | +|--------|------| +| `src/escampe/` | Sources Java (paquetage `escampe`) | +| `src/escampe/JoueurPuyaubreauRussac.java` | Le joueur (interface `IJoueur`) | +| `src/escampe/Moteur.java` | Recherche alpha-bêta + heuristique | +| `src/escampe/EscampeBoard.java` | Modèle de jeu (interface `Partie1`) | +| `src/escampe/{VerifMoves,RulesTest,Bench,Branching}.java` | Utilitaires de test (hors jar de production) | +| `report/rapport.html` · `tools/make_report_pdf.py` | Source du rapport et générateur PDF | +| `RAPPORT.md` | Rapport (version Markdown) | +| `build.sh` | Build reproductible | +| `lib/escampeobf.jar` | Serveur d'arbitre + joueurs de référence (fournis) | +| `dist/` | Livrables buildés (jar, `mainClass`, archive, rapport PDF) | + +## Livrables de la version finale (dans `dist/`) + +- `Puyaubreau_Russac.jar` — jar exécutable (point d'entrée `escampe.ClientJeu`) +- `mainClass` — descripteur du tournoi (jar / clientClass / mainClass) +- `Puyaubreau_Russac.tgz` — archive de rendu (`src/` + `mainClass` + jar) +- `Puyaubreau_Russac_rapport.pdf` — rapport + +## Tests + +```bash +javac -d out src/escampe/*.java +java -cp out escampe.VerifMoves # chemin de recherche ≡ règles vérifiées (0 divergence) +java -cp out escampe.RulesTest # tests de règles (21/21) +java -cp out escampe.Bench 3000 8 # profondeur / vitesse du moteur +java -cp out escampe.Branching # facteur de branchement +``` diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..9de5a99 --- /dev/null +++ b/build.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Build reproductible du joueur Escampe (partie 3). +# Produit dans dist/ : +# - Puyaubreau_Russac.jar : jar exécutable (point d'entrée escampe.ClientJeu) +# - mainClass : descripteur attendu par le tournoi +# - Puyaubreau_Russac.tgz : archive de rendu (Puyaubreau_Russac/src + mainClass + jar) +# - Puyaubreau_Russac_rapport.pdf : rapport (via PyMuPDF ; sauter avec --no-report) +set -e +ROOT="$(cd "$(dirname "$0")" && pwd)"; cd "$ROOT" +WITH_REPORT=1; [ "${1:-}" = "--no-report" ] && WITH_REPORT=0 + +NAME="Puyaubreau_Russac" +JAR="$NAME.jar" +PLAYER="escampe.JoueurPuyaubreauRussac" +CLIENT="escampe.ClientJeu" + +# Classes de PRODUCTION (on exclut les utilitaires de test : VerifMoves, RulesTest, Bench). +RUNTIME="IJoueur ClientJeu Solo Applet Partie1 EscampeBoard Moteur JoueurPuyaubreauRussac" + +echo "[1/4] Compilation des classes de production…" +rm -rf build dist +mkdir -p build dist +SRCS="" +for c in $RUNTIME; do SRCS="$SRCS src/escampe/$c.java"; done +javac -d build $SRCS + +echo "[2/4] Création du jar exécutable ($JAR)…" +jar --create --file "dist/$JAR" --main-class "$CLIENT" -C build escampe + +echo "[3/4] Écriture du fichier mainClass…" +printf 'jar:%s\nclientClass:%s\nmainClass:%s\n' "$JAR" "$CLIENT" "$PLAYER" > dist/mainClass + +echo "[4/4] Assemblage de l'archive de rendu…" +rm -rf "dist/$NAME" +mkdir -p "dist/$NAME/src/escampe" +cp src/escampe/*.java "dist/$NAME/src/escampe/" +cp dist/mainClass "dist/$NAME/mainClass" +cp "dist/$JAR" "dist/$NAME/$JAR" +( cd dist && tar czf "$NAME.tgz" "$NAME" ) + +REPORT="" +if [ "$WITH_REPORT" = "1" ]; then + echo "[5/5] Génération du rapport PDF…" + if python tools/make_report_pdf.py >/dev/null 2>&1; then + REPORT=" dist/${NAME}_rapport.pdf" + else + echo " (PDF non généré : PyMuPDF indisponible — relancer 'python tools/make_report_pdf.py')" + fi +fi + +echo "----------------------------------------------------" +echo "OK :" +echo " dist/$JAR" +echo " dist/mainClass" +echo " dist/$NAME.tgz" +[ -n "$REPORT" ] && echo "$REPORT" +echo "Lancement tournoi (rappel) :" +echo " java -cp $JAR $CLIENT $PLAYER " diff --git a/dist/Puyaubreau_Russac.jar b/dist/Puyaubreau_Russac.jar new file mode 100644 index 0000000..ecce872 Binary files /dev/null and b/dist/Puyaubreau_Russac.jar differ diff --git a/dist/Puyaubreau_Russac.tgz b/dist/Puyaubreau_Russac.tgz new file mode 100644 index 0000000..8fcef60 Binary files /dev/null and b/dist/Puyaubreau_Russac.tgz differ diff --git a/dist/Puyaubreau_Russac/Puyaubreau_Russac.jar b/dist/Puyaubreau_Russac/Puyaubreau_Russac.jar new file mode 100644 index 0000000..ecce872 Binary files /dev/null and b/dist/Puyaubreau_Russac/Puyaubreau_Russac.jar differ diff --git a/dist/Puyaubreau_Russac/mainClass b/dist/Puyaubreau_Russac/mainClass new file mode 100644 index 0000000..b03f3f0 --- /dev/null +++ b/dist/Puyaubreau_Russac/mainClass @@ -0,0 +1,3 @@ +jar:Puyaubreau_Russac.jar +clientClass:escampe.ClientJeu +mainClass:escampe.JoueurPuyaubreauRussac diff --git a/dist/Puyaubreau_Russac/src/escampe/Applet.java b/dist/Puyaubreau_Russac/src/escampe/Applet.java new file mode 100644 index 0000000..e5269b6 --- /dev/null +++ b/dist/Puyaubreau_Russac/src/escampe/Applet.java @@ -0,0 +1,298 @@ +package escampe; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Container; +import java.awt.Dimension; +import java.awt.Frame; +import java.awt.Graphics; +import java.awt.Insets; +import java.awt.event.KeyEvent; +import java.awt.event.MouseEvent; + +import javax.swing.DefaultListModel; +import javax.swing.JApplet; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.ListSelectionModel; + +public class Applet extends JApplet { + // Constantes pour les pièces + final private static int LICORNEBLANCHE = -2; + final private static int PALADINBLANC = -1; + final private static int LICORNENOIRE = 2; + final private static int PALADINNOIR = 1; + final private static int VIDE = 0; + + // Constantes pour le plateau + final private static int LARGEUR = 6; + final private static int HAUTEUR = 6; + final private static int[][] lisereCase = { + {1, 2, 2, 3, 1, 2}, + + {3, 1, 3, 1, 3, 2}, + + {2, 3, 1, 2, 1, 3}, + + {2, 1, 3, 2, 3, 1}, + + {1, 3, 1, 3, 1, 2}, + + {3, 2, 2, 1, 3, 2} + }; + + // Constantes pour les couleurs + Color DARK = new Color(155, 102, 95); + Color LIGHT = new Color(239, 210, 158); + Color BLACK = new Color(255, 255, 255); + Color WHITE = new Color(0, 0, 0); + Color HIGHLIGHT = new Color(255, 0, 0); + + // Constantes pour l'affichage + final private static int TAILLECASE = 100; + final private static int TAILLEPION = 60; + final private static Dimension FRAMEDIMENSION = new Dimension(TAILLECASE*6 + 260,TAILLECASE*6 + 60); + + private static final long serialVersionUID = 1L; + private JList brdList; + private Board displayBoard; + private JScrollPane scrollPane; + private DefaultListModel listModel; + private Frame myFrame; + + static int cpt = 0; + + // Autres constantes utiles pour l'affichage du plateau d'Escampe + int mpiece = (int) (TAILLECASE - TAILLEPION)/2; + + int epaisseurCercle = (int) (TAILLECASE*0.1); + int epaisseurInterCercle = (int) (TAILLECASE*0.05); + + int diametre1e = TAILLECASE; // extérieur 1er cercle + int diametre1i = diametre1e - epaisseurCercle; // intérieur 1er cercle + int diametre2e = diametre1i - epaisseurInterCercle; // extérieur 2eme cercle + int diametre2i = diametre2e - epaisseurCercle; // intérieur 2eme cercle + int diametre3e = diametre2i - epaisseurInterCercle; // extérieur 3eme cercle + int diametre3i = diametre3e - epaisseurCercle; // intérieur 3eme cercle + + int m1e = 0; + int m1i = (int) (TAILLECASE - diametre1i)/2; + int m2e = (int) (TAILLECASE - diametre2e)/2; + int m2i = (int) (TAILLECASE - diametre2i)/2; + int m3e = (int) (TAILLECASE - diametre3e)/2; + int m3i = (int) (TAILLECASE - diametre3i)/2; + + public void init() { + System.out.println("Initialisation BoardApplet" + cpt++); + buildUI(getContentPane()); + } + + public void buildUI(Container container) { + setBackground(Color.white); + + int[][] temp = new int[HAUTEUR][LARGEUR]; + + for (int i = 0; i < HAUTEUR; i++) + for (int j = 0; j < LARGEUR; j++) + temp[i][j] = VIDE; + + displayBoard = new Board("Coups :", temp); + + listModel = new DefaultListModel(); + listModel.addElement(displayBoard); + + brdList = new JList(listModel); + brdList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + brdList.setSelectedIndex(0); + scrollPane = new JScrollPane(brdList); + Dimension d = scrollPane.getSize(); + scrollPane.setPreferredSize(new Dimension(200, d.height)); + + brdList.addKeyListener(new java.awt.event.KeyAdapter() { + public void keyPressed(KeyEvent e) { + brdList_keyPressed(e); + } + }); + brdList.addMouseListener(new java.awt.event.MouseAdapter() { + public void mouseClicked(MouseEvent e) { + brdList_mouseClicked(e); + } + }); + container.add(displayBoard, BorderLayout.CENTER); + container.add(scrollPane, BorderLayout.EAST); + } + + public void update(Graphics g, Insets in) { + Insets tempIn = in; + g.translate(tempIn.left, tempIn.top); + paint(g); + } + + public void paint(Graphics g) { + displayBoard.paint(g); + } + + public void addBoard(String move, int[][] board) { + Board tempEntrop = new Board(move, board); + listModel.addElement(new Board(move, board)); + brdList.setSelectedIndex(listModel.getSize() - 1); + brdList.ensureIndexIsVisible(listModel.getSize() - 1); + displayBoard = tempEntrop; + update(myFrame.getGraphics(), myFrame.getInsets()); + } + + public void setMyFrame(Frame f) { + myFrame = f; + } + + void brdList_keyPressed(KeyEvent e) { + int index = brdList.getSelectedIndex(); + if (e.getKeyCode() == KeyEvent.VK_UP && index > 0) + displayBoard = (Board) listModel.getElementAt(index - 1); + + if (e.getKeyCode() == KeyEvent.VK_DOWN && index < (listModel.getSize() - 1)) + displayBoard = (Board) listModel.getElementAt(index + 1); + + update(myFrame.getGraphics(), myFrame.getInsets()); + } + + void brdList_mouseClicked(MouseEvent e) { + displayBoard = (Board) listModel.getElementAt(brdList.getSelectedIndex()); + update(myFrame.getGraphics(), myFrame.getInsets()); + } + + public Dimension getDimension() { + return FRAMEDIMENSION; + } + + // Sous classe qui dessine le plateau de jeu + class Board extends JPanel { + + private static final long serialVersionUID = 1L; + private int[][] boardState; + String move; + int depCol = -1; + int depLin = -1; + int arvCol = -1; + int arvLin = -1; + + // The string will be the move details + // and the array the details of the board after the move has been applied. + public Board(String mv, int[][] bs) { + boardState = bs; + move = mv; + if (mv.length() == 5) { + String[] positions = mv.split("-"); + depCol = (int) positions[0].charAt(0) - (int) 'A'; + depLin = Integer.parseInt(positions[0].substring(1)) - 1; + arvCol = (int) positions[1].charAt(0) - (int) 'A'; + arvLin = Integer.parseInt(positions[1].substring(1)) - 1; + } + } + + public void drawBoard(Graphics g) { + // First draw the lines + // Board + int bx = 30; + int by = 30; + + // axis labels + g.setColor(new Color(0, 0, 0)); + for (int i = 1; i <= LARGEUR; i++) { + g.drawString("" + (char) ('A' + i - 1), bx + (int) ((i - 0.5)*TAILLECASE), 20); + } + for (int i = 1; i <= HAUTEUR; i++) { + g.drawString("" + i, 10, by + (int) ((i - 0.5)*TAILLECASE)); + } + + // Draw the circles + Color c1 = DARK; + Color c2 = LIGHT; + + int casex; + int casey; + int lisere; + + // fond des cases + g.setColor(c1); + g.fillRect(bx, by, LARGEUR*TAILLECASE, HAUTEUR*TAILLECASE); + + for (int j = 0; j < LARGEUR; j++) { + for (int i = 0; i < HAUTEUR; i++) { + casex = bx + j*TAILLECASE; + casey = by + i*TAILLECASE; + lisere = lisereCase[i][j]; + c2 = (i == depLin && j == depCol) ? HIGHLIGHT : LIGHT; + + // 1er cercle + g.setColor(c2); + g.fillOval(casex + m1e, casey + m1e , diametre1e, diametre1e); + g.setColor(c1); + g.fillOval(casex + m1i, casey + m1i, diametre1i, diametre1i); + if (lisere > 1) { + // 2eme cercle + g.setColor(c2); + g.fillOval(casex + m2e, casey + m2e, diametre2e, diametre2e); + g.setColor(c1); + g.fillOval(casex + m2i, casey + m2i, diametre2i, diametre2i); + if (lisere > 2) { + // 3eme cercle + g.setColor(c2); + g.fillOval(casex + m3e, casey + m3e, diametre3e, diametre3e); + g.setColor(c1); + g.fillOval(casex + m3i, casey + m3i, diametre3i, diametre3i); + } + } + } + } + + // Draw the pieces by referencing boardState array + c1 = BLACK; + c2 = WHITE; + + for (int j = 0; j < LARGEUR; j++) { + for (int i = 0; i < HAUTEUR; i++) { + casex = mpiece + bx + j*TAILLECASE; + casey = mpiece + by + i*TAILLECASE; + + switch (boardState[i][j]) { + case (LICORNEBLANCHE): + g.setColor(c1); + g.fillRect(casex, casey, TAILLEPION, TAILLEPION); + break; + case (PALADINBLANC): + g.setColor(c1); + g.fillOval(casex, casey, TAILLEPION, TAILLEPION); + break; + case (LICORNENOIRE): + g.setColor(c2); + g.fillRect(casex, casey, TAILLEPION, TAILLEPION); + break; + case (PALADINNOIR): + g.setColor(c2); + g.fillOval(casex, casey, TAILLEPION, TAILLEPION); + break; + } + + if (i == arvLin && j == arvCol) { + g.setColor(HIGHLIGHT); + g.fillOval(casex + 20, casey + 20, TAILLEPION - 40, TAILLEPION - 40); + } + } + } + } + + public void paint(Graphics g) { + drawBoard(g); + } + + public void update(Graphics g) { + drawBoard(g); + } + + public String toString() { + return move; + } + } +} diff --git a/dist/Puyaubreau_Russac/src/escampe/Bench.java b/dist/Puyaubreau_Russac/src/escampe/Bench.java new file mode 100644 index 0000000..c18fa58 --- /dev/null +++ b/dist/Puyaubreau_Russac/src/escampe/Bench.java @@ -0,0 +1,30 @@ +package escampe; + +/** + * Banc d'essai du moteur : joue quelques coups depuis l'ouverture et affiche + * profondeur, score, nœuds et vitesse. java -cp out escampe.Bench [msParCoup] [nbCoups] + */ +public class Bench { + public static void main(String[] args) { + long budget = args.length > 0 ? Long.parseLong(args[0]) : 3000; + int coups = args.length > 1 ? Integer.parseInt(args[1]) : 8; + + EscampeBoard b = new EscampeBoard(); + b.play("C1/A1/E1/B2/C2/D2", "noir"); + b.play("C6/A6/E6/B5/C5/D5", "blanc"); + + Moteur mo = new Moteur(); + boolean black = false; // Blanc joue en premier après les placements + for (int i = 0; i < coups && !b.gameOver(); i++) { + long t0 = System.currentTimeMillis(); + int m = mo.bestMove(b, black, budget); + long dt = System.currentTimeMillis() - t0; + System.out.printf("coup %d (%s) : %-6s prof=%2d score=%7d noeuds=%9d %5dms %6.0f kN/s%n", + i, black ? "noir" : "blanc", b.moveToString(m), + mo.reachedDepth, mo.lastScore, mo.nodes, dt, mo.nodes / (dt + 1.0)); + b.play(b.moveToString(m), black ? "noir" : "blanc"); + black = !black; + } + System.out.println(b.gameOver() ? "Partie terminée (capture)." : "Fin du banc."); + } +} diff --git a/dist/Puyaubreau_Russac/src/escampe/Branching.java b/dist/Puyaubreau_Russac/src/escampe/Branching.java new file mode 100644 index 0000000..8e95811 --- /dev/null +++ b/dist/Puyaubreau_Russac/src/escampe/Branching.java @@ -0,0 +1,58 @@ +package escampe; + +import java.util.*; + +/** + * Mesure empirique du facteur de branchement (question Q3 du rapport) : explore + * des parties aléatoires et relève le nombre maximal de coups légaux rencontré, + * en distinguant le cas contraint (un liseré imposé) du cas libre (1er coup ou + * après un pass, lastTileType = -1). java -cp out escampe.Branching [parties] + */ +public class Branching { + public static void main(String[] args) { + int games = args.length > 0 ? Integer.parseInt(args[0]) : 20000; + Random rng = new Random(1L); + + int maxConstrained = 0, maxFree = 0; + long sum = 0, count = 0; + + for (int g = 0; g < games; g++) { + EscampeBoard b = new EscampeBoard(); + int[] nr = rng.nextBoolean() ? new int[]{0, 1} : new int[]{4, 5}; + b.play(rndPlace(b, "noir", nr, rng), "noir"); + int[] wr = nr[0] == 0 ? new int[]{4, 5} : new int[]{0, 1}; + b.play(rndPlace(b, "blanc", wr, rng), "blanc"); + + for (int ply = 0; ply < 120 && !b.gameOver(); ply++) { + String side = b.currentPlayer; + String[] mv = b.possiblesMoves(side); + int n = (mv.length == 1 && mv[0].equals("E")) ? 0 : mv.length; + if (b.lastTileType == -1) maxFree = Math.max(maxFree, n); + else maxConstrained = Math.max(maxConstrained, n); + sum += n; count++; + + if (n == 0) { b.play("E", side); } + else { b.play(mv[rng.nextInt(mv.length)], side); } + } + } + System.out.println("Parties simulées : " + games); + System.out.println("Branchement max CONTRAINT : " + maxConstrained + " (un liseré imposé)"); + System.out.println("Branchement max LIBRE : " + maxFree + " (1er coup / après pass)"); + System.out.printf ("Branchement moyen : %.1f%n", (double) sum / count); + } + + static String rndPlace(EscampeBoard b, String pl, int[] rows, Random rng) { + List cells = new ArrayList<>(); + for (int r : rows) for (int c = 0; c < 6; c++) cells.add(new int[]{r, c}); + for (int t = 0; t < 50; t++) { + Collections.shuffle(cells, rng); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 6; i++) { + if (i > 0) sb.append('/'); + sb.append((char) ('A' + cells.get(i)[1])).append((char) ('1' + cells.get(i)[0])); + } + if (b.isValidMove(sb.toString(), pl)) return sb.toString(); + } + throw new IllegalStateException("placement"); + } +} diff --git a/dist/Puyaubreau_Russac/src/escampe/ClientJeu.java b/dist/Puyaubreau_Russac/src/escampe/ClientJeu.java new file mode 100644 index 0000000..2c9f4c7 --- /dev/null +++ b/dist/Puyaubreau_Russac/src/escampe/ClientJeu.java @@ -0,0 +1,151 @@ +package escampe; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.Socket; +import java.util.StringTokenizer; + +/** + * Cette classe permet de charger dynamiquement une classe de joueur, qui doit obligatoirement + * implanter l'interface IJoueur. Vous lui donnez aussi en argument le nom de la machine distante + * (ou "localhost") sur laquelle le serveur de jeu est lancé, ainsi que le port sur lequel la + * machine écoute. + * + * Exemple: >java -cp . frontieres.ClientJeu frontieres.joueurProf localhost 1234 + * + * Le client s'occupe alors de tout en lançant les méthodes implantées de l'interface IJoueur. Toute + * la gestion réseau est donc cachée. + * + * @author L. Simon (Univ. Paris-Sud)- 2006-2008 + * @see IJoueur + */ +public class ClientJeu { + + // Mais pas lors de la conversation avec l'arbitre + // Vous pouvez changer cela en interne si vous le souhaitez + static final int BLANC = -1; + static final int NOIR = 1; + static final int VIDE = 0; + + /** + * @param args + * Dans l'ordre : NomClasseJoueur MachineServeur PortEcoute + */ + public static void main(String[] args) { + + if (args.length < 3) { + System.err.println("ClientJeu Usage: NomClasseJoueur MachineServeur PortEcoute"); + System.exit(1); + } + + // Le nom de la classe joueur à charger dynamiquement + String classeJoueur = args[0]; + // Le nom de la machine serveur a été donné en ligne de commande + String serverMachine = args[1]; + // Le numéro du port sur lequel on se connecte a aussi été donné + int portNum = Integer.parseInt(args[2]); + + System.out.println("Le client se connectera sur " + serverMachine + ":" + portNum); + + Socket clientSocket = null; + IJoueur joueur; + String msg, firstToken; + // permet d'analyser les chaînes de caractères lues + StringTokenizer msgTokenizer; + // C'est la couleur qui doit jouer le prochain coup + int couleurAJouer; + // C'est ma couleur (quand je joue) + int maCouleur; + + boolean jeuTermine = false; + + try { + // initialise la socket + clientSocket = new Socket(serverMachine, portNum); + PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true); + BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); + + // ***************************************************** + System.out.print("Chargement de la classe joueur " + classeJoueur + "... "); + Class cjoueur = Class.forName(classeJoueur); + joueur = (IJoueur) cjoueur.newInstance(); + System.out.println("Ok"); + // **************************************************** + + // Envoie de l'identifiant de votre quadrinome. + out.println(joueur.binoName()); + System.out.println("Mon nom de quadrinome envoyé est " + joueur.binoName()); + + // Récupère le message sous forme de chaine de caractères + msg = in.readLine(); + System.out.println(msg); + + // Lit le contenu du message, toutes les infos du message + msgTokenizer = new StringTokenizer(msg, " \n\0"); + if ((msgTokenizer.nextToken()).equals("Blanc")) { + System.out.println("Je suis Blanc, j'attends le mouvement de Noir."); + maCouleur = BLANC; + } + else { // doit etre égal à "Noir" + System.out.println("Je suis Noir, c'est à moi de jouer."); + maCouleur = NOIR; + } + + // permet d'initialiser votre joueur avec sa couleur + joueur.initJoueur(maCouleur); + + // boucle générale de jeu + do { + // Lire le msg à partir du serveur + msg = in.readLine(); + + msgTokenizer = new StringTokenizer(msg, " \n\0"); + firstToken = msgTokenizer.nextToken(); + + if (firstToken.equals("FIN!")) { + jeuTermine = true; + String theWinnerIs = msgTokenizer.nextToken(); + + if (theWinnerIs.equals("Blanc")) { + couleurAJouer = BLANC; + } + else { + if (theWinnerIs.equals("Noir")) + couleurAJouer = NOIR; + else + couleurAJouer = VIDE; + } + + if (couleurAJouer == maCouleur) + System.out.println("J'ai gagné!"); + + joueur.declareLeVainqueur(couleurAJouer); + } + else if (firstToken.equals("JOUEUR")) { + // On demande au joueur de jouer + if ((msgTokenizer.nextToken()).equals("Blanc")) { + couleurAJouer = BLANC; + } + else { + couleurAJouer = NOIR; + } + + if (couleurAJouer == maCouleur) { + // On appelle la classe du joueur pour choisir un mouvement + msg = joueur.choixMouvement(); + out.println(msg); + } + } + else if (firstToken.equals("MOUVEMENT")) { + // On lit ce que joue le joueur et on l'envoie à l'autre + joueur.mouvementEnnemi(msgTokenizer.nextToken()); + } + } while (!jeuTermine); + + } + catch (Exception e) { + System.out.println(e); + } + } +} diff --git a/dist/Puyaubreau_Russac/src/escampe/EscampeBoard.java b/dist/Puyaubreau_Russac/src/escampe/EscampeBoard.java new file mode 100644 index 0000000..b6b807d --- /dev/null +++ b/dist/Puyaubreau_Russac/src/escampe/EscampeBoard.java @@ -0,0 +1,862 @@ +package escampe; + +import java.io.*; +import java.util.*; + +/** + * Représentation d'un état du jeu Escampe. + * + *

Le plateau est un tableau {@code int[6][6]} : + *

    + *
  • {@code board[row][col]} avec row 0 = ligne 1 (bas), row 5 = ligne 6 (haut).
  • + *
  • col 0 = colonne A, col 5 = colonne F.
  • + *
+ * + *

Chaque case stocke l'une des constantes pièce : + * {@code EMPTY}, {@code WHITE_LICORNE}, {@code WHITE_PALADIN}, + * {@code BLACK_LICORNE}, {@code BLACK_PALADIN}. + * + *

L'état complémentaire mémorisé : + *

    + *
  • {@code lastTileType} : type de liseré (1, 2 ou 3) de la case d'arrivée du dernier coup ; + * -1 = pas de contrainte (premier coup ou après un pass).
  • + *
  • {@code currentPlayer} : "noir" ou "blanc", joueur dont c'est le tour.
  • + *
  • {@code blackPlaced}, {@code whitePlaced} : phases de placement terminées.
  • + *
  • {@code blackRows} : les deux lignes (index 0-5) choisies par noir lors du placement.
  • + *
+ * + *

Règles de déplacement : + *

    + *
  • Une pièce avance exactement N pas orthogonaux (N = liseré de la case de départ).
  • + *
  • Elle peut changer de direction à chaque pas.
  • + *
  • Elle ne peut pas passer par une case occupée ni repasser deux fois par la même case.
  • + *
  • Au dernier pas uniquement, elle peut se poser sur la licorne adverse (capture).
  • + *
+ */ +public class EscampeBoard implements Partie1 { + + // ── Constantes pièces ──────────────────────────────────────────────────── + + static final int EMPTY = 0; + static final int WHITE_LICORNE = 1; + static final int WHITE_PALADIN = 2; + static final int BLACK_LICORNE = 3; + static final int BLACK_PALADIN = 4; + + /** + * Carte des liserés : {@code TILE_MAP[row][col]}. + * row 0 = ligne 1 (bas), row 5 = ligne 6 (haut). col 0 = A, col 5 = F. + */ + static final int[][] TILE_MAP = { + {1, 2, 2, 3, 1, 2}, // ligne 1 + {3, 1, 3, 1, 3, 2}, // ligne 2 + {2, 3, 1, 2, 1, 3}, // ligne 3 + {2, 1, 3, 2, 3, 1}, // ligne 4 + {1, 3, 1, 3, 1, 2}, // ligne 5 + {3, 2, 2, 1, 3, 2}, // ligne 6 + }; + + // ── État ───────────────────────────────────────────────────────────────── + + int[][] board; + int lastTileType; // -1 = pas de contrainte + String currentPlayer; // "noir" ou "blanc" + boolean blackPlaced; + boolean whitePlaced; + int[] blackRows; // les 2 lignes (0-indexé) choisies par noir + + // ── Constructeur ───────────────────────────────────────────────────────── + + public EscampeBoard() { + board = new int[6][6]; + lastTileType = -1; + currentPlayer = "noir"; + blackPlaced = false; + whitePlaced = false; + blackRows = null; + } + + // ========================================================================= + // Fichier I/O + // ========================================================================= + + @Override + public void setFromFile(String fileName) { + board = new int[6][6]; + lastTileType = -1; + currentPlayer = "noir"; + blackPlaced = false; + whitePlaced = false; + blackRows = null; + + try (BufferedReader br = new BufferedReader(new FileReader(fileName))) { + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + + char first = line.charAt(0); + + // Commentaire / méta-donnée + if (first == '%') { + parseMeta(line); + continue; + } + + // Ligne de plateau : "1 XXXX 1" ou "01 XXXX 01" + int rowNum = -1; + int pos = 0; + if (first >= '1' && first <= '6') { + rowNum = first - '0'; + pos = 1; + } else if (first == '0' && line.length() > 1) { + char second = line.charAt(1); + if (second >= '1' && second <= '6') { + rowNum = second - '0'; + pos = 2; + } + } + if (rowNum != -1) { + int rowIdx = rowNum - 1; + while (pos < line.length() && line.charAt(pos) == ' ') pos++; + for (int c = 0; c < 6 && pos + c < line.length(); c++) { + board[rowIdx][c] = charToPiece(line.charAt(pos + c)); + } + } + } + } catch (IOException e) { + throw new RuntimeException("Erreur de lecture du fichier : " + fileName, e); + } + + // Si pas de méta-commentaires, on infère l'état à partir des pièces + inferState(); + } + + /** Parse une ligne de méta-commentaire "% clé: valeur". */ + private void parseMeta(String line) { + if (line.startsWith("% lastTileType:")) { + lastTileType = Integer.parseInt(line.substring(15).trim()); + } else if (line.startsWith("% currentPlayer:")) { + currentPlayer = line.substring(16).trim(); + } else if (line.startsWith("% blackPlaced:")) { + blackPlaced = Boolean.parseBoolean(line.substring(14).trim()); + } else if (line.startsWith("% whitePlaced:")) { + whitePlaced = Boolean.parseBoolean(line.substring(14).trim()); + } else if (line.startsWith("% blackRows:")) { + String s = line.substring(12).trim(); + String[] parts = s.split(","); + int r0 = Integer.parseInt(parts[0].trim()); + int r1 = Integer.parseInt(parts[1].trim()); + if (r0 >= 0) blackRows = new int[]{r0, r1}; + } + } + + /** + * Infère {@code blackPlaced}, {@code whitePlaced} et {@code blackRows} + * à partir des pièces présentes sur le plateau + * (utilisé quand le fichier ne contient pas de méta-commentaires). + */ + private void inferState() { + if (blackPlaced && whitePlaced) return; // méta déjà chargée + + int bc = 0, wc = 0; + Set bRowSet = new TreeSet<>(); + for (int r = 0; r < 6; r++) { + for (int c = 0; c < 6; c++) { + int p = board[r][c]; + if (p == BLACK_LICORNE || p == BLACK_PALADIN) { bc++; bRowSet.add(r); } + if (p == WHITE_LICORNE || p == WHITE_PALADIN) { wc++; } + } + } + if (!blackPlaced && bc == 6) { + blackPlaced = true; + // Bord de noir déduit d'une ligne occupée (robuste à 1 seule ligne). + int anyRow = bRowSet.iterator().next(); + blackRows = (anyRow <= 1) ? new int[]{0, 1} : new int[]{4, 5}; + } + if (!whitePlaced && wc == 6) { + whitePlaced = true; + } + } + + @Override + public void saveToFile(String fileName) { + try (PrintWriter pw = new PrintWriter(new FileWriter(fileName))) { + pw.println("% Escampe - sauvegarde du plateau"); + pw.println("% lastTileType: " + lastTileType); + pw.println("% currentPlayer: " + currentPlayer); + pw.println("% blackPlaced: " + blackPlaced); + pw.println("% whitePlaced: " + whitePlaced); + if (blackRows != null) { + pw.println("% blackRows: " + blackRows[0] + "," + blackRows[1]); + } else { + pw.println("% blackRows: -1,-1"); + } + // Lignes 6 à 1 (haut vers bas dans le fichier) + for (int rowIdx = 5; rowIdx >= 0; rowIdx--) { + int rowNum = rowIdx + 1; + StringBuilder sb = new StringBuilder(); + String rowLabel = String.format("%02d", rowNum); + sb.append(rowLabel).append(' '); + for (int c = 0; c < 6; c++) sb.append(pieceToChar(board[rowIdx][c])); + sb.append(' ').append(rowLabel); + pw.println(sb.toString()); + } + } catch (IOException e) { + throw new RuntimeException("Erreur d'écriture du fichier : " + fileName, e); + } + } + + // ========================================================================= + // Fin de partie + // ========================================================================= + + @Override + public boolean gameOver() { + if (!blackPlaced || !whitePlaced) return false; + boolean wl = false, bl = false; + for (int r = 0; r < 6; r++) + for (int c = 0; c < 6; c++) { + if (board[r][c] == WHITE_LICORNE) wl = true; + if (board[r][c] == BLACK_LICORNE) bl = true; + } + return !wl || !bl; + } + + // ========================================================================= + // Validation d'un coup + // ========================================================================= + + @Override + public boolean isValidMove(String move, String player) { + if (move == null || move.isEmpty()) return false; + if (!"noir".equals(player) && !"blanc".equals(player)) return false; + + if (move.contains("/")) return isValidPlacement(move, player); + if ("E".equals(move)) return isValidPass(player); + return isValidRegularMove(move, player); + } + + /** + * Valide un coup de placement "P1/P2/P3/P4/P5/P6" + * (P1 = licorne, P2-P6 = paladins). + */ + private boolean isValidPlacement(String move, String player) { + if ("noir".equals(player) && blackPlaced) return false; + if ("blanc".equals(player) && whitePlaced) return false; + if (!player.equals(currentPlayer)) return false; + if ("blanc".equals(player) && !blackPlaced) return false; + + String[] parts = move.split("/"); + if (parts.length != 6) return false; + + int[][] pos = new int[6][2]; + for (int i = 0; i < 6; i++) { + int[] cell = cellFromString(parts[i]); + if (cell == null) return false; + pos[i] = cell; + } + + // Zone autorisée + if ("noir".equals(player)) { + boolean allLow = true, allHigh = true; + for (int[] p : pos) { + if (p[0] != 0 && p[0] != 1) allLow = false; + if (p[0] != 4 && p[0] != 5) allHigh = false; + } + if (!allLow && !allHigh) return false; + } else { + if (blackRows == null) return false; + int[] wr = complementaryRows(blackRows); + for (int[] p : pos) { + if (p[0] != wr[0] && p[0] != wr[1]) return false; + } + } + + // Pas de doublons, cases vides + Set seen = new HashSet<>(); + for (int[] p : pos) { + if (!seen.add(p[0] + "," + p[1])) return false; + if (board[p[0]][p[1]] != EMPTY) return false; + } + return true; + } + + /** Valide un pass "E" : uniquement si aucun coup régulier n'est disponible. */ + private boolean isValidPass(String player) { + if (!player.equals(currentPlayer)) return false; + if (!blackPlaced || !whitePlaced) return false; + if (gameOver()) return false; + String[] m = possiblesMoves(player); + return m.length == 1 && "E".equals(m[0]); + } + + /** Valide un coup régulier "XX-YY". */ + private boolean isValidRegularMove(String move, String player) { + if (!blackPlaced || !whitePlaced) return false; + if (gameOver()) return false; + if (!player.equals(currentPlayer)) return false; + + int dash = move.indexOf('-'); + if (dash < 1 || dash >= move.length() - 1) return false; + + int[] from = cellFromString(move.substring(0, dash)); + int[] to = cellFromString(move.substring(dash + 1)); + if (from == null || to == null) return false; + + if (!belongsToPlayer(board[from[0]][from[1]], player)) return false; + if (lastTileType != -1 && TILE_MAP[from[0]][from[1]] != lastTileType) return false; + + return getReachableSquares(from[0], from[1], player).contains(to[0] + "," + to[1]); + } + + // ========================================================================= + // Génération de coups + // ========================================================================= + + @Override + public String[] possiblesMoves(String player) { + // Pendant le placement le nombre de combinaisons est trop grand pour être énuméré + if (!blackPlaced || !whitePlaced) return new String[0]; + if (gameOver()) return new String[0]; + + List moves = new ArrayList<>(); + for (int r = 0; r < 6; r++) { + for (int c = 0; c < 6; c++) { + if (!belongsToPlayer(board[r][c], player)) continue; + if (lastTileType != -1 && TILE_MAP[r][c] != lastTileType) continue; + + for (String dest : getReachableSquares(r, c, player)) { + String[] d = dest.split(","); + moves.add(stringFromCell(r, c) + "-" + + stringFromCell(Integer.parseInt(d[0]), Integer.parseInt(d[1]))); + } + } + } + if (moves.isEmpty()) return new String[]{"E"}; + return moves.toArray(new String[0]); + } + + // ========================================================================= + // Jouer un coup + // ========================================================================= + + @Override + public void play(String move, String player) { + if (!isValidMove(move, player)) + throw new IllegalArgumentException("Coup invalide : '" + move + "' pour " + player); + + if (move.contains("/")) { + playPlacement(move, player); + } else if ("E".equals(move)) { + // Pass : supprime la contrainte de liseré (règle officielle) + lastTileType = -1; + currentPlayer = opponent(currentPlayer); + } else { + playRegular(move, player); + } + } + + private void playPlacement(String move, String player) { + String[] parts = move.split("/"); + int[][] pos = new int[6][2]; + for (int i = 0; i < 6; i++) pos[i] = cellFromString(parts[i]); + + int licorne = "noir".equals(player) ? BLACK_LICORNE : WHITE_LICORNE; + int paladin = "noir".equals(player) ? BLACK_PALADIN : WHITE_PALADIN; + + board[pos[0][0]][pos[0][1]] = licorne; + for (int i = 1; i < 6; i++) board[pos[i][0]][pos[i][1]] = paladin; + + if ("noir".equals(player)) { + blackPlaced = true; + // Bord de noir (bas {0,1} ou haut {4,5}), déduit de la ligne de la licorne. + blackRows = (pos[0][0] <= 1) ? new int[]{0, 1} : new int[]{4, 5}; + currentPlayer = "blanc"; + } else { + whitePlaced = true; + lastTileType = -1; // pas de contrainte pour le premier coup régulier + currentPlayer = "blanc"; // blanc joue en premier + } + } + + private void playRegular(String move, String player) { + int dash = move.indexOf('-'); + int[] from = cellFromString(move.substring(0, dash)); + int[] to = cellFromString(move.substring(dash + 1)); + + board[to[0]][to[1]] = board[from[0]][from[1]]; // capture si case adverse + board[from[0]][from[1]] = EMPTY; + lastTileType = TILE_MAP[to[0]][to[1]]; + currentPlayer = opponent(currentPlayer); + } + + // ========================================================================= + // Algorithme de déplacement (DFS) + // ========================================================================= + + /** + * Calcule l'ensemble des cases atteignables depuis (fromRow, fromCol). + * Résultats encodés sous forme "row,col". + */ + Set getReachableSquares(int fromRow, int fromCol, String player) { + Set result = new HashSet<>(); + boolean[][] visited = new boolean[6][6]; + visited[fromRow][fromCol] = true; + dfs(fromRow, fromCol, TILE_MAP[fromRow][fromCol], player, visited, result); + return result; + } + + /** + * DFS récursif pour le calcul des destinations. + * + *

À chaque appel, la pièce se trouve en (row, col) et doit encore effectuer + * {@code stepsLeft} pas. Les cases déjà visitées dans le chemin courant sont + * marquées dans {@code visited} (réinitialisation après backtrack). + * + *

Règles : + *

    + *
  • Pas intermédiaires (stepsLeft > 1) : la case suivante doit être vide.
  • + *
  • Dernier pas (stepsLeft == 1) : la case peut être vide ou contenir + * la licorne adverse (capture).
  • + *
+ */ + private void dfs(int row, int col, int stepsLeft, + String player, boolean[][] visited, Set result) { + if (stepsLeft == 0) { + result.add(row + "," + col); + return; + } + // Directions orthogonales : haut, bas, gauche, droite + int[] dr = {-1, 1, 0, 0}; + int[] dc = { 0, 0, -1, 1}; + + for (int d = 0; d < 4; d++) { + int nr = row + dr[d]; + int nc = col + dc[d]; + if (nr < 0 || nr >= 6 || nc < 0 || nc >= 6) continue; + if (visited[nr][nc]) continue; + + int occ = board[nr][nc]; + boolean canStep; + if (stepsLeft > 1) { + // Pas intermédiaire : case obligatoirement vide + canStep = (occ == EMPTY); + } else { + // Dernier pas : vide OU capture de la licorne adverse + canStep = (occ == EMPTY) + || ("blanc".equals(player) && occ == BLACK_LICORNE) + || ("noir".equals(player) && occ == WHITE_LICORNE); + } + if (!canStep) continue; + + visited[nr][nc] = true; + dfs(nr, nc, stepsLeft - 1, player, visited, result); + visited[nr][nc] = false; // backtrack + } + } + + // Chemin de génération « int » pour le moteur, sans allocation de String. + // Case = row*6+col (0..35) ; coup = from*36+to ; pass = MOVE_PASS ; black = noir. + // Équivalent au chemin String vérifié (contrôlé par VerifMoves). + + static final int MOVE_PASS = -1; + + record Undo(int move, int captured, int savedLastTile, String savedPlayer) {} + + /** Copie profonde de l'état (le moteur cherche sur une copie, jamais sur le live). */ + EscampeBoard copy() { + EscampeBoard b = new EscampeBoard(); + for (int r = 0; r < 6; r++) b.board[r] = board[r].clone(); + b.lastTileType = lastTileType; + b.currentPlayer = currentPlayer; + b.blackPlaced = blackPlaced; + b.whitePlaced = whitePlaced; + b.blackRows = (blackRows == null) ? null : blackRows.clone(); + return b; + } + + private boolean isSide(int piece, boolean black) { + return black ? (piece == BLACK_LICORNE || piece == BLACK_PALADIN) + : (piece == WHITE_LICORNE || piece == WHITE_PALADIN); + } + + /** Version allouante de {@link #genMovesIntInto}, pour les tests. */ + int[] genMovesInt(boolean black) { + int[] buf = new int[256]; + int n = genMovesIntInto(black, buf); + if (n == 0) return new int[0]; + return java.util.Arrays.copyOf(buf, n); + } + + /** + * Écrit les coups de la phase régulière de {@code black} dans {@code buf} et + * renvoie leur nombre : 0 hors phase régulière, ou {@code {MOVE_PASS}} si bloqué. + */ + int genMovesIntInto(boolean black, int[] buf) { + if (!blackPlaced || !whitePlaced) return 0; + if (gameOver()) return 0; + int n = 0; + for (int r = 0; r < 6; r++) { + for (int c = 0; c < 6; c++) { + if (!isSide(board[r][c], black)) continue; + if (lastTileType != -1 && TILE_MAP[r][c] != lastTileType) continue; + int from = r * 6 + c; + long reach = dfsMask(r, c, TILE_MAP[r][c], black, 1L << from, 0L); + while (reach != 0L) { + int t = Long.numberOfTrailingZeros(reach); + reach &= reach - 1; + buf[n++] = from * 36 + t; + } + } + } + if (n == 0) { buf[0] = MOVE_PASS; return 1; } + return n; + } + + /** DFS sur masque de bits (équivalent de {@link #dfs}) : {@code visited}/{@code reach} = ensembles de cases. */ + private long dfsMask(int row, int col, int steps, boolean black, long visited, long reach) { + if (steps == 0) return reach | (1L << (row * 6 + col)); + final int[] dr = {-1, 1, 0, 0}; + final int[] dc = { 0, 0, -1, 1}; + for (int d = 0; d < 4; d++) { + int nr = row + dr[d], nc = col + dc[d]; + if (nr < 0 || nr >= 6 || nc < 0 || nc >= 6) continue; + int ncell = nr * 6 + nc; + if ((visited & (1L << ncell)) != 0) continue; + int occ = board[nr][nc]; + boolean canStep; + if (steps > 1) { + canStep = (occ == EMPTY); + } else { + canStep = (occ == EMPTY) + || (black && occ == WHITE_LICORNE) + || (!black && occ == BLACK_LICORNE); + } + if (!canStep) continue; + reach = dfsMask(nr, nc, steps - 1, black, visited | (1L << ncell), reach); + } + return reach; + } + + /** Applique un coup int (régulier ou {@code MOVE_PASS}) et renvoie le jeton d'annulation. */ + Undo makeInt(int move) { + int savedLast = lastTileType; + String savedPlayer = currentPlayer; + if (move == MOVE_PASS) { + lastTileType = -1; + currentPlayer = opponent(currentPlayer); + return new Undo(move, EMPTY, savedLast, savedPlayer); + } + int from = move / 36, to = move % 36; + int fr = from / 6, fc = from % 6, tr = to / 6, tc = to % 6; + int captured = board[tr][tc]; + board[tr][tc] = board[fr][fc]; + board[fr][fc] = EMPTY; + lastTileType = TILE_MAP[tr][tc]; + currentPlayer = opponent(currentPlayer); + return new Undo(move, captured, savedLast, savedPlayer); + } + + /** Annule l'effet de {@link #makeInt}. */ + void unmakeInt(Undo u) { + if (u.move() != MOVE_PASS) { + int from = u.move() / 36, to = u.move() % 36; + int fr = from / 6, fc = from % 6, tr = to / 6, tc = to % 6; + board[fr][fc] = board[tr][tc]; + board[tr][tc] = u.captured(); + } + lastTileType = u.savedLastTile(); + currentPlayer = u.savedPlayer(); + } + + /** Code int → notation "A1-B2" (ou "E" pour le pass). */ + String moveToString(int move) { + if (move == MOVE_PASS) return "E"; + int from = move / 36, to = move % 36; + return stringFromCell(from / 6, from % 6) + "-" + stringFromCell(to / 6, to % 6); + } + + // ========================================================================= + // Méthodes utilitaires + // ========================================================================= + + private int charToPiece(char c) { + switch (c) { + case 'B': return WHITE_LICORNE; + case 'b': return WHITE_PALADIN; + case 'N': return BLACK_LICORNE; + case 'n': return BLACK_PALADIN; + default: return EMPTY; + } + } + + private char pieceToChar(int piece) { + switch (piece) { + case WHITE_LICORNE: return 'B'; + case WHITE_PALADIN: return 'b'; + case BLACK_LICORNE: return 'N'; + case BLACK_PALADIN: return 'n'; + default: return '-'; + } + } + + /** + * Convertit une chaîne "A1"-"F6" en coordonnées {row, col} (0-indexé). + * Retourne null si le format est invalide. + */ + int[] cellFromString(String s) { + if (s == null || s.length() < 2) return null; + s = s.trim(); + char colC = Character.toUpperCase(s.charAt(0)); + char rowC = s.charAt(1); + if (colC < 'A' || colC > 'F') return null; + if (rowC < '1' || rowC > '6') return null; + return new int[]{rowC - '1', colC - 'A'}; + } + + /** Convertit des coordonnées internes en notation "A1"-"F6". */ + String stringFromCell(int row, int col) { + return "" + (char)('A' + col) + (char)('1' + row); + } + + private boolean belongsToPlayer(int piece, String player) { + if ("blanc".equals(player)) return piece == WHITE_LICORNE || piece == WHITE_PALADIN; + if ("noir".equals(player)) return piece == BLACK_LICORNE || piece == BLACK_PALADIN; + return false; + } + + private String opponent(String player) { + return "blanc".equals(player) ? "noir" : "blanc"; + } + + /** + * Retourne les deux lignes (0-indexé) que doit utiliser blanc, + * sachant que noir a choisi {@code bRows}. + * Noir sur {0,1} → blanc sur {4,5} ; noir sur {4,5} → blanc sur {0,1}. + */ + private int[] complementaryRows(int[] bRows) { + return (bRows[0] == 0) ? new int[]{4, 5} : new int[]{0, 1}; + } + + // ========================================================================= + // Affichage + // ========================================================================= + + /** Affiche le plateau en console (ligne 6 en haut). */ + public void printBoard() { + System.out.println(" A B C D E F liseré"); + for (int r = 5; r >= 0; r--) { + System.out.print((r + 1) + " [ "); + for (int c = 0; c < 6; c++) System.out.print(pieceToChar(board[r][c]) + " "); + System.out.print("] " + (r + 1) + " |"); + for (int c = 0; c < 6; c++) System.out.print(" " + TILE_MAP[r][c]); + System.out.println(); + } + System.out.println("lastTileType=" + lastTileType + + " currentPlayer=" + currentPlayer + "\n"); + } + + // ========================================================================= + // Main de démonstration + // ========================================================================= + + public static void main(String[] args) throws IOException { + System.out.println("========================================="); + System.out.println(" Demo EscampeBoard "); + System.out.println("=========================================\n"); + + // ── Placements utilisés dans plusieurs scenarios ────────────────── + // Noir : lignes 5-6 (rows 4-5) — licorne en A6, paladins en B6 C6 D5 E5 F5 + final String NOIR_PL = "A6/B6/C6/D5/E5/F5"; + // Blanc : lignes 1-2 (rows 0-1) — licorne en D2, paladins en A1 B1 C1 E1 F2 + final String BLANC_PL = "D2/A1/B1/C1/E1/F2"; + + // ───────────────────────────────────────────────────────────────── + // 1. PHASE DE PLACEMENT + // ───────────────────────────────────────────────────────────────── + System.out.println("=== 1. PHASE DE PLACEMENT ==="); + EscampeBoard b = new EscampeBoard(); + + // Tentatives invalides avant le placement normal + System.out.println("Blanc tente de placer avant noir : " + + b.isValidMove(BLANC_PL, "blanc") + " (attendu: false)"); + System.out.println("Noir placement au milieu du plateau : " + + b.isValidMove("A3/B3/C3/D3/E3/F3", "noir") + " (attendu: false)"); + System.out.println("Noir placement sur deux bords diff. : " + + b.isValidMove("A1/B1/C1/D5/E5/F5", "noir") + " (attendu: false)"); + + // Placement valide de noir + System.out.println("\nNoir place : " + NOIR_PL + + " valid=" + b.isValidMove(NOIR_PL, "noir")); + b.play(NOIR_PL, "noir"); + System.out.println(" blackPlaced=" + b.blackPlaced + + " blackRows=[" + b.blackRows[0] + "," + b.blackRows[1] + "]" + + " currentPlayer=" + b.currentPlayer); + + // Placement valide de blanc + System.out.println("Blanc place : " + BLANC_PL + + " valid=" + b.isValidMove(BLANC_PL, "blanc")); + b.play(BLANC_PL, "blanc"); + System.out.println(" whitePlaced=" + b.whitePlaced + + " currentPlayer=" + b.currentPlayer); + + b.printBoard(); + + // ───────────────────────────────────────────────────────────────── + // 2. PHASE REGULIERE — contrainte de liseré + // ───────────────────────────────────────────────────────────────── + System.out.println("=== 2. PHASE REGULIERE ==="); + System.out.println("lastTileType=" + b.lastTileType + + " (pas de contrainte pour le premier coup)\n"); + + // Blanc joue en premier, pas de contrainte + String[] bMoves = b.possiblesMoves("blanc"); + System.out.println("Coups possibles pour blanc : " + bMoves.length + " coups"); + System.out.printf("Exemples : %s %s %s%n", + bMoves[0], + bMoves.length > 1 ? bMoves[1] : "", + bMoves.length > 2 ? bMoves[2] : ""); + + String m1 = bMoves[0]; + System.out.println("\nBlanc joue : " + m1 + " valid=" + b.isValidMove(m1, "blanc")); + b.play(m1, "blanc"); + System.out.println(" lastTileType=" + b.lastTileType + + " (liseré de la case d'arrivée = contrainte pour noir)" + + " currentPlayer=" + b.currentPlayer); + + // Tentative invalide : blanc rejoue hors de son tour + System.out.println("\nBlanc rejoue hors tour : " + + b.isValidMove(m1, "blanc") + " (attendu: false)"); + + // Tentative invalide : noir joue depuis un mauvais liseré + String badNoirMove = findMoveFromWrongTile(b, "noir"); + if (badNoirMove != null) { + System.out.println("Noir depuis mauvais liseré (" + badNoirMove + ") : " + + b.isValidMove(badNoirMove, "noir") + " (attendu: false)"); + } + + // Coup valide de noir + String[] nMoves = b.possiblesMoves("noir"); + System.out.println("\nCoups possibles pour noir (liseré " + b.lastTileType + ") : " + + nMoves.length + " coups"); + String m2 = nMoves[0]; + System.out.println("Noir joue : " + m2 + " valid=" + b.isValidMove(m2, "noir")); + b.play(m2, "noir"); + System.out.println(" lastTileType=" + b.lastTileType + + " currentPlayer=" + b.currentPlayer); + + // ───────────────────────────────────────────────────────────────── + // 3. ROUND-TRIP FICHIER + // ───────────────────────────────────────────────────────────────── + System.out.println("\n=== 3. ROUND-TRIP FICHIER ==="); + b.saveToFile("escampe_save.txt"); + System.out.println("Sauvegardé dans escampe_save.txt"); + + EscampeBoard b2 = new EscampeBoard(); + b2.setFromFile("escampe_save.txt"); + System.out.println("Rechargé : lastTileType=" + b2.lastTileType + + " currentPlayer=" + b2.currentPlayer); + System.out.println("Plateaux identiques : " + Arrays.deepEquals(b.board, b2.board)); + System.out.println("lastTileType identique : " + (b.lastTileType == b2.lastTileType)); + System.out.println("currentPlayer identique : " + b.currentPlayer.equals(b2.currentPlayer)); + + // ───────────────────────────────────────────────────────────────── + // 4. SCENARIO DE PASS (E) + // ───────────────────────────────────────────────────────────────── + System.out.println("\n=== 4. SCENARIO DE PASS ==="); + EscampeBoard bPass = new EscampeBoard(); + bPass.play(NOIR_PL, "noir"); + bPass.play(BLANC_PL, "blanc"); + + // Forcer une situation où noir n'a aucun coup : + // lastTileType=2, mais toutes les pièces noires sont sur liseré 1 ou 3. + for (int r = 0; r < 6; r++) Arrays.fill(bPass.board[r], EMPTY); + bPass.board[0][3] = WHITE_LICORNE; // D1 liseré=3 + bPass.board[0][0] = WHITE_PALADIN; // A1 liseré=1 + bPass.board[0][4] = WHITE_PALADIN; // E1 liseré=1 + bPass.board[5][0] = BLACK_LICORNE; // A6 liseré=3 + bPass.board[4][4] = BLACK_PALADIN; // E5 liseré=1 + bPass.board[4][2] = BLACK_PALADIN; // C5 liseré=1 + bPass.lastTileType = 2; // blanc vient de poser sur liseré 2 + bPass.currentPlayer = "noir"; + + System.out.println("Pièces noires sur liserés 1 et 3, contrainte = 2"); + System.out.println("possiblesMoves(noir) = " + + Arrays.toString(bPass.possiblesMoves("noir")) + " (attendu: [E])"); + System.out.println("isValidMove(E, noir) = " + + bPass.isValidMove("E", "noir") + " (attendu: true)"); + System.out.println("isValidMove(E, blanc) = " + + bPass.isValidMove("E", "blanc") + " (attendu: false, pas son tour)"); + + bPass.play("E", "noir"); + System.out.println("Après pass : lastTileType=" + bPass.lastTileType + + " (attendu: -1) currentPlayer=" + bPass.currentPlayer); + + // ───────────────────────────────────────────────────────────────── + // 5. CAPTURE ET FIN DE PARTIE + // ───────────────────────────────────────────────────────────────── + System.out.println("\n=== 5. CAPTURE ET FIN DE PARTIE ==="); + EscampeBoard bCap = new EscampeBoard(); + bCap.play(NOIR_PL, "noir"); + bCap.play(BLANC_PL, "blanc"); + + // Mise en scène : + // - Blanc paladin en B1 (row=0,col=1 ; liseré=2) + // → 2 pas orthogonaux : B1 -> B2 -> B3 + // - Licorne noire en B3 (row=2,col=1) ; case B2 vide + // - lastTileType=2 → blanc peut jouer depuis B1 + for (int r = 0; r < 6; r++) Arrays.fill(bCap.board[r], EMPTY); + bCap.board[0][1] = WHITE_PALADIN; // B1 liseré=2 + bCap.board[0][3] = WHITE_LICORNE; // D1 (garde-fou : licorne blanche présente) + bCap.board[2][1] = BLACK_LICORNE; // B3 + bCap.board[5][5] = BLACK_PALADIN; // F6 (présence de pièce noire restante) + bCap.lastTileType = 2; + bCap.currentPlayer = "blanc"; + + System.out.println("Avant capture :"); + bCap.printBoard(); + System.out.println("gameOver = " + bCap.gameOver() + " (attendu: false)"); + + // Coup invalide : un pas seulement (B1->B2), pas assez de cases + System.out.println("Coup B1-B2 (1 pas, manque 1) : " + + bCap.isValidMove("B1-B2", "blanc") + " (attendu: false)"); + + // Coup valide : deux pas (B1->B2->B3), B2 vide, B3 = licorne noire + System.out.println("Coup B1-B3 (2 pas, capture) : " + + bCap.isValidMove("B1-B3", "blanc") + " (attendu: true)"); + bCap.play("B1-B3", "blanc"); + + System.out.println("Après capture :"); + bCap.printBoard(); + System.out.println("gameOver = " + bCap.gameOver() + " (attendu: true)"); + System.out.println("Blanc gagne !"); + + System.out.println("\n========================================="); + System.out.println(" Demo terminee "); + System.out.println("========================================="); + } + + /** + * Utilitaire pour la démo : trouve un coup depuis une pièce + * de {@code player} dont le liseré est différent de {@code lastTileType}. + * Retourne null si aucune telle pièce n'a de destinations. + */ + private static String findMoveFromWrongTile(EscampeBoard b, String player) { + for (int r = 0; r < 6; r++) { + for (int c = 0; c < 6; c++) { + if (!b.belongsToPlayer(b.board[r][c], player)) continue; + if (TILE_MAP[r][c] == b.lastTileType) continue; + Set reach = b.getReachableSquares(r, c, player); + if (!reach.isEmpty()) { + String dest = reach.iterator().next(); + String[] parts = dest.split(","); + return b.stringFromCell(r, c) + "-" + + b.stringFromCell(Integer.parseInt(parts[0]), + Integer.parseInt(parts[1])); + } + } + } + return null; + } +} diff --git a/dist/Puyaubreau_Russac/src/escampe/IJoueur.java b/dist/Puyaubreau_Russac/src/escampe/IJoueur.java new file mode 100644 index 0000000..c6e9143 --- /dev/null +++ b/dist/Puyaubreau_Russac/src/escampe/IJoueur.java @@ -0,0 +1,65 @@ +package escampe; + + +/** + * Voici l'interface abstraite qu'il suffit d'implanter pour jouer. Ensuite, vous devez utiliser + * ClientJeu en lui donnant le nom de votre classe pour qu'il la charge et se connecte au serveur. + * + * @author L. Simon (Univ. Paris-Sud)- 2006-2013 + * + */ + +public interface IJoueur { + + // Mais pas lors de la conversation avec l'arbitre (méthodes initJoueur et getNumJoueur) + // Vous pouvez changer cela en interne si vous le souhaitez + static final int BLANC = -1; + static final int NOIR = 1; + + /** + * L'arbitre vient de lancer votre joueur. Il lui informe par cette méthode que vous devez jouer + * dans cette couleur. Vous pouvez utiliser cette m?thode abstraite, ou la méthode constructeur + * de votre classe, pour initialiser vos structures. + * + * @param mycolour + * La couleur dans laquelle vous allez jouer (-1=BLANC, 1=NOIR) + */ + public void initJoueur(int mycolour); + + // Doit retourner l'argument passé par la fonction ci-dessus (constantes BLANC ou NOIR) + public int getNumJoueur(); + + /** + * C'est ici que vous devez faire appel à votre IA pour trouver le meilleur coup à jouer sur le + * plateau courant. + * + * @return une chaine décrivant le mouvement. Cette chaine doit être décrite exactement comme + * sur l'exemple : String msg = "" + positionInitiale + "-" +positionFinale + ""; ou "PASSE"; + * Chaque position contient une lettre et un num?ro, par exemple:A1,B2 (coup "A1-B2") + */ + public String choixMouvement(); + + /** + * Méthode appelée par l'arbitre pour désigner le vainqueur. Vous pouvez en profiter pour + * imprimer une bannière de joie... Si vous gagnez... + * + * @param colour + * La couleur du gagnant (BLANC=-1, NOIR=1). + */ + public void declareLeVainqueur(int colour); + + /** + * On suppose que l'arbitre a vérifié que le mouvement ennemi était bien légal. Il vous informe + * du mouvement ennemi. A vous de répercuter ce mouvement dans vos structures. Comme par exemple + * éliminer les pions que ennemi vient de vous prendre par ce mouvement. Il n'est pas nécessaire + * de réfléchir déjà à votre prochain coup à jouer : pour cela l'arbitre appelera ensuite + * choixMouvement(). + * + * @param coup + * une chaine décrivant le mouvement: par exemple: "A1-B2" + */ + public void mouvementEnnemi(String coup); + + public String binoName(); + +} diff --git a/dist/Puyaubreau_Russac/src/escampe/JoueurPuyaubreauRussac.java b/dist/Puyaubreau_Russac/src/escampe/JoueurPuyaubreauRussac.java new file mode 100644 index 0000000..d04c3f4 --- /dev/null +++ b/dist/Puyaubreau_Russac/src/escampe/JoueurPuyaubreauRussac.java @@ -0,0 +1,117 @@ +package escampe; + +/** + * Joueur du tournoi (Puyaubreau / Russac). Enveloppe un {@link EscampeBoard} + * tenu à jour à chaque coup et délègue la décision à {@link Moteur}. + * + * L'interface {@code IJoueur} parle en entiers ({@code NOIR=1}, {@code BLANC=-1}) + * et place les pièces via le même canal que les coups : le premier + * {@code choixMouvement} renvoie un placement, les suivants des coups. Le pass + * se note {@code "E"} (et non {@code "PASSE"}, contrairement au Javadoc d'IJoueur). + */ +public class JoueurPuyaubreauRussac implements IJoueur { + + private int couleur = NOIR; + private EscampeBoard board; + private final Moteur moteur = new Moteur(); + + // Budget de temps : enveloppe sous la limite arbitre de 300 s, fraction du + // temps restant par coup. Surchargeable par -Descampe.* pour les tests. + private static final long BUDGET_MS = Long.getLong("escampe.budgetMs", 280_000); + private static final long MAX_SLICE_MS = Long.getLong("escampe.maxSliceMs", 6_000); + private static final long MIN_SLICE_MS = 120; + private static final int TIME_DIVISOR = 12; + private static final boolean DEBUG = Boolean.getBoolean("escampe.debug"); + private long usedMs = 0; + + @Override + public void initJoueur(int mycolour) { + couleur = mycolour; + board = new EscampeBoard(); + } + + @Override + public int getNumJoueur() { + return couleur; + } + + @Override + public String binoName() { + return "Puyaubreau_Russac"; + } + + private String myStr() { return couleur == NOIR ? "noir" : "blanc"; } + private String oppStr() { return couleur == NOIR ? "blanc" : "noir"; } + + @Override + public String choixMouvement() { + if (board.gameOver()) return "xxxxx"; // fin de partie sous Solo ; l'arbitre, lui, n'appelle plus + + if (couleur == NOIR && !board.blackPlaced) { + String pl = placement(new int[]{0, 1}); + board.play(pl, "noir"); + return pl; + } + if (couleur == BLANC && !board.whitePlaced) { + String pl = placement(complementaryRows(board.blackRows)); + board.play(pl, "blanc"); + return pl; + } + + String move = chooseMove(); + board.play(move, myStr()); + return move; + } + + @Override + public void mouvementEnnemi(String coup) { + if (coup == null) return; + coup = coup.trim(); + if (coup.isEmpty() || coup.equals("xxxxx")) return; + try { + board.play(coup, oppStr()); + } catch (RuntimeException e) { + // L'arbitre garantit la légalité ; on ne plante pas sur une désync. + System.err.println("[" + binoName() + "] coup ennemi rejeté : " + coup); + } + } + + @Override + public void declareLeVainqueur(int colour) { + if (colour == couleur) System.out.println("[" + binoName() + "] Victoire !"); + else if (colour == -couleur) System.out.println("[" + binoName() + "] Défaite."); + } + + /** Temps alloué au moteur pour ce coup, puis appel de la recherche. */ + private String chooseMove() { + long remaining = BUDGET_MS - usedMs; + long slice = Math.max(MIN_SLICE_MS, Math.min(remaining / TIME_DIVISOR, MAX_SLICE_MS)); + if (remaining < 1500) slice = Math.max(40, remaining - 300); + + long t0 = System.currentTimeMillis(); + int m = moteur.bestMove(board, couleur == NOIR, slice); + usedMs += System.currentTimeMillis() - t0; + + if (DEBUG) { + System.err.printf("[%s] %s prof=%d score=%d noeuds=%d cumul=%ds%n", + binoName(), board.moveToString(m), moteur.reachedDepth, moteur.lastScore, + moteur.nodes, usedMs / 1000); + } + return board.moveToString(m); + } + + private int[] complementaryRows(int[] blackRows) { + return blackRows[0] == 0 ? new int[]{4, 5} : new int[]{0, 1}; + } + + /** + * Placement : licorne dans un coin, ses deux voisines occupées par des + * paladins (la licorne devient incapturable), les trois autres paladins sur + * des liserés 1/2/3 distincts pour ne jamais être contraint de passer. + */ + private String placement(int[] rows) { + boolean bottom = Math.min(rows[0], rows[1]) == 0; + return bottom ? "A1/A2/B1/E1/F1/C2" // coin A1, murs A2/B1, mobiles E1(1)/F1(2)/C2(3) + : "A6/A5/B6/C5/F5/E6"; // coin A6, murs A5/B6, mobiles C5(1)/F5(2)/E6(3) + } +} diff --git a/dist/Puyaubreau_Russac/src/escampe/Moteur.java b/dist/Puyaubreau_Russac/src/escampe/Moteur.java new file mode 100644 index 0000000..52e7926 --- /dev/null +++ b/dist/Puyaubreau_Russac/src/escampe/Moteur.java @@ -0,0 +1,137 @@ +package escampe; + +/** + * Recherche du meilleur coup : negamax + élagage alpha-bêta + approfondissement + * itératif sous limite de temps. La recherche se fait sur une copie du plateau, + * via makeInt/unmakeInt (sans allocation). Capturer la licorne adverse vaut + * {@code WIN - ply} (gagner vite plutôt que tard). + */ +final class Moteur { + + static final int WIN = 1_000_000; + static final int INF = 2_000_000; + static final int MAX_DEPTH = 40; + private static final int MAX_PLY = MAX_DEPTH + 8; + + // Poids de l'évaluation (proximité paladins/licornes : attaque vs défense). + int wAtkSum = 2, wDefSum = 2, wAtkMin = 8, wDefMin = 8; + + private long deadline; + private boolean timedOut; + long nodes; + int reachedDepth; + int lastScore; + + private final int[][] buf = new int[MAX_PLY][256]; // un buffer de coups par profondeur + + int bestMove(EscampeBoard root, boolean black, long budgetMs) { + EscampeBoard pos = root.copy(); + deadline = System.currentTimeMillis() + Math.max(1, budgetMs); + nodes = 0; timedOut = false; reachedDepth = 0; lastScore = 0; + + int[] moves = new int[256]; + int n = pos.genMovesIntInto(black, moves); + if (n == 0 || moves[0] == EscampeBoard.MOVE_PASS) return EscampeBoard.MOVE_PASS; + orderCapturesFirst(pos, moves, n, black); + + int best = moves[0]; + for (int depth = 1; depth <= MAX_DEPTH; depth++) { + int alpha = -INF, bestScore = -INF, bestThis = moves[0]; + boolean complete = true; + for (int i = 0; i < n; i++) { + EscampeBoard.Undo u = pos.makeInt(moves[i]); + int sc = isCapture(u, black) ? WIN - 1 : -negamax(pos, depth - 1, -INF, -alpha, !black, 1); + pos.unmakeInt(u); + if (timedOut) { complete = false; break; } + if (sc > bestScore) { bestScore = sc; bestThis = moves[i]; } + if (sc > alpha) alpha = sc; + } + if (!complete) break; // profondeur interrompue : on garde la précédente + best = bestThis; + reachedDepth = depth; + lastScore = bestScore; + moveToFront(moves, n, best); // ordonne l'itération suivante + if (bestScore >= WIN - 64) break; + } + return best; + } + + private int negamax(EscampeBoard pos, int depth, int alpha, int beta, boolean black, int ply) { + if ((++nodes & 2047) == 0 && System.currentTimeMillis() >= deadline) { timedOut = true; return 0; } + if (depth <= 0) return eval(pos, black); + + int[] moves = buf[ply]; + int n = pos.genMovesIntInto(black, moves); + if (n == 0) return eval(pos, black); + orderCapturesFirst(pos, moves, n, black); + + int bestScore = -INF; + for (int i = 0; i < n; i++) { + EscampeBoard.Undo u = pos.makeInt(moves[i]); + int sc = isCapture(u, black) ? WIN - ply : -negamax(pos, depth - 1, -beta, -alpha, !black, ply + 1); + pos.unmakeInt(u); + if (timedOut) return 0; + if (sc > bestScore) bestScore = sc; + if (bestScore > alpha) alpha = bestScore; + if (alpha >= beta) break; + } + return bestScore; + } + + private boolean isCapture(EscampeBoard.Undo u, boolean black) { + return u.captured() == (black ? EscampeBoard.WHITE_LICORNE : EscampeBoard.BLACK_LICORNE); + } + + /** Place en tête un coup capturant la licorne adverse, pour une coupure immédiate. */ + private void orderCapturesFirst(EscampeBoard pos, int[] moves, int n, boolean black) { + int enemy = black ? EscampeBoard.WHITE_LICORNE : EscampeBoard.BLACK_LICORNE; + for (int i = 0; i < n; i++) { + int to = moves[i] % 36; + if (moves[i] != EscampeBoard.MOVE_PASS && pos.board[to / 6][to % 6] == enemy) { + int t = moves[0]; moves[0] = moves[i]; moves[i] = t; + return; + } + } + } + + private void moveToFront(int[] moves, int n, int target) { + for (int i = 0; i < n; i++) { + if (moves[i] == target) { int t = moves[0]; moves[0] = moves[i]; moves[i] = t; return; } + } + } + + private int eval(EscampeBoard pos, boolean black) { + int adv = evalBlackAdvantage(pos); + return black ? adv : -adv; + } + + /** Avantage de Noir : nos paladins proches de la licorne adverse, les leurs loin de la nôtre. */ + private int evalBlackAdvantage(EscampeBoard pos) { + int[][] b = pos.board; + int blr = -1, blc = -1, wlr = -1, wlc = -1; + for (int r = 0; r < 6; r++) + for (int c = 0; c < 6; c++) { + int p = b[r][c]; + if (p == EscampeBoard.BLACK_LICORNE) { blr = r; blc = c; } + else if (p == EscampeBoard.WHITE_LICORNE) { wlr = r; wlc = c; } + } + if (wlr < 0) return WIN; + if (blr < 0) return -WIN; + + int atkSum = 0, defSum = 0, atkMin = 99, defMin = 99; + for (int r = 0; r < 6; r++) + for (int c = 0; c < 6; c++) { + int p = b[r][c]; + if (p == EscampeBoard.BLACK_PALADIN) { + int d = Math.abs(r - wlr) + Math.abs(c - wlc); + atkSum += 10 - d; + if (d < atkMin) atkMin = d; + } else if (p == EscampeBoard.WHITE_PALADIN) { + int d = Math.abs(r - blr) + Math.abs(c - blc); + defSum += 10 - d; + if (d < defMin) defMin = d; + } + } + return wAtkSum * atkSum - wDefSum * defSum + wAtkMin * (10 - atkMin) - wDefMin * (10 - defMin); + } +} diff --git a/dist/Puyaubreau_Russac/src/escampe/Partie1.java b/dist/Puyaubreau_Russac/src/escampe/Partie1.java new file mode 100644 index 0000000..972d1f2 --- /dev/null +++ b/dist/Puyaubreau_Russac/src/escampe/Partie1.java @@ -0,0 +1,45 @@ +package escampe; + +public interface Partie1 { + + /** + * Initialise un plateau à partir d'un fichier texte. + * @param fileName le nom du fichier à lire + */ + public void setFromFile(String fileName); + + /** + * Sauve la configuration de l'état courant (plateau et pièces restantes) dans un fichier. + * @param fileName le nom du fichier à sauvegarder + * Le format doit être compatible avec celui utilisé pour la lecture. + */ + public void saveToFile(String fileName); + + /** + * Indique si le coup {@code move} est valide pour le joueur {@code player} sur le plateau courant. + * @param move le coup à jouer, + * sous la forme "B1-D1" en général, + * sous la forme "C6/A6/B5/D5/E6/F5" pour le coup qui place les pièces, + * ou "E" pour passer son tour. + * @param player le joueur qui joue, représenté par "noir" ou "blanc" + */ + public boolean isValidMove(String move, String player); + + /** + * Calcule les coups possibles pour le joueur {@code player} sur le plateau courant. + * @param player le joueur qui joue, représenté par "noir" ou "blanc" + */ + public String[] possiblesMoves(String player); + + /** + * Modifie le plateau en jouant le coup {@code move} pour le joueur {@code player}. + * @param move le coup à jouer, sous la forme "C1-D1" ou "C6/A6/B5/D5/E6/F5" + * @param player le joueur qui joue, représenté par "noir" ou "blanc" + */ + public void play(String move, String player); + + /** + * Retourne vrai lorsque le plateau correspond à une fin de partie. + */ + public boolean gameOver(); +} diff --git a/dist/Puyaubreau_Russac/src/escampe/RulesTest.java b/dist/Puyaubreau_Russac/src/escampe/RulesTest.java new file mode 100644 index 0000000..7e5fa32 --- /dev/null +++ b/dist/Puyaubreau_Russac/src/escampe/RulesTest.java @@ -0,0 +1,143 @@ +package escampe; + +import java.util.*; + +/** + * Tests directs des règles du jeu : compte de pas selon le liseré, capture au + * dernier pas uniquement, paladins imprenables, interdiction de traverser une + * case occupée, contrainte de liseré, pass forcé, fin de partie, zones de placement. + */ +public class RulesTest { + + static int pass = 0, fail = 0; + static void check(boolean cond, String name) { + if (cond) pass++; + else { fail++; System.out.println(" ÉCHEC : " + name); } + } + static boolean has(Set s, int r, int c) { return s.contains(r + "," + c); } + + public static void main(String[] args) { + stepCount(); + captureAndBlocking(); + lisereConstraint(); + forcedPass(); + gameOver(); + placementZones(); + + System.out.println("\nRulesTest : " + pass + " OK, " + fail + " échec(s)."); + if (fail > 0) System.exit(1); + } + + /** Le nombre de pas est exactement le liseré de la case de départ. */ + static void stepCount() { + EscampeBoard b = new EscampeBoard(); + b.board[2][2] = EscampeBoard.WHITE_PALADIN; // C3, liseré 1 + Set r = b.getReachableSquares(2, 2, "blanc"); + check(r.size() == 4 && has(r,1,2) && has(r,3,2) && has(r,2,1) && has(r,2,3), + "liseré 1 (centre) → exactement les 4 voisins orthogonaux"); + + b = new EscampeBoard(); + b.board[2][3] = EscampeBoard.WHITE_PALADIN; // D3, liseré 2 + r = b.getReachableSquares(2, 3, "blanc"); + check(r.size() == 8 + && has(r,0,3) && has(r,4,3) && has(r,2,1) && has(r,2,5) + && has(r,1,2) && has(r,1,4) && has(r,3,2) && has(r,3,4), + "liseré 2 (centre) → les 8 cases à distance 2"); + + b = new EscampeBoard(); + b.board[3][2] = EscampeBoard.WHITE_PALADIN; // C4, liseré 3 + r = b.getReachableSquares(3, 2, "blanc"); + check(has(r,0,2), "liseré 3 atteint (0,2) à 3 pas en ligne droite"); + check(!has(r,1,2), "liseré 3 n'atteint PAS (1,2) (mauvaise parité : 3 pas)"); + check(has(r,2,2) && has(r,3,3), "liseré 3 atteint des cases à distance 1 (zigzag)"); + } + + /** Capture au dernier pas uniquement ; paladins imprenables ; pas de traversée. */ + static void captureAndBlocking() { + EscampeBoard b = new EscampeBoard(); + b.board[3][2] = EscampeBoard.WHITE_PALADIN; // C4 liseré 3 + b.board[0][2] = EscampeBoard.BLACK_LICORNE; // cible à 3 pas (droit) + Set r = b.getReachableSquares(3, 2, "blanc"); + check(has(r,0,2), "capture de la licorne adverse au dernier pas : autorisée"); + + b = new EscampeBoard(); + b.board[3][2] = EscampeBoard.WHITE_PALADIN; + b.board[0][2] = EscampeBoard.BLACK_PALADIN; // paladin sur la case finale + r = b.getReachableSquares(3, 2, "blanc"); + check(!has(r,0,2), "paladin imprenable : pas d'arrivée dessus"); + + b = new EscampeBoard(); + b.board[3][2] = EscampeBoard.WHITE_PALADIN; + b.board[1][2] = EscampeBoard.BLACK_PALADIN; // bloque l'unique chemin vers (0,2) + r = b.getReachableSquares(3, 2, "blanc"); + check(!has(r,0,2), "interdit de traverser une case occupée"); + + b = new EscampeBoard(); + b.board[3][2] = EscampeBoard.WHITE_PALADIN; + b.board[1][2] = EscampeBoard.BLACK_LICORNE; // licorne à distance 2 (parité ≠) + r = b.getReachableSquares(3, 2, "blanc"); + check(!has(r,1,2), "licorne à mauvaise distance : non capturable (compte de pas exact)"); + } + + /** On ne peut jouer que depuis une case du liseré imposé. */ + static void lisereConstraint() { + EscampeBoard b = inPlay(); + b.board[2][2] = EscampeBoard.WHITE_LICORNE; // C3 liseré 1 + b.board[5][5] = EscampeBoard.BLACK_LICORNE; + b.board[2][3] = EscampeBoard.WHITE_PALADIN; // D3 liseré 2 + b.board[0][0] = EscampeBoard.WHITE_PALADIN; // A1 liseré 1 + b.lastTileType = 2; // seules les pièces liseré 2 bougent + boolean allLis2 = true; + for (String m : b.possiblesMoves("blanc")) { + int[] from = b.cellFromString(m.substring(0, m.indexOf('-'))); + if (EscampeBoard.TILE_MAP[from[0]][from[1]] != 2) allLis2 = false; + } + check(allLis2, "contrainte de liseré : tous les coups partent d'une case liseré 2"); + } + + /** Pass autorisé seulement si aucune pièce ne peut jouer le liseré imposé. */ + static void forcedPass() { + EscampeBoard b = inPlay(); + b.board[0][0] = EscampeBoard.WHITE_LICORNE; // A1 liseré 1 + b.board[5][5] = EscampeBoard.BLACK_LICORNE; + b.lastTileType = 3; // blanc n'a aucune pièce liseré 3 + String[] mv = b.possiblesMoves("blanc"); + check(mv.length == 1 && mv[0].equals("E"), "aucune pièce sur le liseré → pass forcé"); + check(b.isValidMove("E", "blanc"), "E valide quand bloqué"); + + b.lastTileType = 1; // la licorne A1 (liseré 1) peut bouger + String[] mv2 = b.possiblesMoves("blanc"); + check(mv2.length >= 1 && !mv2[0].equals("E"), "des coups existent → pas de pass"); + check(!b.isValidMove("E", "blanc"), "E invalide si des coups existent"); + } + + static void gameOver() { + EscampeBoard b = inPlay(); + b.board[0][0] = EscampeBoard.WHITE_LICORNE; + b.board[5][5] = EscampeBoard.BLACK_LICORNE; + check(!b.gameOver(), "deux licornes présentes → partie en cours"); + b.board[5][5] = EscampeBoard.EMPTY; + check(b.gameOver(), "une licorne manquante → fin de partie"); + check(!new EscampeBoard().gameOver(), "avant placement → jamais fini"); + } + + /** Placement : zones autorisées et complémentarité noir/blanc. */ + static void placementZones() { + EscampeBoard b = new EscampeBoard(); + check(!b.isValidMove("A3/B3/C3/D3/E3/F3", "noir"), "placement noir au centre : refusé"); + check(b.isValidMove("A1/A2/B1/E1/F1/C2", "noir"), "placement noir sur 2 lignes du bord : accepté"); + b.play("A1/A2/B1/E1/F1/C2", "noir"); + check(b.isValidMove("A6/A5/B6/C5/F5/E6", "blanc"), "placement blanc complémentaire (haut) : accepté"); + check(!b.isValidMove("A1/A2/B1/E1/F1/D1", "blanc"), "placement blanc du même côté que noir : refusé"); + } + + /** Plateau vide « en jeu » (les deux placements faits), à remplir à la main. */ + static EscampeBoard inPlay() { + EscampeBoard b = new EscampeBoard(); + b.blackPlaced = true; + b.whitePlaced = true; + b.currentPlayer = "blanc"; + b.lastTileType = -1; + return b; + } +} diff --git a/dist/Puyaubreau_Russac/src/escampe/Solo.java b/dist/Puyaubreau_Russac/src/escampe/Solo.java new file mode 100644 index 0000000..ae4eba9 --- /dev/null +++ b/dist/Puyaubreau_Russac/src/escampe/Solo.java @@ -0,0 +1,183 @@ +package escampe; + + +import java.util.Date; + +import javax.swing.JFrame; + +/** + * Petite Classe toute simple qui vous montre comment on peut lancer une partie sur deux IJoueurs... + * Cela vous servira a debugger facilement votre projet en conditions presque reelles de tournoi + * + * Attention, l'arbitre n'est pas lancé dessus, mais comme il s'agit de deux IJoueur à vous il n'est + * pas nécessaire de vérifier la validité des coups (bien entendu) + * + * Par contre, comme rien ne vérifie la fin de partie (pas d'arbitre), vos IJoueur devront renvoyer + * la chaine "xxxxx" pour dire que la partie est finie. + * + * Cette classe n'affiche rien : elle se contente de donner la main alternativement aux deux + * joueurs. + * + * 2008-2012 + */ +public class Solo { + private static IJoueur joueurBlanc; + private static IJoueur joueurNoir; + + // Ne pas modifier ces constantes, elles seront utilisees par l'arbitre + private final static int BLANC = -1; + private final static int NOIR = 1; + + private static int nbCoups = 0; + + /*// Par défaut, on a une applet graphique + static boolean APPLETGRAPHIQUE = true; + + // applet game viewer + static private Applet vueDuJeu; + static private JFrame f = null;*/ + + + /** + * Pour éviter de toujours envoyer des lignes de commandes, vous pouvez renvoyer automatiquement + * dans cette méthode votre joueur par défaut. Attention, il faut bien remplir le return new + * VOTREJOUEUR() pour que cela fonctionne la classe implantee renvoyee doit implanter + * l'interface IJoueur... + * + * @param s + * @return Ijoueur un joueur demande + */ + private static IJoueur getDefaultPlayer(String s) { + System.out.println(s + " : defaultPlayer"); + // vous devez faire qq chose comme return new MonMeilleurJoueur(); + // JoueurAleatoire vit dans escampeobf.jar (interface obfusquée) : on ne peut + // pas le référencer ici à la compilation. On renvoie donc notre propre joueur. + return new JoueurPuyaubreauRussac(); + } + + /** + * Juste pour rendre le tout plus generique, et vous donner une idee de comment le tournoi sera + * lance automatiquement, voici une methode permettant de charger une certaine classe implantant + * un IJoueur + * + * @param classeJoueur + * @param s + * @return la classe chargee dynamiquement + */ + private static IJoueur loadNamedPlayer(String classeJoueur, String s) { + IJoueur joueur; + System.out.print(s + " : Chargement de la classe joueur " + classeJoueur + "... "); + try { + Class cjoueur = Class.forName(classeJoueur); + joueur = (IJoueur) cjoueur.newInstance(); + } + catch (Exception e) { + System.out.println("Erreur de chargement"); + System.out.println(e); + return null; + } + System.out.println("Ok"); + return joueur; + } + + /** + * Boucle principale du jeu, en utilisant une version de l'arbitre identique a celle du tournoi + * L'arbitre sera le garant de la validite des coups, et de leur affichage standard pour la + * publication via le site web. + * + * @param joueurBlanc + * @param joueurNoir + */ + public static void gameLoop(IJoueur joueurBlanc, IJoueur joueurNoir) { + String coup; + boolean partieFinie = false; + IJoueur joueurCourant = joueurNoir; // Dans Escampe le joueur Noir commence + + while (!partieFinie) { + nbCoups++; + + System.out.println("\n*********\nOn demande à " + joueurCourant.binoName() + " de jouer..."); + long waitingTime1 = new Date().getTime(); + + coup = joueurCourant.choixMouvement(); + + long waitingTime2 = new Date().getTime(); + // On rajoute 1 pour eliminer les temps infinis + long waitingTime = waitingTime2 - waitingTime1 + 1; + System.out.println("Le joueur " + joueurCourant.binoName() + " a joué le coup " + coup + " en " + waitingTime + "s."); + try { + Thread.sleep(1); // Juste pour attendre un peu + } + catch (InterruptedException e) { + } + + if (coup.compareTo("xxxxx") == 0) + partieFinie = true; + else if (nbCoups == 2) { // Dans Escampe le joueur Blanc rejoue après avoir posé ses pièces + // On avertit le joueur Noir du placement des pièces + joueurNoir.mouvementEnnemi(coup); + } + else { + if (joueurCourant.getNumJoueur() == BLANC) + joueurCourant = joueurNoir; + else + joueurCourant = joueurBlanc; + + // On avertit le second joueur du coup calcule par le precedent + joueurCourant.mouvementEnnemi(coup); + // Ce sera ensuite à lui de jouer de nouveau en haut de la boucle + } + } + + System.out.println("Partie finie en " + nbCoups + " coups.\n"); + } + + /** + * On charge eventuellement les classes demandee pour les joueurs, et on lance la boucle + * principale + * + * @param args + */ + public static void main(String args[]) { + /*// S'il le faut, on initialise l'applet graphique + if (APPLETGRAPHIQUE) { + f = new JFrame("Vue du jeu"); + vueDuJeu = new Applet(); + vueDuJeu.buildUI(f.getContentPane()); + f.setSize(vueDuJeu.getDimension()); + vueDuJeu.setMyFrame(f); + f.setVisible(true); + vueDuJeu.addBoard("Départ ", plateau); + vueDuJeu.update(f.getGraphics(), f.getInsets()); + }*/ + + System.out.println("Partie solo ..."); + + if (args.length == 0) { // On a deux classes à charger + joueurBlanc = getDefaultPlayer("Blanc"); + joueurNoir = getDefaultPlayer("Noir"); + } + else if (args.length == 2) { // On a deux classes à charger + joueurBlanc = getDefaultPlayer("Blanc"); + joueurNoir = getDefaultPlayer("Noir"); + } + else if (args.length == 3) { + joueurBlanc = loadNamedPlayer(args[0], "Blanc"); + joueurNoir = loadNamedPlayer(args[0], "Noir"); + } + else if (args.length == 4) { + joueurBlanc = loadNamedPlayer(args[0], "Blanc"); + joueurNoir = loadNamedPlayer(args[1], "Noir"); + } + + joueurBlanc.initJoueur(BLANC); + System.out.println("Joueur Blanc : " + joueurBlanc.binoName()); + + joueurNoir.initJoueur(NOIR); + System.out.println("Joueur Noir : " + joueurNoir.binoName()); + + System.out.println("Initialisation des deux joueurs ok."); + + gameLoop(joueurBlanc, joueurNoir); + } +} diff --git a/dist/Puyaubreau_Russac/src/escampe/VerifMoves.java b/dist/Puyaubreau_Russac/src/escampe/VerifMoves.java new file mode 100644 index 0000000..83175b2 --- /dev/null +++ b/dist/Puyaubreau_Russac/src/escampe/VerifMoves.java @@ -0,0 +1,121 @@ +package escampe; + +import java.util.*; + +/** + * Cross-vérifie le chemin « int » du moteur contre le chemin « String » vérifié, + * sur des milliers de parties aléatoires : mêmes coups que possiblesMoves, makeInt + * équivalent à play, unmakeInt qui restaure l'état. Échoue à la moindre divergence. + */ +public class VerifMoves { + + static int mismatches = 0; + + public static void main(String[] args) { + int games = args.length > 0 ? Integer.parseInt(args[0]) : 3000; + Random rng = new Random(20260530L); + + long positions = 0, makeChecks = 0; + for (int g = 0; g < games; g++) { + EscampeBoard b = new EscampeBoard(); + // Placements aléatoires légaux. + int[] noirRows = rng.nextBoolean() ? new int[]{0, 1} : new int[]{4, 5}; + b.play(randomPlacement(b, "noir", noirRows, rng), "noir"); + int[] blancRows = (noirRows[0] == 0) ? new int[]{4, 5} : new int[]{0, 1}; + b.play(randomPlacement(b, "blanc", blancRows, rng), "blanc"); + + for (int ply = 0; ply < 200 && !b.gameOver(); ply++) { + positions++; + // (1) égalité des ensembles de coups, pour les deux couleurs. + checkMoveSets(b, true); + checkMoveSets(b, false); + + // Côté au trait : (2) make==play et (3) unmake, sur chaque coup. + boolean black = "noir".equals(b.currentPlayer); + String side = b.currentPlayer; + int[] moves = b.genMovesInt(black); + for (int m : moves) { + makeChecks++; + EscampeBoard after = b.copy(); + EscampeBoard.Undo u = after.makeInt(m); + EscampeBoard ref = b.copy(); + ref.play(b.moveToString(m), side); + if (!sameState(after, ref)) { + report(b, "make!=play pour " + b.moveToString(m) + " (" + side + ")"); + } + after.unmakeInt(u); + if (!sameState(after, b)) { + report(b, "unmake ne restaure pas pour " + b.moveToString(m)); + } + } + if (mismatches > 0) { dumpAndExit(); } + + // Avance la partie d'un coup aléatoire (chemin String vérifié). + if (moves.length == 1 && moves[0] == EscampeBoard.MOVE_PASS) { + b.play("E", side); + } else { + int m = moves[rng.nextInt(moves.length)]; + b.play(b.moveToString(m), side); + } + } + } + System.out.println("Parties : " + games); + System.out.println("Positions testées : " + positions); + System.out.println("make/unmake testés: " + makeChecks); + System.out.println(mismatches == 0 + ? "RÉSULTAT : OK — chemin int ≡ chemin String vérifié (0 divergence)." + : "RÉSULTAT : " + mismatches + " DIVERGENCES !"); + if (mismatches != 0) System.exit(1); + } + + /** Compare genMovesInt(black) et possiblesMoves(player) comme ensembles. */ + static void checkMoveSets(EscampeBoard b, boolean black) { + String player = black ? "noir" : "blanc"; + Set fromInt = new TreeSet<>(); + for (int m : b.genMovesInt(black)) fromInt.add(b.moveToString(m)); + Set fromStr = new TreeSet<>(Arrays.asList(b.possiblesMoves(player))); + if (!fromInt.equals(fromStr)) { + report(b, "ensembles différents pour " + player + + "\n int = " + fromInt + "\n str = " + fromStr); + } + } + + static boolean sameState(EscampeBoard a, EscampeBoard c) { + if (a.lastTileType != c.lastTileType) return false; + if (!a.currentPlayer.equals(c.currentPlayer)) return false; + for (int r = 0; r < 6; r++) + for (int col = 0; col < 6; col++) + if (a.board[r][col] != c.board[r][col]) return false; + return true; + } + + static void report(EscampeBoard b, String msg) { + if (mismatches < 5) { + System.out.println("DIVERGENCE : " + msg); + System.out.println(" lastTileType=" + b.lastTileType + " currentPlayer=" + b.currentPlayer); + } + mismatches++; + } + + static void dumpAndExit() { + System.out.println(">>> arrêt sur première divergence."); + System.exit(1); + } + + /** Placement aléatoire légal : 6 cases distinctes sur les 2 lignes, licorne en tête. */ + static String randomPlacement(EscampeBoard b, String player, int[] rows, Random rng) { + List cells = new ArrayList<>(); + for (int r : rows) for (int c = 0; c < 6; c++) cells.add(new int[]{r, c}); + for (int tries = 0; tries < 100; tries++) { + Collections.shuffle(cells, rng); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 6; i++) { + if (i > 0) sb.append('/'); + sb.append((char) ('A' + cells.get(i)[1])).append((char) ('1' + cells.get(i)[0])); + } + String pl = sb.toString(); + if (b.isValidMove(pl, player)) return pl; + } + throw new IllegalStateException("aucun placement légal trouvé"); + } +} diff --git a/dist/Puyaubreau_Russac_rapport.pdf b/dist/Puyaubreau_Russac_rapport.pdf new file mode 100644 index 0000000..7b5a9f7 Binary files /dev/null and b/dist/Puyaubreau_Russac_rapport.pdf differ diff --git a/dist/mainClass b/dist/mainClass new file mode 100644 index 0000000..b03f3f0 --- /dev/null +++ b/dist/mainClass @@ -0,0 +1,3 @@ +jar:Puyaubreau_Russac.jar +clientClass:escampe.ClientJeu +mainClass:escampe.JoueurPuyaubreauRussac diff --git a/escampe_save.txt b/escampe_save.txt new file mode 100644 index 0000000..aa6fbdd --- /dev/null +++ b/escampe_save.txt @@ -0,0 +1,12 @@ +% Escampe - sauvegarde du plateau +% lastTileType: 1 +% currentPlayer: blanc +% blackPlaced: true +% whitePlaced: true +% blackRows: 4,5 +06 Nnn--- 06 +05 ----nn 05 +04 ------ 04 +03 ------ 03 +02 b--n-b 02 +01 -bb-b- 01 diff --git a/jouer-vs-IA.bat b/jouer-vs-IA.bat new file mode 100644 index 0000000..ec9c72b --- /dev/null +++ b/jouer-vs-IA.bat @@ -0,0 +1,29 @@ +@echo off +REM ========================================================================== +REM Escampe — jouer (humain) contre notre IA, en local, sur cette machine. +REM Ouvre 3 fenetres : serveur, IA, et VOUS. Jouez dans la fenetre "VOUS". +REM ========================================================================== + +REM Jar du serveur : dans le repo (lib\) en priorite, sinon dans Downloads. +set "SERVEUR=%~dp0lib\escampeobf.jar" +if not exist "%SERVEUR%" set "SERVEUR=C:\Users\Kerboul\Downloads\escampeobf.jar" +REM Jar de notre IA (genere par build.sh, chemin relatif a ce .bat) : +set "IA=%~dp0dist\Puyaubreau_Russac.jar" + +if not exist "%SERVEUR%" echo [ERREUR] Introuvable : %SERVEUR% & pause & exit /b 1 +if not exist "%IA%" echo [ERREUR] Introuvable : %IA% (lancez d'abord build.sh) & pause & exit /b 1 + +echo Lancement du serveur... +start "Escampe - Serveur" cmd /k java -cp "%SERVEUR%" escampe.ServeurJeu 1234 1 +timeout /t 2 >nul + +echo Lancement de l'IA (Puyaubreau_Russac)... +start "Escampe - IA" cmd /k java -cp "%IA%" escampe.ClientJeu escampe.JoueurPuyaubreauRussac localhost 1234 +timeout /t 1 >nul + +echo Lancement de votre client humain... +start "Escampe - VOUS" cmd /k java -cp "%SERVEUR%" escampe.ClientJeu escampe.JoueurHumain localhost 1234 + +echo. +echo C'est parti ! Jouez dans la fenetre "Escampe - VOUS". +echo (Le serveur ouvre aussi une fenetre graphique du plateau.) diff --git a/jouer-vs-pote.bat b/jouer-vs-pote.bat new file mode 100644 index 0000000..68adca2 --- /dev/null +++ b/jouer-vs-pote.bat @@ -0,0 +1,26 @@ +@echo off +REM ========================================================================== +REM Escampe — deux HUMAINS sur la MEME machine (3 fenetres). +REM Chaque joueur joue dans sa fenetre "Joueur 1" / "Joueur 2". +REM +REM Pour jouer a DISTANCE avec un pote (2 PC), voir MULTIJOUEUR.md : +REM l'hote lance le serveur, le pote se connecte sur l'IP de l'hote. +REM ========================================================================== + +set "SERVEUR=%~dp0lib\escampeobf.jar" +if not exist "%SERVEUR%" set "SERVEUR=C:\Users\Kerboul\Downloads\escampeobf.jar" +if not exist "%SERVEUR%" echo [ERREUR] Introuvable : %SERVEUR% & pause & exit /b 1 + +echo Lancement du serveur... +start "Escampe - Serveur" cmd /k java -cp "%SERVEUR%" escampe.ServeurJeu 1234 1 +timeout /t 2 >nul + +echo Lancement du Joueur 1... +start "Escampe - Joueur 1" cmd /k java -cp "%SERVEUR%" escampe.ClientJeu escampe.JoueurHumain localhost 1234 +timeout /t 1 >nul + +echo Lancement du Joueur 2... +start "Escampe - Joueur 2" cmd /k java -cp "%SERVEUR%" escampe.ClientJeu escampe.JoueurHumain localhost 1234 + +echo. +echo A vous deux ! Chacun joue dans sa fenetre. diff --git a/lib/escampeobf.jar b/lib/escampeobf.jar new file mode 100644 index 0000000..4205313 Binary files /dev/null and b/lib/escampeobf.jar differ diff --git a/main-polytech.pdf b/main-polytech.pdf new file mode 100644 index 0000000..37069ae Binary files /dev/null and b/main-polytech.pdf differ diff --git a/partie1.md b/partie1.md new file mode 100644 index 0000000..0299fd6 --- /dev/null +++ b/partie1.md @@ -0,0 +1,9 @@ +# Analyse des caract eristiques du jeu +## Ethan PUYAUBREAU & Antonin RUSSAC + +1. + +2. Une configuration peut être considérée comme une fin de partie si : +- Il ne reste qu'un licorne sur le terrain + +3. \ No newline at end of file diff --git a/report/rapport.html b/report/rapport.html new file mode 100644 index 0000000..c3ecbfa --- /dev/null +++ b/report/rapport.html @@ -0,0 +1,447 @@ + + + + +Escampe — Rapport (version finale) + + + + +
+
Université Paris-Saclay — Polytech APP5 — Année 2025-2026
+
IA et contraintes
+

Devoir Escampe

+
Conception et réalisation d'un joueur artificiel
+
Rapport — version finale
+
+ Ethan Puyaubreau  &  Antonin Russac +
+
30 mai 2026
+
+ Joueur : escampe.JoueurPuyaubreauRussac
+ Encadrement : Yue Ma (yue.ma@universite-paris-saclay.fr) +
+
+ + +

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 (couleur noire +ou blanche). Les lignes sont numérotées de 1 à 6, les colonnes de A à F. Le but +est de prendre la licorne adverse.

+ +

La règle caractéristique du jeu est une contrainte de liseré : +la pièce que l'on joue doit partir d'une case dont le liseré est identique +à celui de la case d'arrivée du coup adverse précédent. Le liseré de la case de +départ fixe en outre le nombre de pas (1, 2 ou 3), orthogonaux, sans traverser ni +revisiter de case. On ne capture qu'en se posant, au dernier pas, sur la licorne +adverse — les paladins sont imprenables. Si un joueur ne peut rien jouer, il passe +son tour.

+ +

Le déroulement : Noir place ses six pièces sur les deux lignes d'un bord +(haut ou bas) ; Blanc fait de même sur le bord opposé ; Blanc joue le +premier coup. Ce rapport décrit nos choix de modélisation (parties 1 +et 2) puis la conception du joueur artificiel pour le tournoi (partie 3), +avec les mesures qui justifient nos choix.

+ + +

2. Analyse des caractéristiques du jeu

+ +

Nous reprenons ici les sept questions de la première partie, en les étayant +par l'implémentation finalement réalisée.

+ +

Q1 — Modélisation d'un état

+

Le plateau est un tableau int[6][6] : board[ligne][colonne] +avec ligne 0 = ligne 1 (bas) et colonne 0 = A. +Chaque case contient une constante de pièce (EMPTY, +WHITE_LICORNE, WHITE_PALADIN, BLACK_LICORNE, +BLACK_PALADIN). L'état complémentaire, indispensable à la règle, est +maintenu hors du plateau :

+
    +
  • lastTileType : liseré imposé au coup suivant (-1 = aucune contrainte) ;
  • +
  • currentPlayer : joueur au trait ;
  • +
  • blackPlaced, whitePlaced : fin des phases de placement ;
  • +
  • blackRows : le bord choisi par Noir (en déduit celui de Blanc).
  • +
+

Avantages. Accès O(1) à toute case ; copie immédiate de l'état +pour l'arbre de recherche ; sérialisation triviale ; surtout, un schéma +make/unmake sans aucune allocation (essentiel pour la vitesse, §6). +Inconvénient. La contrainte de liseré est un état séparé qu'il +faut maintenir explicitement à chaque coup ; nous l'encapsulons dans play.

+ +

La carte des liserés est une constante TILE_MAP reproduisant la +figure 4 de l'énoncé (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
+

Fait vérifié : cette carte est identique, case pour case, +à celle utilisée en interne par l'arbitre du tournoi — nous l'avons extraite par +réflexion de la classe de jeu du serveur fourni. Elle est aussi cohérente avec +l'exemple tactique de la figure 6 de l'énoncé. Une carte divergente aurait +produit des coups jugés illégaux : ce point était critique.

+ +

Q2 — Détection de fin de partie

+

La partie est finie dès qu'une des deux licornes a disparu du plateau (seul cas +de fin, pas de match nul). La vérification est un simple balayage O(1) du plateau +(gameOver) ; le moteur, lui, détecte la capture directement au moment +où elle est jouée (§6).

+ +

Q3 — Sources de difficulté et facteur de branchement

+

Les principales sources de difficulté sont :

+
    +
  • la contrainte de liseré, qui limite fortement et variablement la mobilité ;
  • +
  • la dépendance entre tours : la case d'arrivée choisie détermine les pièces que l'adversaire pourra jouer ;
  • +
  • l'asymétrie du plateau (zones riches en liserés triples, donc mobiles, vs zones simples) ;
  • +
  • le risque de blocage d'une pièce, voire d'un joueur (pass forcé).
  • +
+

Facteur de branchement. En première partie nous avions estimé +une borne théorique de l'ordre de 120 (6 pièces × jusqu'à ~20 destinations +sur liseré triple). La mesure réelle est bien plus basse, car la contrainte de +liseré ne laisse jouables que les pièces du bon liseré. Sur 30 000 parties +aléatoires simulées (utilitaire escampe.Branching) :

+ + + + + +
SituationBranchement maximal observé
Coup contraint (un liseré imposé)45
Coup libre (1er coup ou après un pass, aucune contrainte)49
Branchement moyen (toutes positions)≈ 8,9
+

Le branchement effectif modeste (moyenne < 10) explique qu'une recherche +alpha-bêta atteigne des profondeurs élevées en quelques secondes (§6).

+ +

Q4 — Coups imparables

+

Il n'existe pas de coup « imparable » universel garanti dès le départ : la +contrainte de liseré peut toujours empêcher l'exécution d'une menace au mauvais +moment. En revanche, certaines configurations créent un zugzwang +partiel où l'adversaire ne peut éviter d'imposer le liseré qui nous +arrange — l'énoncé en donne l'exemple (figure 6 : le paladin blanc en C2 prend +la licorne en C1 dès que Noir est forcé d'imposer un liseré double). Construire de +tels pièges est un axe stratégique ; notre recherche les exploite implicitement +quand ils sont à portée d'horizon.

+ +

Q5 — Critères pour l'heuristique

+

Nous avions identifié cinq critères : distance à la licorne adverse, mobilité +différentielle, contrôle du liseré imposé, protection de sa propre licorne, et +avancée sur le plateau. L'heuristique finalement retenue (§7) s'appuie sur la +proximité des paladins à la licorne adverse (pression d'attaque) +et l'éloignement des paladins adverses de notre licorne +(sécurité) — les autres critères sont, en pratique, largement pris en charge par +la recherche elle-même.

+ +

Q6 — Stratégie selon la phase

+
    +
  • Début (placement) : irréversible et déterminant. On protège + la licorne et on garantit de toujours pouvoir jouer (§5).
  • +
  • Milieu : manœuvre pour construire des menaces sur la licorne + adverse tout en contrôlant le liseré imposé ; recherche de zugzwang partiel.
  • +
  • Fin : dès qu'une capture est à portée, le calcul tactique + (recherche profonde) prime.
  • +
+ +

Q7 — Majorant du nombre de coups et gestion du temps

+

Aucune pièce ne disparaît avant la capture finale ; une partie peut donc +théoriquement s'étirer. En bornant le branchement par tour et en comptant quelques +dizaines de tours, une borne raisonnable se situe vers 400–600 demi-coups. Pour +tenir la contrainte de temps (300 s par joueur et par partie), nous combinons +approfondissement itératif, élagage alpha-bêta et +un budget par coup dérivé du temps restant (§8).

+ + +

3. Modélisation : la classe EscampeBoard

+ +

EscampeBoard (≈ 860 lignes) implémente l'interface fournie +Partie1 : setFromFile / saveToFile, +isValidMove, possiblesMoves, play, +gameOver. Les conventions de l'arbitre sont respectées à la lettre :

+
    +
  • coup régulier "B1-D1" ;
  • +
  • placement "C6/A6/B5/D5/E6/F5" (licorne en tête, puis les 5 paladins) ;
  • +
  • pass "E".
  • +
+ +

Format de fichier. Six lignes de plateau (bas vers haut), +caractères N/n (licorne/paladin noir), B/b (blanc), +- (vide), chaque ligne encadrée d'un numéro ; toute autre ligne +commence par % (commentaire). Nous y ajoutons en commentaires l'état +hors-plateau (liseré courant, joueur, bord de Noir) afin que la sauvegarde soit +fidèlement rechargeable.

+ +

Génération des coups. Depuis une case, on énumère les +destinations par un parcours en profondeur (DFS) avec retour arrière : exactement +N pas (N = liseré de départ), cases intermédiaires vides, dernière case vide ou +occupée par la licorne adverse (capture). possiblesMoves filtre les +pièces sur le bon liseré et renvoie ["E"] si aucun coup n'est possible. +Une méthode main illustre placements, contrainte de liseré, pass, +round-trip fichier et capture.

+ +

Bug latent corrigé en partie 3 : un placement légal mais +disposé sur une seule ligne faisait planter le calcul du bord de Noir +(il supposait deux lignes distinctes). Le bord est désormais déduit de façon +robuste à partir de la ligne de la licorne.

+ + +

4. Intégration au tournoi : le protocole de l'arbitre

+ +

Le joueur escampe.JoueurPuyaubreauRussac implémente l'interface +fournie IJoueur et enveloppe un EscampeBoard tenu à jour +à chaque coup (le nôtre comme celui de l'adversaire, via mouvementEnnemi). +Trois points d'adaptation, dont deux vérifiés par analyse du jar de +l'arbitre car l'infrastructure fournie est obfusquée :

+
    +
  • Couleurs. IJoueur parle en entiers + (NOIR = 1, BLANC = -1) ; + EscampeBoard en chaînes "noir"/"blanc".
  • +
  • Pass = "E", et non "PASSE". Le + Javadoc d'IJoueur indique "PASSE", mais la classe de + jeu du serveur teste move.equals("E") (et "PASSE" + n'apparaît nulle part dans le jar). Envoyer "PASSE" aurait valu une + défaite sur coup illégal.
  • +
  • Carte des liserés identique à celle du serveur (cf. Q1).
  • +
+ +

Machine à états. Le placement et les coups transitent par le +même canal choixMouvement/mouvementEnnemi. Le premier +appel à choixMouvement renvoie donc un placement, les suivants +des coups ; la phase est détectée via blackPlaced/whitePlaced. +La séquence (déduite de la classe Solo fournie) est :

+
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) → ...
+

En appliquant chaque coup à l'EscampeBoard interne dans cet ordre, +le joueur au trait reste naturellement synchronisé avec l'arbitre.

+ +

Exécution. Trois processus (serveur + 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 est irréversible : nous l'avons conçu à partir d'un constat issu de +l'auto-jeu — une licorne mal placée peut se retrouver seule pièce jouable et +bloquée sur le liseré imposé, forçant des passes successifs qui livrent +l'initiative à l'adversaire. Trois principes y répondent :

+
    +
  1. Licorne dans un coin. Un coin n'a que deux cases voisines : + seulement deux cases d'où l'adversaire peut l'atteindre.
  2. +
  3. Murs. On occupe ces deux voisines par des paladins. La + licorne devient incapturable tant que les murs tiennent (impossible de + franchir le dernier pas sur une case occupée).
  4. +
  5. Couverture des liserés. Les trois paladins restants sont + placés sur des cases de liserés 1, 2 et 3 distincts : quel que + soit le liseré imposé, on dispose toujours d'une pièce mobile — jamais de pass + forcé, jamais besoin de déplacer un mur ou la licorne.
  6. +
+ +

Dispositions retenues (légalité et propriétés vérifiées) ; pour Blanc, on joue +le bord complémentaire de celui de Noir :

+
Bord bas  A1/A2/B1/E1/F1/C2          Bord haut  A6/A5/B6/C5/F5/E6
+   A B C D E F                          A B C D E F
+ 2 n . . . . .                        6 N b . . b .
+ 1 N n . n n n                        5 b . b . . b
+ (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)
+ + +

6. Moteur de décision

+ +

La décision repose sur un negamax avec élagage +alpha-bêta et approfondissement itératif (classe +Moteur). La recherche s'effectue sur une copie du plateau, +jamais sur l'état réel. Capturer la licorne adverse est traité comme un nœud +terminal de valeur WIN - ply (gagner vite plutôt que tard).

+ +

Astuces de performance.

+
    +
  • Coups encodés en entier (case = ligne×6+colonne, + coup = départ×36+arrivée) : aucune chaîne manipulée dans la boucle + chaude.
  • +
  • DFS sur masque de bits long : les 36 cases + tiennent dans un long ; les ensembles « visité » et « atteignable » + sont des masques — pas d'allocation de tableau par appel.
  • +
  • make/unmake sans allocation : un + petit jeton d'annulation suffit à défaire un coup, ce qui permet d'explorer des + millions de nœuds sans pression sur le ramasse-miettes.
  • +
  • Buffers de coups pré-alloués, un par profondeur.
  • +
  • Ordonnancement : tout coup capturant la licorne est essayé + en premier (coupure immédiate) ; le meilleur coup d'une itération est replacé en + tête à l'itération suivante.
  • +
+ +

Cohérence des deux chemins. Le chemin « entier » du moteur double +le chemin « chaîne » vérifié de EscampeBoard. Pour exclure toute +divergence silencieuse entre ces deux implémentations des règles, un test croisé +(VerifMoves, §9) vérifie qu'ils produisent exactement les mêmes coups +et les mêmes états — c'est la garantie qu'optimiser n'a pas changé les règles.

+ +

Performance mesurée. Environ 4 à 5 millions de +nœuds par seconde. En milieu de partie, l'approfondissement itératif +atteint une profondeur de 12 à 15 demi-coups en 6 s (davantage +dans les positions étroites). Les annonces de gain forcé du moteur se matérialisent +bien par une capture effective lors des parties de contrôle.

+ + +

7. Heuristique d'évaluation

+ +

Le matériel étant constant (paladins imprenables, licornes présentes jusqu'à la +capture), l'évaluation d'une position non terminale est purement positionnelle, +exprimée du point de vue du joueur au trait. Elle somme, à partir des distances de +Manhattan :

+
    +
  • Pression d'attaque : proximité de nos paladins à la licorne + adverse — un terme de somme (pression globale) et un terme de + minimum (l'attaquant le plus proche pèse davantage) ;
  • +
  • Sécurité : éloignement des paladins adverses de notre + licorne — mêmes deux termes, de signe opposé.
  • +
+

Concrètement, avec les poids retenus (somme = 2, minimum = 8) :

+
eval = 2·Σ(10−d_attaque) − 2·Σ(10−d_défense)
+       + 8·(10−min d_attaque) − 8·(10−min d_défense)
+ +

Heuristiques testées et choix. Le réglage s'est fait par +auto-jeu déterministe et matchs arbitrés contre le joueur aléatoire fourni. Nous +avons comparé : (a) somme seule — jeu trop diffus, le moteur tarde à +concentrer une menace ; (b) somme + minimum (retenue) — le terme minimum, +fortement pondéré, oriente nettement les paladins vers la licorne adverse et +améliore le taux de capture ; (c) ajout d'un terme défensif symétrique — conservé, +il évite d'exposer notre licorne sans nuire à l'attaque. Le fort poids du terme +minimum reflète que, dans ce jeu, c'est l'attaquant le plus avancé qui +décide d'une prise.

+ +

Limite assumée. Faute d'adversaires IA tiers disponibles avant le +tournoi, ces poids sont validés contre l'aléatoire et en auto-jeu, non contre +d'autres joueurs forts. Les tactiques de capture à court terme sont, elles, +gérées par la recherche, ce qui rend le joueur robuste même avec une évaluation +positionnelle simple.

+ + +

8. Gestion du temps réel

+ +

La limite de l'arbitre est de 300 s par joueur et par partie. Nous nous +fixons une enveloppe interne de 280 s (≈ 20 s de marge). +Le budget alloué à un coup est une fraction du temps restant, bornée :

+
tranche = clamp( temps_restant / 12 , 120 ms , 6000 ms )
+

La division par le temps restant décroît géométriquement : le budget ne peut +jamais être épuisé, même sur une partie très longue. Le plafond de +6 s évite de surinvestir en ouverture ; un plancher de 120 ms garantit un +minimum de réflexion ; un mode « panique » sécurise les toutes dernières secondes. +L'approfondissement itératif rend le meilleur coup déjà trouvé dès que la tranche +expire (le temps est contrôlé toutes les 2048 explorations de nœuds).

+ +

Mesures (auto-jeu équilibré, plein budget) : temps +maximal par coup ≈ 6,0 s (le plafond), cumul maximal +≈ 36 s par joueur sur une partie complète — très loin des 300 s. +Le réglage est volontairement conservateur et pourrait être augmenté sans risque.

+ + +

9. Performances et tests

+ +

Notre démarche de validation est empirique et redondante : chaque maillon est +contrôlé contre une référence indépendante.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TestCe qu'il garantitRésultat
VerifMovesChemin entier (moteur) ≡ chemin chaîne (vérifié) : mêmes coups, même + make/unmake3 000 parties · 142 165 positions · 1 281 985 contrôles · 0 divergence
RulesTestRègles directes : pas = liseré, capture au dernier pas, paladins + imprenables, non-traversée, contrainte de liseré, pass forcé, fin, zones de placement21 / 21
Matchs arbitrés vs JoueurAleatoireProtocole de bout en bout (placement, liseré, pass, couleurs), légalité7 / 7 victoires, 0 coup illégal, 0 exception (les deux couleurs)
Démo IA vs IA (serveur réel)Partie complète moteur contre moteur, gestion des pass21 coups, fin propre par capture
Bench / BranchingVitesse, profondeur, facteur de branchement≈ 4–5 M nœuds/s ; profondeur 12–15 ; branchement max 49 / moyen ≈ 8,9
+ +

La séparation des rôles est délibérée : VerifMoves prouve que le +moteur ≡ EscampeBoard ; RulesTest prouve que +EscampeBoard respecte les règles ; les parties arbitrées prouvent que +le tout dialogue correctement avec l'arbitre réel. Aucun coup illégal n'a été +produit sur l'ensemble des parties jouées.

+ + +

10. Compilation, exécution et livrables

+ +

Le script build.sh produit 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 de rendu : répertoire Puyaubreau_Russac/
+                         contenant src/escampe/*.java + mainClass + le jar
+

Seules les classes de production entrent dans le jar (le joueur, le moteur, le +plateau et les classes fournies) ; les utilitaires de test (VerifMoves, +RulesTest, Bench, Branching) en sont exclus. +Le jeu en multijoueur (humain contre humain, ou humain contre notre IA, en local +ou à distance) est documenté à part dans MULTIJOUEUR.md.

+ + +

11. Sources et bibliographie

+
    +
  • Énoncé du cours (Université Paris-Saclay, Polytech APP5, + 2025-2026) : règles d'Escampe, carte des liserés (figure 4), interface + Partie1, et classes d'infrastructure fournies + (IJoueur, ClientJeu, Solo, + Applet, serveur).
  • +
  • Algorithmes classiques, à titre d'inspiration et sans copie + de code : élagage alpha-bêta (Knuth & Moore, An Analysis of Alpha-Beta + Pruning, 1975) ; minimax, negamax et approfondissement itératif + (Russell & Norvig, Artificial Intelligence: A Modern Approach) ; + techniques de représentation par masques de bits et d'ordonnancement de coups + (Chess Programming Wiki).
  • +
  • Déclaration. Aucun programme d'Escampe externe n'a été + recopié. La seule rétro-ingénierie effectuée porte sur le jar d'arbitre + fourni avec le sujet, dans le seul but de confirmer le protocole (pass + "E") et la carte des liserés — points sur lesquels la documentation + était ambiguë.
  • +
+ + +

12. Conclusion et difficultés rencontrées

+

Le joueur conduit une partie de façon autonome, dialogue correctement avec +l'arbitre, ne produit jamais de coup illégal et respecte très confortablement la +contrainte de temps. Les principales difficultés ont été :

+
    +
  • L'obfuscation du serveur : lever l'ambiguïté du pass + ("E" vs "PASSE") et confirmer la carte des liserés a + demandé une analyse du jar — étape décisive pour ne pas perdre sur coup illégal.
  • +
  • L'interface obfusquée vs nos sources : le joueur aléatoire du + jar n'implémente pas notre IJoueur ; les tests contre lui passent + donc par le réseau (seules des chaînes circulent).
  • +
  • L'avantage du trait : en miroir, Blanc (premier à jouer) + conserve l'initiative via la contrainte de liseré — propriété du jeu, indépendante + de la force du moteur.
  • +
  • Le réglage de l'heuristique sans adversaires : validé contre + l'aléatoire et en auto-jeu.
  • +
+

Pistes d'amélioration : table de transposition (hachage de +Zobrist), bibliothèque d'ouvertures de placement, terme de mobilité différentielle +dans l'évaluation, et recherche de quiescence sur les menaces de capture.

+ + + diff --git a/scripts/bench_vs_random.sh b/scripts/bench_vs_random.sh new file mode 100644 index 0000000..c9d3d8f --- /dev/null +++ b/scripts/bench_vs_random.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Lot de parties arbitrées contre JoueurAleatoire, alternant les couleurs. +# Convention observée : le 1er connecté = JOUEUR 1 = Blanc, le 2e = JOUEUR 2 = Noir. +# Donc : moi en joueur A => je suis Blanc ; moi en joueur B => je suis Noir. +# +# usage: bench_vs_random.sh [N_par_couleur] [sliceMs] +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT" +N="${1:-3}"; SLICE="${2:-300}" +JAR="$ROOT/lib/escampeobf.jar"; [ -f "$JAR" ] || JAR="C:/Users/Kerboul/Downloads/escampeobf.jar" +ME="escampe.JoueurPuyaubreauRussac"; RND="escampe.JoueurAleatoire" +LOG="$ROOT/scripts/logs" +port=1300; wins=0; losses=0; illegal=0; exc=0; games=0 + +play() { # $1 = ma couleur attendue (Blanc|Noir) + port=$((port+1)); games=$((games+1)) + if [ "$1" = "Blanc" ]; then + OPTS_A="-Descampe.maxSliceMs=$SLICE" OPTS_B="" \ + bash scripts/match.sh "$ME" out "$RND" "$JAR" "$port" 45 >/dev/null 2>&1 + else + OPTS_A="" OPTS_B="-Descampe.maxSliceMs=$SLICE" \ + bash scripts/match.sh "$RND" "$JAR" "$ME" out "$port" 45 >/dev/null 2>&1 + fi + local winner; winner=$(grep -aoE "FIN! (Blanc|Noir)" "$LOG/server.log" | tail -1 | awk '{print $2}') + local il; il=$(grep -ac "illegal" "$LOG/server.log"); il=${il//[^0-9]/}; il=${il:-0} + illegal=$((illegal + il)) + # exception côté MON client (A si Blanc, B si Noir) + local mylog; [ "$1" = "Blanc" ] && mylog="$LOG/playerA.log" || mylog="$LOG/playerB.log" + if grep -aqiE "exception|\bat escampe\." "$mylog" 2>/dev/null; then exc=$((exc+1)); fi + if [ "$winner" = "$1" ]; then wins=$((wins+1)); R=GAGNE; else losses=$((losses+1)); R=perdu; fi + echo " partie $games : moi=$1 vainqueur=$winner -> $R" +} + +echo "=== $N parties en Blanc, $N en Noir (slice ${SLICE}ms) ===" +for i in $(seq 1 "$N"); do play Blanc; done +for i in $(seq 1 "$N"); do play Noir; done +echo "-------------------------------------------" +echo "Victoires : $wins / $games" +echo "Défaites : $losses" +echo "Coups illégaux (arbitre) : $illegal" +echo "Exceptions dans mon client : $exc" diff --git a/scripts/match.sh b/scripts/match.sh new file mode 100644 index 0000000..2ea7427 --- /dev/null +++ b/scripts/match.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Lance une partie ARBITRÉE entre deux IJoueur via le serveur réseau fourni. +# +# usage: match.sh [CLASS_A] [CP_A] [CLASS_B] [CP_B] [PORT] [TIMEOUT_S] +# +# Par défaut : notre joueur (depuis out/) contre escampe.JoueurAleatoire (jar). +# Le serveur (escampe.ServeurJeu) et les adversaires de référence vivent dans +# escampeobf.jar, fourni séparément (hors livrable). Seules des chaînes de +# caractères circulent sur le réseau : la divergence d'interface obfusquée +# entre le jar et nos sources est donc sans effet. +set -u + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" + +# Jar arbitre : dans le repo (lib/) en priorité, sinon dans Downloads. +JAR="$ROOT/lib/escampeobf.jar" +[ -f "$JAR" ] || JAR="C:/Users/Kerboul/Downloads/escampeobf.jar" +CLA="${1:-escampe.JoueurPuyaubreauRussac}"; CPA="${2:-out}" +CLB="${3:-escampe.JoueurAleatoire}"; CPB="${4:-$JAR}" +PORT="${5:-1234}"; TMO="${6:-60}" +OPTS_A="${OPTS_A:-}" # options JVM pour le joueur A (ex: -Descampe.debug=true) +OPTS_B="${OPTS_B:-}" + +LOG="$ROOT/scripts/logs"; mkdir -p "$LOG"; rm -f "$LOG"/*.log + +echo "Serveur : ServeurJeu $PORT 1" +echo "Joueur A : $CLA (cp=$CPA)" +echo "Joueur B : $CLB (cp=$CPB)" +echo "----------------------------------------" + +java -cp "$JAR" escampe.ServeurJeu "$PORT" 1 > "$LOG/server.log" 2>&1 & +SRV=$! +sleep 2 +java $OPTS_A -cp "$CPA" escampe.ClientJeu "$CLA" localhost "$PORT" > "$LOG/playerA.log" 2>&1 & +A=$! +sleep 1 +java $OPTS_B -cp "$CPB" escampe.ClientJeu "$CLB" localhost "$PORT" > "$LOG/playerB.log" 2>&1 & +B=$! + +# Chien de garde : tue tout après TMO secondes si la partie ne se termine pas. +( sleep "$TMO"; kill $A $B $SRV 2>/dev/null ) & WATCH=$! + +wait $A 2>/dev/null +wait $B 2>/dev/null +kill $SRV 2>/dev/null +kill $WATCH 2>/dev/null + +echo "=== SERVER ==="; cat "$LOG/server.log" +echo; echo "=== PLAYER A ($CLA) ==="; cat "$LOG/playerA.log" +echo; echo "=== PLAYER B ($CLB) ==="; cat "$LOG/playerB.log" diff --git a/src/EscampeBoard.java b/src/EscampeBoard.java new file mode 100644 index 0000000..0bb51a3 --- /dev/null +++ b/src/EscampeBoard.java @@ -0,0 +1,743 @@ +import java.io.*; +import java.util.*; + +/** + * Représentation d'un état du jeu Escampe. + * + *

Le plateau est un tableau {@code int[6][6]} : + *

    + *
  • {@code board[row][col]} avec row 0 = ligne 1 (bas), row 5 = ligne 6 (haut).
  • + *
  • col 0 = colonne A, col 5 = colonne F.
  • + *
+ * + *

Chaque case stocke l'une des constantes pièce : + * {@code EMPTY}, {@code WHITE_LICORNE}, {@code WHITE_PALADIN}, + * {@code BLACK_LICORNE}, {@code BLACK_PALADIN}. + * + *

L'état complémentaire mémorisé : + *

    + *
  • {@code lastTileType} : type de liseré (1, 2 ou 3) de la case d'arrivée du dernier coup ; + * -1 = pas de contrainte (premier coup ou après un pass).
  • + *
  • {@code currentPlayer} : "noir" ou "blanc", joueur dont c'est le tour.
  • + *
  • {@code blackPlaced}, {@code whitePlaced} : phases de placement terminées.
  • + *
  • {@code blackRows} : les deux lignes (index 0-5) choisies par noir lors du placement.
  • + *
+ * + *

Règles de déplacement : + *

    + *
  • Une pièce avance exactement N pas orthogonaux (N = liseré de la case de départ).
  • + *
  • Elle peut changer de direction à chaque pas.
  • + *
  • Elle ne peut pas passer par une case occupée ni repasser deux fois par la même case.
  • + *
  • Au dernier pas uniquement, elle peut se poser sur la licorne adverse (capture).
  • + *
+ */ +public class EscampeBoard implements Partie1 { + + // ── Constantes pièces ──────────────────────────────────────────────────── + + static final int EMPTY = 0; + static final int WHITE_LICORNE = 1; + static final int WHITE_PALADIN = 2; + static final int BLACK_LICORNE = 3; + static final int BLACK_PALADIN = 4; + + /** + * Carte des liserés : {@code TILE_MAP[row][col]}. + * row 0 = ligne 1 (bas), row 5 = ligne 6 (haut). col 0 = A, col 5 = F. + */ + static final int[][] TILE_MAP = { + {1, 2, 2, 3, 1, 2}, // ligne 1 + {3, 1, 3, 1, 3, 2}, // ligne 2 + {2, 3, 1, 2, 1, 3}, // ligne 3 + {2, 1, 3, 2, 3, 1}, // ligne 4 + {1, 3, 1, 3, 1, 2}, // ligne 5 + {3, 2, 2, 1, 3, 2}, // ligne 6 + }; + + // ── État ───────────────────────────────────────────────────────────────── + + int[][] board; + int lastTileType; // -1 = pas de contrainte + String currentPlayer; // "noir" ou "blanc" + boolean blackPlaced; + boolean whitePlaced; + int[] blackRows; // les 2 lignes (0-indexé) choisies par noir + + // ── Constructeur ───────────────────────────────────────────────────────── + + public EscampeBoard() { + board = new int[6][6]; + lastTileType = -1; + currentPlayer = "noir"; + blackPlaced = false; + whitePlaced = false; + blackRows = null; + } + + // ========================================================================= + // Fichier I/O + // ========================================================================= + + @Override + public void setFromFile(String fileName) { + board = new int[6][6]; + lastTileType = -1; + currentPlayer = "noir"; + blackPlaced = false; + whitePlaced = false; + blackRows = null; + + try (BufferedReader br = new BufferedReader(new FileReader(fileName))) { + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + + char first = line.charAt(0); + + // Commentaire / méta-donnée + if (first == '%') { + parseMeta(line); + continue; + } + + // Ligne de plateau : "1 XXXX 1" ou "01 XXXX 01" + int rowNum = -1; + int pos = 0; + if (first >= '1' && first <= '6') { + rowNum = first - '0'; + pos = 1; + } else if (first == '0' && line.length() > 1) { + char second = line.charAt(1); + if (second >= '1' && second <= '6') { + rowNum = second - '0'; + pos = 2; + } + } + if (rowNum != -1) { + int rowIdx = rowNum - 1; + while (pos < line.length() && line.charAt(pos) == ' ') pos++; + for (int c = 0; c < 6 && pos + c < line.length(); c++) { + board[rowIdx][c] = charToPiece(line.charAt(pos + c)); + } + } + } + } catch (IOException e) { + throw new RuntimeException("Erreur de lecture du fichier : " + fileName, e); + } + + // Si pas de méta-commentaires, on infère l'état à partir des pièces + inferState(); + } + + /** Parse une ligne de méta-commentaire "% clé: valeur". */ + private void parseMeta(String line) { + if (line.startsWith("% lastTileType:")) { + lastTileType = Integer.parseInt(line.substring(15).trim()); + } else if (line.startsWith("% currentPlayer:")) { + currentPlayer = line.substring(16).trim(); + } else if (line.startsWith("% blackPlaced:")) { + blackPlaced = Boolean.parseBoolean(line.substring(14).trim()); + } else if (line.startsWith("% whitePlaced:")) { + whitePlaced = Boolean.parseBoolean(line.substring(14).trim()); + } else if (line.startsWith("% blackRows:")) { + String s = line.substring(12).trim(); + String[] parts = s.split(","); + int r0 = Integer.parseInt(parts[0].trim()); + int r1 = Integer.parseInt(parts[1].trim()); + if (r0 >= 0) blackRows = new int[]{r0, r1}; + } + } + + /** + * Infère {@code blackPlaced}, {@code whitePlaced} et {@code blackRows} + * à partir des pièces présentes sur le plateau + * (utilisé quand le fichier ne contient pas de méta-commentaires). + */ + private void inferState() { + if (blackPlaced && whitePlaced) return; // méta déjà chargée + + int bc = 0, wc = 0; + Set bRowSet = new TreeSet<>(); + for (int r = 0; r < 6; r++) { + for (int c = 0; c < 6; c++) { + int p = board[r][c]; + if (p == BLACK_LICORNE || p == BLACK_PALADIN) { bc++; bRowSet.add(r); } + if (p == WHITE_LICORNE || p == WHITE_PALADIN) { wc++; } + } + } + if (!blackPlaced && bc == 6) { + blackPlaced = true; + if (bRowSet.size() == 2) { + Iterator it = bRowSet.iterator(); + blackRows = new int[]{it.next(), it.next()}; + } + } + if (!whitePlaced && wc == 6) { + whitePlaced = true; + } + } + + @Override + public void saveToFile(String fileName) { + try (PrintWriter pw = new PrintWriter(new FileWriter(fileName))) { + pw.println("% Escampe - sauvegarde du plateau"); + pw.println("% lastTileType: " + lastTileType); + pw.println("% currentPlayer: " + currentPlayer); + pw.println("% blackPlaced: " + blackPlaced); + pw.println("% whitePlaced: " + whitePlaced); + if (blackRows != null) { + pw.println("% blackRows: " + blackRows[0] + "," + blackRows[1]); + } else { + pw.println("% blackRows: -1,-1"); + } + // Lignes 6 à 1 (haut vers bas dans le fichier) + for (int rowIdx = 5; rowIdx >= 0; rowIdx--) { + int rowNum = rowIdx + 1; + StringBuilder sb = new StringBuilder(); + String rowLabel = String.format("%02d", rowNum); + sb.append(rowLabel).append(' '); + for (int c = 0; c < 6; c++) sb.append(pieceToChar(board[rowIdx][c])); + sb.append(' ').append(rowLabel); + pw.println(sb.toString()); + } + } catch (IOException e) { + throw new RuntimeException("Erreur d'écriture du fichier : " + fileName, e); + } + } + + // ========================================================================= + // Fin de partie + // ========================================================================= + + @Override + public boolean gameOver() { + if (!blackPlaced || !whitePlaced) return false; + boolean wl = false, bl = false; + for (int r = 0; r < 6; r++) + for (int c = 0; c < 6; c++) { + if (board[r][c] == WHITE_LICORNE) wl = true; + if (board[r][c] == BLACK_LICORNE) bl = true; + } + return !wl || !bl; + } + + // ========================================================================= + // Validation d'un coup + // ========================================================================= + + @Override + public boolean isValidMove(String move, String player) { + if (move == null || move.isEmpty()) return false; + if (!"noir".equals(player) && !"blanc".equals(player)) return false; + + if (move.contains("/")) return isValidPlacement(move, player); + if ("E".equals(move)) return isValidPass(player); + return isValidRegularMove(move, player); + } + + /** + * Valide un coup de placement "P1/P2/P3/P4/P5/P6" + * (P1 = licorne, P2-P6 = paladins). + */ + private boolean isValidPlacement(String move, String player) { + if ("noir".equals(player) && blackPlaced) return false; + if ("blanc".equals(player) && whitePlaced) return false; + if (!player.equals(currentPlayer)) return false; + if ("blanc".equals(player) && !blackPlaced) return false; + + String[] parts = move.split("/"); + if (parts.length != 6) return false; + + int[][] pos = new int[6][2]; + for (int i = 0; i < 6; i++) { + int[] cell = cellFromString(parts[i]); + if (cell == null) return false; + pos[i] = cell; + } + + // Zone autorisée + if ("noir".equals(player)) { + boolean allLow = true, allHigh = true; + for (int[] p : pos) { + if (p[0] != 0 && p[0] != 1) allLow = false; + if (p[0] != 4 && p[0] != 5) allHigh = false; + } + if (!allLow && !allHigh) return false; + } else { + if (blackRows == null) return false; + int[] wr = complementaryRows(blackRows); + for (int[] p : pos) { + if (p[0] != wr[0] && p[0] != wr[1]) return false; + } + } + + // Pas de doublons, cases vides + Set seen = new HashSet<>(); + for (int[] p : pos) { + if (!seen.add(p[0] + "," + p[1])) return false; + if (board[p[0]][p[1]] != EMPTY) return false; + } + return true; + } + + /** Valide un pass "E" : uniquement si aucun coup régulier n'est disponible. */ + private boolean isValidPass(String player) { + if (!player.equals(currentPlayer)) return false; + if (!blackPlaced || !whitePlaced) return false; + if (gameOver()) return false; + String[] m = possiblesMoves(player); + return m.length == 1 && "E".equals(m[0]); + } + + /** Valide un coup régulier "XX-YY". */ + private boolean isValidRegularMove(String move, String player) { + if (!blackPlaced || !whitePlaced) return false; + if (gameOver()) return false; + if (!player.equals(currentPlayer)) return false; + + int dash = move.indexOf('-'); + if (dash < 1 || dash >= move.length() - 1) return false; + + int[] from = cellFromString(move.substring(0, dash)); + int[] to = cellFromString(move.substring(dash + 1)); + if (from == null || to == null) return false; + + if (!belongsToPlayer(board[from[0]][from[1]], player)) return false; + if (lastTileType != -1 && TILE_MAP[from[0]][from[1]] != lastTileType) return false; + + return getReachableSquares(from[0], from[1], player).contains(to[0] + "," + to[1]); + } + + // ========================================================================= + // Génération de coups + // ========================================================================= + + @Override + public String[] possiblesMoves(String player) { + // Pendant le placement le nombre de combinaisons est trop grand pour être énuméré + if (!blackPlaced || !whitePlaced) return new String[0]; + if (gameOver()) return new String[0]; + + List moves = new ArrayList<>(); + for (int r = 0; r < 6; r++) { + for (int c = 0; c < 6; c++) { + if (!belongsToPlayer(board[r][c], player)) continue; + if (lastTileType != -1 && TILE_MAP[r][c] != lastTileType) continue; + + for (String dest : getReachableSquares(r, c, player)) { + String[] d = dest.split(","); + moves.add(stringFromCell(r, c) + "-" + + stringFromCell(Integer.parseInt(d[0]), Integer.parseInt(d[1]))); + } + } + } + if (moves.isEmpty()) return new String[]{"E"}; + return moves.toArray(new String[0]); + } + + // ========================================================================= + // Jouer un coup + // ========================================================================= + + @Override + public void play(String move, String player) { + if (!isValidMove(move, player)) + throw new IllegalArgumentException("Coup invalide : '" + move + "' pour " + player); + + if (move.contains("/")) { + playPlacement(move, player); + } else if ("E".equals(move)) { + // Pass : supprime la contrainte de liseré (règle officielle) + lastTileType = -1; + currentPlayer = opponent(currentPlayer); + } else { + playRegular(move, player); + } + } + + private void playPlacement(String move, String player) { + String[] parts = move.split("/"); + int[][] pos = new int[6][2]; + for (int i = 0; i < 6; i++) pos[i] = cellFromString(parts[i]); + + int licorne = "noir".equals(player) ? BLACK_LICORNE : WHITE_LICORNE; + int paladin = "noir".equals(player) ? BLACK_PALADIN : WHITE_PALADIN; + + board[pos[0][0]][pos[0][1]] = licorne; + for (int i = 1; i < 6; i++) board[pos[i][0]][pos[i][1]] = paladin; + + if ("noir".equals(player)) { + blackPlaced = true; + // Enregistrer les deux lignes choisies par noir + Set rows = new TreeSet<>(); + for (int[] p : pos) rows.add(p[0]); + Iterator it = rows.iterator(); + blackRows = new int[]{it.next(), it.next()}; + currentPlayer = "blanc"; + } else { + whitePlaced = true; + lastTileType = -1; // pas de contrainte pour le premier coup régulier + currentPlayer = "blanc"; // blanc joue en premier + } + } + + private void playRegular(String move, String player) { + int dash = move.indexOf('-'); + int[] from = cellFromString(move.substring(0, dash)); + int[] to = cellFromString(move.substring(dash + 1)); + + board[to[0]][to[1]] = board[from[0]][from[1]]; // capture si case adverse + board[from[0]][from[1]] = EMPTY; + lastTileType = TILE_MAP[to[0]][to[1]]; + currentPlayer = opponent(currentPlayer); + } + + // ========================================================================= + // Algorithme de déplacement (DFS) + // ========================================================================= + + /** + * Calcule l'ensemble des cases atteignables depuis (fromRow, fromCol). + * Résultats encodés sous forme "row,col". + */ + Set getReachableSquares(int fromRow, int fromCol, String player) { + Set result = new HashSet<>(); + boolean[][] visited = new boolean[6][6]; + visited[fromRow][fromCol] = true; + dfs(fromRow, fromCol, TILE_MAP[fromRow][fromCol], player, visited, result); + return result; + } + + /** + * DFS récursif pour le calcul des destinations. + * + *

À chaque appel, la pièce se trouve en (row, col) et doit encore effectuer + * {@code stepsLeft} pas. Les cases déjà visitées dans le chemin courant sont + * marquées dans {@code visited} (réinitialisation après backtrack). + * + *

Règles : + *

    + *
  • Pas intermédiaires (stepsLeft > 1) : la case suivante doit être vide.
  • + *
  • Dernier pas (stepsLeft == 1) : la case peut être vide ou contenir + * la licorne adverse (capture).
  • + *
+ */ + private void dfs(int row, int col, int stepsLeft, + String player, boolean[][] visited, Set result) { + if (stepsLeft == 0) { + result.add(row + "," + col); + return; + } + // Directions orthogonales : haut, bas, gauche, droite + int[] dr = {-1, 1, 0, 0}; + int[] dc = { 0, 0, -1, 1}; + + for (int d = 0; d < 4; d++) { + int nr = row + dr[d]; + int nc = col + dc[d]; + if (nr < 0 || nr >= 6 || nc < 0 || nc >= 6) continue; + if (visited[nr][nc]) continue; + + int occ = board[nr][nc]; + boolean canStep; + if (stepsLeft > 1) { + // Pas intermédiaire : case obligatoirement vide + canStep = (occ == EMPTY); + } else { + // Dernier pas : vide OU capture de la licorne adverse + canStep = (occ == EMPTY) + || ("blanc".equals(player) && occ == BLACK_LICORNE) + || ("noir".equals(player) && occ == WHITE_LICORNE); + } + if (!canStep) continue; + + visited[nr][nc] = true; + dfs(nr, nc, stepsLeft - 1, player, visited, result); + visited[nr][nc] = false; // backtrack + } + } + + // ========================================================================= + // Méthodes utilitaires + // ========================================================================= + + private int charToPiece(char c) { + switch (c) { + case 'B': return WHITE_LICORNE; + case 'b': return WHITE_PALADIN; + case 'N': return BLACK_LICORNE; + case 'n': return BLACK_PALADIN; + default: return EMPTY; + } + } + + private char pieceToChar(int piece) { + switch (piece) { + case WHITE_LICORNE: return 'B'; + case WHITE_PALADIN: return 'b'; + case BLACK_LICORNE: return 'N'; + case BLACK_PALADIN: return 'n'; + default: return '-'; + } + } + + /** + * Convertit une chaîne "A1"-"F6" en coordonnées {row, col} (0-indexé). + * Retourne null si le format est invalide. + */ + int[] cellFromString(String s) { + if (s == null || s.length() < 2) return null; + s = s.trim(); + char colC = Character.toUpperCase(s.charAt(0)); + char rowC = s.charAt(1); + if (colC < 'A' || colC > 'F') return null; + if (rowC < '1' || rowC > '6') return null; + return new int[]{rowC - '1', colC - 'A'}; + } + + /** Convertit des coordonnées internes en notation "A1"-"F6". */ + String stringFromCell(int row, int col) { + return "" + (char)('A' + col) + (char)('1' + row); + } + + private boolean belongsToPlayer(int piece, String player) { + if ("blanc".equals(player)) return piece == WHITE_LICORNE || piece == WHITE_PALADIN; + if ("noir".equals(player)) return piece == BLACK_LICORNE || piece == BLACK_PALADIN; + return false; + } + + private String opponent(String player) { + return "blanc".equals(player) ? "noir" : "blanc"; + } + + /** + * Retourne les deux lignes (0-indexé) que doit utiliser blanc, + * sachant que noir a choisi {@code bRows}. + * Noir sur {0,1} → blanc sur {4,5} ; noir sur {4,5} → blanc sur {0,1}. + */ + private int[] complementaryRows(int[] bRows) { + return (bRows[0] == 0) ? new int[]{4, 5} : new int[]{0, 1}; + } + + // ========================================================================= + // Affichage + // ========================================================================= + + /** Affiche le plateau en console (ligne 6 en haut). */ + public void printBoard() { + System.out.println(" A B C D E F liseré"); + for (int r = 5; r >= 0; r--) { + System.out.print((r + 1) + " [ "); + for (int c = 0; c < 6; c++) System.out.print(pieceToChar(board[r][c]) + " "); + System.out.print("] " + (r + 1) + " |"); + for (int c = 0; c < 6; c++) System.out.print(" " + TILE_MAP[r][c]); + System.out.println(); + } + System.out.println("lastTileType=" + lastTileType + + " currentPlayer=" + currentPlayer + "\n"); + } + + // ========================================================================= + // Main de démonstration + // ========================================================================= + + public static void main(String[] args) throws IOException { + System.out.println("========================================="); + System.out.println(" Demo EscampeBoard "); + System.out.println("=========================================\n"); + + // ── Placements utilisés dans plusieurs scenarios ────────────────── + // Noir : lignes 5-6 (rows 4-5) — licorne en A6, paladins en B6 C6 D5 E5 F5 + final String NOIR_PL = "A6/B6/C6/D5/E5/F5"; + // Blanc : lignes 1-2 (rows 0-1) — licorne en D2, paladins en A1 B1 C1 E1 F2 + final String BLANC_PL = "D2/A1/B1/C1/E1/F2"; + + // ───────────────────────────────────────────────────────────────── + // 1. PHASE DE PLACEMENT + // ───────────────────────────────────────────────────────────────── + System.out.println("=== 1. PHASE DE PLACEMENT ==="); + EscampeBoard b = new EscampeBoard(); + + // Tentatives invalides avant le placement normal + System.out.println("Blanc tente de placer avant noir : " + + b.isValidMove(BLANC_PL, "blanc") + " (attendu: false)"); + System.out.println("Noir placement au milieu du plateau : " + + b.isValidMove("A3/B3/C3/D3/E3/F3", "noir") + " (attendu: false)"); + System.out.println("Noir placement sur deux bords diff. : " + + b.isValidMove("A1/B1/C1/D5/E5/F5", "noir") + " (attendu: false)"); + + // Placement valide de noir + System.out.println("\nNoir place : " + NOIR_PL + + " valid=" + b.isValidMove(NOIR_PL, "noir")); + b.play(NOIR_PL, "noir"); + System.out.println(" blackPlaced=" + b.blackPlaced + + " blackRows=[" + b.blackRows[0] + "," + b.blackRows[1] + "]" + + " currentPlayer=" + b.currentPlayer); + + // Placement valide de blanc + System.out.println("Blanc place : " + BLANC_PL + + " valid=" + b.isValidMove(BLANC_PL, "blanc")); + b.play(BLANC_PL, "blanc"); + System.out.println(" whitePlaced=" + b.whitePlaced + + " currentPlayer=" + b.currentPlayer); + + b.printBoard(); + + // ───────────────────────────────────────────────────────────────── + // 2. PHASE REGULIERE — contrainte de liseré + // ───────────────────────────────────────────────────────────────── + System.out.println("=== 2. PHASE REGULIERE ==="); + System.out.println("lastTileType=" + b.lastTileType + + " (pas de contrainte pour le premier coup)\n"); + + // Blanc joue en premier, pas de contrainte + String[] bMoves = b.possiblesMoves("blanc"); + System.out.println("Coups possibles pour blanc : " + bMoves.length + " coups"); + System.out.printf("Exemples : %s %s %s%n", + bMoves[0], + bMoves.length > 1 ? bMoves[1] : "", + bMoves.length > 2 ? bMoves[2] : ""); + + String m1 = bMoves[0]; + System.out.println("\nBlanc joue : " + m1 + " valid=" + b.isValidMove(m1, "blanc")); + b.play(m1, "blanc"); + System.out.println(" lastTileType=" + b.lastTileType + + " (liseré de la case d'arrivée = contrainte pour noir)" + + " currentPlayer=" + b.currentPlayer); + + // Tentative invalide : blanc rejoue hors de son tour + System.out.println("\nBlanc rejoue hors tour : " + + b.isValidMove(m1, "blanc") + " (attendu: false)"); + + // Tentative invalide : noir joue depuis un mauvais liseré + String badNoirMove = findMoveFromWrongTile(b, "noir"); + if (badNoirMove != null) { + System.out.println("Noir depuis mauvais liseré (" + badNoirMove + ") : " + + b.isValidMove(badNoirMove, "noir") + " (attendu: false)"); + } + + // Coup valide de noir + String[] nMoves = b.possiblesMoves("noir"); + System.out.println("\nCoups possibles pour noir (liseré " + b.lastTileType + ") : " + + nMoves.length + " coups"); + String m2 = nMoves[0]; + System.out.println("Noir joue : " + m2 + " valid=" + b.isValidMove(m2, "noir")); + b.play(m2, "noir"); + System.out.println(" lastTileType=" + b.lastTileType + + " currentPlayer=" + b.currentPlayer); + + // ───────────────────────────────────────────────────────────────── + // 3. ROUND-TRIP FICHIER + // ───────────────────────────────────────────────────────────────── + System.out.println("\n=== 3. ROUND-TRIP FICHIER ==="); + b.saveToFile("escampe_save.txt"); + System.out.println("Sauvegardé dans escampe_save.txt"); + + EscampeBoard b2 = new EscampeBoard(); + b2.setFromFile("escampe_save.txt"); + System.out.println("Rechargé : lastTileType=" + b2.lastTileType + + " currentPlayer=" + b2.currentPlayer); + System.out.println("Plateaux identiques : " + Arrays.deepEquals(b.board, b2.board)); + System.out.println("lastTileType identique : " + (b.lastTileType == b2.lastTileType)); + System.out.println("currentPlayer identique : " + b.currentPlayer.equals(b2.currentPlayer)); + + // ───────────────────────────────────────────────────────────────── + // 4. SCENARIO DE PASS (E) + // ───────────────────────────────────────────────────────────────── + System.out.println("\n=== 4. SCENARIO DE PASS ==="); + EscampeBoard bPass = new EscampeBoard(); + bPass.play(NOIR_PL, "noir"); + bPass.play(BLANC_PL, "blanc"); + + // Forcer une situation où noir n'a aucun coup : + // lastTileType=2, mais toutes les pièces noires sont sur liseré 1 ou 3. + for (int r = 0; r < 6; r++) Arrays.fill(bPass.board[r], EMPTY); + bPass.board[0][3] = WHITE_LICORNE; // D1 liseré=3 + bPass.board[0][0] = WHITE_PALADIN; // A1 liseré=1 + bPass.board[0][4] = WHITE_PALADIN; // E1 liseré=1 + bPass.board[5][0] = BLACK_LICORNE; // A6 liseré=3 + bPass.board[4][4] = BLACK_PALADIN; // E5 liseré=1 + bPass.board[4][2] = BLACK_PALADIN; // C5 liseré=1 + bPass.lastTileType = 2; // blanc vient de poser sur liseré 2 + bPass.currentPlayer = "noir"; + + System.out.println("Pièces noires sur liserés 1 et 3, contrainte = 2"); + System.out.println("possiblesMoves(noir) = " + + Arrays.toString(bPass.possiblesMoves("noir")) + " (attendu: [E])"); + System.out.println("isValidMove(E, noir) = " + + bPass.isValidMove("E", "noir") + " (attendu: true)"); + System.out.println("isValidMove(E, blanc) = " + + bPass.isValidMove("E", "blanc") + " (attendu: false, pas son tour)"); + + bPass.play("E", "noir"); + System.out.println("Après pass : lastTileType=" + bPass.lastTileType + + " (attendu: -1) currentPlayer=" + bPass.currentPlayer); + + // ───────────────────────────────────────────────────────────────── + // 5. CAPTURE ET FIN DE PARTIE + // ───────────────────────────────────────────────────────────────── + System.out.println("\n=== 5. CAPTURE ET FIN DE PARTIE ==="); + EscampeBoard bCap = new EscampeBoard(); + bCap.play(NOIR_PL, "noir"); + bCap.play(BLANC_PL, "blanc"); + + // Mise en scène : + // - Blanc paladin en B1 (row=0,col=1 ; liseré=2) + // → 2 pas orthogonaux : B1 -> B2 -> B3 + // - Licorne noire en B3 (row=2,col=1) ; case B2 vide + // - lastTileType=2 → blanc peut jouer depuis B1 + for (int r = 0; r < 6; r++) Arrays.fill(bCap.board[r], EMPTY); + bCap.board[0][1] = WHITE_PALADIN; // B1 liseré=2 + bCap.board[0][3] = WHITE_LICORNE; // D1 (garde-fou : licorne blanche présente) + bCap.board[2][1] = BLACK_LICORNE; // B3 + bCap.board[5][5] = BLACK_PALADIN; // F6 (présence de pièce noire restante) + bCap.lastTileType = 2; + bCap.currentPlayer = "blanc"; + + System.out.println("Avant capture :"); + bCap.printBoard(); + System.out.println("gameOver = " + bCap.gameOver() + " (attendu: false)"); + + // Coup invalide : un pas seulement (B1->B2), pas assez de cases + System.out.println("Coup B1-B2 (1 pas, manque 1) : " + + bCap.isValidMove("B1-B2", "blanc") + " (attendu: false)"); + + // Coup valide : deux pas (B1->B2->B3), B2 vide, B3 = licorne noire + System.out.println("Coup B1-B3 (2 pas, capture) : " + + bCap.isValidMove("B1-B3", "blanc") + " (attendu: true)"); + bCap.play("B1-B3", "blanc"); + + System.out.println("Après capture :"); + bCap.printBoard(); + System.out.println("gameOver = " + bCap.gameOver() + " (attendu: true)"); + System.out.println("Blanc gagne !"); + + System.out.println("\n========================================="); + System.out.println(" Demo terminee "); + System.out.println("========================================="); + } + + /** + * Utilitaire pour la démo : trouve un coup depuis une pièce + * de {@code player} dont le liseré est différent de {@code lastTileType}. + * Retourne null si aucune telle pièce n'a de destinations. + */ + private static String findMoveFromWrongTile(EscampeBoard b, String player) { + for (int r = 0; r < 6; r++) { + for (int c = 0; c < 6; c++) { + if (!b.belongsToPlayer(b.board[r][c], player)) continue; + if (TILE_MAP[r][c] == b.lastTileType) continue; + Set reach = b.getReachableSquares(r, c, player); + if (!reach.isEmpty()) { + String dest = reach.iterator().next(); + String[] parts = dest.split(","); + return b.stringFromCell(r, c) + "-" + + b.stringFromCell(Integer.parseInt(parts[0]), + Integer.parseInt(parts[1])); + } + } + } + return null; + } +} diff --git a/src/Partie1.java b/src/Partie1.java new file mode 100644 index 0000000..0b73fb5 --- /dev/null +++ b/src/Partie1.java @@ -0,0 +1,43 @@ +public interface Partie1 { + + /** + * Initialise un plateau à partir d'un fichier texte. + * @param fileName le nom du fichier à lire + */ + public void setFromFile(String fileName); + + /** + * Sauve la configuration de l'état courant (plateau et pièces restantes) dans un fichier. + * @param fileName le nom du fichier à sauvegarder + * Le format doit être compatible avec celui utilisé pour la lecture. + */ + public void saveToFile(String fileName); + + /** + * Indique si le coup {@code move} est valide pour le joueur {@code player} sur le plateau courant. + * @param move le coup à jouer, + * sous la forme "B1-D1" en général, + * sous la forme "C6/A6/B5/D5/E6/F5" pour le coup qui place les pièces, + * ou "E" pour passer son tour. + * @param player le joueur qui joue, représenté par "noir" ou "blanc" + */ + public boolean isValidMove(String move, String player); + + /** + * Calcule les coups possibles pour le joueur {@code player} sur le plateau courant. + * @param player le joueur qui joue, représenté par "noir" ou "blanc" + */ + public String[] possiblesMoves(String player); + + /** + * Modifie le plateau en jouant le coup {@code move} pour le joueur {@code player}. + * @param move le coup à jouer, sous la forme "C1-D1" ou "C6/A6/B5/D5/E6/F5" + * @param player le joueur qui joue, représenté par "noir" ou "blanc" + */ + public void play(String move, String player); + + /** + * Retourne vrai lorsque le plateau correspond à une fin de partie. + */ + public boolean gameOver(); +} diff --git a/src/escampe/Applet.java b/src/escampe/Applet.java new file mode 100644 index 0000000..e5269b6 --- /dev/null +++ b/src/escampe/Applet.java @@ -0,0 +1,298 @@ +package escampe; + +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Container; +import java.awt.Dimension; +import java.awt.Frame; +import java.awt.Graphics; +import java.awt.Insets; +import java.awt.event.KeyEvent; +import java.awt.event.MouseEvent; + +import javax.swing.DefaultListModel; +import javax.swing.JApplet; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.ListSelectionModel; + +public class Applet extends JApplet { + // Constantes pour les pièces + final private static int LICORNEBLANCHE = -2; + final private static int PALADINBLANC = -1; + final private static int LICORNENOIRE = 2; + final private static int PALADINNOIR = 1; + final private static int VIDE = 0; + + // Constantes pour le plateau + final private static int LARGEUR = 6; + final private static int HAUTEUR = 6; + final private static int[][] lisereCase = { + {1, 2, 2, 3, 1, 2}, + + {3, 1, 3, 1, 3, 2}, + + {2, 3, 1, 2, 1, 3}, + + {2, 1, 3, 2, 3, 1}, + + {1, 3, 1, 3, 1, 2}, + + {3, 2, 2, 1, 3, 2} + }; + + // Constantes pour les couleurs + Color DARK = new Color(155, 102, 95); + Color LIGHT = new Color(239, 210, 158); + Color BLACK = new Color(255, 255, 255); + Color WHITE = new Color(0, 0, 0); + Color HIGHLIGHT = new Color(255, 0, 0); + + // Constantes pour l'affichage + final private static int TAILLECASE = 100; + final private static int TAILLEPION = 60; + final private static Dimension FRAMEDIMENSION = new Dimension(TAILLECASE*6 + 260,TAILLECASE*6 + 60); + + private static final long serialVersionUID = 1L; + private JList brdList; + private Board displayBoard; + private JScrollPane scrollPane; + private DefaultListModel listModel; + private Frame myFrame; + + static int cpt = 0; + + // Autres constantes utiles pour l'affichage du plateau d'Escampe + int mpiece = (int) (TAILLECASE - TAILLEPION)/2; + + int epaisseurCercle = (int) (TAILLECASE*0.1); + int epaisseurInterCercle = (int) (TAILLECASE*0.05); + + int diametre1e = TAILLECASE; // extérieur 1er cercle + int diametre1i = diametre1e - epaisseurCercle; // intérieur 1er cercle + int diametre2e = diametre1i - epaisseurInterCercle; // extérieur 2eme cercle + int diametre2i = diametre2e - epaisseurCercle; // intérieur 2eme cercle + int diametre3e = diametre2i - epaisseurInterCercle; // extérieur 3eme cercle + int diametre3i = diametre3e - epaisseurCercle; // intérieur 3eme cercle + + int m1e = 0; + int m1i = (int) (TAILLECASE - diametre1i)/2; + int m2e = (int) (TAILLECASE - diametre2e)/2; + int m2i = (int) (TAILLECASE - diametre2i)/2; + int m3e = (int) (TAILLECASE - diametre3e)/2; + int m3i = (int) (TAILLECASE - diametre3i)/2; + + public void init() { + System.out.println("Initialisation BoardApplet" + cpt++); + buildUI(getContentPane()); + } + + public void buildUI(Container container) { + setBackground(Color.white); + + int[][] temp = new int[HAUTEUR][LARGEUR]; + + for (int i = 0; i < HAUTEUR; i++) + for (int j = 0; j < LARGEUR; j++) + temp[i][j] = VIDE; + + displayBoard = new Board("Coups :", temp); + + listModel = new DefaultListModel(); + listModel.addElement(displayBoard); + + brdList = new JList(listModel); + brdList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + brdList.setSelectedIndex(0); + scrollPane = new JScrollPane(brdList); + Dimension d = scrollPane.getSize(); + scrollPane.setPreferredSize(new Dimension(200, d.height)); + + brdList.addKeyListener(new java.awt.event.KeyAdapter() { + public void keyPressed(KeyEvent e) { + brdList_keyPressed(e); + } + }); + brdList.addMouseListener(new java.awt.event.MouseAdapter() { + public void mouseClicked(MouseEvent e) { + brdList_mouseClicked(e); + } + }); + container.add(displayBoard, BorderLayout.CENTER); + container.add(scrollPane, BorderLayout.EAST); + } + + public void update(Graphics g, Insets in) { + Insets tempIn = in; + g.translate(tempIn.left, tempIn.top); + paint(g); + } + + public void paint(Graphics g) { + displayBoard.paint(g); + } + + public void addBoard(String move, int[][] board) { + Board tempEntrop = new Board(move, board); + listModel.addElement(new Board(move, board)); + brdList.setSelectedIndex(listModel.getSize() - 1); + brdList.ensureIndexIsVisible(listModel.getSize() - 1); + displayBoard = tempEntrop; + update(myFrame.getGraphics(), myFrame.getInsets()); + } + + public void setMyFrame(Frame f) { + myFrame = f; + } + + void brdList_keyPressed(KeyEvent e) { + int index = brdList.getSelectedIndex(); + if (e.getKeyCode() == KeyEvent.VK_UP && index > 0) + displayBoard = (Board) listModel.getElementAt(index - 1); + + if (e.getKeyCode() == KeyEvent.VK_DOWN && index < (listModel.getSize() - 1)) + displayBoard = (Board) listModel.getElementAt(index + 1); + + update(myFrame.getGraphics(), myFrame.getInsets()); + } + + void brdList_mouseClicked(MouseEvent e) { + displayBoard = (Board) listModel.getElementAt(brdList.getSelectedIndex()); + update(myFrame.getGraphics(), myFrame.getInsets()); + } + + public Dimension getDimension() { + return FRAMEDIMENSION; + } + + // Sous classe qui dessine le plateau de jeu + class Board extends JPanel { + + private static final long serialVersionUID = 1L; + private int[][] boardState; + String move; + int depCol = -1; + int depLin = -1; + int arvCol = -1; + int arvLin = -1; + + // The string will be the move details + // and the array the details of the board after the move has been applied. + public Board(String mv, int[][] bs) { + boardState = bs; + move = mv; + if (mv.length() == 5) { + String[] positions = mv.split("-"); + depCol = (int) positions[0].charAt(0) - (int) 'A'; + depLin = Integer.parseInt(positions[0].substring(1)) - 1; + arvCol = (int) positions[1].charAt(0) - (int) 'A'; + arvLin = Integer.parseInt(positions[1].substring(1)) - 1; + } + } + + public void drawBoard(Graphics g) { + // First draw the lines + // Board + int bx = 30; + int by = 30; + + // axis labels + g.setColor(new Color(0, 0, 0)); + for (int i = 1; i <= LARGEUR; i++) { + g.drawString("" + (char) ('A' + i - 1), bx + (int) ((i - 0.5)*TAILLECASE), 20); + } + for (int i = 1; i <= HAUTEUR; i++) { + g.drawString("" + i, 10, by + (int) ((i - 0.5)*TAILLECASE)); + } + + // Draw the circles + Color c1 = DARK; + Color c2 = LIGHT; + + int casex; + int casey; + int lisere; + + // fond des cases + g.setColor(c1); + g.fillRect(bx, by, LARGEUR*TAILLECASE, HAUTEUR*TAILLECASE); + + for (int j = 0; j < LARGEUR; j++) { + for (int i = 0; i < HAUTEUR; i++) { + casex = bx + j*TAILLECASE; + casey = by + i*TAILLECASE; + lisere = lisereCase[i][j]; + c2 = (i == depLin && j == depCol) ? HIGHLIGHT : LIGHT; + + // 1er cercle + g.setColor(c2); + g.fillOval(casex + m1e, casey + m1e , diametre1e, diametre1e); + g.setColor(c1); + g.fillOval(casex + m1i, casey + m1i, diametre1i, diametre1i); + if (lisere > 1) { + // 2eme cercle + g.setColor(c2); + g.fillOval(casex + m2e, casey + m2e, diametre2e, diametre2e); + g.setColor(c1); + g.fillOval(casex + m2i, casey + m2i, diametre2i, diametre2i); + if (lisere > 2) { + // 3eme cercle + g.setColor(c2); + g.fillOval(casex + m3e, casey + m3e, diametre3e, diametre3e); + g.setColor(c1); + g.fillOval(casex + m3i, casey + m3i, diametre3i, diametre3i); + } + } + } + } + + // Draw the pieces by referencing boardState array + c1 = BLACK; + c2 = WHITE; + + for (int j = 0; j < LARGEUR; j++) { + for (int i = 0; i < HAUTEUR; i++) { + casex = mpiece + bx + j*TAILLECASE; + casey = mpiece + by + i*TAILLECASE; + + switch (boardState[i][j]) { + case (LICORNEBLANCHE): + g.setColor(c1); + g.fillRect(casex, casey, TAILLEPION, TAILLEPION); + break; + case (PALADINBLANC): + g.setColor(c1); + g.fillOval(casex, casey, TAILLEPION, TAILLEPION); + break; + case (LICORNENOIRE): + g.setColor(c2); + g.fillRect(casex, casey, TAILLEPION, TAILLEPION); + break; + case (PALADINNOIR): + g.setColor(c2); + g.fillOval(casex, casey, TAILLEPION, TAILLEPION); + break; + } + + if (i == arvLin && j == arvCol) { + g.setColor(HIGHLIGHT); + g.fillOval(casex + 20, casey + 20, TAILLEPION - 40, TAILLEPION - 40); + } + } + } + } + + public void paint(Graphics g) { + drawBoard(g); + } + + public void update(Graphics g) { + drawBoard(g); + } + + public String toString() { + return move; + } + } +} diff --git a/src/escampe/Bench.java b/src/escampe/Bench.java new file mode 100644 index 0000000..c18fa58 --- /dev/null +++ b/src/escampe/Bench.java @@ -0,0 +1,30 @@ +package escampe; + +/** + * Banc d'essai du moteur : joue quelques coups depuis l'ouverture et affiche + * profondeur, score, nœuds et vitesse. java -cp out escampe.Bench [msParCoup] [nbCoups] + */ +public class Bench { + public static void main(String[] args) { + long budget = args.length > 0 ? Long.parseLong(args[0]) : 3000; + int coups = args.length > 1 ? Integer.parseInt(args[1]) : 8; + + EscampeBoard b = new EscampeBoard(); + b.play("C1/A1/E1/B2/C2/D2", "noir"); + b.play("C6/A6/E6/B5/C5/D5", "blanc"); + + Moteur mo = new Moteur(); + boolean black = false; // Blanc joue en premier après les placements + for (int i = 0; i < coups && !b.gameOver(); i++) { + long t0 = System.currentTimeMillis(); + int m = mo.bestMove(b, black, budget); + long dt = System.currentTimeMillis() - t0; + System.out.printf("coup %d (%s) : %-6s prof=%2d score=%7d noeuds=%9d %5dms %6.0f kN/s%n", + i, black ? "noir" : "blanc", b.moveToString(m), + mo.reachedDepth, mo.lastScore, mo.nodes, dt, mo.nodes / (dt + 1.0)); + b.play(b.moveToString(m), black ? "noir" : "blanc"); + black = !black; + } + System.out.println(b.gameOver() ? "Partie terminée (capture)." : "Fin du banc."); + } +} diff --git a/src/escampe/Branching.java b/src/escampe/Branching.java new file mode 100644 index 0000000..8e95811 --- /dev/null +++ b/src/escampe/Branching.java @@ -0,0 +1,58 @@ +package escampe; + +import java.util.*; + +/** + * Mesure empirique du facteur de branchement (question Q3 du rapport) : explore + * des parties aléatoires et relève le nombre maximal de coups légaux rencontré, + * en distinguant le cas contraint (un liseré imposé) du cas libre (1er coup ou + * après un pass, lastTileType = -1). java -cp out escampe.Branching [parties] + */ +public class Branching { + public static void main(String[] args) { + int games = args.length > 0 ? Integer.parseInt(args[0]) : 20000; + Random rng = new Random(1L); + + int maxConstrained = 0, maxFree = 0; + long sum = 0, count = 0; + + for (int g = 0; g < games; g++) { + EscampeBoard b = new EscampeBoard(); + int[] nr = rng.nextBoolean() ? new int[]{0, 1} : new int[]{4, 5}; + b.play(rndPlace(b, "noir", nr, rng), "noir"); + int[] wr = nr[0] == 0 ? new int[]{4, 5} : new int[]{0, 1}; + b.play(rndPlace(b, "blanc", wr, rng), "blanc"); + + for (int ply = 0; ply < 120 && !b.gameOver(); ply++) { + String side = b.currentPlayer; + String[] mv = b.possiblesMoves(side); + int n = (mv.length == 1 && mv[0].equals("E")) ? 0 : mv.length; + if (b.lastTileType == -1) maxFree = Math.max(maxFree, n); + else maxConstrained = Math.max(maxConstrained, n); + sum += n; count++; + + if (n == 0) { b.play("E", side); } + else { b.play(mv[rng.nextInt(mv.length)], side); } + } + } + System.out.println("Parties simulées : " + games); + System.out.println("Branchement max CONTRAINT : " + maxConstrained + " (un liseré imposé)"); + System.out.println("Branchement max LIBRE : " + maxFree + " (1er coup / après pass)"); + System.out.printf ("Branchement moyen : %.1f%n", (double) sum / count); + } + + static String rndPlace(EscampeBoard b, String pl, int[] rows, Random rng) { + List cells = new ArrayList<>(); + for (int r : rows) for (int c = 0; c < 6; c++) cells.add(new int[]{r, c}); + for (int t = 0; t < 50; t++) { + Collections.shuffle(cells, rng); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 6; i++) { + if (i > 0) sb.append('/'); + sb.append((char) ('A' + cells.get(i)[1])).append((char) ('1' + cells.get(i)[0])); + } + if (b.isValidMove(sb.toString(), pl)) return sb.toString(); + } + throw new IllegalStateException("placement"); + } +} diff --git a/src/escampe/ClientJeu.java b/src/escampe/ClientJeu.java new file mode 100644 index 0000000..2c9f4c7 --- /dev/null +++ b/src/escampe/ClientJeu.java @@ -0,0 +1,151 @@ +package escampe; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.io.PrintWriter; +import java.net.Socket; +import java.util.StringTokenizer; + +/** + * Cette classe permet de charger dynamiquement une classe de joueur, qui doit obligatoirement + * implanter l'interface IJoueur. Vous lui donnez aussi en argument le nom de la machine distante + * (ou "localhost") sur laquelle le serveur de jeu est lancé, ainsi que le port sur lequel la + * machine écoute. + * + * Exemple: >java -cp . frontieres.ClientJeu frontieres.joueurProf localhost 1234 + * + * Le client s'occupe alors de tout en lançant les méthodes implantées de l'interface IJoueur. Toute + * la gestion réseau est donc cachée. + * + * @author L. Simon (Univ. Paris-Sud)- 2006-2008 + * @see IJoueur + */ +public class ClientJeu { + + // Mais pas lors de la conversation avec l'arbitre + // Vous pouvez changer cela en interne si vous le souhaitez + static final int BLANC = -1; + static final int NOIR = 1; + static final int VIDE = 0; + + /** + * @param args + * Dans l'ordre : NomClasseJoueur MachineServeur PortEcoute + */ + public static void main(String[] args) { + + if (args.length < 3) { + System.err.println("ClientJeu Usage: NomClasseJoueur MachineServeur PortEcoute"); + System.exit(1); + } + + // Le nom de la classe joueur à charger dynamiquement + String classeJoueur = args[0]; + // Le nom de la machine serveur a été donné en ligne de commande + String serverMachine = args[1]; + // Le numéro du port sur lequel on se connecte a aussi été donné + int portNum = Integer.parseInt(args[2]); + + System.out.println("Le client se connectera sur " + serverMachine + ":" + portNum); + + Socket clientSocket = null; + IJoueur joueur; + String msg, firstToken; + // permet d'analyser les chaînes de caractères lues + StringTokenizer msgTokenizer; + // C'est la couleur qui doit jouer le prochain coup + int couleurAJouer; + // C'est ma couleur (quand je joue) + int maCouleur; + + boolean jeuTermine = false; + + try { + // initialise la socket + clientSocket = new Socket(serverMachine, portNum); + PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true); + BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream())); + + // ***************************************************** + System.out.print("Chargement de la classe joueur " + classeJoueur + "... "); + Class cjoueur = Class.forName(classeJoueur); + joueur = (IJoueur) cjoueur.newInstance(); + System.out.println("Ok"); + // **************************************************** + + // Envoie de l'identifiant de votre quadrinome. + out.println(joueur.binoName()); + System.out.println("Mon nom de quadrinome envoyé est " + joueur.binoName()); + + // Récupère le message sous forme de chaine de caractères + msg = in.readLine(); + System.out.println(msg); + + // Lit le contenu du message, toutes les infos du message + msgTokenizer = new StringTokenizer(msg, " \n\0"); + if ((msgTokenizer.nextToken()).equals("Blanc")) { + System.out.println("Je suis Blanc, j'attends le mouvement de Noir."); + maCouleur = BLANC; + } + else { // doit etre égal à "Noir" + System.out.println("Je suis Noir, c'est à moi de jouer."); + maCouleur = NOIR; + } + + // permet d'initialiser votre joueur avec sa couleur + joueur.initJoueur(maCouleur); + + // boucle générale de jeu + do { + // Lire le msg à partir du serveur + msg = in.readLine(); + + msgTokenizer = new StringTokenizer(msg, " \n\0"); + firstToken = msgTokenizer.nextToken(); + + if (firstToken.equals("FIN!")) { + jeuTermine = true; + String theWinnerIs = msgTokenizer.nextToken(); + + if (theWinnerIs.equals("Blanc")) { + couleurAJouer = BLANC; + } + else { + if (theWinnerIs.equals("Noir")) + couleurAJouer = NOIR; + else + couleurAJouer = VIDE; + } + + if (couleurAJouer == maCouleur) + System.out.println("J'ai gagné!"); + + joueur.declareLeVainqueur(couleurAJouer); + } + else if (firstToken.equals("JOUEUR")) { + // On demande au joueur de jouer + if ((msgTokenizer.nextToken()).equals("Blanc")) { + couleurAJouer = BLANC; + } + else { + couleurAJouer = NOIR; + } + + if (couleurAJouer == maCouleur) { + // On appelle la classe du joueur pour choisir un mouvement + msg = joueur.choixMouvement(); + out.println(msg); + } + } + else if (firstToken.equals("MOUVEMENT")) { + // On lit ce que joue le joueur et on l'envoie à l'autre + joueur.mouvementEnnemi(msgTokenizer.nextToken()); + } + } while (!jeuTermine); + + } + catch (Exception e) { + System.out.println(e); + } + } +} diff --git a/src/escampe/EscampeBoard.java b/src/escampe/EscampeBoard.java new file mode 100644 index 0000000..b6b807d --- /dev/null +++ b/src/escampe/EscampeBoard.java @@ -0,0 +1,862 @@ +package escampe; + +import java.io.*; +import java.util.*; + +/** + * Représentation d'un état du jeu Escampe. + * + *

Le plateau est un tableau {@code int[6][6]} : + *

    + *
  • {@code board[row][col]} avec row 0 = ligne 1 (bas), row 5 = ligne 6 (haut).
  • + *
  • col 0 = colonne A, col 5 = colonne F.
  • + *
+ * + *

Chaque case stocke l'une des constantes pièce : + * {@code EMPTY}, {@code WHITE_LICORNE}, {@code WHITE_PALADIN}, + * {@code BLACK_LICORNE}, {@code BLACK_PALADIN}. + * + *

L'état complémentaire mémorisé : + *

    + *
  • {@code lastTileType} : type de liseré (1, 2 ou 3) de la case d'arrivée du dernier coup ; + * -1 = pas de contrainte (premier coup ou après un pass).
  • + *
  • {@code currentPlayer} : "noir" ou "blanc", joueur dont c'est le tour.
  • + *
  • {@code blackPlaced}, {@code whitePlaced} : phases de placement terminées.
  • + *
  • {@code blackRows} : les deux lignes (index 0-5) choisies par noir lors du placement.
  • + *
+ * + *

Règles de déplacement : + *

    + *
  • Une pièce avance exactement N pas orthogonaux (N = liseré de la case de départ).
  • + *
  • Elle peut changer de direction à chaque pas.
  • + *
  • Elle ne peut pas passer par une case occupée ni repasser deux fois par la même case.
  • + *
  • Au dernier pas uniquement, elle peut se poser sur la licorne adverse (capture).
  • + *
+ */ +public class EscampeBoard implements Partie1 { + + // ── Constantes pièces ──────────────────────────────────────────────────── + + static final int EMPTY = 0; + static final int WHITE_LICORNE = 1; + static final int WHITE_PALADIN = 2; + static final int BLACK_LICORNE = 3; + static final int BLACK_PALADIN = 4; + + /** + * Carte des liserés : {@code TILE_MAP[row][col]}. + * row 0 = ligne 1 (bas), row 5 = ligne 6 (haut). col 0 = A, col 5 = F. + */ + static final int[][] TILE_MAP = { + {1, 2, 2, 3, 1, 2}, // ligne 1 + {3, 1, 3, 1, 3, 2}, // ligne 2 + {2, 3, 1, 2, 1, 3}, // ligne 3 + {2, 1, 3, 2, 3, 1}, // ligne 4 + {1, 3, 1, 3, 1, 2}, // ligne 5 + {3, 2, 2, 1, 3, 2}, // ligne 6 + }; + + // ── État ───────────────────────────────────────────────────────────────── + + int[][] board; + int lastTileType; // -1 = pas de contrainte + String currentPlayer; // "noir" ou "blanc" + boolean blackPlaced; + boolean whitePlaced; + int[] blackRows; // les 2 lignes (0-indexé) choisies par noir + + // ── Constructeur ───────────────────────────────────────────────────────── + + public EscampeBoard() { + board = new int[6][6]; + lastTileType = -1; + currentPlayer = "noir"; + blackPlaced = false; + whitePlaced = false; + blackRows = null; + } + + // ========================================================================= + // Fichier I/O + // ========================================================================= + + @Override + public void setFromFile(String fileName) { + board = new int[6][6]; + lastTileType = -1; + currentPlayer = "noir"; + blackPlaced = false; + whitePlaced = false; + blackRows = null; + + try (BufferedReader br = new BufferedReader(new FileReader(fileName))) { + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + + char first = line.charAt(0); + + // Commentaire / méta-donnée + if (first == '%') { + parseMeta(line); + continue; + } + + // Ligne de plateau : "1 XXXX 1" ou "01 XXXX 01" + int rowNum = -1; + int pos = 0; + if (first >= '1' && first <= '6') { + rowNum = first - '0'; + pos = 1; + } else if (first == '0' && line.length() > 1) { + char second = line.charAt(1); + if (second >= '1' && second <= '6') { + rowNum = second - '0'; + pos = 2; + } + } + if (rowNum != -1) { + int rowIdx = rowNum - 1; + while (pos < line.length() && line.charAt(pos) == ' ') pos++; + for (int c = 0; c < 6 && pos + c < line.length(); c++) { + board[rowIdx][c] = charToPiece(line.charAt(pos + c)); + } + } + } + } catch (IOException e) { + throw new RuntimeException("Erreur de lecture du fichier : " + fileName, e); + } + + // Si pas de méta-commentaires, on infère l'état à partir des pièces + inferState(); + } + + /** Parse une ligne de méta-commentaire "% clé: valeur". */ + private void parseMeta(String line) { + if (line.startsWith("% lastTileType:")) { + lastTileType = Integer.parseInt(line.substring(15).trim()); + } else if (line.startsWith("% currentPlayer:")) { + currentPlayer = line.substring(16).trim(); + } else if (line.startsWith("% blackPlaced:")) { + blackPlaced = Boolean.parseBoolean(line.substring(14).trim()); + } else if (line.startsWith("% whitePlaced:")) { + whitePlaced = Boolean.parseBoolean(line.substring(14).trim()); + } else if (line.startsWith("% blackRows:")) { + String s = line.substring(12).trim(); + String[] parts = s.split(","); + int r0 = Integer.parseInt(parts[0].trim()); + int r1 = Integer.parseInt(parts[1].trim()); + if (r0 >= 0) blackRows = new int[]{r0, r1}; + } + } + + /** + * Infère {@code blackPlaced}, {@code whitePlaced} et {@code blackRows} + * à partir des pièces présentes sur le plateau + * (utilisé quand le fichier ne contient pas de méta-commentaires). + */ + private void inferState() { + if (blackPlaced && whitePlaced) return; // méta déjà chargée + + int bc = 0, wc = 0; + Set bRowSet = new TreeSet<>(); + for (int r = 0; r < 6; r++) { + for (int c = 0; c < 6; c++) { + int p = board[r][c]; + if (p == BLACK_LICORNE || p == BLACK_PALADIN) { bc++; bRowSet.add(r); } + if (p == WHITE_LICORNE || p == WHITE_PALADIN) { wc++; } + } + } + if (!blackPlaced && bc == 6) { + blackPlaced = true; + // Bord de noir déduit d'une ligne occupée (robuste à 1 seule ligne). + int anyRow = bRowSet.iterator().next(); + blackRows = (anyRow <= 1) ? new int[]{0, 1} : new int[]{4, 5}; + } + if (!whitePlaced && wc == 6) { + whitePlaced = true; + } + } + + @Override + public void saveToFile(String fileName) { + try (PrintWriter pw = new PrintWriter(new FileWriter(fileName))) { + pw.println("% Escampe - sauvegarde du plateau"); + pw.println("% lastTileType: " + lastTileType); + pw.println("% currentPlayer: " + currentPlayer); + pw.println("% blackPlaced: " + blackPlaced); + pw.println("% whitePlaced: " + whitePlaced); + if (blackRows != null) { + pw.println("% blackRows: " + blackRows[0] + "," + blackRows[1]); + } else { + pw.println("% blackRows: -1,-1"); + } + // Lignes 6 à 1 (haut vers bas dans le fichier) + for (int rowIdx = 5; rowIdx >= 0; rowIdx--) { + int rowNum = rowIdx + 1; + StringBuilder sb = new StringBuilder(); + String rowLabel = String.format("%02d", rowNum); + sb.append(rowLabel).append(' '); + for (int c = 0; c < 6; c++) sb.append(pieceToChar(board[rowIdx][c])); + sb.append(' ').append(rowLabel); + pw.println(sb.toString()); + } + } catch (IOException e) { + throw new RuntimeException("Erreur d'écriture du fichier : " + fileName, e); + } + } + + // ========================================================================= + // Fin de partie + // ========================================================================= + + @Override + public boolean gameOver() { + if (!blackPlaced || !whitePlaced) return false; + boolean wl = false, bl = false; + for (int r = 0; r < 6; r++) + for (int c = 0; c < 6; c++) { + if (board[r][c] == WHITE_LICORNE) wl = true; + if (board[r][c] == BLACK_LICORNE) bl = true; + } + return !wl || !bl; + } + + // ========================================================================= + // Validation d'un coup + // ========================================================================= + + @Override + public boolean isValidMove(String move, String player) { + if (move == null || move.isEmpty()) return false; + if (!"noir".equals(player) && !"blanc".equals(player)) return false; + + if (move.contains("/")) return isValidPlacement(move, player); + if ("E".equals(move)) return isValidPass(player); + return isValidRegularMove(move, player); + } + + /** + * Valide un coup de placement "P1/P2/P3/P4/P5/P6" + * (P1 = licorne, P2-P6 = paladins). + */ + private boolean isValidPlacement(String move, String player) { + if ("noir".equals(player) && blackPlaced) return false; + if ("blanc".equals(player) && whitePlaced) return false; + if (!player.equals(currentPlayer)) return false; + if ("blanc".equals(player) && !blackPlaced) return false; + + String[] parts = move.split("/"); + if (parts.length != 6) return false; + + int[][] pos = new int[6][2]; + for (int i = 0; i < 6; i++) { + int[] cell = cellFromString(parts[i]); + if (cell == null) return false; + pos[i] = cell; + } + + // Zone autorisée + if ("noir".equals(player)) { + boolean allLow = true, allHigh = true; + for (int[] p : pos) { + if (p[0] != 0 && p[0] != 1) allLow = false; + if (p[0] != 4 && p[0] != 5) allHigh = false; + } + if (!allLow && !allHigh) return false; + } else { + if (blackRows == null) return false; + int[] wr = complementaryRows(blackRows); + for (int[] p : pos) { + if (p[0] != wr[0] && p[0] != wr[1]) return false; + } + } + + // Pas de doublons, cases vides + Set seen = new HashSet<>(); + for (int[] p : pos) { + if (!seen.add(p[0] + "," + p[1])) return false; + if (board[p[0]][p[1]] != EMPTY) return false; + } + return true; + } + + /** Valide un pass "E" : uniquement si aucun coup régulier n'est disponible. */ + private boolean isValidPass(String player) { + if (!player.equals(currentPlayer)) return false; + if (!blackPlaced || !whitePlaced) return false; + if (gameOver()) return false; + String[] m = possiblesMoves(player); + return m.length == 1 && "E".equals(m[0]); + } + + /** Valide un coup régulier "XX-YY". */ + private boolean isValidRegularMove(String move, String player) { + if (!blackPlaced || !whitePlaced) return false; + if (gameOver()) return false; + if (!player.equals(currentPlayer)) return false; + + int dash = move.indexOf('-'); + if (dash < 1 || dash >= move.length() - 1) return false; + + int[] from = cellFromString(move.substring(0, dash)); + int[] to = cellFromString(move.substring(dash + 1)); + if (from == null || to == null) return false; + + if (!belongsToPlayer(board[from[0]][from[1]], player)) return false; + if (lastTileType != -1 && TILE_MAP[from[0]][from[1]] != lastTileType) return false; + + return getReachableSquares(from[0], from[1], player).contains(to[0] + "," + to[1]); + } + + // ========================================================================= + // Génération de coups + // ========================================================================= + + @Override + public String[] possiblesMoves(String player) { + // Pendant le placement le nombre de combinaisons est trop grand pour être énuméré + if (!blackPlaced || !whitePlaced) return new String[0]; + if (gameOver()) return new String[0]; + + List moves = new ArrayList<>(); + for (int r = 0; r < 6; r++) { + for (int c = 0; c < 6; c++) { + if (!belongsToPlayer(board[r][c], player)) continue; + if (lastTileType != -1 && TILE_MAP[r][c] != lastTileType) continue; + + for (String dest : getReachableSquares(r, c, player)) { + String[] d = dest.split(","); + moves.add(stringFromCell(r, c) + "-" + + stringFromCell(Integer.parseInt(d[0]), Integer.parseInt(d[1]))); + } + } + } + if (moves.isEmpty()) return new String[]{"E"}; + return moves.toArray(new String[0]); + } + + // ========================================================================= + // Jouer un coup + // ========================================================================= + + @Override + public void play(String move, String player) { + if (!isValidMove(move, player)) + throw new IllegalArgumentException("Coup invalide : '" + move + "' pour " + player); + + if (move.contains("/")) { + playPlacement(move, player); + } else if ("E".equals(move)) { + // Pass : supprime la contrainte de liseré (règle officielle) + lastTileType = -1; + currentPlayer = opponent(currentPlayer); + } else { + playRegular(move, player); + } + } + + private void playPlacement(String move, String player) { + String[] parts = move.split("/"); + int[][] pos = new int[6][2]; + for (int i = 0; i < 6; i++) pos[i] = cellFromString(parts[i]); + + int licorne = "noir".equals(player) ? BLACK_LICORNE : WHITE_LICORNE; + int paladin = "noir".equals(player) ? BLACK_PALADIN : WHITE_PALADIN; + + board[pos[0][0]][pos[0][1]] = licorne; + for (int i = 1; i < 6; i++) board[pos[i][0]][pos[i][1]] = paladin; + + if ("noir".equals(player)) { + blackPlaced = true; + // Bord de noir (bas {0,1} ou haut {4,5}), déduit de la ligne de la licorne. + blackRows = (pos[0][0] <= 1) ? new int[]{0, 1} : new int[]{4, 5}; + currentPlayer = "blanc"; + } else { + whitePlaced = true; + lastTileType = -1; // pas de contrainte pour le premier coup régulier + currentPlayer = "blanc"; // blanc joue en premier + } + } + + private void playRegular(String move, String player) { + int dash = move.indexOf('-'); + int[] from = cellFromString(move.substring(0, dash)); + int[] to = cellFromString(move.substring(dash + 1)); + + board[to[0]][to[1]] = board[from[0]][from[1]]; // capture si case adverse + board[from[0]][from[1]] = EMPTY; + lastTileType = TILE_MAP[to[0]][to[1]]; + currentPlayer = opponent(currentPlayer); + } + + // ========================================================================= + // Algorithme de déplacement (DFS) + // ========================================================================= + + /** + * Calcule l'ensemble des cases atteignables depuis (fromRow, fromCol). + * Résultats encodés sous forme "row,col". + */ + Set getReachableSquares(int fromRow, int fromCol, String player) { + Set result = new HashSet<>(); + boolean[][] visited = new boolean[6][6]; + visited[fromRow][fromCol] = true; + dfs(fromRow, fromCol, TILE_MAP[fromRow][fromCol], player, visited, result); + return result; + } + + /** + * DFS récursif pour le calcul des destinations. + * + *

À chaque appel, la pièce se trouve en (row, col) et doit encore effectuer + * {@code stepsLeft} pas. Les cases déjà visitées dans le chemin courant sont + * marquées dans {@code visited} (réinitialisation après backtrack). + * + *

Règles : + *

    + *
  • Pas intermédiaires (stepsLeft > 1) : la case suivante doit être vide.
  • + *
  • Dernier pas (stepsLeft == 1) : la case peut être vide ou contenir + * la licorne adverse (capture).
  • + *
+ */ + private void dfs(int row, int col, int stepsLeft, + String player, boolean[][] visited, Set result) { + if (stepsLeft == 0) { + result.add(row + "," + col); + return; + } + // Directions orthogonales : haut, bas, gauche, droite + int[] dr = {-1, 1, 0, 0}; + int[] dc = { 0, 0, -1, 1}; + + for (int d = 0; d < 4; d++) { + int nr = row + dr[d]; + int nc = col + dc[d]; + if (nr < 0 || nr >= 6 || nc < 0 || nc >= 6) continue; + if (visited[nr][nc]) continue; + + int occ = board[nr][nc]; + boolean canStep; + if (stepsLeft > 1) { + // Pas intermédiaire : case obligatoirement vide + canStep = (occ == EMPTY); + } else { + // Dernier pas : vide OU capture de la licorne adverse + canStep = (occ == EMPTY) + || ("blanc".equals(player) && occ == BLACK_LICORNE) + || ("noir".equals(player) && occ == WHITE_LICORNE); + } + if (!canStep) continue; + + visited[nr][nc] = true; + dfs(nr, nc, stepsLeft - 1, player, visited, result); + visited[nr][nc] = false; // backtrack + } + } + + // Chemin de génération « int » pour le moteur, sans allocation de String. + // Case = row*6+col (0..35) ; coup = from*36+to ; pass = MOVE_PASS ; black = noir. + // Équivalent au chemin String vérifié (contrôlé par VerifMoves). + + static final int MOVE_PASS = -1; + + record Undo(int move, int captured, int savedLastTile, String savedPlayer) {} + + /** Copie profonde de l'état (le moteur cherche sur une copie, jamais sur le live). */ + EscampeBoard copy() { + EscampeBoard b = new EscampeBoard(); + for (int r = 0; r < 6; r++) b.board[r] = board[r].clone(); + b.lastTileType = lastTileType; + b.currentPlayer = currentPlayer; + b.blackPlaced = blackPlaced; + b.whitePlaced = whitePlaced; + b.blackRows = (blackRows == null) ? null : blackRows.clone(); + return b; + } + + private boolean isSide(int piece, boolean black) { + return black ? (piece == BLACK_LICORNE || piece == BLACK_PALADIN) + : (piece == WHITE_LICORNE || piece == WHITE_PALADIN); + } + + /** Version allouante de {@link #genMovesIntInto}, pour les tests. */ + int[] genMovesInt(boolean black) { + int[] buf = new int[256]; + int n = genMovesIntInto(black, buf); + if (n == 0) return new int[0]; + return java.util.Arrays.copyOf(buf, n); + } + + /** + * Écrit les coups de la phase régulière de {@code black} dans {@code buf} et + * renvoie leur nombre : 0 hors phase régulière, ou {@code {MOVE_PASS}} si bloqué. + */ + int genMovesIntInto(boolean black, int[] buf) { + if (!blackPlaced || !whitePlaced) return 0; + if (gameOver()) return 0; + int n = 0; + for (int r = 0; r < 6; r++) { + for (int c = 0; c < 6; c++) { + if (!isSide(board[r][c], black)) continue; + if (lastTileType != -1 && TILE_MAP[r][c] != lastTileType) continue; + int from = r * 6 + c; + long reach = dfsMask(r, c, TILE_MAP[r][c], black, 1L << from, 0L); + while (reach != 0L) { + int t = Long.numberOfTrailingZeros(reach); + reach &= reach - 1; + buf[n++] = from * 36 + t; + } + } + } + if (n == 0) { buf[0] = MOVE_PASS; return 1; } + return n; + } + + /** DFS sur masque de bits (équivalent de {@link #dfs}) : {@code visited}/{@code reach} = ensembles de cases. */ + private long dfsMask(int row, int col, int steps, boolean black, long visited, long reach) { + if (steps == 0) return reach | (1L << (row * 6 + col)); + final int[] dr = {-1, 1, 0, 0}; + final int[] dc = { 0, 0, -1, 1}; + for (int d = 0; d < 4; d++) { + int nr = row + dr[d], nc = col + dc[d]; + if (nr < 0 || nr >= 6 || nc < 0 || nc >= 6) continue; + int ncell = nr * 6 + nc; + if ((visited & (1L << ncell)) != 0) continue; + int occ = board[nr][nc]; + boolean canStep; + if (steps > 1) { + canStep = (occ == EMPTY); + } else { + canStep = (occ == EMPTY) + || (black && occ == WHITE_LICORNE) + || (!black && occ == BLACK_LICORNE); + } + if (!canStep) continue; + reach = dfsMask(nr, nc, steps - 1, black, visited | (1L << ncell), reach); + } + return reach; + } + + /** Applique un coup int (régulier ou {@code MOVE_PASS}) et renvoie le jeton d'annulation. */ + Undo makeInt(int move) { + int savedLast = lastTileType; + String savedPlayer = currentPlayer; + if (move == MOVE_PASS) { + lastTileType = -1; + currentPlayer = opponent(currentPlayer); + return new Undo(move, EMPTY, savedLast, savedPlayer); + } + int from = move / 36, to = move % 36; + int fr = from / 6, fc = from % 6, tr = to / 6, tc = to % 6; + int captured = board[tr][tc]; + board[tr][tc] = board[fr][fc]; + board[fr][fc] = EMPTY; + lastTileType = TILE_MAP[tr][tc]; + currentPlayer = opponent(currentPlayer); + return new Undo(move, captured, savedLast, savedPlayer); + } + + /** Annule l'effet de {@link #makeInt}. */ + void unmakeInt(Undo u) { + if (u.move() != MOVE_PASS) { + int from = u.move() / 36, to = u.move() % 36; + int fr = from / 6, fc = from % 6, tr = to / 6, tc = to % 6; + board[fr][fc] = board[tr][tc]; + board[tr][tc] = u.captured(); + } + lastTileType = u.savedLastTile(); + currentPlayer = u.savedPlayer(); + } + + /** Code int → notation "A1-B2" (ou "E" pour le pass). */ + String moveToString(int move) { + if (move == MOVE_PASS) return "E"; + int from = move / 36, to = move % 36; + return stringFromCell(from / 6, from % 6) + "-" + stringFromCell(to / 6, to % 6); + } + + // ========================================================================= + // Méthodes utilitaires + // ========================================================================= + + private int charToPiece(char c) { + switch (c) { + case 'B': return WHITE_LICORNE; + case 'b': return WHITE_PALADIN; + case 'N': return BLACK_LICORNE; + case 'n': return BLACK_PALADIN; + default: return EMPTY; + } + } + + private char pieceToChar(int piece) { + switch (piece) { + case WHITE_LICORNE: return 'B'; + case WHITE_PALADIN: return 'b'; + case BLACK_LICORNE: return 'N'; + case BLACK_PALADIN: return 'n'; + default: return '-'; + } + } + + /** + * Convertit une chaîne "A1"-"F6" en coordonnées {row, col} (0-indexé). + * Retourne null si le format est invalide. + */ + int[] cellFromString(String s) { + if (s == null || s.length() < 2) return null; + s = s.trim(); + char colC = Character.toUpperCase(s.charAt(0)); + char rowC = s.charAt(1); + if (colC < 'A' || colC > 'F') return null; + if (rowC < '1' || rowC > '6') return null; + return new int[]{rowC - '1', colC - 'A'}; + } + + /** Convertit des coordonnées internes en notation "A1"-"F6". */ + String stringFromCell(int row, int col) { + return "" + (char)('A' + col) + (char)('1' + row); + } + + private boolean belongsToPlayer(int piece, String player) { + if ("blanc".equals(player)) return piece == WHITE_LICORNE || piece == WHITE_PALADIN; + if ("noir".equals(player)) return piece == BLACK_LICORNE || piece == BLACK_PALADIN; + return false; + } + + private String opponent(String player) { + return "blanc".equals(player) ? "noir" : "blanc"; + } + + /** + * Retourne les deux lignes (0-indexé) que doit utiliser blanc, + * sachant que noir a choisi {@code bRows}. + * Noir sur {0,1} → blanc sur {4,5} ; noir sur {4,5} → blanc sur {0,1}. + */ + private int[] complementaryRows(int[] bRows) { + return (bRows[0] == 0) ? new int[]{4, 5} : new int[]{0, 1}; + } + + // ========================================================================= + // Affichage + // ========================================================================= + + /** Affiche le plateau en console (ligne 6 en haut). */ + public void printBoard() { + System.out.println(" A B C D E F liseré"); + for (int r = 5; r >= 0; r--) { + System.out.print((r + 1) + " [ "); + for (int c = 0; c < 6; c++) System.out.print(pieceToChar(board[r][c]) + " "); + System.out.print("] " + (r + 1) + " |"); + for (int c = 0; c < 6; c++) System.out.print(" " + TILE_MAP[r][c]); + System.out.println(); + } + System.out.println("lastTileType=" + lastTileType + + " currentPlayer=" + currentPlayer + "\n"); + } + + // ========================================================================= + // Main de démonstration + // ========================================================================= + + public static void main(String[] args) throws IOException { + System.out.println("========================================="); + System.out.println(" Demo EscampeBoard "); + System.out.println("=========================================\n"); + + // ── Placements utilisés dans plusieurs scenarios ────────────────── + // Noir : lignes 5-6 (rows 4-5) — licorne en A6, paladins en B6 C6 D5 E5 F5 + final String NOIR_PL = "A6/B6/C6/D5/E5/F5"; + // Blanc : lignes 1-2 (rows 0-1) — licorne en D2, paladins en A1 B1 C1 E1 F2 + final String BLANC_PL = "D2/A1/B1/C1/E1/F2"; + + // ───────────────────────────────────────────────────────────────── + // 1. PHASE DE PLACEMENT + // ───────────────────────────────────────────────────────────────── + System.out.println("=== 1. PHASE DE PLACEMENT ==="); + EscampeBoard b = new EscampeBoard(); + + // Tentatives invalides avant le placement normal + System.out.println("Blanc tente de placer avant noir : " + + b.isValidMove(BLANC_PL, "blanc") + " (attendu: false)"); + System.out.println("Noir placement au milieu du plateau : " + + b.isValidMove("A3/B3/C3/D3/E3/F3", "noir") + " (attendu: false)"); + System.out.println("Noir placement sur deux bords diff. : " + + b.isValidMove("A1/B1/C1/D5/E5/F5", "noir") + " (attendu: false)"); + + // Placement valide de noir + System.out.println("\nNoir place : " + NOIR_PL + + " valid=" + b.isValidMove(NOIR_PL, "noir")); + b.play(NOIR_PL, "noir"); + System.out.println(" blackPlaced=" + b.blackPlaced + + " blackRows=[" + b.blackRows[0] + "," + b.blackRows[1] + "]" + + " currentPlayer=" + b.currentPlayer); + + // Placement valide de blanc + System.out.println("Blanc place : " + BLANC_PL + + " valid=" + b.isValidMove(BLANC_PL, "blanc")); + b.play(BLANC_PL, "blanc"); + System.out.println(" whitePlaced=" + b.whitePlaced + + " currentPlayer=" + b.currentPlayer); + + b.printBoard(); + + // ───────────────────────────────────────────────────────────────── + // 2. PHASE REGULIERE — contrainte de liseré + // ───────────────────────────────────────────────────────────────── + System.out.println("=== 2. PHASE REGULIERE ==="); + System.out.println("lastTileType=" + b.lastTileType + + " (pas de contrainte pour le premier coup)\n"); + + // Blanc joue en premier, pas de contrainte + String[] bMoves = b.possiblesMoves("blanc"); + System.out.println("Coups possibles pour blanc : " + bMoves.length + " coups"); + System.out.printf("Exemples : %s %s %s%n", + bMoves[0], + bMoves.length > 1 ? bMoves[1] : "", + bMoves.length > 2 ? bMoves[2] : ""); + + String m1 = bMoves[0]; + System.out.println("\nBlanc joue : " + m1 + " valid=" + b.isValidMove(m1, "blanc")); + b.play(m1, "blanc"); + System.out.println(" lastTileType=" + b.lastTileType + + " (liseré de la case d'arrivée = contrainte pour noir)" + + " currentPlayer=" + b.currentPlayer); + + // Tentative invalide : blanc rejoue hors de son tour + System.out.println("\nBlanc rejoue hors tour : " + + b.isValidMove(m1, "blanc") + " (attendu: false)"); + + // Tentative invalide : noir joue depuis un mauvais liseré + String badNoirMove = findMoveFromWrongTile(b, "noir"); + if (badNoirMove != null) { + System.out.println("Noir depuis mauvais liseré (" + badNoirMove + ") : " + + b.isValidMove(badNoirMove, "noir") + " (attendu: false)"); + } + + // Coup valide de noir + String[] nMoves = b.possiblesMoves("noir"); + System.out.println("\nCoups possibles pour noir (liseré " + b.lastTileType + ") : " + + nMoves.length + " coups"); + String m2 = nMoves[0]; + System.out.println("Noir joue : " + m2 + " valid=" + b.isValidMove(m2, "noir")); + b.play(m2, "noir"); + System.out.println(" lastTileType=" + b.lastTileType + + " currentPlayer=" + b.currentPlayer); + + // ───────────────────────────────────────────────────────────────── + // 3. ROUND-TRIP FICHIER + // ───────────────────────────────────────────────────────────────── + System.out.println("\n=== 3. ROUND-TRIP FICHIER ==="); + b.saveToFile("escampe_save.txt"); + System.out.println("Sauvegardé dans escampe_save.txt"); + + EscampeBoard b2 = new EscampeBoard(); + b2.setFromFile("escampe_save.txt"); + System.out.println("Rechargé : lastTileType=" + b2.lastTileType + + " currentPlayer=" + b2.currentPlayer); + System.out.println("Plateaux identiques : " + Arrays.deepEquals(b.board, b2.board)); + System.out.println("lastTileType identique : " + (b.lastTileType == b2.lastTileType)); + System.out.println("currentPlayer identique : " + b.currentPlayer.equals(b2.currentPlayer)); + + // ───────────────────────────────────────────────────────────────── + // 4. SCENARIO DE PASS (E) + // ───────────────────────────────────────────────────────────────── + System.out.println("\n=== 4. SCENARIO DE PASS ==="); + EscampeBoard bPass = new EscampeBoard(); + bPass.play(NOIR_PL, "noir"); + bPass.play(BLANC_PL, "blanc"); + + // Forcer une situation où noir n'a aucun coup : + // lastTileType=2, mais toutes les pièces noires sont sur liseré 1 ou 3. + for (int r = 0; r < 6; r++) Arrays.fill(bPass.board[r], EMPTY); + bPass.board[0][3] = WHITE_LICORNE; // D1 liseré=3 + bPass.board[0][0] = WHITE_PALADIN; // A1 liseré=1 + bPass.board[0][4] = WHITE_PALADIN; // E1 liseré=1 + bPass.board[5][0] = BLACK_LICORNE; // A6 liseré=3 + bPass.board[4][4] = BLACK_PALADIN; // E5 liseré=1 + bPass.board[4][2] = BLACK_PALADIN; // C5 liseré=1 + bPass.lastTileType = 2; // blanc vient de poser sur liseré 2 + bPass.currentPlayer = "noir"; + + System.out.println("Pièces noires sur liserés 1 et 3, contrainte = 2"); + System.out.println("possiblesMoves(noir) = " + + Arrays.toString(bPass.possiblesMoves("noir")) + " (attendu: [E])"); + System.out.println("isValidMove(E, noir) = " + + bPass.isValidMove("E", "noir") + " (attendu: true)"); + System.out.println("isValidMove(E, blanc) = " + + bPass.isValidMove("E", "blanc") + " (attendu: false, pas son tour)"); + + bPass.play("E", "noir"); + System.out.println("Après pass : lastTileType=" + bPass.lastTileType + + " (attendu: -1) currentPlayer=" + bPass.currentPlayer); + + // ───────────────────────────────────────────────────────────────── + // 5. CAPTURE ET FIN DE PARTIE + // ───────────────────────────────────────────────────────────────── + System.out.println("\n=== 5. CAPTURE ET FIN DE PARTIE ==="); + EscampeBoard bCap = new EscampeBoard(); + bCap.play(NOIR_PL, "noir"); + bCap.play(BLANC_PL, "blanc"); + + // Mise en scène : + // - Blanc paladin en B1 (row=0,col=1 ; liseré=2) + // → 2 pas orthogonaux : B1 -> B2 -> B3 + // - Licorne noire en B3 (row=2,col=1) ; case B2 vide + // - lastTileType=2 → blanc peut jouer depuis B1 + for (int r = 0; r < 6; r++) Arrays.fill(bCap.board[r], EMPTY); + bCap.board[0][1] = WHITE_PALADIN; // B1 liseré=2 + bCap.board[0][3] = WHITE_LICORNE; // D1 (garde-fou : licorne blanche présente) + bCap.board[2][1] = BLACK_LICORNE; // B3 + bCap.board[5][5] = BLACK_PALADIN; // F6 (présence de pièce noire restante) + bCap.lastTileType = 2; + bCap.currentPlayer = "blanc"; + + System.out.println("Avant capture :"); + bCap.printBoard(); + System.out.println("gameOver = " + bCap.gameOver() + " (attendu: false)"); + + // Coup invalide : un pas seulement (B1->B2), pas assez de cases + System.out.println("Coup B1-B2 (1 pas, manque 1) : " + + bCap.isValidMove("B1-B2", "blanc") + " (attendu: false)"); + + // Coup valide : deux pas (B1->B2->B3), B2 vide, B3 = licorne noire + System.out.println("Coup B1-B3 (2 pas, capture) : " + + bCap.isValidMove("B1-B3", "blanc") + " (attendu: true)"); + bCap.play("B1-B3", "blanc"); + + System.out.println("Après capture :"); + bCap.printBoard(); + System.out.println("gameOver = " + bCap.gameOver() + " (attendu: true)"); + System.out.println("Blanc gagne !"); + + System.out.println("\n========================================="); + System.out.println(" Demo terminee "); + System.out.println("========================================="); + } + + /** + * Utilitaire pour la démo : trouve un coup depuis une pièce + * de {@code player} dont le liseré est différent de {@code lastTileType}. + * Retourne null si aucune telle pièce n'a de destinations. + */ + private static String findMoveFromWrongTile(EscampeBoard b, String player) { + for (int r = 0; r < 6; r++) { + for (int c = 0; c < 6; c++) { + if (!b.belongsToPlayer(b.board[r][c], player)) continue; + if (TILE_MAP[r][c] == b.lastTileType) continue; + Set reach = b.getReachableSquares(r, c, player); + if (!reach.isEmpty()) { + String dest = reach.iterator().next(); + String[] parts = dest.split(","); + return b.stringFromCell(r, c) + "-" + + b.stringFromCell(Integer.parseInt(parts[0]), + Integer.parseInt(parts[1])); + } + } + } + return null; + } +} diff --git a/src/escampe/IJoueur.java b/src/escampe/IJoueur.java new file mode 100644 index 0000000..c6e9143 --- /dev/null +++ b/src/escampe/IJoueur.java @@ -0,0 +1,65 @@ +package escampe; + + +/** + * Voici l'interface abstraite qu'il suffit d'implanter pour jouer. Ensuite, vous devez utiliser + * ClientJeu en lui donnant le nom de votre classe pour qu'il la charge et se connecte au serveur. + * + * @author L. Simon (Univ. Paris-Sud)- 2006-2013 + * + */ + +public interface IJoueur { + + // Mais pas lors de la conversation avec l'arbitre (méthodes initJoueur et getNumJoueur) + // Vous pouvez changer cela en interne si vous le souhaitez + static final int BLANC = -1; + static final int NOIR = 1; + + /** + * L'arbitre vient de lancer votre joueur. Il lui informe par cette méthode que vous devez jouer + * dans cette couleur. Vous pouvez utiliser cette m?thode abstraite, ou la méthode constructeur + * de votre classe, pour initialiser vos structures. + * + * @param mycolour + * La couleur dans laquelle vous allez jouer (-1=BLANC, 1=NOIR) + */ + public void initJoueur(int mycolour); + + // Doit retourner l'argument passé par la fonction ci-dessus (constantes BLANC ou NOIR) + public int getNumJoueur(); + + /** + * C'est ici que vous devez faire appel à votre IA pour trouver le meilleur coup à jouer sur le + * plateau courant. + * + * @return une chaine décrivant le mouvement. Cette chaine doit être décrite exactement comme + * sur l'exemple : String msg = "" + positionInitiale + "-" +positionFinale + ""; ou "PASSE"; + * Chaque position contient une lettre et un num?ro, par exemple:A1,B2 (coup "A1-B2") + */ + public String choixMouvement(); + + /** + * Méthode appelée par l'arbitre pour désigner le vainqueur. Vous pouvez en profiter pour + * imprimer une bannière de joie... Si vous gagnez... + * + * @param colour + * La couleur du gagnant (BLANC=-1, NOIR=1). + */ + public void declareLeVainqueur(int colour); + + /** + * On suppose que l'arbitre a vérifié que le mouvement ennemi était bien légal. Il vous informe + * du mouvement ennemi. A vous de répercuter ce mouvement dans vos structures. Comme par exemple + * éliminer les pions que ennemi vient de vous prendre par ce mouvement. Il n'est pas nécessaire + * de réfléchir déjà à votre prochain coup à jouer : pour cela l'arbitre appelera ensuite + * choixMouvement(). + * + * @param coup + * une chaine décrivant le mouvement: par exemple: "A1-B2" + */ + public void mouvementEnnemi(String coup); + + public String binoName(); + +} diff --git a/src/escampe/JoueurPuyaubreauRussac.java b/src/escampe/JoueurPuyaubreauRussac.java new file mode 100644 index 0000000..d04c3f4 --- /dev/null +++ b/src/escampe/JoueurPuyaubreauRussac.java @@ -0,0 +1,117 @@ +package escampe; + +/** + * Joueur du tournoi (Puyaubreau / Russac). Enveloppe un {@link EscampeBoard} + * tenu à jour à chaque coup et délègue la décision à {@link Moteur}. + * + * L'interface {@code IJoueur} parle en entiers ({@code NOIR=1}, {@code BLANC=-1}) + * et place les pièces via le même canal que les coups : le premier + * {@code choixMouvement} renvoie un placement, les suivants des coups. Le pass + * se note {@code "E"} (et non {@code "PASSE"}, contrairement au Javadoc d'IJoueur). + */ +public class JoueurPuyaubreauRussac implements IJoueur { + + private int couleur = NOIR; + private EscampeBoard board; + private final Moteur moteur = new Moteur(); + + // Budget de temps : enveloppe sous la limite arbitre de 300 s, fraction du + // temps restant par coup. Surchargeable par -Descampe.* pour les tests. + private static final long BUDGET_MS = Long.getLong("escampe.budgetMs", 280_000); + private static final long MAX_SLICE_MS = Long.getLong("escampe.maxSliceMs", 6_000); + private static final long MIN_SLICE_MS = 120; + private static final int TIME_DIVISOR = 12; + private static final boolean DEBUG = Boolean.getBoolean("escampe.debug"); + private long usedMs = 0; + + @Override + public void initJoueur(int mycolour) { + couleur = mycolour; + board = new EscampeBoard(); + } + + @Override + public int getNumJoueur() { + return couleur; + } + + @Override + public String binoName() { + return "Puyaubreau_Russac"; + } + + private String myStr() { return couleur == NOIR ? "noir" : "blanc"; } + private String oppStr() { return couleur == NOIR ? "blanc" : "noir"; } + + @Override + public String choixMouvement() { + if (board.gameOver()) return "xxxxx"; // fin de partie sous Solo ; l'arbitre, lui, n'appelle plus + + if (couleur == NOIR && !board.blackPlaced) { + String pl = placement(new int[]{0, 1}); + board.play(pl, "noir"); + return pl; + } + if (couleur == BLANC && !board.whitePlaced) { + String pl = placement(complementaryRows(board.blackRows)); + board.play(pl, "blanc"); + return pl; + } + + String move = chooseMove(); + board.play(move, myStr()); + return move; + } + + @Override + public void mouvementEnnemi(String coup) { + if (coup == null) return; + coup = coup.trim(); + if (coup.isEmpty() || coup.equals("xxxxx")) return; + try { + board.play(coup, oppStr()); + } catch (RuntimeException e) { + // L'arbitre garantit la légalité ; on ne plante pas sur une désync. + System.err.println("[" + binoName() + "] coup ennemi rejeté : " + coup); + } + } + + @Override + public void declareLeVainqueur(int colour) { + if (colour == couleur) System.out.println("[" + binoName() + "] Victoire !"); + else if (colour == -couleur) System.out.println("[" + binoName() + "] Défaite."); + } + + /** Temps alloué au moteur pour ce coup, puis appel de la recherche. */ + private String chooseMove() { + long remaining = BUDGET_MS - usedMs; + long slice = Math.max(MIN_SLICE_MS, Math.min(remaining / TIME_DIVISOR, MAX_SLICE_MS)); + if (remaining < 1500) slice = Math.max(40, remaining - 300); + + long t0 = System.currentTimeMillis(); + int m = moteur.bestMove(board, couleur == NOIR, slice); + usedMs += System.currentTimeMillis() - t0; + + if (DEBUG) { + System.err.printf("[%s] %s prof=%d score=%d noeuds=%d cumul=%ds%n", + binoName(), board.moveToString(m), moteur.reachedDepth, moteur.lastScore, + moteur.nodes, usedMs / 1000); + } + return board.moveToString(m); + } + + private int[] complementaryRows(int[] blackRows) { + return blackRows[0] == 0 ? new int[]{4, 5} : new int[]{0, 1}; + } + + /** + * Placement : licorne dans un coin, ses deux voisines occupées par des + * paladins (la licorne devient incapturable), les trois autres paladins sur + * des liserés 1/2/3 distincts pour ne jamais être contraint de passer. + */ + private String placement(int[] rows) { + boolean bottom = Math.min(rows[0], rows[1]) == 0; + return bottom ? "A1/A2/B1/E1/F1/C2" // coin A1, murs A2/B1, mobiles E1(1)/F1(2)/C2(3) + : "A6/A5/B6/C5/F5/E6"; // coin A6, murs A5/B6, mobiles C5(1)/F5(2)/E6(3) + } +} diff --git a/src/escampe/Moteur.java b/src/escampe/Moteur.java new file mode 100644 index 0000000..52e7926 --- /dev/null +++ b/src/escampe/Moteur.java @@ -0,0 +1,137 @@ +package escampe; + +/** + * Recherche du meilleur coup : negamax + élagage alpha-bêta + approfondissement + * itératif sous limite de temps. La recherche se fait sur une copie du plateau, + * via makeInt/unmakeInt (sans allocation). Capturer la licorne adverse vaut + * {@code WIN - ply} (gagner vite plutôt que tard). + */ +final class Moteur { + + static final int WIN = 1_000_000; + static final int INF = 2_000_000; + static final int MAX_DEPTH = 40; + private static final int MAX_PLY = MAX_DEPTH + 8; + + // Poids de l'évaluation (proximité paladins/licornes : attaque vs défense). + int wAtkSum = 2, wDefSum = 2, wAtkMin = 8, wDefMin = 8; + + private long deadline; + private boolean timedOut; + long nodes; + int reachedDepth; + int lastScore; + + private final int[][] buf = new int[MAX_PLY][256]; // un buffer de coups par profondeur + + int bestMove(EscampeBoard root, boolean black, long budgetMs) { + EscampeBoard pos = root.copy(); + deadline = System.currentTimeMillis() + Math.max(1, budgetMs); + nodes = 0; timedOut = false; reachedDepth = 0; lastScore = 0; + + int[] moves = new int[256]; + int n = pos.genMovesIntInto(black, moves); + if (n == 0 || moves[0] == EscampeBoard.MOVE_PASS) return EscampeBoard.MOVE_PASS; + orderCapturesFirst(pos, moves, n, black); + + int best = moves[0]; + for (int depth = 1; depth <= MAX_DEPTH; depth++) { + int alpha = -INF, bestScore = -INF, bestThis = moves[0]; + boolean complete = true; + for (int i = 0; i < n; i++) { + EscampeBoard.Undo u = pos.makeInt(moves[i]); + int sc = isCapture(u, black) ? WIN - 1 : -negamax(pos, depth - 1, -INF, -alpha, !black, 1); + pos.unmakeInt(u); + if (timedOut) { complete = false; break; } + if (sc > bestScore) { bestScore = sc; bestThis = moves[i]; } + if (sc > alpha) alpha = sc; + } + if (!complete) break; // profondeur interrompue : on garde la précédente + best = bestThis; + reachedDepth = depth; + lastScore = bestScore; + moveToFront(moves, n, best); // ordonne l'itération suivante + if (bestScore >= WIN - 64) break; + } + return best; + } + + private int negamax(EscampeBoard pos, int depth, int alpha, int beta, boolean black, int ply) { + if ((++nodes & 2047) == 0 && System.currentTimeMillis() >= deadline) { timedOut = true; return 0; } + if (depth <= 0) return eval(pos, black); + + int[] moves = buf[ply]; + int n = pos.genMovesIntInto(black, moves); + if (n == 0) return eval(pos, black); + orderCapturesFirst(pos, moves, n, black); + + int bestScore = -INF; + for (int i = 0; i < n; i++) { + EscampeBoard.Undo u = pos.makeInt(moves[i]); + int sc = isCapture(u, black) ? WIN - ply : -negamax(pos, depth - 1, -beta, -alpha, !black, ply + 1); + pos.unmakeInt(u); + if (timedOut) return 0; + if (sc > bestScore) bestScore = sc; + if (bestScore > alpha) alpha = bestScore; + if (alpha >= beta) break; + } + return bestScore; + } + + private boolean isCapture(EscampeBoard.Undo u, boolean black) { + return u.captured() == (black ? EscampeBoard.WHITE_LICORNE : EscampeBoard.BLACK_LICORNE); + } + + /** Place en tête un coup capturant la licorne adverse, pour une coupure immédiate. */ + private void orderCapturesFirst(EscampeBoard pos, int[] moves, int n, boolean black) { + int enemy = black ? EscampeBoard.WHITE_LICORNE : EscampeBoard.BLACK_LICORNE; + for (int i = 0; i < n; i++) { + int to = moves[i] % 36; + if (moves[i] != EscampeBoard.MOVE_PASS && pos.board[to / 6][to % 6] == enemy) { + int t = moves[0]; moves[0] = moves[i]; moves[i] = t; + return; + } + } + } + + private void moveToFront(int[] moves, int n, int target) { + for (int i = 0; i < n; i++) { + if (moves[i] == target) { int t = moves[0]; moves[0] = moves[i]; moves[i] = t; return; } + } + } + + private int eval(EscampeBoard pos, boolean black) { + int adv = evalBlackAdvantage(pos); + return black ? adv : -adv; + } + + /** Avantage de Noir : nos paladins proches de la licorne adverse, les leurs loin de la nôtre. */ + private int evalBlackAdvantage(EscampeBoard pos) { + int[][] b = pos.board; + int blr = -1, blc = -1, wlr = -1, wlc = -1; + for (int r = 0; r < 6; r++) + for (int c = 0; c < 6; c++) { + int p = b[r][c]; + if (p == EscampeBoard.BLACK_LICORNE) { blr = r; blc = c; } + else if (p == EscampeBoard.WHITE_LICORNE) { wlr = r; wlc = c; } + } + if (wlr < 0) return WIN; + if (blr < 0) return -WIN; + + int atkSum = 0, defSum = 0, atkMin = 99, defMin = 99; + for (int r = 0; r < 6; r++) + for (int c = 0; c < 6; c++) { + int p = b[r][c]; + if (p == EscampeBoard.BLACK_PALADIN) { + int d = Math.abs(r - wlr) + Math.abs(c - wlc); + atkSum += 10 - d; + if (d < atkMin) atkMin = d; + } else if (p == EscampeBoard.WHITE_PALADIN) { + int d = Math.abs(r - blr) + Math.abs(c - blc); + defSum += 10 - d; + if (d < defMin) defMin = d; + } + } + return wAtkSum * atkSum - wDefSum * defSum + wAtkMin * (10 - atkMin) - wDefMin * (10 - defMin); + } +} diff --git a/src/escampe/Partie1.java b/src/escampe/Partie1.java new file mode 100644 index 0000000..972d1f2 --- /dev/null +++ b/src/escampe/Partie1.java @@ -0,0 +1,45 @@ +package escampe; + +public interface Partie1 { + + /** + * Initialise un plateau à partir d'un fichier texte. + * @param fileName le nom du fichier à lire + */ + public void setFromFile(String fileName); + + /** + * Sauve la configuration de l'état courant (plateau et pièces restantes) dans un fichier. + * @param fileName le nom du fichier à sauvegarder + * Le format doit être compatible avec celui utilisé pour la lecture. + */ + public void saveToFile(String fileName); + + /** + * Indique si le coup {@code move} est valide pour le joueur {@code player} sur le plateau courant. + * @param move le coup à jouer, + * sous la forme "B1-D1" en général, + * sous la forme "C6/A6/B5/D5/E6/F5" pour le coup qui place les pièces, + * ou "E" pour passer son tour. + * @param player le joueur qui joue, représenté par "noir" ou "blanc" + */ + public boolean isValidMove(String move, String player); + + /** + * Calcule les coups possibles pour le joueur {@code player} sur le plateau courant. + * @param player le joueur qui joue, représenté par "noir" ou "blanc" + */ + public String[] possiblesMoves(String player); + + /** + * Modifie le plateau en jouant le coup {@code move} pour le joueur {@code player}. + * @param move le coup à jouer, sous la forme "C1-D1" ou "C6/A6/B5/D5/E6/F5" + * @param player le joueur qui joue, représenté par "noir" ou "blanc" + */ + public void play(String move, String player); + + /** + * Retourne vrai lorsque le plateau correspond à une fin de partie. + */ + public boolean gameOver(); +} diff --git a/src/escampe/RulesTest.java b/src/escampe/RulesTest.java new file mode 100644 index 0000000..7e5fa32 --- /dev/null +++ b/src/escampe/RulesTest.java @@ -0,0 +1,143 @@ +package escampe; + +import java.util.*; + +/** + * Tests directs des règles du jeu : compte de pas selon le liseré, capture au + * dernier pas uniquement, paladins imprenables, interdiction de traverser une + * case occupée, contrainte de liseré, pass forcé, fin de partie, zones de placement. + */ +public class RulesTest { + + static int pass = 0, fail = 0; + static void check(boolean cond, String name) { + if (cond) pass++; + else { fail++; System.out.println(" ÉCHEC : " + name); } + } + static boolean has(Set s, int r, int c) { return s.contains(r + "," + c); } + + public static void main(String[] args) { + stepCount(); + captureAndBlocking(); + lisereConstraint(); + forcedPass(); + gameOver(); + placementZones(); + + System.out.println("\nRulesTest : " + pass + " OK, " + fail + " échec(s)."); + if (fail > 0) System.exit(1); + } + + /** Le nombre de pas est exactement le liseré de la case de départ. */ + static void stepCount() { + EscampeBoard b = new EscampeBoard(); + b.board[2][2] = EscampeBoard.WHITE_PALADIN; // C3, liseré 1 + Set r = b.getReachableSquares(2, 2, "blanc"); + check(r.size() == 4 && has(r,1,2) && has(r,3,2) && has(r,2,1) && has(r,2,3), + "liseré 1 (centre) → exactement les 4 voisins orthogonaux"); + + b = new EscampeBoard(); + b.board[2][3] = EscampeBoard.WHITE_PALADIN; // D3, liseré 2 + r = b.getReachableSquares(2, 3, "blanc"); + check(r.size() == 8 + && has(r,0,3) && has(r,4,3) && has(r,2,1) && has(r,2,5) + && has(r,1,2) && has(r,1,4) && has(r,3,2) && has(r,3,4), + "liseré 2 (centre) → les 8 cases à distance 2"); + + b = new EscampeBoard(); + b.board[3][2] = EscampeBoard.WHITE_PALADIN; // C4, liseré 3 + r = b.getReachableSquares(3, 2, "blanc"); + check(has(r,0,2), "liseré 3 atteint (0,2) à 3 pas en ligne droite"); + check(!has(r,1,2), "liseré 3 n'atteint PAS (1,2) (mauvaise parité : 3 pas)"); + check(has(r,2,2) && has(r,3,3), "liseré 3 atteint des cases à distance 1 (zigzag)"); + } + + /** Capture au dernier pas uniquement ; paladins imprenables ; pas de traversée. */ + static void captureAndBlocking() { + EscampeBoard b = new EscampeBoard(); + b.board[3][2] = EscampeBoard.WHITE_PALADIN; // C4 liseré 3 + b.board[0][2] = EscampeBoard.BLACK_LICORNE; // cible à 3 pas (droit) + Set r = b.getReachableSquares(3, 2, "blanc"); + check(has(r,0,2), "capture de la licorne adverse au dernier pas : autorisée"); + + b = new EscampeBoard(); + b.board[3][2] = EscampeBoard.WHITE_PALADIN; + b.board[0][2] = EscampeBoard.BLACK_PALADIN; // paladin sur la case finale + r = b.getReachableSquares(3, 2, "blanc"); + check(!has(r,0,2), "paladin imprenable : pas d'arrivée dessus"); + + b = new EscampeBoard(); + b.board[3][2] = EscampeBoard.WHITE_PALADIN; + b.board[1][2] = EscampeBoard.BLACK_PALADIN; // bloque l'unique chemin vers (0,2) + r = b.getReachableSquares(3, 2, "blanc"); + check(!has(r,0,2), "interdit de traverser une case occupée"); + + b = new EscampeBoard(); + b.board[3][2] = EscampeBoard.WHITE_PALADIN; + b.board[1][2] = EscampeBoard.BLACK_LICORNE; // licorne à distance 2 (parité ≠) + r = b.getReachableSquares(3, 2, "blanc"); + check(!has(r,1,2), "licorne à mauvaise distance : non capturable (compte de pas exact)"); + } + + /** On ne peut jouer que depuis une case du liseré imposé. */ + static void lisereConstraint() { + EscampeBoard b = inPlay(); + b.board[2][2] = EscampeBoard.WHITE_LICORNE; // C3 liseré 1 + b.board[5][5] = EscampeBoard.BLACK_LICORNE; + b.board[2][3] = EscampeBoard.WHITE_PALADIN; // D3 liseré 2 + b.board[0][0] = EscampeBoard.WHITE_PALADIN; // A1 liseré 1 + b.lastTileType = 2; // seules les pièces liseré 2 bougent + boolean allLis2 = true; + for (String m : b.possiblesMoves("blanc")) { + int[] from = b.cellFromString(m.substring(0, m.indexOf('-'))); + if (EscampeBoard.TILE_MAP[from[0]][from[1]] != 2) allLis2 = false; + } + check(allLis2, "contrainte de liseré : tous les coups partent d'une case liseré 2"); + } + + /** Pass autorisé seulement si aucune pièce ne peut jouer le liseré imposé. */ + static void forcedPass() { + EscampeBoard b = inPlay(); + b.board[0][0] = EscampeBoard.WHITE_LICORNE; // A1 liseré 1 + b.board[5][5] = EscampeBoard.BLACK_LICORNE; + b.lastTileType = 3; // blanc n'a aucune pièce liseré 3 + String[] mv = b.possiblesMoves("blanc"); + check(mv.length == 1 && mv[0].equals("E"), "aucune pièce sur le liseré → pass forcé"); + check(b.isValidMove("E", "blanc"), "E valide quand bloqué"); + + b.lastTileType = 1; // la licorne A1 (liseré 1) peut bouger + String[] mv2 = b.possiblesMoves("blanc"); + check(mv2.length >= 1 && !mv2[0].equals("E"), "des coups existent → pas de pass"); + check(!b.isValidMove("E", "blanc"), "E invalide si des coups existent"); + } + + static void gameOver() { + EscampeBoard b = inPlay(); + b.board[0][0] = EscampeBoard.WHITE_LICORNE; + b.board[5][5] = EscampeBoard.BLACK_LICORNE; + check(!b.gameOver(), "deux licornes présentes → partie en cours"); + b.board[5][5] = EscampeBoard.EMPTY; + check(b.gameOver(), "une licorne manquante → fin de partie"); + check(!new EscampeBoard().gameOver(), "avant placement → jamais fini"); + } + + /** Placement : zones autorisées et complémentarité noir/blanc. */ + static void placementZones() { + EscampeBoard b = new EscampeBoard(); + check(!b.isValidMove("A3/B3/C3/D3/E3/F3", "noir"), "placement noir au centre : refusé"); + check(b.isValidMove("A1/A2/B1/E1/F1/C2", "noir"), "placement noir sur 2 lignes du bord : accepté"); + b.play("A1/A2/B1/E1/F1/C2", "noir"); + check(b.isValidMove("A6/A5/B6/C5/F5/E6", "blanc"), "placement blanc complémentaire (haut) : accepté"); + check(!b.isValidMove("A1/A2/B1/E1/F1/D1", "blanc"), "placement blanc du même côté que noir : refusé"); + } + + /** Plateau vide « en jeu » (les deux placements faits), à remplir à la main. */ + static EscampeBoard inPlay() { + EscampeBoard b = new EscampeBoard(); + b.blackPlaced = true; + b.whitePlaced = true; + b.currentPlayer = "blanc"; + b.lastTileType = -1; + return b; + } +} diff --git a/src/escampe/Solo.java b/src/escampe/Solo.java new file mode 100644 index 0000000..ae4eba9 --- /dev/null +++ b/src/escampe/Solo.java @@ -0,0 +1,183 @@ +package escampe; + + +import java.util.Date; + +import javax.swing.JFrame; + +/** + * Petite Classe toute simple qui vous montre comment on peut lancer une partie sur deux IJoueurs... + * Cela vous servira a debugger facilement votre projet en conditions presque reelles de tournoi + * + * Attention, l'arbitre n'est pas lancé dessus, mais comme il s'agit de deux IJoueur à vous il n'est + * pas nécessaire de vérifier la validité des coups (bien entendu) + * + * Par contre, comme rien ne vérifie la fin de partie (pas d'arbitre), vos IJoueur devront renvoyer + * la chaine "xxxxx" pour dire que la partie est finie. + * + * Cette classe n'affiche rien : elle se contente de donner la main alternativement aux deux + * joueurs. + * + * 2008-2012 + */ +public class Solo { + private static IJoueur joueurBlanc; + private static IJoueur joueurNoir; + + // Ne pas modifier ces constantes, elles seront utilisees par l'arbitre + private final static int BLANC = -1; + private final static int NOIR = 1; + + private static int nbCoups = 0; + + /*// Par défaut, on a une applet graphique + static boolean APPLETGRAPHIQUE = true; + + // applet game viewer + static private Applet vueDuJeu; + static private JFrame f = null;*/ + + + /** + * Pour éviter de toujours envoyer des lignes de commandes, vous pouvez renvoyer automatiquement + * dans cette méthode votre joueur par défaut. Attention, il faut bien remplir le return new + * VOTREJOUEUR() pour que cela fonctionne la classe implantee renvoyee doit implanter + * l'interface IJoueur... + * + * @param s + * @return Ijoueur un joueur demande + */ + private static IJoueur getDefaultPlayer(String s) { + System.out.println(s + " : defaultPlayer"); + // vous devez faire qq chose comme return new MonMeilleurJoueur(); + // JoueurAleatoire vit dans escampeobf.jar (interface obfusquée) : on ne peut + // pas le référencer ici à la compilation. On renvoie donc notre propre joueur. + return new JoueurPuyaubreauRussac(); + } + + /** + * Juste pour rendre le tout plus generique, et vous donner une idee de comment le tournoi sera + * lance automatiquement, voici une methode permettant de charger une certaine classe implantant + * un IJoueur + * + * @param classeJoueur + * @param s + * @return la classe chargee dynamiquement + */ + private static IJoueur loadNamedPlayer(String classeJoueur, String s) { + IJoueur joueur; + System.out.print(s + " : Chargement de la classe joueur " + classeJoueur + "... "); + try { + Class cjoueur = Class.forName(classeJoueur); + joueur = (IJoueur) cjoueur.newInstance(); + } + catch (Exception e) { + System.out.println("Erreur de chargement"); + System.out.println(e); + return null; + } + System.out.println("Ok"); + return joueur; + } + + /** + * Boucle principale du jeu, en utilisant une version de l'arbitre identique a celle du tournoi + * L'arbitre sera le garant de la validite des coups, et de leur affichage standard pour la + * publication via le site web. + * + * @param joueurBlanc + * @param joueurNoir + */ + public static void gameLoop(IJoueur joueurBlanc, IJoueur joueurNoir) { + String coup; + boolean partieFinie = false; + IJoueur joueurCourant = joueurNoir; // Dans Escampe le joueur Noir commence + + while (!partieFinie) { + nbCoups++; + + System.out.println("\n*********\nOn demande à " + joueurCourant.binoName() + " de jouer..."); + long waitingTime1 = new Date().getTime(); + + coup = joueurCourant.choixMouvement(); + + long waitingTime2 = new Date().getTime(); + // On rajoute 1 pour eliminer les temps infinis + long waitingTime = waitingTime2 - waitingTime1 + 1; + System.out.println("Le joueur " + joueurCourant.binoName() + " a joué le coup " + coup + " en " + waitingTime + "s."); + try { + Thread.sleep(1); // Juste pour attendre un peu + } + catch (InterruptedException e) { + } + + if (coup.compareTo("xxxxx") == 0) + partieFinie = true; + else if (nbCoups == 2) { // Dans Escampe le joueur Blanc rejoue après avoir posé ses pièces + // On avertit le joueur Noir du placement des pièces + joueurNoir.mouvementEnnemi(coup); + } + else { + if (joueurCourant.getNumJoueur() == BLANC) + joueurCourant = joueurNoir; + else + joueurCourant = joueurBlanc; + + // On avertit le second joueur du coup calcule par le precedent + joueurCourant.mouvementEnnemi(coup); + // Ce sera ensuite à lui de jouer de nouveau en haut de la boucle + } + } + + System.out.println("Partie finie en " + nbCoups + " coups.\n"); + } + + /** + * On charge eventuellement les classes demandee pour les joueurs, et on lance la boucle + * principale + * + * @param args + */ + public static void main(String args[]) { + /*// S'il le faut, on initialise l'applet graphique + if (APPLETGRAPHIQUE) { + f = new JFrame("Vue du jeu"); + vueDuJeu = new Applet(); + vueDuJeu.buildUI(f.getContentPane()); + f.setSize(vueDuJeu.getDimension()); + vueDuJeu.setMyFrame(f); + f.setVisible(true); + vueDuJeu.addBoard("Départ ", plateau); + vueDuJeu.update(f.getGraphics(), f.getInsets()); + }*/ + + System.out.println("Partie solo ..."); + + if (args.length == 0) { // On a deux classes à charger + joueurBlanc = getDefaultPlayer("Blanc"); + joueurNoir = getDefaultPlayer("Noir"); + } + else if (args.length == 2) { // On a deux classes à charger + joueurBlanc = getDefaultPlayer("Blanc"); + joueurNoir = getDefaultPlayer("Noir"); + } + else if (args.length == 3) { + joueurBlanc = loadNamedPlayer(args[0], "Blanc"); + joueurNoir = loadNamedPlayer(args[0], "Noir"); + } + else if (args.length == 4) { + joueurBlanc = loadNamedPlayer(args[0], "Blanc"); + joueurNoir = loadNamedPlayer(args[1], "Noir"); + } + + joueurBlanc.initJoueur(BLANC); + System.out.println("Joueur Blanc : " + joueurBlanc.binoName()); + + joueurNoir.initJoueur(NOIR); + System.out.println("Joueur Noir : " + joueurNoir.binoName()); + + System.out.println("Initialisation des deux joueurs ok."); + + gameLoop(joueurBlanc, joueurNoir); + } +} diff --git a/src/escampe/VerifMoves.java b/src/escampe/VerifMoves.java new file mode 100644 index 0000000..83175b2 --- /dev/null +++ b/src/escampe/VerifMoves.java @@ -0,0 +1,121 @@ +package escampe; + +import java.util.*; + +/** + * Cross-vérifie le chemin « int » du moteur contre le chemin « String » vérifié, + * sur des milliers de parties aléatoires : mêmes coups que possiblesMoves, makeInt + * équivalent à play, unmakeInt qui restaure l'état. Échoue à la moindre divergence. + */ +public class VerifMoves { + + static int mismatches = 0; + + public static void main(String[] args) { + int games = args.length > 0 ? Integer.parseInt(args[0]) : 3000; + Random rng = new Random(20260530L); + + long positions = 0, makeChecks = 0; + for (int g = 0; g < games; g++) { + EscampeBoard b = new EscampeBoard(); + // Placements aléatoires légaux. + int[] noirRows = rng.nextBoolean() ? new int[]{0, 1} : new int[]{4, 5}; + b.play(randomPlacement(b, "noir", noirRows, rng), "noir"); + int[] blancRows = (noirRows[0] == 0) ? new int[]{4, 5} : new int[]{0, 1}; + b.play(randomPlacement(b, "blanc", blancRows, rng), "blanc"); + + for (int ply = 0; ply < 200 && !b.gameOver(); ply++) { + positions++; + // (1) égalité des ensembles de coups, pour les deux couleurs. + checkMoveSets(b, true); + checkMoveSets(b, false); + + // Côté au trait : (2) make==play et (3) unmake, sur chaque coup. + boolean black = "noir".equals(b.currentPlayer); + String side = b.currentPlayer; + int[] moves = b.genMovesInt(black); + for (int m : moves) { + makeChecks++; + EscampeBoard after = b.copy(); + EscampeBoard.Undo u = after.makeInt(m); + EscampeBoard ref = b.copy(); + ref.play(b.moveToString(m), side); + if (!sameState(after, ref)) { + report(b, "make!=play pour " + b.moveToString(m) + " (" + side + ")"); + } + after.unmakeInt(u); + if (!sameState(after, b)) { + report(b, "unmake ne restaure pas pour " + b.moveToString(m)); + } + } + if (mismatches > 0) { dumpAndExit(); } + + // Avance la partie d'un coup aléatoire (chemin String vérifié). + if (moves.length == 1 && moves[0] == EscampeBoard.MOVE_PASS) { + b.play("E", side); + } else { + int m = moves[rng.nextInt(moves.length)]; + b.play(b.moveToString(m), side); + } + } + } + System.out.println("Parties : " + games); + System.out.println("Positions testées : " + positions); + System.out.println("make/unmake testés: " + makeChecks); + System.out.println(mismatches == 0 + ? "RÉSULTAT : OK — chemin int ≡ chemin String vérifié (0 divergence)." + : "RÉSULTAT : " + mismatches + " DIVERGENCES !"); + if (mismatches != 0) System.exit(1); + } + + /** Compare genMovesInt(black) et possiblesMoves(player) comme ensembles. */ + static void checkMoveSets(EscampeBoard b, boolean black) { + String player = black ? "noir" : "blanc"; + Set fromInt = new TreeSet<>(); + for (int m : b.genMovesInt(black)) fromInt.add(b.moveToString(m)); + Set fromStr = new TreeSet<>(Arrays.asList(b.possiblesMoves(player))); + if (!fromInt.equals(fromStr)) { + report(b, "ensembles différents pour " + player + + "\n int = " + fromInt + "\n str = " + fromStr); + } + } + + static boolean sameState(EscampeBoard a, EscampeBoard c) { + if (a.lastTileType != c.lastTileType) return false; + if (!a.currentPlayer.equals(c.currentPlayer)) return false; + for (int r = 0; r < 6; r++) + for (int col = 0; col < 6; col++) + if (a.board[r][col] != c.board[r][col]) return false; + return true; + } + + static void report(EscampeBoard b, String msg) { + if (mismatches < 5) { + System.out.println("DIVERGENCE : " + msg); + System.out.println(" lastTileType=" + b.lastTileType + " currentPlayer=" + b.currentPlayer); + } + mismatches++; + } + + static void dumpAndExit() { + System.out.println(">>> arrêt sur première divergence."); + System.exit(1); + } + + /** Placement aléatoire légal : 6 cases distinctes sur les 2 lignes, licorne en tête. */ + static String randomPlacement(EscampeBoard b, String player, int[] rows, Random rng) { + List cells = new ArrayList<>(); + for (int r : rows) for (int c = 0; c < 6; c++) cells.add(new int[]{r, c}); + for (int tries = 0; tries < 100; tries++) { + Collections.shuffle(cells, rng); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 6; i++) { + if (i > 0) sb.append('/'); + sb.append((char) ('A' + cells.get(i)[1])).append((char) ('1' + cells.get(i)[0])); + } + String pl = sb.toString(); + if (b.isValidMove(pl, player)) return pl; + } + throw new IllegalStateException("aucun placement légal trouvé"); + } +} diff --git a/src/escampe_save.txt b/src/escampe_save.txt new file mode 100644 index 0000000..aa6fbdd --- /dev/null +++ b/src/escampe_save.txt @@ -0,0 +1,12 @@ +% Escampe - sauvegarde du plateau +% lastTileType: 1 +% currentPlayer: blanc +% blackPlaced: true +% whitePlaced: true +% blackRows: 4,5 +06 Nnn--- 06 +05 ----nn 05 +04 ------ 04 +03 ------ 03 +02 b--n-b 02 +01 -bb-b- 01 diff --git a/tools/make_report_pdf.py b/tools/make_report_pdf.py new file mode 100644 index 0000000..3effa53 --- /dev/null +++ b/tools/make_report_pdf.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Génère le rapport PDF à partir de report/rapport.html, avec PyMuPDF (fitz). + +Aucune dépendance externe : ni pandoc, ni LaTeX, ni navigateur. On utilise +l'API fitz.Story (rendu HTML/CSS -> PDF multi-pages) puis une seconde passe +pour le pied de page et les numéros de page. + + python tools/make_report_pdf.py +""" +import os +import sys +import fitz # PyMuPDF + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +HTML = os.path.join(ROOT, "report", "rapport.html") +OUT = os.path.join(ROOT, "dist", "Puyaubreau_Russac_rapport.pdf") + +# Feuille de style : mise en page A4 sobre, titres colorés, tables et blocs
+# en Courier pour aligner diagrammes et carte des liserés.
+CSS = """
+* { font-family: serif; }
+body { font-size: 10.5pt; line-height: 1.45; color: #1a1a1a; }
+
+h1, h2, h3, .cover-title, .cover-course, .cover-sub, th { font-family: sans-serif; }
+
+h2 { font-size: 15pt; color: #1c3d5a; margin: 18pt 0 6pt 0;
+     border-bottom: 1.5px solid #1c3d5a; padding-bottom: 2pt; }
+h3 { font-size: 12pt; color: #2a6f97; margin: 12pt 0 3pt 0; }
+p  { margin: 5pt 0; text-align: justify; }
+ul, ol { margin: 4pt 0 4pt 0; }
+li { margin: 2pt 0; }
+
+code { font-family: monospace; font-size: 9.5pt; color: #8a2846; }
+
+pre.grid { font-family: monospace; font-size: 9pt; line-height: 1.25;
+           background: #f4f6f8; border: 1px solid #d3dae0; border-radius: 3px;
+           padding: 7pt; margin: 6pt 0; color: #14213d; white-space: pre; }
+
+table { border-collapse: collapse; width: 100%; margin: 7pt 0; font-size: 9.5pt; }
+th { background: #1c3d5a; color: #ffffff; text-align: left; padding: 4pt 6pt; }
+td { border: 1px solid #c5ccd3; padding: 4pt 6pt; vertical-align: top; }
+tr:nth-child(even) td { background: #f4f6f8; }
+
+p.note { background: #fff8e6; border-left: 3px solid #e0a526;
+         padding: 5pt 8pt; margin: 7pt 0; font-size: 9.8pt; }
+
+/* Éviter qu'un bloc préformaté ou une table soit coupé entre deux pages. */
+pre.grid, table, tr { page-break-inside: avoid; }
+h2, h3 { page-break-after: avoid; }
+.cover { page-break-after: always; }
+
+/* Page de titre */
+.cover { text-align: center; padding-top: 40pt; }
+.cover-univ   { font-size: 10pt; color: #555; margin-bottom: 30pt; }
+.cover-course { font-size: 13pt; color: #2a6f97; letter-spacing: 1pt; }
+.cover-title  { font-size: 30pt; color: #1c3d5a; margin: 8pt 0 0 0; }
+.cover-sub    { font-size: 12pt; color: #333; margin: 4pt 0; }
+.cover-authors{ font-size: 15pt; color: #14213d; margin-top: 34pt; font-family: sans-serif; }
+.cover-date   { font-size: 11pt; color: #555; margin-top: 6pt; }
+.cover-meta   { font-size: 9.5pt; color: #555; margin-top: 34pt; line-height: 1.6; }
+"""
+
+MARGIN = 48          # marge en points (1pt = 1/72")
+FOOTER = "Escampe — Puyaubreau / Russac — version finale"
+
+
+def build():
+    with open(HTML, "r", encoding="utf-8") as f:
+        html = f.read()
+
+    os.makedirs(os.path.dirname(OUT), exist_ok=True)
+
+    mediabox = fitz.paper_rect("a4")
+    where = mediabox + (MARGIN, MARGIN, -MARGIN, -MARGIN)
+
+    # 1) Rendu du flux HTML en pages PDF via Story.
+    writer = fitz.DocumentWriter(OUT)
+    story = fitz.Story(html=html, user_css=CSS)
+    more = 1
+    pages = 0
+    while more:
+        dev = writer.begin_page(mediabox)
+        more, _ = story.place(where)
+        story.draw(dev)
+        writer.end_page()
+        pages += 1
+    writer.close()
+
+    # 2) Seconde passe : pied de page + numéros « page X / N ».
+    doc = fitz.open(OUT)
+    n = doc.page_count
+    for i, page in enumerate(doc, start=1):
+        y = page.rect.height - 26
+        page.insert_text((MARGIN, y), FOOTER, fontname="helv",
+                         fontsize=7.5, color=(0.45, 0.45, 0.45))
+        label = f"page {i} / {n}"
+        w = fitz.get_text_length(label, fontname="helv", fontsize=7.5)
+        page.insert_text((page.rect.width - MARGIN - w, y), label,
+                         fontname="helv", fontsize=7.5, color=(0.45, 0.45, 0.45))
+    doc.saveIncr()
+    doc.close()
+    return pages, n
+
+
+def verify():
+    """Contrôle que les accents survivent au rendu (round-trip texte)."""
+    doc = fitz.open(OUT)
+    full = "".join(p.get_text() for p in doc)
+    doc.close()
+    # Mots accentués présents tels quels dans report/rapport.html.
+    probes = ["liseré", "Présentation", "élagage", "Modélisation",
+              "stratégique", "approfondissement", "Puyaubreau"]
+    missing = [s for s in probes if s not in full]
+    return missing, len(full)
+
+
+if __name__ == "__main__":
+    pages, n = build()
+    missing, chars = verify()
+    print(f"PDF écrit : {OUT}")
+    print(f"Pages : {n}  |  texte extrait : {chars} caractères")
+    if missing:
+        print("ATTENTION, chaînes accentuées introuvables après rendu :", missing)
+        sys.exit(1)
+    print("Accents vérifiés (round-trip OK).")