GameOn! API un back-end ASP.NET Core (.NET 10) qui sert de plateforme de statistiques gaming et de gestion de tournois, actuellement déployé en production.
Les 6 projets (Onion Architecture)
GameOn.Presentation ──► GameOn.Application ──► GameOn.Domain │ │ ▲ │ GameOn.Persistence ───────────┘ │ GameOn.External └──────────────► GameOn.Common Projet Rôle GameOn.Presentation Controllers HTTP, pipeline ASP.NET (Program.cs) GameOn.Application Toute la logique métier via CQRS (MediatR) GameOn.Domain Entités pures — aucune dépendance externe GameOn.Persistence EF Core + SQL Server (GameOnContext) GameOn.External Client Riot Games API + stockage S3 GameOn.Common DTOs, interfaces, exceptions partagés entre couches Domaine métier Deux jeux sont supportés :
FIFA / Soccer
FifaGamePlayed — matches enregistrés FifaTeam / FifaTeamPlayer — équipes et compositions Tournament / TournamentPlayer — tournois Season — saisons en cours League of Legends
LoLGame / LoLGameParticipant — historique de parties
LoLGameTimelineFrame — timeline détaillée d'une partie
LeagueOfLegendsRankHistory — suivi du rang
LoLQueue — types de queue Riot normalisés (Id = queueId Riot, clé naturelle), synchronisés quotidiennement depuis queues.json ; LoLGame.QueueId la référence en FK nullable. L'ancien LoLGame.QueueType (string, dictionnaire en dur) a été entièrement supprimé (colonne + propriété + migration)
LoLGameParticipantStat — stats de performance dérivées par participant (KDA, CS/min, gold/min, dégâts/min, kill participation %, wards posés/détruits), relation 1:1 à clé partagée avec LoLGameParticipant (LoLGameParticipant.Stats). Calculées via LoLGameParticipantStatCalculator (partagé entre l'import live et le backfill), à partir des données déjà en base (dernière frame de timeline + kills d'équipe + events de wards) — aucun appel Riot supplémentaire. Préfère désormais Kda/KillParticipation de Riot (voir LoLGameParticipantChallenge) quand disponibles plutôt que de les recalculer
LoLGameParticipantChallenge — miroir 1:1 (clé partagée) de l'objet challenges de Riot (~120 stats déjà calculées par Riot : KDA, kill participation, dégâts/min, solo kills...), stocké tel quel sans recalcul. ParticipantDto.RiotChallenges/TeamPosition/IndividualPosition/VisionScore sont désormais capturés depuis l'API Riot (auparavant silencieusement jetés par la désérialisation Newtonsoft, MissingMemberHandling.Ignore par défaut)
Commun
Player — joueurs (authentifiés via JWT) Platform — plateformes de jeu Highlight, Changelog — contenu éditorial Flux d'une requête (CQRS)
Controller → MediatR.Send(Query/Command) → Validator (FluentValidation) → Handler → Repository Interface └─ EF Core (Persistence) Exemple concret : FifaGameController envoie une commande via MediatR → le handler dans GameOn.Application/FIFA/FifaGamePlayed/ l'exécute → passe par une interface de repository → EF Core écrit en base.
Les erreurs ne sont jamais catchées dans les handlers — elles remontent via des exceptions custom vers un middleware global qui les transforme en réponses HTTP standardisées.
Intégrations externes Riot Games API — récupération des données LoL (matches, summoners, rangs) S3 — stockage des photos de profil et logos de tournois JWT — authentification des joueurs
Tu es un expert en Clean Architecture, CQRS et .NET 10. Tu dois suivre rigoureusement ces directives pour maintenir l'intégrité du projet et éviter le "vibecoding irrégulier".
Le projet est découpé en 6 projets distincts. Respecte strictement les frontières de dépendances :
- GameOn.Presentation :
- Point d'entrée (Controllers / Minimal APIs).
- Configuration du pipeline HTTP (
Program.cs).
- GameOn.Application :
- Logique métier via les Features (CQRS).
- Dépend du Domaine. Contient les
Interfaces,DTOset lesExceptionsapplicatives.
- GameOn.Domain :
- Cœur du système : Entités, Logique métier pure, Interfaces de Repositories.
- AUCUNE dépendance externe.
- GameOn.Persistence :
- Implémentation de la persistance (EF Core,
GameOnContext). - Implémentation des Repositories définis dans le Domaine.
- Implémentation de la persistance (EF Core,
- GameOn.External :
- Clients pour services tiers (API Riot Games, ...).
- GameOn.Common :
- Tout ce qui est commun à tout les projets (DTOs, ...).
- Toute action doit passer par une Catégorie dans
Application/Category/[NomDeLaCategory](exemple : Category LeagueOfLegends, FIFA). Tout ce qui est socle commun est dansCommon - Chaque feature doit contenir dans le même dossier :
CommandsouQueries(structuré avec les paramètres d'entrée).Handler(la logique d'exécution).Validator(FluentValidation).
- Ne jamais faire de try/catch dans les Handlers pour formater des erreurs API.
- Lever des Exceptions Custom (définies dans
Application/Exceptions). - Le middleware global se charge de catcher ces exceptions et de les transformer en réponses standardisées.
- Utiliser FluentValidation.
- Chaque
CommandouQuerydoit avoir un validateur associé injecté automatiquement dans le pipeline MediatR.
- Chaque couche possède un fichier
DependencyInjection.cs. - Toute nouvelle classe (Service, Repository, Handler) doit y être enregistrée.
Important
- Accès Data : Ne jamais injecter
GameOnContextdans la couche Presentation ou dans les Handlers. Toujours passer par les interfaces de Repositories. - Couplage : La couche
Domainne doit jamais référencerPersistenceouPresentation. - Style : Respecter strictement
stylecop.json. Ne pas supprimer les règles de style pour "gagner du temps". - Langue des commentaires : tout commentaire de code est en anglais, sans exception. Ça couvre les
//, les///de documentation XML, le libellé accolé à un#pragma warning, et les commentaires des scripts Python descripts/. Piège principal : le quick-fix « Supprimer l'avertissement » de Visual Studio en locale française recopie le message Roslyn traduit (// Déréférencement d'une éventuelle référence null.) — le remplacer par le libellé anglais officiel (// Dereference of a possibly null reference.). En revanche, les chaînes de caractères destinées aux joueurs restent en français (prompt de Raimmus, brief du coach, descriptions de tournoi), et cette documentation Markdown aussi.
✅ Rattrapage de masse des LoLGame.QueueId fait en prod par l'utilisateur (2026-07-16, via UPDATE manuel en DBeaver).
✅ LoLGame.QueueType supprimé (front à faire basculer sur QueueId/LoLQueue via les routes /lol/Queue et /lol/Queue/player/{playerId} si pas déjà fait).
✅ Filtre par plage de date ajouté sur l'historique de parties LoL (2026-07-23) : GetLastGamesPlayedQuery.StartDate/EndDate (bornes inclusives sur LoLGame.GameStart), exposés en query params startDate/endDate sur GET lol/Match/last et GET lol/Match/player/{playerId}.
✅ Entité LoLGameParticipantStat ajoutée (2026-07-23) : stats dérivées par participant (KDA, kill participation %, CS/gold/dégâts par minute, wards). Calculées automatiquement à chaque import/update de partie (UpdateLoLGameCommandHandler), et backfillées pour tout l'historique via POST Admin/lol/recompute-participant-stats (rôle gameon_admin) — migration générée et appliquée par l'utilisateur, backfill exécuté en prod. Exposées nested sur LoLGameParticipant.Stats dans GET lol/Match/{matchId} et GET lol/Match/player/{playerId}.
✅ Phase 1 « maximiser les données Riot » (2026-07-24) : ParticipantDto capture désormais teamPosition/individualPosition/visionScore/challenges (auparavant jetés silencieusement — HttpServiceBase.RunRequest désérialise via Newtonsoft sans JsonSerializerSettings, donc tout champ Riot sans propriété C# est ignoré par défaut, pas d'erreur). ChallengesDto (stub orphelin trouvé dans le repo, jamais branché) a été complété (~120 champs) et branché via ParticipantDto.RiotChallenges. Nouvelle entité LoLGameParticipantChallenge (1:1 clé partagée avec LoLGameParticipant, même pattern que Stats) miroir de ChallengesDto, peuplée dans UpdateLoLGameCommandHandler — quelques types de champs (int→float) corrigés par l'utilisateur après coup pour matcher les vraies valeurs Riot. LoLGameParticipantStatCalculator utilise Challenges.Kda/Challenges.KillParticipation de Riot en priorité (fallback sur le calcul manuel si absent). Migration générée et appliquée, exposé côté front. Hors scope de cette phase (à faire plus tard, Phase 2) : perks/runes (page de runes, table normalisée déjà actée avec l'utilisateur), sorts d'invocateur, multikills, objectifs d'équipe et bans (TeamDto.Objectives/Bans, déjà récupérés de Riot mais toujours jetés), InfoDto.GameMode/GameDuration/MapId.
✅ Dates 100 % UTC (2026-08-06) — l'ancien bug .ToLocalTime() est corrigé. GameOnContext.ConfigureUtcDates() applique un ValueConverter à toutes les propriétés DateTime/DateTime? du modèle : lecture → SpecifyKind(Utc), écriture → ToUniversalTime() si Local. Les colonnes datetime2 de SQL Server n'ayant pas d'offset, EF matérialisait tout en Unspecified et System.Text.Json sérialisait sans le Z final — l'API renvoie désormais du vrai ISO 8601 UTC. En complément : .ToLocalTime() retiré de UpdateLoLGameCommandHandler (DateTime.UnixEpoch.AddMilliseconds(...) direct), et tous les DateTime.Now du code applicatif passés en DateTime.UtcNow. Aucun backfill nécessaire : le conteneur de prod n'a pas de TZ configurée et tourne en UTC, donc les valeurs déjà en base étaient déjà de l'UTC — le converter ne fait que le déclarer. Aucune migration nécessaire non plus (le type de colonne ne change pas). Côté front, parseApiDate() reste en place comme filet de sécurité (il laisse passer les chaînes déjà suffixées Z).
✅ Audit complet des « stats marrantes » LoL (2026-08-06, GetLoLGlobalStatsQueryHandler) :
- Biggest Inter : le tri portait sur un score composite (
Deaths - Kills - Assists/2) alors que la valeur affichée étaitDeaths, d'où des records non monotones entre périodes (16 morts sur 7 j, 14 sur 1 mois). Le tri porte désormais surDeaths, le score composite ne sert plus qu'à départager. - Highest Bounty :
ParticipantDto.BountyLeveln'est plus renvoyé par match-v5 depuis 2025 (cf. RiotGames/developer-relations#1076) et vaut 0 partout. L'award est reconstruit depuisLoLGameTimelineEvent(MAX(ShutdownBounty)sur lesCHAMPION_KILLoù le joueur trackés est la victime). La colonneBountyLevelreste en base mais ne doit plus servir de critère de classement. - Ping Machine : ne sommait que 3 des 13 types de pings de Riot. Les 10 manquants ont été ajoutés à
ParticipantDto(donc en colonnes) + migrationAdded_Missing_Pings_In_LoLGames. Les games importées avant restent à 0 dessus, faute de ré-import. - Garde-fou « zéro donnée » sur les 4 awards issus des participations : sans lui,
.First()sur un dataset entièrement à 0 sacrait un joueur au hasard (c'est ce qui masquait le champ Riot mort). - Les awards ne supposent plus du 5v5 : le camp vient du vrai
LoLGameParticipant.TeamId(au lieu deParticipantId <= 5) et le nombre d'ennemis est compté sur le roster (au lieu d'un/5en dur). - Night Owl compte désormais les games de minuit à 6 h sur l'horloge des joueurs (
Europe/Paris) et non sur l'horloge UTC du serveur. - Les games dont la queue n'est pas résolvable (
LoLGame.Queue == null) sont exclues : sans ligneLoLQueue, elles échappaient au filtre par mots-clés bot/custom/tutorial. - Départages déterministes ajoutés partout (les ex æquo pouvaient changer de gagnant d'un refresh à l'autre, faute d'
ORDER BYstable en base).
✅ Bloc « Fait de la semaine » ajouté sur GET lol/Home (2026-08-12, GetLoLHomeStatsQueryHandler) : LoLHomeStatsDto.FactOfTheWeek (nullable) met en avant le joueur avec le meilleur gain net de LP de la semaine calendaire en cours, toutes queues classées confondues (même somme par joueur que WeeklyActivity.NetLpChangeThisWeek, mais par joueur au lieu du crew). Toujours le top gainer de la semaine, record historique ou pas (décision utilisateur : pas de comparaison à une date de référence type « depuis février », trop de cas ambigus). Contient aussi GamesThisWeek/WinsThisWeek/WinRateThisWeek et LongestWinStreakThisWeek (plus longue série de victoires consécutives de ce joueur sur la semaine, même algo que LongestLossStreak dans GetLoLGlobalStatsQueryHandler mais sur les victoires). Null si aucun joueur n'a de snapshot de rang comparable (semaine dernière + cette semaine) sur une queue classée. Aucune migration nécessaire (pas de nouvelle donnée persistée).
✅ Bloc « Records du crew » ajouté sur GET lol/Home (2026-08-12) : LoLHomeStatsDto.CrewRecords (type LoLGlobalStatsDto, jamais null) réutilise directement GetLoLGlobalStatsQueryHandler via mediator.Send(new GetLoLGlobalStatsQuery { Period = LoLStatsPeriod.Week }) plutôt que de dupliquer la logique des awards — GetLoLHomeStatsQueryHandler prend donc désormais ISender en plus de IApplicationDbContext. new GetLoLGlobalStatsQuery { RankedOnly = true, Period = LoLStatsPeriod.Month } (+ les deux flags smurf/crew, voir plus bas). Donc mois glissant, pas semaine, et classées uniquement, pas toutes queues — le commentaire sur place justifie le mois (« a single week rarely holds enough ranked games for the awards to be meaningful »). La fenêtre reste volontairement différente du calendrier lundi→dimanche utilisé par WeeklyActivity/FactOfTheWeek dans le même DTO. Chaque award individuel (BiggestInter, NightOwl, etc.) reste nullable côté LoLGlobalStatsDto si personne n'a de record sur la fenêtre.
✅ « Champions du crew » ajouté (2026-08-12, GetLoLGlobalStatsQueryHandler) : LoLGlobalStatsDto.TopChampions (liste, jamais null, vide si aucune game) — les 5 champions les plus joués sur la période/queue demandée (games groupées par ChampionName sur le dataset participants déjà filtré remakes/bot/custom/tutorial), triés par nombre de parties puis win rate puis nom pour un ordre stable. Calculé directement dans GetLoLGlobalStatsQueryHandler (pas de nouveau handler) donc disponible à la fois sur GET lol/Stats/global (toute période/queue) et automatiquement sur GET lol/Home via CrewRecords (qui appelle ce même handler en Period = Week) — la carte « Champions du crew » du front doit donc lire homeStats.crewRecords.topChampions, malgré le nom CrewRecords pensé à l'origine pour les awards. Aucune donnée d'icône renvoyée : le front reconstruit l'icône depuis ChampionName (Data Dragon), comme ailleurs dans le code.
🟡 Import des parties personnalisées (customs) depuis le client LoL (2026-09-17) — codé, pas encore exécuté en prod. Les customs sont introuvables via l'API Riot : absentes de matches/by-puuid/{puuid}/ids, et matches/{matchId} répond soit 404, soit un stub endOfGameResult = "Abort_Unexpected" avec queueId = 0, gameCreation = 0 et zéro participant (c'est l'origine des 10 lignes fantômes en base, GameStart = 1970-01-01, 0 participant — à supprimer). Seule source restante : l'historique du client League lui-même (LCU), dumpé par scripts/fetch_custom_games_lcu.py (lit le lockfile, filtre les CUSTOM_GAME, écrit <platformId>_<gameId>.json + .timeline.json, et pousse vers l'API avec --push / --from-dir). Côté API : POST lol/Match/custom/import (rôle gameon_admin) → ImportCustomLoLGameCommand → ImportCustomLoLGameCommandHandler, qui mappe le payload LCU (GameOn.Common/DTOs/LeagueOfLegends/LeagueClient/) vers les entités et renvoie un ImportCustomLoLGameResultDto. Points à connaître :
- Le payload LCU est l'ancien format match-v4 : pas de
challenges(doncLoLGameParticipant.Challengesreste null et le KDA / la kill participation repassent par les formules manuelles), pas dedamageStats/championStatssur les frames de timeline, et seulement 3 types d'events (CHAMPION_KILL,BUILDING_KILL,ELITE_MONSTER_KILL— donc aucun event de ward, d'item ou de level up). Les stats dérivées (LoLGameParticipantStat) sont calculées depuis les totaux de fin de partie du participant (que le client, lui, envoie) et non depuis la dernière frame, via le mêmeLoLGameParticipantStatCalculator. - La dernière frame de timeline est remplie avec ces totaux de fin de partie (dégâts infligés/subis + leurs splits physique/magique/brut,
TimeEnemySpentControlled), parce que c'est là que tout le reste du système va chercher le snapshot de fin de partie — le front litlatestStatsFor(timeline, puuid)pour le graphe « Dégâts aux champions » et l'award « Punching-Ball ». Les frames intermédiaires restent à 0 : le client ne connaît pas la courbe de dégâts.ChampExperience, absent des stats de fin de partie, est repris duxpde cette même dernière frame. En revanche les stats de champion (AD, AP, armure, vitesse de déplacement…) restent à 0 sur toutes les frames, y compris la dernière : elles n'ont aucun équivalent de fin de partie, le bloc « Stats du champion » du front est donc vide sur une custom. - Définitivement absents du payload client, aucun moyen de les reconstituer : les compteurs de pings,
consumablesPurchased, et les primes (ShutdownBountysur lesCHAMPION_KILL). Les awards du front qui reposent dessus (Ping Machine, Shopping Addict, Tête mise à prix) sacrent donc un joueur au hasard avec 0 — c'est exactement le bug « zéro donnée » corrigé côté serveur en 2026-08-06 surGetLoLGlobalStatsQueryHandler, mais côté front cette fois (LolGameHighlights.vuede JungleDiff, pas de garde survalue <= 0). - Le client anonymise les PUUID (UUID de 36 caractères, pas le vrai PUUID de 78). Les joueurs sont donc reliés sur leur Riot ID (
RiotGamesNickname+RiotGamesTagLine, insensible à la casse) et c'est le vrai PUUID de GameOn qui est stocké quand ça matche ; sinon l'UUID anonymisé sert de clé (l'alternate key(MatchId, Puuid)doit rester unique). Les Riot IDs non reconnus remontent dansUnlinkedRiotIds— sur le dump de septembre 2026,RememberV#8888(probablementRyusen#8888renommé, player 34) etZélouf#1360(probablementZéloufis#EUW, player 52) ne sont pas reliés. - Le champion n'est donné que par son ID : résolu via
ICommunityDragonChampionService(champion-summary.json, alias typeMonkeyKing= ce que renvoie match-v5), caché 24 h en mémoire. - Le rôle du client (
timeline.lane/role) est trop faux pour être recopié (deux junglers par équipe en custom) :TeamPosition/IndividualPositionsont déduits (Smite → JUNGLE, plus petit CS → UTILITY, le reste par lane). LoLQueueconnaît déjà les queues de customs (3100 aveugle, 3110 draft, 3140 outil d'entraînement), doncLoLGame.QueueIdest bien renseigné. Ces descriptions venant de Community Dragon sont en français, et échappaient donc au filtre anglais deGetLoLGlobalStatsQueryHandler:ExcludedQueueTypeKeywordss'est vu ajouter"personnalis"et"entraînement"pour que les customs restent hors des records du crew (vérifié : ça n'exclut que les 9 queues de customs SR/ARAM/TFT, aucune file normale ou classée). Les customs restent en revanche visibles dans l'historique (GET lol/Match/last,GET lol/Match/player/{playerId}).- Aucune migration nécessaire (aucun changement de schéma).
🟡 Coach IA sur les parties LoL (2026-09-17) — codé et compilé, migration générée, pas encore appliquée ni exécutée. Décision : pas d'auto-hébergement pour l'instant (le mini-PC Proxmox est un Ryzen 7 H 255 / Radeon 780M sans NPU, donc pas de VRAM dédiée : un 30B MoE y tournerait à ~15-22 tok/s, et surtout le prefill d'un contexte de 3 k tokens y coûte 30-60 s en CPU pur). On passe par la clé gratuite Google AI Studio (free tier permanent, ~1 500 req/jour sur Gemini 2.5 Flash, aucun compte de facturation rattaché au projet gen-lang-client-0926924054 — ne jamais cliquer « Configurer la facturation », la promotion en Tier 1 payant est immédiate et sans confirmation). L'abonnement Claude Pro et l'abonnement Google AI Pro n'ouvrent aucun accès API, c'est un produit séparé.
- Couche External :
GameOn.External/Llm/avecILlmService(seam volontairement agnostique du provider :ModelName,IsConfigured,GenerateJsonAsync(system, user, jsonSchema)) et trois implémentations, choisies au démarrage parLLM_PROVIDERdans leswitchdeGameOn.External/DependencyInjection.cs(défautgemini) :GeminiLlmService—GEMINI_API_KEY,GEMINI_MODEL(défautgemini-3.6-flash). Free tier inutilisable pour ce projet, voir plus bas.GroqLlmService(LLM_PROVIDER=groq) —GROQ_API_KEY,GROQ_MODEL(défautopenai/gpt-oss-120b). Dialecte OpenAI (POST /openai/v1/chat/completions), sortie contrainte parresponse_format: json_schemaen modestrict. C'est le provider retenu (2026-09-17).OllamaLlmService(LLM_PROVIDER=ollama) —OLLAMA_BASE_URL,OLLAMA_MODEL,LLM_NUM_CTX/LLM_NUM_PREDICT/LLM_NUM_THREADS. Repli auto-hébergé, sans quota et sans donnée qui sort de la machine.LLM_TIMEOUT_SECONDSvaut 180 par défaut, 900 en modeollama(un modèle sur CPU met des minutes, et son premier appel paie en plus le chargement des poids).
⚠️ Le schéma de réponse est du JSON Schema nu dansLoLCoachPrompt.ResponseSchema, et chaque provider l'adapte chez lui. Gemini et Ollama le prennent tel quel ; le mode strict de Groq exige en plusadditionalProperties: falsesur chaque objet, ajouté à la volée parGroqLlmService.Harden(). Ne jamais remonter un dialecte de provider dans le contrat partagé.⚠️ L'API Gemini a changé de surface, vérifié en direct le 2026-09-17 (les exemples qui traînent partout sont périmés) :gemini-2.5-flashrépond 404 « no longer available to new users » sur une clé créée aujourd'hui. Les modèles Gemini 3.x ne sont pas servis du tout parPOST /v1beta/models/{modèle}:generateContent: cet endpoint renvoie un 404 à corps vide pour eux, ce qui est très trompeur au diagnostic.- La bonne surface est la Interactions API :
POST https://generativelanguage.googleapis.com/v1beta/interactions, corps{ model, system_instruction, input, response_format: { type: "text", mime_type: "application/json", schema }, generation_config: { temperature } }. - Le schéma est du JSON Schema standard en minuscules (
"type": "object"), et non le sous-ensemble OpenAPI en majuscules ("OBJECT") qu'attendaitgenerateContent. Pas depropertyOrdering. - La réponse n'a plus de
candidates: c'eststeps[], dont il faut concaténer letextdes entréestype == "model_output"(les entréestype == "thought"portent le raisonnement, à ignorer). Plus demaxOutputTokensà régler, donc l'ancien piège « le raisonnement consomme tout le budget et renvoie un candidat vide » disparaît. - Modèle retenu :
gemini-3.6-flash(~15 s pour un rapport).gemini-3.8-flashexiste mais renvoyait des 503 « high demand » en rafale. Le raisonnement coûte cher en tokens (~1 900 tokens de pensée pour ~480 de sortie) — sans incidence sur le free tier, mais à surveiller si passage au payant. LlmTransientException(ex-LlmRateLimitedException) couvre 429 ET 5xx : sur ce tier, un modèle saturé est aussi banal qu'un quota épuisé, et ni l'un ni l'autre ne doit consommer une tentative.
- N'utilise volontairement pas
HttpServiceBase: à l'époque, sonRunRequestlevaitNotImplementedExceptionsur tout statut non-200/204, ce qui écrasait un 429 de quota et un 400 de requête invalide dans la même erreur. Or les distinguer est tout l'enjeu ici. (Depuis le 2026-09-17,RunRequestlève uneExternalApiExceptionqui porte le statut, la route et le corps — maisGeminiLlmServicegarde sa propre logique, qui distingue en plus le transitoire du définitif.) Idem pour leHttpClient:services.AddScoped<HttpClient>()porte le timeout par défaut de 100 s, trop court pour une génération — d'où un client nommé dédié (GeminiLlmService.HttpClientName). - Génération à la demande, jamais automatique (décision utilisateur du 2026-09-17, toujours valable) : aucun rapport n'est écrit sans que quelqu'un l'ait demandé. Pas de génération à la synchro d'une partie, pas de rattrapage du backlog, pas de scan de la base — un ticket existe parce qu'un joueur a cliqué.
- Mise en file au clic (2026-09-17, révision du flux synchrone) : le
POSTn'appelle plus le modèle, il enfile et renvoie 202 avec la place dans la file ; le front poll leGET. Ce qui a rouvert la décision : le synchrone reposait sur un « ~15 s » qui s'est révélé faux — une génération prend 49 s mesurées en prod, contre un free tier à 5 req/min. Sérialisées, ça plafonne à ~1,2 analyse/min : tenir la connexion ouverte ne marchait que pour un joueur à la fois, et un deuxième clic simultané ne pouvait que échouer (le proxy Nuxt coupe à 60 s).ILoLCoachQueue/LoLCoachQueue(singleton,LinkedList+ lock — pas unChannel, qui ne sait ni donner une position ni remettre un ticket en tête) ;ProcessLoLCoachQueueJobest le consommateur unique, donc c'est lui le vrai garde-fou de débit. Un refusLlmTransientExceptionremet le ticket en tête et attend 30 s, 5 tentatives maximum avant abandon (sans plafond, un ticket définitivement refusé affamerait toute la file). La file vit en mémoire : perdue au redéploiement, ce qui est le prix assumé pour que la table reste un cache pur — pas de colonne de statut, pas de migration. Valable tant qu'il n'y a qu'une seule instance en prod (confirmé par l'utilisateur le 2026-09-17) ; passer à plusieurs conteneurs casse le polling et imposerait l'état en base. - Entité
LoLGameCoachReport(tableLeagueOfLegendsGameCoachReport) : une ligne par (match, joueur), index unique(MatchId, Puuid). StockeSummary,Rating,ContentJson,ModelName,PromptVersion,GeneratedOn. Pas de statut : le flux étant synchrone, une ligne qui existe contient forcément une analyse terminée — la table est un cache, pas un journal, et c'est elle qui garantit qu'une même analyse n'est jamais payée deux fois.PromptVersionpermet de retrouver et régénérer les rapports écrits par un prompt périmé (LoLCoachPrompt.Version, à bumper).Ratingest éditorial et non reproductible : ne jamais l'agréger ni classer dessus, contrairement àLoLGameParticipantStat.Rating. - Le vrai point dur,
LoLCoachContextBuilder: une timeline match-v5 brute fait 500 Ko-2 Mo (150 k-600 k tokens), donc le builder sélectionne ~2-3 k tokens — stats du joueur en différentiel face à son opposant direct (identifié viaTeamPosition), courbe or/CS/XP à 10/15/20 min depuisLoLGameTimelineFrame, morts horodatées avec zone de la carte, objectifs d'équipe depuisLoLGameTeam, et une sélection dechallengesRiot. Règle centrale : une valeur à zéro n'est jamais envoyée au modèle (wards, challenges…), parce qu'une donnée non capturée présentée comme un fait est exactement ce qui fait inventer une faiblesse — c'est le bug « zéro donnée » de 2026-08-06, transposé au prompt. La zone de mort n'est calculée que siLoLQueue.Mapcontient « Summoner » (sinon on nommerait une lane sur une ARAM). ⚠️ Garde-fou de débit — leSemaphoreSlim(1)n'en était pas un (corrigé le 2026-09-17). Il limite la concurrence, pas le débit, et ne ressemble à une limite de débit que tant que chaque appel est lent. Or un refus revient en 285 ms là où une génération tient 49 s : dès que Gemini commence à dire non, le verrou tourne à ~210 fois/min au lieu de 4, un premier 429 devient une rafale, la rafale cloue le quota, et on n'en sort plus. Observé en prod : trois 429 terminés en moins d'une seconde d'écart. Le correctif estMinimumInterval— un plancher de 12 s entre deux départs d'appel (= 5 req/min en espacement), réglable parLLM_MIN_INTERVAL_SECONDS, appliqué quel que soit le résultat pour qu'un refus coûte un créneau entier comme une génération. Le sémaphore reste, mais depuis la mise en file il ne contend plus :ProcessLoLCoachQueueJobest le seul appelant. Le chemin « rapport déjà en cache » ne passe ni par la file ni par le sémaphore.⚠️ Le raisonnement est juste, la cible était fausse : ce plancher espace pour tenir 5 req/min alors que la contrainte réelle de Gemini était journalière (voir ci-dessous), donc il n'a jamais rien empêché.GroqLlmServicereprend le même mécanisme avec un défaut de 35 s, calibré cette fois sur ce qui borne vraiment là-bas — le TPM (6 000), pas le nombre de requêtes.OllamaLlmServicen'en a aucun : pas de quota à respecter.- ✅ Le 429 mystérieux est résolu (2026-09-17) : c'est le RPD. Le tableau de bord AI Studio affiche, pour
gemini-3.6-flashen free tier,RPM 3/5,TPM 3.43K/250KetRPD 10/20. Le quota qui saute est journalier, à 20 requêtes pour tout le crew — d'où un 429 après seulement 2 requêtes en 60 s. Ni le RPM ni le TPM n'ont jamais été en cause : le raisonnement du modèle consomme 1,4 % du budget de tokens, donc l'hypothèse « le thinking fait sauter le TPM » était fausse, et réduire le budget de raisonnement n'aurait rien changé. Conséquence : le free tier Gemini n'est pas « trop juste », il est structurellement inadapté — 20 rapports/jour ne couvrent ni l'usage de 17 joueurs, ni une régénération d'historique enforce=true. D'où la bascule sur Groq, dont le free tier donne 14 400 req/jour sans carte bancaire. ⚠️ Le « middleware global » décrit deux fois dans ce fichier n'existe pas.Program.csn'a niUseExceptionHandler, niIExceptionHandler, ni filtre d'exception — seulement Swagger, CORS, Auth etMapControllers. Conséquence :CoachController.Generatecontient le seul try/catch de la couche Presentation, qui mappeLlmTransientExceptionsur un HTTP 429 +Retry-After: 30. Sans lui, un refus de quota arriverait au client en 500 nu, signalé comme un défaut alors que c'est « reviens dans une minute ». À supprimer le jour où ce middleware sera réellement écrit.- Routes :
GET lol/Coach/{matchId}/player/{playerId}— libre, ne déclenche jamais rien.200+ rapport,202+LoLCoachQueueStatusDto(position,queueLength,estimatedWaitSeconds,enqueuedOn) quand l'analyse est en file,404tant que personne ne l'a demandée (le front doit traiter ce 404 comme « propose le bouton », pas comme une erreur). C'est la route à poller.POST lol/Coach/{matchId}/player/{playerId}—[Authorize], n'importe quel joueur connecté sur n'importe quelle partie. Renvoie 200 si le rapport est déjà en cache, 202 + statut de file sinon, 404 si le joueur n'a pas joué ce match. Ne bloque plus et ne renvoie plus de 429. Cliquer deux fois n'achète pas deux créneaux (dédup sur(matchId, playerId)). Le paramètreforce=truen'est honoré que si l'appelant a le rôlegameon_admin: n'importe qui peut le passer, personne d'autre ne l'obtient. ⚠️ Le seul try/catch de la couche Presentation a disparu avec la mise en file : plus rien n'appelle le modèle depuis une requête HTTP, doncCoachControllern'a plus à traduireLlmTransientExceptionen 429. Les exceptions du modèle sont désormais attrapées et logguées parProcessLoLCoachQueueJob. La remarque sur l'absence de middleware global d'exceptions reste valable pour le reste de l'API.- Le coach s'appelle « Raimmus » (Rammus + AI), démonte les défaites et sacre les victoires —
LoLCoachPrompt.SystemPrompt, version 4 (2026-09-18).⚠️ Le ton est calibré sur le RÉSULTAT de la partie, plus sur la performance individuelle (demande du crew : la v2 chambrait trop mollement) : démolition sans filtre en défaite, éloge sans réserve en victoire.⚠️ La v4 corrige le registre, pas la virulence : la v3 fournissait au modèle une liste d'expressions d'argot (« wesh », « mon reuf », « frérot », « de ouf ») et une liste de memes, et il s'appuyait dessus au point que chaque rapport tournait à la caricature — or l'attendu, c'est le roast, une remarque marrante n'étant qu'un bonus quand la partie en offre une. Le lexique est remplacé par une consigne d'ironie sèche et de constats francs et chiffrés (un chiffre précis tape plus fort qu'une punchline), et l'argot comme les memes sont désormais explicitement interdits. Deux nuances écrites en dur, parce qu'un ton purement indexé sur le résultat sacrerait un feeder et enterrerait le seul joueur qui a tenu la partie : porter une défaite → on tape sur le déroulé de la partie, se faire porter dans une victoire → on sauce quand même en rappelant le carry. Garde-fous conservés et renforcés : rien d'inventé (on exagère le ton, jamais un chiffre), une valeur à zéro reste ignorée, aucune attaque personnelle, et aucune vanne sur un coéquipier nommé — ce sont des potes qui ont chacun droit à leur propre rapport.axesProgressionreste sérieux dans les deux cas, etnoteSur10suit la performance individuelle, pas le résultat ni le ton (un 2/11 gagné reste une mauvaise note) : c'est le seul champ que le ton ne doit pas bouger.- Les garde-fous passent avant l'humour, et c'est essentiel : un modèle à qui on demande d'être drôle enjolive. Interdiction explicite d'inventer ou d'exagérer un chiffre pour faire marcher une vanne, de chambrer sur une donnée absente (le piège « zéro donnée » à nouveau), et de viser la personne plutôt que le jeu.
- Le chambrage vit uniquement dans
synthese. LesaxesProgressionrestent sérieux et applicables : c'est ce pour quoi le joueur est venu. Vérifié : sur la partie roastée, les trois axes (vision, placement, temporisation) sont propres. pointsFortspeut sortir vide, et c'est voulu — sur la partie à 1/9/8 le modèle n'a rien inventé plutôt que de servir un compliment de politesse. Le front doit gérer ce cas sans paraître cassé.- Une allusion au tatou ou un « OK. », une fois par rapport maximum, sinon c'est lourd.
⚠️ Les rapports déjà en base ontPromptVersion = 1,2ou3et restent servis tels quels (ton neutre en v1, chambrage indexé sur la performance en v2, argot en v3). Pour les repasser en v4 :POST lol/Coach/{matchId}/player/{playerId}?force=trueavec le rôlegameon_admin.⚠️ Léger travers observé : le modèle sur-attribue parfois une stat globale à l'adversaire de lane (« 3 kills en solo contre Viktor » alors que le brief dit seulement « Kills en solo : 3 »). Sans gravité, mais à surveiller si tu ajoutes des stats agrégées au brief.
- Reste à faire : brancher le front sur le flux en file (bouton « Analyser cette partie », puis polling du
GETavec affichage de la position et de l'estimation tant qu'il répond 202). La migration et les variables d'environnement sont posées ;LLM_MIN_INTERVAL_SECONDSest optionnelle (défaut 12 s sur Gemini, 35 s sur Groq, sans objet sur Ollama). ⚠️ Sur les customs importées via LCU, le contexte est nettement plus pauvre (pas dechallenges, pas de courbe de dégâts, pas d'events de ward,LoLQueue.Mapvide donc pas de zone de mort). Le builder dégrade proprement, mais la qualité du coaching s'en ressent.⚠️ Les données envoyées à Gemini contiennent les Riot IDs du crew, et le free tier est utilisé par Google pour entraîner ses modèles. Si ça pose problème, anonymiser dansLoLCoachContextBuilder(« Joueur 1 », « Toplaner adverse »…) ne dégraderait pas le coaching.
✅ HttpServiceBase.RunRequest ne lève plus NotImplementedException sur les statuts non gérés (2026-09-17) : nouvelle ExternalApiException (GameOn.External/Common/Exceptions/) qui porte le StatusCode, la route appelée et le corps de réponse brut. L'ancien comportement transformait n'importe quelle réponse Riot (400, 403, 404, 429) en un 500 nu sans le moindre indice — c'est ce qui a rendu illisible le bug des PUUID périmés ci-dessous. La route est expurgée de la clé d'API avant d'entrer dans le message d'exception (ExternalApiException.Redact), Riot prenant sa clé en query param : sans ça, un identifiant vivant se retrouverait dans chaque log et chaque réponse d'erreur. Aucun appelant n'attrapait NotImplementedException, le changement est donc sans effet de bord.
gameondev contient des PUUID chiffrés avec une autre clé que celle de launchSettings.json, donc tous les endpoints by-puuid (summoner-v4, account-v1, league-v4) répondent 400 Bad Request - Exception decrypting <puuid> — les 17 joueurs sans exception. Vérifié : le même Riot ID passé à account-v1/by-riot-id avec la clé courante renvoie un PUUID différent, qui lui fonctionne. Conséquence : une clé de dev régénérée (elles expirent toutes les 24 h) invalide tout l'historique de PUUID en base locale. En prod la clé ne tourne pas, le problème n'y existe pas.
UpdatePlayerSummonerAdminCommandHandler ne peut pas réparer un PUUID périmé : il court-circuite (return playerInDb) dès qu'un joueur porte déjà ce couple nickname/tagline — ce qui est toujours vrai quand on rafraîchit un joueur existant. Il ne résout le PUUID que pour un Riot ID encore inconnu de la base. Il cherche aussi le joueur par KeycloakId seul, donc il ne peut rien faire sur un smurf (même piège que celui déjà corrigé dans UpdatePlayerSummonerCommandHandler).
POST Admin/lol/recompute-participant-stats qui n'existe nulle part dans le code (AdminController n'expose que dashboard). Soit elle a été retirée, soit elle vit sur une autre branche.
✅ Build sans warning StyleCop (2026-09-17) — on est passé de 48 warnings à 1. Ce qui a été fait, et surtout pourquoi :
- 42 des 48 venaient des migrations EF. Toutes les migrations jusqu'au 2026-02-18 portent
// <auto-generated />en première ligne, ce qui fait que StyleCop saute entièrement le fichier ; les 14 écrites depuis l'avaient perdu (dotnet ef migrations addn'émet ce marqueur que dans le.Designer.cs, pas dans le.csde migration). Le marqueur a été remis sur les 14. À refaire à la main après chaquemigrations add, sinon les SA1633/SA1200/SA1413/SA1122 reviennent — ce n'est pas une suppression de règle, c'est déclarer généré du code qui l'est, et c'est déjà la convention du repo. GetLeaguePlayerByIdQueryHandler: constantes remontées avantExcludedQueueTypeKeywords(SA1203) etNormalizeTeamPositionremontée avant les méthodes d'instance (SA1204). Aucun changement de comportement.MinIOService.UploadFile: letry { ... } catch (Exception ex) { throw; }était intégralement neutre (aucun log,exjamais lu — d'où le CS0168). Supprimé ; l'exception remonte exactement comme avant.⚠️ SA1009 sur les 3BackgroundServiceà constructeur primaire est un faux positif, neutralisé par un#pragmalocal et justifié sur place. StyleCop 1.1.118 date de 2018 et ne reconnaît pas la forme) : Based'un constructeur primaire C# 12. Vérifié en direct : retirer l'espace pour satisfaire SA1009 déclenche aussitôt SA1024 (« colon should be preceded by a space ») à la colonne suivante — les deux règles se contredisent, aucun formatage source ne les satisfait toutes les deux. Ne pas « corriger » ces pragmas.⚠️ Le dernier warning restant (SA1516, sans fichier ni ligne) est irréductible et c'est normal — décision utilisateur : on le laisse. Il vient des top-level statements deProgram.cs, que StyleCop 1.1.118 ne connaît pas non plus (C# 9). Vérifié en remplaçantProgram.cspar une classeProgram/Mainclassique : 0 warning, 0 erreur. Il est signalé avecLocation.None— d'où le préfixeCSC :sans chemin — donc ni#pragmani[SuppressMessage]ciblé ne peuvent l'atteindre : inutile de réessayer. Les seules sorties seraient de restructurer le point d'entrée enMain, ou un<NoWarn>SA1516</NoWarn>qui masquerait aussi les vrais SA1516 des Controllers. Les deux ont été écartées.- Hypothèses testées et écartées (ne pas les re-explorer) : les global usings générés ne sont pas en cause (
GameOn.Applicationa unGlobalUsings.g.csde structure identique et ne déclenche rien) ; les pragmas SA1200 deProgram.cset l'espacement autour devar buildernon plus.
🟡 Notion inCrew sur les comptes LoL (2026-09-18) — codé et compilé, migration générée, pas encore appliquée. Player.InCrew (colonne in_crew sur la table Player) sépare les comptes qui décrivent le crew de ceux qu'on garde en base sans les compter. Un compte hors crew garde tout son historique et reste rafraîchissable à la demande, il cesse simplement de peser sur les chiffres.
- Défaut
falsecôté CLR, et aucunHasDefaultValuedansGameOnContext: c'est délibéré et c'est le piège principal. EF omet de l'INSERTtoute propriété égale à son défaut CLR, donc un défaut base àtrueremonterait silencieusement chaque nouveau compte dans le crew. La migrationAdded_InCrew_In_Playersajoute la colonne avecdefaultValue: falsepuis fait unUPDATE [Player] SET [in_crew] = 1explicite : les comptes existants restent dans le crew (rien ne bouge en prod), tout nouveau compte arrive hors crew — y compris une inscription Keycloak (GetConnectedPlayerQueryHandler) et un smurf lié viaLinkSmurfAccount. Un nouveau membre n'apparaît donc pas dans la liste LoL tant qu'un admin ne l'a pas fait entrer. - Ce qui filtre sur le crew : le refresh auto (
UpdateAllPlayerRanksCommandHandler, via.InCrewOnly()), les stats globales (GetLoLGlobalStatsQueryHandler— participations et snapshots de rang), la home (GetLoLHomeStatsQueryHandler— idem, doncWeeklyActivityetFactOfTheWeekdécrivent le même roster), la liste des joueurs LoL (GetAllLeaguePlayersQuery.IncludeOutOfCrew,falsepar défaut, exposé en query paramincludeOutOfCrewsurGET lol/Summoner), et l'historique global des parties (GET lol/Match/lastsansplayerId: une partie n'y figure que si au moins un compte du crew y a joué — une partie jouée par un membre aux côtés d'un compte hors crew reste une partie du crew). - Ce qui ne filtre volontairement pas : le refresh manuel (
PATCH lol/Summoner/{id},/me), le profil d'un compte (GET lol/Summoner/{id}, y compris ses duos), son historique perso (GET lol/Match/player/{playerId}), le dashboard admin (il compte tout, c'est son rôle), et l'import de customs LCU (il doit pouvoir relier un compte hors crew). - Bascule :
PATCH lol/Summoner/{id}/crew?inCrew=true|false, rôlegameon_admin, →SetCrewMembershipCommand. Sortir un compte principal du crew en sort aussi ses smurfs (même personne, ils continueraient sinon à alimenter les stats dont on vient de retirer leur propriétaire) ; l'y remettre ne les y remet pas — un admin a pu en exclure un exprès. Le nombre de smurfs emportés remonte dansSetCrewMembershipResultDto.AffectedSmurfAccounts.InCrewest volontairement hors deUpdatePlayerDto: la route de mise à jour joueur ne doit pas permettre de s'auto-attribuer le crew. - Pas d'index sur
in_crew: la tablePlayerfait une vingtaine de lignes. - Reste à faire : appliquer la migration, puis côté front exposer le badge/toggle « dans le crew » sur l'admin des joueurs (le champ
inCrewest déjà dansPlayerDto, donc sur toutes les réponses joueur).
✅ GET lol/Stats/global ne timeoute plus (2026-09-18) — les trois requêtes de frames de timeline (lastFrames, previousFrames, framesAt20 dans GetLoLGlobalStatsQueryHandler) étaient écrites avec des agrégats corrélés (Timestamp == Game.LoLGameTimelineFrames.Max(...)) posés sur LeagueOfLegendsGameTimelineFrameParticipant, la plus grosse table du schéma. SQL Server rescannait donc la table des frames pour chaque ligne participant, et celle de l'avant-dernière frame (award écureuil) avait même deux niveaux de MAX imbriqués. Avec Period = AllTime (le défaut de la route), ça dépassait le CommandTimeout de 30 s. Remplacé par deux requêtes à plat, toutes deux sur index : on liste (Id, MatchId, Timestamp) des frames des matchs suivis (seek sur le FK match_id), on résout côté client les trois frames cibles par partie, puis on récupère leurs lignes participants par LoLGameTimelineFrameId (~3 frames par partie au lieu de 35). Sémantique identique, aucune migration. launchSettings.json pointe sur la base de prod à travers le LAN (Server=192.168.1.45;Database=gameonprod) — en prod l'API est collée à la base et passait de justesse. Un index sur LeagueOfLegendsGameTimelineFrame (match_id, timestamp) rendrait la première requête entièrement couvrante ; pas fait, pas nécessaire.
✅ includeSmurfs / includeOutOfCrew sur GET lol/Stats/global et GET lol/Home (2026-09-18) — quatre query params au total, mêmes noms et mêmes défauts que GET lol/Summoner (includeSmurfs défaut true, includeOutOfCrew défaut false), donc aucun changement de comportement sans paramètre explicite.
IncludeSmurfsfiltrex.Player.PrimaryPlayerId == null,IncludeOutOfCrewconditionne lex.Player.InCrewqui était jusque-là en dur. Les deux sont appliqués à la fois sur les participations et sur les snapshots de rang, dans les deux handlers. Laisser les deux désynchronisés ferait gagner l'award de chute de LP (EmotionalElevator) à un compte dont les games sont déjà exclues partout ailleurs.- Le défaut
IncludeSmurfs = trueest un choix : un award parle d'une partie jouée, et les parties d'un smurf ont été jouées.falserépond à l'autre lecture (un record par membre) en écartant le compte secondaire, jamais en le fusionnant dans son propriétaire — fusionner mélangerait deux ladders et deux pools de champions dans une carte qui ne voudrait plus rien dire. GetLoLHomeStatsQueryHandlerpropage les deux flags à laGetLoLGlobalStatsQueryqu'il envoie pourCrewRecords, sinon la carte « Records du crew » décrirait un autre roster que les blocsWeeklyActivity/FactOfTheWeekjuste à côté d'elle.- Vérifié en direct sur les données de prod : sur
GET lol/Home,crewRecords.totalPlayersTrackedpasse de 13 (défauts) à 15 avecincludeOutOfCrew=trueet tombe à 11 avecincludeSmurfs=false. SurGET lol/Stats/global?queue=Solo&period=Month: 12 joueurs / 484 parties aux défauts, 13/520 avec les hors-crew, 11/465 sans les smurfs, 12/501 avec les deux. ⚠️ Corrige une note antérieure de ce fichier qui affirmait que la home n'aurait volontairement pas le paramètre. Elle l'a.
✅ Les deux mêmes paramètres sur GET lol/Match/last (2026-09-18), GetLastGamesPlayedQuery.IncludeSmurfs/IncludeOutOfCrew, mêmes défauts. La sémantique n'est pas la même que sur les stats et c'est le point à retenir : ici les flags décident si une partie est listée, jamais quels participants elle porte — une partie listée embarque toujours son roster complet, smurfs et extérieurs compris. Ils ne s'appliquent qu'à la branche « historique partagé » (PlayerId == null) ; l'historique d'un compte donné (GET lol/Match/player/{playerId}) continue de tout montrer, y compris pour un compte hors crew, parce qu'il parle de ce compte-là.
⚠️ Les deux conditions vivent dans un seulAny, et doivent y rester. Scindées en deuxWhere, elles voudraient dire « un participant est dans le crew et un participant est un compte principal » — deux personnes différentes peuvent satisfaire ça — au lieu de « un même compte est les deux », qui est la question posée. La forme retenue est(includeOutOfCrew || y.Player.InCrew) && (includeSmurfs || y.Player.PrimaryPlayerId == null)avec les flags capturés dans des variables locales, qu'EF paramètre proprement.- Vérifié sur les données de prod,
totaldeGET lol/Match/last: 7759 aux défauts, 10136 avecincludeOutOfCrew=true, 7160 avecincludeSmurfs=false, 8653 avec les deux.GET lol/Match/player/47(compte hors crew) renvoie toujours ses 979 parties.
GameOn.Presentation/Program.cs se termine par // </copyright>ddd — un ddd parasite collé à la balise fermante.
✅ Commentaires de code 100 % anglais (2026-09-17) — règle ajoutée aux « Interdictions Strictes » de CLAUDE.md, AGENTS.md et CURSOR.md (les trois fichiers sont des copies qui ont divergé : AGENTS.md et CURSOR.md sont identiques entre eux et en retard sur CLAUDE.md, pensez à les resynchroniser). 40 commentaires français corrigés :
- 28 étaient des
#pragma warning ... // <message Roslyn traduit>, dans 9 fichiers (SummonerController,MatchV5Service, les autres clients Riot,UpdateLoLGameCommandHandler,UpdatePlayerSummonerCommandHandler…). C'est le quick-fix « Supprimer l'avertissement » de Visual Studio en locale française qui recopie le libellé localisé. Remplacés par le libellé anglais officiel, en s'alignant sur ce que le repo utilisait déjà ailleurs (GetConnectedPlayerQueryHandlerpour CS8602,Persistence/DependencyInjectionpour CS8603) : CS8601 →Possible null reference assignment., CS8602 →Dereference of a possibly null reference., CS8603 →Possible null reference return., CS8604 →Possible null reference argument. - Les 12 autres étaient la justification du faux positif SA1009 sur les 3
BackgroundService, écrite en français plus tôt dans la même session. Traduite. ⚠️ Ce qui reste accentué dans les sources est volontaire et ne doit pas être traduit : ce sont des chaînes, pas des commentaires — le prompt deLoLCoachPrompt, tout le brief construit parLoLCoachContextBuilder(Raimmus s'adresse aux joueurs en français), et le"Aucune description renseignée."par défaut deTournament/TournamentDto. Seule subtilité :LoLCoachContextBuilder.GetDeathZonea un<returns>anglais qui cite"bot, moitié ennemie"— c'est la valeur réellement renvoyée, pas du commentaire à traduire.
🟡 Gain / perte de LP par partie classée (2026-09-23, branche features/league-of-legends/lp-per-game) — migration Added_LoLGameParticipantRankChange appliquée en prod et backfill POST lol/Match/rank-changes/recompute exécuté le 2026-09-23 (depuis l'API de dev branchée sur la base de prod) ; code pas encore commité ni déployé. Riot n'expose aucun LP par partie (match-v5 ignore tout du rang, league-v4 ne donne que la lecture courante) : la valeur est déduite des snapshots LeagueOfLegendsRankHistory, comme le font les sites type op.gg.
- Nouvelle entité
LoLGameParticipantRankChange(tableLeagueOfLegendsGameParticipantRankChange), 1:1 à clé partagée avecLoLGameParticipant(même pattern queStats/Challenges), exposée enLoLGameParticipant.RankChange:LeaguePointsChange(+18 / -21, promotions et rétrogradations comprises viaLoLRankScaleCalculator),TierBefore/RankBefore/LeaguePointsBefore,TierAfter/RankAfter/LeaguePointsAfter,ComputedOn. Incluse dansGET lol/Match/{matchId},GET lol/Match/lastetGET lol/Match/player/{playerId}. - Règle d'attribution,
LoLGameRankChangeCalculator(statique, pur, dansMatches/Services) : deux snapshots consécutifs d'une même file dont les compteurs victoires/défaites diffèrent d'exactement une partie, avec exactement une partie non-remake du joueur dans cette file terminée entre les deux (GameEnddans(avant.CreatedOn, après.CreatedOn]), dont le résultat colle au compteur (+1 V ↔ victoire), et dont le signe est cohérent (une victoire rapporte > 0, une défaite ≤ 0 — 0 est légitime sur une défaite à 0 LP sous protection de rétrogradation). Sinon pas de ligne :rankChange == nullveut dire « inconnu », jamais « 0 ». Deux parties entre deux refresh, reset de saison, dodge ou decay qui pollue la fenêtre → non attribué plutôt que deviné. ⚠️ Le taux de couverture dépend de la fréquence du refresh (RefreshLeagueSummonerRanksJob, 20 min depuis le 2026-09-23, 30 min avant) : deux parties terminées dans le même créneau de 20 min ne sont attribuables ni l'une ni l'autre. Pas encore mesuré sur la prod (la lecture de la base de prod a été refusée par le mode auto de Claude Code) — la réponse de la route de backfill donne le chiffre (ParticipationsWithRankChange / ParticipationsScanned).- Le dédoublonnage des snapshots compare désormais aussi
Wins/Losses(UpdatePlayerSummonerCommandHandler) : avant, une partie qui laissait les LP inchangés (défaite à 0 LP) ne produisait aucun snapshot et fusionnait avec la suivante. Sans effet sur les awards LP existants (un snapshot à LP identique ne change aucun écart). - Synchro, pas ajout :
RecomputeLoLGameRankChangesCommand(PlayerId?,Since?,Until?surGameEnd) remet chaque participation de la plage exactement dans l'état calculé (crée, met à jour, supprime si la partie est devenue ambiguë, ex. une partie importée en retard dans la même fenêtre). Les bornes sont élargies au snapshot précédent / suivant pour que chaque fenêtre soit chargée en entier.ComputedOnn'est réécrit que si la valeur change. Appelée :- à la fin de chaque refresh joueur (
UpdatePlayerSummonerCommandHandler, 7 derniers jours) — c'est là que se résolvent les deux cas « moitié manquante » : snapshot d'une partie déjà importée par le refresh d'un duo, ou partie que match-v5 ne sert qu'au refresh suivant ; - à la fin de
UpdateLoLGameCommandHandlerpour chaque joueur suivi d'une partie classée, bornée à la fenêtre de cette partie (Since = Until = GameEnd) : un ré-import supprime et recrée les participants, donc leurRankChangepart en cascade et doit être remis ; - par la route admin
POST lol/Match/rank-changes/recompute[?playerId=](rôlegameon_admin), pour le backfill de tout l'historique. Ne lit rien chez Riot.
- à la fin de chaque refresh joueur (
- Historique des gains/pertes par partie pour la page de profil, pendant du
GET lol/Summoner/{id}/rank(historique de LP) :GET lol/Summoner/{id}/rank/changes?queue=All|Solo|Flex&limit=50&days=→GetSummonerRankChangesQuery, liste deLoLGameRankChangeDto(matchId,queueId,gameStart,win,championName,kills/deaths/assists,rankChange= le même objet que sur les routes de matchs), triée de la plus ancienne à la plus récente comme l'historique de rang. Leslimitparties les plus récentes (50 par défaut), remakes exclus.⚠️ Les parties sans delta attribué sont gardées avecrankChange: null: les retirer collerait sur un graphe deux parties qui en ont d'autres entre elles. C'est au front de les afficher en trou ou en barre neutre, jamais à 0. - Reste à faire : commiter, déployer (d'ici là, le serveur de prod tourne l'ancien code : pas de calcul au fil des refresh, et snapshots encore dédoublonnés sur les seuls LP — le recalcul sur 7 jours du premier refresh après déploiement rattrapera), puis afficher
participant.rankChangecôté front (JungleDiff). Idée non faite : donner le delta à Raimmus dansLoLCoachContextBuilder, et un award « plus grosse perte en une partie » plus juste queEmotionalElevator(qui mesure un écart entre snapshots, donc potentiellement plusieurs parties).
🟡 Riot ID resynchronisé depuis la dernière game (2026-09-23, branche features/league-of-legends/summoners) — codé et compilé, pas encore testé ni déployé. Un joueur qui se renommait gardait son ancien RiotGamesNickname/RiotGamesTagLine indéfiniment : tout le refresh (UpdatePlayerSummonerCommandHandler) passe par le PUUID, qui ne change pas au renommage, et summoner-v4 ne renvoie plus aucun nom. Seul account-v1 by-puuid le donne, et IAccountService n'implémente que by-riot-id. Les games, elles, continuaient d'arriver avec le bon nom (LoLGameParticipant.RiotIdGameName, recopié de match-v5 à chaque import).
- Choix utilisateur : aucun appel Riot supplémentaire.
SyncRiotIdFromLatestGame(en fin de refresh, après l'import des games) recopie le Riot ID de la participation la plus récente du compte (PlayerIdetPuuid= ceux du joueur, pour qu'un joueur rebranché sur un autre compte Riot ne récupère pas le nom de l'ancien). Contrepartie assumée : un renommage n'est vu qu'après une partie jouée sous le nouveau nom. ⚠️ Garde-fou : on n'écrase qu'un nom déjà vu dans une game du compte. Sans lui, la route admin etLinkSmurfAccount(qui écrivent le nom puis lancent ce même refresh dans la foulée) verraient une correction manuelle écrasée immédiatement par l'ancien nom de la dernière game. Un nom qu'aucune game ne montre est donc considéré comme plus récent que les games, et gardé. Un nom vide est toujours rempli.- Effet de bord attendu : l'import des customs LCU (qui relie les joueurs par Riot ID) devrait se mettre à relier les comptes renommés, probablement
RememberV#8888. - Aucune migration.
🟡 Données de la nouvelle home JungleDiff (2026-09-25, branche features/league-of-legends/home) — codé, compilé et vérifié hors base (harnais SQLite + traduction SQL Server), pas encore commité, testé sur la prod ni déployé. Tout est additif : sans paramètre, chaque route existante renvoie exactement les mêmes champs, vérifié en comparant l'ancien et le nouveau handler sur les mêmes données (4 combinaisons de includeSmurfs/includeOutOfCrew). Aucune migration.
- Fenêtre de
GET lol/Home:window=CalendarWeek(défaut, comportement historique) ouwindow=Last7Days(J-6 00:00 Paris → maintenant, comparé aux 7 jours d'avant). Elle s'applique à toutWeeklyActivityet àFactOfTheWeek, jamais àCrewRecords(toujours le mois glissant). La fenêtre effective est exposée :WindowStart,WindowEnd,PreviousWindowStart(UTC). - Nouveaux champs de
LoLWeeklyActivityDto:WinsLastWeek/LossesLastWeek,Days(unLoLDailyActivityDtopar jour de Paris, jours vides inclus à 0) etActivePlayers(LoLActivePlayerDto, par compte, trié parties ↓ puis victoires ↓ puis id). Les parties/victoires/défaites des jours retombent exactement sur les totaux. Le temps de jeu aussi, au dixième près : les dixièmes sont répartis au plus fort reste (SpreadTenths), donc un jour peut s'écarter d'un dixième de son propre arrondi. ⚠️ Days[].NetLpChangene retombe pas toujours surNetLpChangeThisWeek, et c'est voulu. Le jour compare le dernier snapshot du jour au dernier snapshot antérieur, quel que soit son âge (le journal de rang n'écrit que les changements, donc ce snapshot est bien le rang au début du jour). Le total hebdo historique, lui, ignore un compte sans snapshot dans la fenêtre précédente. Écart = la contribution de ces comptes (plus, marginalement, un tier hors échelle en cours de semaine).null≠ 0 :null= aucune paire comparable ce jour-là.TopChampions[].TopPlayer({ Player, GamesPlayed }, départage victoires puis id le plus petit) : porté par une sous-classeLoLCrewChampionStatDto : LoLChampionStatDtoplutôt que par une propriété nullable, parce queLoLChampionStatDtosert aussiperformanceStats.championStatsoù le top joueur serait toujours le compte lui-même. Le contrat par joueur ne bouge pas. Dispo surGET lol/Stats/globalet viaCrewRecordssur la home.PlayerDto.MainChampionName, rempli uniquement parGET lol/summoner, en une requête pour toute la liste. C'est exactementchampionStats[0]deGET lol/summoner/{id}?period=Month, doncAddMonths(-1)(28 à 31 jours) et non 30 jours pile. Pour que les deux ne divergent jamais, la liste d'exclusion de files du profil a été sortie dansLoLPerformanceQueueFilter(partagée, comportement du profil inchangé, vérifié).⚠️ Cette liste n'a pas les mots-clés français (« personnalis », « entraînement ») deGetLoLGlobalStatsQueryHandler: les customs (3100/3110/3140) comptent donc dans les stats du profil et dansMainChampionName. Laissé tel quel (le corriger change le contrat deperformanceStats), à trancher.GET lol/live(anonyme,includeSmurfsdéfauttrue,includeOutOfCrewdéfautfalse, comptes archivés jamais) : spectator-v5 viaISpectatorService/SpectatorV5Service(GameOn.External, 404 →null). Il n'existe aucun limiteur de débit devant les clients Riot, donc le budget est tenu parLoLLiveGameCache(singleton) : au plus un appel par compte et par 60 s, quel que soit le trafic, et zéro quand personne ne regarde. Un seul verrou pour tous les rafraîchissements. Un appel répond pour toute une premade (les autres comptes suivis trouvés dans la même partie ne sont pas réinterrogés). Sur un 429 : fin du tour, 60 s sans appeler Riot, les dernières réponses connues restent servies (d'oùRetrievedOn). Toute autre erreur sur un compte (PUUID d'une autre clé → 400, 5xx, timeout) : loggée, compte affiché « pas en partie » jusqu'au tour suivant, sans faire tomber la liste.GameStartestnulletGameLengthSeconds0 pendant l'écran de chargement (gameStartTime = 0chez Riot). Pas de rôle (le spectator n'en donne pas).- Nom de champion du live :
ICommunityDragonChampionService(déjà utilisé par l'import LCU, caché 24 h) plutôt quechampion.jsonde Data Dragon. Même référentiel d'IDs, alias = convention match-v5.⚠️ Community Dragon écritFiddlestickslà où match-v5 écritFiddleSticks(cf.lol-champion.tsde JungleDiff) : une tableMatchV5Aliasescorrige ça dans le service, donc l'import LCU des customs stocke désormaisFiddleStickslui aussi, comme match-v5. - Pas de FluentValidation ajoutée : le repo n'en contient aucune (ni package, ni behavior MediatR), contrairement à ce que décrit ce fichier.