Sur cette page

← Toute la documentation

Mods : accès au classement local et onglets de la sélection

Classement local et onglets de la sélection de maps, écrits par des mods.

Note de conception écrite avant le code (branche feature/mod-leaderboard-tabs). Deux demandes du joueur :

  1. « Ajouter leaderboard access dans mods » : lecture du classement LOCAL d'un chart par les mods.
  2. « 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
});
  • chartId est l'identifiant de chart de la bibliothèque (celui de library.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 null quand la limite est atteinte : 2 requêtes en attente par mod, 16 pour tous les mods. Même convention que metron.performance et ratings.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 mods met la requête en file ; un worker de crates/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 dans ModHostOptions) et canal d'arrivée au thread mods (même mécanique que performance_sender).
  • crates/library/src/mod_scores.rs : ModScoreService (pool de fond à 1 thread, indépendant de ScoreService : 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 un ScoreRequest et appelle Library::evaluate_page (nouveau : une page du classement complet, avec les noms de paliers).
  • apps/desktop : la fonction fournie au ModHostOptions envoie un Event::ModLeaderboard au 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 } : les limit (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 store modTabs (onglets triés avec libellé et icône, extensions par onglet et emplacement, valeurs résolues pour le chart sélectionné) et l'action chart.panel qui accepte désormais la clé d'un onglet publié. Aucun identifiant de mod en dur.
  • SelectedMap.svelte : TabBar reçoit les onglets de l'hôte puis ceux des mods ; un tabpanel par 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.svelte rend aussi text et leaderboard : les huit interfaces (dossiers themes/, 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

  1. Pas de permission pour leaderboard.* et tabs.* (donnée locale du joueur, lecture seule, bornée).
  2. Résultat par événement leaderboard.result (numéro de requête, null si limité) plutôt qu'une promesse : c'est le motif des appels asynchrones existants ; aucun appel du SDK n'est une promesse.
  3. leaderboard.best(chartId) = le rang 1 du classement entier (importés compris, drapeau received). Pas de personalBest séparé : un mod filtre received sur la page qu'il lit.
  4. 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.
  5. Onglets de mods après ceux de l'hôte, order ne comparant que les onglets de mods entre eux.
  6. Champs column via une note du mod (pas de table libre), parce que seules les notes écrivent les tables de mods aujourd'hui.
  7. 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.

Source dans le dépôt du jeu : docs/mods-leaderboard-et-onglets.md