Note de conception écrite avant le code (branche feature/mod-leaderboard-tabs). Deux demandes du joueur :
- « Ajouter leaderboard access dans mods » : lecture du classement LOCAL d'un chart par les mods.
- « Ajouter l'ajout et modification de tabs pour les mods dans la song select » : un mod ajoute un onglet au panneau du chart choisi et complète les onglets existants.
Les deux suivent les règles du projet : TypeScript exécuté en processus sur le thread mods budgété, toute valeur qui entre dans Rust est validée en Rust, aucun SQL, DOM ni IPC brut pour les scripts, le thread de jeu n'est jamais bloqué, l'interface est un rendu générique (jamais de if (mod === …)).
Ce que font déjà les mods (point de départ lu dans le code)
| Mécanisme | Où | Forme |
|---|---|---|
| Lecture synchrone, bornée, sans verrou | game.hits (hits.rs), game.notes, game.song |
l'état est déjà en mémoire (anneau atomique, Arc publiés avant le play) |
Travail lourd hors du thread mods |
metron.performance, ratings.backfill (api.rs, library.rs) |
la fonction hôte renvoie un numéro de requête (null si la limite est atteinte), un service de crates/library calcule sur ses workers, le résultat revient par un canal borné et le thread mods l'émet comme événement typé au seul mod demandeur (metron.performanceResult, ratings.progress) |
| Déclaration statique validée en Rust | judgements.register, scrollSpeed.register, gameplay.register, ratings.register, library.filters.register |
appel autorisé pendant setup seulement (registre « fermé » ensuite), publié sans verrou (ArcSwap) par ModHost::…(), relayé à la page par un HostEvent, retiré à la désactivation / désinstallation du mod (withdraw_ratings) |
| Panneaux déclaratifs | RatingPanel (metrics, bars, radar, timeline) dans ratings.rs, rendu par MapAnalysis.svelte |
bornes : 4 panneaux, 1 à 16 champs, titres de 1 à 64 caractères, sources = colonnes numériques de la table du mod ou statistiques neutres du chart |
| Permissions | permissions: ["stage"] (manifest.rs) |
uniquement pour un dessin natif que le joueur peut refuser mod par mod |
1. Classement local lisible par les mods
API
// dans setup() ou un gestionnaire d'événement (jamais au chargement)
const id = leaderboard.query({ chartId, limit: 20, offset: 0 }); // number | null
const best = leaderboard.best(chartId); // number | null (= query limit 1)
ctx.on("leaderboard.result", (result) => {
// result.requestId === id ; result.chartId, offset, total, entries, judgement, performance, error
});
chartIdest l'identifiant de chart de la bibliothèque (celui delibrary.chartAdd,library.chartsAdded, des tables de mods). Un mod n'a ainsi aucun chemin de fichier.limit: entier de 1 à 100 (défaut 20).offset: entier de 0 à 100 000. Hors bornes ou type faux : exception (validée en Rust). Seuls les mods (pas les skins ni les scripts de map) l'appellent.- Le retour est un numéro de requête, ou
nullquand la limite est atteinte : 2 requêtes en attente par mod, 16 pour tous les mods. Même convention quemetron.performanceetratings.backfill. - Le résultat arrive uniquement au mod demandeur, par l'événement
leaderboard.result. Un mod désactivé, déchargé ou un hôte remplacé n'en reçoit plus. - Aucun appel ne bloque : le thread
modsmet la requête en file ; un worker decrates/library(pool de fond à 1 thread,ModScoreService) relit les replays, les rejuge et classe.
Ce que renvoie une entrée
C'est le même classement que l'interface : chaque replay du chart est rejugé avec le jugement COURANT du joueur (choose_judgement), la performance est celle du calculateur choisi (réglage performanceCalculator, sinon celui associé au jugement), et l'ordre est celui de rank_rows (performance d'abord quand il y en a une, puis précision, combo, misses, date). Les replays abandonnés ne comptent pas.
| Champ | Sens |
|---|---|
rank |
rang global à partir de 1 (offset + position + 1) |
replayId |
identifiant du replay |
playerName |
nom enregistré au moment du play, ou null : un ancien replay sans nom est honnêtement « inconnu », jamais attribué au joueur actuel |
received |
true : replay importé d'un fichier (.pvreplay/.osr), le nom est celui du fichier |
accuracy |
précision en pourcent (0–100) sous le jugement courant |
performance, performanceNonstandard |
valeur du calculateur ou null (non calculable / pas de calculateur) ; nonstandard = précision d'un autre modèle que celui du calculateur, jamais présentée comme pp/SSR officiel |
maxCombo, misses |
du rejugement |
tiers |
[{ name, count }] dans l'ordre des paliers du jugement courant, Miss compris une seule fois |
rate |
cadence enregistrée (jamais le réglage actuel) |
modified |
joué avec des modificateurs de chart (mirror, random, LN…) |
playedAtMs |
date de l'enregistrement (ms Unix) |
La réponse porte aussi total (taille du classement), judgement (nom du jugement courant), performance ({ calculator, unit } ou null), unavailableReplays et error (texte anglais brut, null si tout va bien ; chart inconnu, bibliothèque ou travailleurs indisponibles…).
Pas de permission
Le classement local est la propre donnée du joueur, en lecture seule, bornée et sans chemin ni fichier. Les lectures existantes (game.song, metron.*, tables) n'ont pas de permission ; la seule permission du projet (stage) protège un dessin natif que le joueur peut vouloir couper. Je n'en ajoute donc pas (point à valider ci-dessous).
Côté hôte
crates/modding/src/leaderboard.rs: types (LeaderboardQuery,LeaderboardResult,LeaderboardEntry…), validation des bornes,LeaderboardBackend(Arc<dyn Fn(LeaderboardJob) -> bool>fourni dansModHostOptions) et canal d'arrivée au threadmods(même mécanique queperformance_sender).crates/library/src/mod_scores.rs:ModScoreService(pool de fond à 1 thread, indépendant deScoreService: le classement de l'interface a un cache unique invalidé par révision, une requête de mod ne doit ni l'annuler ni le remplacer). Il lit la source du chart (db::mod_tables::source), décode le chart (Song::decode_variant), construit unScoreRequestet appelleLibrary::evaluate_page(nouveau : une page du classement complet, avec les noms de paliers).apps/desktop: la fonction fournie auModHostOptionsenvoie unEvent::ModLeaderboardau thread principal, qui y joint les réglages courants (jugement, calculateur) et le confie au service : le thread principal ne fait que cloner des réglages.- Pas de cache : chaque requête rejuge le chart (comme chaque sélection de l'interface). C'est pourquoi la limite est de 2 requêtes par mod.
2. Onglets de mods dans la sélection
Principe
Même principe que les panneaux de notes : le mod déclare (typé, validé en Rust, borné), l'interface rend de façon générique. Le mod ne fournit ni HTML, ni CSS, ni code ; les valeurs affichées sont résolues par l'hôte pour le chart sélectionné, comme ratingFields.
// uniquement pendant setup(), après ratings.register si les champs lisent une colonne
tabs.register({
id: "skills", title: "Skills", icon: "gauge", order: 10,
panels: [
{ kind: "metrics", title: "Rating", fields: [
{ label: "Overall", source: { kind: "column", rating: "etterna-515", column: "overall" }, decimals: 2 },
{ label: "Plays", source: { kind: "leaderboard", stat: "plays" }, decimals: 0 } ] },
{ kind: "leaderboard", title: "Best plays", limit: 5 },
{ kind: "text", title: "Note", text: "Computed on the base chart, rate 1.0." },
],
});
// compléter un onglet de l'hôte, sans rien retirer ni réécrire
tabs.extend({ tab: "info", slot: "bottom", order: 10, panels: [ /* … */ ] });
Vocabulaire de panneaux (TabPanel)
Les quatre panneaux existants gardent leur forme et leur rendu (metrics, bars, radar, timeline) : mêmes bornes (1 à 16 champs, radar 3 à 12, 1 à 2 séries, échelle max dans (0, 1 000 000], titres 1 à 64). Deux ajouts seulement, parce que rien d'existant ne couvre « liste de lignes » ni « texte » :
leaderboard { title, limit }: leslimit(1 à 10) meilleurs plays du chart sélectionné, les mêmes données déjà évaluées que l'onglet Leaderboard (rang, nom ou « inconnu », précision, performance, rate) : aucune requête de plus.text { title?, text }: texte statique du mod, 1 à 280 caractères, sans balisage ni lien.
Sources de champs (TabFieldSource) :
{ kind: "column", rating, column }: colonne numérique de la table d'une note déclarée par le même mod (ratings.register). L'hôte lit déjà ces valeurs (ratingFields, et la version à la cadence choisie), l'onglet n'ajoute pas de chemin de lecture ni de SQL. Les tables des mods ne sont aujourd'hui remplies que par les calculs de notes, d'où la référence à une note plutôt qu'à une table libre.{ kind: "chart", metric }: statistique neutre du chart (déjà utilisée par les notes).{ kind: "leaderboard", stat }:plays(taille du classement),bestPerformance,bestAccuracy. Exact ou « indisponible », jamais approximé : la meilleure précision n'est connue que si le classement est ordonné par précision (pas de calculateur) ou tient sur la première page.
Ajouter un onglet / modifier un onglet
| Appel | Effet | Bornes |
|---|---|---|
tabs.register({ id, title, icon?, order, panels }) |
un onglet de plus, <mod>/<id> |
4 onglets par mod, 12 au total, titre 1 à 24 caractères, icon dans une liste fermée (même technique que mod-icons.ts), order 0 à 1000, 1 à 8 panneaux |
tabs.extend({ tab, slot, order, panels }) |
des panneaux en plus dans info, leaderboard ou mods, slot: "top" (avant le contenu de l'hôte) ou "bottom" (après) |
4 extensions par mod, 1 à 4 panneaux chacune, 24 sections au total |
Jamais de suppression ni de réécriture : le contenu de l'hôte est rendu tel quel, les panneaux de mods sont insérés avant/après dans la région de défilement. Les onglets de mods se placent après les onglets de l'hôte (Info, Leaderboard, Mods, Training, Editor), triés par (order, clé). Appels hors setup, par un skin ou un script de map, doublons, colonne ou note inconnue, bornes dépassées : erreur Rust.
Cycle de vie
Publié comme les notes (ModHost::tabs(), ArcSwap, révision) et relayé par HostEvent::ModTabs { revision, tabs, extensions }. À la désactivation, au crash ou à la désinstallation d'un mod, ses onglets et extensions disparaissent (même withdraw que ses notes). Si l'onglet affiché disparaît, la page revient sur Info. Un redémarrage de l'hôte envoie d'abord une liste vide.
Interface
apps/web/src/menu/: le storemodTabs(onglets triés avec libellé et icône, extensions par onglet et emplacement, valeurs résolues pour le chart sélectionné) et l'actionchart.panelqui accepte désormais la clé d'un onglet publié. Aucun identifiant de mod en dur.SelectedMap.svelte:TabBarreçoit les onglets de l'hôte puis ceux des mods ; untabpanelpar onglet de mod ; les extensions s'insèrent dans#chart-info,#chart-mods(régions déjà défilantes) et, pour le Leaderboard, dans deux bandes bornées au-dessus et au-dessous de la liste (la liste reste la seule région qui défile).MapAnalysis.svelterend aussitextetleaderboard: les huit interfaces (dossiersthemes/, données de jetons) héritent du rendu parce que tout passe par les classes et jetons existants (pv-plate,--pv-*).- Textes de l'interface (états vides, étiquettes d'accessibilité) dans
en.json,fr.json,zh.json. Les textes déclarés par le mod (titres, libellés) restent les siens : ils ne passent pas par les catalogues. - Le mock de développement (
apps/web/src/mock/) publie des onglets de démonstration pour les tests Playwright.
Choix à faire valider
- Pas de permission pour
leaderboard.*ettabs.*(donnée locale du joueur, lecture seule, bornée). - Résultat par événement
leaderboard.result(numéro de requête,nullsi limité) plutôt qu'une promesse : c'est le motif des appels asynchrones existants ; aucun appel du SDK n'est une promesse. leaderboard.best(chartId)= le rang 1 du classement entier (importés compris, drapeaureceived). Pas depersonalBestséparé : un mod filtrereceivedsur la page qu'il lit.- Pas d'exemple dans
mods/: les mods de ce dossier sont livrés et actifs, un onglet de démonstration s'afficherait chez tous les joueurs. Les exemples sont dans cette note et Mods TypeScript, les tests utilisent des mods temporaires. - Onglets de mods après ceux de l'hôte,
orderne comparant que les onglets de mods entre eux. - Champs
columnvia une note du mod (pas de table libre), parce que seules les notes écrivent les tables de mods aujourd'hui. - Valeurs de colonne à la cadence choisie pour les notes qui déclarent des panneaux (comme
Infos), sinon valeurs de base ; jamais multipliées par la cadence.