From c1ac79482ee94ad5b8d356b3e39c7b0d03dd09f0 Mon Sep 17 00:00:00 2001 From: Hamza Date: Sat, 26 Sep 2026 00:01:42 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(discord):=20le=20quotidien=20passe=20d?= =?UTF-8?q?e=208=20=C3=A0=2037=20voix,=20tout=20le=20cast=20principal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les huit voix d'origine brisent le quatrième mur dans les jeux — Velvet Room et mascottes — et pouvaient donc s'adresser au joueur sans que ça sonne faux. Le casting s'ouvre au cast principal de P3, P4 et P5 : eux ne parlent pas AU joueur depuis l'extérieur, ils lui parlent comme à quelqu'un qui joue à côté d'eux. C'est ce registre qui est tenu, pas celui du narrateur omniscient. 29 voix ajoutées, 3 répliques chacune, en français et en anglais. Chaque voix s'appuie sur ce qui la rend reconnaissable plutôt que sur une humeur générique : le baseball de Junpei, les crises de rire de Yukiko, le chou d'Adachi, les vingt minutes que Yusuke passe à contempler sans répondre, le jardinage de Haru qui finit en menace polie. Joker et Koromaru ne parlent pas — ce sont des didascalies. 37 voix : pas un multiple de 7, la contrainte documentée en tête de fichier. Une voix revient tous les 37 jours, le cycle complet fait 111 jours, et chaque personnage parle environ 9 fois par an. Les 37 avatars ont été vérifiés un par un contre la production : Discord télécharge l'image lui-même, un chemin faux donnerait une pastille vide sans que rien ne le signale. --- api/cron/discord-daily.php | 572 ++++++++++++++++++++++++++++++++++++- 1 file changed, 569 insertions(+), 3 deletions(-) diff --git a/api/cron/discord-daily.php b/api/cron/discord-daily.php index 6da0df24..81a55799 100644 --- a/api/cron/discord-daily.php +++ b/api/cron/discord-daily.php @@ -53,9 +53,14 @@ $now = new DateTime('now', $paris); /** - * Les voix. Toutes brisent le quatrième mur dans les jeux : Velvet Room et - * mascottes. Ce sont les seuls personnages qui peuvent s'adresser au joueur - * sans que ça sonne faux. + * Les voix. + * + * Les huit premières brisent le quatrième mur dans les jeux — Velvet Room et + * mascottes — et pouvaient donc s'adresser au joueur sans que ça sonne faux. + * Le casting s'ouvre depuis au cast principal de P3, P4 et P5 (décision Hamza + * du 2026-09-25) : eux ne parlent pas AU joueur depuis l'extérieur, ils lui + * parlent comme à quelqu'un qui joue à côté d'eux. C'est ce registre-là qui + * est tenu — pas celui du narrateur omniscient. * * 'image' est un chemin depuis la racine du site — Discord télécharge l'image * lui-même, elle DOIT donc être publiquement servie. Merope et Philemon @@ -315,6 +320,567 @@ "relance" => "Trouvez-le. / Find it."], ], ], + + // ═══════════════════════════════════════════════════════════════════════ + // PERSONA 3 — S.E.E.S. + // ═══════════════════════════════════════════════════════════════════════ + [ + 'nom' => 'Junpei Iori', + 'image' => 'img/avatar/Junpei.png', + 'color' => 0x2E6DB4, + 'phrases' => [ + ["titre" => "⚾ Le grand Junpei a parlé", + "fr" => "J'ai trouvé la réponse du premier coup. Bon, du troisième. Mais j'ai trouvé.", + "en" => "Got today's answer first try. Okay, third try. But I got it.", + "relance" => "Et toi, combien d'essais ? / How many tries did you need?"], + ["titre" => "⚾ Deuxième base", + "fr" => "Au baseball on te donne trois prises. Ici t'en as plus. Franchement, c'est cadeau.", + "en" => "In baseball you get three strikes. Here you get more. Honestly, that's generous.", + "relance" => "Tu t'en sors ? / Holding up?"], + ["titre" => "⚾ Pas Stupei aujourd'hui", + "fr" => "On m'a assez appelé Stupei pour aujourd'hui. Va rater le tien, ça m'arrangerait.", + "en" => "I've been called Stupei enough for one day. Go miss yours, it'd help.", + "relance" => "Alors, raté ? / So, did you?"], + ], + ], + [ + 'nom' => 'Yukari Takeba', + 'image' => 'img/avatar/Yukari.jpg', + 'color' => 0xE8447E, + 'phrases' => [ + ["titre" => "🏹 Vise mieux que ça", + "fr" => "Une flèche part droit ou elle ne part pas. Réfléchis avant de répondre n'importe quoi.", + "en" => "An arrow flies straight or it doesn't fly. Think before you answer anything.", + "relance" => "Premier essai, sérieusement ? / First try, seriously?"], + ["titre" => "🏹 Ne me refais pas ça", + "fr" => "Hier tu as abandonné. Je ne dirai rien à personne. Mais moi, je l'ai vu.", + "en" => "You gave up yesterday. I won't tell anyone. But I saw it.", + "relance" => "Aujourd'hui, on fait mieux ? / Better today?"], + ["titre" => "🏹 Pas le temps", + "fr" => "Il y a un épisode de Featherman ce soir. Donc fais vite, je ne t'attends pas.", + "en" => "There's a Featherman episode tonight. So be quick, I'm not waiting.", + "relance" => "Tu tiens ta série ? / Streak still alive?"], + ], + ], + [ + 'nom' => 'Mitsuru Kirijo', + 'image' => 'img/avatar/Mitsuru.jpg', + 'color' => 0x8E1230, + 'phrases' => [ + ["titre" => "❄️ Rapport de mission", + "fr" => "La cible du jour est identifiée. Je compte sur une exécution propre.", + "en" => "Today's target has been identified. I expect a clean execution.", + "relance" => "Rapport, s'il vous plaît. / Your report, please."], + ["titre" => "❄️ La discipline avant tout", + "fr" => "Abandonner reste une option. Ce n'est simplement pas celle que j'attends de vous.", + "en" => "Giving up remains an option. It is simply not the one I expect from you.", + "relance" => "Combien d'essais ? / How many attempts?"], + ["titre" => "❄️ Vous êtes attendu", + "fr" => "Six modes. Vous en éviterez un, comme d'habitude. Lequel, cette fois ?", + "en" => "Six modes. You will avoid one, as usual. Which one today?", + "relance" => "Avouez. / Admit it."], + ], + ], + [ + 'nom' => 'Akihiko Sanada', + 'image' => 'img/avatar/Akihiko.jpg', + 'color' => 0xC0392B, + 'phrases' => [ + ["titre" => "🥊 L'entraînement du jour", + "fr" => "Une partie par jour, tous les jours. C'est comme ça qu'on progresse, pas autrement.", + "en" => "One game a day, every day. That's how you get better, no other way.", + "relance" => "Tu tiens depuis combien ? / How long's your streak?"], + ["titre" => "🥊 Encore une série", + "fr" => "Tu as trouvé ? Bien. Recommence dans un autre mode. On ne s'arrête pas sur une victoire.", + "en" => "Got it? Good. Now do another mode. You don't stop on a win.", + "relance" => "Lequel ensuite ? / Which one next?"], + ["titre" => "🥊 Pas d'excuses", + "fr" => "« J'ai pas eu le temps. » Moi non plus. J'ai quand même couru ce matin.", + "en" => "\"I didn't have time.\" Neither did I. I still ran this morning.", + "relance" => "Alors ? / Well?"], + ], + ], + [ + 'nom' => 'Fuuka Yamagishi', + 'image' => 'img/avatar/Fuuka.jpeg', + 'color' => 0x27AE60, + 'phrases' => [ + ["titre" => "📡 Je capte quelque chose", + "fr" => "La cible du jour est en place. Je ne peux pas t'en dire plus… ce ne serait pas juste.", + "en" => "Today's target is in place. I can't tell you more… that wouldn't be fair.", + "relance" => "Tu veux que je cherche encore ? / Want me to keep scanning?"], + ["titre" => "📡 Bon courage", + "fr" => "Prends ton temps. Vraiment. Personne ne te chronomètre ici.", + "en" => "Take your time. Really. Nobody's timing you here.", + "relance" => "Ça avance ? / How's it going?"], + ["titre" => "📡 J'ai préparé quelque chose", + "fr" => "J'ai fait des cookies pour fêter ta série. Ne les mange pas tout de suite.", + "en" => "I baked cookies to celebrate your streak. Don't eat them right away.", + "relance" => "Tu es sûr ? / Are you sure?"], + ], + ], + [ + 'nom' => 'Aigis', + 'image' => 'img/avatar/Aigis.jpg', + 'color' => 0xD4AF37, + 'phrases' => [ + ["titre" => "🤖 Rapport quotidien", + "fr" => "Nouvelle cible détectée. Probabilité de réussite estimée : inconnue. Je manque de données sur toi.", + "en" => "New target detected. Estimated success rate: unknown. I lack data on you.", + "relance" => "Fournis-moi un résultat. / Provide me with a result."], + ["titre" => "🤖 Observation", + "fr" => "Tu joues chaque jour à la même heure. J'ai remarqué. Ce n'est pas un reproche.", + "en" => "You play at the same hour every day. I have noticed. This is not a criticism.", + "relance" => "Est-ce que je me trompe ? / Am I mistaken?"], + ["titre" => "🤖 Protéger", + "fr" => "Ma mission est de te protéger. Je ne peux rien contre une mauvaise réponse.", + "en" => "My mission is to protect you. I can do nothing about a wrong answer.", + "relance" => "Combien d'essais ? / How many tries?"], + ], + ], + [ + 'nom' => 'Ken Amada', + 'image' => 'img/avatar/Ken.jpeg', + 'color' => 0x5DADE2, + 'phrases' => [ + ["titre" => "🔱 Je ne suis pas un enfant", + "fr" => "J'ai trouvé avant toi. Ça arrive plus souvent que tu ne le crois.", + "en" => "I got it before you did. That happens more often than you'd think.", + "relance" => "Prouve le contraire. / Prove me wrong."], + ["titre" => "🔱 Après les devoirs", + "fr" => "Je joue toujours après mes devoirs. Toi, tu as quoi comme excuse ?", + "en" => "I always play after my homework. What's your excuse?", + "relance" => "Alors ? / Well?"], + ["titre" => "🔱 On tient bon", + "fr" => "Rater un jour, ça arrive. Ce n'est pas une raison pour arrêter.", + "en" => "Missing a day happens. That's no reason to stop.", + "relance" => "Ta série va bien ? / Streak okay?"], + ], + ], + [ + 'nom' => 'Shinjiro Aragaki', + 'image' => 'img/avatar/shinjiro_aragaki_p3.jpg', + 'color' => 0x6E4B3A, + 'phrases' => [ + ["titre" => "🍲 Tch.", + "fr" => "Le puzzle est sorti. Tu fais ce que tu veux, ça ne me regarde pas.", + "en" => "Puzzle's up. Do what you want, it's none of my business.", + "relance" => "…tu l'as eu ? / …did you get it?"], + ["titre" => "🍲 Mange d'abord", + "fr" => "J'ai laissé quelque chose sur le feu. Joue après. On réfléchit mal le ventre vide.", + "en" => "Left something on the stove. Play after. You think badly on an empty stomach.", + "relance" => "C'était bon ? / Was it good?"], + ["titre" => "🍲 Pas de sermon", + "fr" => "Je ne vais pas te dire de continuer ta série. Tu sais très bien ce que tu fais.", + "en" => "I'm not gonna tell you to keep your streak. You know what you're doing.", + "relance" => "…ou pas. / …or not."], + ], + ], + [ + 'nom' => 'Koromaru', + 'image' => 'img/avatar/Koromaru.jpg', + 'color' => 0xECF0F1, + 'phrases' => [ + ["titre" => "🐕 Wan !", + "fr" => "*Koromaru attend devant l'écran, la laisse dans la gueule. Le puzzle du jour est sorti.*", + "en" => "*Koromaru waits by the screen, leash in his mouth. Today's puzzle is up.*", + "relance" => "*Il remue la queue.* / *He wags his tail.*"], + ["titre" => "🐕 Il a déjà trouvé", + "fr" => "*Koromaru aboie une fois, s'assoit, et attend. On dirait qu'il connaît la réponse.*", + "en" => "*Koromaru barks once, sits, and waits. He seems to know the answer.*", + "relance" => "*Il penche la tête.* / *He tilts his head.*"], + ["titre" => "🐕 Fidèle au poste", + "fr" => "*Il est venu hier. Avant-hier aussi. Sa série est plus longue que la tienne.*", + "en" => "*He came yesterday. And the day before. His streak is longer than yours.*", + "relance" => "*Wan !* / *Woof!*"], + ], + ], + [ + 'nom' => 'Metis', + 'image' => 'img/avatar/Metis.jpg', + 'color' => 0x9B59B6, + 'phrases' => [ + ["titre" => "⚙️ Cible identifiée", + "fr" => "Le puzzle du jour existe. Je te le signale. Ne me remercie pas, je le fais pour ma sœur.", + "en" => "Today's puzzle exists. I am informing you. Don't thank me, I do it for my sister.", + "relance" => "Va jouer. / Go play."], + ["titre" => "⚙️ Analyse", + "fr" => "Tu abandonnes trop tôt. Ce n'est pas une opinion, c'est une moyenne.", + "en" => "You give up too early. That is not an opinion, it is an average.", + "relance" => "Corrige ça. / Correct that."], + ["titre" => "⚙️ Je reste", + "fr" => "Je n'ai nulle part où aller. Autant te regarder chercher.", + "en" => "I have nowhere to be. I may as well watch you search.", + "relance" => "Continue. / Continue."], + ], + ], + // ═══════════════════════════════════════════════════════════════════════ + // PERSONA 4 — Investigation Team + // ═══════════════════════════════════════════════════════════════════════ + [ + 'nom' => 'Yu Narukami', + 'image' => 'img/avatar/Yu.gif', + 'color' => 0x9FA8DA, + 'phrases' => [ + ["titre" => "📺 …", + "fr" => "Le puzzle du jour est sorti. J'ai déjà joué. Je ne dirai rien de plus.", + "en" => "Today's puzzle is up. I've already played. I won't say more.", + "relance" => "À toi. / Your turn."], + ["titre" => "📺 Question de liens", + "fr" => "Défie un ami aujourd'hui. On progresse plus vite à deux qu'en boucle tout seul.", + "en" => "Challenge a friend today. You improve faster together than alone on a loop.", + "relance" => "Qui tu défies ? / Who are you challenging?"], + ["titre" => "📺 Dans le brouillard", + "fr" => "La réponse est là, derrière. Il suffit de regarder assez longtemps.", + "en" => "The answer is right there, behind it. You just have to look long enough.", + "relance" => "Tu vois quelque chose ? / See anything?"], + ], + ], + [ + 'nom' => 'Yosuke Hanamura', + 'image' => 'img/avatar/Yosuke.jpg', + 'color' => 0xF39C12, + 'phrases' => [ + ["titre" => "🎧 Chaque jour est un grand jour", + "fr" => "Nouveau puzzle ! Bon, j'ai séché sur celui d'hier. Genre longtemps. On n'en parle pas.", + "en" => "New puzzle! Okay, I blanked on yesterday's. For a while. We don't talk about it.", + "relance" => "Toi t'as fait mieux ? / Did you do better?"], + ["titre" => "🎧 Pause déjeuner", + "fr" => "Je joue entre deux rayons. Si le chef passe, tu ne m'as pas vu.", + "en" => "I play between aisles. If the boss walks by, you didn't see me.", + "relance" => "Tu couvres, hein ? / You'll cover for me, right?"], + ["titre" => "🎧 Sérieux ?", + "fr" => "Trois essais et toujours rien. Je commence à croire que le jeu me déteste.", + "en" => "Three tries and nothing. I'm starting to think the game hates me.", + "relance" => "Dis-moi que t'as galéré aussi. / Tell me you struggled too."], + ], + ], + [ + 'nom' => 'Chie Satonaka', + 'image' => 'img/avatar/Chie.jpg', + 'color' => 0x27AE60, + 'phrases' => [ + ["titre" => "🥋 Allez, on y va !", + "fr" => "Nouveau puzzle ! Si tu le trouves du premier coup, je te paie un steak. Enfin, peut-être.", + "en" => "New puzzle! Get it first try and I'll buy you steak. Well — maybe.", + "relance" => "Alors, ce premier essai ? / So, first try?"], + ["titre" => "🥋 Technique secrète", + "fr" => "Mon secret : je réponds vite et fort. Ça marche pas toujours mais c'est marrant.", + "en" => "My secret: answer fast and loud. Doesn't always work but it's fun.", + "relance" => "Essaie, pour voir. / Try it, see what happens."], + ["titre" => "🥋 Pas d'abandon", + "fr" => "Abandonner ? Devant moi ? Recommence, et cette fois réfléchis.", + "en" => "Give up? In front of me? Start over, and actually think this time.", + "relance" => "C'est mieux ? / Better?"], + ], + ], + [ + 'nom' => 'Yukiko Amagi', + 'image' => 'img/avatar/Yukiko.jpg', + 'color' => 0xC0392B, + 'phrases' => [ + ["titre" => "🏮 Bienvenue", + "fr" => "Le puzzle du jour vous attend. Prenez votre temps, la maison ne ferme pas.", + "en" => "Today's puzzle awaits you. Take your time, the inn doesn't close.", + "relance" => "Bien installé ? / Comfortable?"], + ["titre" => "🏮 Pff… hahaha !", + "fr" => "Pardon. J'ai repensé à ma réponse d'hier. Elle était… vraiment très mauvaise. Hahaha !", + "en" => "Sorry. I remembered my answer from yesterday. It was… really quite bad. Hahaha!", + "relance" => "Ne demandez pas. / Don't ask."], + ["titre" => "🏮 Une série tenue", + "fr" => "Revenir chaque jour, c'est tout un art. Je le sais, c'est mon métier.", + "en" => "Coming back every day is an art. I'd know — it's my family's trade.", + "relance" => "Vous tenez depuis quand ? / How long have you kept it?"], + ], + ], + [ + 'nom' => 'Kanji Tatsumi', + 'image' => 'img/avatar/Kanji.jpg', + 'color' => 0x34495E, + 'phrases' => [ + ["titre" => "🧵 Qu'est-ce tu regardes ?", + "fr" => "Ouais, je joue tous les jours. Et alors ? T'as un problème avec ça ?", + "en" => "Yeah, I play every day. So what? You got a problem with that?", + "relance" => "C'est bien ce que je pensais. / That's what I thought."], + ["titre" => "🧵 Du travail soigné", + "fr" => "Une bonne réponse, c'est comme une bonne couture. Ça se voit pas, mais ça tient.", + "en" => "A good answer's like good stitching. You don't see it, but it holds.", + "relance" => "Quoi ? / What?"], + ["titre" => "🧵 Vas-y franchement", + "fr" => "Arrête de tourner autour. Réponds. Le pire qui arrive, c'est que t'as tort.", + "en" => "Quit circling it. Answer. Worst case, you're wrong.", + "relance" => "Alors ? / Well?"], + ], + ], + [ + 'nom' => 'Rise Kujikawa', + 'image' => 'img/avatar/Rise.jpg', + 'color' => 0xE84393, + 'phrases' => [ + ["titre" => "🎤 Risette est en ligne !", + "fr" => "Le puzzle du jour est là, et je le sens bien pour toi. Vas-y, je te regarde !", + "en" => "Today's puzzle is up, and I've got a good feeling about you. Go on, I'm watching!", + "relance" => "Tu me montres ? / Show me?"], + ["titre" => "🎤 Analyse en cours", + "fr" => "Je vois tes faiblesses d'ici. Le mode Émoji, par exemple. On en parle ?", + "en" => "I can see your weak spots from here. Emoji mode, for instance. Shall we talk?", + "relance" => "Je me trompe ? / Am I wrong?"], + ["titre" => "🎤 Encore une !", + "fr" => "Une partie de plus ? Allez, une seule. Pour moi.", + "en" => "One more round? Come on, just one. For me.", + "relance" => "Tu peux pas dire non. / You can't say no."], + ], + ], + [ + 'nom' => 'Naoto Shirogane', + 'image' => 'img/avatar/Naoto.jpg', + 'color' => 0x2C3E50, + 'phrases' => [ + ["titre" => "🔍 Le dossier du jour", + "fr" => "Une nouvelle affaire. Les indices sont tous là ; il ne manque que la déduction.", + "en" => "A new case. Every clue is present; only the deduction is missing.", + "relance" => "Votre conclusion ? / Your conclusion?"], + ["titre" => "🔍 Méthode", + "fr" => "Éliminez ce qui est impossible. Ce qui reste, aussi improbable soit-il, est la réponse.", + "en" => "Eliminate the impossible. What remains, however improbable, is the answer.", + "relance" => "Combien d'essais ? / How many attempts?"], + ["titre" => "🔍 Une remarque", + "fr" => "Vous abandonnez plus souvent en Silhouette qu'ailleurs. J'ai vérifié.", + "en" => "You give up more often in Silhouette than elsewhere. I checked.", + "relance" => "Démentez-moi. / Prove me wrong."], + ], + ], + [ + 'nom' => 'Nanako Dojima', + 'image' => 'img/avatar/Nanako.jpg', + 'color' => 0xF8C471, + 'phrases' => [ + ["titre" => "🌻 Grand frère !", + "fr" => "Il y a un nouveau puzzle ! J'ai essayé mais c'est dur. Tu m'aides ?", + "en" => "There's a new puzzle! I tried but it's hard. Will you help me?", + "relance" => "Dis, tu as trouvé ? / Did you get it?"], + ["titre" => "🌻 Every day's great…", + "fr" => "J'ai la chanson de Junes dans la tête depuis ce matin. Maintenant toi aussi.", + "en" => "I've had the Junes song stuck in my head all morning. Now you do too.", + "relance" => "Désolée ! / Sorry!"], + ["titre" => "🌻 Tu reviens demain ?", + "fr" => "Papa rentre tard encore. Mais toi tu reviens demain, hein ?", + "en" => "Dad's working late again. But you'll come back tomorrow, right?", + "relance" => "Promis ? / Promise?"], + ], + ], + [ + 'nom' => 'Tohru Adachi', + 'image' => 'img/avatar/Adachi.jpg', + 'color' => 0x7F8C8D, + 'phrases' => [ + ["titre" => "🥬 Encore un puzzle", + "fr" => "Un de plus. Tu vas le faire, tu vas gagner, et demain ça recommence. Passionnant.", + "en" => "Another one. You'll do it, you'll win, and tomorrow it starts over. Riveting.", + "relance" => "Amuse-toi bien. / Have fun with that."], + ["titre" => "🥬 Entre nous", + "fr" => "Personne ne vérifie si tu abandonnes. Personne ne regarde. Enfin — presque personne.", + "en" => "Nobody checks if you give up. Nobody's watching. Well — almost nobody.", + "relance" => "Quoi ? / What?"], + ["titre" => "🥬 Du chou", + "fr" => "J'ai encore mangé du chou ce midi. Voilà, c'était mon actualité. À toi de jouer.", + "en" => "Had cabbage for lunch again. There, that's my news. Your turn.", + "relance" => "Fascinant, hein ? / Fascinating, right?"], + ], + ], + // ═══════════════════════════════════════════════════════════════════════ + // PERSONA 5 — Phantom Thieves + // ═══════════════════════════════════════════════════════════════════════ + [ + 'nom' => 'Joker', + 'image' => 'img/avatar/JOKER.webp', + 'color' => 0xE60012, + 'phrases' => [ + ["titre" => "🃏 …", + "fr" => "*Il pose la carte du jour sur la table, sans un mot, et attend.*", + "en" => "*He lays today's card on the table, without a word, and waits.*", + "relance" => "*Il hausse un sourcil.* / *He raises an eyebrow.*"], + ["titre" => "🃏 Prends ton temps", + "fr" => "*Un hochement de tête vers l'écran. Le message est clair : à toi de jouer.*", + "en" => "*A nod toward the screen. The message is clear: your move.*", + "relance" => "*Il attend toujours.* / *Still waiting.*"], + ["titre" => "🃏 Tout ou rien", + "fr" => "*Il retourne la carte. Elle était bonne depuis le début — il le savait.*", + "en" => "*He flips the card. It was right all along — he knew.*", + "relance" => "*Un demi-sourire.* / *Half a smile.*"], + ], + ], + [ + 'nom' => 'Ryuji Sakamoto', + 'image' => 'img/avatar/Ryuji.jpg', + 'color' => 0xF1C40F, + 'phrases' => [ + ["titre" => "💀 Sérieux ?!", + "fr" => "Nouveau puzzle ! Bon j'ai rien compris au premier essai, mais on s'en fout, on y va.", + "en" => "New puzzle! Okay I got nothin' on the first try, but whatever, let's go.", + "relance" => "Tu viens ou quoi ? / You comin' or what?"], + ["titre" => "💀 On fonce", + "fr" => "Réfléchir c'est surfait. Moi je réponds, et après je réfléchis. Ça marche… des fois.", + "en" => "Thinking's overrated. I answer first, think after. Works… sometimes.", + "relance" => "Fais pas comme moi. / Don't do it like me."], + ["titre" => "💀 Ta série !", + "fr" => "Hé, t'as pas joué hier. J'ai vu. Me laisse pas tout seul là-dedans.", + "en" => "Yo, you didn't play yesterday. I saw. Don't leave me hangin' here.", + "relance" => "Aujourd'hui tu viens. / Today you show up."], + ], + ], + [ + 'nom' => 'Ann Takamaki', + 'image' => 'img/avatar/Ann.jpg', + 'color' => 0xE74C3C, + 'phrases' => [ + ["titre" => "🍰 Pause crêpe", + "fr" => "Nouveau puzzle ! Je le fais en mangeant une crêpe. C'est ma méthode, elle vaut ce qu'elle vaut.", + "en" => "New puzzle! I'm doing it over a crepe. That's my method, take it or leave it.", + "relance" => "Tu manges quoi, toi ? / What are you having?"], + ["titre" => "🍰 Pas d'abandon", + "fr" => "Abandonner, jamais. Enfin… presque jamais. Bon, hier. Mais c'était injuste.", + "en" => "Never give up. Well… almost never. Okay, yesterday. But that one was unfair.", + "relance" => "Tu l'as trouvé, toi ? / Did you get it?"], + ["titre" => "🍰 Ne te compare pas", + "fr" => "Peu importe le nombre d'essais. Tu es venu jouer, c'est déjà ça.", + "en" => "Doesn't matter how many tries. You showed up, that already counts.", + "relance" => "Alors ? / So?"], + ], + ], + [ + 'nom' => 'Yusuke Kitagawa', + 'image' => 'img/avatar/Yusuke.jpg', + 'color' => 0x3498DB, + 'phrases' => [ + ["titre" => "🎨 Magnifique", + "fr" => "La composition de ce puzzle est remarquable. Je l'observe depuis vingt minutes. Sans répondre.", + "en" => "The composition of today's puzzle is remarkable. I have observed it for twenty minutes. Without answering.", + "relance" => "Vous voyez ce que je vois ? / Do you see what I see?"], + ["titre" => "🎨 Une question de forme", + "fr" => "Une silhouette n'est jamais vide. Elle contient tout ce que l'œil veut bien y mettre.", + "en" => "A silhouette is never empty. It holds whatever the eye is willing to place in it.", + "relance" => "Regardez encore. / Look again."], + ["titre" => "🎨 Je suis fauché", + "fr" => "J'ai dépensé mon dernier yen en pinceaux. Heureusement, ce jeu est gratuit.", + "en" => "I spent my last yen on brushes. Fortunately, this game is free.", + "relance" => "Quelle chance. / How fortunate."], + ], + ], + [ + 'nom' => 'Makoto Niijima', + 'image' => 'img/avatar/Makoto.jpg', + 'color' => 0x8E44AD, + 'phrases' => [ + ["titre" => "📋 Ordre du jour", + "fr" => "Nouveau puzzle. Je propose de commencer par le mode où tu es le plus faible.", + "en" => "New puzzle. I suggest starting with the mode you're weakest in.", + "relance" => "Tu sais lequel. / You know which one."], + ["titre" => "📋 De la méthode", + "fr" => "Répondre au hasard n'est pas une stratégie. C'est l'absence de stratégie.", + "en" => "Answering at random isn't a strategy. It's the absence of one.", + "relance" => "On recommence ? / Shall we start over?"], + ["titre" => "📋 Un bilan", + "fr" => "Ta série tient bon. Je le note, parce que personne d'autre ne le fera.", + "en" => "Your streak is holding. I'm noting it, since no one else will.", + "relance" => "Continue. / Keep going."], + ], + ], + [ + 'nom' => 'Haru Okumura', + 'image' => 'img/avatar/Haru.png', + 'color' => 0xD98CB3, + 'phrases' => [ + ["titre" => "🌱 Bonjour !", + "fr" => "Le puzzle du jour a fleuri. Prenez-en soin, il ne durera qu'une journée.", + "en" => "Today's puzzle has bloomed. Do take care of it — it only lasts a day.", + "relance" => "Vous l'avez trouvé ? / Did you find it?"], + ["titre" => "🌱 Avec douceur", + "fr" => "Rien ne presse. Un jardin ne pousse pas plus vite parce qu'on crie dessus.", + "en" => "There's no rush. A garden doesn't grow faster because you shout at it.", + "relance" => "Respirez. / Breathe."], + ["titre" => "🌱 Ah, et…", + "fr" => "Si le puzzle vous résiste, écrasez-le. Métaphoriquement, bien sûr. ☺️", + "en" => "If the puzzle resists you, crush it. Metaphorically, of course. ☺️", + "relance" => "Bien sûr. / Of course."], + ], + ], + [ + 'nom' => 'Futaba Sakura', + 'image' => 'img/avatar/Futaba.jpg', + 'color' => 0x16A085, + 'phrases' => [ + ["titre" => "💻 Oracle en ligne", + "fr" => "J'ai regardé le code. Non je rigole. Enfin… presque. Bon, joue.", + "en" => "I looked at the code. Kidding. Well… mostly. Anyway, go play.", + "relance" => "T'as trouvé ? / Got it?"], + ["titre" => "💻 Statistiques", + "fr" => "Tu joues toujours le même mode en premier. Toujours. J'ai les chiffres.", + "en" => "You always play the same mode first. Always. I have the numbers.", + "relance" => "Je peux les montrer. / I can show them."], + ["titre" => "💻 Sors de ta chambre", + "fr" => "Bon, moi je peux parler. Mais toi, va au moins jusqu'à l'écran.", + "en" => "Okay, look who's talking. But you — at least make it to the screen.", + "relance" => "Allez. / Go."], + ], + ], + [ + 'nom' => 'Goro Akechi', + 'image' => 'img/avatar/Akechi.jpg', + 'color' => 0xB03A2E, + 'phrases' => [ + ["titre" => "🎩 Le Prince Détective", + "fr" => "Une nouvelle énigme. J'ai déjà la réponse, naturellement. Mais je vous laisse chercher.", + "en" => "A new riddle. I already have the answer, naturally. But I'll let you look.", + "relance" => "Prenez votre temps. / Do take your time."], + ["titre" => "🎩 Un détail", + "fr" => "Vous hésitez trop longtemps. C'est révélateur — mais je ne dirai pas de quoi.", + "en" => "You hesitate too long. It's revealing — though I won't say of what.", + "relance" => "Continuez. / Do go on."], + ["titre" => "🎩 Des pancakes", + "fr" => "J'ai un rendez-vous, un tournage, et une pile de pancakes qui m'attend. Faites vite.", + "en" => "I have an appointment, a shoot, and a stack of pancakes waiting. Be quick.", + "relance" => "Alors ? / Well?"], + ], + ], + [ + 'nom' => 'Sae Niijima', + 'image' => 'img/avatar/sae_niijima_p5.jpg', + 'color' => 0x5D6D7E, + 'phrases' => [ + ["titre" => "⚖️ Soyons directs", + "fr" => "Le puzzle du jour. Vous avez vos indices. Je veux une réponse, pas une hypothèse.", + "en" => "Today's puzzle. You have your evidence. I want an answer, not a theory.", + "relance" => "J'écoute. / I'm listening."], + ["titre" => "⚖️ Contre-interrogatoire", + "fr" => "Vous avez abandonné hier. Expliquez-moi pourquoi. Je ne juge pas — je note.", + "en" => "You gave up yesterday. Explain why. I'm not judging — I'm recording.", + "relance" => "Je vous écoute. / I'm waiting."], + ["titre" => "⚖️ Le dossier tient", + "fr" => "Votre série est solide. C'est rare. Ne la compromettez pas ce soir.", + "en" => "Your streak is solid. That's rare. Don't compromise it tonight.", + "relance" => "C'est noté ? / Understood?"], + ], + ], + [ + 'nom' => 'Sumire Yoshizawa', + 'image' => 'img/avatar/Sumire.jpg', + 'color' => 0xCD6155, + 'phrases' => [ + ["titre" => "🤸 Encore une fois", + "fr" => "Nouveau puzzle ! Si vous ratez, recommencez. C'est comme ça qu'on apprend une figure.", + "en" => "New puzzle! If you miss, start again. That's how you learn a routine.", + "relance" => "On y retourne ? / Shall we go again?"], + ["titre" => "🤸 Sans trembler", + "fr" => "Le plus dur n'est pas de trouver. C'est de valider la réponse dont on est sûr.", + "en" => "The hard part isn't finding it. It's committing to the answer you're sure of.", + "relance" => "Vous hésitez ? / Hesitating?"], + ["titre" => "🤸 Merci d'être là", + "fr" => "Vous revenez tous les jours. Ça compte plus que le score, vraiment.", + "en" => "You come back every day. That matters more than the score, truly.", + "relance" => "À demain ! / See you tomorrow!"], + ], + ], ]; // Voix par jour, phrase par tour de rotation : la voix revient tous les 8 jours From a770eb7318a4b8e066d7ec7107238088978ebac9 Mon Sep 17 00:00:00 2001 From: Hamza Date: Sat, 26 Sep 2026 00:03:43 +0200 Subject: [PATCH 2/9] feat(discord): Joker parle vraiment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Je l'avais écrit en didascalies, au motif qu'il est un protagoniste muet. C'est une convention de jeu, pas une règle : il a des répliques dans P5, et rien ne justifiait de le priver de voix ici. Décision Hamza. Ses trois lignes tiennent le registre Phantom Thief — une cible qu'on repère, une réponse qu'on vole plutôt qu'on ne devine — et restent courtes, comme ses options de dialogue dans le jeu. Koromaru garde ses didascalies : c'est un chien. --- api/cron/discord-daily.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/api/cron/discord-daily.php b/api/cron/discord-daily.php index 81a55799..20c7503d 100644 --- a/api/cron/discord-daily.php +++ b/api/cron/discord-daily.php @@ -696,18 +696,18 @@ 'image' => 'img/avatar/JOKER.webp', 'color' => 0xE60012, 'phrases' => [ - ["titre" => "🃏 …", - "fr" => "*Il pose la carte du jour sur la table, sans un mot, et attend.*", - "en" => "*He lays today's card on the table, without a word, and waits.*", - "relance" => "*Il hausse un sourcil.* / *He raises an eyebrow.*"], - ["titre" => "🃏 Prends ton temps", - "fr" => "*Un hochement de tête vers l'écran. Le message est clair : à toi de jouer.*", - "en" => "*A nod toward the screen. The message is clear: your move.*", - "relance" => "*Il attend toujours.* / *Still waiting.*"], - ["titre" => "🃏 Tout ou rien", - "fr" => "*Il retourne la carte. Elle était bonne depuis le début — il le savait.*", - "en" => "*He flips the card. It was right all along — he knew.*", - "relance" => "*Un demi-sourire.* / *Half a smile.*"], + ["titre" => "🃏 La cible du jour", + "fr" => "On a repéré la cible. Le reste, c'est de l'exécution. Ne rate pas ton entrée.", + "en" => "Target's been marked. The rest is execution. Don't miss your entrance.", + "relance" => "Tu prends quel mode ? / Which mode are you taking?"], + ["titre" => "🃏 Vole-la", + "fr" => "Une réponse, ça ne se devine pas. Ça se prend. Avance et sers-toi.", + "en" => "You don't guess an answer. You take it. Move in and help yourself.", + "relance" => "Alors ? / Well?"], + ["titre" => "🃏 Je l'avais", + "fr" => "Premier essai. Non, je ne vais pas te dire comment. Cherche.", + "en" => "First try. No, I'm not telling you how. Go look.", + "relance" => "Bonne chance. / Good luck."], ], ], [ From 07017ad1f891be1436ffca7de0668e9eeee2022b Mon Sep 17 00:00:00 2001 From: Hamza Date: Sat, 26 Sep 2026 00:05:05 +0200 Subject: [PATCH 3/9] feat(discord): Makoto Yuki et Kotone Shiomi rejoignent le quotidien MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Les deux protagonistes de Persona 3 ouvrent la section P3 — même année, même dortoir, deux façons de la raconter. C'est ce contraste qui porte leurs répliques : lui laconique et fataliste, elle enjouée et taquine. Comme pour Joker, ils parlent. Le protagoniste muet est une convention de jeu, pas une raison de les priver de voix ici. Kotone porte son portrait P5X, celui de sa mise à jour. 39 voix : toujours pas un multiple de 7 (reste 4). Cycle complet de 117 jours, chaque personnage parle environ 9 fois par an. Les 39 avatars revérifiés contre la production. --- api/cron/discord-daily.php | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/api/cron/discord-daily.php b/api/cron/discord-daily.php index 20c7503d..03a19ac7 100644 --- a/api/cron/discord-daily.php +++ b/api/cron/discord-daily.php @@ -324,6 +324,44 @@ // ═══════════════════════════════════════════════════════════════════════ // PERSONA 3 — S.E.E.S. // ═══════════════════════════════════════════════════════════════════════ + [ + 'nom' => 'Makoto Yuki', + 'image' => 'img/avatar/makoto_yuki.jpg', + 'color' => 0x3A5BA0, + 'phrases' => [ + ["titre" => "🎧 Une journée de plus", + "fr" => "Nouveau puzzle. Ça revient chaque jour, sans faute. C'est reposant, quelque part.", + "en" => "New puzzle. It comes back every day, without fail. There's something restful in that.", + "relance" => "Tu joues ? / Are you playing?"], + ["titre" => "🎧 Memento mori", + "fr" => "Rien ne dure. Ni les séries, ni les jours. Raison de plus pour jouer celui-là.", + "en" => "Nothing lasts. Not streaks, not days. All the more reason to play this one.", + "relance" => "Vas-y. / Go ahead."], + ["titre" => "🎧 Sans commentaire", + "fr" => "J'ai trouvé. Je n'ai rien dit à personne. Tu peux faire pareil.", + "en" => "I got it. I didn't tell anyone. You can do the same.", + "relance" => "Ou pas. / Or not."], + ], + ], + [ + 'nom' => 'Kotone Shiomi', + 'image' => 'img/avatar/kotone_shiomi_p5x_crossover.jpg', + 'color' => 0xE8447E, + 'phrases' => [ + ["titre" => "🎀 Salut, toi !", + "fr" => "Nouveau puzzle ! Moi je l'ai déjà fait, évidemment. Mais je ne dis rien, promis.", + "en" => "New puzzle! I've already done it, obviously. But I'm not saying a word, promise.", + "relance" => "À toi ! / Your turn!"], + ["titre" => "🎀 Même dortoir, autre chanson", + "fr" => "On a vécu la même année, lui et moi. On ne l'a pas racontée pareil.", + "en" => "He and I lived through the same year. We didn't tell it the same way.", + "relance" => "Tu préfères laquelle ? / Which version do you prefer?"], + ["titre" => "🎀 Pas une princesse", + "fr" => "Personne ne viendra te souffler la réponse. Tant mieux — c'est plus drôle comme ça.", + "en" => "Nobody's coming to hand you the answer. Good — it's more fun that way.", + "relance" => "Vas-y, fonce. / Go on, jump in."], + ], + ], [ 'nom' => 'Junpei Iori', 'image' => 'img/avatar/Junpei.png', From d59aef03e37426849271ebe7fcb5c4e4d9389578 Mon Sep 17 00:00:00 2001 From: Hamza Date: Sat, 26 Sep 2026 00:13:42 +0200 Subject: [PATCH 4/9] =?UTF-8?q?feat(discord):=20l'hebdomadaire=20montre=20?= =?UTF-8?q?les=20portraits=20et=20les=20amiti=C3=A9s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le top 3 de la semaine gagne un encart illustré par joueur, et un podium des amitiés les plus fortes. Le mot d'accueil de Margaret tourne sur 7 phrases. Le piège est le stockage des portraits : un portrait recadré vit en base64 dans avatar_data, et Discord va chercher l'image à une adresse — il ne sait rien faire d'une data-URL. Seuls les portraits de galerie non recadrés laissent un chemin exploitable, soit 43 profils sur 328 en production. Le portrait est donc un bonus et jamais la structure : le podium reste entièrement lisible en texte, et l'encart illustré ne s'ajoute que pour ceux qui en ont un. Aucun encart vide, aucune image cassée. La résolution d'URL refuse tout ce qui n'est pas un fichier de la galerie — data-URL, URL externe, remontée de dossier, nom hors liste blanche. Ces valeurs viennent de la base, donc d'une saisie utilisateur passée par l'API. Les amitiés ne sont pas bornées à la semaine, contrairement aux victoires : une amitié se construit dans la durée, et sept jours ne diraient que qui a joué ensemble. Tri par XP comme la page Amitié, le rang plafonnant à 10. Les 7 phrases ne divisent pas 52 sans reste, sinon la même phrase retomberait sur la même semaine chaque année. Un test l'impose. --- CONTRIBUTING.md | 2 +- .../PersonaDLE 2.3/DEV_CHANGELOG.md | 74 ++++++++ ROADMAP.md | 4 +- TODO.md | 2 +- api/cron/discord_weekly.php | 58 +++++-- api/lib/weekly_podium.php | 140 ++++++++++++++- tests/README.md | 4 +- tests/php/WeeklyDigestTest.php | 164 ++++++++++++++++++ 8 files changed, 429 insertions(+), 19 deletions(-) create mode 100644 tests/php/WeeklyDigestTest.php diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6dd5c722..92946188 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ Le hook de pre-commit lance déjà i18n + tests. Détail : | Commande | Vérifie | |---|---| | `make test` / `npm test` | 1548 tests JS (Vitest) | -| `make test-php` | 379 méthodes de test PHPUnit dans 22 fichiers (logique + intégration BDD). Tourne **dans le conteneur Docker** (`make up` requis) — pas besoin de PHP installé sur ta machine | +| `make test-php` | 395 méthodes de test PHPUnit dans 23 fichiers (logique + intégration BDD). Tourne **dans le conteneur Docker** (`make up` requis) — pas besoin de PHP installé sur ta machine | | `npm run lint` | ESLint | | `npm run data:check` | schéma des données personnages | | `npm run i18n:check` | cohérence des clés de traduction | diff --git a/PersonaDLE_Update_Documentation/PersonaDLE 2.3/DEV_CHANGELOG.md b/PersonaDLE_Update_Documentation/PersonaDLE 2.3/DEV_CHANGELOG.md index 3bfaef40..500d463d 100644 --- a/PersonaDLE_Update_Documentation/PersonaDLE 2.3/DEV_CHANGELOG.md +++ b/PersonaDLE_Update_Documentation/PersonaDLE 2.3/DEV_CHANGELOG.md @@ -45,6 +45,80 @@ Découpage en lots — une branche, une PR vers `develop` par ligne : --- +## 2026-09-26 — Discord : 39 voix au quotidien, portraits et amitiés à l'hebdo + +Deux demandes de Hamza sur le bot. + +### Le quotidien passe de 8 à 39 voix + +Les huit d'origine brisent le quatrième mur **dans les jeux** — Velvet Room et mascottes — +ce qui justifiait qu'elles s'adressent au joueur. Le cast principal n'a pas ce privilège : +plutôt que de le leur prêter, ils parlent **comme à quelqu'un qui joue à côté d'eux**. + +31 voix ajoutées, 3 répliques chacune, FR et EN, chacune accrochée à ce qui rend le +personnage reconnaissable — le baseball de Junpei, les crises de rire de Yukiko, le chou +d'Adachi, les vingt minutes que Yusuke passe à contempler **sans répondre**, le jardinage +de Haru qui finit en menace polie. + +**Joker parle**, ainsi que Makoto Yuki et Kotone Shiomi. Je les avais d'abord écrits en +didascalies au motif qu'ils sont des protagonistes muets — c'est une convention de jeu, pas +une règle, et Hamza a tranché. Koromaru garde les siennes : c'est un chien. + +**39 voix** : la contrainte du fichier interdit tout multiple de 7, sinon « jour de l'année +% 7 » fige une voix par jour de la semaine et le joueur du lundi n'en voit qu'une, à vie. +39 laisse un reste de 4. Cycle complet de 117 jours, chaque personnage parle ~9 fois par an. + +**Les 39 avatars ont été contrôlés un par un contre la production.** Discord télécharge +l'image lui-même : un chemin faux donne une pastille vide, sans la moindre erreur. Au +passage, le premier contrôle renvoyait `000` partout — c'était la boucle de test qui +traînait des retours chariot Windows, pas les URL. + +### L'hebdomadaire montre les portraits et les amitiés + +Demande : « dans le weekly on fait le top 3 des joueurs, si ils ont une pdp on l'affiche, +et les meilleurs amis pareil, avec un message qui change ». + +**Le piège est le stockage des portraits.** Un portrait recadré vit en **base64** dans +`profiles.avatar_data` (`data:image/png;base64,…`), et Discord va CHERCHER l'image à une +adresse — il ne sait rien faire d'une data-URL. Seuls les portraits de galerie non recadrés +laissent un chemin exploitable. + +Mesuré en production : **43 profils sur 328** ont une adresse utilisable, soit 13 %. Le +portrait est donc **un bonus, jamais la structure** : le podium reste entièrement lisible en +texte, et un encart illustré s'ajoute uniquement pour les joueurs qui en ont un. Aucun +encart vide, aucune image cassée — c'est ce que Discord afficherait sinon. + +`personadle_weekly_avatar_url()` refuse tout ce qui n'est pas un fichier de la galerie : +data-URL, URL externe, remontée de dossier, nom hors liste blanche. Ces valeurs viennent de +la base, donc d'une saisie utilisateur passée par l'API — elles ne doivent pas devenir une +adresse qu'on demande à Discord d'aller chercher. + +**Les amitiés ne sont pas bornées à la semaine**, contrairement aux victoires. Une amitié se +construit dans la durée ; un classement hebdomadaire des liens ne dirait que « qui a joué +ensemble ces sept jours ». C'est donc le classement de tous les temps, trié par XP comme la +page Amitié — le rang plafonne à 10 et donnerait des dizaines d'ex æquo. + +**Le mot d'accueil de Margaret tourne** sur 7 phrases. Le nombre n'est pas neutre : s'il +divisait 52 sans reste, la même phrase retomberait sur la même semaine chaque année et le +rendez-vous deviendrait un calendrier fixe. 52 = 7 × 7 + 3, la rotation dérive. Un test +l'impose plutôt que de compter sur la vigilance. + +### Vérifications + +- `tests/php/WeeklyDigestTest.php` — **nouveau**, 16 cas / 38 assertions. Dont la remontée + de dossier, l'URL externe, le pseudo en Markdown qui détournerait la mise en forme, et le + fait que 52 ne soit pas divisible par le nombre de phrases. +- **395 tests PHPUnit** verts, syntaxe PHP validée dans le conteneur. +- Chacune des 39 voix contrôlée : 3 répliques **complètes** (titre, FR, EN, relance). Une + réplique amputée passerait sinon en production sans erreur. +- Aucune réplique ne dépasse 165 caractères — au-delà, c'est illisible sur téléphone. + +### Laissé de côté + +Sojiro, Dojima et Elizabeth n'ont **pas d'avatar** dans `img/avatar/`. Ils ne sont pas dans +le casting : leur coller le portrait d'un autre aurait été pire que leur absence. + +--- ## 2026-09-25 — La page Classique démarrait 46 px plus bas que les cinq autres « Sur l'écran d'ami, la page Classique n'a pas la nouvelle taille adaptée, obligé de diff --git a/ROADMAP.md b/ROADMAP.md index f56a92ad..ef395fab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -499,7 +499,7 @@ Nouveau jeu — cas A (roster inédit) > Synthèse : backend PHP/MariaDB complet (auth, sessions, social, leaderboard, admin, RGPD), > profil personnalisable (avatars groupés, musique, couleurs, badges, titres, wallpapers), > Social Link rangs 1-10, défis, streak globale + Jack Frost, FAQ, i18n 6 langues, -> **1548 tests JS · 379 PHPUnit · 283 E2E · PHPStan niveau 5 · CI/CD GitHub Actions**. +> **1548 tests JS · 395 PHPUnit · 283 E2E · PHPStan niveau 5 · CI/CD GitHub Actions**. ### Backend & Infrastructure @@ -555,7 +555,7 @@ Nouveau jeu — cas A (roster inédit) | # | Élément | Notes | | --- | --------------------------------------------- | ------------------------------------------------- | -| Q1 | Tests : 1548 Vitest · 379 PHPUnit · 283 E2E | `npm test` · `make test-php` · `npm run test:e2e` | +| Q1 | Tests : 1548 Vitest · 395 PHPUnit · 283 E2E | `npm test` · `make test-php` · `npm run test:e2e` | | Q2 | i18n EN/FR/ES/DE/IT/PT (1314 clés) | `npm run i18n:check` | | Q3 | PHPStan niveau 5 + ESLint + Prettier | Dans la CI | | Q4 | Seuils de couverture en CI | `npm run test:coverage` (~77 %) | diff --git a/TODO.md b/TODO.md index ddb4516c..eff309f0 100644 --- a/TODO.md +++ b/TODO.md @@ -8,7 +8,7 @@ > > Chaque section numérotée est dimensionnée pour tenir dans **une seule branche**. > -> Vérifié le 2026-08-26 : 1548 tests Vitest (86 suites), 379 méthodes PHPUnit, 283 tests E2E, +> Vérifié le 2026-08-26 : 1548 tests Vitest (86 suites), 395 méthodes PHPUnit, 283 tests E2E, > lint et data/i18n/pools propres. --- diff --git a/api/cron/discord_weekly.php b/api/cron/discord_weekly.php index befb5c5e..8c0a678a 100644 --- a/api/cron/discord_weekly.php +++ b/api/cron/discord_weekly.php @@ -58,6 +58,11 @@ $normal = personadle_weekly_podium($pdo, $from, false); $expert = personadle_weekly_podium($pdo, $from, true); +// Les amitiés ne sont PAS bornées à la semaine : une amitié se construit sur la +// durée, et un classement hebdomadaire des liens ne dirait que « qui a joué +// ensemble ces sept jours ». C'est donc le classement de tous les temps, comme +// la page Amitié du site. +$bonds = personadle_weekly_bonds($pdo); if ($normal === [] && $expert === []) { jsonSuccess([ @@ -73,10 +78,16 @@ if ($expert !== []) { $fields[] = ['name' => '⚡ Mode Expert', 'value' => personadle_weekly_lines($expert), 'inline' => false]; } +if ($bonds !== []) { + $fields[] = ['name' => '💞 Les amitiés les plus fortes', 'value' => personadle_weekly_bond_lines($bonds), 'inline' => false]; +} $periode = sprintf('du %s au %s', $monday->format('d/m'), $sunday->format('d/m')); -$corps = "Le registre de la semaine est clos. Voici ceux dont les noms y figurent en tête.\n" - . "*The week's record is closed. These are the names written at the top.*\n\n" +$intro = personadle_weekly_intro((int) $monday->format('W')); +$corps = $intro['fr'] . " +*" . $intro['en'] . "* + +" . '[Classement complet / Full leaderboard](' . LEADERBOARD_URL . '?period=week)'; $avatar = SITE_WEEKLY . 'img/avatar/margaret.jpg'; @@ -84,17 +95,41 @@ ? preg_replace('/\D/', '', (string) DISCORD_WEEKLY_MENTION_ROLE) : ''; +$embeds = [[ + 'title' => '📖 Top 3 de la semaine — ' . $periode, + 'description' => $corps, + 'color' => 0xB03A2E, + 'thumbnail' => ['url' => $avatar], + 'fields' => $fields, + 'footer' => ['text' => 'PersonaDLE — semaine ' . $monday->format('W')], +]]; + +// Un encart par joueur du podium QUI A un portrait affichable — Discord ne sait +// pas mettre une image par ligne, c'est le seul moyen de les montrer. +// +// Ceux qui n'en ont pas sont simplement absents d'ici : ils restent nommés dans +// le podium en texte juste au-dessus. Aucun encart vide, aucune image cassée. +// La majorité des joueurs a un portrait recadré, stocké en base64, que Discord +// ne peut pas aller chercher (cf. personadle_weekly_avatar_url). +$medals = ['🥇', '🥈', '🥉']; +foreach (array_slice($normal, 0, 3) as $i => $joueur) { + if (($joueur['avatar'] ?? null) === null) continue; + $embeds[] = [ + 'title' => $medals[$i] . ' ' . personadle_discord_escape($joueur['pseudo']), + 'color' => [0xD4AF37, 0xBDC3C7, 0xCD7F32][$i], + 'thumbnail' => ['url' => $joueur['avatar']], + 'description' => sprintf( + '%d victoire%s · %d partie%s', + $joueur['wins'], $joueur['wins'] > 1 ? 's' : '', + $joueur['games'], $joueur['games'] > 1 ? 's' : '' + ), + ]; +} + $payload = [ 'username' => 'Margaret', 'avatar_url' => $avatar, - 'embeds' => [[ - 'title' => '📖 Top 3 de la semaine — ' . $periode, - 'description' => $corps, - 'color' => 0xB03A2E, - 'thumbnail' => ['url' => $avatar], - 'fields' => $fields, - 'footer' => ['text' => 'PersonaDLE — semaine ' . $monday->format('W')], - ]], + 'embeds' => $embeds, 'allowed_mentions' => ['parse' => [], 'roles' => $mention !== '' ? [$mention] : []], ]; if ($mention !== '') { @@ -119,6 +154,8 @@ 'from' => $from, 'normal' => count($normal), 'expert' => count($expert), + 'bonds' => count($bonds), + 'embeds' => count($embeds), ]); jsonSuccess([ @@ -129,6 +166,7 @@ 'to' => $sunday->format('Y-m-d'), 'normal' => $normal, 'expert' => $expert, + 'bonds' => $bonds, 'mention' => $mention !== '' ? $mention : null, 'status' => $r['code'], 'elapsed_ms' => round((microtime(true) - $start) * 1000), diff --git a/api/lib/weekly_podium.php b/api/lib/weekly_podium.php index 919e101a..060f9a5b 100644 --- a/api/lib/weekly_podium.php +++ b/api/lib/weekly_podium.php @@ -16,20 +16,25 @@ * pseudo. Comptes supprimés exclus. Le littéral is_expert vient d'un bool, * jamais d'une entrée externe. * - * @return list + * @return list */ function personadle_weekly_podium(PDO $pdo, string $from, bool $expert): array { + // Le profil est en LEFT JOIN : un compte tout neuf n'en a pas encore, et + // doit rester classable — il apparaîtra simplement sans portrait. $stmt = $pdo->prepare( "SELECT u.pseudo, SUM(gs.result = 'win') AS wins, - COUNT(*) AS games + COUNT(*) AS games, + pr.avatar_src AS av_src, + pr.avatar_data AS av_data FROM game_sessions gs JOIN users u ON u.id = gs.user_id + LEFT JOIN profiles pr ON pr.user_id = u.id WHERE gs.played_date >= ? AND gs.is_expert = " . ($expert ? 1 : 0) . " AND u.is_deleted = 0 - GROUP BY u.id, u.pseudo + GROUP BY u.id, u.pseudo, pr.avatar_src, pr.avatar_data HAVING wins > 0 ORDER BY wins DESC, games ASC, u.pseudo ASC LIMIT 3" @@ -39,6 +44,7 @@ function personadle_weekly_podium(PDO $pdo, string $from, bool $expert): array 'pseudo' => (string) $r['pseudo'], 'wins' => (int) $r['wins'], 'games' => (int) $r['games'], + 'avatar' => personadle_weekly_avatar_url($r['av_src'] ?? null, $r['av_data'] ?? null), ], $stmt->fetchAll()); } @@ -62,3 +68,131 @@ function personadle_weekly_lines(array $podium): string } return implode("\n", $lines); } + +/** + * URL publique du portrait d'un joueur, ou null s'il n'y en a pas d'affichable. + * + * ── Pourquoi ce n'est pas toujours possible ───────────────────────────────── + * Un portrait recadré est stocké en **base64** dans `profiles.avatar_data` + * (`data:image/png;base64,…`). Discord, lui, va CHERCHER l'image à une adresse : + * il ne sait rien faire d'une data-URL. Seuls les portraits choisis dans la + * galerie sans recadrage laissent un chemin exploitable — dans `avatar_src` + * (colonne ajoutée par la 052) ou, pour les plus anciens, directement dans + * `avatar_data`. + * + * Mesuré en production le 2026-09-26 : **43 profils sur 328** ont une adresse + * utilisable. Le portrait est donc un bonus, jamais la structure du message — + * un podium doit rester lisible quand les trois joueurs n'en ont aucun. + * + * @param string|null $src profiles.avatar_src + * @param string|null $data profiles.avatar_data + */ +function personadle_weekly_avatar_url(?string $src, ?string $data): ?string +{ + foreach ([$src, $data] as $candidat) { + $c = trim((string) $candidat); + if ($c === '' || str_starts_with($c, 'data:')) continue; + + // Les chemins sont relatifs à `profile/` (« ../img/avatar/X.webp ») : + // on les ramène à la racine du site. + $c = preg_replace('#^\.\./#', '', $c); + $c = ltrim($c, '/'); + + // Liste blanche stricte : ces valeurs viennent de la base, donc d'une + // saisie utilisateur passée par l'API. Une chaîne fantaisiste ne doit + // pas devenir une URL qu'on demande à Discord d'aller chercher. + if (!preg_match('#^img/avatar/[A-Za-z0-9_\-.]+\.(?:gif|png|jpe?g|webp|avif)$#i', $c)) continue; + + return 'https://www.personadle.net/' . $c; + } + return null; +} + +/** + * Les trois amitiés les plus fortes du site, par XP. + * + * Même tri que le classement public (api/leaderboard/index.php, vue `bonds`) : l'XP + * et non le rang, qui plafonne à 10 et donnerait des dizaines d'ex æquo. Un + * compte supprimé fait disparaître le lien — il n'a plus deux côtés. + * + * @return list + */ +function personadle_weekly_bonds(PDO $pdo): array +{ + $stmt = $pdo->query( + "SELECT sl.xp, sl.`rank`, + ua.pseudo AS a_pseudo, ub.pseudo AS b_pseudo, + pa.avatar_src AS a_src, pa.avatar_data AS a_data, + pb.avatar_src AS b_src, pb.avatar_data AS b_data + FROM social_links sl + JOIN users ua ON ua.id = sl.user_a_id AND ua.is_deleted = 0 + JOIN users ub ON ub.id = sl.user_b_id AND ub.is_deleted = 0 + LEFT JOIN profiles pa ON pa.user_id = sl.user_a_id + LEFT JOIN profiles pb ON pb.user_id = sl.user_b_id + WHERE sl.xp > 0 + ORDER BY sl.xp DESC, sl.id ASC + LIMIT 3" + ); + return array_map(static fn(array $r): array => [ + 'a' => (string) $r['a_pseudo'], + 'b' => (string) $r['b_pseudo'], + 'xp' => (int) $r['xp'], + 'rank' => (int) $r['rank'], + 'a_avatar' => personadle_weekly_avatar_url($r['a_src'] ?? null, $r['a_data'] ?? null), + 'b_avatar' => personadle_weekly_avatar_url($r['b_src'] ?? null, $r['b_data'] ?? null), + ], $stmt->fetchAll()); +} + +/** + * Lignes 🥇🥈🥉 d'un podium d'amitiés. + * + * @param list $bonds + */ +function personadle_weekly_bond_lines(array $bonds): string +{ + $medals = ['🥇', '🥈', '🥉']; + $lines = []; + foreach (array_slice($bonds, 0, 3) as $i => $b) { + $lines[] = sprintf( + '%s **%s** 💞 **%s** — rang %d · %s XP', + $medals[$i], + personadle_discord_escape($b['a']), + personadle_discord_escape($b['b']), + $b['rank'], + number_format($b['xp'], 0, ',', ' ') + ); + } + return implode("\n", $lines); +} + +/** + * Le mot d'accueil de Margaret, qui change chaque semaine. + * + * ⚠️ Le nombre de phrases ne doit pas diviser 52 sans reste — sinon la même + * phrase retombe sur la même semaine chaque année. 7 convient : 52 = 7×7 + 3, + * donc la rotation dérive d'une année sur l'autre. (Le quotidien porte la même + * contrainte pour ses voix, pour la même raison.) + * + * @param int $semaine numéro de semaine ISO + * @return array{fr:string, en:string} + */ +function personadle_weekly_intro(int $semaine): array +{ + $phrases = [ + ['fr' => "Le registre de la semaine est clos. Voici ceux dont les noms y figurent en tête.", + 'en' => "The week's record is closed. These are the names written at the top."], + ['fr' => "Une semaine de plus consignée. Certains y reviennent souvent — je le remarque.", + 'en' => "Another week set down. Some names return often — I do notice."], + ['fr' => "J'ai relu les pages de cette semaine. Trois d'entre elles méritaient d'être lues à voix haute.", + 'en' => "I have reread this week's pages. Three of them deserved to be read aloud."], + ['fr' => "Les contrats se mesurent à ce qu'on y met. Cette semaine, voici ce qui a été mis.", + 'en' => "A contract is measured by what one puts into it. Here is what was put in this week."], + ['fr' => "Le temps passe, les registres restent. Voici ce que celui-ci retiendra de la semaine.", + 'en' => "Time passes; the records remain. Here is what this one will keep of the week."], + ['fr' => "Ma sœur tient les clés, mon frère tient la porte. Moi, je tiens les comptes.", + 'en' => "My sister holds the keys, my brother holds the door. I hold the accounts."], + ['fr' => "Rien ici n'est écrit à l'avance. Ces trois noms, vous les avez inscrits vous-mêmes.", + 'en' => "Nothing here is written in advance. You inscribed these three names yourselves."], + ]; + return $phrases[$semaine % count($phrases)]; +} diff --git a/tests/README.md b/tests/README.md index b4af0d89..dab89011 100644 --- a/tests/README.md +++ b/tests/README.md @@ -20,7 +20,7 @@ ╱ ╲ Playwright E2E — 283 tests (stack Docker réelle) ╱────╲ smoke (5) + API badges/streak (2) + Social Link ami→XP→rang (6) ╱ ╲ - ╱────────╲ PHPUnit — 379 méthodes (22 fichiers, dont intégration vraie MariaDB) + ╱────────╲ PHPUnit — 395 méthodes (23 fichiers, dont intégration vraie MariaDB) ╱ ╲ contraintes SQL, FK cascade, contrat de schéma, streak/social/auth ╱────────────╲ ╱ ╲ Vitest — 1548 tests unitaires (jsdom, 86 fichiers) @@ -105,7 +105,7 @@ Plus, en garde-fou statique : **PHPStan niveau 5** (analyse PHP) · **ESLint + P | `ClientIpTest.php` | logique | IP de rate limiting : X-Forwarded-For ignoré hors `TRUSTED_PROXIES`, CIDR v4/v6 | | `DatabaseIntegrationTest.php` | intégration | unicité, CHECK, FK cascade, **contrat de schéma** (35 tests)| -> **379 méthodes de test** au total sur ces 22 fichiers. Les tests d'intégration tournent dans +> **395 méthodes de test** au total sur ces 23 fichiers. Les tests d'intégration tournent dans > une transaction annulée (`rollBack`) → zéro pollution. Si la DB n'est pas joignable, ils sont > **skippés** (la suite reste verte). diff --git a/tests/php/WeeklyDigestTest.php b/tests/php/WeeklyDigestTest.php new file mode 100644 index 00000000..7bc64c48 --- /dev/null +++ b/tests/php/WeeklyDigestTest.php @@ -0,0 +1,164 @@ + 'Hamza', 'b' => 'Colonel-Maskou', 'xp' => 4200, 'rank' => 10], + ['a' => 'Sr.Toad', 'b' => 'Shadow', 'xp' => 1500, 'rank' => 7], + ]); + self::assertStringContainsString('🥇', $lignes); + self::assertStringContainsString('🥈', $lignes); + self::assertStringNotContainsString('🥉', $lignes, 'pas de 3e médaille pour 2 amitiés'); + self::assertStringContainsString('4 200 XP', $lignes, "l'XP est lisible, espace fin"); + } + + public function testPseudoDAmitieEstEchappe(): void + { + // Un pseudo en Markdown détournerait la mise en forme de l'embed. + $lignes = personadle_weekly_bond_lines([ + ['a' => '**gras**', 'b' => '_italique_', 'xp' => 10, 'rank' => 1], + ]); + self::assertStringNotContainsString('**gras**', $lignes); + } + + public function testJamaisPlusDeTroisAmities(): void + { + $quatre = array_fill(0, 4, ['a' => 'A', 'b' => 'B', 'xp' => 1, 'rank' => 1]); + self::assertCount(3, explode("\n", personadle_weekly_bond_lines($quatre))); + } + + public function testAucuneAmitieDonneUneChaineVide(): void + { + self::assertSame('', personadle_weekly_bond_lines([])); + } + + // ── Le mot d'accueil ──────────────────────────────────────────────────── + + public function testIntroTourneEtEstBilingue(): void + { + $i = personadle_weekly_intro(3); + self::assertArrayHasKey('fr', $i); + self::assertArrayHasKey('en', $i); + self::assertNotSame('', trim($i['fr'])); + self::assertNotSame('', trim($i['en'])); + } + + public function testLaRotationDeriveDUneAnneeSurLAutre(): void + { + // Si le nombre de phrases divisait 52 sans reste, la même phrase + // retomberait sur la même semaine chaque année — le rendez-vous + // hebdomadaire deviendrait un calendrier fixe. + $vues = []; + for ($w = 0; $w < 60; $w++) $vues[] = personadle_weekly_intro($w)['fr']; + $nb = count(array_unique($vues)); + self::assertGreaterThan(1, $nb); + self::assertNotSame(0, 52 % $nb, "$nb phrases divisent 52 : la rotation se figerait"); + } + + public function testLIntroNeSortJamaisDuTableau(): void + { + foreach ([0, 1, 52, 53, 999] as $w) { + self::assertIsArray(personadle_weekly_intro($w), "semaine $w"); + } + } +} From 8058152acc4b7929a9890107f399df20622d218f Mon Sep 17 00:00:00 2001 From: Hamza Date: Sat, 26 Sep 2026 10:22:55 +0200 Subject: [PATCH 5/9] =?UTF-8?q?fix(classique):=20Indice=20et=20Abandon=20c?= =?UTF-8?q?=C3=B4te=20=C3=A0=20c=C3=B4te=20sur=20mobile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retour de Hamza : le correctif de mise en page de la 2.3.1 « ne s'affiche pas pour les autres ». Ce n'est pas un problème de cache — la prod sert bien le bon CSS, sw.js est en v104 et le service worker est en network-first sur le CSS avec cache: reload. Le correctif était actif pour tout le monde ; il ne valait simplement que 46 px. Les 154 px restants venaient de .hint-giveup-row. C'est une rangée flex en flex-wrap: wrap, et Classique est le seul mode à y placer deux blocs — les cinq autres n'ont que le bloc Abandon, d'où l'absence du symptôme ailleurs. À 390 px chaque bloc fait 229 px, soit 478 px avec le gap pour 380 px disponibles : la rangée passait à la ligne et occupait 292 px au lieu de 136. Ce ne sont pas les images qui débordent (173 px) mais le padding 12px 20px de .link-wrapper, qui ajoute 40 px à chaque bloc. On le rogne et on descend l'image à 60 px : bloc à 176 px, deux blocs plus le gap tiennent jusqu'à 375 px de large. Gain mesuré : 170 px. Le champ de réponse passe de 894 à 724 px, ce qui place Classique devant Émoji. La cible tactile reste à 160×80 px. Il reste 129 px de scroll à 390×844, dus au gabarit commun aux six modes — aucun des six ne tient sur un écran de téléphone, et les toucher serait une refonte. --- .../PersonaDLE 2.3/DEV_CHANGELOG.md | 69 ++++++++++++ README.md | 2 +- ROADMAP.md | 4 +- TODO.md | 2 +- classiqueMode/classique.css | 51 +++++++++ tests-e2e/README.md | 2 +- tests-e2e/classic_mobile_layout.spec.js | 100 ++++++++++++++++++ tests/README.md | 2 +- 8 files changed, 226 insertions(+), 6 deletions(-) diff --git a/PersonaDLE_Update_Documentation/PersonaDLE 2.3/DEV_CHANGELOG.md b/PersonaDLE_Update_Documentation/PersonaDLE 2.3/DEV_CHANGELOG.md index 500d463d..d3ceefc3 100644 --- a/PersonaDLE_Update_Documentation/PersonaDLE 2.3/DEV_CHANGELOG.md +++ b/PersonaDLE_Update_Documentation/PersonaDLE 2.3/DEV_CHANGELOG.md @@ -45,6 +45,75 @@ Découpage en lots — une branche, une PR vers `develop` par ligne : --- +## 2026-09-26 — Classique : Indice et Abandon côte à côte sur mobile + +Retour de Hamza : « la correction visuelle (pas besoin de scroller pour la réponse en +Classique) ne s'affiche pas pour les autres, mais moi ça marche ». + +### Ce n'était pas un problème de cache + +Première hypothèse, écartée par la mesure : la prod sert bien le bon CSS +(`padding-top: 60px`, `Cache-Control: no-cache`), `sw.js` est en v104, et le service +worker est en *network-first* sur le CSS avec `cache: "reload"`, `skipWaiting()` et +`clients.claim()`. Le correctif de la 2.3.1 est donc **déjà actif pour tout le monde**. + +⚠️ Au passage, un relevé initial donnait `padding-top: 70px` et un champ de réponse +introuvable : l'URL interrogée était `classique.html`, qui n'existe pas — le fichier est +`classiqueMode.html`. C'était la page 404 qui était mesurée. Toute mesure de mise en page +doit vérifier qu'elle tape la bonne URL avant de conclure. + +### Le vrai coupable : 154 px, pas 46 + +Le correctif de la 2.3.1 ne valait que **46 px** (`padding-top` 96 → 60). L'essentiel de +l'écart était ailleurs. + +`.hint-giveup-row` (global.css §8) est une rangée `flex` en `flex-wrap: wrap`, et +**Classique est le seul mode à y mettre deux blocs** — les cinq autres n'ont que le bloc +Abandon, d'où l'absence du symptôme ailleurs. + +Mesuré en production à 390 px : chaque bloc fait **229 px** de large, soit 478 px avec le +gap pour 380 px disponibles → la rangée passe à la ligne et occupe **292 px** de haut au +lieu de 136. Le champ de réponse démarrait à 894 px, contre 748 px en Émoji : Classique +était le mode où il fallait scroller le plus loin pour répondre. + +Et ce ne sont pas les images qui débordent (173 px) mais le `padding: 12px 20px` de +`.link-wrapper` (global.css:165), qui ajoute 40 px de large à chaque bloc. + +### Le correctif + +Rogner ce padding et descendre l'image de 70 à 60 px : bloc à 176 px, deux blocs + gap = +364 px, ça tient jusqu'à 375 px de large. Le sélecteur `.hint-giveup-row` n'existe que dans +`classiqueMode.html`, donc la règle ne peut rien casser ailleurs. + +**Gain mesuré : 170 px.** Le champ de réponse passe de 894 à 724 px — Classique devient le +mode où il est le plus haut, **devant** Émoji (748 px). La cible tactile reste à 160×80 px, +loin au-dessus des 48 px de CLAUDE.md §7, et aucun débordement horizontal n'apparaît. + +### Ce qui n'est pas réglé, et pourquoi + +Il reste **129 px de scroll à 390×844**. Ils viennent de `.personadle-box` (313 px) et du +logo, partagés par les six modes. Aucun des six ne tient sur un écran de téléphone +aujourd'hui — Émoji, le meilleur, demandait déjà 152 px de scroll avant ce lot. Les toucher +serait une refonte du gabarit commun, pas un correctif, et changerait les six modes d'un +coup. + +### Vérifications + +Docker Desktop étant à l'arrêt, la suite E2E n'a pas pu tourner localement. La preuve a été +faite autrement, et plus directement : **la prod EST l'état non corrigé**. Les invariants du +test, exécutés contre elle puis contre elle avec le correctif injecté : + +| | 360 px | 390 px | 412 px | 480 px | écart vs Émoji | +|---|---|---|---|---|---| +| prod telle quelle | échec | échec | échec | échec | 146 px (seuil ≤24) | +| prod + correctif | OK | OK | OK | OK | −24 px | + +- `tests-e2e/classic_mobile_layout.spec.js` — second `describe` ajouté : côte à côte, ordre + gauche→droite, pas de débordement horizontal, cible tactile ≥48 px, et le champ de réponse + jamais plus bas que dans un mode de référence. Le seuil est relatif à Émoji et non figé à + « 724 px », qui casserait au premier changement de contenu. + +--- ## 2026-09-26 — Discord : 39 voix au quotidien, portraits et amitiés à l'hebdo Deux demandes de Hamza sur le bot. diff --git a/README.md b/README.md index 279a2b30..4f090009 100644 --- a/README.md +++ b/README.md @@ -167,7 +167,7 @@ Every pull request runs the whole thing; `main` only receives releases from `dev ``` Vitest + jsdom → 1548 tests unitaires (logique de jeu, backend, streak, i18n, validation données) PHPUnit → tests de logique pure + intégration DB (contrat de schéma) -Playwright → 283 E2E sur la stack Docker complète (smoke, API, Social Link) +Playwright → 288 E2E sur la stack Docker complète (smoke, API, Social Link) PHPStan → analyse statique PHP niveau 5 · Psalm → taint analysis ESLint+Prettier→ lint + format · i18n / data / daily-pool consistency checks ``` diff --git a/ROADMAP.md b/ROADMAP.md index ef395fab..224980ae 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -499,7 +499,7 @@ Nouveau jeu — cas A (roster inédit) > Synthèse : backend PHP/MariaDB complet (auth, sessions, social, leaderboard, admin, RGPD), > profil personnalisable (avatars groupés, musique, couleurs, badges, titres, wallpapers), > Social Link rangs 1-10, défis, streak globale + Jack Frost, FAQ, i18n 6 langues, -> **1548 tests JS · 395 PHPUnit · 283 E2E · PHPStan niveau 5 · CI/CD GitHub Actions**. +> **1548 tests JS · 395 PHPUnit · 288 E2E · PHPStan niveau 5 · CI/CD GitHub Actions**. ### Backend & Infrastructure @@ -555,7 +555,7 @@ Nouveau jeu — cas A (roster inédit) | # | Élément | Notes | | --- | --------------------------------------------- | ------------------------------------------------- | -| Q1 | Tests : 1548 Vitest · 395 PHPUnit · 283 E2E | `npm test` · `make test-php` · `npm run test:e2e` | +| Q1 | Tests : 1548 Vitest · 395 PHPUnit · 288 E2E | `npm test` · `make test-php` · `npm run test:e2e` | | Q2 | i18n EN/FR/ES/DE/IT/PT (1314 clés) | `npm run i18n:check` | | Q3 | PHPStan niveau 5 + ESLint + Prettier | Dans la CI | | Q4 | Seuils de couverture en CI | `npm run test:coverage` (~77 %) | diff --git a/TODO.md b/TODO.md index eff309f0..f637f100 100644 --- a/TODO.md +++ b/TODO.md @@ -8,7 +8,7 @@ > > Chaque section numérotée est dimensionnée pour tenir dans **une seule branche**. > -> Vérifié le 2026-08-26 : 1548 tests Vitest (86 suites), 395 méthodes PHPUnit, 283 tests E2E, +> Vérifié le 2026-08-26 : 1548 tests Vitest (86 suites), 395 méthodes PHPUnit, 288 tests E2E, > lint et data/i18n/pools propres. --- diff --git a/classiqueMode/classique.css b/classiqueMode/classique.css index d2da94b6..967f8e88 100644 --- a/classiqueMode/classique.css +++ b/classiqueMode/classique.css @@ -689,3 +689,54 @@ body.expert-mode #quoteHint { font-size: 0.95rem; } } + +/* ===================================================================== + INDICE + ABANDON CÔTE À CÔTE SUR MOBILE + ===================================================================== + `.hint-giveup-row` (global.css §8) est une rangée `flex` avec + `flex-wrap: wrap`. Classique est le SEUL mode à y mettre deux blocs — + les cinq autres n'ont que le bloc Abandon, d'où l'absence du problème + ailleurs. Le sélecteur n'existe que dans `classiqueMode.html`, donc ce + qui suit ne peut rien casser ailleurs. + + Mesuré en production le 2026-09-26 à 390px : chaque bloc fait 229px de + large, soit 478px avec le gap pour 380px disponibles → la rangée passe à + la ligne et occupe 292px de haut au lieu de 136. Le champ de réponse + démarrait à 894px, contre 748px en Émoji : Classique était le mode où il + fallait scroller le plus loin pour répondre, alors que le correctif de la + 2.3.1 (padding-top 96→60) était censé l'aligner sur les autres. Ce + correctif-là n'avait réglé que 46px ; les 154px restants venaient d'ici. + + Ce n'est pas la taille des images qui déborde (173px) mais le + `padding: 12px 20px` de `.link-wrapper` (global.css:165), qui ajoute 40px + de large à chaque bloc. On le rogne et on descend l'image de 70 à 60px : + bloc à 176px, deux blocs + gap = 364px, ça tient jusqu'à 375px de large. + + ⚠️ `height` porte `!important` parce que global.css l'impose déjà à 70px + sous 768px — à specificité égale, c'est l'ordre de chargement qui tranche + et `classique.css` se charge après (cf. classiqueMode.html). + + La cible tactile reste largement au-dessus des 48px de CLAUDE.md §7 : + le bouton mesure 160×80px après correction (contre 213×94 avant). + + Gain mesuré : 170px. Le champ de réponse passe de 894 à 724px à 390px de + large — Classique devient le mode où il est le plus haut, devant Émoji. + Il reste 129px de scroll à 390×844 : ils viennent de `.personadle-box` + (313px) et du logo, partagés par les six modes — les toucher serait une + refonte, pas un correctif. + --------------------------------------------------------------------- */ +@media (max-width: 900px) { + .hint-giveup-row { + flex-wrap: nowrap; + gap: 12px; + } + + .hint-giveup-row .link-wrapper { + min-width: 0; + padding: 10px 6px; + } + + .hint-giveup-row .img-wrapper img { + height: 60px !important; + } +} diff --git a/tests-e2e/README.md b/tests-e2e/README.md index 5ad987ce..105d8cd5 100644 --- a/tests-e2e/README.md +++ b/tests-e2e/README.md @@ -5,7 +5,7 @@ Playwright Docker -> **283 tests (37 fichiers) sur un vrai navigateur, contre la stack Docker complète.** +> **288 tests (37 fichiers) sur un vrai navigateur, contre la stack Docker complète.** > Couvre les parcours qu'aucun test unitaire ne voit (login, leaderboard, profil public, Social Link, admin). diff --git a/tests-e2e/classic_mobile_layout.spec.js b/tests-e2e/classic_mobile_layout.spec.js index 534a8b24..e731b895 100644 --- a/tests-e2e/classic_mobile_layout.spec.js +++ b/tests-e2e/classic_mobile_layout.spec.js @@ -99,3 +99,103 @@ test.describe("Page Classique — haut de page sur mobile", () => { await ctx.close(); }); }); + +/** + * La rangée Indice + Abandon, sur mobile. + * + * Deuxième moitié du même symptôme (« obligé de scroller pour répondre »), et + * la plus coûteuse : `.hint-giveup-row` est un `flex` avec `flex-wrap: wrap`, et + * Classique est le seul mode à y placer DEUX blocs. À 390 px ils faisaient + * 229 px chacun pour 380 px disponibles → passage à la ligne, 292 px de haut au + * lieu de 136, et le champ de réponse repoussé à 894 px contre 748 en Émoji. + * + * Le correctif de la 2.3.1 n'avait traité que le `padding-top` (46 px) ; ces + * 154 px-là venaient du `padding: 12px 20px` de `.link-wrapper`. + * + * Le test vérifie l'invariant, pas les valeurs : les deux blocs sur une seule + * rangée, et le champ de réponse jamais plus bas que dans un mode de référence. + * Figer « 724 px » casserait au premier changement de contenu. + */ +test.describe("Page Classique — rangée Indice/Abandon sur mobile", () => { + async function rects(page) { + return page.evaluate(() => { + const r = (sel) => { + const el = document.querySelector(sel); + if (!el) return null; + const b = el.getBoundingClientRect(); + return { top: b.top, bottom: b.bottom, left: b.left, right: b.right, height: b.height }; + }; + return { + hint: r(".hint-block"), + giveup: r(".giveup-block"), + bouton: r("#hintButton"), + saisie: r(".input-wrapper"), + largeurDoc: document.documentElement.scrollWidth, + largeurVue: document.documentElement.clientWidth, + }; + }); + } + + for (const largeur of [360, 390, 412, 480]) { + test(`à ${largeur} px : Indice et Abandon restent côte à côte`, async ({ browser }) => { + const ctx = await browser.newContext({ viewport: { width: largeur, height: 844 } }); + const page = await ctx.newPage(); + await gotoSettled(page, "/classiqueMode/classiqueMode.html"); + await page.waitForTimeout(400); + + const g = await rects(page); + expect(g.hint, "bloc Indice présent").not.toBeNull(); + expect(g.giveup, "bloc Abandon présent").not.toBeNull(); + + // Côte à côte : leurs bandes verticales se croisent, et l'un est à gauche + // de l'autre. C'est la définition d'une rangée, indépendamment des tailles. + expect( + g.hint.top < g.giveup.bottom && g.hint.bottom > g.giveup.top, + "Indice et Abandon sont empilés au lieu d'être côte à côte" + ).toBe(true); + expect(g.hint.right).toBeLessThanOrEqual(g.giveup.left + 1); + + // Le gain ne doit pas se payer en débordement latéral. + expect( + g.largeurDoc, + "la rangée forcée sur une ligne fait déborder la page horizontalement" + ).toBeLessThanOrEqual(g.largeurVue + 2); + + // Cible tactile : CLAUDE.md §7 impose 48 px, le bouton doit rester confortable. + expect( + g.bouton.bottom - g.bouton.top, + "bouton Indice trop bas pour le doigt" + ).toBeGreaterThanOrEqual(48); + expect(g.bouton.right - g.bouton.left, "bouton Indice trop étroit").toBeGreaterThanOrEqual( + 48 + ); + + await ctx.close(); + }); + } + + test("le champ de réponse n'est pas plus bas que dans les autres modes", async ({ browser }) => { + const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const page = await ctx.newPage(); + + await gotoSettled(page, "/emojiMode/emojiMode.html"); + await page.waitForTimeout(300); + const reference = await rects(page); + + await gotoSettled(page, "/classiqueMode/classiqueMode.html"); + await page.waitForTimeout(300); + const classique = await rects(page); + + expect(reference.saisie, "champ de réponse trouvé en Émoji").not.toBeNull(); + expect(classique.saisie, "champ de réponse trouvé en Classique").not.toBeNull(); + + // Classique porte un bloc de plus (Indice) et 10 px de padding-top en plus : + // on tolère une marge, mais plus l'écart d'une rangée entière (146 px avant). + expect( + classique.saisie.top - reference.saisie.top, + "le champ de réponse de Classique est nettement plus bas que celui d'Émoji" + ).toBeLessThanOrEqual(24); + + await ctx.close(); + }); +}); diff --git a/tests/README.md b/tests/README.md index dab89011..5d84a86a 100644 --- a/tests/README.md +++ b/tests/README.md @@ -17,7 +17,7 @@ ``` ╱╲ - ╱ ╲ Playwright E2E — 283 tests (stack Docker réelle) + ╱ ╲ Playwright E2E — 288 tests (stack Docker réelle) ╱────╲ smoke (5) + API badges/streak (2) + Social Link ami→XP→rang (6) ╱ ╲ ╱────────╲ PHPUnit — 395 méthodes (23 fichiers, dont intégration vraie MariaDB) From 7226c571bff9e10fc4f1e5cb3ac33e3ee20bdde7 Mon Sep 17 00:00:00 2001 From: Hamza Date: Sat, 26 Sep 2026 10:29:23 +0200 Subject: [PATCH 6/9] feat(discord): le quotidien peut vivre dans un salon forum MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un fil par jour sous l'annonce : aujourd'hui un score posté par un joueur se perd entre deux messages du bot, dans un fil il répond à quelque chose. Le type du salon est une dépendance invisible du cron. Un webhook ne peut pas poster dans un forum sans thread_name, ni dans un salon texte avec : Discord renvoie 400 dans les deux cas. Le jour de la conversion, un cron qui l'ignore s'arrête net et le rendez-vous devient muet — déjà vécu dix jours en septembre pour une autre raison. D'où un repli automatique dans les deux sens plutôt qu'un paramètre : on essaie la forme attendue et, sur un 400, on réessaie l'autre une fois. Le salon peut être converti ou reconverti sans toucher au code, et la constante DISCORD_DAILY_FORUM n'économise plus qu'un aller-retour. Quand le repli sert, un warning le dit dans les logs, sinon la constante resterait fausse en silence. 401, 404, 429 et 500 ne sont pas réessayés : ce ne sont pas des problèmes de type de salon, et réessayer ne ferait que doubler l'échec. Le weekly passe par le même helper, et c'est lui qui aurait cassé : DISCORD_WEEKLY_WEBHOOK est absent de la prod, donc le récap du dimanche part dans le salon du quotidien. Il ne suit la constante que s'il n'a pas de webhook à lui. Le commentaire de rotation du quotidien parlait encore de 8 voix et d'un cycle de 48 jours, faux depuis le passage à 39 (117 jours). --- CONTRIBUTING.md | 2 +- .../PersonaDLE 2.3/DEV_CHANGELOG.md | 72 +++++++++ ROADMAP.md | 4 +- TODO.md | 2 +- api/cron/discord-daily.php | 54 ++++--- api/cron/discord_weekly.php | 17 +- api/lib/discord_webhook.php | 78 +++++++++ tests/README.md | 4 +- tests/php/DiscordForumThreadTest.php | 153 ++++++++++++++++++ 9 files changed, 355 insertions(+), 31 deletions(-) create mode 100644 tests/php/DiscordForumThreadTest.php diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 92946188..52fe01e4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -41,7 +41,7 @@ Le hook de pre-commit lance déjà i18n + tests. Détail : | Commande | Vérifie | |---|---| | `make test` / `npm test` | 1548 tests JS (Vitest) | -| `make test-php` | 395 méthodes de test PHPUnit dans 23 fichiers (logique + intégration BDD). Tourne **dans le conteneur Docker** (`make up` requis) — pas besoin de PHP installé sur ta machine | +| `make test-php` | 407 méthodes de test PHPUnit dans 24 fichiers (logique + intégration BDD). Tourne **dans le conteneur Docker** (`make up` requis) — pas besoin de PHP installé sur ta machine | | `npm run lint` | ESLint | | `npm run data:check` | schéma des données personnages | | `npm run i18n:check` | cohérence des clés de traduction | diff --git a/PersonaDLE_Update_Documentation/PersonaDLE 2.3/DEV_CHANGELOG.md b/PersonaDLE_Update_Documentation/PersonaDLE 2.3/DEV_CHANGELOG.md index 500d463d..0e660844 100644 --- a/PersonaDLE_Update_Documentation/PersonaDLE 2.3/DEV_CHANGELOG.md +++ b/PersonaDLE_Update_Documentation/PersonaDLE 2.3/DEV_CHANGELOG.md @@ -45,6 +45,78 @@ Découpage en lots — une branche, une PR vers `develop` par ligne : --- +## 2026-09-26 — Discord : le quotidien peut vivre dans un salon forum + +Première pièce du remaniement validée par Hamza. Un fil par jour sous l'annonce : +aujourd'hui un score posté par un joueur se perd entre deux messages du bot ; dans un fil, +il **répond** à quelque chose. C'est la seule idée de la liste qui ne demandait aucune +ligne de code — le webhook accepte déjà `thread_name`. + +### Le piège : le type du salon est une dépendance invisible + +Un webhook ne peut **pas** poster dans un salon forum sans `thread_name`, et ne peut pas +poster dans un salon texte **avec** : Discord renvoie 400 dans les deux cas. Le jour où +`🎲┃daily-personadle` est converti, un cron qui l'ignore s'arrête net — et le rendez-vous +quotidien devient muet sans que personne ne soit prévenu. Déjà vécu du 9 au 19 septembre +pour une autre raison (clé fausse) : dix jours de silence. + +Donc pas de simple paramètre, mais un **repli automatique dans les deux sens** : +`personadle_discord_post_thread()` essaie la forme attendue et, sur un 400, réessaie +l'autre **une fois**. Le salon peut être converti — ou reconverti — sans toucher au code. +`DISCORD_DAILY_FORUM` n'économise plus qu'un aller-retour dans le cas courant, et le cron +fonctionne même si la constante ment. + +Quand le repli sert, un `warning` le dit dans Admin → Logs : sinon la constante resterait +fausse indéfiniment et chaque envoi coûterait deux appels en silence. + +Ce qui n'est **pas** réessayé : 401, 404, 429, 500. Ceux-là ne sont pas un problème de +type de salon, et réessayer ne ferait que doubler l'échec en brouillant le log. + +### Le weekly aussi, et c'est ce qui aurait cassé + +`DISCORD_WEEKLY_WEBHOOK` est **absent du `config.php` de prod** (vérifié) : le récap du +dimanche part donc dans le salon du quotidien, par le repli prévu. Convertir ce salon en +forum aurait tué le rendez-vous hebdomadaire — et une fois par semaine, c'est long à +remarquer. Le weekly passe par le même helper, avec un nom de fil par semaine ISO +(`🏆 Semaine 39/2026 — top 3`). + +Il ne suit la constante que s'il n'a pas de webhook à lui : le jour où Hamza lui donne son +propre salon, les deux réglages redeviennent indépendants. + +### Nom des fils + +La **date d'abord** (`🎲 26/09/2026 — Yukari Takeba`) : dans un forum on cherche le jour, +pas le personnage — le nom de la voix est déjà dans le message. Tronqué à 100 caractères, +la limite de Discord, sans quoi c'est la requête entière qui est refusée à 6 h du matin. + +### Corrigé au passage + +Le commentaire de rotation du quotidien parlait encore de « 8 jours » et d'un « cycle de +48 jours » — faux depuis le passage à 39 voix (117 jours). La contrainte « jamais un +multiple de 7 » y est maintenant écrite, puisque c'est elle qui a dicté le nombre. + +### Vérifications + +- `tests/php/DiscordForumThreadTest.php` — **nouveau**, 12 cas. Le poster est injecté, + donc les deux sens du repli sont réellement exercés : c'est de la logique qu'on ne + déclenche jamais à la main, il faudrait convertir un vrai salon Discord pour la voir + passer. Couvre aussi 204 (succès sans `?wait=true`), le double échec qui doit remonter, + et un `thread_name` déjà présent dans le payload qui ne doit pas gagner. +- **32 tests verts** (Discord + weekly) exécutés sur PHP 8.2.33. + +⚠️ Docker Desktop étant à l'arrêt, la suite PHP complète n'a pas pu tourner localement, et +elle n'a **pas** été lancée sur le serveur de production : `DatabaseIntegrationTest` s'y +connecterait à la base de prod. C'est la CI qui la couvre. + +### Ce que Hamza doit faire + +1. Convertir `🎲┃daily-personadle` en **salon forum** (Discord : Modifier le salon → Type). +2. Ajouter `define('DISCORD_DAILY_FORUM', true);` dans `api/config.php` — facultatif, ça + n'économise qu'un appel HTTP par jour. + +Dans cet ordre ou l'inverse : le repli couvre l'intervalle. + +--- ## 2026-09-26 — Discord : 39 voix au quotidien, portraits et amitiés à l'hebdo Deux demandes de Hamza sur le bot. diff --git a/ROADMAP.md b/ROADMAP.md index ef395fab..ba2b58c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -499,7 +499,7 @@ Nouveau jeu — cas A (roster inédit) > Synthèse : backend PHP/MariaDB complet (auth, sessions, social, leaderboard, admin, RGPD), > profil personnalisable (avatars groupés, musique, couleurs, badges, titres, wallpapers), > Social Link rangs 1-10, défis, streak globale + Jack Frost, FAQ, i18n 6 langues, -> **1548 tests JS · 395 PHPUnit · 283 E2E · PHPStan niveau 5 · CI/CD GitHub Actions**. +> **1548 tests JS · 407 PHPUnit · 283 E2E · PHPStan niveau 5 · CI/CD GitHub Actions**. ### Backend & Infrastructure @@ -555,7 +555,7 @@ Nouveau jeu — cas A (roster inédit) | # | Élément | Notes | | --- | --------------------------------------------- | ------------------------------------------------- | -| Q1 | Tests : 1548 Vitest · 395 PHPUnit · 283 E2E | `npm test` · `make test-php` · `npm run test:e2e` | +| Q1 | Tests : 1548 Vitest · 407 PHPUnit · 283 E2E | `npm test` · `make test-php` · `npm run test:e2e` | | Q2 | i18n EN/FR/ES/DE/IT/PT (1314 clés) | `npm run i18n:check` | | Q3 | PHPStan niveau 5 + ESLint + Prettier | Dans la CI | | Q4 | Seuils de couverture en CI | `npm run test:coverage` (~77 %) | diff --git a/TODO.md b/TODO.md index eff309f0..aced9ed2 100644 --- a/TODO.md +++ b/TODO.md @@ -8,7 +8,7 @@ > > Chaque section numérotée est dimensionnée pour tenir dans **une seule branche**. > -> Vérifié le 2026-08-26 : 1548 tests Vitest (86 suites), 395 méthodes PHPUnit, 283 tests E2E, +> Vérifié le 2026-08-26 : 1548 tests Vitest (86 suites), 407 méthodes PHPUnit, 283 tests E2E, > lint et data/i18n/pools propres. --- diff --git a/api/cron/discord-daily.php b/api/cron/discord-daily.php index 03a19ac7..aab13c5d 100644 --- a/api/cron/discord-daily.php +++ b/api/cron/discord-daily.php @@ -23,6 +23,7 @@ require_once __DIR__ . '/../bootstrap.php'; require_once __DIR__ . '/../lib/event_calendar.php'; +require_once __DIR__ . '/../lib/discord_webhook.php'; requireCronSecret(); @@ -921,8 +922,14 @@ ], ]; -// Voix par jour, phrase par tour de rotation : la voix revient tous les 8 jours -// et dit alors la phrase suivante. Cycle complet = 8 × 6 = 48 jours. +// Voix par jour, phrase par tour de rotation : la voix revient tous les 39 jours +// et dit alors la phrase suivante. Cycle complet = 39 × 3 = 117 jours, soit ~9 +// passages par personnage et par an. +// +// ⚠️ Le nombre de voix ne doit JAMAIS être un multiple de 7 : « jour de l'année +// % nombre de voix » figerait alors une voix par jour de la semaine, et le +// joueur qui ne vient que le lundi n'en entendrait qu'une, à vie. 39 laisse un +// reste de 4. $z = (int) $now->format('z'); $iVoix = $z % count($voix); $v = $voix[$iVoix]; @@ -978,29 +985,18 @@ $payload['content'] = '<@&' . $mention . '>'; } -$body = json_encode($payload, JSON_UNESCAPED_UNICODE); -if ($body === false) { - jsonError('Failed to encode Discord payload', 500); -} +/* Le salon peut être un forum (un fil par jour, pour que les scores des joueurs + répondent à quelque chose au lieu de se perdre entre deux annonces). Un webhook + ne peut pas poster dans un forum sans `thread_name`, ni dans un salon texte avec + — d'où le helper, qui se replie tout seul dans les deux sens. `DISCORD_DAILY_FORUM` + n'économise qu'un aller-retour : le cron marche même si la constante est fausse. */ +$forum = defined('DISCORD_DAILY_FORUM') && (bool) DISCORD_DAILY_FORUM; +$fil = personadle_discord_thread_name($now, $v['nom']); -// curl_init() sans argument : passer l'URL ici peut renvoyer false, et -// curl_setopt_array(false, …) est une TypeError fatale en PHP 8. -// « ?wait=true » fait répondre Discord avec le message créé (200) au lieu d'un -// 204 muet — sinon un webhook révoqué serait indiscernable d'un envoi réussi. -$ch = curl_init(); -curl_setopt_array($ch, [ - CURLOPT_URL => DISCORD_DAILY_WEBHOOK . '?wait=true', - CURLOPT_POST => true, - CURLOPT_POSTFIELDS => $body, - CURLOPT_HTTPHEADER => ['Content-Type: application/json'], - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CONNECTTIMEOUT => 5, - CURLOPT_TIMEOUT => 15, -]); -$reponse = curl_exec($ch); -$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); -$erreur = curl_error($ch); -curl_close($ch); +$envoi = personadle_discord_post_thread(DISCORD_DAILY_WEBHOOK, $payload, $fil, $forum); +$code = $envoi['code']; +$erreur = $envoi['error']; +$reponse = $envoi['body']; // curl_exec() renvoie false sur échec réseau ($code reste 0) ; Discord renvoie // 401/404 sur webhook révoqué et 429 sur rate limit. Les trois sont couverts. @@ -1014,6 +1010,16 @@ jsonError('Discord webhook call failed (HTTP ' . $code . ')', 502); } +// Le repli a servi : la constante DISCORD_DAILY_FORUM ne dit pas la vérité sur +// le type du salon. Le message est passé quand même, mais chaque envoi coûte +// désormais deux appels — et c'est le log qui le signale, pas un joueur. +if (isset($envoi['repli'])) { + personadle_log_error(pdo(), 'warning', 'Discord daily : ' . $envoi['repli'], [ + 'source' => 'cron-discord-daily', + 'forum_attendu' => $forum ? 'oui' : 'non', + ]); +} + // Trace de succès (Admin → Logs) : un cron hPanel qui ne tourne pas ne laisse // AUCUNE autre empreinte — vécu du 9 au 19 septembre 2026, clé fausse, silence. personadle_log_error(pdo(), 'info', 'Discord daily posted (' . $v['nom'] . ')', [ diff --git a/api/cron/discord_weekly.php b/api/cron/discord_weekly.php index 8c0a678a..cf8fa33d 100644 --- a/api/cron/discord_weekly.php +++ b/api/cron/discord_weekly.php @@ -136,7 +136,16 @@ $payload['content'] = '<@&' . $mention . '>'; } -$r = personadle_discord_post($webhook, $payload); +/* Le récap n'a pas de webhook à lui : faute de DISCORD_WEEKLY_WEBHOOK il part + dans le salon du quotidien (voir plus haut). Si ce salon devient un forum, un + POST sans `thread_name` est refusé en 400 — le rendez-vous du dimanche + mourrait en silence, et une fois par semaine c'est long à remarquer. Même + helper que le quotidien, même repli automatique dans les deux sens. */ +$forum = defined('DISCORD_DAILY_FORUM') && (bool) DISCORD_DAILY_FORUM + && !(defined('DISCORD_WEEKLY_WEBHOOK') && DISCORD_WEEKLY_WEBHOOK !== ''); +$fil = sprintf('🏆 Semaine %s — top 3', $monday->format('W/Y')); + +$r = personadle_discord_post_thread($webhook, $payload, $fil, $forum); // curl_exec() renvoie false sur échec réseau (code 0) ; Discord renvoie 401/404 // sur webhook révoqué et 429 sur rate limit. Le détail part en log, caviardé. @@ -149,6 +158,12 @@ jsonError('Discord webhook call failed (HTTP ' . $r['code'] . ')', 502); } +if (isset($r['repli'])) { + personadle_log_error($pdo, 'warning', 'Discord weekly : ' . $r['repli'], [ + 'source' => 'cron-discord-weekly', + ]); +} + personadle_log_error($pdo, 'info', 'Discord weekly top 3 posted', [ 'source' => 'cron-discord-weekly', 'from' => $from, diff --git a/api/lib/discord_webhook.php b/api/lib/discord_webhook.php index a646c9e0..0945199d 100644 --- a/api/lib/discord_webhook.php +++ b/api/lib/discord_webhook.php @@ -88,3 +88,81 @@ function personadle_discord_escape(string $s): string { return preg_replace('/([\\\\*_~`|>])/', '\\\\$1', $s) ?? $s; } + +/** + * Nom du fil d'un rendez-vous quotidien, pour un salon **forum**. + * + * Date d'abord : dans un forum, on cherche le jour, pas le personnage — le nom + * de la voix est déjà dans le message. Discord plafonne à 100 caractères et + * refuse la requête au-delà, d'où la troncature ici plutôt qu'un 400 à 6 h du + * matin. + */ +function personadle_discord_thread_name(DateTimeInterface $jour, string $voix): string +{ + $nom = '🎲 ' . $jour->format('d/m/Y'); + $voix = trim($voix); + if ($voix !== '') { + $nom .= ' — ' . $voix; + } + return mb_substr($nom, 0, 100); +} + +/** + * POST sur un webhook, que le salon soit un forum ou un salon texte. + * + * ── Pourquoi ce n'est pas un simple paramètre ─────────────────────────────── + * Un webhook ne peut PAS poster dans un salon forum **sans** `thread_name`, et + * ne peut pas poster dans un salon texte **avec** : Discord renvoie 400 dans les + * deux cas. Le type du salon est donc une dépendance invisible du cron — le jour + * où `🎲┃daily-personadle` est converti en forum, un cron qui ne le sait pas + * s'arrête net, et seul le log le dit. + * + * D'où le repli automatique, dans les deux sens : on essaie la forme attendue, + * et si Discord répond 400 on réessaie l'autre **une fois**. Le salon peut être + * converti (ou reconverti) sans toucher au code, et `$forumAttendu` ne sert plus + * qu'à économiser un aller-retour dans le cas courant. + * + * Un 400 pour une autre raison (embed malformé) sera lui aussi réessayé une + * fois, échouera de nouveau, et remontera — c'est le comportement voulu : deux + * appels ratés valent mieux qu'un rendez-vous muet. + * + * @param callable|null $poster Point d'injection pour les tests ; par défaut + * `personadle_discord_post`. + * @return array{code:int, error:string, body:string, repli?:string} + */ +function personadle_discord_post_thread( + string $webhook, + array $payload, + string $threadName, + bool $forumAttendu = false, + ?callable $poster = null +): array { + $envoyer = $poster ?? 'personadle_discord_post'; + + $avecFil = $payload; + if ($threadName !== '') { + $avecFil['thread_name'] = mb_substr($threadName, 0, 100); + } + $sansFil = $payload; + unset($sansFil['thread_name']); + + // Sans nom de fil utilisable, il n'y a pas de choix à faire. + if ($threadName === '') { + return $envoyer($webhook, $sansFil); + } + + [$premier, $second, $note] = $forumAttendu + ? [$avecFil, $sansFil, "le salon n'est pas un forum : posté sans fil, retirer DISCORD_DAILY_FORUM"] + : [$sansFil, $avecFil, 'le salon est un forum : posté dans un fil, activer DISCORD_DAILY_FORUM']; + + $r = $envoyer($webhook, $premier); + if ((int) ($r['code'] ?? 0) !== 400) { + return $r; + } + + $r2 = $envoyer($webhook, $second); + if ((int) ($r2['code'] ?? 0) >= 200 && (int) $r2['code'] < 300) { + $r2['repli'] = $note; + } + return $r2; +} diff --git a/tests/README.md b/tests/README.md index dab89011..aa640c01 100644 --- a/tests/README.md +++ b/tests/README.md @@ -20,7 +20,7 @@ ╱ ╲ Playwright E2E — 283 tests (stack Docker réelle) ╱────╲ smoke (5) + API badges/streak (2) + Social Link ami→XP→rang (6) ╱ ╲ - ╱────────╲ PHPUnit — 395 méthodes (23 fichiers, dont intégration vraie MariaDB) + ╱────────╲ PHPUnit — 407 méthodes (24 fichiers, dont intégration vraie MariaDB) ╱ ╲ contraintes SQL, FK cascade, contrat de schéma, streak/social/auth ╱────────────╲ ╱ ╲ Vitest — 1548 tests unitaires (jsdom, 86 fichiers) @@ -105,7 +105,7 @@ Plus, en garde-fou statique : **PHPStan niveau 5** (analyse PHP) · **ESLint + P | `ClientIpTest.php` | logique | IP de rate limiting : X-Forwarded-For ignoré hors `TRUSTED_PROXIES`, CIDR v4/v6 | | `DatabaseIntegrationTest.php` | intégration | unicité, CHECK, FK cascade, **contrat de schéma** (35 tests)| -> **395 méthodes de test** au total sur ces 23 fichiers. Les tests d'intégration tournent dans +> **407 méthodes de test** au total sur ces 24 fichiers. Les tests d'intégration tournent dans > une transaction annulée (`rollBack`) → zéro pollution. Si la DB n'est pas joignable, ils sont > **skippés** (la suite reste verte). diff --git a/tests/php/DiscordForumThreadTest.php b/tests/php/DiscordForumThreadTest.php new file mode 100644 index 00000000..93461d03 --- /dev/null +++ b/tests/php/DiscordForumThreadTest.php @@ -0,0 +1,153 @@ + $code, 'error' => '', 'body' => '']; + }; + } + + // ── Le nom du fil ─────────────────────────────────────────────────────── + + public function testLeNomDuFilCommenceParLaDate(): void + { + // La date d'abord : dans un forum on cherche le jour, pas le personnage. + $n = personadle_discord_thread_name(new DateTimeImmutable('2026-09-26'), 'Yukari Takeba'); + self::assertSame('🎲 26/09/2026 — Yukari Takeba', $n); + } + + public function testLeNomDuFilTientDansLaLimiteDeDiscord(): void + { + // 100 caractères : au-delà Discord refuse la requête entière. + $n = personadle_discord_thread_name(new DateTimeImmutable('2026-09-26'), str_repeat('Makoto ', 40)); + self::assertLessThanOrEqual(100, mb_strlen($n)); + } + + public function testUneVoixVideNeLaissePasDeTiretOrphelin(): void + { + self::assertSame('🎲 26/09/2026', personadle_discord_thread_name(new DateTimeImmutable('2026-09-26'), ' ')); + } + + // ── Salon texte (l'état actuel) ───────────────────────────────────────── + + public function testSalonTexteUnSeulAppelSansFil(): void + { + $vus = []; + $r = personadle_discord_post_thread('https://x', ['content' => 'a'], '🎲 fil', false, $this->poster([200], $vus)); + self::assertSame(200, $r['code']); + self::assertCount(1, $vus, 'le cas courant ne doit coûter qu\'un appel'); + self::assertArrayNotHasKey('thread_name', $vus[0]); + self::assertArrayNotHasKey('repli', $r); + } + + // ── Salon forum ───────────────────────────────────────────────────────── + + public function testForumAnnonceUnSeulAppelAvecFil(): void + { + $vus = []; + $r = personadle_discord_post_thread('https://x', ['content' => 'a'], '🎲 fil', true, $this->poster([200], $vus)); + self::assertSame(200, $r['code']); + self::assertCount(1, $vus); + self::assertSame('🎲 fil', $vus[0]['thread_name']); + } + + public function testSalonConvertiEnForumSansPrevenirLeCron(): void + { + // Le scénario qui casserait tout : la constante dit « salon texte », le + // salon est devenu un forum. Premier appel 400, second avec le fil → passe. + $vus = []; + $r = personadle_discord_post_thread('https://x', ['content' => 'a'], '🎲 fil', false, $this->poster([400, 200], $vus)); + self::assertSame(200, $r['code']); + self::assertCount(2, $vus); + self::assertArrayNotHasKey('thread_name', $vus[0]); + self::assertSame('🎲 fil', $vus[1]['thread_name']); + self::assertArrayHasKey('repli', $r, 'le repli doit être signalé pour que la constante soit corrigée'); + self::assertStringContainsString('DISCORD_DAILY_FORUM', $r['repli']); + } + + public function testForumReconvertiEnSalonTexte(): void + { + // Le sens inverse compte autant : on peut défaire une conversion. + $vus = []; + $r = personadle_discord_post_thread('https://x', ['content' => 'a'], '🎲 fil', true, $this->poster([400, 200], $vus)); + self::assertSame(200, $r['code']); + self::assertCount(2, $vus); + self::assertSame('🎲 fil', $vus[0]['thread_name']); + self::assertArrayNotHasKey('thread_name', $vus[1]); + self::assertArrayHasKey('repli', $r); + } + + // ── Ce qui ne doit PAS être avalé ─────────────────────────────────────── + + public function testUnWebhookRevoqueNEstPasReessaye(): void + { + // 401/404 ne sont pas un problème de type de salon : réessayer ne sert + // qu'à doubler l'échec et à brouiller le log. + foreach ([401, 404, 429, 500] as $code) { + $vus = []; + $r = personadle_discord_post_thread('https://x', ['content' => 'a'], '🎲 fil', false, $this->poster([$code], $vus)); + self::assertSame($code, $r['code']); + self::assertCount(1, $vus, "HTTP $code ne doit pas déclencher de second appel"); + } + } + + public function testDeuxCentQuatreEstUnSucces(): void + { + // Sans ?wait=true Discord répond 204. Le helper ne doit pas y voir un échec. + $vus = []; + $r = personadle_discord_post_thread('https://x', ['content' => 'a'], '🎲 fil', false, $this->poster([204], $vus)); + self::assertSame(204, $r['code']); + self::assertCount(1, $vus); + } + + public function testDoubleEchecRemonteLeSecondCode(): void + { + // Un 400 pour une autre raison (embed malformé) : réessayé une fois, + // échoue encore, et c'est bien un échec qui remonte — pas un faux succès. + $vus = []; + $r = personadle_discord_post_thread('https://x', ['embeds' => []], '🎲 fil', false, $this->poster([400, 400], $vus)); + self::assertSame(400, $r['code']); + self::assertCount(2, $vus); + self::assertArrayNotHasKey('repli', $r, 'pas de repli à signaler quand rien n\'est passé'); + } + + public function testSansNomDeFilAucunChoixNEstFait(): void + { + $vus = []; + $r = personadle_discord_post_thread('https://x', ['content' => 'a'], '', true, $this->poster([200], $vus)); + self::assertSame(200, $r['code']); + self::assertCount(1, $vus); + self::assertArrayNotHasKey('thread_name', $vus[0]); + } + + public function testUnThreadNameDejaPresentEstEcrase(): void + { + // Le payload du cron ne doit pas pouvoir contredire le nom calculé. + $vus = []; + personadle_discord_post_thread('https://x', ['thread_name' => 'vieux'], '🎲 neuf', true, $this->poster([200], $vus)); + self::assertSame('🎲 neuf', $vus[0]['thread_name']); + } +} From 01cec0a59c336536dba55cd03f36cd03d22e0e37 Mon Sep 17 00:00:00 2001 From: Hamza Date: Sat, 26 Sep 2026 14:05:24 +0200 Subject: [PATCH 7/9] docs: la conversion en salon forum est impossible, et un index des commandes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rectification du changelog du jour. Il disait « convertir le salon en forum (Modifier le salon → Type) » : ça n'existe pas. PATCH /channels/:id {"type":15} répond 400 / 50035, « Value must be one of (0, 5) » — un salon texte ne devient que texte ou annonces. Un forum se crée, donc nouveau salon, nouveau webhook, nouvelle valeur en production, et perte de l'historique. Décision de Hamza : le salon du quotidien reste, en lecture seule. Le code du lot précédent n'est pas à défaire, il se replie tout seul sur un salon texte. Vérifié en production qu'un webhook n'est pas soumis aux surcharges de permission du salon, donc que le rendez-vous quotidien ne risque rien. Ajoute aussi, §10, le renvoi vers l'index des commandes d'exploitation et deux pièges qui ont déjà coûté du temps : crontab n'existe pas sur le mutualisé donc un crontab -l vide ne prouve rien, et display_errors est éteint en CLI donc un script qui plante n'affiche rien — une sortie vide n'est pas « aucun résultat ». --- CLAUDE.md | 20 ++++++++++- .../PersonaDLE 2.3/DEV_CHANGELOG.md | 35 ++++++++++++++++--- 2 files changed, 49 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 558a772a..23924ed1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -219,6 +219,8 @@ Utiliser `min()`, `clamp()`, `vw`/`vh`. Éviter les largeurs fixes en `px` sur l | Chaîne comparée à une fonction SQL | `DATE_FORMAT(col, …) = ?` avec les préparées natives (`EMULATE_PREPARES=false`) : sur la MariaDB d'Hostinger (`skip-character-set-client-handshake`), le paramètre lié est en `utf8mb4_general_ci` et la chaîne produite en `utf8mb4_unicode_ci` → `Illegal mix of collations` → **500**. Vécu en 2.2.6 (`GET /api/titles` pour tous, 30 min). Comparer des **entiers** (`MONTH()`, `DAY()`, `MONTH()*100+DAY()`) ou des dates, jamais des chaînes formatées. Et toute boucle serveur sur des conditions (réconciliation) enveloppe chaque vérification : une condition qui plante est loguée et sautée, pas propagée | | La préprod « marche » mais pas la prod | Une préprod ne vaut que si elle reproduit **exactement** la prod : même MariaDB (11.8, `collation_server=utf8mb4_unicode_ci`, `skip-character-set-client-handshake`), même PHP (**8.2**, pas 8.3 — le client MySQL ne négocie pas la même collation), même dump. Les trois écarts ont masqué le bug ci-dessus. Config : `ARG PHP_VERSION=8.2` dans `docker/php/Dockerfile`, override MariaDB dans le dépôt privé `personadle-discord/ops/preprod/`. Avant de dire « ça passe en préprod », vérifier `php -v` et `SELECT @@collation_connection` dedans | | Bouton rond/carré rendu ovale | `css/global.css` §18 impose `min-height: 48px; padding: 12px 20px` à **tout** `