Cendrelune a un écran de succès et un classement des meilleures incursions, connectés à Game Center côté iOS et à Google Play Games Services côté Android via le plugin games_services. Sur le papier, c'est une fonctionnalité annexe : quelques dizaines de conditions à cocher, un écran de plus. En pratique, la brancher a demandé bien plus de code défensif que de logique de jeu — parce que les deux plateformes mentent, chacune à sa façon, et qu'aucune documentation ne le dit.

Une brique qui ne doit jamais bloquer le jeu

Le catalogue des succès (kAchievementCatalog) est une simple liste déclarative : chaque succès porte une condition (isAchieved), une récompense en ressources, et parfois une progression affichable via progressOf — de quoi afficher une barre « 12 / 16 recrues » plutôt qu'un simple oui ou non. Un cas d'usage, EvaluateAchievements, parcourt cette liste à chaque changement d'état de partie et débloque ce qui doit l'être, en accordant la récompense au passage. Un commentaire du fichier précise que les succès liés aux troupes ou aux zones d'expédition sont générés à partir des catalogues correspondants : ajouter une nouvelle troupe ou une nouvelle zone au jeu étend donc automatiquement la liste des succès, sans ligne à ajouter à la main. Rien dans cette couche ne sait qu'un plugin natif existe quelque part.

La connexion à la plateforme est isolée derrière une interface de domaine, GameServicesRepository, avec deux implémentations : une neutre (NoopGameServicesRepository, sans aucun effet, utilisée par défaut et dans les tests) et une réelle (GamesServicesRepositoryImpl) que main() injecte au démarrage. Tout, dans l'implémentation réelle, est pensé pour échouer en silence : une connexion refusée, un joueur hors ligne, un simulateur sans compte Game Center — dans tous les cas, les succès deviennent des no-op et le jeu continue sans jamais attendre après un service qui ne viendra pas. Détail plus terre-à-terre : Android exige un identifiant propre à chaque succès, attribué par la Play Console, quand iOS réutilise directement l'identifiant interne du catalogue — d'où une table de correspondance (kPlayAchievementIds) qui n'existe que pour Android.

Le lancement qui ne se relance jamais

Le premier piège a été de connecter le joueur au service trop tôt. Une tentative initiale appelait signIn() avant runApp() — juste après le chargement de la sauvegarde, pour paraître le plus tôt possible. Sur iOS, Game Center rappelle l'app en lui tendant sa propre feuille de connexion, que le plugin présente sur la rootViewController de la fenêtre clé. Avant runApp(), cette fenêtre n'existe pas encore : la feuille ne se présente nulle part, la promesse du plugin ne se résout jamais, et le plugin — qui n'arme son gestionnaire d'authentification qu'une seule fois dans toute la durée de vie de l'app — répond ensuite « déjà authentifié » à tout appel ultérieur de signIn(), sans jamais relancer quoi que ce soit. Le joueur restait hors de Game Center pour toute la session, sans qu'aucune erreur ne le signale nulle part.

Le correctif tient en un déplacement : l'appel à signIn() passe dans un addPostFrameCallback, juste après runApp(). La fenêtre existe alors, la feuille de connexion a un endroit où s'afficher, et le rattrapage des succès déjà débloqués localement (syncUnlocked) suit dans la foulée.

isSignedIn ment, mais seulement dans un sens

Le deuxième piège concerne l'état de connexion une fois l'app lancée. Le plugin expose GameAuth.isSignedIn pour relire l'état sans relancer de flux — pratique pour savoir, au moment où le joueur ouvre l'écran de classement, s'il faut retenter une connexion. Le problème : cette valeur lit un cache que le plugin remplit à null dès le tout premier abonnement, avant même que GameKit ait eu le temps d'authentifier qui que ce soit, et qui ne se corrige que si un abonné est encore présent pour recevoir la mise à jour ultérieure. Comme l'appel se désabonne dès qu'il obtient une réponse, le cache reste figé à « non connecté » pour le reste de la session — même si le joueur s'est authentifié entre-temps.

« On croit le oui, on ne croit pas le non. »

La règle retenue traite isSignedIn comme fiable uniquement quand elle répond vrai. Un « faux » ne prouve rien — il peut n'être que ce cache jamais rafraîchi — et déclenche donc une nouvelle tentative de connexion plutôt qu'un renoncement.

Un mot que Sentry filtre lui-même

Troisième surprise, plus inattendue : la réponse brute que le plugin renvoie à une authentification. Sur iOS, elle vaut soit « Player already authenticated » soit « Player authenticated successfully » — deux phrases qui distinguent une connexion déjà établie d'une connexion qui vient tout juste d'aboutir, une nuance utile pour diagnostiquer un souci. Les deux contiennent le mot « auth », que le nettoyage de données par défaut de Sentry considère comme sensible et remplace par [Filtered] avant l'envoi. Résultat : la seule information qu'on cherchait à observer disparaissait systématiquement du diagnostic, remplacée par un texte inutile.

La correction encode la réponse en une étiquette neutre avant de la transmettre (deja_connecte, connexion_reussie, succes côté Android, plus laconique, ou inattendue en repli) plutôt que de transmettre la phrase du plugin telle quelle. C'est aussi, accessoirement, plus lisible qu'un filtrage de sécurité qui décide à la place du diagnostic ce qui a le droit d'être vu.

Un contrat asymétrique entre les deux plateformes

Reste un dernier piège de lecture, sur l'ouverture du classement natif. Les deux plugins iOS et Android partagent la même convention : un échec se signale en levant une exception (FlutterError côté iOS, une erreur de canal côté Android), donc toute valeur de retour, quelle qu'elle soit, signifie un succès. Le contenu de cette valeur, en revanche, ne se ressemble pas d'une plateforme à l'autre : Android répond sobrement « Success », iOS répond une phrase descriptive différente selon le contexte. Une lecture qui traiterait cette valeur comme un message d'erreur à interpréter affichait « classement indisponible » par-dessus un classement qui venait pourtant de s'ouvrir sans problème.

Un classement qui ne vérifie rien, et ce n'est pas un oubli

Le score soumis au classement — le plus haut palier d'incursion vaincu — est déclaratif : ni Game Center ni Google Play Games Services ne le valident, et une sauvegarde locale modifiée passerait sans encombre. Le vérifier demanderait de rejouer le combat côté serveur, ce qu'un jeu entièrement hors-ligne comme Cendrelune n'a pas les moyens d'offrir. C'est une limite assumée dans le code lui-même, pas une faille découverte après coup : un classement communautaire pour un jeu solo sans serveur, ça reste un classement pour le plaisir de comparer, pas une preuve.

Ce qui n'existe (pour l'instant) que dans Cendrelune

AbcDonjon n'a aucune dépendance games_services dans son pubspec.yaml : pas d'écran de succès, pas de classement. Je n'ai pas de raison arrêtée à donner sur cette différence pour l'instant — les dépôts ne disent que le fait, pas le pourquoi.

Ce que ces pièges ont en commun, c'est qu'aucun n'était documenté dans le SDK du plugin ni dans la doc officielle des deux services. Ils ne se voient qu'en lisant le code natif qu'un plugin Flutter enveloppe, ou en observant un joueur qui reste étrangement déconnecté sans qu'aucune erreur ne s'affiche nulle part. Intégrer un service tiers, ici, a surtout consisté à écrire la couche qui ne lui fait pas confiance par défaut : accepter ses succès quand tout va bien, mais ne jamais le laisser décider si le jeu attend, si un joueur reste bloqué, ou si un diagnostic mérite d'être vu.