From 457ad6f00c30bff239b01510b44f4a37839a0dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?RAMELOT=20Lo=C3=AFc?= Date: Fri, 2 Oct 2026 23:20:56 +0200 Subject: [PATCH 1/5] feat(rngdle): porte rngdle depuis DJ4H --- AGENTS.md | 1 + docs/functional.md | 52 ++ docs/site/.vitepress/config.ts | 2 + docs/site/en/guide/tasks.md | 46 ++ docs/site/fr/guide/tasks.md | 46 ++ docs/specs/rngdle-migration.md | 199 ++++++ package.json | 1 + pnpm-lock.yaml | 9 + scripts/export-dj4h-rngdle.ts | 51 ++ src/core/i18n/en.json | 3 + src/core/i18n/fr.json | 3 + src/core/loaders/task-scheduler.test.ts | 89 +++ src/core/loaders/task-scheduler.ts | 52 ++ src/index.ts | 6 + src/lib/declared.ts | 1 + src/lib/imaging.ts | 24 +- src/lib/registry.ts | 11 + src/lib/task.ts | 16 + src/modules/rngdle/assets/arrow_up.png | Bin 0 -> 4168 bytes src/modules/rngdle/assets/medal_bronze.png | Bin 0 -> 1319 bytes src/modules/rngdle/assets/medal_gold.png | Bin 0 -> 1190 bytes src/modules/rngdle/assets/medal_silver.png | Bin 0 -> 1273 bytes src/modules/rngdle/assets/outfit.ttf | Bin 0 -> 55396 bytes src/modules/rngdle/assets/score-table.json | 202 ++++++ src/modules/rngdle/assets/spacemono_bold.ttf | Bin 0 -> 97256 bytes src/modules/rngdle/assets/trash.png | Bin 0 -> 13410 bytes src/modules/rngdle/commands/replies.ts | 25 + .../rngdle/commands/rngdle-admin.command.ts | 290 ++++++++ src/modules/rngdle/commands/rngdle.command.ts | 257 ++++++++ src/modules/rngdle/i18n/en.json | 58 ++ src/modules/rngdle/i18n/fr.json | 58 ++ .../interactions/overall-page.button.ts | 105 +++ src/modules/rngdle/rendering/common.ts | 60 ++ .../rendering/daily-leaderboard-image.test.ts | 64 ++ .../rendering/daily-leaderboard-image.ts | 190 ++++++ .../overall-leaderboard-image.test.ts | 70 ++ .../rendering/overall-leaderboard-image.ts | 124 ++++ .../rngdle/rendering/profile-image.test.ts | 73 ++ src/modules/rngdle/rendering/profile-image.ts | 232 +++++++ .../rendering/server-stats-image.test.ts | 116 ++++ .../rngdle/rendering/server-stats-image.ts | 205 ++++++ src/modules/rngdle/rngdle.config.ts | 12 + src/modules/rngdle/rngdle.module.ts | 44 ++ src/modules/rngdle/rngdle.prisma | 27 + src/modules/rngdle/services/boards.test.ts | 92 +++ src/modules/rngdle/services/boards.ts | 300 +++++++++ src/modules/rngdle/services/format.test.ts | 45 ++ src/modules/rngdle/services/format.ts | 36 + .../services/image-cache.service.test.ts | 88 +++ .../rngdle/services/image-cache.service.ts | 45 ++ .../rngdle/services/rngdle-api.test.ts | 301 +++++++++ src/modules/rngdle/services/rngdle-api.ts | 118 ++++ .../rngdle/services/rngdle.service.test.ts | 507 ++++++++++++++ src/modules/rngdle/services/rngdle.service.ts | 232 +++++++ .../rngdle/services/score-table.service.ts | 65 ++ .../rngdle/services/score-table.test.ts | 267 ++++++++ src/modules/rngdle/services/score-table.ts | 157 +++++ src/modules/rngdle/services/stats.test.ts | 193 ++++++ src/modules/rngdle/services/stats.ts | 111 ++++ .../rngdle/services/sync.service.test.ts | 622 ++++++++++++++++++ src/modules/rngdle/services/sync.service.ts | 200 ++++++ .../rngdle/tasks/daily-leaderboard.task.ts | 72 ++ src/modules/rngdle/tasks/score-table.task.ts | 14 + src/modules/rngdle/tasks/sync.task.ts | 10 + .../20261002211526_rngdle/migration.sql | 36 + src/prisma/schema.prisma | 29 + 66 files changed, 6355 insertions(+), 9 deletions(-) create mode 100644 docs/site/en/guide/tasks.md create mode 100644 docs/site/fr/guide/tasks.md create mode 100644 docs/specs/rngdle-migration.md create mode 100644 scripts/export-dj4h-rngdle.ts create mode 100644 src/core/loaders/task-scheduler.test.ts create mode 100644 src/core/loaders/task-scheduler.ts create mode 100644 src/lib/task.ts create mode 100644 src/modules/rngdle/assets/arrow_up.png create mode 100644 src/modules/rngdle/assets/medal_bronze.png create mode 100644 src/modules/rngdle/assets/medal_gold.png create mode 100644 src/modules/rngdle/assets/medal_silver.png create mode 100644 src/modules/rngdle/assets/outfit.ttf create mode 100644 src/modules/rngdle/assets/score-table.json create mode 100644 src/modules/rngdle/assets/spacemono_bold.ttf create mode 100644 src/modules/rngdle/assets/trash.png create mode 100644 src/modules/rngdle/commands/replies.ts create mode 100644 src/modules/rngdle/commands/rngdle-admin.command.ts create mode 100644 src/modules/rngdle/commands/rngdle.command.ts create mode 100644 src/modules/rngdle/i18n/en.json create mode 100644 src/modules/rngdle/i18n/fr.json create mode 100644 src/modules/rngdle/interactions/overall-page.button.ts create mode 100644 src/modules/rngdle/rendering/common.ts create mode 100644 src/modules/rngdle/rendering/daily-leaderboard-image.test.ts create mode 100644 src/modules/rngdle/rendering/daily-leaderboard-image.ts create mode 100644 src/modules/rngdle/rendering/overall-leaderboard-image.test.ts create mode 100644 src/modules/rngdle/rendering/overall-leaderboard-image.ts create mode 100644 src/modules/rngdle/rendering/profile-image.test.ts create mode 100644 src/modules/rngdle/rendering/profile-image.ts create mode 100644 src/modules/rngdle/rendering/server-stats-image.test.ts create mode 100644 src/modules/rngdle/rendering/server-stats-image.ts create mode 100644 src/modules/rngdle/rngdle.config.ts create mode 100644 src/modules/rngdle/rngdle.module.ts create mode 100644 src/modules/rngdle/rngdle.prisma create mode 100644 src/modules/rngdle/services/boards.test.ts create mode 100644 src/modules/rngdle/services/boards.ts create mode 100644 src/modules/rngdle/services/format.test.ts create mode 100644 src/modules/rngdle/services/format.ts create mode 100644 src/modules/rngdle/services/image-cache.service.test.ts create mode 100644 src/modules/rngdle/services/image-cache.service.ts create mode 100644 src/modules/rngdle/services/rngdle-api.test.ts create mode 100644 src/modules/rngdle/services/rngdle-api.ts create mode 100644 src/modules/rngdle/services/rngdle.service.test.ts create mode 100644 src/modules/rngdle/services/rngdle.service.ts create mode 100644 src/modules/rngdle/services/score-table.service.ts create mode 100644 src/modules/rngdle/services/score-table.test.ts create mode 100644 src/modules/rngdle/services/score-table.ts create mode 100644 src/modules/rngdle/services/stats.test.ts create mode 100644 src/modules/rngdle/services/stats.ts create mode 100644 src/modules/rngdle/services/sync.service.test.ts create mode 100644 src/modules/rngdle/services/sync.service.ts create mode 100644 src/modules/rngdle/tasks/daily-leaderboard.task.ts create mode 100644 src/modules/rngdle/tasks/score-table.task.ts create mode 100644 src/modules/rngdle/tasks/sync.task.ts create mode 100644 src/prisma/migrations/20261002211526_rngdle/migration.sql diff --git a/AGENTS.md b/AGENTS.md index d8284f88..273f8b35 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,7 @@ See [`docs/site/`](docs/site/README.md) for the full developer guide: | Event listeners | [docs/site/en/guide/listeners.md](docs/site/en/guide/listeners.md) | | Buttons / modals / selects | [docs/site/en/guide/interactions.md](docs/site/en/guide/interactions.md) | | Services | [docs/site/en/guide/services.md](docs/site/en/guide/services.md) | +| Scheduled tasks | [docs/site/en/guide/tasks.md](docs/site/en/guide/tasks.md) | | Prisma / database | [docs/site/en/guide/database.md](docs/site/en/guide/database.md) | | Functional behavior | [docs/functional.md](docs/functional.md) | diff --git a/docs/functional.md b/docs/functional.md index 9db61e17..0fb5c85d 100644 --- a/docs/functional.md +++ b/docs/functional.md @@ -70,6 +70,58 @@ Fixe le score d'un membre, ou le supprime. Fixer un score à 0 revient à le sup --- +## Module RNGdle + +Repris du bot DJ4H. Suit les tirages quotidiens des membres sur [rngdle.com](https://www.rngdle.com) : un administrateur lie chaque membre à son pseudo RNGdle, puis le bot télécharge ses tirages et en tire des classements, des profils et des statistiques. Toutes les données sont propres à chaque serveur : un même joueur peut être lié sur plusieurs serveurs sans que ses tirages se mélangent. + +### Paliers + +Chaque tirage est classé selon le percentile de son score parmi tous les tirages de rngdle.com : TRASH (moins de 1 %), COMMON (moins de 50 %), UNCOMMON (moins de 75 %), RARE (moins de 90 %), EPIC (moins de 95 %), ANOMALY (moins de 99 %) et MYTHIC. La table des percentiles est extraite du site au démarrage du bot puis chaque semaine ; si elle change, tous les tirages sont retéléchargés. Une table extraite qui ne ressemble pas à une table de percentiles est ignorée. + +### Synchronisation + +Les tirages sont téléchargés à 06:00 et 18:00 UTC, avant le classement quotidien, et au plus toutes les 5 minutes par serveur quand un joueur lance une commande `/rngdle`. Si rngdle.com est lent, la commande n'attend pas plus de 10 secondes et affiche les données déjà enregistrées. Un compte qui a déjà un tirage enregistré pour le jour en cours n'est pas re-téléchargé. Un pseudo qui n'existe plus sur rngdle.com est signalé dans les journaux et ignoré. + +### `/rngdle leaderboard` + +Image du classement des tirages du jour (UTC) : rang, avatar, pseudo, numéro tiré coloré selon son palier, score et placement en percentile. Limité aux 25 premiers. + +### `/rngdle profile [member] [username]` + +Image du profil d'un joueur : meilleur et pire tirage avec leur date, nombre de tirages, score moyen, record de badges, score total, rang au classement général du serveur et répartition par palier. Sans option, affiche son propre profil. L'option `username` propose les pseudos liés sur le serveur ; les deux options ne se combinent pas. + +### `/rngdle server-stats` + +Image des statistiques du serveur : meilleur et pire tirage de tous les temps, nombre de tirages, score moyen, score total et répartition par palier avec les trois joueurs les plus représentés dans chacun. + +### `/rngdle leaderboard-all [page]` + +Image du classement général (somme des scores), 10 joueurs par page, avec des boutons Précédent et Suivant. Si le membre qui consulte n'est pas sur la page, sa ligne est ajoutée en bas. Cliquer sur les boutons d'un message public ouvre une copie visible de soi seul, qu'on peut ensuite parcourir. + +### Classement quotidien + +Chaque jour à 01:00 UTC, le bot publie le classement de la veille dans le salon configuré, en mentionnant le ou les meilleurs joueurs. + +### `/rngdle-admin` + +**Permission requise :** Administrateur. Réponses visibles de l'administrateur seul. + +| Sous-commande | Effet | +| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `register ` | Lie un membre à un pseudo RNGdle, après avoir vérifié qu'il existe, et importe tous ses tirages. Un pseudo ne peut être lié qu'à un seul membre par serveur. Changer de pseudo supprime les tirages de l'ancien. | +| `delete ` | Délie le membre et supprime ses tirages sur ce serveur. | +| `show` | Liste les comptes liés sur le serveur. | +| `refresh [full]` | Télécharge tout de suite les nouveaux tirages du serveur ; avec `full`, retélécharge tout l'historique (au plus une fois tous les quarts d'heure). | +| `clear` | Supprime les tirages enregistrés sur ce serveur ; ils sont retéléchargés à la synchronisation suivante. | + +### Configuration — `/config rngdle` + +| Champ | Type | Description | +| -------------------- | ----- | ------------------------------------------------------------------------------ | +| `leaderboardChannel` | Salon | Salon du classement quotidien. Tant qu'il n'est pas défini, rien n'est publié. | + +--- + ## Module Thread Creator Crée automatiquement un fil de discussion sous chaque nouveau message dans un salon configuré. Remplace le bot Needle. diff --git a/docs/site/.vitepress/config.ts b/docs/site/.vitepress/config.ts index 6c2effe5..db952104 100644 --- a/docs/site/.vitepress/config.ts +++ b/docs/site/.vitepress/config.ts @@ -51,6 +51,7 @@ export default defineConfig({ { text: "Configuration", link: "/fr/guide/configuration" }, { text: "Localisation", link: "/fr/guide/localisation" }, { text: "Services", link: "/fr/guide/services" }, + { text: "Tâches planifiées", link: "/fr/guide/tasks" }, { text: "Base de données", link: "/fr/guide/database" }, { text: "Contribuer", link: "/fr/guide/contributing" }, ], @@ -106,6 +107,7 @@ export default defineConfig({ { text: "Configuration", link: "/en/guide/configuration" }, { text: "Localization", link: "/en/guide/localization" }, { text: "Services", link: "/en/guide/services" }, + { text: "Scheduled Tasks", link: "/en/guide/tasks" }, { text: "Database", link: "/en/guide/database" }, { text: "Contributing", link: "/en/guide/contributing" }, ], diff --git a/docs/site/en/guide/tasks.md b/docs/site/en/guide/tasks.md new file mode 100644 index 00000000..cb6b76b3 --- /dev/null +++ b/docs/site/en/guide/tasks.md @@ -0,0 +1,46 @@ +# Scheduled Tasks + +A module can run code on a schedule — a nightly report, a periodic sync with an external API — by declaring **tasks**. The core schedules them with [croner](https://github.com/Hexagon/croner) once the Discord client is ready, and stops them on shutdown. + +## Declaring a Task + +```typescript +// src/modules/my-module/tasks/cleanup.task.ts + +import { declareTask } from "#lib/task.js"; + +export default declareTask({ + id: "cleanup", + schedule: "0 3 * * *", // every day at 03:00 UTC + runOnStart: false, + async run(client) { + // client is the ready Discord client + }, +}); +``` + +| Field | Description | +| ------------ | ---------------------------------------------------------------------------------- | +| `id` | Identifier, unique within the module. The scheduler names the job `:`. | +| `schedule` | Cron expression, always evaluated in **UTC**. A 6-field expression adds seconds. | +| `runOnStart` | Also run once as soon as the bot starts. Optional, `false` by default. | +| `run` | The work to do. Receives the ready Discord client. | + +Register the task in the module's `onLoad`, like commands and listeners: + +```typescript +onLoad(_client, registry) { + registry.register(cleanupTask); +}, +``` + +## Behavior + +- **Global, not per guild.** A task runs once for the whole bot, whatever the number of guilds. To act on the guilds where the module is enabled, list them with `moduleService.getActivatedGuildIds(module.id)` and load each guild's configuration with `configService.getConfigForModuleIn(module, guildId)`. +- **No overlap.** If a run is still in progress when the next one is due, the next one is skipped. +- **Failures are contained.** An error thrown by `run` is logged with the task name; the task keeps its schedule and the bot keeps running. +- **Every run is logged** with its duration. + +::: warning Importing core services from a module +`#core/services/*` modules import the bot entry point, which is itself still loading modules when your module file is imported. Import them **lazily inside `run`** (`await import("#core/services/module.service.js")`) rather than at the top of a file imported by your module — see the `rngdle` module's daily leaderboard task for an example. +::: diff --git a/docs/site/fr/guide/tasks.md b/docs/site/fr/guide/tasks.md new file mode 100644 index 00000000..4147491d --- /dev/null +++ b/docs/site/fr/guide/tasks.md @@ -0,0 +1,46 @@ +# Tâches planifiées + +Un module peut exécuter du code à heure fixe (un rapport nocturne, une synchronisation périodique avec une API externe) en déclarant des **tâches**. Le cœur les planifie avec [croner](https://github.com/Hexagon/croner) dès que le client Discord est prêt, et les arrête à l'extinction. + +## Déclarer une tâche + +```typescript +// src/modules/my-module/tasks/cleanup.task.ts + +import { declareTask } from "#lib/task.js"; + +export default declareTask({ + id: "cleanup", + schedule: "0 3 * * *", // tous les jours à 03:00 UTC + runOnStart: false, + async run(client) { + // client est le client Discord prêt + }, +}); +``` + +| Champ | Description | +| ------------ | -------------------------------------------------------------------------------------------- | +| `id` | Identifiant, unique au sein du module. Le planificateur nomme la tâche `:`. | +| `schedule` | Expression cron, toujours évaluée en **UTC**. Une expression à 6 champs ajoute les secondes. | +| `runOnStart` | Exécute aussi la tâche une fois au démarrage du bot. Optionnel, `false` par défaut. | +| `run` | Le travail à faire. Reçoit le client Discord prêt. | + +Enregistrez la tâche dans le `onLoad` du module, comme les commandes et les écouteurs : + +```typescript +onLoad(_client, registry) { + registry.register(cleanupTask); +}, +``` + +## Comportement + +- **Globale, pas par serveur.** Une tâche s'exécute une seule fois pour tout le bot, quel que soit le nombre de serveurs. Pour agir sur les serveurs où le module est activé, listez-les avec `moduleService.getActivatedGuildIds(module.id)` et chargez la configuration de chacun avec `configService.getConfigForModuleIn(module, guildId)`. +- **Pas de chevauchement.** Si une exécution est encore en cours à l'échéance suivante, celle-ci est sautée. +- **Les erreurs sont contenues.** Une erreur levée par `run` est journalisée avec le nom de la tâche ; la tâche garde son planning et le bot continue de tourner. +- **Chaque exécution est journalisée** avec sa durée. + +::: warning Importer les services du cœur depuis un module +Les modules `#core/services/*` importent le point d'entrée du bot, qui est lui-même encore en train de charger les modules quand le fichier de votre module est importé. Importez-les **paresseusement dans `run`** (`await import("#core/services/module.service.js")`) plutôt qu'en tête d'un fichier importé par votre module — voir la tâche du classement quotidien du module `rngdle`. +::: diff --git a/docs/specs/rngdle-migration.md b/docs/specs/rngdle-migration.md new file mode 100644 index 00000000..546f0780 --- /dev/null +++ b/docs/specs/rngdle-migration.md @@ -0,0 +1,199 @@ +# Spec — Module RNGdle : portage depuis DJ4H + +> **Statut : implémenté.** Ce document a servi de contrat d'implémentation : il fige les signatures partagées entre les différentes parties du module. + +## Objectif + +Porter dans OmniBot la partie RNGdle du bot DJ4H (Python) : lien entre un membre Discord et son compte [rngdle.com](https://www.rngdle.com), synchronisation de ses tirages, classements, profil, statistiques du serveur et classement quotidien posté automatiquement. Après ce portage, DJ4H peut être arrêté. + +Source de référence : `DJ4H/commands/cogs/rngdle.py`, `DJ4H/utils/rngdle.py`, `DJ4H/utils/database/dao/rngdle.py`, `DJ4H/utils/tasks/rngdle_*.py`, `DJ4H/utils/image_generator.py`. + +## Arborescence + +``` +src/modules/rngdle/ +├── rngdle.module.ts +├── rngdle.config.ts # leaderboardChannel (CHANNEL) +├── rngdle.prisma +├── assets/ # polices, médailles, icônes, score-table.json +├── i18n/{en,fr}.json +├── commands/ +│ ├── rngdle.command.ts # leaderboard, profile, server-stats, leaderboard-all +│ └── rngdle-admin.command.ts # register, delete, show, refresh, clear +├── interactions/ +│ └── overall-page.button.ts # pagination de leaderboard-all +├── tasks/ # synchro, table des percentiles, classement quotidien +├── services/ +│ ├── score-table.ts # paliers, percentiles (pur) +│ ├── format.ts # formatage des nombres (pur) +│ ├── rngdle-api.ts # client rngdle.com +│ ├── stats.ts # agrégats profil / serveur (pur) +│ └── … # comptes, synchro, requêtes, cache d'images +└── rendering/ + ├── common.ts # polices, palette, médailles, icônes, boîte arrondie + ├── daily-leaderboard-image.ts + ├── profile-image.ts + ├── server-stats-image.ts + └── overall-leaderboard-image.ts +``` + +## Décisions + +- Les commandes gardent leurs noms (`/rngdle`, `/rngdle-admin`). `/rngdle-admin setleaderboard` est remplacée par le champ `leaderboardChannel` de `/config rngdle`. +- Les tirages sont dédupliqués par l'identifiant unique que l'API renvoie pour chaque tirage, et non plus par « date + numéro ». +- Toutes les données sont scopées par serveur, y compris `clear` et `refresh`, qui touchaient tous les serveurs dans DJ4H. +- La table des percentiles est stockée en base. Un instantané versionné (`assets/score-table.json`) sert de valeur par défaut, ce qui évite le plantage de DJ4H sur un déploiement neuf. +- Les tâches planifiées passent par le planificateur du cœur (`#lib/task.ts`, croner, UTC). +- La mention codée en dur « TnTube » du classement quotidien est supprimée. + +## Contrat des générateurs d'images + +Chaque générateur est un port fidèle de la classe Python correspondante : mêmes dimensions, positions, couleurs et tailles de police. Seuls les textes changent : ils sont passés en paramètre (`labels`) pour être traduits. Les générateurs ne font aucune requête réseau : les avatars et icônes arrivent déjà chargés (`Image | null`, `null` = disque gris). + +Ancres de texte Pillow : `anchor="lt"` → `fillTextAnchored(ctx, text, x, y)` ; texte sans ancre (défaut Pillow `la`) → `fillTextAnchored(ctx, text, x, y, "ascender")`. Pour l'ancre `rt`, poser `ctx.textAlign = "right"` avant l'appel. + +### `renderDailyLeaderboard` — `LeaderboardGenerator` avec `RNGdleLeaderboardUser` + +```ts +export interface DailyLeaderboardRow { + rank: number; + name: string; + avatar: Image | null; + number: number; + score: number; + percent: number; + tier: TierOrError; +} +export interface DailyLeaderboardLabels { + rank: string; + player: string; + number: string; + score: string; + placement: string; +} +export function renderDailyLeaderboard( + rows: DailyLeaderboardRow[], + labels: DailyLeaderboardLabels +): Promise; +``` + +Lignes placées selon leur index (et non leur rang). Colonne tirage : `formatSpaced(number)` complété à gauche à 7 caractères, police `MONO_FONT`, couleur du palier. Colonne score : `formatCompact(score)`. Colonne placement : `formatPercent(percent)` en couleur du palier, précédé de `arrow_up` si `percent > 50`, sinon `trash`. + +### `renderProfile` — `ProfileGenerator` + +```ts +export interface ProfileRoll { + number: number; + score: number; + tier: TierOrError; + dateText: string; +} +export interface ProfileImageData { + username: string; + avatar: Image | null; + serverRank: number; + totalPlayers: number; + best: ProfileRoll; + worst: ProfileRoll; + totalRolls: number; + averageScore: number; + averageTier: TierOrError; + maxBadges: number; + totalScore: number; + tierCounts: Record; +} +export interface ProfileLabels { + bestRoll: string; + worstRoll: string; + date: (date: string) => string; + totalRolls: string; + averageScore: string; + maxBadges: string; + maxBadgesValue: (count: number) => string; + overallScore: string; + tierBreakdown: string; +} +export function renderProfile( + data: ProfileImageData, + labels: ProfileLabels +): Promise; +``` + +### `renderServerStats` — `ServerStatGenerator` + +```ts +export interface ServerStatsRoll { + number: number; + score: number; + tier: TierOrError; + playerName: string; + avatar: Image | null; +} +export interface ServerStatsImageData { + icon: Image | null; + best: ServerStatsRoll; + worst: ServerStatsRoll; + totalRolls: number; + averageScore: number; + averageTier: TierOrError; + overallScore: number; + tierCounts: Record; + tierLeaders: Record; +} +export interface ServerStatsLabels { + title: string; + bestRoll: string; + worstRoll: string; + by: (name: string) => string; + totalRolls: string; + averageScore: string; + overallScore: string; + tierBreakdown: string; +} +export function renderServerStats( + data: ServerStatsImageData, + labels: ServerStatsLabels +): Promise; +``` + +`tierLeaders` : au plus 3 avatars par palier, du plus fréquent au moins fréquent ; le premier est dessiné par-dessus les autres. + +### `renderOverallLeaderboard` — `OverallLeaderboardGenerator` + +```ts +export interface OverallRow { + rank: number; + name: string; + avatar: Image | null; + totalScore: number; +} +export interface OverallLabels { + rank: string; + player: string; + overallScore: string; +} +export function renderOverallLeaderboard( + rows: OverallRow[], + labels: OverallLabels, + locale: string, + caller?: OverallRow +): Promise; +``` + +Score affiché : `` `${formatShort(totalScore, locale)} EP` ``. `caller` ajoute, sous un séparateur, la ligne du membre qui consulte quand il n'est pas sur la page. + +### Modules partagés (déjà écrits, ne pas modifier) + +- `services/score-table.ts` : `TIERS`, `Tier`, `TierOrError`, `TIER_COLORS`, `formatPercent`, `ScoreTable`. +- `services/format.ts` : `formatSpaced`, `formatCompact`, `formatShort`. +- `rendering/common.ts` : `FONT`, `MONO_FONT`, `PALETTE`, `registerFonts`, `font`, `loadMedals`, `loadIcon`, `fillRoundedBox`. +- `#lib/imaging.js` : `drawCircularImage`, `drawFittedText`, `fillTextAnchored`, `rgb`. + +## Mise en production + +1. Déployer OmniBot : le conteneur `migrate` crée les tables `RngdleAccount`, `RngdleRoll` et `RngdleScoreTable`. +2. Arrêter DJ4H, puis récupérer sa base : `docker cp :/app/data/dj4h.db .` +3. Générer le SQL d'import des comptes et des salons de classement : `pnpm exec tsx scripts/export-dj4h-rngdle.ts dj4h.db > rngdle.sql` +4. L'appliquer dans le conteneur PostgreSQL : `docker exec -i psql -U -d -v ON_ERROR_STOP=1 < rngdle.sql`. Le script est idempotent et fusionne le salon dans la configuration existante du serveur. +5. Redémarrer le bot, qui garde la configuration des serveurs en cache. +6. Activer le module avec `/modules`. Les tirages sont téléchargés à la synchronisation suivante, ou tout de suite avec `/rngdle-admin refresh`. diff --git a/package.json b/package.json index af86fcdd..71602a33 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "dependencies": { "@napi-rs/canvas": "^1.0.9", "@prisma/client": "^6.19.3", + "croner": "^10.0.1", "discord.js": "^14.27.0", "i18next": "^26.4.2", "pino": "^10.3.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20a3010d..6d9fe43e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@prisma/client': specifier: ^6.19.3 version: 6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3) + croner: + specifier: ^10.0.1 + version: 10.0.1 discord.js: specifier: ^14.27.0 version: 14.27.0 @@ -1548,6 +1551,10 @@ packages: typescript: optional: true + croner@10.0.1: + resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} + engines: {node: '>=18.0'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -3632,6 +3639,8 @@ snapshots: optionalDependencies: typescript: 6.0.3 + croner@10.0.1: {} + csstype@3.2.3: {} dateformat@4.6.3: {} diff --git a/scripts/export-dj4h-rngdle.ts b/scripts/export-dj4h-rngdle.ts new file mode 100644 index 00000000..cd9081dd --- /dev/null +++ b/scripts/export-dj4h-rngdle.ts @@ -0,0 +1,51 @@ +import { DatabaseSync } from "node:sqlite"; + +const [databasePath] = process.argv.slice(2); +if (!databasePath) { + console.error("Usage: tsx scripts/export-dj4h-rngdle.ts "); + process.exit(1); +} + +function literal(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +const database = new DatabaseSync(databasePath, { readOnly: true }); + +function readAll(sql: string): Row[] { + const statement = database.prepare(sql); + statement.setReadBigInts(true); + return statement.all() as Row[]; +} + +const accounts = readAll<{ + guild_id: bigint; + user_id: bigint; + rng_username: string; +}>("SELECT guild_id, user_id, rng_username FROM rngdleuser"); + +const channels = readAll<{ guild_id: bigint; leaderboard_channel_id: bigint }>( + "SELECT guild_id, leaderboard_channel_id FROM rngdleguildconfig WHERE leaderboard_channel_id IS NOT NULL" +); + +const lines = ["BEGIN;"]; + +for (const account of accounts) { + lines.push( + `INSERT INTO "RngdleAccount" ("guildId", "userId", "username") VALUES (${literal(String(account.guild_id))}, ${literal(String(account.user_id))}, ${literal(account.rng_username)}) ON CONFLICT ("guildId", "userId") DO UPDATE SET "username" = EXCLUDED."username";` + ); +} + +for (const channel of channels) { + const guildId = literal(String(channel.guild_id)); + const setting = `jsonb_build_object('leaderboardChannel', ${literal(String(channel.leaderboard_channel_id))})`; + lines.push( + `INSERT INTO "GuildConfiguration" ("guildId", "data") VALUES (${guildId}, jsonb_build_object('rngdle', ${setting})) ON CONFLICT ("guildId") DO UPDATE SET "data" = "GuildConfiguration"."data" || jsonb_build_object('rngdle', COALESCE("GuildConfiguration"."data" -> 'rngdle', '{}'::jsonb) || ${setting});` + ); +} + +lines.push("COMMIT;"); +console.log(lines.join("\n")); +console.error( + `Exported ${accounts.length} accounts and ${channels.length} leaderboard channels.` +); diff --git a/src/core/i18n/en.json b/src/core/i18n/en.json index 6c890db1..95115eb0 100644 --- a/src/core/i18n/en.json +++ b/src/core/i18n/en.json @@ -86,6 +86,9 @@ "modules.four-hour-game.name": "4h Game", "modules.four-hour-game.description": "Score a point when your message stays the last one in the game channel for the configured delay.", + "modules.rngdle.name": "RNGdle", + "modules.rngdle.description": "Tracks the members' rngdle.com rolls: leaderboards, profiles, server statistics and a daily leaderboard.", + "modules.test-config.name": "Test Config", "modules.test-config.description": "Development module declaring all configuration types, used to test the configuration UI.", diff --git a/src/core/i18n/fr.json b/src/core/i18n/fr.json index 6ba356a2..70f3d25a 100644 --- a/src/core/i18n/fr.json +++ b/src/core/i18n/fr.json @@ -86,6 +86,9 @@ "modules.four-hour-game.name": "Jeu des 4h", "modules.four-hour-game.description": "Marquez un point quand votre message reste le dernier du salon de jeu pendant le délai configuré.", + "modules.rngdle.name": "RNGdle", + "modules.rngdle.description": "Suit les tirages rngdle.com des membres : classements, profils, statistiques du serveur et classement quotidien.", + "modules.test-config.name": "Test Config", "modules.test-config.description": "Module de développement déclarant tous les types de configuration, pour tester l'UI de configuration.", diff --git a/src/core/loaders/task-scheduler.test.ts b/src/core/loaders/task-scheduler.test.ts new file mode 100644 index 00000000..40fa8a4b --- /dev/null +++ b/src/core/loaders/task-scheduler.test.ts @@ -0,0 +1,89 @@ +import type { Client } from "discord.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Module } from "#lib/module.js"; +import { Registry } from "#lib/registry.js"; +import { declareTask } from "#lib/task.js"; +import { startModuleTasks, stopAllTasks } from "./task-scheduler.js"; + +const client = {} as Client; + +function moduleWith(...tasks: ReturnType[]): Module { + const registry = new Registry(); + for (const task of tasks) { + registry.register(task); + } + return { id: "test-module", registry } as unknown as Module; +} + +afterEach(() => { + stopAllTasks(); + vi.useRealTimers(); +}); + +describe("task scheduler", () => { + it("runs a task flagged runOnStart immediately", async () => { + const run = vi.fn(async () => {}); + startModuleTasks( + client, + moduleWith( + declareTask({ + id: "boot", + schedule: "0 0 1 1 *", + runOnStart: true, + run, + }) + ) + ); + + await vi.waitFor(() => expect(run).toHaveBeenCalledWith(client)); + }); + + it("does not run other tasks before their schedule", async () => { + const run = vi.fn(async () => {}); + startModuleTasks( + client, + moduleWith(declareTask({ id: "yearly", schedule: "0 0 1 1 *", run })) + ); + + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(run).not.toHaveBeenCalled(); + }); + + it("runs a task on its schedule and keeps running after a failure", async () => { + const run = vi + .fn<() => Promise>() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue(undefined); + startModuleTasks( + client, + moduleWith( + declareTask({ id: "every-second", schedule: "* * * * * *", run }) + ) + ); + + await vi.waitFor(() => expect(run).toHaveBeenCalledTimes(2), { + timeout: 3_500, + }); + }); + + it("stops every task on shutdown", async () => { + const run = vi.fn(async () => {}); + startModuleTasks( + client, + moduleWith(declareTask({ id: "stopped", schedule: "* * * * * *", run })) + ); + + stopAllTasks(); + await new Promise((resolve) => setTimeout(resolve, 1_200)); + expect(run).not.toHaveBeenCalled(); + }); + + it("registers tasks through the module registry", () => { + const task = declareTask({ + id: "x", + schedule: "0 1 * * *", + run: async () => {}, + }); + expect(moduleWith(task).registry.tasks).toEqual([task]); + }); +}); diff --git a/src/core/loaders/task-scheduler.ts b/src/core/loaders/task-scheduler.ts new file mode 100644 index 00000000..5751ee16 --- /dev/null +++ b/src/core/loaders/task-scheduler.ts @@ -0,0 +1,52 @@ +import { Cron } from "croner"; +import type { Client } from "discord.js"; +import { loggerMaker } from "#lib/logger.js"; +import type { Module } from "#lib/module.js"; +import type { ScheduledTask } from "#lib/task.js"; + +const logger = loggerMaker("tasks"); + +const jobs: Cron[] = []; + +async function runTask( + client: Client, + name: string, + task: ScheduledTask +): Promise { + const startedAt = Date.now(); + logger.info(`Task started | task = ${name}`); + try { + await task.run(client); + logger.info( + `Task finished | task = ${name} | durationMs = ${Date.now() - startedAt}` + ); + } catch (err) { + logger.error({ err }, `Task failed | task = ${name}`); + } +} + +export function startModuleTasks(client: Client, module: Module): void { + for (const task of module.registry.tasks) { + const name = `${module.id}:${task.id}`; + const job = new Cron( + task.schedule, + { name, timezone: "UTC", protect: true }, + () => runTask(client, name, task) + ); + jobs.push(job); + logger.info( + `Task scheduled | task = ${name} | schedule = ${task.schedule} | next = ${job.nextRun()?.toISOString()}` + ); + + if (task.runOnStart) { + void job.trigger(); + } + } +} + +export function stopAllTasks(): void { + for (const job of jobs) { + job.stop(); + } + jobs.length = 0; +} diff --git a/src/index.ts b/src/index.ts index f3d1e629..ebfee1ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,10 @@ import { loadModuleI18n, loadModules, } from "./core/loaders/module-loader.js"; +import { + startModuleTasks, + stopAllTasks, +} from "./core/loaders/task-scheduler.js"; import prisma, { Prisma } from "./lib/database.js"; import { initI18n } from "./lib/i18n.js"; import logger from "./lib/logger.js"; @@ -66,6 +70,7 @@ client.once(Events.ClientReady, async (readyClient) => { continue; } loadModuleEvents(readyClient, module); + startModuleTasks(readyClient, module); } // Dropped from the shared list so that nothing it registered before @@ -111,6 +116,7 @@ const shutdown = async () => { shuttingDown = true; logger.info("Shutting down..."); + stopAllTasks(); try { await client.destroy(); await prisma.$disconnect(); diff --git a/src/lib/declared.ts b/src/lib/declared.ts index bbcda1cc..b141cad4 100644 --- a/src/lib/declared.ts +++ b/src/lib/declared.ts @@ -4,6 +4,7 @@ export enum DeclarationType { Listener = "listener", Service = "service", Interaction = "interaction", + Task = "task", } /** diff --git a/src/lib/imaging.ts b/src/lib/imaging.ts index 910eb5aa..1754ef0f 100644 --- a/src/lib/imaging.ts +++ b/src/lib/imaging.ts @@ -41,25 +41,31 @@ export function loadAsset(file: URL): Promise { return image; } -export async function fetchAvatar( - user: User, - size: 64 | 128 | 256 = 128 -): Promise { +export async function fetchImage(url: string | null): Promise { + if (!url) { + return null; + } try { - const response = await fetch( - user.displayAvatarURL({ extension: "png", size }), - { signal: AbortSignal.timeout(AVATAR_TIMEOUT_MS) } - ); + const response = await fetch(url, { + signal: AbortSignal.timeout(AVATAR_TIMEOUT_MS), + }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } return await loadImage(Buffer.from(await response.arrayBuffer())); } catch (err) { - logger.warn({ err }, `Could not fetch avatar | userId = ${user.id}`); + logger.warn({ err }, `Could not fetch image | url = ${url}`); return null; } } +export function fetchAvatar( + user: User, + size: 64 | 128 | 256 = 128 +): Promise { + return fetchImage(user.displayAvatarURL({ extension: "png", size })); +} + export function drawCircularImage( ctx: SKRSContext2D, image: Image | null, diff --git a/src/lib/registry.ts b/src/lib/registry.ts index 740cf773..4ed96ba2 100644 --- a/src/lib/registry.ts +++ b/src/lib/registry.ts @@ -7,6 +7,7 @@ import type { InteractionHandler, } from "./interaction.js"; import type { EventListener } from "./listener.js"; +import type { ScheduledTask } from "./task.js"; /** * Represents the declarations made by a module. @@ -32,6 +33,8 @@ export class Registry { InteractionHandler >[] = []; + private readonly _tasks: Declared[] = []; + /** * Retrieves all registered commands. * @@ -59,6 +62,10 @@ export class Registry { return [...this._interactionHandlers]; } + get tasks(): Declared[] { + return [...this._tasks]; + } + /** * Registers a handler (command, event listener, or interaction handler) with the module. * @param handler The handler to register. @@ -73,6 +80,7 @@ export class Registry { | InteractionHandler | EventListener | Command + | ScheduledTask > ) { switch (handler.type) { @@ -101,6 +109,9 @@ export class Registry { this._interactionHandlers.push(interaction); break; } + case DeclarationType.Task: + this._tasks.push(handler as Declared); + break; default: throw new Error(`Unknown declaration type | type = ${handler.type}`); } diff --git a/src/lib/task.ts b/src/lib/task.ts new file mode 100644 index 00000000..5190325e --- /dev/null +++ b/src/lib/task.ts @@ -0,0 +1,16 @@ +import type { Client } from "discord.js"; +import { DeclarationType, type Declared } from "./declared.js"; + +export interface ScheduledTask { + id: string; + schedule: string; + runOnStart?: boolean; + run: (client: Client) => Promise; +} + +export function declareTask(task: ScheduledTask): Declared { + return { + type: DeclarationType.Task, + ...task, + }; +} diff --git a/src/modules/rngdle/assets/arrow_up.png b/src/modules/rngdle/assets/arrow_up.png new file mode 100644 index 0000000000000000000000000000000000000000..2606e6e610dd025e1658febbdebe07bf43a78539 GIT binary patch literal 4168 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 literal 0 HcmV?d00001 diff --git a/src/modules/rngdle/assets/medal_bronze.png b/src/modules/rngdle/assets/medal_bronze.png new file mode 100644 index 0000000000000000000000000000000000000000..ceb8a44537bb18c10c34363e255f024a2f93274b GIT binary patch literal 1319 zcmeAS@N?(olHy`uVBq!ia0vp^u|OQf!3HF6l|)qoDb50q$YKTtZeb8+WSBKa0w~B> z9OUlAujXNZ5}#gS4+qApB2moO}c_FMRnYNHm&b0 zn;7R5I7R=4*)7*9PS?PF3AZ!EU$Ta{{%p!!6uU$`K=dj1w$8GbGEXby|4P)b&vy>k z(l6CEyN(YaR?>F7G zs*6y$qwuyQqN80z$8nX-dNmscwr0l#0{0Z0JW@7lx|b|vjQ{%k@0L#?4FWliPb)3^ zOO*F4OgL1(Z4H;Xf}Hk(lShxbu597II9cq5&a?%g)855cx&PQ&db3jC`z+qC+rEe> z+L zbD4Zm)%*faWQyaeiN4NtH~yDZ)S17}+@}8{?Wm1@R@y?V*ZZSmQY-c6wz9tt{xV^8 zqlNl!xn<6*#Xlt_xY6?1!_&B_VzS&q?K2YQ?%C3!w!hXqoS%DruDt7H`-j+^_pR{{kDKIowtKOVwVL%LOMsPYzEIRK6XOsrG(T=wbKglle67x-Jk|zopr0Ojpt9smFU literal 0 HcmV?d00001 diff --git a/src/modules/rngdle/assets/medal_gold.png b/src/modules/rngdle/assets/medal_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..647d394de268b4155b53c138165a0e1f2a90ad2b GIT binary patch literal 1190 zcmV;X1X=ruP)Jib?h1Mpj)r+1Q z&3~y$RO^rE*&=HZ)v86$jOM@8AgXmo^w|Zfk7{-O=rW2uCnIyXh5>fY@zy^7ewVN4 z8-Ais%e+-Y64Cwz=5d)La40k5UE#Gj+QuOW_bea0CEgz4Q`kp$aexGfi{^;kOx&gx z`~fVn@B)I!x6uxdkd~0ji~kMvNx@@6Ws@R`ylCP=5GB1)^>K)o5GKvd@EzayJofoD z4_W&7!R>Q`9!Zj>gKqH!%SZNZD#?MQ{pd2X2Y7*)-MY!_a_#yd5|TV&85Q*RQJw7M zk^?F-!wO}qkQ`8xf|s%#K1RDi5_ae|)}RD6{peAqRv;E$HuIZ>u7t%$eX_0UDY+Wa z6_h#KP=ua{cF~iU42sayblgN_{Ch6LDnXBEUqPGtTTk<$M>L&L^m)>DujhuSLbTuK z_IXzSnaU?6n_PKY;NM+9s9nq^gA(STfu9_C(^98l1n;omr^gCHVf&DKD&5i3nw2T< zwunAOHf4VhyucY=cT&|YBspb@J&f>!v3JXtVW*-)bPIbpnHBB^c`R}5EM?7+@Fl*Q z_KD-Tb3H|`6kWBBIb3JKU&?y*MUM5Z>?(f0=y43gFbu;m48t%C!>Apa2GBe!i~n&e zZhL&ZHR(a;lh83FEat$Ou@i9hZ-6Dbm$tx^zD!CL58ov|_N8(lWm1xg9*yP5P(4}d z>2YZz+QlDYFcsojcS4#ohlSEU)@tTtp#+#{AxyLoCR&Jp1891^UYiIH5V5<(pJH+3 z(arO`K*K3TKk4-4YSn@Rl@m$gMZX$(Ozm|KGFTA_c}7j(Cnr|5)&TUpbLrW<>xYvBEh)GbiY9gYFXybAqNfL4ifc0wJ-Q8%+8HO`m|w2|9g(_NEy|xkv?q`n z=@Q-iA|0O%j`BY#JAI{8hhGQszE-OZ2m}PuS?m(OGgE z4A@LLxiuato%VG&sRKEVM<5`rb7XRaAF~BQVx@ButU(6h>ZL&-AXfTtI5gc+ zatQ>)O0QHZugNgCx3?Pv66N%?y+JQDT4B`(sFBJ#Uz?(B3VVI^&P>1k$=k4UE&>D+R0C+(MB<*4lF z_oMRpV{bwcMW>GEl8Mr}<#IwJy-|r?5h>mHEU8$LFY{h_D6kPJXPVgrKP`#5m&L`! zR}T*l|46=_egM8lyykLlvXpalbmTgU{!&V2BN+T;Xno?7df#fbJhIHp^V9P{gjXE; zY*h9rS`Qw5y|ur;Z*@#|UIwNEIKoeJ6D}yVRINB4$ue^zouj-##iR<{@L1NE8|jXt zji(*r;2ZoavdCOjFGZo0Z?)-KO6j(NbPGVb1t8r5)k!-!D(H>7lqWne z?vQ>5=^^^*DIocy)Eh|u50{Do)r$=v-2#wq0Z6w1q+0;eEdc2jfOHE$x&nRQRzjk#py+N42Sv)6$A_JMfYi4%(-)7tv4p!_x%r407|KDbYH90 z>XE^R`Xt~rn|pspclw$NI(OY2lfJsT+Rav4oXXogf96s)%boaKcfROd z%3D02`|j@EhZi?e7QfA6cF26xc~WlDbG(|(W`F3@Z=~pi3?j1SXDwwXts~uSwYHU` j@;P@>JG-sD;N1TOk{P=Jr;hHP z2q}c9OW-0zzutZNj$Qo7X(5aVA>{FX!-tLBe*W)1LPVYvVrXi=k)wOATKefRb{Hpw z>y%+5qvPhUN}WvFPsnBB$Wc8;E!i<^0G}uFxz*%3$@2ytTQ)`rw>BJL@U-Ol^VHG! zz5(y<(`GN3x;yj8d?C_13o)g|^pxZ&t#;jChwnG=ed2T?>fPsaiO+m5BBswtU;N@L z@rQ-*2kYY5b0;SkAgws_JMS)e#_-)J__k@h&sFrxktPwe8mu9RiA48 zSyU>HnqT>=5OpFQD=H&=uicSG?X6O|>-Yvb3>L=7or11kN2pq03of5g>*S9q@^=C?YY|?J`367-$Hr@vBymII3%R2 z4||Mfk2iH$gG2+==q;q%b-sUq96#avc6|Swvn=GU%gR3by}R>0owI4ewioz{ri5W4 zf-p)%6UK@p!j7UJ;b1X}aEwrAJ4H+7mCLSSBbTR8^p7O+r>`8UE;5VuZn$y zZwksRJ`zU=Kc+sVI4Qm(JS)x<<_bzJ%0xNg4VuiXT|+(~ITJZSP9RimW1(#}2&{s? zW}=H2AV!Nv#X64pqIg|=AU+Xa(wv>-9Jxe(D38gnP2(k0fVi_5((54t?!vfbrnm-k$bx}0?R z&gHtRpKFY3XV>Yj>8>kXH@JS{`lahR*L>G9YbR@8>ssqp>m@f|w;~pD5p7%KA@x8}S9wnYu&q&W$&n}(= zJV$#j^?c6r70)+44|)FE)8={IE5xgn*C4MIUOTv{=&P`$J3{;&%Hhm`TWJ_yw4TiXx|yW3w#gz{@eGA?|I)VzU6gfoya=#>nyKx zrH;LBaNPlQQ|dlk_tCm*>u#yLyY6dsKdSp_UAtcczqWoI{rdRb>-Uh~biZYOhy3#V z3hRZ|Yf~?&UV6P(>%CL&qk5m#`=)+_`c3QKSARFhCM_i7KjGP!bBXV`*E0M<|zlppMSrqwmb8GW@ z&7+$SZoanp$IZ_+zux@kC`(kAsKrq`qCSZFD(Xtq&n-M#gtlncqIZjTTKv+oP0Nif z3tDw=HM7>(Z`~{kG98z$MlaG z9kV26cg(+Heu^oKDUYd$b&YKl8y=e&+cmat?9kXTvEyT>#;%Fo9D6cO^;pVda zq4JUqgo{fD-lvQ1c$5~x2m9|RmW$V=o2)Mz%Vsi4wwCcSNp_X}tUf`;bB|Dc81pr z_m8Uoi$UKcI8vyH5uLE%H>9TwkRh_UY$e;tMA=dHkOSp#IbKeYGvz5GNFV9A@ts9r z$(|gkdB{qRbUn0wXi#XdK2pb8N7@v&-FYN>LD`gJ{Rn^JuX1kX`O1@32`d*>P7xyS z0JKqYd4YL8d4ajPxd(DT$nBQfQN{f@_Qw%tdsxl7oJz#woJ+X;M%rYt{8|1gD-CzU z(`aCX8!e2kMsK69k!q+h1b@anW2y1Du|{tb8XFbH7X4+dz*u8!sQD<{$^^{{0 zmgp?r5%-9<8D+dD`p{qY76(Otddwl>Ut*XzCWg|#-7EerMvBkG2=S>HD^7`tB1=3V zzM@b5PD~Krie!;3riyc7He;7LVxF+kL+6W!#eeAYm(YKo5aYyY`K;V1x5=GyyWA{S z$d&Y}Yvnq*8f#uA*T~1@6L^91B1PngY2pWwDlW;vSbTqMagZD!ACU{>B6`?I5Qw_QaiW1(}~O8i9U zw!5fn40mpOhzO&NbK6U_lakM3!e?cV3y2-T_B6KC-f7tD3_eX~|C(HC;zse! z4BBoi`&6ZyFXn5jSNo)+j``>=n%8{Fl18d|#Lh>r^Vy!w`&?qD@%=E)U5)-!j`Q&! zefcJpw4s!CE_zGH!c>V;h%r;l=6y1sQ%SGNA4;iHc~2o#DCwu`v?K95gZVs+BdfHv zb5>_klS3Q68>9DDWm88F#e22Wgw`tA?Z>SuO)}mqnf+9~O(Im~T*PNpj!@#%aeH7} zYCD}e2_^pjq=pue>mo{(Mx89sXS9?YRhg#n-j4H`ODk5Tno7E4>Ng$BROdgHZ_Qdt zBc>h7Sc2agLF^P#sy3XfOZwZKt8yBJ_a2TKhLKM?`KdE(TdkzK*Ix{ZxQDz|E6(LL zn-+V3xOVgl?eJAe_|e+=g;wWh*1x%bRSE97Ep5%QtJGaPr(PUw9_MJDo2v0pju!*5 zY$>&=cB;KcC})?(UNiY-vQFPapXqes(zG&L{HEomIoo*wEnn3FO3^hnr1W%qOxtRO z&!>-(SgfabP%=yH-FNUP5i>hEJry6I=9hvIjG6};*tZM4l$GAfgVbKcy5E*U-Zh0! za`NnS@$%%!bLNTXb+~ED>>1O5CAoSCrmkyP;R&zcTy6cej5lU&W zrqMl`sWr}gu_=MM9Ml0z?2SzrhS*6%3`_fDG=GB~ZW^M+hu*kp2U2->Z z77;-IKe^Voo#??Yi!0))C=i9BNL&-eqJ&vssh$Pe#SL*&+!8;FU&ODXLR1O|^SXv| zA~`go58pr!zESt#o9WHB((i6(EV5URMc$$ZeTN?XJ<4>F9`dxTE630hYUM(qdYDo4 z+su`P3v+mbR2Ie&0m7XYwlaP>$p~kcI4Rv}$?8wUZV(^iM>%PwY$TiMxTv6}$>z>jwMtW0)+1%rzLnIInz5KUsrm_`JSv@wuJvuyh^nn!wNk0pR_7!c zdl_OkD+e|CsWmWl7HSl$&Q#@V=)QP7V=gr>iqQF+rB`XyzN&o#oat37o3Xd2QEg?> zs})kOBuV)cM^>pV9Bmi-{8g7HgCnaFsAH>d{>o={T=NW-cWFf#LiBk{YNI~i8O)7Z z6E=~p2!mxyj?|F3Sqt{6I){m(t=XP`*1l->Dhq2Qo61lbCc|YjTCcA3ll7SUHIV+y z$^vB&_NUs*JnL>KK((}L>&JLmu#aac^>$u9+8TYa$M>;|BfNr8=4r->EJui7atsnp zv=YOaxr|{vm?3%@--|kb7Rv?FJ|=n_Z;R01a9#AaScO01&E6J#p)rkgf5cGI`iaom z7-!kqO2gxGQazAkw?vAB7$Nha8 ztM6Raah~by50-egH?UosevFat9mQmnBL+JbRJzqLrV;4#z6%0gFisNCp?V)q^nJ5 zk!WAJ*O4bE-yJX)N@PCnLs@PY|8|HZk_fQLBN^CFa4XaggGvPEMD$=H4jb3OE8)FD1j zG*R1fCvihL*P&t{ghC^z1A)*KA|Og{zbEP&iUaVDs85gFK#mgrawu1r?k9hBZQ_WD zY$NWGjcF65ksuLg>Cfj7u2(b_?PV!-n9qv9el$0M@2_y&?V=glv1m?nJ{_p1$$WQ< zG>M!`BK0vw)Pu&HUk?b?`B#wUKOziTqs1_2&HOhA0%bGaoALfBD+)i0cuQYV&nOek zsjn#GG&B+|Ev-dcbR17T2GJvhQ0{vb#%Hwav-n^a>fk<+U_3xQoN!ba3q+FfB6DoY zYQ&3JCoy+ySA7mu@$!raH|{0ReIktYtKLrP)!J5Z`ZKkc1ouOWS`4eTT{~WFH-g5X zRxn%0Ya&VBC+?TM*aphqLY}O}7`DscONgQU`tk0IuT_15I#e zH}&YMHGN+jS(l!WQ7NtTh3a3KpS7cJk2ZT*R&7|7(|v72F;zCAR9~{XGfjL=FR#{^ z-ejF9o)L2b+G@gRpedRN6_>;>s@JAIn#l;(Cg`JB3z{ynSVN0qHLWFcxlXLGwU%vU zTV{Km#Z}o(9FozZ3pHcYD~0hgff4)+R_YR2mzXVP$|Tu=dG;LfA~VXqqLLxTT&b=E zU160`GD?~!?@_ZZ*;zcyI$>Al@&m;!*-dsAgJcip_1TP37c$#jpjRe)iy^X)7|L8> z7;B3Cd_QZ753$lXftl=sqJVMBL76Nbl9R-L zSS_8xy7g2!jg`_FtSZmaE2OEcLC#}6GL1FJbk?^Pvck2PHLj(sc0I~yZn-!rGWA;7 zWAbsvbx+9Etmf?z_0gg~W5Pg2I1SNfV@5Y?^lI9Za=jY)vDUd!J|j1=s;I7S*;#Sh zBDb;vw+#=lo%vWD)-yv`1$1GRFr3xFf3dpg!x*!k=*HMd&18D&+2nXJk(I&jtN|Wl zZu7oguY8SF#3QV|x-$28j`cuVC^v}EoEVf+k(^?F|oaR zyY-lpJUMM{s#}k_)8?k8%<}9tWo~-%DJzpllg|Xw7JRY);{`^XP@ft-TF9p z?PKn0W*Haf*{?c@OaDp9X~uwoZUdZ22h>g)pJ*MNJb6KSigmEg+sq)or*)_qJG548 zLVHV})M-A$rY}gHmYlX=&g|p`>2AZ!T&*L_^doAeZ=Yx#sbh7faj_lLdu*SMeXOHu zQpLsgu#VQ%;WPTSj7K|*JK8L+>*%x@sne`ub%tJJC(lTmykO4M*(r-X#!i`$l9n=m z#(eA8X=%v|Q@rl0`OhWsCX9mVFx#43yE*!}B|DFt>}-z7)q7c| z=z?2Q^qXf&^%rg_&V5p9S8iOq=d|i1F4I*z%$Vsm!@)WfIk@X|>X~PqNO}vCj7BXicyAHZHcOb%EIq z3vSDBfwQ;^ob9kcx5FZxk=LS{s`gk^-42V)c34vLrFDtf4omfJo=dB1#Vt9dom#H3 znr~hyI)RSzGHY3H*G;L(OGo&oR2@x!tW(nz&VBXq^tQLNZJZy>)6gHR)6BP7=2zMr zcYaT@n(VU!>2NJ;gi^cN#`Vy3KW-U%Hvbtnr$isdoLr zGIhdKmZ(`CcY3!ReA>G10knWBXF%P~pC2elI z^r==A!CU$w^~pVbdRmJ5>^61of;9a;W1;?j{*1-y`}z2$RJD;ZgWFhCg47v01Ff5w zo*kX~PfnepOXkvNLE2oMCN8$U zxlhmbZpmqBa~Ca`r{iNgCVA@5X{r#;s16=FYRcS2sm_>=X3XrAsa0tbb$0PF+S|m$ z$0T`ZDNLR_XOf2&m)dfs>FK2!Fg~VNJJt=IapqYO=looiUk?x6ytVdDFjK@j_lR?* zj5AF+KCXjjvbrBBZSK73DS@U3Z`X4~k6wfNjB2+qIjzU+^q5$G<>h}98#ra|?Agg_ zzlmoc%(%;w(bRC~nyc>}Yp&)>32Q2|QdkcSpOc(6OEj7@YtAe^Hk7PJFvuepbA5$X zDBs1FH2O+Ude*wctE(`Q^|R**r?ckfEyi%HfsBB*}gs)rt3IAjKM}-!(9&*FjLHM$9gYZ3zE8#xl3&NX(j5mF3kPECQ_#4ja|axqgF3HjG0Dn`H76<4x5#X4LWh=hAy!h zAL~_JL#-J}qrS9JlDBmpZ?k=8s(76$UZ+xbZ%J`jrSfT|$JI_PPLEiJ9BBS zzi+O;|3K&R0oxv8jr*6BZz*HJRBCz>X2UlY~C!yK19)I=Ndb>AA6&_u6sP1MF9(rEq(Z-{> zN2o_59X6;H`s!^nbl2O44t09>U)}9oC@$8axqU@%UvfY1{+;_7_ml3&-H+<+gF1Y# zR``bA-m62Mo}6A*B~qdLF8A$(n{;Sy>(HaO`+DM^a9>WiSchpkG-GC~ZJozdRW|pD z?)U3(lnyByF++9OUxz()Xr}C}w-eoC$hnmcBXnrSgt)U-?_N)b-a0g6%+Tu2LEU~< zp}XEz<#a1^E7D=U3OzdL?F%|Qr^75AeyKud`V%_l6SpJk7F8X-qr=yA_^J+HbbFp} zpVi?;9j?>i<2rm)oxR&a9nRC?OdU>f8}ByGZG;X7>#%RFu)E&wq{9Rqn(5o=?G`!= zcMB%x03FuVp&8?;w_U82)|=MrIxMIa+Vr*==IZUUDstI&hXZxkTZdhB*g=PJI&7oE=K5HnI&7pvGp2!+9>?mgLqmsV zj1FCYb;XWu3yWQ^xL$HSufy+jct(dOb$DEdM|F5mhh};+e9!d_a@(uJmvy*Hhud|y z$rTH7eL{!Jb+}lEX*!(kI+brH>hOLYj#8m&FV~@}eFX4RY+YD)6wrOA2p=SEp zZQBP0C4m<16j7hB7<~ZY3+tq~JQ`(BXB3wg=mf{7euBp7vs+;6qIJNzT+Lraz zs6*CQBMr70V{lalf6lC3`SS`r(x}kmkP1BlsnEGq=rmqxG$OrJ8tJ8T@X|TxUXv9n zmCutp&67IKlRC|lI?a}Mr}x$AebrcqZ&leAs#3AiruOw! z`?4O!w)m90u%)=B%Xv+g^RkY=rqleSaz~j#|_oT4b{gD)yECh#|>5K z4RvRjl%aaBrh2cY`rD?uJnFtb$(1ctlB4>FFGzJ~^81#rRj5lP+Q0OP&tZx8wA;<5WG!aeCizI!(DwQ?7F;*J;XinsS|{T&I~}DPgY( zmhC#+s6&TZ>8f^?=593 z9lzD`65Ct#5w}{@5x44cZq@0xs`QLQPy$z|lpY?|=^xhLKCE+lSm*YzS$@48t>dH3 zZ5=;QZ3}%ALe7Ygx+0_|2{{TYUFQflnW5T?^)J1b89Ga=YIv)z2R(9>TXlSYUH<;M z%s01nxLWVKTF0-}XR%c8wOW5WN1yu~%NEKo zM;~#HKH?m`*Brgq99_0K`nV_bzIxm&=a7TJJq_xa03p;f0bZ;XMzBt)o(Tx!?wlmi ziWTL4Y^l4#x-+7m#OkklCSZ(yCg1_qeOK~XJrgijUl&dn>Y0FL`a1Vx`k8?Bj4%&z z_r^!OUf|A!6Rb0eSHOCRh zJB}B4T_@%l_*L(t_f%WNTs7bC7CL@1W1V}I-7U@C<7&=gw-~=k!+1@%3J2Tj_6ftLd!`?C}D%yT`#i)$yw%i<;lT)(;}w@t)(j z2xXgEHA~ALL2Rqio9Bx4JHEn_gyVhqkuqgD9@Y8&DuNvOYOChYtPkhe679I?IIe2b z@ti}=_4S`_`%=pAvua`fxc;TzYXuHeV*WW^*6$6u4spkyqn0YFzN$V4vkeWdeVF&2 z`J!%bu81_{>V~>Iun|`>`tz;w5|P?V#Nj1U@gnoF)z0EEyhK;L#5S%oJWnk!mQ za#d-Fc#6B@M~Y{-PBdC`kg?i^NYN$T8uIJX7Fo6kl)E*|H8 zJazZHZ+@LTxlL8 z2MJ&9?jAy@>dQ~pm*o1{L##rnTJz;TzQ1s$2J3OEPq1)~-kiUxI}hE?F1npvsiS_J zLw{XUs$P6_y}0Un@znL=MZL@?Ufo?w`|&b#FX63g#8$nN($WRrM194d}zU)^YkFble2YajO)d=H|lVAp7}sIkn8K}%EIB1)vJkni$=j`gHHw@41TNnb&}7w zzm^9t4o(Z6-DnHx?tH0z4>o!tC^cwG&;V zH65%Sf)82}6Z;#v++q5S@v*VjI@LH!>&K#ldena))>%*6rW%DramBPH-!!-sr%-h*6Mpkt8bH5-)8NJws5ao z5LaB=%C_P~t-F`Fw%%U6!gci|@mKDC?95%FUAb=lD%Zz*iofx+OmD%xv|J&7P5ZCE zYdyZ9uXDep?eQHsQBD-^at(W`cu(8r2VAw?&C@GZ$tZ}sm5QW4?*Wct8OS@m3-?;q zcU+SG#02nd5MGF17Q%`gLxkULtz*Ur*|Ji z|2r1O!F`S_aX&l&55hw*9wxvmpEPf+t`#tbw(#4%Wj{umPTijqnU?g3Yi6 zw!*Wp&5fFS>4lx_pj9tX`g3>>{p6vn~TW;L>>mQPwM^sv5hipqYT?9!#2vWjWRS|hQ`a#co`ZmLx*MPunZlR zp~EtCScVSE&|w)mEJKH7=&%g^mErS7aNG<=ElvJZ-Z75i|1tkb)IVoV4dn8k2N&QX zT!IyNh>LiLi+G5Oc!-O5h>LiLi+Bj0xC0OH1TXLgUwyBPA6M`E^s63W7=%MJh=3Tz zvfX&^4n3eJ^n%{d2l_%k=nsQnxT658JBrmE#p;e?O`l**r)h;Ru%>shrgyQXcd@2- zv8H#irt4VKQLO1G)^wB}#h?3|{JC%3pB}@X*{45uF!|G0_%qk_r?>FuO#SIE{OK+H z8R__QH;%th&knAEwXhD>!&9&Uo`#L^3~YkUum!flv#^cU+s^xQumg6&`>-Dlzz6Ul z9E3w~7(Rj{kO?Q*{}h~t@0p7*%b(n42hp{umeRa)pO3M{6V^JfO`&$!#4=QRAe z@64b3vHZD%$X^sg30#K?sC1NJC1qGi8CFt;m6Ty6WwI_>_j44=dX8`Df&R%@T$M*M z7c#&CF5n7Qa07Sn08j7&Z}4Hx>`QCafx6%a^+4T!rC$CJ0D+)>3!x!2g2oUGO&|oC zLMVi>Z#XoA2#6#u3R*%dP*0U||1x*yH4yC}8e*6~#_}Eq@sI%RArX?G19Sv+CH5ZZ z3|*irbmJJ^p$GJYUeFu*Kwszw{b2xU2f`q>2lGAzhQcsd=*Y%*XXCrG@!i?@?reN_ zHoiL>-<^%`&c=6V7qyR-4#+4$~ke0Mg!I~(7f zjqlFJcW2|fv+>>8`0i}fb_3s?jqlFJcW2|fv&9?u;5XqdcpKh0r&tu zgoAJh4#P)q1pZ0dqmaq=$GrawK7nI!-0>YMy@5(^pwb)o`+WR;KK?!*f1i)P&&S{A z9~Q?Z=m!WDE$UXzk$+k&>!T}ALP>?2dn8rHyCSO@Fj zDcAr{!$x=pHo<1t0$br(*bdLZ4%i79*y8)V?}r2M0elDt;Sd~#kKhPo!ezJuSD^q3 zp$M))F_ge{sBqk*HE+@njX_IeVH}tv;Ua4095usp*DwcCVLsod!ve@)ChSG(gVf`f ze2PG+HpT;m_{R^`c$_i8HT>gAM(A0x6QeoC1?*>l1zf-ttl$Rj-~pcC1>O(?WANHz zVI0il^DLMRb08Jw6Q2$Xz>8y7;G2KNH~&f*N-0Buo+Si2ZpmoIDKWq}fmH#XCnR;5 zGj$oM7`Zrpkt28?NnSkHQ~hlzdF7JVB~q0$#wjIbIr)7;exJy$j_=4bm^_1JGUvUE z5s#I;ODKh%JuZ{Flzh*S?-|xA0L!MDIZx8%QU*ImxFH+p^x32@BE?Un_=){a zvLDaNv0n+XSBS;t^rx;INiy!@LZ#$6NaAdyC?rK8`(0qa3yc^Rl4=nwh9$5R9)U+; z87zktuo51FRqzC?hBdGj*1>vs3O2yguo0etO|TiZz*cw`wsGX`ygvs!U?;o}`{4k5 z03X6ZI0T2`BRB$?aFYE`!D+b6=PPg(3ZM{*;2IP|30#K?sN}3A7+?VxaD~x)*MXWS zq$Ucfi9%|kkeVo@CJL#ELTaLrnkb|u3aN=gYNC*uD5NF|sfj{rqL7*>q$Ucfi9%|k zkeVo@CJL#ELTaLrnkb|u3aN=gYNC*uD5Mq&sf9vnp^#cAq!tRPg+gkfkXk6D77D3_ zLTaIqS}3Fz3aN!cYN3!bFXYS%IrBo!ypS{J$x^rm#ZUs*p@Q}v%{$LIV$c8o`>PTE zlfU|&GUq}bT!4#k3I10<#^|5DdQ%7G)Im9|Tuv*O)5_(vayhMBPAiww%H_0jIjvky zE0@#C<+O4+tz1qkm($ASv~oGETuv*O)5_(vayhMBPAiww%H_0jIjvkyE0@#C<>nZc z_vc^-?1c9jTkq%n0DJ%+!a+C$hv6eQ0+~>I411Z+SKunJ`cJEu)9U54dO59LPOF#G z>gBY0`R|TVJM%|R9sU8Y z!#;Qe-h{W{ZFmRXh4)+O+403Jl0J-)=fOtO+403##?2K zx61J6hw!uK{(EK! zXHmtETr10EI}a|vMYsf)+2;yeg#svqBDe;{Py*MX0)B7q)|I|@1U=#b9QRZPR-e= zIXg9Hr{?U`oSmApQ*(A|&Q8tQu?ai1W~bKd)S4Z8xJk{~sX03}XQ$@u)SR7~vr}_+ zYR*p0*{L}@HD{;h)J(US+PXq*U8lB+sjXsatC-pD1Jcohu;6Im%gb{gHN1IkN{!ysDJQ)r8~Z z@uTAw-za^SF*+H+IAtWITTT7uQNMZAZyxoVNB!nezj@Se9`&0?{pL}>dDL$n^_wRi z$NpCF{sgRsHLw=e!FqTKHo()c5uSlfuoa8&Ma&0_m=6{)A1q=%Sj2p=NUv_3rR=}I!lCAbd9-;R zZJtM)=h5bQw0RzFo=2PK(dK!yc^)lz9v@YNk1E1PUByRT#YbJmM_t87UByRT#YbI5 z7kR&HO;%Lk2JYa28ayG|@eUe#M|NhUtwzR!)U_xW&D?Pe`goqJ9v0>cBhW}G8YxxR zF*wd5SPV;GDLev?!ZKJ6D_|u&2CLu+SPg4nEv$p}@Dyx-r(q*J1DjwoY=N!tENtU? z|90M=gB`FFOl_$4FQ)yAY5!u{znJzfru~a)|69Gr|B1fmC;FbBSP%HK?Vpbosuiw_*nB=V zpO4MwWApjgd_MN4+W#u;f0g#Yl%|cnq~Ro4{38)$g9w{^APd zEutLxJf9gaJE4)z+JBto+1C!T56^-2W6W-Fwm*|&0e;5Zuay0Yxys~^j?QxL#gFJn zee&XptKce^;LaMsbuOV-sVnjH5)9xO3dyxkt{w|kHUHPy=$~7=$p0VLF7lb(<}b+8_uf(`IAY=mcE6KsYpuoa$#ZM4I7-k*aVuoK>g{cr$2fDhpy9D>8}5gdU` zILZE};52;C`KTT&m-jrl02kpBTqgbsT!jKCgd(^G#ZUs*p#mzI<4Z8W0xsYRi9Ai; z6-A9EjlYO-EMRr~pL&I8GS?-h&~wdToHi5Yl42LvCo&x0pwH!$>TOiD1)cp3eXi$P z$R%`Lfj$qDrDPbyWi_-~z5-1vhX95AXyp@CG0HQC~c79jFU_P!H-8 z-vIm}00JQh8bTvz48hO@LZB&xLKypoLo>~&J$iY5xu#X(;9Bd>98_B^&awSIQjHA`Pf$u_LYNu5T@VfPc&XoI-Fpt(I*oeitAVRbgF&W6?5upS%M zW5arESdR_M*onezDBOm^Z7AG^!fhzrhQe(q+=jw!DBOm!Z7BI7O1_MeFQeqkDD;w3 zp_lHuemWP0%_m(tEP#cMOca`lLNiflCJN0&p_wQ&6NP4?&`cDXi9$0`XeR2+M4g$a zGZS@YqRvdznTa|xQD-LV%tW1;s529FW}?na)R~DoGf`(I>dZu)=TK)R>dZu)nW!_9 z@y=1ycNFy-wjPr+&U0!4iZU%?sp8oq&VAq%qMJNVvl3iV~9zK>DgDb#lg z^_@a}r%>N1)OQ^9Wum@J)R&3+GErY9>dQoZnW!%l^<|>IOw@On@y=0HcNEnfMRi9} z-BDC`6xAI?b(yH{9I88q>dv9MbExhdsym13PNF(~KMgz_M^W8T#yej!-uaSwc|P-U zHHPt|f5=6Rr%_=hD!haOZTy~r-)rL}SDAk5i&pVZR z4yFF~ewVq};e67i!va|7$UvzXC^ZA6W}wszl$wE3Gf-*JgNB1f?E9sXI_=21?C9sTn9W1Epr5)C`oGfl@P2Y6eO@f>Mv5 z)C`n*1f?E9sYg)i5tPb3+Z^{4oQ5w@#+UFFoPn?58~7HoARE4e@8K+d^+(E(%XS`I zfQxVmesb(bsTn9W1Epr5)C`oGfl@P2Y6eQpK&crhH3Ox-h*FQB)FUYM2ueMIQjeh2 zBPjIJgNB1f?ECsRvQ&XDIcvT1x!{ zrDiyldXD=LVjPd4lpNGyLmijtX|ov{U7%Jrb9OItc29C9FFMCWjPF#i65@x>2}FZjh^xZNHqu!jolp#pp0 z?p=5U9))GF99F)|Qb08hh4cm_7XX4nE-;aS)Y&%qAZ3Gc&x zH~=5Ohj0)M!D09ajzA`S&x}jW!k8k6(Qh%M-(p6;#f*N7QRk1S^9nY30d;wNwV_ZO3bmn78w$0d zP#X%hp->wNwV_ZO3bmn78w$0dP#X%hp->wNwV_ZO3bmn78w$1Iv5Qct4VBtZsSTCd zP^k@-+EA$tmD*6L4VBtZsSTCdP^k@-+EA$tKcm*gitxTgc;6ztuO07e$NSpxzIME? z9q(($``YoocD%10?`uchYE40{9IJaJ?zVPcgu-no+=jw!DBOm^Z7AG^!fhzrhQe(q z+=jw!c-JDlYZ2bH2=7{icP+xZ7U5m(cvm~#)sD~myn56*g6~GcXx2{E$g^tH*^|3` ztlZIK_=nP$;D-3{vFc{ST&lw8CU<}@WER2IG)Wi(dS7vf|;VhU9b08Jw^L;uj zV7%?TCe7Wy{;b*rQ7cy%i(la`5i2!ug*!#8+zTchpV2dXM$hmWJ;P`844=_6d`8dk z89l>i^bDWTGkiwR@EJYBXUvpu;TLW(E5607_!hI`Tg-}YF)O~stoRnQ;#6Euj^(hBnX^+CenLI5y!2ZZQkK#Vq(1v*26If^RVkzQru~7PH`6 z%z|$*3%%fBawZK8M3gSv#bp_W^K4Jc2j`e++uCGvHT15=uPXKrXN>c z@1j$O=kah{HNIdJqQT&X8t-Ok*y^5v1ilx1ZrM-$y4xp0*=;Q!8 z`Tzdv{x-&e+c|FSJCgpFyOB<^|0y^P<`q756{#FQeYY!ofBMcPeDq~}^ksbXWqkBy zeDq~}^ksB*leN05+;Q|e8q3EU9l{$O;%dWweDr>_b`!63Nd3nqdddH7?XC#b978q7 zP|Yz^a}3oS!%G$6rMQa}0w55ApdmDZ#t;lmAOxC1D1@$ALN|`l9eO}d=mou@ z5A=n8&>senb|4I5dob@qU?>cO;jFXOzQ6cB6n{TF01v`LFdinrL`a57FqzfTDb)82 z=0Y>^?z3Pv%z;#x%Xhckhs<#n!D3hfOW_fC6qdnqSOF{HF<1ppz-m|nYhfL%ho@iz zJPjM+8Q27yVGC@9XJMP;Ta=!K(zEc2S$M@PykZt!F$=Glg;&hND`w#pv+#;pc*QKd zVisO83$K`kS3J+?NUih_3I0Ik9H}EZFK{k8`-#gx9^l}QTIEPid z$=Li&#^!G_Hh+_``I~s!EIe%%o;C|ln}w&%!qaBqX|wROS$NtkJZ%iMe1IbHOsK5sOvR1@xJOmpA6R{t}^<4b*C_QU13cZ@V^)EzZdYo7x2Fq@V^)EzZdYo z7x2Fq@V^)EzZaPKc{20!Waj6|%+HgVpC>avPiB6e%=|o=`FW!DTxNcr%=|o=`FS$) z^JM1d$;{7_nV+O3%tRX8mj|!!4K*| z7=%MJhyXR??#6p}=m9;U7xacc&=>kae;5SAnZJ!;L@^e|>DfR&wsVs+xk4{-gPk`{{d|VHexkHr`_9zK>@kIr%M5z8 zKe|g53)08QBNug5<2rk&`zE;Wg5Pv@mJZv9;oIR{MV-tjU-#OXGl5K6yRbPG_My?c}i*wcS`>Nwt^UMg( z>6yo{U`ZUK{_RJx1iCegN(zj+?Py7HTgGjPJDy(asFwacH*fB_R8}A5)9RzFQb(zd z-&-BOjOUqa;%iE~f~QSt;x|^OU&q}nI^NOJxMX}>9lt>y;~91JKS|YDZ>x@9!_z6t zs^fQ8$FJr2O`b?p=`Dw-S)OHPp6JK?u%SM8|7N;=1F7RMzj!|j@6xRAiWk0ik#joA zX_cXSB4w}2k0kB8w&K|5D_4HLZk?(P8MSJaY_N8%YPsG76Uje<=Sju<_VICv?Gu_e zZ`RDYr8fcs{2N%^lJuLknL6CzSKB27wQlu(5uHNALuN)NQlt7PT3^0SeO;tR`BgV{baWKPocRxqkN&l; z{-7LDPNn^q@>ssER$eH|;OuE>vs_Q{AJ>#J%yKE^neoec-d&Yfwa^(_D3CP(vxRD! zD1_SbHFT4_@n-jl3GF9ze{cWrwJAwS)LG@l-Rt8g_3AY-;mP<3j}0C2`1k~~1uUpY z80x>nTF8^%2^xur>Z=yNYtH_1W#!6!cgtSBu(ut16etiIp$P<*KIFt*f>)+E?$d^~f{I+TXoO`?@;g88J9LFyGF{#A@eU z<=<{Q!n9ict>#GbX=jyGA5r-@^N6ch#a6z_EQwN@8NZBY$L|)uoL{A=iC4bQOuvFB z@@nFh?=$1qu~M$%QTTn@_nGk?=Zeg*Rj#l zE04+f54_~nVKP4BiOESlJI!j-CTVido>MwR-`Bmb>*~N2`^Ht-q1~eTAN&*Tw3=V2 zP-hidIp3fp)$!}q9YQJ|e`2?&+BDNI=f4iB{z}#IbGM(Za-F79WYC^UFZq-@TYbh+ z>(;5>m_L3ApjL0Jbdm3jeX6|hq#Mk=|ODQ7T@l5g+HvT`ocnwv(=^MUK?xYsN2g zm@4Vc-aplfS8KcUv|-{Od($ z$z2>R#V=Y->UdVU;hfTiFVEKGaGpHTGB&(<6rGdS0a>7e>|T?b&8QdKG^9Ap2 zV%wxNY}7Einf$skaaekHr%!!{z11(MiDb!urM=Wue68|=^#8c>Q`xg(%Wi%ee?;Zx z?v?y9lv#%;fW4@ZSW4n1*YlJq4W?VVk+apRZMXOC2>FoOas7Iw5~YSlx)$Q~zAJg+ zRojY+*Lj-pkMo;aI-Xkm8R_pD$Vn@2ktkbMW_O{_A1}xM zW7R*X9er@E2}>aR=+PZxGf8^CKua@Al3(w6GN7aMe|UHB`_tcRYndTNKCbof<6%R!)qPFGjUOYEM^uZusx3xVrCY zqdk4S^St-y^Jbu^)Wz?ZsJnqxee9ukFmt6px;@`&k5@bYm$b(_#)$6r<5YJ8>Uh)} zrKOF`)Fw~c6xr5;$ON=Lmg!nEB`~J{_UQ}>~N!WPub3B z7Oq@Sh#|KPUprNeQ-(XoE5o}@NC@dLBwD>C4fXE8IAth4Cu#EPp+iPcH6-bKYLD>UO*Re2*Fue^>KuYO}posXGb`D!zM z1;5{+>f4OJq0gr!R|jg1eC~cu76#OT@CWBMa>$6~$?!+#Xgu2PME|#-x{RvdFw3}( z{{x{sW@E=?i#NZ2Vxd1_Y}O-CM!8mx5AR+|94*x2rHY3txM_HA!Y3_#KZu;#v_0>iWe4ujw#5)Yl?3EduVF!9Vt!s)IPt82X+Dz3`yd_B<-WDAn7rlLN z-?6l%_)!uG7 z-p)K~GyL{%=2tbxt@3?$KmMmqC-}SNxYHYQw&>X`&wBpriRwM;G8%|tRMkqUxT^PT zpU}>=eL`Yl^;}H(HdjVfGHC3wgsI^plj4#h?&;U}o``Pq1`K?p#njLqEnCKzF`K+P zc1nqA9TD0jqES%O=zfVk#>XbKO$iHa+N5zprn-fs)qEDbqx)7*3fop$Z*Dg zN{QnBRGF%$W`Ck=ZJZ0@fgujiL5^!V2C3;!iF z2X7m$dYK-qpt#kidfPh(Cu(9H&O};Qv6@p`EpZ`6tba^9i}o4x%2dC6;N2BZzBVSh zXJGvSEh7_~Hod21{g%Fcg5v`c$Hez}WOeMsq0v!Y2lQ>&en8~Tp&R9^A4Y8*Te;7> z&g;S9jbcK>qZ_)qj&=8nf2do(S)GmC`a$)kT0K0hEqk@7>?$u0nB9fiZh(2u!V2`* zi=J1HN=tTEt}(_MA5`==IzIU%-|1ASR;4n>4mw?xKF#zmR!u{k`^jgjXSxljCyqk> zjnQqrp8xBk)YlR9so(3Q*GGNL+jIP?alG1O6hCq7>Op%Vx;1anx!In9Pk0SnHA=o- zIjB|Fu&^$zp59Got>monRyh|OPWVt z=Nz$gbi}LLtB&8m|H|^LPG43XzlL9vQ@__*m$p7gZ8OSFmhttQ)sL4lw6b{b7k$pW zT3LwuI%eEsuFEi%wtP=Y)z%iBQl(~`7;h=Nxa-v}FF*2f=ZwExeB_l*udrL5(Nxn_ zy>ooMw{DFoW^3^LoT^1LUe&r8zlz`Ns)<*%ZpJU;SH)`LRjr%xD`XGO!(9J6tj|NQ zQzly&$8s)VGSE^#s(!rTa;$!3@u83F%eoaKrR$Q!)7X09>lvGyV+CT{JM z%;AGuh@5<+a@R-G_^R2SyJMs^7b~Lvir}p}Sc|3J?=JA&6m0B0&H(Va1eydhA^qfeIfa>4oX%_CO z?QbgnUF~_y9Fm+lG*acS@=?-{zFLj1qRfsez+X>|wJVw^TSZ4UZ0_GVc65i1V`3_s z@2twP;@{H0d9;62!zLZaL`RQIs{Bk3NK6lBuP$lzQIr;RmJ8a{%EP%&ki%{?;XifO zW#z27)B2r-3H0|hm>cL7R6Pg`2ncMS6xAMO$zC(MbeYjBc37On5<9$JZ0E+Yp`o!& zg5%Z%25$H4nKocRTF>UaTAyj%tNHpKap4I~nk0mWC4{V8wQ%90O5M8pxW+Hk_Cjat zKCOErGyW@Qyj8C5uT~PwSbjA~m$KcTEoYS${(E^RJAM8imfI+(S&ed*sJfi1`YX+v zu_vozmnGH2)|~4K?i#I$f2TV2x_)MCOCw1ZJ7eAW?{h&lsVza(@oV@cs+wFft8-b~ z-i$4`Y?URfkZh2oW7MzenHe|jeUFhvO$Mr!5asQo+Upf--GxV@6}LeDq^iYJXS>Ux zu_F>(ToOja_L8ry7nPa~^x=fZ`nR)!>m znQc#q?^eGgazU1G*O%GL+%`I^TK&>1R#ur6{XHs%{i%M{G%CPz)>Ur=(yFxu-`xa z5T_scopKmAs>cffj+W|dX%}7p@~PRXUCh!bg_xyWj&D`%Vk$?qOK4TQI7hDmPCLBK z5M7&LiGK0YRkuf}MED0k5!(xJ~EKvq;4{WrvM$@NS_i4EvGYpRpp;?^9D5t4o zH)I48iQn+2hV)egMln@BAd0S2E5lqRsG1K31X!XX17*_zi~4q-*`s-MQ{Q`jc>CYo z`Yaq|s-kIJaB$q7aQSGMSI>w0^q$eBezPD~nNa!TgZKBD+w;kggizI}@nN9}A#&1~ z4P#8(vA7snF3tHhHPwEP%U`v>S2Z&Z#VY?6!5x9jHt0gsNUN%hrk}c7{n+KC%6)%O zMMm(NH5F&8eb(>Rlh&_M7yaI@BYio^wSA;p)e4FpA8Q#mANEM!J_`r-Pwi%LspLOb z_LM=<;SuedH0>A_(J5e5#8j^y=>t%6*VKMKP3rTXEZek7Y7*ADb?Yu+4I7SX)ymY1 zQCF*)U!~TnUQ(rM;?-}SwY^Q%zh9baasOeddRJ-uznAVdt(U9PplVf0t6Zgh5Lzcz zY2A!n7U8sjTCr~YHZT3{UCwlcK%CX_%1fHLtcy2e<5gQYWAT#w)*ipl&UHm0FBy5* z(f`ximjJd^U1{q+*;c$S+GShTE?KfI*|H>C^1j7O5<8pi*om{+AuECGA&DUg0YaeA zLcpvo4GaurfYNDchYl@khoK8YTWIOZED-3*GL#a({NK6nX|dx#I{*BCz_NAq?mg$8 zd+xbsyGJ;;cajB?VgPc5e^+QtTTNfdnk-{snL|9wZW0P&t0UIZHCfq?vgvo(h9gJ7 zYdUz+if}Hf!E?2o!m?L3Bprd!>)oHhgDh=ezkSTqT2~Q%rGMd~49`o_UY>A6#w!|+-_znm|xEDY!6d}V{ zv371vitY6~j4oX)FbEyW4;|_}&FQDpUaU3^NMm8$k)N!fIEDHGBd*iV8j{No6z83# zlF`86#{h=@iSP;w+a3cjK=NcI9ue8&L65!CLtKlUk47MXk@EwVm%jsrAtH)cO(n zy^}J+7{bOvX&)k!OrHYZO`);vFm2TZ)*tBXE$Z zdBkMf-#_&5%jvI(+Nrbeh#xnLZwj=IpidaqKYD-*|BmQ`XbE{q*#`-BW4RJG8Ny!EkVKKzcJQL`LiD zhVX5IqKTC_%hficDY~?>YDvBLFY^1GrWMuxp+@mlnVJQFeK9++W<_0iDOocq8z#7{ z2IETnO)DdvcaO?+JA~g-q=?Qtf&mNa0{cC2#>x9W0qqT0$Da#oT&e9Cv=9vfK~;$UYh@0Py(U0wSYEZ9e{R?^q47ShkM+jn($U*6uf zv%7m&`}L(E=!V=-3zgEGISei*{!w0u!-@0Q%ViC<`s_3$NjB_IkJY$39M4BJlnNt~!gQ&gH1btGS!~tXqtxqIA$2bGc(y zYt)U`1eJ-iBuuVx$#mn^n@l&^h9iy(3Ng-cb6RhRUuaeOLiKD0n8SJ-w@TJqbxCne z7EJZ;`?@@m4*aa}#GyTV#ILi33G)rsg2eBD4rz1&+mWIt7YRLXI|6$3Ea=KFi(iny zld%%_9AHfhnES=hPE{+zQ!VO|0eeU@ znq^kID!I_iu30{&)$PpA@sm`6%zu*aB%H|DF;}(|HQl@w_eSAUG|n|{T$s`Lqy2pR zQjM$fK3O}T1&4h#VR_;#VZawWCBdV!gdtb=1AL$R2Xs2%yi5s`q{)Vql(I%)k~FaT zTfU|9jYEbucX6pb+z_?}hGR{etV8@p*@f zb@_SuMR^)qebxMB#LFBmpZyEc>^V)P*53geI5958?SnqKo^0Z@Ry`VClmHM z=mV-rcwXPeu?H=at#s~>Viq{}&wYfw@avhjK{DY?&ONhbXr(X+^gV8wxuwt`TkNXU z1~YXqX_)Q-8LHz+{u&_R&s#BeX;L-uFrS+J*O#s*|vi{-U%j7}BW3^vbH zbRs*lwwam4w~Y>s&&3SlTPggw`S{iTc3Vno8^V+KfgG%_e(?DANaPGX&%>zzh=ejzWM69$BA>( zAl-}PF%&~myhceW+b@6b{>jE8_y6eq&#xU6*xN`cI4T-gu#v{ zrIrMX<|M&7JU|$rRdu&xCQSqpaTLZFU$<%X~gg1%kz? z@D8Xz{#eO##c_ZpZ=oFR6y0@hEaGHgpMg!IEc8DOtGL4|i5+nWVHfvUNw^U;rB=yf zHM2G8v69+}bEFx@JyudXNd{>Y$z!F5WB~0GPoKoMKj3@3aCgRoFWs(yAFmNNe)w64 z1DRF;`JhMxl;ST*cPFW#c7Bdf#65A)aQ`{BoBqGsw=^0}+cL9iDg(79zquaA#OfGa zT%naQ)-e%n*xaVKY1DQ-UN!cwtk^O9dSH2^qOQ0rOP6nURF)T)>qDdUvC-P#U?j3E zu!Kcc#u~>$>KF4_|mgTiY5_ zjcHA5$GXT=xx-OD<-rRxHLs1-N2B#PijerHF0ZUC7k?lB4SDLt-u?t|=-!14Cl!D< zwyCr0lIT>q%U#Y2+bLzgL;NGl^Hx`T#rLqgcz@2>AIY438GZ|LJAI!;@!TWFnK>20 zFXHOld5}`eC_UL(70t;AET-wB~6-nmc9t|YGPoa%)Uu^OO22XSOLT>zq=ulv)R8wfo zT2;~|-pNj9R}IG4Ct{k(l$||hv9aw(j{*XX^k?N#%v0nPKcYCpH5Srz0U^^y@$An7T8S1?~r}Dk^L?pYQpp3%hKt zpF>?`mvSD((#_%)qVK#atF?-LrvJhhH;?F(_s10a^d#srj%pTnDZZk3;(tgf3M1&no(lxP0gWIyZ5c>3%Q6STi`irx2F z0X_DD6m68l-Juvg7A(+nfKZi<>>m8AN9w zMFeuOk`ypQYI7;do zK@lF{9tXP5$)WHC;e*m{ChrwSY8CIOtN#&?Vlk<=_@Z>CD9In8EaRA}V6F2p<~*Ot zks-V|^HioWWGO!bMZyi^3SXDKA-G*a6o?;oe5i-$oJ;n{U%VH)jr9q*%Z^B(6;YR@ zkkM=T!9WStIC?bkY^W(ui^I|4@iwHT#k3}i#b~lx`}6ZB*lq64ii%E8mF;F*)k<5j z$!0SZ+XfeP&+iuBAU!Rc-O82#QaOOHL%93uf|ib zP6Z7xF;LFWUe<)$u|zwSDI9zocuqpq#09ceFN7>D7Ct25xCxgsLPYy*QD!N&x zn(jH^9iKBF`Jt~o_JsI5_Tm-U!N7Ql-4w7|{U$jBxut7s16wxq5_|cctB0xpU5nZE zaT?4+{hp+cKm|tAzWwm;AE*_CGk1K*zApxchbynXoBgBMM;w*|tkvjemf}Xq=w%Y? zWJZ>fTs>~LRV(&PdMDI9K8i2YI~)zhT)nr(wrESF^WZBTwi}(xwCn~6>*AozUQ?`A zZz$5|v}|ta+TO(4n^~Xuk~2H1*aw z1$(Pj*gSm-Tr*rr6e4whNpXdd)j$)ktUxzTRlGRB>y$->`9*M(IVn1T zu1Z-r%Ua*|t7vYS-`%pM8RYEU)7kW5oTjCvH7t|{r!bN%yLPm-UfMM>D*lSCH3uzL zI<>8qAVj)AT*rn%Q@VEs`4)lN2@k-xtp`3@{%=lUXFo1;+_&o4V|X$h6z#canc!J+ zY~g~VOO_s6u;9otZ_Hwic|9?UrQy?Lf)dr$6hb~#>aI{ffgAN!_w zhW+T^XNw4MGJLj)9O&6HBL6=ge&_xmn|^B!837-X-~!&VjF=1WG}jVvwQXsczqijqNq$HX1wWIF7#-y@!f3mYjDS5UlyeSBl@X^tzT?m0?}YTw z3>o1DhVK6*8R1^ItJ%}-7s{jN*v7WDjZt%*R~hK`H1AwgR$W|NT_(Nqw3I-AWJGWv zoH<)YWQGTVpDrU1trz^CDkEmcZg>AC3X$t--P+Q%ueWbsXY)%ePE@lQ7k$(8j-pQ! z5&~*VkY@VV}OYvbD!hvi!O=-(+;{?MEKP6eY;5|qo9E@L@`^P2cBj2HS{gF;*ELzjC>M!vx?P?D;iQ&gI_F~* zRB<1@3MeKg$Pw8;zI)@&QdW3$>)g=AqCfnhvC%r#@I!=W=5dHqGDIspNIq1aB#%Lo z5(zmX(?Plh1vUMM|M5Jv~xg*L6?h z@@2uXMr$hS(977U;(L4tVYQda>$QC6*B)h~aY{q#YVocXnx~es-IiW(HBFz-%qS|% zGZpq?`C597#e>(a`4*d+d!e9>;al#j0Nco=7HPC`&!eRE%|W9td>Sp-@(a(Jv;>+( zhb%21TbHKNTbrcgkCRKwP$3TACWo9W*aOhJ66t5B+_klCPcWDrc6mY}kBdU~@NKVF z{1MN>Up5qp!0VpU0eO-=l`2hcX&~fwMskW4@4GfPUmb9`>mr^4uk99Wbo9r!*|DDj z7WCj$XsN!Tn`OwJ*%&ZS5*Gp0?^g4%ipZ9m1DjL_VogugJWXy1$1JyKpb+!&xP!r2 z7_xs(j1Lbh=%UP3{6m_X`B@%(sQ4`)ZVI2WAFl-eL&{H+PPC=CLzOy*WybrBQ5kMP|s0oeK7B^ZL5L z@^DS&zIi@pmBy}@z>k9V#9OZiN1~CXk?fjPcY16(Yf*(`G}bE=BxZ|MP%wN4IQv1G z3RS$GXBOf?#a~!C!*^z5_A}JUjEu(|R6fXIr`Nw&>TK3JEB?3DS%`NnEDT-iH&lIR zsaxfyzz||HsDP#aF}tGfb9;w|0^3*Ip}E=5!gsIORy{nt|K|Eb>_FgF=#rF5uv3;H zQbZTpIvGys_|0pawCp~#N8Bj*#oXNocb^il>8G1s+O`cvv!HR_xpgaHfU39bknlb@ zhSprE{P5}zBTDbtpU(aavEIL=eil|d#cpS9)Ne0fhS|EOpeYh1zAFfm|cMl%q z?av@;)R1Tw-hbx60kkX5tz{phLMg^sj&az>_wL)rDRb^B<_2EGi;_7Z$J+F-)(?2S z1NF;VIyzb=t52LB899BTdU4a&ckTQ}Gxf|zPo3No%m>Nuj!-^FM#g&HXBqE5Inex# zox8r?v{)X2dKR7gA`4^pNH%ID5|(RHx!taCFsKDf>Gisd3{}W1s8ks`G<`g@VqUgV zTY`UDWp?JsP@~IRk~S|bKNtuE^V8-ji@mNjBP09EOxsOmyH||vu-VspqploRmNV*| zu-SJ2YB8WT%TRdjKMIV6f2WZ(n`Fp#-WkSR7}?Fu-hiQQD+u_ zMZN3owjHA@c5^iLkBorYt$^CdB?Y$vhHwS}xR&&W)+?Hxe){P(%JA8jcI*&d*s+78 zxCPIb%>DeAt~fjEdF&BcD&t-)a;v5H>=j5Z6{_21PrnISWo0+Br@;F~WQi@MjCLvA z+Ro*7B$9r9(;Hl5mikGULu9fF2zPBxLbwaxAnERR#-)4t%(sB)V~5!b!pjoO*7Hvb z=lM-6XQrGMe?0RD>`9Sc_*Cj)A7Nq4T^5|HoFfQAUIF=BG*Hu5AxT;6B9RX*|KnA;&WTJbUz~hqjN-$ z9&aUjMBG8W%F18g+p^_z6BFX8En9A!m`HYE97YcBAw!VH=8q5!XvA#!b3T$jIRrrr zu}9b~GJh#radKJl!%9kSDG`5Ga!V;bMWGq`!B|1|h~h>5tb)$Cemo?7j?an|&B|Kg zHu86kLz=3P9Ln;i*S_1p9$P1doBz7zjKI#U6SEYchU?tT!ViQxWH>}nlW;z2jl+gY z)P^J9h04vHVdM?Vkn28?7)rTQq9tWA#gU=M2BKSEbpP^j_o1;+c=eS%>uy=5*6b?K zWQEptbZu#<-`MW&E9yW@(o&PbY(DwSdpS8{m)4FR?Cw6evUd5c z>-`JL1a+X^(z790x2dn^k~)*I&r|-Cr_<}}D)$BVR{K`jj7F;!{>&AFg9G#D&xhMm zK)v1%g~wob=~0m#cd%1JFmfCfj!`DZZ)C|T9hP|ln--sP*&?f(s~6PePiTClX1BVC zT`P`vZ;5qvwVKWP3bU=caARRJe7fGg%J68Uw9>e?7>+od4GysE`t^-%8!hYesw^hA zuGF+yS8jp?ai6rF2tSC5_xegx{&{{8}>=XLfGx%4yk;|4*P)v zUpsJsF_aAH+7OYeeTY`2>+H{!IQ|9HC%%TghkatlT4MESRjTGrnsoS9taERx}t|EEY6$pkYmOn#Jj+KZC1o zT4&H3ON#W))=+41MNRdnrPQF&nbdk`Q;omJBYrfq3ASrWhk;5^3AI555$mRgPpvhT zALUv!aiYa*7tY{IRZ{Qb^kk>{kF5>Wt~u5}aP^u{Xw9+q#`V$YhNh|o=D{3`PVds{ zTsplqXUMv!Vf4C13r~!WT(@B1by=udvtZ$|b#-;?jxAbn^@hk7k(3f!87?yq<>qRg z254>hc|+E+%}Z_^9z3~Z@yVg#8<$`|1+l~b1p1ThN14y{Smh+X+!shD7i5oqHl~po zwMnq2P!XkdLw(%_as}3JxS_hct7g%r@IY17K)80GvT`6lkXKlk7YJFrUMtIP*x1}W z9%~qHYME%*{p(+^$(ZO3EUgVLuC87jtX&#-z?q+uo9}j(mcn&`9A%uh&kJ9~Un<}! z+h6>qxZV!uoIS*g18T47|7TXAt2I{H>DCO zT2dO+Xp4|=oSTzxa1`SPU9qmFpddd#w=gHCz~Cwl)~C-PpMYxO9}0)T^$~m@L_Bht z;zrgdWR@<7e1f$d!nE$E70MN4pm31R+rf-tU8-!kHL#nUh*Fs9LDs;IraVi zhE*Zf0*w_uH+-MgyTX%@VF=@lC;3}OGL9SxRSGdM?*(s3AKbov?t9WJgnyV`0;>+* zj7{*1uHZY0ZeWmyBNe9XUx(E&Yv=2+$Doo()wC*`$Fb|1TQ)?a>)&0pX;G`*rq)>X zT4XcZzq5JKCUO6!MVo%KX!F7^gjdC4tHOAVj)lE=vQ2F>81S%Nud}Nk##8U&XA%Af z9USNG6e^T%@D)y&r04U36@54{Y)cN*thWx-R)_7;uFj~VdHej%eXjLo%}yVx`gEes z&kgr%rbl9onRfC^XjoUgp$4l3%%dIAl?krB(bL?UE?)=Ts zuy1`yiN3s4RAcX2ckYBF1x1C+aaKxIKS`RBPQ)vtP#TR=Zx3lPCX_h%^4@;<^w8-C z-#LEIH%!k6=C3(!f^q*H@kVyJ*dmIoQ~W3PtJSN|$ryi{#V6p*St$rYJC}^o4Q`0T zYW9lw+L0qHe{vFc!!b#wsAl=F@~Dm?e+o~~rzDjEig6)bsFz0Ai@P~W)Ym$>lI>qP zF5b41Z5m(6_KgC$O)JIQh_r@wEw1W!?&<`$jSaTUtegRP&1{Vt2t4P_)<%y!=^WttNZQ!)#gfvLvQqH z`W*voUV*kiryuZF6=(`H2K*-KS1Z1Pb$LEHYqzw1N3eWdlam`oa$YEG>^b&0cows6 zbq7_bx1(mU?e*LD+&(tNo_pmLRwVv`Mq7NYQ84oCYTPQI(b2O5(Bg@lX?rrsWTD|2R z?n?}sBBQZLV_@Cxc5gX~RJ4_Q+tIA1Vbn-%pel65LceeVI3ts;6*P1*RO@6cJw>r?c?do4t-VYwGo5>z@P)oqgC|s53rL~6CR2!7B*HTAkw;n$(K1oms`fOO^Di%>X)u@pvWULnN zUfs5ORs>168I&$H(qq4_;UFA^E+67xJrOHu7p zovw5jT5lX<>&Lpqn`m(M7|oAerxb4@P=X>NIB;w0WyyOB?Iz(X4yOElLcXc|r2JcO z#IGpFe`8mS2q=8OM}+w~Oe{e0SNsLg^)YZl&Ed>$8!X zdzT5NJ-2e5%_C&8Unn7ixx|sr=iTyE79lfUi3_9jD{g1=u=n8AhQ%J4<<_3+oniFf zKGW$SVPRyak-txp4aWpriop>%hgmT4iwU!yYK_+&znnjY8usxzI($xAj7B%a6|LCz z+;j_aK@bkH(>2S(C=<8OZPl37!YiTWH8smaWnQ&ai}mpcTHLTU$kIKjEuFXLzv7D7 z?J<`#X0yed(grmMT3giVjN0w=YFlPfYm6qf8o>~vvYEXsd>5xKw=oiuGg;Q7-Qsua z^_IMz(pI0v@6j17x%g8s>vU>$x6iB7t2I4dgw1HIR`wvjJd#Ls}Zq4HpT^rn=@|`f zN0mxY>&2bHgXy^iLR!S(s>(o_g?x`0<7jaE&4`RC=mYR}CoEX_DJh&4CZgW#sW0*8 zmZ`NxI=#g3vOI*p!u>o0*xnxyQ~zK2L;Zjj_rQ*++go#sa^~ZUEV3{$%sw zuGmU0UyIb$&d;^vm75DS^U?>h^U13x@4mDQc8GiVkQWP(mF_oR2#!NL*t)At98TUq=xhMQ!S0{_;&$oqw^ji<3ZBJQZ@RT Yi_+;6pf5_VCmb$Dw_o(2@Xmez2QTNB>Hq)$ literal 0 HcmV?d00001 diff --git a/src/modules/rngdle/assets/score-table.json b/src/modules/rngdle/assets/score-table.json new file mode 100644 index 00000000..ab64458b --- /dev/null +++ b/src/modules/rngdle/assets/score-table.json @@ -0,0 +1,202 @@ +{ + "1759": 0, + "1980": 0.5, + "2087": 1, + "2137": 1.5, + "2215": 2, + "2271": 2.5, + "2309": 3, + "2361": 3.5, + "2434": 4, + "2484": 4.5, + "2544": 5, + "2631": 5.5, + "2668": 6, + "2742": 6.5, + "2798": 7, + "2838": 7.5, + "2881": 8, + "2930": 8.5, + "2969": 9, + "3001": 9.5, + "3034": 10, + "3061": 10.5, + "3100": 11, + "3133": 11.5, + "3176": 12, + "3192": 12.5, + "3228": 13, + "3256": 13.5, + "3296": 14, + "3338": 14.5, + "3367": 15, + "3400": 15.5, + "3446": 16, + "3482": 16.5, + "3523": 17, + "3564": 17.5, + "3607": 18, + "3634": 18.5, + "3674": 19, + "3706": 19.5, + "3738": 20, + "3770": 20.5, + "3800": 21, + "3832": 21.5, + "3867": 22, + "3898": 22.5, + "3926": 23, + "3953": 23.5, + "3981": 24, + "4010": 24.5, + "4041": 25, + "4071": 25.5, + "4106": 26, + "4142": 26.5, + "4170": 27, + "4198": 27.5, + "4235": 28, + "4263": 28.5, + "4297": 29, + "4325": 29.5, + "4361": 30, + "4391": 30.5, + "4427": 31, + "4458": 31.5, + "4487": 32, + "4520": 32.5, + "4552": 33, + "4590": 33.5, + "4623": 34, + "4652": 34.5, + "4684": 35, + "4718": 35.5, + "4755": 36, + "4791": 36.5, + "4827": 37, + "4858": 37.5, + "4894": 38, + "4925": 38.5, + "4963": 39, + "4993": 39.5, + "5028": 40, + "5058": 40.5, + "5096": 41, + "5126": 41.5, + "5162": 42, + "5198": 42.5, + "5230": 43, + "5267": 43.5, + "5305": 44, + "5345": 44.5, + "5382": 45, + "5420": 45.5, + "5465": 46, + "5506": 46.5, + "5548": 47, + "5589": 47.5, + "5636": 48, + "5681": 48.5, + "5719": 49, + "5762": 49.5, + "5802": 50, + "5848": 50.5, + "5890": 51, + "5932": 51.5, + "5973": 52, + "6015": 52.5, + "6061": 53, + "6104": 53.5, + "6152": 54, + "6202": 54.5, + "6251": 55, + "6303": 55.5, + "6356": 56, + "6413": 56.5, + "6468": 57, + "6522": 57.5, + "6583": 58, + "6641": 58.5, + "6694": 59, + "6750": 59.5, + "6811": 60, + "6870": 60.5, + "6930": 61, + "6995": 61.5, + "7062": 62, + "7129": 62.5, + "7202": 63, + "7274": 63.5, + "7352": 64, + "7433": 64.5, + "7510": 65, + "7596": 65.5, + "7678": 66, + "7766": 66.5, + "7860": 67, + "7953": 67.5, + "8043": 68, + "8142": 68.5, + "8244": 69, + "8359": 69.5, + "8486": 70, + "8612": 70.5, + "8745": 71, + "8876": 71.5, + "9016": 72, + "9166": 72.5, + "9323": 73, + "9504": 73.5, + "9692": 74, + "9878": 74.5, + "10074": 75, + "10290": 75.5, + "10528": 76, + "10769": 76.5, + "11022": 77, + "11312": 77.5, + "11629": 78, + "11927": 78.5, + "12219": 79, + "12518": 79.5, + "12834": 80, + "13123": 80.5, + "13414": 81, + "13691": 81.5, + "13994": 82, + "14271": 82.5, + "14566": 83, + "14850": 83.5, + "15170": 84, + "15527": 84.5, + "15870": 85, + "16266": 85.5, + "16723": 86, + "17189": 86.5, + "17687": 87, + "18273": 87.5, + "18872": 88, + "19587": 88.5, + "20367": 89, + "21218": 89.5, + "22293": 90, + "23618": 90.5, + "25466": 91, + "27158": 91.5, + "28159": 92, + "28994": 92.5, + "29804": 93, + "30745": 93.5, + "31930": 94, + "33548": 94.5, + "35469": 95, + "38086": 95.5, + "41350": 96, + "45161": 96.5, + "51817": 97, + "62185": 97.5, + "94697": 98, + "118135": 98.5, + "162292": 99, + "337579": 99.5 +} diff --git a/src/modules/rngdle/assets/spacemono_bold.ttf b/src/modules/rngdle/assets/spacemono_bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..7c38f236f12f0a9d062020253e3758f17c7bd628 GIT binary patch literal 97256 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 az`btsuc`k3b9|k844QJAACu8f|NAdOT<&uK literal 0 HcmV?d00001 diff --git a/src/modules/rngdle/assets/trash.png b/src/modules/rngdle/assets/trash.png new file mode 100644 index 0000000000000000000000000000000000000000..60dc81b6d42ad11ab69572e548374410e90e05ab GIT binary patch literal 13410 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 Ef|+%a#sB~S literal 0 HcmV?d00001 diff --git a/src/modules/rngdle/commands/replies.ts b/src/modules/rngdle/commands/replies.ts new file mode 100644 index 00000000..81155418 --- /dev/null +++ b/src/modules/rngdle/commands/replies.ts @@ -0,0 +1,25 @@ +import { MessageFlags, type RepliableInteraction } from "discord.js"; +import { loggerMaker } from "#lib/logger.js"; + +const logger = loggerMaker("rngdle"); + +export async function replyWithError( + interaction: RepliableInteraction, + content: string +): Promise { + try { + if (interaction.deferred || interaction.replied) { + await interaction.editReply({ + content, + files: [], + attachments: [], + components: [], + embeds: [], + }); + } else { + await interaction.reply({ content, flags: MessageFlags.Ephemeral }); + } + } catch (err) { + logger.warn({ err }, "Could not report an error to the user"); + } +} diff --git a/src/modules/rngdle/commands/rngdle-admin.command.ts b/src/modules/rngdle/commands/rngdle-admin.command.ts new file mode 100644 index 00000000..e1d05219 --- /dev/null +++ b/src/modules/rngdle/commands/rngdle-admin.command.ts @@ -0,0 +1,290 @@ +import { + EmbedBuilder, + InteractionContextType, + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { requireAdmin } from "#core/utils/require-admin.js"; +import { declareCommand } from "#lib/command.js"; +import type { ConfigProvider } from "#lib/config.js"; +import { loggerMaker } from "#lib/logger.js"; +import type { RngdleConfigSchema } from "#modules/rngdle/rngdle.config.js"; +import { + fetchUserRolls, + RngdleUserNotFoundError, +} from "#modules/rngdle/services/rngdle-api.js"; +import rngdleService from "#modules/rngdle/services/rngdle.service.js"; +import syncService, { + FullSyncCooldownError, +} from "#modules/rngdle/services/sync.service.js"; +import { Colors } from "#utils/colors.js"; +import { replyWithError } from "./replies.js"; + +const PERMISSION_ADMINISTRATOR = 0x8; +const USERNAME_MAX_LENGTH = 64; + +const logger = loggerMaker("rngdle"); + +type Config = ConfigProvider; +type GuildCommand = ChatInputCommandInteraction<"cached" | "raw">; + +async function register(interaction: GuildCommand, config: Config) { + const member = interaction.options.getUser("member", true); + const username = interaction.options.getString("username", true).trim(); + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + + let rolls; + try { + rolls = await fetchUserRolls(username, null); + } catch (err) { + if (err instanceof RngdleUserNotFoundError) { + await interaction.editReply( + config.t("admin.register.notFound", { username }) + ); + return; + } + logger.warn({ err }, `Could not reach rngdle.com | username = ${username}`); + await interaction.editReply(config.t("admin.register.unreachable")); + return; + } + + const outcome = await syncService.runExclusive( + interaction.guildId, + async () => { + const holder = await rngdleService.findAccountByUsername( + interaction.guildId, + username + ); + if (holder && holder.userId !== member.id) { + return { conflict: holder.userId } as const; + } + const result = await rngdleService.register( + interaction.guildId, + member.id, + username + ); + const saved = await rngdleService.saveRolls( + { guildId: interaction.guildId, userId: member.id, username }, + rolls + ); + return { result, saved } as const; + } + ); + + if ("conflict" in outcome) { + await interaction.editReply({ + content: config.t("admin.register.conflict", { + username, + user: `<@${outcome.conflict}>`, + }), + allowedMentions: { parse: [] }, + }); + return; + } + + const { result, saved } = outcome; + logger.info( + `Account ${result} | guildId = ${interaction.guildId} | userId = ${member.id} | username = ${username} | rolls = ${saved.inserted} | by = ${interaction.user.id}` + ); + await interaction.editReply({ + content: config.t(`admin.register.${result}`, { + user: `<@${member.id}>`, + username, + count: saved.inserted, + }), + allowedMentions: { parse: [] }, + }); +} + +async function unregister(interaction: GuildCommand, config: Config) { + const member = interaction.options.getUser("member", true); + const deleted = await syncService.runExclusive(interaction.guildId, () => + rngdleService.unregister(interaction.guildId, member.id) + ); + if (deleted) { + logger.info( + `Account deleted | guildId = ${interaction.guildId} | userId = ${member.id} | by = ${interaction.user.id}` + ); + } + await interaction.reply({ + content: config.t(deleted ? "admin.delete.done" : "admin.delete.none", { + user: `<@${member.id}>`, + }), + flags: MessageFlags.Ephemeral, + allowedMentions: { parse: [] }, + }); +} + +async function show(interaction: GuildCommand, config: Config) { + const accounts = await rngdleService.listAccounts(interaction.guildId); + if (accounts.length === 0) { + await interaction.reply({ + content: config.t("admin.show.empty"), + flags: MessageFlags.Ephemeral, + }); + return; + } + await interaction.reply({ + embeds: [ + new EmbedBuilder() + .setTitle(config.t("admin.show.title", { count: accounts.length })) + .setDescription( + accounts + .map((account) => `<@${account.userId}> → \`${account.username}\``) + .join("\n") + .slice(0, 4096) + ) + .setColor(Colors.SkyBlue), + ], + flags: MessageFlags.Ephemeral, + allowedMentions: { parse: [] }, + }); +} + +async function refresh(interaction: GuildCommand, config: Config) { + const full = interaction.options.getBoolean("full") ?? false; + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + let report; + try { + report = await syncService.syncGuild(interaction.guildId, { full }); + } catch (err) { + if (err instanceof FullSyncCooldownError) { + await interaction.editReply( + config.t("admin.refresh.cooldown", { + time: ``, + }) + ); + return; + } + throw err; + } + await interaction.editReply( + report.accounts === 0 + ? config.t("admin.show.empty") + : config.t("admin.refresh.done", { ...report }) + ); +} + +async function clear(interaction: GuildCommand, config: Config) { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + const count = await syncService.runExclusive(interaction.guildId, () => + rngdleService.clearRolls(interaction.guildId) + ); + logger.info( + `Rolls cleared | guildId = ${interaction.guildId} | count = ${count} | by = ${interaction.user.id}` + ); + await interaction.editReply(config.t("admin.clear.done", { count })); +} + +export default declareCommand({ + data: new SlashCommandBuilder() + .setName("rngdle-admin") + .setDescription("RNGdle admin commands") + .setDescriptionLocalizations({ fr: "Administration RNGdle" }) + .setDefaultMemberPermissions(PERMISSION_ADMINISTRATOR) + .setContexts([InteractionContextType.Guild]) + .addSubcommand((sub) => + sub + .setName("register") + .setDescription("Link a member to their RNGdle account") + .setDescriptionLocalizations({ + fr: "Lier un membre à son compte RNGdle", + }) + .addUserOption((option) => + option + .setName("member") + .setDescription("Member to link") + .setDescriptionLocalizations({ fr: "Membre à lier" }) + .setRequired(true) + ) + .addStringOption((option) => + option + .setName("username") + .setDescription("RNGdle username") + .setDescriptionLocalizations({ fr: "Pseudo RNGdle" }) + .setMaxLength(USERNAME_MAX_LENGTH) + .setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName("delete") + .setDescription("Unlink a member and delete their rolls") + .setDescriptionLocalizations({ + fr: "Délier un membre et supprimer ses tirages", + }) + .addUserOption((option) => + option + .setName("member") + .setDescription("Member to unlink") + .setDescriptionLocalizations({ fr: "Membre à délier" }) + .setRequired(true) + ) + ) + .addSubcommand((sub) => + sub + .setName("show") + .setDescription("List the linked accounts") + .setDescriptionLocalizations({ fr: "Lister les comptes liés" }) + ) + .addSubcommand((sub) => + sub + .setName("refresh") + .setDescription("Download the latest rolls now") + .setDescriptionLocalizations({ + fr: "Télécharger les derniers tirages maintenant", + }) + .addBooleanOption((option) => + option + .setName("full") + .setDescription("Download every roll again, not only new ones") + .setDescriptionLocalizations({ + fr: "Retélécharger tous les tirages, pas seulement les nouveaux", + }) + ) + ) + .addSubcommand((sub) => + sub + .setName("clear") + .setDescription("Delete this server's stored rolls") + .setDescriptionLocalizations({ + fr: "Supprimer les tirages enregistrés sur ce serveur", + }) + ), + + async execute(interaction, config) { + if (!interaction.inGuild()) { + return; + } + if (!(await requireAdmin(interaction, config.t))) { + return; + } + + try { + switch (interaction.options.getSubcommand()) { + case "register": + await register(interaction, config); + break; + case "delete": + await unregister(interaction, config); + break; + case "show": + await show(interaction, config); + break; + case "refresh": + await refresh(interaction, config); + break; + case "clear": + await clear(interaction, config); + break; + } + } catch (err) { + logger.error( + { err }, + `RNGdle admin command failed | guildId = ${interaction.guildId}` + ); + await replyWithError(interaction, config.t("error.generic")); + } + }, +}); diff --git a/src/modules/rngdle/commands/rngdle.command.ts b/src/modules/rngdle/commands/rngdle.command.ts new file mode 100644 index 00000000..7aafaadb --- /dev/null +++ b/src/modules/rngdle/commands/rngdle.command.ts @@ -0,0 +1,257 @@ +import { + AttachmentBuilder, + InteractionContextType, + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { declareCommand } from "#lib/command.js"; +import type { ConfigProvider } from "#lib/config.js"; +import { loggerMaker } from "#lib/logger.js"; +import { overallPageComponents } from "#modules/rngdle/interactions/overall-page.button.js"; +import type { RngdleConfigSchema } from "#modules/rngdle/rngdle.config.js"; +import { + buildDailyLeaderboard, + buildProfile, + buildServerStats, + cachedOverallPage, + utcDayRange, +} from "#modules/rngdle/services/boards.js"; +import imageCache from "#modules/rngdle/services/image-cache.service.js"; +import rngdleService from "#modules/rngdle/services/rngdle.service.js"; +import syncService from "#modules/rngdle/services/sync.service.js"; +import { replyWithError } from "./replies.js"; + +const logger = loggerMaker("rngdle"); + +type Config = ConfigProvider; +type GuildCommand = ChatInputCommandInteraction<"cached" | "raw">; + +const AUTOCOMPLETE_LIMIT = 25; + +function attachment(image: Buffer, name: string) { + return [new AttachmentBuilder(image, { name })]; +} + +async function showToday(interaction: GuildCommand, config: Config) { + await interaction.deferReply(); + await syncService.syncGuildIfStale(interaction.guildId); + + const range = utcDayRange(0); + const board = await imageCache.get( + interaction.guildId, + `daily:${config.locale}:${range[0].toISOString()}`, + () => + buildDailyLeaderboard( + interaction.client, + interaction.guildId, + range, + config.t + ) + ); + if (!board) { + await interaction.editReply(config.t("leaderboard.empty")); + return; + } + await interaction.editReply({ + files: attachment(board.image, "leaderboard.png"), + }); +} + +async function showProfile(interaction: GuildCommand, config: Config) { + const member = interaction.options.getUser("member"); + const username = interaction.options.getString("username"); + if (member && username) { + await interaction.reply({ + content: config.t("profile.bothOptions"), + flags: MessageFlags.Ephemeral, + }); + return; + } + + const account = username + ? await rngdleService.findAccountByUsername(interaction.guildId, username) + : await rngdleService.getAccount( + interaction.guildId, + (member ?? interaction.user).id + ); + if (!account) { + await interaction.reply({ + content: config.t("profile.notRegistered"), + flags: MessageFlags.Ephemeral, + }); + return; + } + + await interaction.deferReply(); + await syncService.syncGuildIfStale(interaction.guildId); + + const user = await interaction.client.users + .fetch(account.userId) + .catch(() => null); + if (!user) { + await interaction.editReply(config.t("profile.notRegistered")); + return; + } + + const image = await imageCache.get( + interaction.guildId, + `profile:${config.locale}:${account.userId}`, + () => buildProfile(account, user, config.t, config.locale) + ); + if (!image) { + await interaction.editReply( + config.t("profile.noRolls", { username: account.username }) + ); + return; + } + await interaction.editReply({ + files: attachment(image, `profile-${account.username}.png`), + }); +} + +async function showServerStats(interaction: GuildCommand, config: Config) { + await interaction.deferReply(); + await syncService.syncGuildIfStale(interaction.guildId); + + const guild = + interaction.guild ?? + (await interaction.client.guilds.fetch(interaction.guildId)); + const image = await imageCache.get( + interaction.guildId, + `server:${config.locale}`, + () => buildServerStats(interaction.client, guild, config.t) + ); + if (!image) { + await interaction.editReply(config.t("server.empty")); + return; + } + await interaction.editReply({ files: attachment(image, "server-stats.png") }); +} + +async function showOverall(interaction: GuildCommand, config: Config) { + await interaction.deferReply(); + await syncService.syncGuildIfStale(interaction.guildId); + + const result = await cachedOverallPage( + interaction.client, + interaction.guildId, + interaction.options.getInteger("page") ?? 1, + interaction.user.id, + config.t, + config.locale + ); + if (!result) { + await interaction.editReply(config.t("overall.empty")); + return; + } + await interaction.editReply({ + files: attachment(result.image, "leaderboard.png"), + components: overallPageComponents( + result.page, + result.pageCount, + "public", + config.t + ), + }); +} + +export default declareCommand({ + data: new SlashCommandBuilder() + .setName("rngdle") + .setDescription("RNGdle leaderboards and statistics") + .setDescriptionLocalizations({ fr: "Classements et statistiques RNGdle" }) + .setContexts([InteractionContextType.Guild]) + .addSubcommand((sub) => + sub + .setName("leaderboard") + .setDescription("Show today's leaderboard") + .setDescriptionLocalizations({ fr: "Afficher le classement du jour" }) + ) + .addSubcommand((sub) => + sub + .setName("profile") + .setDescription("Show a player's profile") + .setDescriptionLocalizations({ fr: "Afficher le profil d'un joueur" }) + .addUserOption((option) => + option + .setName("member") + .setDescription("Member to look up (defaults to you)") + .setDescriptionLocalizations({ + fr: "Membre à consulter (vous par défaut)", + }) + ) + .addStringOption((option) => + option + .setName("username") + .setDescription("RNGdle username to look up") + .setDescriptionLocalizations({ fr: "Pseudo RNGdle à consulter" }) + .setAutocomplete(true) + ) + ) + .addSubcommand((sub) => + sub + .setName("server-stats") + .setDescription("Show the server's statistics") + .setDescriptionLocalizations({ + fr: "Afficher les statistiques du serveur", + }) + ) + .addSubcommand((sub) => + sub + .setName("leaderboard-all") + .setDescription("Show the all-time leaderboard") + .setDescriptionLocalizations({ fr: "Afficher le classement général" }) + .addIntegerOption((option) => + option + .setName("page") + .setDescription("Page number") + .setDescriptionLocalizations({ fr: "Numéro de page" }) + .setMinValue(1) + ) + ), + + async execute(interaction, config) { + if (!interaction.inGuild()) { + return; + } + + try { + switch (interaction.options.getSubcommand()) { + case "leaderboard": + await showToday(interaction, config); + break; + case "profile": + await showProfile(interaction, config); + break; + case "server-stats": + await showServerStats(interaction, config); + break; + case "leaderboard-all": + await showOverall(interaction, config); + break; + } + } catch (err) { + logger.error( + { err }, + `RNGdle command failed | guildId = ${interaction.guildId}` + ); + await replyWithError(interaction, config.t("error.generic")); + } + }, + + async complete(interaction) { + if (!interaction.inGuild()) { + await interaction.respond([]); + return; + } + const typed = interaction.options.getFocused().toLowerCase(); + const accounts = await rngdleService.listAccounts(interaction.guildId); + await interaction.respond( + accounts + .filter((account) => account.username.toLowerCase().includes(typed)) + .slice(0, AUTOCOMPLETE_LIMIT) + .map((account) => ({ name: account.username, value: account.username })) + ); + }, +}); diff --git a/src/modules/rngdle/i18n/en.json b/src/modules/rngdle/i18n/en.json new file mode 100644 index 00000000..ac2b0f0c --- /dev/null +++ b/src/modules/rngdle/i18n/en.json @@ -0,0 +1,58 @@ +{ + "config.leaderboardChannel.name": "Daily leaderboard channel", + "config.leaderboardChannel.description": "Channel where yesterday's leaderboard is posted every day at 01:00 UTC.", + + "leaderboard.header.rank": "Rank", + "leaderboard.header.player": "Player", + "leaderboard.header.number": "Roll", + "leaderboard.header.score": "Score", + "leaderboard.header.placement": "Placement", + "leaderboard.empty": "Nobody has rolled today yet.", + + "daily.message": "🏆 Yesterday's RNGdle leaderboard — congratulations to {{winners}}!", + + "overall.header.score": "Overall Score", + "overall.empty": "No roll has been recorded on this server yet.", + "overall.page": "Page {{current}}/{{total}}", + + "profile.bothOptions": "Choose either a member or an RNGdle username, not both.", + "profile.notRegistered": "This player has no linked RNGdle account. An administrator can link one with `/rngdle-admin register`.", + "profile.noRolls": "No roll found for `{{username}}` yet.", + "profile.bestRoll": "Best Roll", + "profile.worstRoll": "Worst Roll", + "profile.date": "Date: {{date}}", + "profile.totalRolls": "Total Rolls", + "profile.averageScore": "Average Score", + "profile.maxBadges": "Max Badges", + "profile.maxBadgesValue_one": "{{count}} badge at once", + "profile.maxBadgesValue_other": "{{count}} badges at once", + "profile.overallScore": "Overall Score", + "profile.tierBreakdown": "Tier Breakdown", + + "server.title": "RNGdle - Server Stats", + "server.bestRoll": "Best Roll OAT", + "server.worstRoll": "Worst Roll OAT", + "server.by": "by {{name}}", + "server.totalRolls": "Total Rolls", + "server.averageScore": "Average Score", + "server.overallScore": "Overall Score", + "server.tierBreakdown": "Tier Breakdown", + "server.empty": "No roll has been recorded on this server yet.", + + "error.generic": "Something went wrong. Try again later.", + + "admin.noPermission": "You must be an administrator to use this command.", + "admin.register.created": "{{user}} is now linked to the RNGdle account `{{username}}` ({{count}} rolls imported).", + "admin.register.renamed": "{{user}} is now linked to the RNGdle account `{{username}}`; the rolls of the previous account were removed ({{count}} rolls imported).", + "admin.register.unchanged": "{{user}} was already linked to `{{username}}` ({{count}} new rolls imported).", + "admin.register.notFound": "No RNGdle account named `{{username}}` exists.", + "admin.register.unreachable": "rngdle.com could not be reached. Try again in a few minutes.", + "admin.register.conflict": "`{{username}}` is already linked to {{user}} on this server.", + "admin.delete.done": "{{user}} is no longer linked, and their rolls were deleted.", + "admin.delete.none": "{{user}} has no linked RNGdle account.", + "admin.show.title": "Linked RNGdle accounts ({{count}})", + "admin.show.empty": "No RNGdle account is linked on this server.", + "admin.refresh.done": "{{accounts}} accounts refreshed: {{inserted}} rolls added, {{updated}} updated, {{failed}} failures.", + "admin.refresh.cooldown": "A full refresh already ran recently on this server. Try again {{time}}.", + "admin.clear.done": "{{count}} rolls deleted. They will be downloaded again at the next refresh." +} diff --git a/src/modules/rngdle/i18n/fr.json b/src/modules/rngdle/i18n/fr.json new file mode 100644 index 00000000..72bbf59d --- /dev/null +++ b/src/modules/rngdle/i18n/fr.json @@ -0,0 +1,58 @@ +{ + "config.leaderboardChannel.name": "Salon du classement quotidien", + "config.leaderboardChannel.description": "Salon où le classement de la veille est posté chaque jour à 01:00 UTC.", + + "leaderboard.header.rank": "Rang", + "leaderboard.header.player": "Pseudo", + "leaderboard.header.number": "Tirage", + "leaderboard.header.score": "Score", + "leaderboard.header.placement": "Placement", + "leaderboard.empty": "Personne n'a encore tiré aujourd'hui.", + + "daily.message": "🏆 Classement RNGdle d'hier — félicitations à {{winners}} !", + + "overall.header.score": "Score total", + "overall.empty": "Aucun tirage n'est encore enregistré sur ce serveur.", + "overall.page": "Page {{current}}/{{total}}", + + "profile.bothOptions": "Choisissez soit un membre, soit un pseudo RNGdle, pas les deux.", + "profile.notRegistered": "Ce joueur n'a pas de compte RNGdle lié. Un administrateur peut en lier un avec `/rngdle-admin register`.", + "profile.noRolls": "Aucun tirage trouvé pour `{{username}}` pour l'instant.", + "profile.bestRoll": "Meilleur tirage", + "profile.worstRoll": "Pire tirage", + "profile.date": "Date : {{date}}", + "profile.totalRolls": "Tirages", + "profile.averageScore": "Score moyen", + "profile.maxBadges": "Badges max", + "profile.maxBadgesValue_one": "{{count}} badge d'un coup", + "profile.maxBadgesValue_other": "{{count}} badges d'un coup", + "profile.overallScore": "Score total", + "profile.tierBreakdown": "Répartition par palier", + + "server.title": "RNGdle - Stats du serveur", + "server.bestRoll": "Meilleur tirage", + "server.worstRoll": "Pire tirage", + "server.by": "par {{name}}", + "server.totalRolls": "Tirages", + "server.averageScore": "Score moyen", + "server.overallScore": "Score total", + "server.tierBreakdown": "Répartition par palier", + "server.empty": "Aucun tirage n'est encore enregistré sur ce serveur.", + + "error.generic": "Une erreur est survenue. Réessayez plus tard.", + + "admin.noPermission": "Vous devez être administrateur pour utiliser cette commande.", + "admin.register.created": "{{user}} est maintenant lié au compte RNGdle `{{username}}` ({{count}} tirages importés).", + "admin.register.renamed": "{{user}} est maintenant lié au compte RNGdle `{{username}}` ; les tirages de l'ancien compte ont été supprimés ({{count}} tirages importés).", + "admin.register.unchanged": "{{user}} était déjà lié à `{{username}}` ({{count}} nouveaux tirages importés).", + "admin.register.notFound": "Aucun compte RNGdle ne s'appelle `{{username}}`.", + "admin.register.unreachable": "rngdle.com est injoignable. Réessayez dans quelques minutes.", + "admin.register.conflict": "`{{username}}` est déjà lié à {{user}} sur ce serveur.", + "admin.delete.done": "{{user}} n'est plus lié, et ses tirages ont été supprimés.", + "admin.delete.none": "{{user}} n'a pas de compte RNGdle lié.", + "admin.show.title": "Comptes RNGdle liés ({{count}})", + "admin.show.empty": "Aucun compte RNGdle n'est lié sur ce serveur.", + "admin.refresh.done": "{{accounts}} comptes rafraîchis : {{inserted}} tirages ajoutés, {{updated}} mis à jour, {{failed}} échecs.", + "admin.refresh.cooldown": "Un rafraîchissement complet vient déjà d'avoir lieu sur ce serveur. Réessayez {{time}}.", + "admin.clear.done": "{{count}} tirages supprimés. Ils seront retéléchargés au prochain rafraîchissement." +} diff --git a/src/modules/rngdle/interactions/overall-page.button.ts b/src/modules/rngdle/interactions/overall-page.button.ts new file mode 100644 index 00000000..6a2f6c20 --- /dev/null +++ b/src/modules/rngdle/interactions/overall-page.button.ts @@ -0,0 +1,105 @@ +import { + ActionRowBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags, + type ButtonInteraction, +} from "discord.js"; +import type { TFunction } from "#lib/i18n.js"; +import { declareInteractionHandler } from "#lib/interaction.js"; +import { loggerMaker } from "#lib/logger.js"; +import { replyWithError } from "#modules/rngdle/commands/replies.js"; +import { cachedOverallPage } from "#modules/rngdle/services/boards.js"; + +const logger = loggerMaker("rngdle"); + +const CUSTOM_ID = "rngdle-overall"; + +type Visibility = "public" | "private"; + +export function overallPageComponents( + page: number, + pageCount: number, + visibility: Visibility, + t: TFunction +): ActionRowBuilder[] { + return [ + new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`${CUSTOM_ID}:${page - 1}:${visibility}`) + .setLabel(t("config.previous")) + .setStyle(ButtonStyle.Primary) + .setDisabled(page <= 1), + new ButtonBuilder() + .setCustomId(`${CUSTOM_ID}:indicator:${visibility}`) + .setLabel(t("overall.page", { current: page, total: pageCount })) + .setStyle(ButtonStyle.Success) + .setDisabled(true), + new ButtonBuilder() + .setCustomId(`${CUSTOM_ID}:${page + 1}:${visibility}`) + .setLabel(t("config.next")) + .setStyle(ButtonStyle.Primary) + .setDisabled(page >= pageCount) + ), + ]; +} + +export default declareInteractionHandler({ + customId: CUSTOM_ID, + access: "everyone", + check: (interaction): interaction is ButtonInteraction => + interaction.isButton(), + async execute(interaction, [rawPage, visibility], config) { + const page = Number(rawPage); + if (!interaction.inGuild() || !Number.isInteger(page)) { + return; + } + + try { + if (visibility === "private") { + await interaction.deferUpdate(); + } else { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + } + + const result = await cachedOverallPage( + interaction.client, + interaction.guildId, + page, + interaction.user.id, + config.t, + config.locale + ); + if (!result) { + await interaction.editReply({ + content: config.t("overall.empty"), + files: [], + attachments: [], + components: [], + }); + return; + } + + await interaction.editReply({ + content: "", + files: [ + new AttachmentBuilder(result.image, { name: "leaderboard.png" }), + ], + attachments: [], + components: overallPageComponents( + result.page, + result.pageCount, + "private", + config.t + ), + }); + } catch (err) { + logger.error( + { err }, + `RNGdle page button failed | guildId = ${interaction.guildId}` + ); + await replyWithError(interaction, config.t("error.generic")); + } + }, +}); diff --git a/src/modules/rngdle/rendering/common.ts b/src/modules/rngdle/rendering/common.ts new file mode 100644 index 00000000..66e445ae --- /dev/null +++ b/src/modules/rngdle/rendering/common.ts @@ -0,0 +1,60 @@ +import type { Image, SKRSContext2D } from "@napi-rs/canvas"; +import { loadAsset, registerFont, rgb, type Rgb } from "#lib/imaging.js"; + +const ASSETS = new URL("../assets/", import.meta.url); + +export const FONT = "RngdleOutfit"; +export const MONO_FONT = "RngdleSpaceMonoBold"; + +export const PALETTE = { + background: [25, 25, 25], + header: [50, 50, 50], + rowEven: [35, 35, 35], + rowOdd: [45, 45, 45], + box: [35, 35, 35], + text: [255, 255, 255], + title: [200, 200, 200], + subtext: [170, 170, 170], +} as const satisfies Record; + +export function registerFonts(): void { + registerFont(new URL("outfit.ttf", ASSETS), FONT); + registerFont(new URL("spacemono_bold.ttf", ASSETS), MONO_FONT); +} + +export function font(size: number, family: string = FONT): string { + return `${size}px ${family}`; +} + +export function loadMedals(): Promise<[Image, Image, Image]> { + return Promise.all([ + loadAsset(new URL("medal_gold.png", ASSETS)), + loadAsset(new URL("medal_silver.png", ASSETS)), + loadAsset(new URL("medal_bronze.png", ASSETS)), + ]); +} + +export function loadIcon(name: "arrow_up" | "trash"): Promise { + return loadAsset(new URL(`${name}.png`, ASSETS)); +} + +export function fillRoundedBox( + ctx: SKRSContext2D, + x: number, + y: number, + width: number, + height: number, + outline?: Rgb +): void { + ctx.beginPath(); + ctx.roundRect(x, y, width, height, 12); + ctx.fillStyle = rgb(PALETTE.box); + ctx.fill(); + if (outline) { + ctx.beginPath(); + ctx.roundRect(x + 1, y + 1, width - 2, height - 2, 11); + ctx.lineWidth = 2; + ctx.strokeStyle = rgb(outline); + ctx.stroke(); + } +} diff --git a/src/modules/rngdle/rendering/daily-leaderboard-image.test.ts b/src/modules/rngdle/rendering/daily-leaderboard-image.test.ts new file mode 100644 index 00000000..4281bc52 --- /dev/null +++ b/src/modules/rngdle/rendering/daily-leaderboard-image.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + renderDailyLeaderboard, + type DailyLeaderboardLabels, + type DailyLeaderboardRow, +} from "./daily-leaderboard-image.js"; + +const labels: DailyLeaderboardLabels = { + rank: "Rang", + player: "Pseudo", + number: "Tirage", + score: "Score", + placement: "Placement", +}; + +const rows: DailyLeaderboardRow[] = [ + { + rank: 1, + name: "Alice", + avatar: null, + number: 131081274, + score: 131081274, + percent: 99.9, + tier: "MYTHIC", + }, + { + rank: 2, + name: "A very very long player name that must be shrunk", + avatar: null, + number: 1234567, + score: 2000000, + percent: 80, + tier: "EPIC", + }, + { + rank: 3, + name: "Carol", + avatar: null, + number: 42, + score: 9000, + percent: 40, + tier: "COMMON", + }, + { + rank: 4, + name: "Dave", + avatar: null, + number: 7, + score: 50, + percent: 2, + tier: "ERROR", + }, +]; + +describe("renderDailyLeaderboard", () => { + it("renders a PNG with the expected dimensions", async () => { + const png = await renderDailyLeaderboard(rows, labels); + expect(png.subarray(0, 8)).toEqual( + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + ); + expect(png.readUInt32BE(16)).toBe(1010); + expect(png.readUInt32BE(20)).toBe(100 + rows.length * 90); + }); +}); diff --git a/src/modules/rngdle/rendering/daily-leaderboard-image.ts b/src/modules/rngdle/rendering/daily-leaderboard-image.ts new file mode 100644 index 00000000..671adaad --- /dev/null +++ b/src/modules/rngdle/rendering/daily-leaderboard-image.ts @@ -0,0 +1,190 @@ +import { createCanvas, type Image, type SKRSContext2D } from "@napi-rs/canvas"; +import { + drawCircularImage, + drawFittedText, + fillTextAnchored, + rgb, +} from "#lib/imaging.js"; +import { formatCompact, formatSpaced } from "../services/format.js"; +import { + formatPercent, + TIER_COLORS, + type TierOrError, +} from "../services/score-table.js"; +import { + FONT, + font, + loadIcon, + loadMedals, + MONO_FONT, + PALETTE, + registerFonts, +} from "./common.js"; + +const MIN_WIDTH = 800; +const HEADER_HEIGHT = 100; +const ROW_HEIGHT = 90; +const FONT_SIZE = 30; +const AVATAR_SIZE = 60; +const MEDAL_SIZE = 50; +const ICON_SIZE = 40; + +const RANK_X = 15; +const RANK_TEXT_X = 30; +const AVATAR_X = 100; +const PLAYER_X = 190; +const PLAYER_MAX_WIDTH = 320; +const COLUMN_X = [500, 650, 810] as const; +const COLUMN_WIDTHS = [140, 130, 180] as const; +const RIGHT_MARGIN = 20; +const WIDTH = Math.max( + MIN_WIDTH, + COLUMN_X[2] + COLUMN_WIDTHS[2] + RIGHT_MARGIN +); + +export interface DailyLeaderboardRow { + rank: number; + name: string; + avatar: Image | null; + number: number; + score: number; + percent: number; + tier: TierOrError; +} + +export interface DailyLeaderboardLabels { + rank: string; + player: string; + number: string; + score: string; + placement: string; +} + +function fittedWidth( + ctx: SKRSContext2D, + text: string, + family: string, + maxWidth: number +): number { + let size = FONT_SIZE; + ctx.font = font(size, family); + while (size > 1 && ctx.measureText(text).width > maxWidth) { + size -= 1; + ctx.font = font(size, family); + } + return ctx.measureText(text).width; +} + +export async function renderDailyLeaderboard( + rows: DailyLeaderboardRow[], + labels: DailyLeaderboardLabels +): Promise { + registerFonts(); + const [medals, arrowUp, trash] = await Promise.all([ + loadMedals(), + loadIcon("arrow_up"), + loadIcon("trash"), + ]); + + const canvas = createCanvas(WIDTH, HEADER_HEIGHT + rows.length * ROW_HEIGHT); + const ctx = canvas.getContext("2d"); + + ctx.fillStyle = rgb(PALETTE.background); + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = rgb(PALETTE.header); + ctx.fillRect(0, 0, canvas.width, HEADER_HEIGHT); + + const headerY = HEADER_HEIGHT / 2 - 15; + ctx.font = font(FONT_SIZE); + ctx.fillStyle = rgb(PALETTE.text); + ctx.textAlign = "left"; + fillTextAnchored(ctx, labels.rank, RANK_X, headerY); + fillTextAnchored(ctx, labels.player, PLAYER_X, headerY); + ctx.textAlign = "right"; + [labels.number, labels.score, labels.placement].forEach((label, i) => { + fillTextAnchored(ctx, label, COLUMN_X[i]! + COLUMN_WIDTHS[i]!, headerY); + }); + + rows.forEach((row, index) => { + const top = HEADER_HEIGHT + index * ROW_HEIGHT; + const textY = top + ROW_HEIGHT / 2 - 15; + const tierColor = rgb(TIER_COLORS[row.tier]); + + ctx.fillStyle = rgb(index % 2 === 0 ? PALETTE.rowEven : PALETTE.rowOdd); + ctx.fillRect(0, top, canvas.width, ROW_HEIGHT); + + const medal = medals[row.rank - 1]; + if (medal) { + ctx.drawImage( + medal, + RANK_X, + Math.trunc(textY - 10), + MEDAL_SIZE, + MEDAL_SIZE + ); + } else { + ctx.font = font(FONT_SIZE); + ctx.fillStyle = rgb(PALETTE.text); + ctx.textAlign = "left"; + fillTextAnchored(ctx, String(row.rank), RANK_TEXT_X, textY, "ascender"); + } + + drawCircularImage(ctx, row.avatar, AVATAR_X, top + 15, AVATAR_SIZE); + + drawFittedText(ctx, row.name, PLAYER_X, textY, { + family: FONT, + size: FONT_SIZE, + maxWidth: PLAYER_MAX_WIDTH, + color: rgb(PALETTE.text), + }); + + drawFittedText( + ctx, + formatSpaced(row.number).padStart(7), + COLUMN_X[0] + COLUMN_WIDTHS[0], + textY, + { + family: MONO_FONT, + size: FONT_SIZE, + maxWidth: COLUMN_WIDTHS[0], + color: tierColor, + align: "right", + } + ); + + drawFittedText( + ctx, + formatCompact(row.score), + COLUMN_X[1] + COLUMN_WIDTHS[1], + textY, + { + family: FONT, + size: FONT_SIZE, + maxWidth: COLUMN_WIDTHS[1], + color: rgb(PALETTE.text), + align: "right", + } + ); + + const placement = formatPercent(row.percent); + const right = COLUMN_X[2] + COLUMN_WIDTHS[2]; + const textWidth = fittedWidth(ctx, placement, FONT, COLUMN_WIDTHS[2]); + const icon = row.percent > 50 ? arrowUp : trash; + ctx.drawImage( + icon, + Math.trunc(right - textWidth - 10 - ICON_SIZE), + Math.trunc(textY - 10), + ICON_SIZE, + ICON_SIZE + ); + drawFittedText(ctx, placement, right, textY, { + family: FONT, + size: FONT_SIZE, + maxWidth: COLUMN_WIDTHS[2], + color: tierColor, + align: "right", + }); + }); + + return canvas.encode("png"); +} diff --git a/src/modules/rngdle/rendering/overall-leaderboard-image.test.ts b/src/modules/rngdle/rendering/overall-leaderboard-image.test.ts new file mode 100644 index 00000000..23ab010c --- /dev/null +++ b/src/modules/rngdle/rendering/overall-leaderboard-image.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + renderOverallLeaderboard, + type OverallLabels, + type OverallRow, +} from "./overall-leaderboard-image.js"; + +const labels: OverallLabels = { + rank: "Rang", + player: "Pseudo", + overallScore: "Overall Score", +}; + +const rows: OverallRow[] = [ + { rank: 1, name: "Alice", avatar: null, totalScore: 999 }, + { rank: 2, name: "Bob", avatar: null, totalScore: 1500 }, + { rank: 3, name: "Carol", avatar: null, totalScore: 2_000_000 }, + { + rank: 4, + name: "A very very long player name that must be shrunk to fit", + avatar: null, + totalScore: 1_234_567_890, + }, + { rank: 5, name: "Eve", avatar: null, totalScore: Number.MAX_SAFE_INTEGER }, +]; + +const caller: OverallRow = { + rank: 14, + name: "Me", + avatar: null, + totalScore: 42, +}; + +function dimensions(png: Buffer): { width: number; height: number } { + return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; +} + +describe("renderOverallLeaderboard", () => { + it("renders a PNG with the expected dimensions", async () => { + const png = await renderOverallLeaderboard(rows, labels, "fr"); + expect(png.subarray(0, 8)).toEqual( + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + ); + expect(dimensions(png)).toEqual({ width: 800, height: 100 + 5 * 90 }); + }); + + it("adds room for the caller row", async () => { + const png = await renderOverallLeaderboard(rows, labels, "en", caller); + expect(dimensions(png)).toEqual({ + width: 800, + height: 100 + 5 * 90 + 90 + 20, + }); + }); + + it("renders ranks beyond the podium and an empty page", async () => { + const paged = rows.map((row, index) => ({ ...row, rank: 11 + index })); + const png = await renderOverallLeaderboard(paged, labels, "en"); + expect(dimensions(png).height).toBe(100 + 5 * 90); + const empty = await renderOverallLeaderboard([], labels, "en"); + expect(dimensions(empty).height).toBe(100); + }); + + it("renders a podium caller row", async () => { + const png = await renderOverallLeaderboard([], labels, "fr", { + ...caller, + rank: 2, + }); + expect(dimensions(png).height).toBe(100 + 90 + 20); + }); +}); diff --git a/src/modules/rngdle/rendering/overall-leaderboard-image.ts b/src/modules/rngdle/rendering/overall-leaderboard-image.ts new file mode 100644 index 00000000..736ef861 --- /dev/null +++ b/src/modules/rngdle/rendering/overall-leaderboard-image.ts @@ -0,0 +1,124 @@ +import { createCanvas, type Image, type SKRSContext2D } from "@napi-rs/canvas"; +import { + drawCircularImage, + drawFittedText, + fillTextAnchored, + rgb, +} from "#lib/imaging.js"; +import { formatShort } from "../services/format.js"; +import { FONT, font, loadMedals, PALETTE, registerFonts } from "./common.js"; + +const WIDTH = 800; +const ROW_HEIGHT = 90; +const HEADER_HEIGHT = 100; +const GAP = 20; +const FONT_SIZE = 30; +const AVATAR_SIZE = 60; +const MEDAL_SIZE = 50; +const NAME_MAX_WIDTH = 320; +const SCORE_MAX_WIDTH = 250; +const SCORE_COLOR = rgb([251, 251, 251]); +const SEPARATOR_COLOR = rgb([80, 80, 80]); + +export interface OverallRow { + rank: number; + name: string; + avatar: Image | null; + totalScore: number; +} + +export interface OverallLabels { + rank: string; + player: string; + overallScore: string; +} + +function drawRow( + ctx: SKRSContext2D, + medals: Image[], + row: OverallRow, + top: number, + color: string, + locale: string +): void { + ctx.fillStyle = color; + ctx.fillRect(0, top, WIDTH, ROW_HEIGHT + 1); + + const textY = top + ROW_HEIGHT / 2 - 15; + const medal = medals[row.rank - 1]; + if (medal) { + ctx.drawImage(medal, 15, Math.trunc(textY - 10), MEDAL_SIZE, MEDAL_SIZE); + } else { + ctx.font = font(FONT_SIZE); + ctx.fillStyle = rgb(PALETTE.text); + ctx.textAlign = "left"; + fillTextAnchored(ctx, String(row.rank), 30, textY, "ascender"); + } + + drawCircularImage(ctx, row.avatar, 100, top + 15, AVATAR_SIZE); + + drawFittedText(ctx, row.name, 190, textY, { + family: FONT, + size: FONT_SIZE, + maxWidth: NAME_MAX_WIDTH, + color: rgb(PALETTE.text), + }); + drawFittedText(ctx, `${formatShort(row.totalScore, locale)} EP`, 770, textY, { + family: FONT, + size: FONT_SIZE, + maxWidth: SCORE_MAX_WIDTH, + color: SCORE_COLOR, + align: "right", + }); +} + +export async function renderOverallLeaderboard( + rows: OverallRow[], + labels: OverallLabels, + locale: string, + caller?: OverallRow +): Promise { + registerFonts(); + const medals = await loadMedals(); + + const height = + HEADER_HEIGHT + rows.length * ROW_HEIGHT + (caller ? ROW_HEIGHT + GAP : 0); + const canvas = createCanvas(WIDTH, height); + const ctx = canvas.getContext("2d"); + + ctx.fillStyle = rgb(PALETTE.background); + ctx.fillRect(0, 0, WIDTH, height); + ctx.fillStyle = rgb(PALETTE.header); + ctx.fillRect(0, 0, WIDTH, HEADER_HEIGHT + 1); + + const headerY = HEADER_HEIGHT / 2 - 15; + ctx.font = font(FONT_SIZE); + ctx.fillStyle = rgb(PALETTE.text); + ctx.textAlign = "left"; + fillTextAnchored(ctx, labels.rank, 15, headerY); + fillTextAnchored(ctx, labels.player, 190, headerY); + ctx.textAlign = "right"; + fillTextAnchored(ctx, labels.overallScore, 770, headerY); + + rows.forEach((row, index) => { + const color = rgb(index % 2 === 0 ? PALETTE.rowEven : PALETTE.rowOdd); + drawRow( + ctx, + medals, + row, + HEADER_HEIGHT + index * ROW_HEIGHT, + color, + locale + ); + }); + + if (caller) { + const baseY = HEADER_HEIGHT + rows.length * ROW_HEIGHT; + const lineY = baseY + GAP / 2; + ctx.fillStyle = SEPARATOR_COLOR; + ctx.fillRect(0, lineY, WIDTH, 2); + drawRow(ctx, medals, caller, baseY + GAP, rgb(PALETTE.rowEven), locale); + } + + return canvas.encode("png"); +} diff --git a/src/modules/rngdle/rendering/profile-image.test.ts b/src/modules/rngdle/rendering/profile-image.test.ts new file mode 100644 index 00000000..4f42dbbf --- /dev/null +++ b/src/modules/rngdle/rendering/profile-image.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + renderProfile, + type ProfileImageData, + type ProfileLabels, +} from "./profile-image.js"; + +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +const labels: ProfileLabels = { + bestRoll: "Best Roll", + worstRoll: "Worst Roll", + date: (date) => `Date : ${date}`, + totalRolls: "Total Rolls", + averageScore: "Average Score", + maxBadges: "Max Badges", + maxBadgesValue: (count) => `${count} badges at once`, + overallScore: "Overall Score", + tierBreakdown: "Tier Breakdown", +}; + +function data(serverRank: number): ProfileImageData { + return { + username: "virilegamer", + avatar: null, + serverRank, + totalPlayers: 12, + best: { + number: 1234567, + score: 98765, + tier: "MYTHIC", + dateText: "2026-01-02", + }, + worst: { number: 42, score: 3, tier: "TRASH", dateText: "2026-02-03" }, + totalRolls: 321, + averageScore: 4567, + averageTier: "RARE", + maxBadges: 4, + totalScore: 1466007, + tierCounts: { + MYTHIC: 1, + ANOMALY: 2, + EPIC: 3, + RARE: 4, + UNCOMMON: 5, + COMMON: 6, + TRASH: 7, + }, + }; +} + +describe("renderProfile", () => { + it.each([0, 1, 2, 3, 7])( + "renders an 800x840 PNG for rank %i", + async (rank) => { + const png = await renderProfile(data(rank), labels); + expect(png.subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true); + expect(png.readUInt32BE(16)).toBe(800); + expect(png.readUInt32BE(20)).toBe(840); + } + ); + + it("draws a different image depending on the rank", async () => { + const [none, gold, silver, other] = await Promise.all( + [0, 1, 2, 7].map((rank) => renderProfile(data(rank), labels)) + ); + expect(none?.equals(gold as Buffer)).toBe(false); + expect(gold?.equals(silver as Buffer)).toBe(false); + expect(silver?.equals(other as Buffer)).toBe(false); + }); +}); diff --git a/src/modules/rngdle/rendering/profile-image.ts b/src/modules/rngdle/rendering/profile-image.ts new file mode 100644 index 00000000..5cb9c854 --- /dev/null +++ b/src/modules/rngdle/rendering/profile-image.ts @@ -0,0 +1,232 @@ +import { createCanvas, type Image, type SKRSContext2D } from "@napi-rs/canvas"; +import { + drawCircularImage, + fillTextAnchored, + rgb, + type Rgb, +} from "#lib/imaging.js"; +import { formatSpaced } from "../services/format.js"; +import { + TIER_COLORS, + type Tier, + type TierOrError, +} from "../services/score-table.js"; +import { + PALETTE, + fillRoundedBox, + font, + loadMedals, + registerFonts, +} from "./common.js"; + +const WIDTH = 800; +const HEIGHT = 840; +const HEADER_HEIGHT = 150; + +const RANK_COLOR: Rgb = [150, 150, 150]; +const SUFFIX_COLOR: Rgb = [215, 215, 215]; + +const BREAKDOWN_TIERS: Tier[] = [ + "MYTHIC", + "ANOMALY", + "EPIC", + "RARE", + "UNCOMMON", + "COMMON", + "TRASH", +]; + +export interface ProfileRoll { + number: number; + score: number; + tier: TierOrError; + dateText: string; +} + +export interface ProfileImageData { + username: string; + avatar: Image | null; + serverRank: number; + totalPlayers: number; + best: ProfileRoll; + worst: ProfileRoll; + totalRolls: number; + averageScore: number; + averageTier: TierOrError; + maxBadges: number; + totalScore: number; + tierCounts: Record; +} + +export interface ProfileLabels { + bestRoll: string; + worstRoll: string; + date: (date: string) => string; + totalRolls: string; + averageScore: string; + maxBadges: string; + maxBadgesValue: (count: number) => string; + overallScore: string; + tierBreakdown: string; +} + +interface BoxOptions { + subtext?: string; + outline?: Rgb; + valueColor?: Rgb; + scoreSuffix?: string; +} + +function text( + ctx: SKRSContext2D, + value: string, + x: number, + y: number, + size: number, + color: Rgb +): void { + ctx.font = font(size); + ctx.fontKerning = "none"; + ctx.fillStyle = rgb(color); + ctx.textAlign = "left"; + fillTextAnchored(ctx, value, x, y, "ascender"); +} + +function textWidth(ctx: SKRSContext2D, value: string, size: number): number { + ctx.font = font(size); + ctx.fontKerning = "none"; + return ctx.measureText(value).width; +} + +function drawBox( + ctx: SKRSContext2D, + x: number, + y: number, + title: string, + value: string, + { subtext, outline, valueColor = PALETTE.text, scoreSuffix }: BoxOptions = {} +): void { + fillRoundedBox(ctx, x, y, 346, 111, outline); + text(ctx, title, x + 20, y + 15, 22, PALETTE.title); + text(ctx, value, x + 20, y + 45, 30, valueColor); + if (scoreSuffix) { + const valueWidth = textWidth(ctx, `${value} `, 30); + text(ctx, scoreSuffix, x + 20 + valueWidth, y + 45, 30, SUFFIX_COLOR); + } + if (subtext) { + text(ctx, subtext, x + 20, y + 80, 18, PALETTE.subtext); + } +} + +function drawRank( + ctx: SKRSContext2D, + rank: number, + totalPlayers: number, + medals: Image[] +): void { + const totalText = ` / ${totalPlayers}`; + const totalWidth = textWidth(ctx, totalText, 30); + const medal = medals[rank - 1]; + if (medal) { + const width = rank === 1 ? 90 : 93; + ctx.drawImage(medal, Math.trunc(760 - totalWidth - width), 15, width, 90); + } else { + const rankText = `#${rank}`; + const rankWidth = textWidth(ctx, rankText, 80); + text(ctx, rankText, 760 - totalWidth - rankWidth, 25, 80, RANK_COLOR); + } + text(ctx, totalText, 760 - totalWidth, 70, 30, PALETTE.subtext); +} + +function rollOptions(roll: ProfileRoll, labels: ProfileLabels): BoxOptions { + const color = TIER_COLORS[roll.tier]; + return { + subtext: labels.date(roll.dateText), + outline: color, + valueColor: color, + scoreSuffix: `(${formatSpaced(roll.score)} EP)`, + }; +} + +export async function renderProfile( + data: ProfileImageData, + labels: ProfileLabels +): Promise { + registerFonts(); + const medals = await loadMedals(); + + const canvas = createCanvas(WIDTH, HEIGHT); + const ctx = canvas.getContext("2d"); + + ctx.fillStyle = rgb(PALETTE.background); + ctx.fillRect(0, 0, WIDTH, HEIGHT); + ctx.fillStyle = rgb(PALETTE.header); + ctx.fillRect(0, 0, WIDTH, HEADER_HEIGHT + 1); + + drawCircularImage(ctx, data.avatar, 40, 25, 100); + + text(ctx, data.username, 170, 35, 50, PALETTE.text); + + if (data.serverRank > 0) { + drawRank(ctx, data.serverRank, data.totalPlayers, medals); + } + + drawBox( + ctx, + 40, + 180, + labels.bestRoll, + formatSpaced(data.best.number), + rollOptions(data.best, labels) + ); + drawBox( + ctx, + 415, + 180, + labels.worstRoll, + formatSpaced(data.worst.number), + rollOptions(data.worst, labels) + ); + + const averageColor = TIER_COLORS[data.averageTier]; + drawBox(ctx, 40, 310, labels.totalRolls, String(data.totalRolls)); + drawBox( + ctx, + 415, + 310, + labels.averageScore, + `${formatSpaced(data.averageScore)} EP`, + { outline: averageColor, valueColor: averageColor } + ); + drawBox( + ctx, + 40, + 440, + labels.maxBadges, + labels.maxBadgesValue(data.maxBadges) + ); + drawBox( + ctx, + 415, + 440, + labels.overallScore, + `${formatSpaced(data.totalScore)} EP` + ); + + ctx.beginPath(); + ctx.roundRect(40, 570, 721, 241, 12); + ctx.fillStyle = rgb(PALETTE.box); + ctx.fill(); + text(ctx, labels.tierBreakdown, 60, 585, 22, PALETTE.title); + + const columns = [80, 440]; + BREAKDOWN_TIERS.forEach((tier, index) => { + const x = columns[index < 4 ? 0 : 1] as number; + const y = 630 + 40 * (index < 4 ? index : index - 4); + text(ctx, `${tier}:`, x, y, 22, TIER_COLORS[tier]); + const count = formatSpaced(data.tierCounts[tier] ?? 0); + text(ctx, count, x + 220 - textWidth(ctx, count, 22), y, 22, PALETTE.text); + }); + + return canvas.encode("png"); +} diff --git a/src/modules/rngdle/rendering/server-stats-image.test.ts b/src/modules/rngdle/rendering/server-stats-image.test.ts new file mode 100644 index 00000000..a9975ac3 --- /dev/null +++ b/src/modules/rngdle/rendering/server-stats-image.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import type { Tier } from "../services/score-table.js"; +import { + renderServerStats, + type ServerStatsImageData, + type ServerStatsLabels, +} from "./server-stats-image.js"; + +const labels: ServerStatsLabels = { + title: "RNGdle - Server Stats", + bestRoll: "Best Roll OAT", + worstRoll: "Worst Roll OAT", + by: (name) => `by ${name}`, + totalRolls: "Total Rolls", + averageScore: "Average Score", + overallScore: "Overall Score", + tierBreakdown: "Tier Breakdown", +}; + +function buildData(leaders: Record): ServerStatsImageData { + const tierLeaders = Object.fromEntries( + Object.entries(leaders).map(([tier, count]) => [ + tier, + Array.from({ length: count }, () => null), + ]) + ) as ServerStatsImageData["tierLeaders"]; + return { + icon: null, + best: { + number: 131081274, + score: 4200000, + tier: "MYTHIC", + playerName: "Alice", + avatar: null, + }, + worst: { + number: 12, + score: 3, + tier: "TRASH", + playerName: "Bob", + avatar: null, + }, + totalRolls: 123456, + averageScore: 98765, + averageTier: "UNCOMMON", + overallScore: 123456789, + tierCounts: { + MYTHIC: 1, + ANOMALY: 20, + EPIC: 300, + RARE: 4000, + UNCOMMON: 50000, + COMMON: 600000, + TRASH: 7000000, + }, + tierLeaders, + }; +} + +function expectPng(png: Buffer): void { + expect(png.subarray(0, 8)).toEqual( + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) + ); + expect(png.readUInt32BE(16)).toBe(800); + expect(png.readUInt32BE(20)).toBe(710); +} + +describe("renderServerStats", () => { + it("renders a PNG with no tier leaders", async () => { + const png = await renderServerStats( + buildData({ + MYTHIC: 0, + ANOMALY: 0, + EPIC: 0, + RARE: 0, + UNCOMMON: 0, + COMMON: 0, + TRASH: 0, + }), + labels + ); + expectPng(png); + }); + + it("renders a PNG with one leader per tier", async () => { + const png = await renderServerStats( + buildData({ + MYTHIC: 1, + ANOMALY: 1, + EPIC: 1, + RARE: 1, + UNCOMMON: 1, + COMMON: 1, + TRASH: 1, + }), + labels + ); + expectPng(png); + }); + + it("renders a PNG with three leaders per tier and null images", async () => { + const png = await renderServerStats( + buildData({ + MYTHIC: 3, + ANOMALY: 3, + EPIC: 3, + RARE: 3, + UNCOMMON: 3, + COMMON: 3, + TRASH: 3, + }), + labels + ); + expectPng(png); + }); +}); diff --git a/src/modules/rngdle/rendering/server-stats-image.ts b/src/modules/rngdle/rendering/server-stats-image.ts new file mode 100644 index 00000000..04a39c83 --- /dev/null +++ b/src/modules/rngdle/rendering/server-stats-image.ts @@ -0,0 +1,205 @@ +import { createCanvas, type Image } from "@napi-rs/canvas"; +import { drawCircularImage, fillTextAnchored, rgb } from "#lib/imaging.js"; +import { formatSpaced } from "../services/format.js"; +import { + TIER_COLORS, + type Tier, + type TierOrError, +} from "../services/score-table.js"; +import { fillRoundedBox, font, PALETTE, registerFonts } from "./common.js"; + +const WIDTH = 800; +const HEIGHT = 710; +const HEADER_HEIGHT = 150; + +const SUFFIX_COLOR = "rgb(215, 215, 215)"; + +const BREAKDOWN_TIERS: Tier[] = [ + "MYTHIC", + "ANOMALY", + "EPIC", + "RARE", + "UNCOMMON", + "COMMON", + "TRASH", +]; + +export interface ServerStatsRoll { + number: number; + score: number; + tier: TierOrError; + playerName: string; + avatar: Image | null; +} + +export interface ServerStatsImageData { + icon: Image | null; + best: ServerStatsRoll; + worst: ServerStatsRoll; + totalRolls: number; + averageScore: number; + averageTier: TierOrError; + overallScore: number; + tierCounts: Record; + tierLeaders: Record; +} + +export interface ServerStatsLabels { + title: string; + bestRoll: string; + worstRoll: string; + by: (name: string) => string; + totalRolls: string; + averageScore: string; + overallScore: string; + tierBreakdown: string; +} + +export async function renderServerStats( + data: ServerStatsImageData, + labels: ServerStatsLabels +): Promise { + registerFonts(); + + const canvas = createCanvas(WIDTH, HEIGHT); + const ctx = canvas.getContext("2d"); + ctx.textAlign = "left"; + + ctx.fillStyle = rgb(PALETTE.background); + ctx.fillRect(0, 0, WIDTH, HEIGHT); + ctx.fillStyle = rgb(PALETTE.header); + ctx.fillRect(0, 0, WIDTH, HEADER_HEIGHT + 1); + + drawCircularImage(ctx, data.icon, 40, 25, 100); + + const text = ( + value: string, + x: number, + y: number, + size: number, + color: string + ): void => { + ctx.font = font(size); + ctx.fillStyle = color; + ctx.textAlign = "left"; + fillTextAnchored(ctx, value, x, y, "ascender"); + }; + + text(labels.title, 170, 45, 45, rgb(PALETTE.text)); + + const drawBox = ( + x: number, + y: number, + w: number, + h: number, + title: string, + value: string, + options: { + subtext?: string; + tier?: TierOrError; + suffix?: string; + avatar?: Image | null; + } = {} + ): void => { + const tierColor = options.tier ? TIER_COLORS[options.tier] : undefined; + fillRoundedBox(ctx, x, y, w + 1, h + 1, tierColor); + + text(title, x + 20, y + 15, 22, rgb(PALETTE.title)); + + if (options.avatar !== undefined) { + drawCircularImage(ctx, options.avatar, x + w - 32 - 15, y + 10, 32); + } + + text(value, x + 20, y + 45, 28, rgb(tierColor ?? PALETTE.text)); + + if (options.suffix) { + ctx.font = font(28); + const valueWidth = ctx.measureText(`${value} `).width; + text(options.suffix, x + 20 + valueWidth, y + 45, 28, SUFFIX_COLOR); + } + + if (options.subtext) { + text(options.subtext, x + 20, y + 80, 18, rgb(PALETTE.subtext)); + } + }; + + drawBox(40, 180, 345, 110, labels.bestRoll, formatSpaced(data.best.number), { + subtext: labels.by(data.best.playerName), + tier: data.best.tier, + suffix: `(${formatSpaced(data.best.score)} EP)`, + avatar: data.best.avatar, + }); + drawBox( + 415, + 180, + 345, + 110, + labels.worstRoll, + formatSpaced(data.worst.number), + { + subtext: labels.by(data.worst.playerName), + tier: data.worst.tier, + suffix: `(${formatSpaced(data.worst.score)} EP)`, + avatar: data.worst.avatar, + } + ); + + const boxWidth = 226; + drawBox( + 40, + 310, + boxWidth, + 110, + labels.totalRolls, + formatSpaced(data.totalRolls) + ); + drawBox( + 286, + 310, + boxWidth, + 110, + labels.averageScore, + `${formatSpaced(data.averageScore)} EP`, + { tier: data.averageTier } + ); + drawBox( + 532, + 310, + boxWidth, + 110, + labels.overallScore, + `${formatSpaced(data.overallScore)} EP` + ); + + fillRoundedBox(ctx, 40, 440, 721, 241); + text(labels.tierBreakdown, 60, 455, 22, rgb(PALETTE.title)); + + const columnX = [80, 440] as const; + const startY = 500; + + BREAKDOWN_TIERS.forEach((tier, i) => { + const color = rgb(TIER_COLORS[tier]); + const x = columnX[i < 4 ? 0 : 1]; + const y = startY + 45 * (i < 4 ? i : i - 4); + + text(`${tier}:`, x, y, 22, color); + + const count = formatSpaced(data.tierCounts[tier] ?? 0); + ctx.font = font(22); + const countWidth = ctx.measureText(count).width; + text(count, x + 220 - countWidth, y, 22, rgb(PALETTE.text)); + + const leaders = (data.tierLeaders[tier] ?? []).slice(0, 3); + for (let idx = leaders.length - 1; idx >= 0; idx--) { + const avatarX = x + 235 + idx * 14; + const avatarY = y - 1; + ctx.beginPath(); + ctx.arc(avatarX + 12, avatarY + 12, 14, 0, Math.PI * 2); + ctx.fillStyle = rgb(PALETTE.box); + ctx.fill(); + drawCircularImage(ctx, leaders[idx] ?? null, avatarX, avatarY, 24); + } + }); + + return canvas.encode("png"); +} diff --git a/src/modules/rngdle/rngdle.config.ts b/src/modules/rngdle/rngdle.config.ts new file mode 100644 index 00000000..b547db71 --- /dev/null +++ b/src/modules/rngdle/rngdle.config.ts @@ -0,0 +1,12 @@ +import { ConfigType, type ConfigSchema } from "#lib/config.js"; + +export const rngdleConfigSchema = { + leaderboardChannel: { + name: "Daily leaderboard channel", + description: + "Channel where yesterday's leaderboard is posted every day at 01:00 UTC.", + type: ConfigType.CHANNEL, + }, +} satisfies ConfigSchema; + +export type RngdleConfigSchema = typeof rngdleConfigSchema; diff --git a/src/modules/rngdle/rngdle.module.ts b/src/modules/rngdle/rngdle.module.ts new file mode 100644 index 00000000..0b9bbabf --- /dev/null +++ b/src/modules/rngdle/rngdle.module.ts @@ -0,0 +1,44 @@ +import { GatewayIntentBits } from "discord.js"; +import { loggerMaker } from "#lib/logger.js"; +import { defineModule } from "#lib/module.js"; +import rngdleAdminCommand from "./commands/rngdle-admin.command.js"; +import rngdleCommand from "./commands/rngdle.command.js"; +import overallPageButton from "./interactions/overall-page.button.js"; +import { rngdleConfigSchema } from "./rngdle.config.js"; +import { createDailyLeaderboardTask } from "./tasks/daily-leaderboard.task.js"; +import scoreTableTask from "./tasks/score-table.task.js"; +import syncTask from "./tasks/sync.task.js"; + +const logger = loggerMaker("rngdle"); + +const rngdleModule = defineModule({ + id: "rngdle", + name: "RNGdle", + description: + "Tracks the members' rngdle.com rolls: leaderboards, profiles, server statistics and a daily leaderboard.", + version: "1.0.0", + author: "LoicR", + + config: rngdleConfigSchema, + + intents: [GatewayIntentBits.Guilds], + + onLoad(_client, registry) { + registry.register(rngdleCommand); + registry.register(rngdleAdminCommand); + registry.register(overallPageButton); + registry.register(syncTask); + registry.register(scoreTableTask); + registry.register(createDailyLeaderboardTask(rngdleModule)); + }, + + onInstall(_client, guild) { + logger.info(`Module installed | guildId = ${guild.id}`); + }, + + onUninstall(_client, guild) { + logger.info(`Module uninstalled | guildId = ${guild.id}`); + }, +}); + +export default rngdleModule; diff --git a/src/modules/rngdle/rngdle.prisma b/src/modules/rngdle/rngdle.prisma new file mode 100644 index 00000000..ea593338 --- /dev/null +++ b/src/modules/rngdle/rngdle.prisma @@ -0,0 +1,27 @@ +model RngdleAccount { + guildId String + userId String + username String + + @@id([guildId, userId]) +} + +model RngdleRoll { + guildId String + rollId String + userId String + number Int + score BigInt + badgeCount Int @default(0) + rolledAt DateTime + + @@id([guildId, rollId]) + @@index([guildId, rolledAt]) + @@index([guildId, userId]) +} + +model RngdleScoreTable { + id Int @id @default(1) + data Json + updatedAt DateTime @updatedAt +} diff --git a/src/modules/rngdle/services/boards.test.ts b/src/modules/rngdle/services/boards.test.ts new file mode 100644 index 00000000..e1d67ec5 --- /dev/null +++ b/src/modules/rngdle/services/boards.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("#lib/database.js", () => ({ default: {} })); + +const { utcDayRange } = await import("./boards.js"); + +const iso = ([from, to]: [Date, Date]) => [ + from.toISOString(), + to.toISOString(), +]; + +describe("utcDayRange", () => { + it("returns the current UTC day for day 0", () => { + const now = new Date("2026-05-15T13:45:10.123Z"); + + expect(iso(utcDayRange(0, now))).toEqual([ + "2026-05-15T00:00:00.000Z", + "2026-05-16T00:00:00.000Z", + ]); + }); + + it("returns the previous UTC day for day 1", () => { + const now = new Date("2026-05-15T13:45:10.123Z"); + + expect(iso(utcDayRange(1, now))).toEqual([ + "2026-05-14T00:00:00.000Z", + "2026-05-15T00:00:00.000Z", + ]); + }); + + it("works just after midnight UTC", () => { + const now = new Date("2026-05-15T00:00:00.001Z"); + + expect(iso(utcDayRange(0, now))).toEqual([ + "2026-05-15T00:00:00.000Z", + "2026-05-16T00:00:00.000Z", + ]); + expect(iso(utcDayRange(1, now))).toEqual([ + "2026-05-14T00:00:00.000Z", + "2026-05-15T00:00:00.000Z", + ]); + }); + + it("works just before midnight UTC", () => { + const now = new Date("2026-05-15T23:59:59.999Z"); + + expect(iso(utcDayRange(1, now))).toEqual([ + "2026-05-14T00:00:00.000Z", + "2026-05-15T00:00:00.000Z", + ]); + }); + + it("crosses month boundaries", () => { + const now = new Date("2026-03-01T00:30:00.000Z"); + + expect(iso(utcDayRange(1, now))).toEqual([ + "2026-02-28T00:00:00.000Z", + "2026-03-01T00:00:00.000Z", + ]); + }); + + it("crosses year boundaries", () => { + const now = new Date("2027-01-01T00:00:05.000Z"); + + expect(iso(utcDayRange(1, now))).toEqual([ + "2026-12-31T00:00:00.000Z", + "2027-01-01T00:00:00.000Z", + ]); + }); + + it("handles leap days", () => { + const now = new Date("2028-03-01T10:00:00.000Z"); + + expect(iso(utcDayRange(1, now))).toEqual([ + "2028-02-29T00:00:00.000Z", + "2028-03-01T00:00:00.000Z", + ]); + }); + + it("defaults to the current time", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-07-04T12:00:00.000Z")); + try { + expect(iso(utcDayRange(0))).toEqual([ + "2026-07-04T00:00:00.000Z", + "2026-07-05T00:00:00.000Z", + ]); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/modules/rngdle/services/boards.ts b/src/modules/rngdle/services/boards.ts new file mode 100644 index 00000000..8620c59a --- /dev/null +++ b/src/modules/rngdle/services/boards.ts @@ -0,0 +1,300 @@ +import type { Client, Guild, User } from "discord.js"; +import type { TFunction } from "#lib/i18n.js"; +import { fetchAvatar, fetchImage } from "#lib/imaging.js"; +import { renderDailyLeaderboard } from "#modules/rngdle/rendering/daily-leaderboard-image.js"; +import { + renderOverallLeaderboard, + type OverallRow, +} from "#modules/rngdle/rendering/overall-leaderboard-image.js"; +import { renderProfile } from "#modules/rngdle/rendering/profile-image.js"; +import { renderServerStats } from "#modules/rngdle/rendering/server-stats-image.js"; +import imageCache from "./image-cache.service.js"; +import rngdleService, { type Account } from "./rngdle.service.js"; +import { TIERS, type Tier } from "./score-table.js"; +import scoreTableService from "./score-table.service.js"; +import { computeRollStats, computeServerStats } from "./stats.js"; + +export const DAILY_LEADERBOARD_SIZE = 25; +export const OVERALL_PAGE_SIZE = 10; + +export interface DailyLeaderboard { + image: Buffer; + winnerIds: string[]; +} + +export interface OverallPage { + image: Buffer; + page: number; + pageCount: number; +} + +function fetchUser(client: Client, userId: string): Promise { + return client.users.fetch(userId).catch(() => null); +} + +export function utcDayRange(daysAgo: number, now = new Date()): [Date, Date] { + const start = new Date( + Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() - daysAgo + ) + ); + const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); + return [start, end]; +} + +export async function buildDailyLeaderboard( + client: Client, + guildId: string, + [from, to]: [Date, Date], + t: TFunction +): Promise { + const rolls = (await rngdleService.rollsBetween(guildId, from, to)).slice( + 0, + DAILY_LEADERBOARD_SIZE + ); + const table = await scoreTableService.get(); + + const rows = ( + await Promise.all( + rolls.map(async (roll, index) => { + const user = await fetchUser(client, roll.userId); + if (!user) { + return null; + } + return { + rank: index + 1, + name: user.username, + avatar: await fetchAvatar(user), + number: roll.number, + score: roll.score, + percent: table.percentOf(roll.score), + tier: table.tierOf(roll.score), + }; + }) + ) + ).filter((row) => row !== null); + + const [top] = rolls; + if (!top || rows.length === 0) { + return null; + } + + const image = await renderDailyLeaderboard(rows, { + rank: t("leaderboard.header.rank"), + player: t("leaderboard.header.player"), + number: t("leaderboard.header.number"), + score: t("leaderboard.header.score"), + placement: t("leaderboard.header.placement"), + }); + const winnerIds = [ + ...new Set( + rolls + .filter((roll) => roll.score === top.score) + .map((roll) => roll.userId) + ), + ]; + return { image, winnerIds }; +} + +export async function buildOverallPage( + client: Client, + guildId: string, + requestedPage: number, + callerId: string, + t: TFunction, + locale: string +): Promise { + const totals = await rngdleService.totalsByUser(guildId); + if (totals.length === 0) { + return null; + } + const accounts = new Map( + (await rngdleService.listAccounts(guildId)).map((account) => [ + account.userId, + account.username, + ]) + ); + + const pageCount = Math.ceil(totals.length / OVERALL_PAGE_SIZE); + const page = Math.min(Math.max(requestedPage, 1), pageCount); + const start = (page - 1) * OVERALL_PAGE_SIZE; + + const toRow = async (index: number): Promise => { + const entry = totals[index]!; + const user = await fetchUser(client, entry.userId); + return { + rank: index + 1, + name: user?.username ?? accounts.get(entry.userId) ?? "?", + avatar: user ? await fetchAvatar(user) : null, + totalScore: entry.total, + }; + }; + + const indexes = totals + .slice(start, start + OVERALL_PAGE_SIZE) + .map((_, offset) => start + offset); + const rows = await Promise.all(indexes.map(toRow)); + + const callerIndex = totals.findIndex((entry) => entry.userId === callerId); + const callerOnPage = + callerIndex >= start && callerIndex < start + OVERALL_PAGE_SIZE; + const caller = + callerIndex >= 0 && !callerOnPage ? await toRow(callerIndex) : undefined; + + const image = await renderOverallLeaderboard( + rows, + { + rank: t("leaderboard.header.rank"), + player: t("leaderboard.header.player"), + overallScore: t("overall.header.score"), + }, + locale, + caller + ); + return { image, page, pageCount }; +} + +export function cachedOverallPage( + client: Client, + guildId: string, + page: number, + callerId: string, + t: TFunction, + locale: string +): Promise { + return imageCache.get(guildId, `overall:${locale}:${page}:${callerId}`, () => + buildOverallPage(client, guildId, page, callerId, t, locale) + ); +} + +export async function buildProfile( + account: Account, + user: User, + t: TFunction, + locale: string +): Promise { + const [rolls, totals, accounts, table] = await Promise.all([ + rngdleService.userRolls(account.guildId, account.userId), + rngdleService.totalsByUser(account.guildId), + rngdleService.listAccounts(account.guildId), + scoreTableService.get(), + ]); + const stats = computeRollStats(rolls, table); + if (!stats) { + return null; + } + + const formatDate = new Intl.DateTimeFormat(locale, { + day: "2-digit", + month: "short", + year: "numeric", + timeZone: "UTC", + }); + const toRoll = (roll: typeof stats.best) => ({ + number: roll.number, + score: roll.score, + tier: table.tierOf(roll.score), + dateText: formatDate.format(roll.rolledAt), + }); + + return renderProfile( + { + username: account.username, + avatar: await fetchAvatar(user), + serverRank: + totals.findIndex((entry) => entry.userId === account.userId) + 1, + totalPlayers: accounts.length, + best: toRoll(stats.best), + worst: toRoll(stats.worst), + totalRolls: stats.totalRolls, + averageScore: stats.averageScore, + averageTier: table.tierOf(stats.averageScore), + maxBadges: stats.maxBadges, + totalScore: stats.totalScore, + tierCounts: stats.tierCounts, + }, + { + bestRoll: t("profile.bestRoll"), + worstRoll: t("profile.worstRoll"), + date: (date) => t("profile.date", { date }), + totalRolls: t("profile.totalRolls"), + averageScore: t("profile.averageScore"), + maxBadges: t("profile.maxBadges"), + maxBadgesValue: (count) => t("profile.maxBadgesValue", { count }), + overallScore: t("profile.overallScore"), + tierBreakdown: t("profile.tierBreakdown"), + } + ); +} + +export async function buildServerStats( + client: Client, + guild: Guild, + t: TFunction +): Promise { + const [rolls, accounts, table] = await Promise.all([ + rngdleService.guildRolls(guild.id), + rngdleService.listAccounts(guild.id), + scoreTableService.get(), + ]); + const stats = computeServerStats(rolls, table); + if (!stats) { + return null; + } + + const usernames = new Map( + accounts.map((account) => [account.userId, account.username]) + ); + const avatarOf = async (userId: string, size: 64 | 128) => { + const user = await fetchUser(client, userId); + return user ? fetchAvatar(user, size) : null; + }; + const toRoll = async (roll: typeof stats.best) => ({ + number: roll.number, + score: roll.score, + tier: table.tierOf(roll.score), + playerName: usernames.get(roll.userId) ?? "?", + avatar: await avatarOf(roll.userId, 64), + }); + + const leaderAvatars = await Promise.all( + TIERS.map( + async (tier) => + [ + tier, + await Promise.all( + stats.tierLeaders[tier].map((userId) => avatarOf(userId, 64)) + ), + ] as const + ) + ); + + return renderServerStats( + { + icon: await fetchImage(guild.iconURL({ extension: "png", size: 128 })), + best: await toRoll(stats.best), + worst: await toRoll(stats.worst), + totalRolls: stats.totalRolls, + averageScore: stats.averageScore, + averageTier: table.tierOf(stats.averageScore), + overallScore: stats.totalScore, + tierCounts: stats.tierCounts, + tierLeaders: Object.fromEntries(leaderAvatars) as Record< + Tier, + Awaited>[] + >, + }, + { + title: t("server.title"), + bestRoll: t("server.bestRoll"), + worstRoll: t("server.worstRoll"), + by: (name) => t("server.by", { name }), + totalRolls: t("server.totalRolls"), + averageScore: t("server.averageScore"), + overallScore: t("server.overallScore"), + tierBreakdown: t("server.tierBreakdown"), + } + ); +} diff --git a/src/modules/rngdle/services/format.test.ts b/src/modules/rngdle/services/format.test.ts new file mode 100644 index 00000000..c53e4b42 --- /dev/null +++ b/src/modules/rngdle/services/format.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { formatCompact, formatShort, formatSpaced } from "./format.js"; + +describe("formatSpaced", () => { + it.each([ + [0, "0"], + [999, "999"], + [1000, "1 000"], + [1_234_567, "1 234 567"], + [-1_234_567, "-1 234 567"], + [-5, "-5"], + [1234.9, "1 234"], + ])("formats %s as %s", (value, text) => { + expect(formatSpaced(value)).toBe(text); + }); +}); + +describe("formatCompact", () => { + it.each([ + [0, "0"], + [999, "999"], + [1000, "1.0k"], + [1500, "1.5k"], + [2_000_000, "2.0M"], + [1_234_567_890, "1.2B"], + ])("formats %s as %s", (value, text) => { + expect(formatCompact(value)).toBe(text); + }); +}); + +describe("formatShort", () => { + it.each([ + [999, "en", "999"], + [1500, "en", "1.5k"], + [1000, "en", "1k"], + [2_000_000, "en", "2M"], + [2_500_000, "fr", "2.5M"], + [1_234_567_890, "en", "1.2B"], + [1_234_567_890, "fr", "1.2Md"], + [3_000_000_000, "fr", "3Md"], + [3_000_000_000, "de", "3B"], + ])("formats %s in %s as %s", (value, locale, text) => { + expect(formatShort(value, locale)).toBe(text); + }); +}); diff --git a/src/modules/rngdle/services/format.ts b/src/modules/rngdle/services/format.ts new file mode 100644 index 00000000..9fa1cac8 --- /dev/null +++ b/src/modules/rngdle/services/format.ts @@ -0,0 +1,36 @@ +export function formatSpaced(value: number): string { + const sign = value < 0 ? "-" : ""; + return ( + sign + + String(Math.trunc(Math.abs(value))).replace(/\B(?=(\d{3})+(?!\d))/g, " ") + ); +} + +const COMPACT_SUFFIXES = [ + [1_000_000_000, "B"], + [1_000_000, "M"], + [1_000, "k"], +] as const; + +export function formatCompact(value: number): string { + for (const [threshold, suffix] of COMPACT_SUFFIXES) { + if (value >= threshold) { + return `${(value / threshold).toFixed(1)}${suffix}`; + } + } + return String(value); +} + +export function formatShort(value: number, locale: string): string { + const suffixes = [ + [1_000_000_000, locale === "fr" ? "Md" : "B"], + [1_000_000, "M"], + [1_000, "k"], + ] as const; + for (const [threshold, suffix] of suffixes) { + if (value >= threshold) { + return `${(value / threshold).toFixed(1).replace(".0", "")}${suffix}`; + } + } + return String(value); +} diff --git a/src/modules/rngdle/services/image-cache.service.test.ts b/src/modules/rngdle/services/image-cache.service.test.ts new file mode 100644 index 00000000..1fe455c6 --- /dev/null +++ b/src/modules/rngdle/services/image-cache.service.test.ts @@ -0,0 +1,88 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const revisions = vi.hoisted(() => new Map()); +vi.mock("./rngdle.service.js", () => ({ + default: { getRevision: (guildId: string) => revisions.get(guildId) ?? 0 }, +})); + +const { default: cache } = await import("./image-cache.service.js"); + +let guild = 0; +let guildId = ""; + +beforeEach(() => { + guildId = `guild-${++guild}`; + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("ImageCacheService", () => { + it("renders once for repeated and concurrent requests", async () => { + const render = vi.fn(async () => Buffer.from("png")); + + await Promise.all([ + cache.get(guildId, "daily", render), + cache.get(guildId, "daily", render), + ]); + await cache.get(guildId, "daily", render); + + expect(render).toHaveBeenCalledTimes(1); + }); + + it("renders again once the guild data has changed", async () => { + const render = vi.fn(async () => Buffer.from("png")); + + await cache.get(guildId, "daily", render); + revisions.set(guildId, 1); + await cache.get(guildId, "daily", render); + + expect(render).toHaveBeenCalledTimes(2); + }); + + it("renders again after the cache lifetime", async () => { + const render = vi.fn(async () => Buffer.from("png")); + + await cache.get(guildId, "daily", render); + vi.advanceTimersByTime(14_000); + await cache.get(guildId, "daily", render); + expect(render).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(2_000); + await cache.get(guildId, "daily", render); + expect(render).toHaveBeenCalledTimes(2); + }); + + it("keeps one image per key and per guild", async () => { + const render = vi.fn(async () => Buffer.from("png")); + + await cache.get(guildId, "a", render); + await cache.get(guildId, "b", render); + await cache.get(`${guildId}-other`, "a", render); + + expect(render).toHaveBeenCalledTimes(3); + }); + + it("does not keep a failed render", async () => { + const failing = vi.fn(async () => { + throw new Error("render failed"); + }); + const render = vi.fn(async () => Buffer.from("png")); + + await expect(cache.get(guildId, "daily", failing)).rejects.toThrow(); + await expect(cache.get(guildId, "daily", render)).resolves.toBeInstanceOf( + Buffer + ); + expect(render).toHaveBeenCalledTimes(1); + }); + + it("returns the rendered value", async () => { + const buffer = Buffer.from("png"); + + await expect(cache.get(guildId, "daily", async () => buffer)).resolves.toBe( + buffer + ); + }); +}); diff --git a/src/modules/rngdle/services/image-cache.service.ts b/src/modules/rngdle/services/image-cache.service.ts new file mode 100644 index 00000000..75f01052 --- /dev/null +++ b/src/modules/rngdle/services/image-cache.service.ts @@ -0,0 +1,45 @@ +import { declareService, type Service } from "#lib/service.js"; +import rngdleService from "./rngdle.service.js"; + +const CACHE_TTL_MS = 15_000; + +interface CachedValue { + revision: number; + expiresAt: number; + value: Promise; +} + +class ImageCacheService implements Service { + private readonly entries = new Map(); + + get(guildId: string, key: string, render: () => Promise): Promise { + const fullKey = `${guildId}:${key}`; + const revision = rngdleService.getRevision(guildId); + const cached = this.entries.get(fullKey); + if ( + cached && + cached.revision === revision && + cached.expiresAt > Date.now() + ) { + return cached.value as Promise; + } + + for (const [entryKey, entry] of this.entries) { + if (entry.expiresAt <= Date.now()) { + this.entries.delete(entryKey); + } + } + + const value = render(); + const entry = { revision, expiresAt: Date.now() + CACHE_TTL_MS, value }; + this.entries.set(fullKey, entry); + value.catch(() => { + if (this.entries.get(fullKey) === entry) { + this.entries.delete(fullKey); + } + }); + return value; + } +} + +export default declareService(new ImageCacheService()); diff --git a/src/modules/rngdle/services/rngdle-api.test.ts b/src/modules/rngdle/services/rngdle-api.test.ts new file mode 100644 index 00000000..8c3843fe --- /dev/null +++ b/src/modules/rngdle/services/rngdle-api.test.ts @@ -0,0 +1,301 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + fetchScoreTableFromSite, + fetchUserRolls, + RngdleUserNotFoundError, +} from "./rngdle-api.js"; + +const fetchMock = + vi.fn<(url: string, init?: RequestInit) => Promise>(); + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +function json(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { status }); +} + +function remoteRoll(id: string, rolledAt: string, extra: object = {}) { + return { id, number: 1, totalScore: 10, rolledAt, ...extra }; +} + +function urlOf(call: number) { + return new URL(fetchMock.mock.calls[call]![0]); +} + +describe("fetchUserRolls", () => { + it("maps the API fields", async () => { + fetchMock.mockResolvedValueOnce( + json({ + rolls: [ + { + id: "r1", + number: 42, + totalScore: 1234, + badgeCount: 3, + rolledAt: "2026-03-04T05:06:07.000Z", + }, + remoteRoll("r2", "2026-03-03T00:00:00.000Z"), + ], + hasMore: false, + }) + ); + + const rolls = await fetchUserRolls("alice", null); + + expect(rolls).toEqual([ + { + id: "r1", + number: 42, + score: 1234, + badgeCount: 3, + rolledAt: new Date("2026-03-04T05:06:07.000Z"), + }, + { + id: "r2", + number: 1, + score: 10, + badgeCount: 0, + rolledAt: new Date("2026-03-03T00:00:00.000Z"), + }, + ]); + }); + + it("encodes the username and uses limit 10 on the first page", async () => { + fetchMock.mockResolvedValueOnce(json({ rolls: [], hasMore: false })); + + await fetchUserRolls("a b/c", null); + + expect(urlOf(0).pathname).toBe("/api/users/a%20b%2Fc/rolls"); + expect(urlOf(0).origin).toBe("https://www.rngdle.com"); + expect(urlOf(0).searchParams.get("limit")).toBe("10"); + expect(urlOf(0).searchParams.get("offset")).toBe("0"); + }); + + it("uses limit 100 and increasing offsets on later pages until hasMore is false", async () => { + const page = (ids: string[], hasMore: boolean) => + json({ + rolls: ids.map((id) => remoteRoll(id, "2026-01-01T00:00:00.000Z")), + hasMore, + }); + fetchMock + .mockResolvedValueOnce(page(["1", "2", "3"], true)) + .mockResolvedValueOnce(page(["4", "5"], true)) + .mockResolvedValueOnce(page(["6"], false)); + + const rolls = await fetchUserRolls("alice", null); + + expect(rolls.map((roll) => roll.id)).toEqual([ + "1", + "2", + "3", + "4", + "5", + "6", + ]); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect( + [0, 1, 2].map((call) => [ + urlOf(call).searchParams.get("limit"), + urlOf(call).searchParams.get("offset"), + ]) + ).toEqual([ + ["10", "0"], + ["100", "3"], + ["100", "5"], + ]); + }); + + it("stops when the oldest roll of a page is at or before since", async () => { + fetchMock.mockResolvedValue( + json({ + rolls: [ + remoteRoll("new", "2026-02-02T00:00:00.000Z"), + remoteRoll("old", "2026-02-01T00:00:00.000Z"), + ], + hasMore: true, + }) + ); + + const rolls = await fetchUserRolls( + "alice", + new Date("2026-02-01T00:00:00.000Z") + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(rolls).toHaveLength(2); + }); + + it("keeps fetching while the oldest roll is newer than since", async () => { + fetchMock + .mockResolvedValueOnce( + json({ + rolls: [remoteRoll("a", "2026-02-03T00:00:00.000Z")], + hasMore: true, + }) + ) + .mockResolvedValueOnce( + json({ + rolls: [remoteRoll("b", "2026-02-01T00:00:00.000Z")], + hasMore: true, + }) + ); + + const rolls = await fetchUserRolls( + "alice", + new Date("2026-02-02T00:00:00.000Z") + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(rolls.map((roll) => roll.id)).toEqual(["a", "b"]); + }); + + it("stops on an empty page even if hasMore is true", async () => { + fetchMock.mockResolvedValue(json({ rolls: [], hasMore: true })); + + const rolls = await fetchUserRolls("alice", null); + + expect(rolls).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("throws RngdleUserNotFoundError on 404", async () => { + fetchMock.mockResolvedValueOnce(new Response("nope", { status: 404 })); + + const error = await fetchUserRolls("ghost", null).catch( + (err: unknown) => err + ); + + expect(error).toBeInstanceOf(RngdleUserNotFoundError); + expect((error as RngdleUserNotFoundError).username).toBe("ghost"); + }); + + it("throws on any other non-OK status", async () => { + fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 })); + + const error = await fetchUserRolls("alice", null).catch( + (err: unknown) => err + ); + + expect(error).toBeInstanceOf(Error); + expect(error).not.toBeInstanceOf(RngdleUserNotFoundError); + expect((error as Error).message).toContain("500"); + }); + + it("sends an abort signal with every request", async () => { + fetchMock.mockResolvedValueOnce(json({ rolls: [], hasMore: false })); + + await fetchUserRolls("alice", null); + + expect(fetchMock.mock.calls[0]![1]?.signal).toBeInstanceOf(AbortSignal); + }); +}); + +function table(size: number, firstKey: number) { + const entries: string[] = []; + for (let i = 0; i < size; i++) { + entries.push(`${firstKey + i}:${((i / size) * 100).toFixed(4)}`); + } + return `{${entries.join(",")}}`; +} + +describe("fetchScoreTableFromSite", () => { + const home = ` + + + + `; + + function route(files: Record) { + fetchMock.mockImplementation(async (url) => { + const path = new URL(url).pathname; + if (path === "/") return new Response(home); + return files[path] ?? new Response("missing", { status: 404 }); + }); + } + + it("keeps the bigger valid table among the scripts", async () => { + route({ + "/a.js": new Response(`var t=${table(1100, 1000)};`), + "/b.js": new Response(`var t=${table(1500, 5000)};`), + }); + + const compressed = await fetchScoreTableFromSite(); + + expect(compressed).not.toBeNull(); + expect(Object.keys(compressed!).every((key) => Number(key) >= 5000)).toBe( + true + ); + expect(Object.keys(compressed!).length).toBeGreaterThan(0); + }); + + it("resolves relative script urls against the site", async () => { + route({ + "/a.js": new Response(`var t=${table(1100, 1000)};`), + "/b.js": new Response("nothing"), + }); + + await fetchScoreTableFromSite(); + + const requested = fetchMock.mock.calls.map(([url]) => url); + expect(requested).toContain("https://www.rngdle.com/a.js"); + expect(requested).toContain("https://www.rngdle.com/b.js"); + }); + + it("skips scripts that fail to load", async () => { + route({ "/b.js": new Response(`var t=${table(1100, 1000)};`) }); + + expect(await fetchScoreTableFromSite()).not.toBeNull(); + }); + + it("returns null when no script contains a table", async () => { + route({ + "/a.js": new Response("var a={1:2}"), + "/b.js": new Response("console.log(1)"), + }); + + expect(await fetchScoreTableFromSite()).toBeNull(); + }); + + it("throws when the home page fails", async () => { + fetchMock.mockResolvedValueOnce(new Response("down", { status: 503 })); + + await expect(fetchScoreTableFromSite()).rejects.toThrow("503"); + }); +}); + +describe("fetchScoreTableFromSite plausibility", () => { + it("returns null when the compressed table is not plausible", async () => { + const entries: string[] = []; + for (let i = 0; i < 1500; i++) { + entries.push(`${1000 + i}:${(100 - (i / 1500) * 100).toFixed(4)}`); + } + fetchMock.mockImplementation(async (url) => { + const path = new URL(url).pathname; + if (path === "/") return new Response(''); + return new Response(`var t={${entries.join(",")}};`); + }); + + expect(await fetchScoreTableFromSite()).toBeNull(); + }); + + it("returns null when the table has too few distinct steps", async () => { + const entries: string[] = []; + for (let i = 0; i < 1500; i++) { + entries.push(`${1000 + i}:${i < 750 ? "10.1" : "10.2"}`); + } + fetchMock.mockImplementation(async (url) => { + const path = new URL(url).pathname; + if (path === "/") return new Response(''); + return new Response(`var t={${entries.join(",")}};`); + }); + + expect(await fetchScoreTableFromSite()).toBeNull(); + }); +}); diff --git a/src/modules/rngdle/services/rngdle-api.ts b/src/modules/rngdle/services/rngdle-api.ts new file mode 100644 index 00000000..b933e1be --- /dev/null +++ b/src/modules/rngdle/services/rngdle-api.ts @@ -0,0 +1,118 @@ +import { + compressScoreTable, + extractScoreTable, + isPlausibleTable, +} from "./score-table.js"; +import type { CompressedTable } from "./score-table.js"; + +const SITE_URL = "https://www.rngdle.com"; +const REQUEST_TIMEOUT_MS = 30_000; +const FIRST_PAGE_SIZE = 10; +const PAGE_SIZE = 100; +const MAX_PAGES = 100; + +export interface RemoteRoll { + id: string; + number: number; + score: number; + badgeCount: number; + rolledAt: Date; +} + +interface RollsPage { + rolls: { + id: string; + number: number; + totalScore: number; + badgeCount?: number; + rolledAt: string; + }[]; + hasMore: boolean; +} + +export class RngdleUserNotFoundError extends Error { + constructor(readonly username: string) { + super(`RNGdle user not found: ${username}`); + } +} + +async function get(url: string): Promise { + const response = await fetch(url, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { Accept: "application/json, text/html, */*" }, + }); + return response; +} + +export async function fetchUserRolls( + username: string, + since: Date | null +): Promise { + const rolls: RemoteRoll[] = []; + let offset = 0; + let limit = FIRST_PAGE_SIZE; + + for (let page = 0; page < MAX_PAGES; page++) { + const url = `${SITE_URL}/api/users/${encodeURIComponent(username)}/rolls?limit=${limit}&offset=${offset}`; + const response = await get(url); + if (response.status === 404) { + throw new RngdleUserNotFoundError(username); + } + if (!response.ok) { + throw new Error( + `RNGdle API responded ${response.status} for ${username}` + ); + } + + const body = (await response.json()) as RollsPage; + for (const roll of body.rolls) { + rolls.push({ + id: roll.id, + number: roll.number, + score: roll.totalScore, + badgeCount: roll.badgeCount ?? 0, + rolledAt: new Date(roll.rolledAt), + }); + } + + const oldest = rolls.at(-1); + const reachedKnownRolls = + since !== null && oldest !== undefined && oldest.rolledAt <= since; + if (!body.hasMore || body.rolls.length === 0 || reachedKnownRolls) { + break; + } + offset += body.rolls.length; + limit = PAGE_SIZE; + } + + return rolls; +} + +export async function fetchScoreTableFromSite(): Promise { + const home = await get(SITE_URL); + if (!home.ok) { + throw new Error(`rngdle.com responded ${home.status}`); + } + const html = await home.text(); + const scripts = [ + ...html.matchAll(/]*\bsrc=["']([^"']+)["']/g), + ].map((match) => new URL(match[1]!, SITE_URL).href); + + let best: Map | null = null; + for (const script of scripts) { + const response = await get(script); + if (!response.ok) { + continue; + } + const table = extractScoreTable(await response.text()); + if (table && (!best || table.size > best.size)) { + best = table; + } + } + + if (!best) { + return null; + } + const compressed = compressScoreTable(best); + return isPlausibleTable(compressed) ? compressed : null; +} diff --git a/src/modules/rngdle/services/rngdle.service.test.ts b/src/modules/rngdle/services/rngdle.service.test.ts new file mode 100644 index 00000000..289543a4 --- /dev/null +++ b/src/modules/rngdle/services/rngdle.service.test.ts @@ -0,0 +1,507 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RemoteRoll } from "./rngdle-api.js"; + +interface AccountRow { + guildId: string; + userId: string; + username: string; +} + +interface RollRow { + guildId: string; + userId: string; + rollId: string; + number: number; + score: bigint; + badgeCount: number; + rolledAt: Date; +} + +const db = vi.hoisted(() => ({ + accounts: new Map(), + rolls: [] as unknown[], +})); + +const accounts = db.accounts as Map; +const accountKey = (guildId: string, userId: string) => `${guildId}:${userId}`; + +function rolls() { + return db.rolls as RollRow[]; +} + +vi.mock("#lib/database.js", () => ({ + default: { + async $transaction(operations: Promise[]) { + return Promise.all(operations); + }, + rngdleAccount: { + async findUnique({ + where, + }: { + where: { guildId_userId: { guildId: string; userId: string } }; + }) { + const { guildId, userId } = where.guildId_userId; + return accounts.get(accountKey(guildId, userId)) ?? null; + }, + async upsert({ + where, + create, + update, + }: { + where: { guildId_userId: { guildId: string; userId: string } }; + create: AccountRow; + update: Partial; + }) { + const { guildId, userId } = where.guildId_userId; + const key = accountKey(guildId, userId); + const existing = accounts.get(key); + const row = existing ? { ...existing, ...update } : create; + accounts.set(key, row); + return row; + }, + async deleteMany({ + where, + }: { + where: { guildId: string; userId: string }; + }) { + const removed = accounts.delete( + accountKey(where.guildId, where.userId) + ); + return { count: removed ? 1 : 0 }; + }, + }, + rngdleRoll: { + async deleteMany({ + where, + }: { + where: { guildId: string; userId?: string }; + }) { + const before = rolls().length; + db.rolls = rolls().filter( + (row) => + !( + row.guildId === where.guildId && + (where.userId === undefined || row.userId === where.userId) + ) + ); + return { count: before - rolls().length }; + }, + async findMany({ + where, + orderBy, + }: { + where: { + guildId: string; + userId?: string; + rollId?: { in: string[] }; + rolledAt?: { gte: Date; lt: Date }; + }; + orderBy?: unknown; + }) { + const matching = rolls().filter( + (row) => + row.guildId === where.guildId && + (where.userId === undefined || row.userId === where.userId) && + (where.rollId === undefined || + where.rollId.in.includes(row.rollId)) && + (where.rolledAt === undefined || + (row.rolledAt >= where.rolledAt.gte && + row.rolledAt < where.rolledAt.lt)) + ); + if (Array.isArray(orderBy)) { + matching.sort( + (a, b) => + Number(b.score - a.score) || + a.rolledAt.getTime() - b.rolledAt.getTime() + ); + } else if (orderBy) { + matching.sort((a, b) => a.rolledAt.getTime() - b.rolledAt.getTime()); + } + return matching.map((row) => ({ ...row })); + }, + async groupBy({ where }: { where: { guildId: string } }) { + const sums = new Map(); + for (const row of rolls()) { + if (row.guildId === where.guildId) { + sums.set(row.userId, (sums.get(row.userId) ?? 0n) + row.score); + } + } + return [...sums].map(([userId, sum]) => ({ + userId, + _sum: { score: sum }, + })); + }, + async createMany({ + data, + skipDuplicates, + }: { + data: RollRow[]; + skipDuplicates?: boolean; + }) { + let count = 0; + for (const row of data) { + const duplicate = rolls().some( + (existing) => + existing.guildId === row.guildId && existing.rollId === row.rollId + ); + if (duplicate) { + if (!skipDuplicates) throw new Error("unique constraint"); + continue; + } + rolls().push({ ...row }); + count++; + } + return { count }; + }, + async update({ + where, + data, + }: { + where: { guildId_rollId: { guildId: string; rollId: string } }; + data: Partial; + }) { + const { guildId, rollId } = where.guildId_rollId; + const row = rolls().find( + (existing) => + existing.guildId === guildId && existing.rollId === rollId + ); + if (!row) throw new Error("record not found"); + Object.assign(row, data); + return row; + }, + }, + }, +})); + +const { default: service } = await import("./rngdle.service.js"); + +let guild = 0; +let guildId = ""; + +beforeEach(() => { + db.accounts.clear(); + db.rolls = []; + guildId = `guild-${++guild}`; +}); + +const account = (userId: string, id = guildId) => ({ + guildId: id, + userId, + username: `name-${userId}`, +}); + +function remote( + id: string, + score = 100, + badgeCount = 0, + number = 1 +): RemoteRoll { + return { + id, + number, + score, + badgeCount, + rolledAt: new Date("2026-01-01T00:00:00.000Z"), + }; +} + +function rollIds(forGuild = guildId, userId?: string) { + return rolls() + .filter( + (row) => + row.guildId === forGuild && + (userId === undefined || row.userId === userId) + ) + .map((row) => row.rollId) + .sort(); +} + +describe("register", () => { + it("creates an account", async () => { + await expect(service.register(guildId, "u1", "alice")).resolves.toBe( + "created" + ); + + expect(accounts.get(accountKey(guildId, "u1"))?.username).toBe("alice"); + }); + + it("is unchanged when the username is the same", async () => { + await service.register(guildId, "u1", "alice"); + const revision = service.getRevision(guildId); + await service.saveRolls(account("u1"), [remote("r1")]); + const afterSave = service.getRevision(guildId); + + await expect(service.register(guildId, "u1", "alice")).resolves.toBe( + "unchanged" + ); + + expect(rollIds()).toEqual(["r1"]); + expect(service.getRevision(guildId)).toBe(afterSave); + expect(afterSave).toBeGreaterThan(revision); + }); + + it("renames the account and deletes the previous rolls of that guild only", async () => { + const other = `${guildId}-other`; + await service.register(guildId, "u1", "alice"); + await service.register(other, "u1", "alice"); + await service.register(guildId, "u2", "bob"); + await service.saveRolls(account("u1"), [remote("a1"), remote("a2")]); + await service.saveRolls(account("u1", other), [remote("b1")]); + await service.saveRolls(account("u2"), [remote("c1")]); + + await expect(service.register(guildId, "u1", "alice2")).resolves.toBe( + "renamed" + ); + + expect(accounts.get(accountKey(guildId, "u1"))?.username).toBe("alice2"); + expect(rollIds(guildId, "u1")).toEqual([]); + expect(rollIds(guildId, "u2")).toEqual(["c1"]); + expect(rollIds(other, "u1")).toEqual(["b1"]); + }); + + it("bumps the revision when something changes", async () => { + const before = service.getRevision(guildId); + + await service.register(guildId, "u1", "alice"); + + expect(service.getRevision(guildId)).toBeGreaterThan(before); + }); +}); + +describe("unregister", () => { + it("deletes the account and its rolls", async () => { + await service.register(guildId, "u1", "alice"); + await service.register(guildId, "u2", "bob"); + await service.saveRolls(account("u1"), [remote("a1")]); + await service.saveRolls(account("u2"), [remote("c1")]); + const before = service.getRevision(guildId); + + await expect(service.unregister(guildId, "u1")).resolves.toBe(true); + + expect(accounts.has(accountKey(guildId, "u1"))).toBe(false); + expect(rollIds()).toEqual(["c1"]); + expect(service.getRevision(guildId)).toBeGreaterThan(before); + }); + + it("returns false and keeps the revision when the account is absent", async () => { + const before = service.getRevision(guildId); + + await expect(service.unregister(guildId, "ghost")).resolves.toBe(false); + + expect(service.getRevision(guildId)).toBe(before); + }); +}); + +describe("saveRolls", () => { + beforeEach(async () => { + await service.register(guildId, "u1", "alice"); + }); + + it("returns zeros for an empty list without touching the revision", async () => { + const before = service.getRevision(guildId); + + await expect(service.saveRolls(account("u1"), [])).resolves.toEqual({ + inserted: 0, + updated: 0, + }); + + expect(service.getRevision(guildId)).toBe(before); + }); + + it("inserts new rolls with their owner", async () => { + const result = await service.saveRolls(account("u1"), [ + remote("r1", 10, 1, 5), + remote("r2", 20), + ]); + + expect(result).toEqual({ inserted: 2, updated: 0 }); + expect(rolls()).toHaveLength(2); + expect(rolls()[0]).toMatchObject({ + guildId, + userId: "u1", + rollId: "r1", + number: 5, + score: 10n, + badgeCount: 1, + }); + }); + + it("updates only the rolls whose score or badge count changed", async () => { + await service.saveRolls(account("u1"), [ + remote("r1", 10, 0), + remote("r2", 20, 0), + remote("r3", 30, 0), + ]); + + const result = await service.saveRolls(account("u1"), [ + remote("r1", 10, 0), + remote("r2", 21, 0), + remote("r3", 30, 2), + remote("r4", 40, 0), + ]); + + expect(result).toEqual({ inserted: 1, updated: 2 }); + const byId = new Map(rolls().map((row) => [row.rollId, row])); + expect(byId.get("r1")).toMatchObject({ score: 10n, badgeCount: 0 }); + expect(byId.get("r2")).toMatchObject({ score: 21n, badgeCount: 0 }); + expect(byId.get("r3")).toMatchObject({ score: 30n, badgeCount: 2 }); + expect(byId.get("r4")).toMatchObject({ score: 40n, badgeCount: 0 }); + }); + + it("is idempotent", async () => { + const batch = [remote("r1", 10, 1), remote("r2", 20, 0)]; + await service.saveRolls(account("u1"), batch); + const revision = service.getRevision(guildId); + + const result = await service.saveRolls(account("u1"), batch); + + expect(result).toEqual({ inserted: 0, updated: 0 }); + expect(rolls()).toHaveLength(2); + expect(service.getRevision(guildId)).toBe(revision); + }); + + it("treats the same roll id in another guild as a new roll", async () => { + const other = `${guildId}-other`; + await service.saveRolls(account("u1"), [remote("r1")]); + + const result = await service.saveRolls(account("u1", other), [ + remote("r1"), + ]); + + expect(result).toEqual({ inserted: 1, updated: 0 }); + expect(rolls()).toHaveLength(2); + }); +}); + +describe("getRevision", () => { + it("starts at 0 and is tracked per guild", async () => { + const other = `${guildId}-other`; + + expect(service.getRevision(guildId)).toBe(0); + + await service.register(guildId, "u1", "alice"); + + expect(service.getRevision(guildId)).toBe(1); + expect(service.getRevision(other)).toBe(0); + }); + + it("increases on every data change", async () => { + const seen = [service.getRevision(guildId)]; + const record = () => seen.push(service.getRevision(guildId)); + + await service.register(guildId, "u1", "alice"); + record(); + await service.saveRolls(account("u1"), [remote("r1", 10)]); + record(); + await service.saveRolls(account("u1"), [remote("r1", 11)]); + record(); + await service.clearRolls(guildId); + record(); + await service.unregister(guildId, "u1"); + record(); + + expect(seen).toEqual([0, 1, 2, 3, 4, 5]); + }); +}); + +describe("clearRolls", () => { + it("only removes the rolls of its guild and keeps accounts", async () => { + const other = `${guildId}-other`; + await service.register(guildId, "u1", "alice"); + await service.register(other, "u1", "alice"); + await service.saveRolls(account("u1"), [remote("a1"), remote("a2")]); + await service.saveRolls(account("u1", other), [remote("b1")]); + const otherRevision = service.getRevision(other); + + await expect(service.clearRolls(guildId)).resolves.toBe(2); + + expect(rollIds(guildId)).toEqual([]); + expect(rollIds(other)).toEqual(["b1"]); + expect(accounts.has(accountKey(guildId, "u1"))).toBe(true); + expect(service.getRevision(other)).toBe(otherRevision); + }); +}); + +describe("bigint scores", () => { + const BIG = 9_007_199_254_740_000; + + beforeEach(async () => { + await service.register(guildId, "u1", "alice"); + await service.register(guildId, "u2", "bob"); + }); + + const at = (id: string, score: number, day: number): RemoteRoll => ({ + id, + number: day, + score, + badgeCount: 0, + rolledAt: new Date(Date.UTC(2026, 0, day)), + }); + + it("stores scores as bigint", async () => { + await service.saveRolls(account("u1"), [at("r1", 5_000_000_000, 1)]); + + expect(rolls()[0]!.score).toBe(5_000_000_000n); + }); + + it("returns scores as numbers from userRolls and guildRolls", async () => { + await service.saveRolls(account("u1"), [ + at("r1", 5_000_000_000, 1), + at("r2", 7, 2), + ]); + await service.saveRolls(account("u2"), [at("r3", BIG, 3)]); + + const user = await service.userRolls(guildId, "u1"); + const all = await service.guildRolls(guildId); + + expect(user.map((roll) => roll.score)).toEqual([5_000_000_000, 7]); + expect(all.map((roll) => roll.score)).toEqual([5_000_000_000, 7, BIG]); + expect(typeof all[0]!.score).toBe("number"); + }); + + it("returns scores as numbers from rollsBetween, best first", async () => { + await service.saveRolls(account("u1"), [ + at("r1", 10, 1), + at("r2", 5_000_000_000, 2), + at("r3", 99, 5), + ]); + + const between = await service.rollsBetween( + guildId, + new Date(Date.UTC(2026, 0, 1)), + new Date(Date.UTC(2026, 0, 3)) + ); + + expect(between.map((roll) => roll.score)).toEqual([5_000_000_000, 10]); + }); + + it("compares stored bigint with incoming number when saving", async () => { + await service.saveRolls(account("u1"), [at("r1", 5_000_000_000, 1)]); + const revision = service.getRevision(guildId); + + const same = await service.saveRolls(account("u1"), [ + at("r1", 5_000_000_000, 1), + ]); + const changed = await service.saveRolls(account("u1"), [ + at("r1", 5_000_000_001, 1), + ]); + + expect(same).toEqual({ inserted: 0, updated: 0 }); + expect(changed).toEqual({ inserted: 0, updated: 1 }); + expect(rolls()[0]!.score).toBe(5_000_000_001n); + expect(service.getRevision(guildId)).toBe(revision + 1); + }); + + it("sums totals as numbers, best user first", async () => { + await service.saveRolls(account("u1"), [ + at("r1", 4_000_000_000, 1), + at("r2", 4_000_000_000, 2), + ]); + await service.saveRolls(account("u2"), [at("r3", 100, 3)]); + + await expect(service.totalsByUser(guildId)).resolves.toEqual([ + { userId: "u1", total: 8_000_000_000 }, + { userId: "u2", total: 100 }, + ]); + }); +}); diff --git a/src/modules/rngdle/services/rngdle.service.ts b/src/modules/rngdle/services/rngdle.service.ts new file mode 100644 index 00000000..3dbe8799 --- /dev/null +++ b/src/modules/rngdle/services/rngdle.service.ts @@ -0,0 +1,232 @@ +import prisma from "#lib/database.js"; +import { declareService, type Service } from "#lib/service.js"; +import type { RemoteRoll } from "./rngdle-api.js"; + +export interface Account { + guildId: string; + userId: string; + username: string; +} + +export interface StoredRoll { + userId: string; + number: number; + score: number; + badgeCount: number; + rolledAt: Date; +} + +export interface UserTotal { + userId: string; + total: number; +} + +export type RegisterResult = "created" | "renamed" | "unchanged"; + +const rollFields = { + userId: true, + number: true, + score: true, + badgeCount: true, + rolledAt: true, +} as const; + +function toStoredRoll(roll: { + userId: string; + number: number; + score: bigint; + badgeCount: number; + rolledAt: Date; +}): StoredRoll { + return { ...roll, score: Number(roll.score) }; +} + +class RngdleService implements Service { + private readonly revisions = new Map(); + + getRevision(guildId: string): number { + return this.revisions.get(guildId) ?? 0; + } + + private bumpRevision(guildId: string): void { + this.revisions.set(guildId, this.getRevision(guildId) + 1); + } + + async register( + guildId: string, + userId: string, + username: string + ): Promise { + const existing = await prisma.rngdleAccount.findUnique({ + where: { guildId_userId: { guildId, userId } }, + }); + if (existing?.username === username) { + return "unchanged"; + } + + await prisma.$transaction([ + prisma.rngdleRoll.deleteMany({ where: { guildId, userId } }), + prisma.rngdleAccount.upsert({ + where: { guildId_userId: { guildId, userId } }, + create: { guildId, userId, username }, + update: { username }, + }), + ]); + this.bumpRevision(guildId); + return existing ? "renamed" : "created"; + } + + async unregister(guildId: string, userId: string): Promise { + const [, deleted] = await prisma.$transaction([ + prisma.rngdleRoll.deleteMany({ where: { guildId, userId } }), + prisma.rngdleAccount.deleteMany({ where: { guildId, userId } }), + ]); + if (deleted.count === 0) { + return false; + } + this.bumpRevision(guildId); + return true; + } + + listAccounts(guildId: string): Promise { + return prisma.rngdleAccount.findMany({ + where: { guildId }, + orderBy: { username: "asc" }, + }); + } + + listAllAccounts(): Promise { + return prisma.rngdleAccount.findMany(); + } + + getAccount(guildId: string, userId: string): Promise { + return prisma.rngdleAccount.findUnique({ + where: { guildId_userId: { guildId, userId } }, + }); + } + + findAccountByUsername( + guildId: string, + username: string + ): Promise { + return prisma.rngdleAccount.findFirst({ + where: { guildId, username: { equals: username, mode: "insensitive" } }, + }); + } + + async clearRolls(guildId: string): Promise { + const { count } = await prisma.rngdleRoll.deleteMany({ + where: { guildId }, + }); + this.bumpRevision(guildId); + return count; + } + + async latestRollDate(guildId: string, userId: string): Promise { + const latest = await prisma.rngdleRoll.findFirst({ + where: { guildId, userId }, + orderBy: { rolledAt: "desc" }, + select: { rolledAt: true }, + }); + return latest?.rolledAt ?? null; + } + + async saveRolls( + account: Account, + rolls: RemoteRoll[] + ): Promise<{ inserted: number; updated: number }> { + const { guildId, userId } = account; + if (rolls.length === 0) { + return { inserted: 0, updated: 0 }; + } + + const existing = await prisma.rngdleRoll.findMany({ + where: { guildId, rollId: { in: rolls.map((roll) => roll.id) } }, + select: { rollId: true, score: true, badgeCount: true }, + }); + const known = new Map(existing.map((roll) => [roll.rollId, roll])); + + const fresh = rolls.filter((roll) => !known.has(roll.id)); + const changed = rolls.filter((roll) => { + const stored = known.get(roll.id); + return ( + stored !== undefined && + (stored.score !== BigInt(roll.score) || + stored.badgeCount !== roll.badgeCount) + ); + }); + + const [created] = await prisma.$transaction([ + prisma.rngdleRoll.createMany({ + data: fresh.map((roll) => ({ + guildId, + userId, + rollId: roll.id, + number: roll.number, + score: BigInt(roll.score), + badgeCount: roll.badgeCount, + rolledAt: roll.rolledAt, + })), + skipDuplicates: true, + }), + ...changed.map((roll) => + prisma.rngdleRoll.update({ + where: { guildId_rollId: { guildId, rollId: roll.id } }, + data: { score: BigInt(roll.score), badgeCount: roll.badgeCount }, + }) + ), + ]); + + if (created.count > 0 || changed.length > 0) { + this.bumpRevision(guildId); + } + return { inserted: created.count, updated: changed.length }; + } + + async rollsBetween( + guildId: string, + from: Date, + to: Date + ): Promise { + const rolls = await prisma.rngdleRoll.findMany({ + where: { guildId, rolledAt: { gte: from, lt: to } }, + orderBy: [{ score: "desc" }, { rolledAt: "asc" }], + select: rollFields, + }); + return rolls.map(toStoredRoll); + } + + async userRolls(guildId: string, userId: string): Promise { + const rolls = await prisma.rngdleRoll.findMany({ + where: { guildId, userId }, + orderBy: { rolledAt: "asc" }, + select: rollFields, + }); + return rolls.map(toStoredRoll); + } + + async guildRolls(guildId: string): Promise { + const rolls = await prisma.rngdleRoll.findMany({ + where: { guildId }, + orderBy: { rolledAt: "asc" }, + select: rollFields, + }); + return rolls.map(toStoredRoll); + } + + async totalsByUser(guildId: string): Promise { + const groups = await prisma.rngdleRoll.groupBy({ + by: ["userId"], + where: { guildId }, + _sum: { score: true }, + }); + return groups + .map((group) => ({ + userId: group.userId, + total: Number(group._sum.score ?? 0n), + })) + .sort((a, b) => b.total - a.total || a.userId.localeCompare(b.userId)); + } +} + +export default declareService(new RngdleService()); diff --git a/src/modules/rngdle/services/score-table.service.ts b/src/modules/rngdle/services/score-table.service.ts new file mode 100644 index 00000000..89eef50e --- /dev/null +++ b/src/modules/rngdle/services/score-table.service.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import prisma from "#lib/database.js"; +import { loggerMaker } from "#lib/logger.js"; +import { declareService, type Service } from "#lib/service.js"; +import { fetchScoreTableFromSite } from "./rngdle-api.js"; +import { ScoreTable, type CompressedTable } from "./score-table.js"; + +const logger = loggerMaker("rngdle"); + +const SNAPSHOT = new URL("../assets/score-table.json", import.meta.url); + +function loadSnapshot(): CompressedTable { + return JSON.parse(readFileSync(SNAPSHOT, "utf8")) as CompressedTable; +} + +class ScoreTableService implements Service { + private current: Promise | null = null; + + get(): Promise { + if (!this.current) { + this.current = this.load(); + this.current.catch(() => { + this.current = null; + }); + } + return this.current; + } + + private async load(): Promise { + const stored = await prisma.rngdleScoreTable.findUnique({ + where: { id: 1 }, + }); + return new ScoreTable( + stored ? (stored.data as CompressedTable) : loadSnapshot() + ); + } + + async refresh(): Promise { + const fetched = await fetchScoreTableFromSite(); + if (!fetched) { + logger.warn( + "Score table not found on rngdle.com, keeping the current one" + ); + return false; + } + + const current = await this.get(); + if (current.equals(fetched)) { + return false; + } + + await prisma.rngdleScoreTable.upsert({ + where: { id: 1 }, + create: { id: 1, data: fetched }, + update: { data: fetched }, + }); + this.current = Promise.resolve(new ScoreTable(fetched)); + logger.info( + `Score table updated | entries = ${Object.keys(fetched).length}` + ); + return true; + } +} + +export default declareService(new ScoreTableService()); diff --git a/src/modules/rngdle/services/score-table.test.ts b/src/modules/rngdle/services/score-table.test.ts new file mode 100644 index 00000000..fd18dc87 --- /dev/null +++ b/src/modules/rngdle/services/score-table.test.ts @@ -0,0 +1,267 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { + compressScoreTable, + extractScoreTable, + formatPercent, + isPlausibleTable, + ScoreTable, + type CompressedTable, +} from "./score-table.js"; + +function bigTable(size: number) { + const entries: string[] = []; + for (let i = 0; i < size; i++) { + const key = 100_000 + i; + const rendered = + i === 0 ? "1e5" : i === 1 ? `0x${key.toString(16)}` : String(key); + const value = i === 2 ? ".0083" : "0.5"; + entries.push(`${rendered}:${value}`); + } + return `{${entries.join(",")}}`; +} + +describe("extractScoreTable", () => { + it("finds the table inside a larger blob and parses exotic notations", () => { + const source = `var a=function(){return 1};const t=${bigTable(1200)};export{t};`; + + const table = extractScoreTable(source); + + expect(table).not.toBeNull(); + expect(table!.size).toBe(1200); + expect(table!.get(100_000)).toBe(0.5); + expect(table!.get(100_001)).toBe(0.5); + expect(table!.get(100_002)).toBe(0.0083); + }); + + it("ignores small dict-like objects", () => { + expect( + extractScoreTable("var a={1:2,3:4,5:6};var b={0x10:1.5}") + ).toBeNull(); + }); + + it("returns null when there is no table", () => { + expect(extractScoreTable("console.log('hello')")).toBeNull(); + expect(extractScoreTable("")).toBeNull(); + }); + + it("ignores a table that is never closed", () => { + const unclosed = bigTable(1200).slice(0, -1); + + expect(extractScoreTable(`var a=${unclosed}`)).toBeNull(); + }); + + it("keeps the biggest valid table", () => { + const source = `var a=${bigTable(1100)};var b=${bigTable(1500)};var c=${bigTable(1050)};var d={1:2}`; + + expect(extractScoreTable(source)!.size).toBe(1500); + }); +}); + +describe("compressScoreTable", () => { + it("truncates to 0.5% steps and keeps the lowest score per step", () => { + const table = new Map([ + [100, 0.1], + [200, 0.4], + [50, 0.7], + [300, 0.6], + [400, 1.2], + [500, 99.99], + ]); + + expect(compressScoreTable(table)).toEqual({ + "100": 0, + "50": 0.5, + "400": 1, + "500": 99.5, + }); + }); + + it("returns an empty object for an empty table", () => { + expect(compressScoreTable(new Map())).toEqual({}); + }); +}); + +describe("ScoreTable", () => { + const table = new ScoreTable({ "100": 10, "200": 50, "300": 90 }); + + it("throws on an empty table", () => { + expect(() => new ScoreTable({})).toThrow("empty"); + }); + + describe("percentOf", () => { + it("returns the percent of an exact key", () => { + expect(table.percentOf(200)).toBe(50); + }); + + it("returns the percent of the closest lower key between keys", () => { + expect(table.percentOf(250)).toBe(50); + expect(table.percentOf(101)).toBe(10); + }); + + it("returns the lowest percent below the lowest key", () => { + expect(table.percentOf(5)).toBe(10); + expect(table.percentOf(-50)).toBe(10); + }); + + it("returns the highest percent above the highest key", () => { + expect(table.percentOf(10_000)).toBe(90); + }); + + it("sorts keys numerically", () => { + const unsorted = new ScoreTable({ "1000": 80, "9": 5, "100": 40 }); + + expect(unsorted.percentOf(500)).toBe(40); + expect(unsorted.percentOf(1000)).toBe(80); + }); + }); + + describe("tierOf", () => { + const tierAt = (percent: number) => + new ScoreTable({ "0": percent }).tierOf(0); + + it.each([ + [0, "TRASH"], + [0.99, "TRASH"], + [1, "COMMON"], + [49.5, "COMMON"], + [50, "UNCOMMON"], + [74.5, "UNCOMMON"], + [75, "RARE"], + [89.5, "RARE"], + [90, "EPIC"], + [94.5, "EPIC"], + [95, "ANOMALY"], + [98.5, "ANOMALY"], + [99, "MYTHIC"], + [99.5, "MYTHIC"], + [100, "ERROR"], + [-1, "ERROR"], + ])("maps %s%% to %s", (percent, tier) => { + expect(tierAt(percent)).toBe(tier); + }); + + it("returns ERROR for a negative score", () => { + expect(table.tierOf(-1)).toBe("ERROR"); + }); + + it("returns ERROR for a negative score even when the percent is valid", () => { + expect(new ScoreTable({ "-5": 50 }).tierOf(-1)).toBe("ERROR"); + }); + }); + + describe("equals", () => { + it("is true for the same content", () => { + expect(table.equals({ "300": 90, "100": 10, "200": 50 })).toBe(true); + }); + + it("is false for a different value", () => { + expect(table.equals({ "100": 10, "200": 51, "300": 90 })).toBe(false); + }); + + it("is false for a different size", () => { + expect(table.equals({ "100": 10, "200": 50 })).toBe(false); + expect(table.equals({ "100": 10, "200": 50, "300": 90, "400": 95 })).toBe( + false + ); + }); + + it("is false for different keys with the same size", () => { + expect(table.equals({ "100": 10, "200": 50, "301": 90 })).toBe(false); + }); + }); +}); + +describe("formatPercent", () => { + it.each([ + [99.5, "0%"], + [99, "1%"], + [90.5, "9%"], + [50.5, "49%"], + [50, "50%"], + [49.5, "50%"], + [0.5, "1%"], + [0, "0%"], + ])("formats %s as %s", (percent, text) => { + expect(formatPercent(percent)).toBe(text); + }); +}); + +describe("real score table snapshot", () => { + const snapshot = JSON.parse( + readFileSync(new URL("../assets/score-table.json", import.meta.url), "utf8") + ) as CompressedTable; + const table = new ScoreTable(snapshot); + + it("maps a very high score to MYTHIC", () => { + expect(table.tierOf(131_081_274)).toBe("MYTHIC"); + }); + + it("maps a zero score to TRASH", () => { + expect(table.tierOf(0)).toBe("TRASH"); + }); + + it("never lowers the percent as the score grows", () => { + let previous = -1; + for (const score of [0, 2_000, 10_000, 50_000, 162_292, 1_000_000]) { + const percent = table.percentOf(score); + expect(percent).toBeGreaterThanOrEqual(previous); + previous = percent; + } + }); + + it("round-trips through equals", () => { + expect(table.equals(snapshot)).toBe(true); + }); +}); + +describe("isPlausibleTable", () => { + const build = (size: number, percent = (i: number) => i * 2) => + Object.fromEntries( + Array.from({ length: size }, (_, i) => [String(i * 100), percent(i)]) + ) as CompressedTable; + + it("accepts a monotonic table of 20 entries", () => { + expect(isPlausibleTable(build(20))).toBe(true); + }); + + it("accepts equal consecutive percents", () => { + expect(isPlausibleTable(build(30, () => 50))).toBe(true); + }); + + it("accepts the bounds 0 and 100", () => { + expect(isPlausibleTable(build(21, (i) => (i * 100) / 20))).toBe(true); + }); + + it("rejects fewer than 20 entries", () => { + expect(isPlausibleTable(build(19))).toBe(false); + expect(isPlausibleTable({})).toBe(false); + }); + + it("rejects a percent above 100", () => { + expect(isPlausibleTable(build(25, (i) => (i === 24 ? 100.5 : i)))).toBe( + false + ); + }); + + it("rejects a negative percent", () => { + expect(isPlausibleTable(build(25, (i) => (i === 0 ? -1 : i)))).toBe(false); + }); + + it("rejects percents decreasing as the score increases", () => { + expect(isPlausibleTable(build(25, (i) => (i === 10 ? 1 : i * 2)))).toBe( + false + ); + }); + + it("accepts the real snapshot", () => { + const snapshot = JSON.parse( + readFileSync( + new URL("../assets/score-table.json", import.meta.url), + "utf8" + ) + ) as CompressedTable; + + expect(isPlausibleTable(snapshot)).toBe(true); + }); +}); diff --git a/src/modules/rngdle/services/score-table.ts b/src/modules/rngdle/services/score-table.ts new file mode 100644 index 00000000..c6ff4a24 --- /dev/null +++ b/src/modules/rngdle/services/score-table.ts @@ -0,0 +1,157 @@ +import type { Rgb } from "#lib/imaging.js"; + +export const TIERS = [ + "TRASH", + "COMMON", + "UNCOMMON", + "RARE", + "EPIC", + "ANOMALY", + "MYTHIC", +] as const; + +export type Tier = (typeof TIERS)[number]; + +export type TierOrError = Tier | "ERROR"; + +export const TIER_COLORS: Record = { + TRASH: [229, 126, 98], + COMMON: [229, 231, 235], + UNCOMMON: [94, 233, 181], + RARE: [142, 197, 255], + EPIC: [218, 178, 255], + ANOMALY: [255, 137, 4], + MYTHIC: [253, 165, 213], + ERROR: [255, 41, 41], +}; + +export type CompressedTable = Record; + +const TABLE_START = + /\{(?:0x[0-9a-fA-F]+|\d+(?:e\d+)?)\s*:\s*(?:\d+(?:\.\d+)?|\.\d+)\s*[,}]/g; +const TABLE_ENTRY = + /(0x[0-9a-fA-F]+|\d+(?:e\d+)?)\s*:\s*(\d+(?:\.\d+)?|\.\d+)\s*(,\s*|\})/y; + +const MIN_TABLE_ENTRIES = 1_000; + +export function extractScoreTable(source: string): Map | null { + let best: Map | null = null; + + for (const start of source.matchAll(TABLE_START)) { + const table = new Map(); + TABLE_ENTRY.lastIndex = start.index + 1; + let closed = false; + let entry: RegExpExecArray | null; + while ((entry = TABLE_ENTRY.exec(source))) { + table.set(Number(entry[1]), Number(entry[2])); + if (entry[3] === "}") { + closed = true; + break; + } + } + if (closed && table.size >= MIN_TABLE_ENTRIES) { + if (!best || table.size > best.size) { + best = table; + } + } + } + + return best; +} + +export function compressScoreTable( + table: Map +): CompressedTable { + const lowestScoreByPercent = new Map(); + for (const [score, percent] of table) { + const rounded = Math.trunc(percent * 2) / 2; + const current = lowestScoreByPercent.get(rounded); + if (current === undefined || score < current) { + lowestScoreByPercent.set(rounded, score); + } + } + + const compressed: CompressedTable = {}; + for (const [percent, score] of lowestScoreByPercent) { + compressed[String(score)] = percent; + } + return compressed; +} + +const MIN_COMPRESSED_ENTRIES = 20; + +export function isPlausibleTable(table: CompressedTable): boolean { + const entries = Object.entries(table) + .map(([score, percent]) => [Number(score), percent] as const) + .sort((a, b) => a[0] - b[0]); + return ( + entries.length >= MIN_COMPRESSED_ENTRIES && + entries.every( + ([score, percent], index) => + Number.isFinite(score) && + percent >= 0 && + percent <= 100 && + (index === 0 || percent >= entries[index - 1]![1]) + ) + ); +} + +export class ScoreTable { + private readonly scores: number[]; + private readonly percents: number[]; + + constructor(readonly compressed: CompressedTable) { + const entries = Object.entries(compressed) + .map(([score, percent]) => [Number(score), percent] as const) + .sort((a, b) => a[0] - b[0]); + if (entries.length === 0) { + throw new Error("The score table is empty"); + } + this.scores = entries.map(([score]) => score); + this.percents = entries.map(([, percent]) => percent); + } + + percentOf(score: number): number { + let low = 0; + let high = this.scores.length; + while (low < high) { + const mid = (low + high) >>> 1; + if (this.scores[mid]! <= score) { + low = mid + 1; + } else { + high = mid; + } + } + return this.percents[Math.max(low - 1, 0)]!; + } + + tierOf(score: number): TierOrError { + if (score < 0) { + return "ERROR"; + } + const percent = this.percentOf(score); + if (percent < 0) return "ERROR"; + if (percent < 1) return "TRASH"; + if (percent < 50) return "COMMON"; + if (percent < 75) return "UNCOMMON"; + if (percent < 90) return "RARE"; + if (percent < 95) return "EPIC"; + if (percent < 99) return "ANOMALY"; + if (percent < 100) return "MYTHIC"; + return "ERROR"; + } + + equals(other: CompressedTable): boolean { + const keys = Object.keys(this.compressed); + return ( + keys.length === Object.keys(other).length && + keys.every((key) => other[key] === this.compressed[key]) + ); + } +} + +export function formatPercent(percent: number): string { + return percent > 50 + ? `${Math.floor(100 - percent)}%` + : `${Math.ceil(percent)}%`; +} diff --git a/src/modules/rngdle/services/stats.test.ts b/src/modules/rngdle/services/stats.test.ts new file mode 100644 index 00000000..72247d1e --- /dev/null +++ b/src/modules/rngdle/services/stats.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import type { StoredRoll } from "./rngdle.service.js"; +import { ScoreTable } from "./score-table.js"; +import { computeRollStats, computeServerStats } from "./stats.js"; + +const table = new ScoreTable({ + "0": 0, + "100": 10, + "200": 60, + "300": 80, + "400": 92, + "500": 97, + "600": 99.5, +}); + +let counter = 0; +function roll( + userId: string, + score: number, + badgeCount = 0, + number = ++counter +): StoredRoll { + return { + userId, + number, + score, + badgeCount, + rolledAt: new Date(Date.UTC(2026, 0, number)), + }; +} + +describe("computeRollStats", () => { + it("returns null without rolls", () => { + expect(computeRollStats([], table)).toBeNull(); + }); + + it("aggregates a single roll", () => { + const only = roll("a", 250, 2, 7); + + const stats = computeRollStats([only], table)!; + + expect(stats.totalRolls).toBe(1); + expect(stats.totalScore).toBe(250); + expect(stats.averageScore).toBe(250); + expect(stats.best).toEqual({ + userId: "a", + number: 7, + score: 250, + rolledAt: only.rolledAt, + }); + expect(stats.worst).toEqual(stats.best); + expect(stats.maxBadges).toBe(2); + }); + + it("computes totals, truncated average, extremes and max badges", () => { + const rolls = [ + roll("a", 100, 1), + roll("b", 601, 5), + roll("a", 50, 3), + roll("c", 10, 0), + ]; + + const stats = computeRollStats(rolls, table)!; + + expect(stats.totalRolls).toBe(4); + expect(stats.totalScore).toBe(761); + expect(stats.averageScore).toBe(190); + expect(stats.best.score).toBe(601); + expect(stats.best.userId).toBe("b"); + expect(stats.worst.score).toBe(10); + expect(stats.worst.userId).toBe("c"); + expect(stats.maxBadges).toBe(5); + }); + + it("picks the first occurrence on ties", () => { + const rolls = [ + roll("a", 300, 0, 1), + roll("b", 300, 0, 2), + roll("c", 300, 0, 3), + ]; + + const stats = computeRollStats(rolls, table)!; + + expect(stats.best.userId).toBe("a"); + expect(stats.worst.userId).toBe("a"); + }); + + it("counts tiers and excludes ERROR rolls", () => { + const rolls = [ + roll("a", 0), + roll("a", 5), + roll("a", 100), + roll("a", 250), + roll("a", 350), + roll("a", 450), + roll("a", 550), + roll("a", 650), + roll("a", 700), + roll("a", -1), + ]; + + const stats = computeRollStats(rolls, table)!; + + expect(stats.totalRolls).toBe(10); + expect(stats.tierCounts).toEqual({ + TRASH: 2, + COMMON: 1, + UNCOMMON: 1, + RARE: 1, + EPIC: 1, + ANOMALY: 1, + MYTHIC: 2, + }); + }); + + it("reports every tier with a zero count when nothing matches", () => { + const stats = computeRollStats([roll("a", 100)], table)!; + + expect(stats.tierCounts).toEqual({ + TRASH: 0, + COMMON: 1, + UNCOMMON: 0, + RARE: 0, + EPIC: 0, + ANOMALY: 0, + MYTHIC: 0, + }); + }); +}); + +describe("computeServerStats", () => { + it("returns null without rolls", () => { + expect(computeServerStats([], table)).toBeNull(); + }); + + it("includes the roll stats", () => { + const stats = computeServerStats([roll("a", 100), roll("b", 300)], table)!; + + expect(stats.totalRolls).toBe(2); + expect(stats.best.userId).toBe("b"); + }); + + it("orders tier leaders by count and gives [] to empty tiers", () => { + const rolls = [ + roll("a", 100), + roll("b", 110), + roll("b", 120), + roll("c", 130), + roll("c", 140), + roll("c", 150), + roll("a", 650), + ]; + + const { tierLeaders } = computeServerStats(rolls, table)!; + + expect(tierLeaders.COMMON).toEqual(["c", "b", "a"]); + expect(tierLeaders.MYTHIC).toEqual(["a"]); + expect(tierLeaders.TRASH).toEqual([]); + expect(tierLeaders.UNCOMMON).toEqual([]); + expect(tierLeaders.RARE).toEqual([]); + expect(tierLeaders.EPIC).toEqual([]); + expect(tierLeaders.ANOMALY).toEqual([]); + }); + + it("keeps at most 3 leaders per tier", () => { + const rolls = [ + roll("a", 100), + roll("a", 100), + roll("a", 100), + roll("a", 100), + roll("b", 100), + roll("b", 100), + roll("b", 100), + roll("c", 100), + roll("c", 100), + roll("d", 100), + roll("e", 100), + ]; + + const { tierLeaders } = computeServerStats(rolls, table)!; + + expect(tierLeaders.COMMON).toEqual(["a", "b", "c"]); + }); + + it("ignores ERROR rolls for leaders", () => { + const { tierLeaders } = computeServerStats( + [roll("a", -10), roll("b", 100)], + table + )!; + + expect(Object.values(tierLeaders).flat()).toEqual(["b"]); + }); +}); diff --git a/src/modules/rngdle/services/stats.ts b/src/modules/rngdle/services/stats.ts new file mode 100644 index 00000000..d570fec9 --- /dev/null +++ b/src/modules/rngdle/services/stats.ts @@ -0,0 +1,111 @@ +import type { StoredRoll } from "./rngdle.service.js"; +import { TIERS, type ScoreTable, type Tier } from "./score-table.js"; + +export interface RollSummary { + userId: string; + number: number; + score: number; + rolledAt: Date; +} + +export interface RollStats { + totalRolls: number; + totalScore: number; + averageScore: number; + best: RollSummary; + worst: RollSummary; + maxBadges: number; + tierCounts: Record; +} + +export interface ServerRollStats extends RollStats { + tierLeaders: Record; +} + +const TIER_LEADERS = 3; + +function emptyTierCounts(): Record { + return Object.fromEntries(TIERS.map((tier) => [tier, 0])) as Record< + Tier, + number + >; +} + +function summarize(roll: StoredRoll): RollSummary { + return { + userId: roll.userId, + number: roll.number, + score: roll.score, + rolledAt: roll.rolledAt, + }; +} + +export function computeRollStats( + rolls: StoredRoll[], + table: ScoreTable +): RollStats | null { + const [first] = rolls; + if (!first) { + return null; + } + + let best = first; + let worst = first; + let totalScore = 0; + let maxBadges = 0; + const tierCounts = emptyTierCounts(); + + for (const roll of rolls) { + totalScore += roll.score; + if (roll.score > best.score) best = roll; + if (roll.score < worst.score) worst = roll; + maxBadges = Math.max(maxBadges, roll.badgeCount); + const tier = table.tierOf(roll.score); + if (tier !== "ERROR") { + tierCounts[tier] += 1; + } + } + + return { + totalRolls: rolls.length, + totalScore, + averageScore: Math.trunc(totalScore / rolls.length), + best: summarize(best), + worst: summarize(worst), + maxBadges, + tierCounts, + }; +} + +export function computeServerStats( + rolls: StoredRoll[], + table: ScoreTable +): ServerRollStats | null { + const stats = computeRollStats(rolls, table); + if (!stats) { + return null; + } + + const countsByTier = new Map>(); + for (const roll of rolls) { + const tier = table.tierOf(roll.score); + if (tier === "ERROR") { + continue; + } + const counts = countsByTier.get(tier) ?? new Map(); + counts.set(roll.userId, (counts.get(roll.userId) ?? 0) + 1); + countsByTier.set(tier, counts); + } + + const tierLeaders = Object.fromEntries( + TIERS.map((tier) => [ + tier, + [...(countsByTier.get(tier) ?? new Map())] + .sort((a, b) => b[1] - a[1]) + .slice(0, TIER_LEADERS) + .map(([userId]) => userId), + ]) + ) as Record; + + return { ...stats, tierLeaders }; +} diff --git a/src/modules/rngdle/services/sync.service.test.ts b/src/modules/rngdle/services/sync.service.test.ts new file mode 100644 index 00000000..c15b5a82 --- /dev/null +++ b/src/modules/rngdle/services/sync.service.test.ts @@ -0,0 +1,622 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { RemoteRoll } from "./rngdle-api.js"; +import type { Account } from "./rngdle.service.js"; + +const mocks = vi.hoisted(() => ({ + fetchUserRolls: vi.fn(), + listAccounts: vi.fn(), + listAllAccounts: vi.fn(), + getActivatedGuildIds: vi.fn(), + latestRollDate: vi.fn(), + saveRolls: vi.fn(), +})); + +vi.mock("#lib/logger.js", () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }; + return { loggerMaker: () => logger }; +}); + +vi.mock("#core/services/module.service.js", () => ({ + default: { getActivatedGuildIds: mocks.getActivatedGuildIds }, +})); + +vi.mock("./rngdle-api.js", async () => { + const actual = + await vi.importActual("./rngdle-api.js"); + return { ...actual, fetchUserRolls: mocks.fetchUserRolls }; +}); + +vi.mock("./rngdle.service.js", () => ({ + default: { + listAccounts: mocks.listAccounts, + listAllAccounts: mocks.listAllAccounts, + latestRollDate: mocks.latestRollDate, + saveRolls: mocks.saveRolls, + }, +})); + +const { RngdleUserNotFoundError } = await import("./rngdle-api.js"); +const { default: sync, FullSyncCooldownError } = + await import("./sync.service.js"); + +const NOW = new Date("2026-05-15T12:00:00.000Z"); +let guild = 0; +let guildId = ""; + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +function accountsFor(id: string, ...names: string[]): Account[] { + return names.map((name) => ({ + guildId: id, + userId: `u-${name}`, + username: name, + })); +} + +interface Pending { + username: string; + since: Date | null; + resolve: (rolls: RemoteRoll[]) => void; + reject: (error: unknown) => void; +} + +function deferFetches() { + const pending: Pending[] = []; + let inFlight = 0; + let maxInFlight = 0; + mocks.fetchUserRolls.mockImplementation( + (username: string, since: Date | null) => + new Promise((resolve, reject) => { + inFlight++; + maxInFlight = Math.max(maxInFlight, inFlight); + pending.push({ + username, + since, + resolve: (rolls) => { + inFlight--; + resolve(rolls); + }, + reject: (error) => { + inFlight--; + reject(error); + }, + }); + }) + ); + return { + pending, + get maxInFlight() { + return maxInFlight; + }, + async drain() { + for (let i = 0; i < 100; i++) { + await flush(); + const next = pending.shift(); + if (!next) { + if (i > 3) return; + continue; + } + next.resolve([]); + } + }, + }; +} + +beforeEach(() => { + guildId = `guild-${++guild}`; + vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); + vi.setSystemTime(NOW); + for (const mock of Object.values(mocks)) mock.mockReset(); + mocks.latestRollDate.mockResolvedValue(null); + mocks.fetchUserRolls.mockResolvedValue([]); + mocks.saveRolls.mockResolvedValue({ inserted: 0, updated: 0 }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("syncGuild", () => { + it("fetches, saves and reports", async () => { + const rolls: RemoteRoll[] = [ + { id: "r1", number: 1, score: 1, badgeCount: 0, rolledAt: NOW }, + { id: "r2", number: 2, score: 2, badgeCount: 0, rolledAt: NOW }, + ]; + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice", "bob")); + mocks.fetchUserRolls.mockResolvedValue(rolls); + mocks.saveRolls.mockResolvedValue({ inserted: 1, updated: 2 }); + + const report = await sync.syncGuild(guildId); + + expect(report).toEqual({ + accounts: 2, + fetched: 4, + inserted: 2, + updated: 4, + failed: 0, + }); + expect(mocks.listAccounts).toHaveBeenCalledWith(guildId); + expect(mocks.saveRolls).toHaveBeenCalledTimes(2); + }); + + it("passes the latest stored roll date as since", async () => { + const latest = new Date("2026-05-14T08:00:00.000Z"); + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + mocks.latestRollDate.mockResolvedValue(latest); + + await sync.syncGuild(guildId); + + expect(mocks.fetchUserRolls).toHaveBeenCalledWith("alice", latest); + }); + + it("skips an account whose latest roll is today (UTC)", async () => { + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + mocks.latestRollDate.mockResolvedValue( + new Date("2026-05-15T00:00:00.000Z") + ); + + const report = await sync.syncGuild(guildId); + + expect(mocks.fetchUserRolls).not.toHaveBeenCalled(); + expect(mocks.saveRolls).not.toHaveBeenCalled(); + expect(report).toEqual({ + accounts: 1, + fetched: 0, + inserted: 0, + updated: 0, + failed: 0, + }); + }); + + it("does not skip an account whose latest roll is just before today", async () => { + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + mocks.latestRollDate.mockResolvedValue( + new Date("2026-05-14T23:59:59.999Z") + ); + + await sync.syncGuild(guildId); + + expect(mocks.fetchUserRolls).toHaveBeenCalledTimes(1); + }); + + it("fetches everything with full and does not read the latest roll", async () => { + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + mocks.latestRollDate.mockResolvedValue(NOW); + + await sync.syncGuild(guildId, { full: true }); + + expect(mocks.latestRollDate).not.toHaveBeenCalled(); + expect(mocks.fetchUserRolls).toHaveBeenCalledWith("alice", null); + }); + + it("counts a failure per account without stopping the others", async () => { + mocks.listAccounts.mockResolvedValue( + accountsFor(guildId, "ghost", "broken", "alice", "bob") + ); + mocks.fetchUserRolls.mockImplementation(async (username: string) => { + if (username === "ghost") throw new RngdleUserNotFoundError(username); + if (username === "broken") throw new Error("network down"); + return [ + { id: username, number: 1, score: 1, badgeCount: 0, rolledAt: NOW }, + ]; + }); + mocks.saveRolls.mockResolvedValue({ inserted: 1, updated: 0 }); + + const report = await sync.syncGuild(guildId); + + expect(report).toEqual({ + accounts: 4, + fetched: 2, + inserted: 2, + updated: 0, + failed: 2, + }); + }); + + it("counts a save failure as one failure", async () => { + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + mocks.saveRolls.mockRejectedValue(new Error("db down")); + + const report = await sync.syncGuild(guildId); + + expect(report.failed).toBe(1); + }); + + it("runs at most 4 fetches concurrently", async () => { + const names = Array.from({ length: 10 }, (_, i) => `user${i}`); + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, ...names)); + const fetches = deferFetches(); + + const done = sync.syncGuild(guildId); + await flush(); + expect(fetches.pending).toHaveLength(4); + + await fetches.drain(); + const report = await done; + + expect(fetches.maxInFlight).toBe(4); + expect(mocks.fetchUserRolls).toHaveBeenCalledTimes(10); + expect(report.accounts).toBe(10); + }); + + it("handles a guild without accounts", async () => { + mocks.listAccounts.mockResolvedValue([]); + + await expect(sync.syncGuild(guildId)).resolves.toEqual({ + accounts: 0, + fetched: 0, + inserted: 0, + updated: 0, + failed: 0, + }); + }); +}); + +const MINUTE = 60 * 1000; + +describe("per-guild exclusivity", () => { + it("never overlaps two syncs of the same guild", async () => { + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + const fetches = deferFetches(); + + const first = sync.syncGuild(guildId); + const second = sync.syncGuild(guildId, { full: true }); + await flush(); + + expect(fetches.pending).toHaveLength(1); + fetches.pending.shift()!.resolve([]); + await first; + await flush(); + expect(fetches.pending).toHaveLength(1); + fetches.pending.shift()!.resolve([]); + await second; + expect(fetches.maxInFlight).toBe(1); + }); + + it("makes a queued task wait for an in-flight sync of the same guild", async () => { + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + const fetches = deferFetches(); + const order: string[] = []; + + const syncing = sync.syncGuild(guildId).then(() => order.push("sync done")); + await flush(); + const clearing = sync.runExclusive(guildId, async () => { + order.push("clear"); + }); + await flush(); + + expect(order).toEqual([]); + fetches.pending.shift()!.resolve([]); + await Promise.all([syncing, clearing]); + + expect(order).toEqual(["sync done", "clear"]); + }); + + it("does not block another guild", async () => { + const other = `${guildId}-other`; + mocks.listAccounts.mockImplementation(async (id: string) => + accountsFor(id, id === guildId ? "alice" : "bob") + ); + const fetches = deferFetches(); + + const blocked = sync.syncGuild(guildId); + await flush(); + const otherTask = sync.runExclusive(other, async () => "ran"); + const otherSync = sync.syncGuild(other); + await flush(); + + await expect(otherTask).resolves.toBe("ran"); + expect(fetches.pending.map((p) => p.username).sort()).toEqual([ + "alice", + "bob", + ]); + + await fetches.drain(); + await Promise.all([blocked, otherSync]); + }); + + it("keeps running queued tasks after a failure", async () => { + const failing = sync.runExclusive(guildId, async () => { + throw new Error("boom"); + }); + const next = sync.runExclusive(guildId, async () => "ok"); + + await expect(failing).rejects.toThrow("boom"); + await expect(next).resolves.toBe("ok"); + }); + + it("returns the task result", async () => { + await expect(sync.runExclusive(guildId, async () => 42)).resolves.toBe(42); + }); +}); + +describe("pending sync reuse", () => { + it("reuses a pending sync instead of starting a duplicate", async () => { + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + const fetches = deferFetches(); + + const first = sync.syncGuild(guildId); + const second = sync.syncGuild(guildId); + await flush(); + fetches.pending.shift()!.resolve([]); + const [a, b] = await Promise.all([first, second]); + + expect(mocks.listAccounts).toHaveBeenCalledTimes(1); + expect(b).toBe(a); + }); + + it("starts a new sync once the previous one has finished", async () => { + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + + await sync.syncGuild(guildId); + await flush(); + await sync.syncGuild(guildId); + + expect(mocks.listAccounts).toHaveBeenCalledTimes(2); + }); + + it("does not reuse a pending sync for a full request", async () => { + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + const fetches = deferFetches(); + + const first = sync.syncGuild(guildId); + const full = sync.syncGuild(guildId, { full: true }); + await flush(); + await fetches.drain(); + await Promise.all([first, full]); + + expect(mocks.listAccounts).toHaveBeenCalledTimes(2); + expect(mocks.fetchUserRolls).toHaveBeenLastCalledWith("alice", null); + }); +}); + +describe("full sync cooldown", () => { + beforeEach(() => { + mocks.listAccounts.mockResolvedValue([]); + }); + + it("rejects a second full sync within 15 minutes with retryAt", async () => { + await sync.syncGuild(guildId, { full: true }); + vi.setSystemTime(new Date(NOW.getTime() + 5 * MINUTE)); + + const error = await sync + .syncGuild(guildId, { full: true }) + .catch((err: unknown) => err); + + expect(error).toBeInstanceOf(FullSyncCooldownError); + expect( + (error as InstanceType).retryAt + ).toEqual(new Date(NOW.getTime() + 15 * MINUTE)); + expect(mocks.listAccounts).toHaveBeenCalledTimes(1); + }); + + it("rejects right up to the 15 minute mark and accepts after it", async () => { + await sync.syncGuild(guildId, { full: true }); + + vi.setSystemTime(new Date(NOW.getTime() + 15 * MINUTE - 1)); + await expect( + sync.syncGuild(guildId, { full: true }) + ).rejects.toBeInstanceOf(FullSyncCooldownError); + + vi.setSystemTime(new Date(NOW.getTime() + 15 * MINUTE)); + await expect( + sync.syncGuild(guildId, { full: true }) + ).resolves.toBeDefined(); + }); + + it("does not limit a normal sync", async () => { + await sync.syncGuild(guildId, { full: true }); + + await expect(sync.syncGuild(guildId)).resolves.toBeDefined(); + }); + + it("tracks guilds independently", async () => { + await sync.syncGuild(guildId, { full: true }); + + await expect( + sync.syncGuild(`${guildId}-other`, { full: true }) + ).resolves.toBeDefined(); + }); + + it("is not triggered by a normal sync", async () => { + await sync.syncGuild(guildId); + + await expect( + sync.syncGuild(guildId, { full: true }) + ).resolves.toBeDefined(); + }); +}); + +describe("syncAll", () => { + it("only syncs activated guilds, without listing every account", async () => { + const other = `${guildId}-other`; + mocks.getActivatedGuildIds.mockResolvedValue([guildId, other]); + mocks.listAccounts.mockImplementation(async (id: string) => + accountsFor(id, "alice") + ); + mocks.saveRolls.mockResolvedValue({ inserted: 3, updated: 1 }); + + const report = await sync.syncAll(); + + expect(mocks.getActivatedGuildIds).toHaveBeenCalledWith("rngdle"); + expect(mocks.listAccounts.mock.calls.map(([id]) => id)).toEqual([ + guildId, + other, + ]); + expect(mocks.listAllAccounts).not.toHaveBeenCalled(); + expect(report).toEqual({ + accounts: 2, + fetched: 0, + inserted: 6, + updated: 2, + failed: 0, + }); + }); + + it("does nothing without activated guilds", async () => { + mocks.getActivatedGuildIds.mockResolvedValue([]); + + await expect(sync.syncAll()).resolves.toEqual({ + accounts: 0, + fetched: 0, + inserted: 0, + updated: 0, + failed: 0, + }); + expect(mocks.listAccounts).not.toHaveBeenCalled(); + }); + + it("syncs guilds one after another", async () => { + const other = `${guildId}-other`; + mocks.getActivatedGuildIds.mockResolvedValue([guildId, other]); + mocks.listAccounts.mockImplementation(async (id: string) => + accountsFor(id, id === guildId ? "alice" : "bob") + ); + const fetches = deferFetches(); + + const done = sync.syncAll(); + await flush(); + + expect(fetches.pending.map((p) => p.username)).toEqual(["alice"]); + expect(mocks.listAccounts).toHaveBeenCalledTimes(1); + + fetches.pending.shift()!.resolve([]); + await flush(); + expect(fetches.pending.map((p) => p.username)).toEqual(["bob"]); + fetches.pending.shift()!.resolve([]); + await done; + }); + + it("waits for a running sync of the same guild", async () => { + mocks.getActivatedGuildIds.mockResolvedValue([guildId]); + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + const fetches = deferFetches(); + + const running = sync.syncGuild(guildId); + await flush(); + const all = sync.syncAll(); + await flush(); + + expect(fetches.pending).toHaveLength(1); + fetches.pending.shift()!.resolve([]); + await Promise.all([running, all]); + expect(mocks.listAccounts).toHaveBeenCalledTimes(1); + }); + + it("passes full to each guild", async () => { + mocks.getActivatedGuildIds.mockResolvedValue([guildId]); + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + mocks.latestRollDate.mockResolvedValue(NOW); + + await sync.syncAll({ full: true }); + + expect(mocks.latestRollDate).not.toHaveBeenCalled(); + expect(mocks.fetchUserRolls).toHaveBeenCalledWith("alice", null); + }); + + it("marks synced guilds as fresh", async () => { + mocks.getActivatedGuildIds.mockResolvedValue([guildId]); + mocks.listAccounts.mockResolvedValue([]); + await sync.syncAll(); + mocks.listAccounts.mockClear(); + + await sync.syncGuildIfStale(guildId); + + expect(mocks.listAccounts).not.toHaveBeenCalled(); + }); +}); + +describe("syncGuildIfStale", () => { + beforeEach(() => { + mocks.listAccounts.mockResolvedValue([]); + }); + + it("syncs a guild that was never synced", async () => { + await sync.syncGuildIfStale(guildId); + + expect(mocks.listAccounts).toHaveBeenCalledTimes(1); + }); + + it("skips within 5 minutes of the last sync", async () => { + await sync.syncGuild(guildId); + mocks.listAccounts.mockClear(); + + vi.setSystemTime(new Date(NOW.getTime() + 5 * MINUTE - 1)); + await sync.syncGuildIfStale(guildId); + + expect(mocks.listAccounts).not.toHaveBeenCalled(); + }); + + it("syncs again once 5 minutes have passed", async () => { + await sync.syncGuild(guildId); + await flush(); + mocks.listAccounts.mockClear(); + + vi.setSystemTime(new Date(NOW.getTime() + 5 * MINUTE)); + await sync.syncGuildIfStale(guildId); + + expect(mocks.listAccounts).toHaveBeenCalledTimes(1); + }); + + it("tracks guilds independently", async () => { + await sync.syncGuild(guildId); + mocks.listAccounts.mockClear(); + + await sync.syncGuildIfStale(`${guildId}-other`); + + expect(mocks.listAccounts).toHaveBeenCalledWith(`${guildId}-other`); + }); + + it("reuses a pending sync", async () => { + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + const fetches = deferFetches(); + + const running = sync.syncGuild(guildId); + await flush(); + const stale = sync.syncGuildIfStale(guildId); + await flush(); + fetches.pending.shift()!.resolve([]); + await Promise.all([running, stale]); + + expect(mocks.listAccounts).toHaveBeenCalledTimes(1); + }); + + it("waits for a quick sync and leaves no timer behind", async () => { + await sync.syncGuildIfStale(guildId); + + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not wait for more than 10 s while the sync keeps running", async () => { + mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); + const fetches = deferFetches(); + let resolved = false; + + const stale = sync.syncGuildIfStale(guildId).then(() => { + resolved = true; + }); + await flush(); + await vi.advanceTimersByTimeAsync(9_999); + expect(resolved).toBe(false); + + await vi.advanceTimersByTimeAsync(1); + await stale; + expect(resolved).toBe(true); + expect(fetches.pending).toHaveLength(1); + + fetches.pending.shift()!.resolve([]); + await flush(); + expect(mocks.saveRolls).toHaveBeenCalledTimes(1); + }); + + it("swallows a failing sync", async () => { + mocks.listAccounts.mockRejectedValue(new Error("db down")); + + await expect(sync.syncGuildIfStale(guildId)).resolves.toBeUndefined(); + }); +}); diff --git a/src/modules/rngdle/services/sync.service.ts b/src/modules/rngdle/services/sync.service.ts new file mode 100644 index 00000000..2fbf1cf0 --- /dev/null +++ b/src/modules/rngdle/services/sync.service.ts @@ -0,0 +1,200 @@ +import { loggerMaker } from "#lib/logger.js"; +import { declareService, type Service } from "#lib/service.js"; +import { fetchUserRolls, RngdleUserNotFoundError } from "./rngdle-api.js"; +import rngdleService, { type Account } from "./rngdle.service.js"; + +const logger = loggerMaker("rngdle"); + +const MAX_PARALLEL_FETCHES = 4; +const GUILD_COOLDOWN_MS = 5 * 60 * 1000; +const FULL_SYNC_COOLDOWN_MS = 15 * 60 * 1000; +const DISPLAY_SYNC_WAIT_MS = 10_000; + +export interface SyncOptions { + full?: boolean; +} + +export interface SyncReport { + accounts: number; + fetched: number; + inserted: number; + updated: number; + failed: number; +} + +export class FullSyncCooldownError extends Error { + constructor(readonly retryAt: Date) { + super("A full sync ran recently on this guild"); + } +} + +function emptyReport(): SyncReport { + return { accounts: 0, fetched: 0, inserted: 0, updated: 0, failed: 0 }; +} + +function addReports(target: SyncReport, source: SyncReport): void { + target.accounts += source.accounts; + target.fetched += source.fetched; + target.inserted += source.inserted; + target.updated += source.updated; + target.failed += source.failed; +} + +function startOfUtcDay(date: Date): Date { + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) + ); +} + +class SyncService implements Service { + private readonly guildQueues = new Map>(); + private readonly lastGuildSync = new Map(); + private readonly lastFullSync = new Map(); + private readonly pendingSyncs = new Map>(); + + runExclusive(guildId: string, task: () => Promise): Promise { + const previous = this.guildQueues.get(guildId) ?? Promise.resolve(); + const next = previous.then(task, task); + const settled = next.catch(() => undefined); + this.guildQueues.set(guildId, settled); + void settled.then(() => { + if (this.guildQueues.get(guildId) === settled) { + this.guildQueues.delete(guildId); + } + }); + return next; + } + + syncGuild(guildId: string, options: SyncOptions = {}): Promise { + if (options.full) { + const last = this.lastFullSync.get(guildId); + if (last !== undefined && Date.now() - last < FULL_SYNC_COOLDOWN_MS) { + return Promise.reject( + new FullSyncCooldownError(new Date(last + FULL_SYNC_COOLDOWN_MS)) + ); + } + this.lastFullSync.set(guildId, Date.now()); + } + return this.queueGuildSync(guildId, options); + } + + async syncAll(options: SyncOptions = {}): Promise { + const { default: moduleService } = + await import("#core/services/module.service.js"); + const guildIds = await moduleService.getActivatedGuildIds("rngdle"); + + const report = emptyReport(); + for (const guildId of guildIds) { + addReports(report, await this.queueGuildSync(guildId, options)); + } + logger.info( + `Sync done | guilds = ${guildIds.length} | accounts = ${report.accounts} | inserted = ${report.inserted} | updated = ${report.updated} | failed = ${report.failed}` + ); + return report; + } + + async syncGuildIfStale(guildId: string): Promise { + const last = this.lastGuildSync.get(guildId); + const pending = + this.pendingSyncs.get(guildId) ?? + (last !== undefined && Date.now() - last < GUILD_COOLDOWN_MS + ? undefined + : this.queueGuildSync(guildId, {})); + if (!pending) { + return; + } + + let timeout: NodeJS.Timeout | undefined; + await Promise.race([ + pending.catch(() => undefined), + new Promise((resolve) => { + timeout = setTimeout(resolve, DISPLAY_SYNC_WAIT_MS); + }), + ]); + clearTimeout(timeout); + } + + private queueGuildSync( + guildId: string, + options: SyncOptions + ): Promise { + const pending = this.pendingSyncs.get(guildId); + if (pending && !options.full) { + return pending; + } + + this.lastGuildSync.set(guildId, Date.now()); + const sync = this.runExclusive(guildId, async () => + this.syncAccounts(await rngdleService.listAccounts(guildId), options) + ); + this.pendingSyncs.set(guildId, sync); + void sync + .catch(() => undefined) + .then(() => { + if (this.pendingSyncs.get(guildId) === sync) { + this.pendingSyncs.delete(guildId); + } + }); + return sync; + } + + private async syncAccounts( + accounts: Account[], + options: SyncOptions + ): Promise { + const report = emptyReport(); + report.accounts = accounts.length; + + const queue = [...accounts]; + const worker = async () => { + for (let account = queue.shift(); account; account = queue.shift()) { + const result = await this.syncAccount(account, options); + addReports(report, { ...result, accounts: 0 }); + } + }; + await Promise.all( + Array.from( + { length: Math.min(MAX_PARALLEL_FETCHES, accounts.length) }, + worker + ) + ); + + return report; + } + + private async syncAccount( + account: Account, + { full = false }: SyncOptions + ): Promise> { + const result = { fetched: 0, inserted: 0, updated: 0, failed: 0 }; + try { + const latest = full + ? null + : await rngdleService.latestRollDate(account.guildId, account.userId); + if (latest && latest >= startOfUtcDay(new Date())) { + return result; + } + + const rolls = await fetchUserRolls(account.username, latest); + result.fetched = rolls.length; + const saved = await rngdleService.saveRolls(account, rolls); + result.inserted = saved.inserted; + result.updated = saved.updated; + } catch (err) { + result.failed = 1; + if (err instanceof RngdleUserNotFoundError) { + logger.warn( + `RNGdle account not found | guildId = ${account.guildId} | userId = ${account.userId} | username = ${account.username}` + ); + } else { + logger.error( + { err }, + `Failed to sync RNGdle rolls | guildId = ${account.guildId} | username = ${account.username}` + ); + } + } + return result; + } +} + +export default declareService(new SyncService()); diff --git a/src/modules/rngdle/tasks/daily-leaderboard.task.ts b/src/modules/rngdle/tasks/daily-leaderboard.task.ts new file mode 100644 index 00000000..8755e798 --- /dev/null +++ b/src/modules/rngdle/tasks/daily-leaderboard.task.ts @@ -0,0 +1,72 @@ +import { AttachmentBuilder, type Client } from "discord.js"; +import { loggerMaker } from "#lib/logger.js"; +import type { Module } from "#lib/module.js"; +import { declareTask } from "#lib/task.js"; +import type { RngdleConfigSchema } from "#modules/rngdle/rngdle.config.js"; +import { + buildDailyLeaderboard, + utcDayRange, +} from "#modules/rngdle/services/boards.js"; +import syncService from "#modules/rngdle/services/sync.service.js"; + +const logger = loggerMaker("rngdle"); + +export async function postDailyLeaderboard( + client: Client, + module: Module, + guildId: string +): Promise { + const { default: configService } = + await import("#core/services/config.service.js"); + const config = await configService.getConfigForModuleIn(module, guildId); + const channel = config.get("leaderboardChannel"); + if (!channel?.isSendable()) { + return false; + } + + const board = await buildDailyLeaderboard( + client, + guildId, + utcDayRange(1), + config.t + ); + if (!board) { + return false; + } + + await channel.send({ + content: config.t("daily.message", { + winners: board.winnerIds.map((id) => `<@${id}>`).join(" "), + }), + files: [new AttachmentBuilder(board.image, { name: "leaderboard.png" })], + allowedMentions: { users: board.winnerIds }, + }); + return true; +} + +export function createDailyLeaderboardTask(module: Module) { + return declareTask({ + id: "daily-leaderboard", + schedule: "0 1 * * *", + async run(client) { + await syncService.syncAll(); + + const { default: moduleService } = + await import("#core/services/module.service.js"); + for (const guildId of await moduleService.getActivatedGuildIds( + module.id + )) { + try { + if (await postDailyLeaderboard(client, module, guildId)) { + logger.info(`Daily leaderboard posted | guildId = ${guildId}`); + } + } catch (err) { + logger.error( + { err }, + `Failed to post the daily leaderboard | guildId = ${guildId}` + ); + } + } + }, + }); +} diff --git a/src/modules/rngdle/tasks/score-table.task.ts b/src/modules/rngdle/tasks/score-table.task.ts new file mode 100644 index 00000000..ff204b9b --- /dev/null +++ b/src/modules/rngdle/tasks/score-table.task.ts @@ -0,0 +1,14 @@ +import { declareTask } from "#lib/task.js"; +import scoreTableService from "#modules/rngdle/services/score-table.service.js"; +import syncService from "#modules/rngdle/services/sync.service.js"; + +export default declareTask({ + id: "score-table", + schedule: "0 3 * * 1", + runOnStart: true, + async run() { + if (await scoreTableService.refresh()) { + await syncService.syncAll({ full: true }); + } + }, +}); diff --git a/src/modules/rngdle/tasks/sync.task.ts b/src/modules/rngdle/tasks/sync.task.ts new file mode 100644 index 00000000..e5ed1410 --- /dev/null +++ b/src/modules/rngdle/tasks/sync.task.ts @@ -0,0 +1,10 @@ +import { declareTask } from "#lib/task.js"; +import syncService from "#modules/rngdle/services/sync.service.js"; + +export default declareTask({ + id: "sync", + schedule: "0 6,18 * * *", + async run() { + await syncService.syncAll(); + }, +}); diff --git a/src/prisma/migrations/20261002211526_rngdle/migration.sql b/src/prisma/migrations/20261002211526_rngdle/migration.sql new file mode 100644 index 00000000..d85e7bc7 --- /dev/null +++ b/src/prisma/migrations/20261002211526_rngdle/migration.sql @@ -0,0 +1,36 @@ +-- CreateTable +CREATE TABLE "RngdleAccount" ( + "guildId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "username" TEXT NOT NULL, + + CONSTRAINT "RngdleAccount_pkey" PRIMARY KEY ("guildId","userId") +); + +-- CreateTable +CREATE TABLE "RngdleRoll" ( + "guildId" TEXT NOT NULL, + "rollId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "number" INTEGER NOT NULL, + "score" BIGINT NOT NULL, + "badgeCount" INTEGER NOT NULL DEFAULT 0, + "rolledAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RngdleRoll_pkey" PRIMARY KEY ("guildId","rollId") +); + +-- CreateTable +CREATE TABLE "RngdleScoreTable" ( + "id" INTEGER NOT NULL DEFAULT 1, + "data" JSONB NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RngdleScoreTable_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RngdleRoll_guildId_rolledAt_idx" ON "RngdleRoll"("guildId", "rolledAt"); + +-- CreateIndex +CREATE INDEX "RngdleRoll_guildId_userId_idx" ON "RngdleRoll"("guildId", "userId"); diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index 2c122f87..82abd5bc 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -32,3 +32,32 @@ model FourHourGameLastMessage { authorId String sentAt DateTime } + +// === Modèles de modules/rngdle/rngdle.prisma === +model RngdleAccount { + guildId String + userId String + username String + + @@id([guildId, userId]) +} + +model RngdleRoll { + guildId String + rollId String + userId String + number Int + score BigInt + badgeCount Int @default(0) + rolledAt DateTime + + @@id([guildId, rollId]) + @@index([guildId, rolledAt]) + @@index([guildId, userId]) +} + +model RngdleScoreTable { + id Int @id @default(1) + data Json + updatedAt DateTime @updatedAt +} From 7d2e4ba144f9868420a73ecc1bd1a068432b4f86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?RAMELOT=20Lo=C3=AFc?= Date: Sat, 3 Oct 2026 10:57:17 +0200 Subject: [PATCH 2/5] refactor(rngdle): simplifie et mutualise le code --- AGENTS.md | 1 + docs/site/.vitepress/config.ts | 2 + docs/site/en/guide/creating-a-module.md | 2 +- docs/site/en/guide/images.md | 89 +++ docs/site/en/guide/tasks.md | 4 - docs/site/fr/guide/creating-a-module.md | 2 +- docs/site/fr/guide/images.md | 89 +++ docs/site/fr/guide/tasks.md | 4 - docs/specs/rngdle-migration.md | 177 +---- src/core/commands/config.command.test.ts | 2 +- src/core/commands/config.command.ts | 2 +- src/core/config/config-edit.ts | 2 +- .../interactions/disable-module.button.ts | 2 +- src/core/interactions/enable-module.button.ts | 2 +- .../listeners/guild-create.listener.test.ts | 2 +- src/core/listeners/guild-create.listener.ts | 2 +- .../interaction-create.listener.test.ts | 2 +- .../listeners/interaction-create.listener.ts | 2 +- .../command-loader.integration.test.ts | 2 +- src/core/loaders/listener-loader.test.ts | 2 +- src/core/loaders/module-installer.ts | 2 +- src/core/runtime.ts | 10 + src/core/services/config.service.test.ts | 4 +- src/core/services/config.service.ts | 2 +- src/core/services/module.service.test.ts | 4 +- src/core/services/module.service.ts | 2 +- src/index.ts | 6 +- .../assets/medal_bronze.png | Bin .../assets/medal_gold.png | Bin .../assets/medal_silver.png | Bin .../four-hour-game => lib}/assets/outfit.ttf | Bin src/lib/imaging.ts | 91 ++- src/lib/keyed-queue.test.ts | 54 ++ src/lib/keyed-queue.ts | 15 + src/lib/leaderboard-table.ts | 155 +++++ src/lib/revision-cache.test.ts | 50 ++ src/lib/revision-cache.ts | 35 + src/lib/stat-card.test.ts | 55 ++ src/lib/stat-card.ts | 228 +++++++ src/lib/testing.ts | 6 +- .../four-hour-game/commands/jd4h.command.ts | 8 +- .../rendering/leaderboard-image.test.ts | 15 +- .../rendering/leaderboard-image.ts | 106 +-- .../services/four-hour-game.service.ts | 24 +- .../leaderboard-cache.service.test.ts | 75 --- .../services/leaderboard-cache.service.ts | 43 -- .../four-hour-game/services/round-sync.ts | 6 +- src/modules/rngdle/assets/medal_bronze.png | Bin 1319 -> 0 bytes src/modules/rngdle/assets/medal_gold.png | Bin 1190 -> 0 bytes src/modules/rngdle/assets/medal_silver.png | Bin 1273 -> 0 bytes src/modules/rngdle/assets/outfit.ttf | Bin 55396 -> 0 bytes src/modules/rngdle/commands/replies.ts | 25 - .../rngdle/commands/rngdle-admin.command.ts | 223 +++---- src/modules/rngdle/commands/rngdle.command.ts | 238 +++---- .../interactions/overall-page.button.ts | 145 ++-- src/modules/rngdle/rendering/cards.test.ts | 113 ++++ src/modules/rngdle/rendering/cards.ts | 224 +++++++ src/modules/rngdle/rendering/common.ts | 60 -- .../rendering/daily-leaderboard-image.test.ts | 64 -- .../rendering/daily-leaderboard-image.ts | 190 ------ .../{services => rendering}/format.test.ts | 8 +- src/modules/rngdle/rendering/format.ts | 30 + .../rngdle/rendering/leaderboards.test.ts | 70 ++ src/modules/rngdle/rendering/leaderboards.ts | 115 ++++ .../overall-leaderboard-image.test.ts | 70 -- .../rendering/overall-leaderboard-image.ts | 124 ---- .../rngdle/rendering/profile-image.test.ts | 73 -- src/modules/rngdle/rendering/profile-image.ts | 232 ------- .../rendering/server-stats-image.test.ts | 116 ---- .../rngdle/rendering/server-stats-image.ts | 205 ------ src/modules/rngdle/rngdle.module.ts | 10 +- src/modules/rngdle/services/api.test.ts | 215 ++++++ src/modules/rngdle/services/api.ts | 151 +++++ src/modules/rngdle/services/boards.test.ts | 92 --- src/modules/rngdle/services/boards.ts | 300 --------- src/modules/rngdle/services/format.ts | 36 - .../services/image-cache.service.test.ts | 88 --- .../rngdle/services/image-cache.service.ts | 45 -- .../rngdle/services/rngdle-api.test.ts | 301 --------- src/modules/rngdle/services/rngdle-api.ts | 118 ---- .../rngdle/services/rngdle.service.test.ts | 507 -------------- .../rngdle/services/score-table.service.ts | 65 -- .../rngdle/services/score-table.test.ts | 267 -------- src/modules/rngdle/services/scoring.test.ts | 139 ++++ .../services/{score-table.ts => scoring.ts} | 0 src/modules/rngdle/services/stats.test.ts | 156 +---- src/modules/rngdle/services/stats.ts | 4 +- src/modules/rngdle/services/store.test.ts | 311 +++++++++ .../services/{rngdle.service.ts => store.ts} | 179 ++--- .../rngdle/services/sync.service.test.ts | 622 ------------------ src/modules/rngdle/services/sync.service.ts | 200 ------ src/modules/rngdle/services/sync.test.ts | 205 ++++++ src/modules/rngdle/services/sync.ts | 169 +++++ src/modules/rngdle/services/views.test.ts | 225 +++++++ src/modules/rngdle/services/views.ts | 316 +++++++++ src/modules/rngdle/tasks.ts | 84 +++ .../rngdle/tasks/daily-leaderboard.task.ts | 72 -- src/modules/rngdle/tasks/score-table.task.ts | 14 - src/modules/rngdle/tasks/sync.task.ts | 10 - 99 files changed, 3691 insertions(+), 4923 deletions(-) create mode 100644 docs/site/en/guide/images.md create mode 100644 docs/site/fr/guide/images.md create mode 100644 src/core/runtime.ts rename src/{modules/four-hour-game => lib}/assets/medal_bronze.png (100%) rename src/{modules/four-hour-game => lib}/assets/medal_gold.png (100%) rename src/{modules/four-hour-game => lib}/assets/medal_silver.png (100%) rename src/{modules/four-hour-game => lib}/assets/outfit.ttf (100%) create mode 100644 src/lib/keyed-queue.test.ts create mode 100644 src/lib/keyed-queue.ts create mode 100644 src/lib/leaderboard-table.ts create mode 100644 src/lib/revision-cache.test.ts create mode 100644 src/lib/revision-cache.ts create mode 100644 src/lib/stat-card.test.ts create mode 100644 src/lib/stat-card.ts delete mode 100644 src/modules/four-hour-game/services/leaderboard-cache.service.test.ts delete mode 100644 src/modules/four-hour-game/services/leaderboard-cache.service.ts delete mode 100644 src/modules/rngdle/assets/medal_bronze.png delete mode 100644 src/modules/rngdle/assets/medal_gold.png delete mode 100644 src/modules/rngdle/assets/medal_silver.png delete mode 100644 src/modules/rngdle/assets/outfit.ttf delete mode 100644 src/modules/rngdle/commands/replies.ts create mode 100644 src/modules/rngdle/rendering/cards.test.ts create mode 100644 src/modules/rngdle/rendering/cards.ts delete mode 100644 src/modules/rngdle/rendering/common.ts delete mode 100644 src/modules/rngdle/rendering/daily-leaderboard-image.test.ts delete mode 100644 src/modules/rngdle/rendering/daily-leaderboard-image.ts rename src/modules/rngdle/{services => rendering}/format.test.ts (90%) create mode 100644 src/modules/rngdle/rendering/format.ts create mode 100644 src/modules/rngdle/rendering/leaderboards.test.ts create mode 100644 src/modules/rngdle/rendering/leaderboards.ts delete mode 100644 src/modules/rngdle/rendering/overall-leaderboard-image.test.ts delete mode 100644 src/modules/rngdle/rendering/overall-leaderboard-image.ts delete mode 100644 src/modules/rngdle/rendering/profile-image.test.ts delete mode 100644 src/modules/rngdle/rendering/profile-image.ts delete mode 100644 src/modules/rngdle/rendering/server-stats-image.test.ts delete mode 100644 src/modules/rngdle/rendering/server-stats-image.ts create mode 100644 src/modules/rngdle/services/api.test.ts create mode 100644 src/modules/rngdle/services/api.ts delete mode 100644 src/modules/rngdle/services/boards.test.ts delete mode 100644 src/modules/rngdle/services/boards.ts delete mode 100644 src/modules/rngdle/services/format.ts delete mode 100644 src/modules/rngdle/services/image-cache.service.test.ts delete mode 100644 src/modules/rngdle/services/image-cache.service.ts delete mode 100644 src/modules/rngdle/services/rngdle-api.test.ts delete mode 100644 src/modules/rngdle/services/rngdle-api.ts delete mode 100644 src/modules/rngdle/services/rngdle.service.test.ts delete mode 100644 src/modules/rngdle/services/score-table.service.ts delete mode 100644 src/modules/rngdle/services/score-table.test.ts create mode 100644 src/modules/rngdle/services/scoring.test.ts rename src/modules/rngdle/services/{score-table.ts => scoring.ts} (100%) create mode 100644 src/modules/rngdle/services/store.test.ts rename src/modules/rngdle/services/{rngdle.service.ts => store.ts} (54%) delete mode 100644 src/modules/rngdle/services/sync.service.test.ts delete mode 100644 src/modules/rngdle/services/sync.service.ts create mode 100644 src/modules/rngdle/services/sync.test.ts create mode 100644 src/modules/rngdle/services/sync.ts create mode 100644 src/modules/rngdle/services/views.test.ts create mode 100644 src/modules/rngdle/services/views.ts create mode 100644 src/modules/rngdle/tasks.ts delete mode 100644 src/modules/rngdle/tasks/daily-leaderboard.task.ts delete mode 100644 src/modules/rngdle/tasks/score-table.task.ts delete mode 100644 src/modules/rngdle/tasks/sync.task.ts diff --git a/AGENTS.md b/AGENTS.md index 273f8b35..c24cbdef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,6 +44,7 @@ See [`docs/site/`](docs/site/README.md) for the full developer guide: | Buttons / modals / selects | [docs/site/en/guide/interactions.md](docs/site/en/guide/interactions.md) | | Services | [docs/site/en/guide/services.md](docs/site/en/guide/services.md) | | Scheduled tasks | [docs/site/en/guide/tasks.md](docs/site/en/guide/tasks.md) | +| Generating images | [docs/site/en/guide/images.md](docs/site/en/guide/images.md) | | Prisma / database | [docs/site/en/guide/database.md](docs/site/en/guide/database.md) | | Functional behavior | [docs/functional.md](docs/functional.md) | diff --git a/docs/site/.vitepress/config.ts b/docs/site/.vitepress/config.ts index db952104..4be32013 100644 --- a/docs/site/.vitepress/config.ts +++ b/docs/site/.vitepress/config.ts @@ -52,6 +52,7 @@ export default defineConfig({ { text: "Localisation", link: "/fr/guide/localisation" }, { text: "Services", link: "/fr/guide/services" }, { text: "Tâches planifiées", link: "/fr/guide/tasks" }, + { text: "Images", link: "/fr/guide/images" }, { text: "Base de données", link: "/fr/guide/database" }, { text: "Contribuer", link: "/fr/guide/contributing" }, ], @@ -108,6 +109,7 @@ export default defineConfig({ { text: "Localization", link: "/en/guide/localization" }, { text: "Services", link: "/en/guide/services" }, { text: "Scheduled Tasks", link: "/en/guide/tasks" }, + { text: "Images", link: "/en/guide/images" }, { text: "Database", link: "/en/guide/database" }, { text: "Contributing", link: "/en/guide/contributing" }, ], diff --git a/docs/site/en/guide/creating-a-module.md b/docs/site/en/guide/creating-a-module.md index 4fbf2235..b0bf69ce 100644 --- a/docs/site/en/guide/creating-a-module.md +++ b/docs/site/en/guide/creating-a-module.md @@ -12,7 +12,7 @@ pnpm new-module my-module "My Module" The generated module compiles and its test passes unmodified. Fill the `TODO` description, then enable it via `/modules` on your dev guild. No registration step exists: modules are auto-discovered from `src/modules/` at startup. -For tests, use the shared helpers from `#lib/testing.js` (`makeTestConfig`, `fakeGuild`, `fakeMessage`, `initTestI18n`) instead of hand-rolled mocks. Note that `vi.mock("#index.js")` and `vi.mock("#lib/database.js")` must still be declared per test file — Vitest hoists them, so no helper can hide them. +For tests, use the shared helpers from `#lib/testing.js` (`makeTestConfig`, `fakeGuild`, `fakeMessage`, `initTestI18n`) instead of hand-rolled mocks. Note that `vi.mock("#core/runtime.js")` and `vi.mock("#lib/database.js")` must still be declared per test file — Vitest hoists them, so no helper can hide them. ## Module Structure diff --git a/docs/site/en/guide/images.md b/docs/site/en/guide/images.md new file mode 100644 index 00000000..0f0f1f48 --- /dev/null +++ b/docs/site/en/guide/images.md @@ -0,0 +1,89 @@ +# Generating Images + +Leaderboards and stat cards are rendered as PNG images with [`@napi-rs/canvas`](https://github.com/Brooooooklyn/canvas) (prebuilt binaries, nothing to install). Two ready-made renderers cover most needs; lower-level helpers are there for anything else. + +| Module | Provides | +| ------------------------ | ---------------------------------------------------------------------------------------------------- | +| `#lib/leaderboard-table` | `renderLeaderboardTable`: ranked table with medals, avatars, names and your own columns | +| `#lib/stat-card` | `renderStatCard`: card with a portrait, a title, an optional rank, a grid of stat boxes and a footer | +| `#lib/imaging` | Shared palette and font, avatar download, circular images, fitted text, assets | + +The Outfit font and the gold, silver and bronze medals ship in `src/lib/assets/` and are loaded for you. + +## A Leaderboard + +Rows extend `LeaderboardEntry` (`rank`, `name`, `avatar`); the rank, medal, avatar and name columns are drawn for you. Describe the other columns: + +```typescript +import { fetchAvatar } from "#lib/imaging.js"; +import { + renderLeaderboardTable, + type LeaderboardEntry, +} from "#lib/leaderboard-table.js"; + +interface Row extends LeaderboardEntry { + points: number; +} + +const rows: Row[] = await Promise.all( + players.map(async (player, index) => ({ + rank: index + 1, + name: player.user.username, + avatar: await fetchAvatar(player.user), + points: player.points, + })) +); + +const png = await renderLeaderboardTable({ + headers: { rank: t("header.rank"), player: t("header.player") }, + rows, + columns: [ + { + header: t("header.points"), + x: 650, + maxWidth: 140, + cell: (row) => ({ text: String(row.points) }), + }, + ], +}); +``` + +A column can be right-aligned (`align: "right"`, `x` is then its right edge), and each cell can set its own `color`, font `family` or a small `icon` drawn before the text. Pass `caller` to add the viewer's own row under a separator when they are not on the page, and `width` for a table wider than the default 800 px. + +## A Stat Card + +```typescript +import { renderStatCard } from "#lib/stat-card.js"; + +const png = await renderStatCard({ + image: await fetchAvatar(user), + title: { text: user.username, y: 35, size: 50 }, + rank: { position: 3, total: 42 }, + valueSize: 30, + rows: [ + [ + { title: t("stats.messages"), value: "1 204" }, + { title: t("stats.streak"), value: "12 days", color: [94, 233, 181] }, + ], + [ + { + title: t("stats.joined"), + value: "2024-03-01", + subtext: t("stats.joinedHint"), + }, + ], + ], +}); +``` + +- **Rows** hold one to three boxes, laid out on the full width, in halves or in thirds. +- **A box** has a `title` and a `value`, and optionally a `color` (value and outline), a `suffix` after the value, a `subtext` and a small corner `avatar`. +- **The rank** shows a medal for the podium, `#N / total` otherwise; it is hidden when `position` is 0. +- **A footer** (`{ height, draw(ctx, top) }`) adds a custom panel under the boxes. `drawPanel`, `drawPanelTitle`, `drawText` and `measureText` from `#lib/stat-card` keep it consistent with the rest of the card; the RNGdle tier breakdown is built this way (`src/modules/rngdle/rendering/cards.ts`). + +## Good Practices + +- **Translate every text** you pass in (headers, titles, units): renderers draw what they receive. +- **Defer the reply** (`interaction.deferReply()`) before rendering: downloading avatars and drawing takes longer than Discord's 3-second limit allows. +- **Cache the PNG** when the same image is requested often, with `RevisionCache` from `#lib/revision-cache` keyed on the data that drives it. +- **Module-specific assets** (an extra font, icons) go in the module's own `assets/` folder; the Dockerfile copies every `assets/` folder next to the compiled code. diff --git a/docs/site/en/guide/tasks.md b/docs/site/en/guide/tasks.md index cb6b76b3..6369aac8 100644 --- a/docs/site/en/guide/tasks.md +++ b/docs/site/en/guide/tasks.md @@ -40,7 +40,3 @@ onLoad(_client, registry) { - **No overlap.** If a run is still in progress when the next one is due, the next one is skipped. - **Failures are contained.** An error thrown by `run` is logged with the task name; the task keeps its schedule and the bot keeps running. - **Every run is logged** with its duration. - -::: warning Importing core services from a module -`#core/services/*` modules import the bot entry point, which is itself still loading modules when your module file is imported. Import them **lazily inside `run`** (`await import("#core/services/module.service.js")`) rather than at the top of a file imported by your module — see the `rngdle` module's daily leaderboard task for an example. -::: diff --git a/docs/site/fr/guide/creating-a-module.md b/docs/site/fr/guide/creating-a-module.md index f8459b53..0f623e72 100644 --- a/docs/site/fr/guide/creating-a-module.md +++ b/docs/site/fr/guide/creating-a-module.md @@ -12,7 +12,7 @@ pnpm new-module mon-module "Mon Module" Le module généré compile et son test passe sans retouche. Remplissez la description `TODO`, puis activez-le via `/modules` sur votre serveur dev. Aucune étape d'enregistrement : les modules sont auto-découverts depuis `src/modules/` au démarrage. -Pour les tests, utilisez les helpers partagés de `#lib/testing.js` (`makeTestConfig`, `fakeGuild`, `fakeMessage`, `initTestI18n`) plutôt que des mocks maison. Notez que `vi.mock("#index.js")` et `vi.mock("#lib/database.js")` doivent toujours être déclarés par fichier de test — Vitest les hisse, aucun helper ne peut les masquer. +Pour les tests, utilisez les helpers partagés de `#lib/testing.js` (`makeTestConfig`, `fakeGuild`, `fakeMessage`, `initTestI18n`) plutôt que des mocks maison. Notez que `vi.mock("#core/runtime.js")` et `vi.mock("#lib/database.js")` doivent toujours être déclarés par fichier de test — Vitest les hisse, aucun helper ne peut les masquer. ``` src/modules/mon-module/ diff --git a/docs/site/fr/guide/images.md b/docs/site/fr/guide/images.md new file mode 100644 index 00000000..f3303bfa --- /dev/null +++ b/docs/site/fr/guide/images.md @@ -0,0 +1,89 @@ +# Générer des images + +Les classements et les fiches de statistiques sont rendus en images PNG avec [`@napi-rs/canvas`](https://github.com/Brooooooklyn/canvas) (binaires précompilés, rien à installer). Deux générateurs prêts à l'emploi couvrent l'essentiel des besoins ; des fonctions de plus bas niveau sont là pour le reste. + +| Module | Fournit | +| ------------------------ | ------------------------------------------------------------------------------------------------------------------- | +| `#lib/leaderboard-table` | `renderLeaderboardTable` : tableau classé avec médailles, avatars, pseudos et vos propres colonnes | +| `#lib/stat-card` | `renderStatCard` : fiche avec portrait, titre, rang optionnel, grille de cases de statistiques et pied personnalisé | +| `#lib/imaging` | Palette et police communes, téléchargement d'avatars, images rondes, texte ajusté, ressources | + +La police Outfit et les médailles or, argent et bronze sont fournies dans `src/lib/assets/` et chargées pour vous. + +## Un classement + +Les lignes étendent `LeaderboardEntry` (`rank`, `name`, `avatar`) : les colonnes rang, médaille, avatar et pseudo sont dessinées pour vous. Décrivez les autres colonnes : + +```typescript +import { fetchAvatar } from "#lib/imaging.js"; +import { + renderLeaderboardTable, + type LeaderboardEntry, +} from "#lib/leaderboard-table.js"; + +interface Row extends LeaderboardEntry { + points: number; +} + +const rows: Row[] = await Promise.all( + players.map(async (player, index) => ({ + rank: index + 1, + name: player.user.username, + avatar: await fetchAvatar(player.user), + points: player.points, + })) +); + +const png = await renderLeaderboardTable({ + headers: { rank: t("header.rank"), player: t("header.player") }, + rows, + columns: [ + { + header: t("header.points"), + x: 650, + maxWidth: 140, + cell: (row) => ({ text: String(row.points) }), + }, + ], +}); +``` + +Une colonne peut être alignée à droite (`align: "right"`, `x` est alors son bord droit), et chaque cellule peut fixer sa `color`, sa police (`family`) ou une petite `icon` dessinée avant le texte. Passez `caller` pour ajouter, sous un séparateur, la ligne de la personne qui consulte quand elle n'est pas sur la page, et `width` pour un tableau plus large que les 800 px par défaut. + +## Une fiche de statistiques + +```typescript +import { renderStatCard } from "#lib/stat-card.js"; + +const png = await renderStatCard({ + image: await fetchAvatar(user), + title: { text: user.username, y: 35, size: 50 }, + rank: { position: 3, total: 42 }, + valueSize: 30, + rows: [ + [ + { title: t("stats.messages"), value: "1 204" }, + { title: t("stats.streak"), value: "12 jours", color: [94, 233, 181] }, + ], + [ + { + title: t("stats.joined"), + value: "01/03/2024", + subtext: t("stats.joinedHint"), + }, + ], + ], +}); +``` + +- **Les lignes** contiennent une à trois cases, disposées sur toute la largeur, en moitiés ou en tiers. +- **Une case** a un `title` et une `value`, et éventuellement une `color` (valeur et contour), un `suffix` après la valeur, un `subtext` et un petit `avatar` dans le coin. +- **Le rang** affiche une médaille pour le podium, `#N / total` sinon ; il est masqué quand `position` vaut 0. +- **Un pied** (`{ height, draw(ctx, top) }`) ajoute un panneau personnalisé sous les cases. `drawPanel`, `drawPanelTitle`, `drawText` et `measureText` de `#lib/stat-card` le gardent cohérent avec le reste de la fiche ; la répartition par palier de RNGdle est construite ainsi (`src/modules/rngdle/rendering/cards.ts`). + +## Bonnes pratiques + +- **Traduisez chaque texte** que vous passez (en-têtes, titres, unités) : les générateurs dessinent ce qu'ils reçoivent. +- **Différez la réponse** (`interaction.deferReply()`) avant de générer : télécharger les avatars et dessiner prend plus que les 3 secondes accordées par Discord. +- **Mettez le PNG en cache** quand la même image est souvent demandée, avec `RevisionCache` de `#lib/revision-cache`, sur une clé qui dépend des données affichées. +- **Les ressources propres à un module** (une autre police, des icônes) vont dans son dossier `assets/` ; le Dockerfile copie chaque dossier `assets/` à côté du code compilé. diff --git a/docs/site/fr/guide/tasks.md b/docs/site/fr/guide/tasks.md index 4147491d..66e2a444 100644 --- a/docs/site/fr/guide/tasks.md +++ b/docs/site/fr/guide/tasks.md @@ -40,7 +40,3 @@ onLoad(_client, registry) { - **Pas de chevauchement.** Si une exécution est encore en cours à l'échéance suivante, celle-ci est sautée. - **Les erreurs sont contenues.** Une erreur levée par `run` est journalisée avec le nom de la tâche ; la tâche garde son planning et le bot continue de tourner. - **Chaque exécution est journalisée** avec sa durée. - -::: warning Importer les services du cœur depuis un module -Les modules `#core/services/*` importent le point d'entrée du bot, qui est lui-même encore en train de charger les modules quand le fichier de votre module est importé. Importez-les **paresseusement dans `run`** (`await import("#core/services/module.service.js")`) plutôt qu'en tête d'un fichier importé par votre module — voir la tâche du classement quotidien du module `rngdle`. -::: diff --git a/docs/specs/rngdle-migration.md b/docs/specs/rngdle-migration.md index 546f0780..b1c78d2f 100644 --- a/docs/specs/rngdle-migration.md +++ b/docs/specs/rngdle-migration.md @@ -1,6 +1,6 @@ # Spec — Module RNGdle : portage depuis DJ4H -> **Statut : implémenté.** Ce document a servi de contrat d'implémentation : il fige les signatures partagées entre les différentes parties du module. +> **Statut : implémenté.** ## Objectif @@ -15,180 +15,39 @@ src/modules/rngdle/ ├── rngdle.module.ts ├── rngdle.config.ts # leaderboardChannel (CHANNEL) ├── rngdle.prisma -├── assets/ # polices, médailles, icônes, score-table.json +├── tasks.ts # synchro, table des percentiles, classement quotidien +├── assets/ # police mono, icônes, score-table.json ├── i18n/{en,fr}.json ├── commands/ │ ├── rngdle.command.ts # leaderboard, profile, server-stats, leaderboard-all │ └── rngdle-admin.command.ts # register, delete, show, refresh, clear ├── interactions/ │ └── overall-page.button.ts # pagination de leaderboard-all -├── tasks/ # synchro, table des percentiles, classement quotidien ├── services/ -│ ├── score-table.ts # paliers, percentiles (pur) -│ ├── format.ts # formatage des nombres (pur) -│ ├── rngdle-api.ts # client rngdle.com +│ ├── api.ts # client rngdle.com (validation, nouvelles tentatives) +│ ├── scoring.ts # table des percentiles, paliers (pur) │ ├── stats.ts # agrégats profil / serveur (pur) -│ └── … # comptes, synchro, requêtes, cache d'images +│ ├── store.ts # persistance : comptes, tirages, table +│ ├── sync.ts # synchronisation, file par serveur +│ └── views.ts # données des images, résolution Discord, cache └── rendering/ - ├── common.ts # polices, palette, médailles, icônes, boîte arrondie - ├── daily-leaderboard-image.ts - ├── profile-image.ts - ├── server-stats-image.ts - └── overall-leaderboard-image.ts + ├── format.ts # formatage des nombres + ├── leaderboards.ts # classements du jour et général + └── cards.ts # profil et statistiques du serveur ``` +Briques partagées avec les autres modules : `#lib/task.ts` (tâches planifiées), `#lib/keyed-queue.ts` (file par clé), `#lib/revision-cache.ts` (cache invalidé par révision), `#lib/imaging.ts`, `#lib/leaderboard-table.ts` et `#lib/stat-card.ts` (rendu, décrit dans le guide « Générer des images » ; le classement est aussi utilisé par le jeu des 4h). + ## Décisions - Les commandes gardent leurs noms (`/rngdle`, `/rngdle-admin`). `/rngdle-admin setleaderboard` est remplacée par le champ `leaderboardChannel` de `/config rngdle`. -- Les tirages sont dédupliqués par l'identifiant unique que l'API renvoie pour chaque tirage, et non plus par « date + numéro ». -- Toutes les données sont scopées par serveur, y compris `clear` et `refresh`, qui touchaient tous les serveurs dans DJ4H. -- La table des percentiles est stockée en base. Un instantané versionné (`assets/score-table.json`) sert de valeur par défaut, ce qui évite le plantage de DJ4H sur un déploiement neuf. -- Les tâches planifiées passent par le planificateur du cœur (`#lib/task.ts`, croner, UTC). +- Les tirages sont dédupliqués par l'identifiant unique que l'API renvoie pour chaque tirage, et non plus par « date + numéro ». Les scores sont stockés en `BIGINT`. +- Toutes les données sont scopées par serveur, y compris `clear` et `refresh`, qui touchaient tous les serveurs dans DJ4H. Les synchronisations et les modifications de comptes d'un même serveur passent par une seule file. +- Les réponses de rngdle.com sont validées à la réception ; les erreurs 429 et 5xx sont retentées. +- La table des percentiles est stockée en base. Un instantané versionné (`assets/score-table.json`) sert de valeur par défaut, ce qui évite le plantage de DJ4H sur un déploiement neuf. Une table extraite du site qui ne ressemble pas à une table de percentiles est ignorée. +- Les images reproduisent au pixel près celles de DJ4H, mais leurs textes sont traduits. - La mention codée en dur « TnTube » du classement quotidien est supprimée. -## Contrat des générateurs d'images - -Chaque générateur est un port fidèle de la classe Python correspondante : mêmes dimensions, positions, couleurs et tailles de police. Seuls les textes changent : ils sont passés en paramètre (`labels`) pour être traduits. Les générateurs ne font aucune requête réseau : les avatars et icônes arrivent déjà chargés (`Image | null`, `null` = disque gris). - -Ancres de texte Pillow : `anchor="lt"` → `fillTextAnchored(ctx, text, x, y)` ; texte sans ancre (défaut Pillow `la`) → `fillTextAnchored(ctx, text, x, y, "ascender")`. Pour l'ancre `rt`, poser `ctx.textAlign = "right"` avant l'appel. - -### `renderDailyLeaderboard` — `LeaderboardGenerator` avec `RNGdleLeaderboardUser` - -```ts -export interface DailyLeaderboardRow { - rank: number; - name: string; - avatar: Image | null; - number: number; - score: number; - percent: number; - tier: TierOrError; -} -export interface DailyLeaderboardLabels { - rank: string; - player: string; - number: string; - score: string; - placement: string; -} -export function renderDailyLeaderboard( - rows: DailyLeaderboardRow[], - labels: DailyLeaderboardLabels -): Promise; -``` - -Lignes placées selon leur index (et non leur rang). Colonne tirage : `formatSpaced(number)` complété à gauche à 7 caractères, police `MONO_FONT`, couleur du palier. Colonne score : `formatCompact(score)`. Colonne placement : `formatPercent(percent)` en couleur du palier, précédé de `arrow_up` si `percent > 50`, sinon `trash`. - -### `renderProfile` — `ProfileGenerator` - -```ts -export interface ProfileRoll { - number: number; - score: number; - tier: TierOrError; - dateText: string; -} -export interface ProfileImageData { - username: string; - avatar: Image | null; - serverRank: number; - totalPlayers: number; - best: ProfileRoll; - worst: ProfileRoll; - totalRolls: number; - averageScore: number; - averageTier: TierOrError; - maxBadges: number; - totalScore: number; - tierCounts: Record; -} -export interface ProfileLabels { - bestRoll: string; - worstRoll: string; - date: (date: string) => string; - totalRolls: string; - averageScore: string; - maxBadges: string; - maxBadgesValue: (count: number) => string; - overallScore: string; - tierBreakdown: string; -} -export function renderProfile( - data: ProfileImageData, - labels: ProfileLabels -): Promise; -``` - -### `renderServerStats` — `ServerStatGenerator` - -```ts -export interface ServerStatsRoll { - number: number; - score: number; - tier: TierOrError; - playerName: string; - avatar: Image | null; -} -export interface ServerStatsImageData { - icon: Image | null; - best: ServerStatsRoll; - worst: ServerStatsRoll; - totalRolls: number; - averageScore: number; - averageTier: TierOrError; - overallScore: number; - tierCounts: Record; - tierLeaders: Record; -} -export interface ServerStatsLabels { - title: string; - bestRoll: string; - worstRoll: string; - by: (name: string) => string; - totalRolls: string; - averageScore: string; - overallScore: string; - tierBreakdown: string; -} -export function renderServerStats( - data: ServerStatsImageData, - labels: ServerStatsLabels -): Promise; -``` - -`tierLeaders` : au plus 3 avatars par palier, du plus fréquent au moins fréquent ; le premier est dessiné par-dessus les autres. - -### `renderOverallLeaderboard` — `OverallLeaderboardGenerator` - -```ts -export interface OverallRow { - rank: number; - name: string; - avatar: Image | null; - totalScore: number; -} -export interface OverallLabels { - rank: string; - player: string; - overallScore: string; -} -export function renderOverallLeaderboard( - rows: OverallRow[], - labels: OverallLabels, - locale: string, - caller?: OverallRow -): Promise; -``` - -Score affiché : `` `${formatShort(totalScore, locale)} EP` ``. `caller` ajoute, sous un séparateur, la ligne du membre qui consulte quand il n'est pas sur la page. - -### Modules partagés (déjà écrits, ne pas modifier) - -- `services/score-table.ts` : `TIERS`, `Tier`, `TierOrError`, `TIER_COLORS`, `formatPercent`, `ScoreTable`. -- `services/format.ts` : `formatSpaced`, `formatCompact`, `formatShort`. -- `rendering/common.ts` : `FONT`, `MONO_FONT`, `PALETTE`, `registerFonts`, `font`, `loadMedals`, `loadIcon`, `fillRoundedBox`. -- `#lib/imaging.js` : `drawCircularImage`, `drawFittedText`, `fillTextAnchored`, `rgb`. - ## Mise en production 1. Déployer OmniBot : le conteneur `migrate` crée les tables `RngdleAccount`, `RngdleRoll` et `RngdleScoreTable`. diff --git a/src/core/commands/config.command.test.ts b/src/core/commands/config.command.test.ts index c9c64fcc..927d8425 100644 --- a/src/core/commands/config.command.test.ts +++ b/src/core/commands/config.command.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("#index.js", () => ({ modules: [], client: {} })); +vi.mock("#core/runtime.js", () => ({ modules: [], client: {} })); vi.mock("#core/core.module.js", () => ({ default: { id: "core", name: "Core" }, })); diff --git a/src/core/commands/config.command.ts b/src/core/commands/config.command.ts index 6c64641e..dd539556 100644 --- a/src/core/commands/config.command.ts +++ b/src/core/commands/config.command.ts @@ -5,10 +5,10 @@ import { SlashCommandBuilder, } from "discord.js"; import coreModule from "#core/core.module.js"; +import { modules } from "#core/runtime.js"; import configService from "#core/services/config.service.js"; import moduleService from "#core/services/module.service.js"; import { configurationMessage } from "#core/utils/core-messages.js"; -import { modules } from "#index.js"; import { declareCommand } from "#lib/command.js"; import { Colors } from "#utils/colors.js"; diff --git a/src/core/config/config-edit.ts b/src/core/config/config-edit.ts index 752ebdac..d51c2eff 100644 --- a/src/core/config/config-edit.ts +++ b/src/core/config/config-edit.ts @@ -1,11 +1,11 @@ import { MessageFlags } from "discord.js"; import coreModule from "#core/core.module.js"; +import { modules } from "#core/runtime.js"; import configService from "#core/services/config.service.js"; import { configPageOfKey, configurationMessage, } from "#core/utils/core-messages.js"; -import { modules } from "#index.js"; import { ConfigType, type ConfigEntry, diff --git a/src/core/interactions/disable-module.button.ts b/src/core/interactions/disable-module.button.ts index a9c37e7e..3756a5cf 100644 --- a/src/core/interactions/disable-module.button.ts +++ b/src/core/interactions/disable-module.button.ts @@ -1,9 +1,9 @@ import { MessageFlags } from "discord.js"; import { uninstallModule } from "#core/loaders/module-installer.js"; +import { modules } from "#core/runtime.js"; import moduleService from "#core/services/module.service.js"; import { getCoreT } from "#core/utils/core-config.js"; import { modulesMessage } from "#core/utils/core-messages.js"; -import { modules } from "#index.js"; import { declareInteractionHandler } from "#lib/interaction.js"; export default declareInteractionHandler({ diff --git a/src/core/interactions/enable-module.button.ts b/src/core/interactions/enable-module.button.ts index edbacc07..213932f5 100644 --- a/src/core/interactions/enable-module.button.ts +++ b/src/core/interactions/enable-module.button.ts @@ -1,9 +1,9 @@ import { MessageFlags } from "discord.js"; import { installModule } from "#core/loaders/module-installer.js"; +import { modules } from "#core/runtime.js"; import moduleService from "#core/services/module.service.js"; import { getCoreT } from "#core/utils/core-config.js"; import { modulesMessage } from "#core/utils/core-messages.js"; -import { modules } from "#index.js"; import { declareInteractionHandler } from "#lib/interaction.js"; export default declareInteractionHandler({ diff --git a/src/core/listeners/guild-create.listener.test.ts b/src/core/listeners/guild-create.listener.test.ts index 903dd484..2c929e0d 100644 --- a/src/core/listeners/guild-create.listener.test.ts +++ b/src/core/listeners/guild-create.listener.test.ts @@ -8,7 +8,7 @@ import { } from "#lib/testing.js"; const { mockModules } = vi.hoisted(() => ({ mockModules: [] as any[] })); -vi.mock("#index.js", () => ({ modules: mockModules, client: {} })); +vi.mock("#core/runtime.js", () => ({ modules: mockModules, client: {} })); const { clearCacheForGuild, diff --git a/src/core/listeners/guild-create.listener.ts b/src/core/listeners/guild-create.listener.ts index c16a752c..6eaaa099 100644 --- a/src/core/listeners/guild-create.listener.ts +++ b/src/core/listeners/guild-create.listener.ts @@ -5,10 +5,10 @@ import { type GuildTextBasedChannel, } from "discord.js"; import { installModuleCommandsIn } from "#core/loaders/command-loader.js"; +import { modules } from "#core/runtime.js"; import configService from "#core/services/config.service.js"; import moduleService from "#core/services/module.service.js"; import { guildWelcomeMessage } from "#core/utils/core-messages.js"; -import { modules } from "#index.js"; import { createT, type TFunction } from "#lib/i18n.js"; import { declareEventListener } from "#lib/listener.js"; import { loggerMaker } from "#lib/logger.js"; diff --git a/src/core/listeners/interaction-create.listener.test.ts b/src/core/listeners/interaction-create.listener.test.ts index 4f7e30df..88fd9c1e 100644 --- a/src/core/listeners/interaction-create.listener.test.ts +++ b/src/core/listeners/interaction-create.listener.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { mockModules } = vi.hoisted(() => ({ mockModules: [] as any[] })); -vi.mock("#index.js", () => ({ modules: mockModules, client: {} })); +vi.mock("#core/runtime.js", () => ({ modules: mockModules, client: {} })); vi.mock("#core/core.module.js", () => ({ default: { id: "core", registry: { commands: [], interactionHandlers: [] } }, })); diff --git a/src/core/listeners/interaction-create.listener.ts b/src/core/listeners/interaction-create.listener.ts index c503a18d..aa4d55d5 100644 --- a/src/core/listeners/interaction-create.listener.ts +++ b/src/core/listeners/interaction-create.listener.ts @@ -5,10 +5,10 @@ import { MessageFlags, } from "discord.js"; import coreModule from "#core/core.module.js"; +import { modules } from "#core/runtime.js"; import configService from "#core/services/config.service.js"; import moduleService from "#core/services/module.service.js"; import { requireAdmin } from "#core/utils/require-admin.js"; -import { modules } from "#index.js"; import { createT } from "#lib/i18n.js"; import type { CompatibleInteraction } from "#lib/interaction.js"; import { declareEventListener } from "#lib/listener.js"; diff --git a/src/core/loaders/command-loader.integration.test.ts b/src/core/loaders/command-loader.integration.test.ts index 38d92838..0a529da2 100644 --- a/src/core/loaders/command-loader.integration.test.ts +++ b/src/core/loaders/command-loader.integration.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Module } from "#lib/module.js"; // Avoid booting the bot / Prisma when importing the command loader's module graph. -vi.mock("#index.js", () => ({ +vi.mock("#core/runtime.js", () => ({ modules: [], client: { user: { id: "app-1" }, token: "token", isReady: () => true }, })); diff --git a/src/core/loaders/listener-loader.test.ts b/src/core/loaders/listener-loader.test.ts index bf4561c9..5ef0caeb 100644 --- a/src/core/loaders/listener-loader.test.ts +++ b/src/core/loaders/listener-loader.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("#index.js", () => ({ modules: [], client: {} })); +vi.mock("#core/runtime.js", () => ({ modules: [], client: {} })); vi.mock("#core/core.module.js", () => ({ default: { id: "core", registry: { listeners: [] } }, })); diff --git a/src/core/loaders/module-installer.ts b/src/core/loaders/module-installer.ts index b5c75016..e9a979ff 100644 --- a/src/core/loaders/module-installer.ts +++ b/src/core/loaders/module-installer.ts @@ -1,6 +1,6 @@ import type { Client, Guild } from "discord.js"; +import { client } from "#core/runtime.js"; import moduleService from "#core/services/module.service.js"; -import { client } from "#index.js"; import type { Module } from "#lib/module.js"; import { installModuleCommandsIn, diff --git a/src/core/runtime.ts b/src/core/runtime.ts new file mode 100644 index 00000000..77d89eb7 --- /dev/null +++ b/src/core/runtime.ts @@ -0,0 +1,10 @@ +import type { Client } from "discord.js"; +import type { Module } from "#lib/module.js"; + +export let modules: Module[] = []; +export let client!: Client; + +export function setRuntime(loadedModules: Module[], discordClient: Client) { + modules = loadedModules; + client = discordClient; +} diff --git a/src/core/services/config.service.test.ts b/src/core/services/config.service.test.ts index 0f3660ae..3633bdd5 100644 --- a/src/core/services/config.service.test.ts +++ b/src/core/services/config.service.test.ts @@ -6,7 +6,7 @@ const { guildsFetch, channelsFetch } = vi.hoisted(() => ({ guildsFetch: vi.fn(), channelsFetch: vi.fn(), })); -vi.mock("#index.js", () => ({ +vi.mock("#core/runtime.js", () => ({ modules: [], client: { users: { fetch: vi.fn() }, guilds: { fetch: guildsFetch } }, })); @@ -59,7 +59,7 @@ vi.mock("#lib/database.js", () => { }); const { default: configService } = await import("./config.service.js"); -const { modules } = await import("#index.js"); +const { modules } = await import("#core/runtime.js"); const coreModule = { id: "core", config: {} } as unknown as Module; diff --git a/src/core/services/config.service.ts b/src/core/services/config.service.ts index 6b5ad235..2ddcb157 100644 --- a/src/core/services/config.service.ts +++ b/src/core/services/config.service.ts @@ -1,6 +1,6 @@ import type { JsonValue } from "@prisma/client/runtime/client"; import coreModule from "#core/core.module.js"; -import { client, modules } from "#index.js"; +import { client, modules } from "#core/runtime.js"; import { ConfigProvider, ConfigType, diff --git a/src/core/services/module.service.test.ts b/src/core/services/module.service.test.ts index bd5e63b6..e8bec450 100644 --- a/src/core/services/module.service.test.ts +++ b/src/core/services/module.service.test.ts @@ -3,7 +3,7 @@ import type { Module } from "#lib/module.js"; // The service pulls `client`/`modules` from the bot entrypoint and the Prisma // client; stub both so importing it never boots the bot or hits a database. -vi.mock("#index.js", () => ({ modules: [], client: {} })); +vi.mock("#core/runtime.js", () => ({ modules: [], client: {} })); const { findMany, findFirst, upsert } = vi.hoisted(() => ({ findMany: vi.fn(), @@ -16,7 +16,7 @@ vi.mock("#lib/database.js", () => ({ })); const { default: moduleService } = await import("./module.service.js"); -const { modules } = await import("#index.js"); +const { modules } = await import("#core/runtime.js"); const module = { id: "thread-creator", version: "2.0.0" } as unknown as Module; diff --git a/src/core/services/module.service.ts b/src/core/services/module.service.ts index f8d3e786..976ef8a2 100644 --- a/src/core/services/module.service.ts +++ b/src/core/services/module.service.ts @@ -1,5 +1,5 @@ import type { Guild } from "discord.js"; -import { modules } from "#index.js"; +import { modules } from "#core/runtime.js"; import prisma from "#lib/database.js"; import type { Module } from "#lib/module.js"; import { declareService, type Service } from "#lib/service.js"; diff --git a/src/index.ts b/src/index.ts index ebfee1ef..02e5e751 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import { startModuleTasks, stopAllTasks, } from "./core/loaders/task-scheduler.js"; +import { setRuntime } from "./core/runtime.js"; import prisma, { Prisma } from "./lib/database.js"; import { initI18n } from "./lib/i18n.js"; import logger from "./lib/logger.js"; @@ -37,7 +38,7 @@ await initI18n(); const corePath = path.resolve(fileURLToPath(import.meta.url), "..", "core"); await loadModuleI18n("core", corePath); -export const modules = await loadModules("./modules"); +const modules = await loadModules("./modules"); const intents = [ ...new Set( [coreModule, ...modules].flatMap((module) => module.intents ?? []) @@ -49,10 +50,11 @@ const partials = [ ), ]; -export const client = new Client({ +const client = new Client({ intents: intents, partials: partials, }); +setRuntime(modules, client); client.once(Events.ClientReady, async (readyClient) => { const failed = new Set(); diff --git a/src/modules/four-hour-game/assets/medal_bronze.png b/src/lib/assets/medal_bronze.png similarity index 100% rename from src/modules/four-hour-game/assets/medal_bronze.png rename to src/lib/assets/medal_bronze.png diff --git a/src/modules/four-hour-game/assets/medal_gold.png b/src/lib/assets/medal_gold.png similarity index 100% rename from src/modules/four-hour-game/assets/medal_gold.png rename to src/lib/assets/medal_gold.png diff --git a/src/modules/four-hour-game/assets/medal_silver.png b/src/lib/assets/medal_silver.png similarity index 100% rename from src/modules/four-hour-game/assets/medal_silver.png rename to src/lib/assets/medal_silver.png diff --git a/src/modules/four-hour-game/assets/outfit.ttf b/src/lib/assets/outfit.ttf similarity index 100% rename from src/modules/four-hour-game/assets/outfit.ttf rename to src/lib/assets/outfit.ttf diff --git a/src/lib/imaging.ts b/src/lib/imaging.ts index 1754ef0f..5967bf8f 100644 --- a/src/lib/imaging.ts +++ b/src/lib/imaging.ts @@ -2,6 +2,7 @@ import { fileURLToPath } from "node:url"; import { type Image, type SKRSContext2D, + createCanvas, GlobalFonts, loadImage, } from "@napi-rs/canvas"; @@ -11,13 +12,32 @@ import { loggerMaker } from "./logger.js"; const logger = loggerMaker("imaging"); const AVATAR_TIMEOUT_MS = 5_000; +const ASSETS = new URL("./assets/", import.meta.url); + +export const FONT = "Outfit"; export type Rgb = readonly [number, number, number]; +export const PALETTE = { + background: [25, 25, 25], + header: [50, 50, 50], + rowEven: [35, 35, 35], + rowOdd: [45, 45, 45], + box: [35, 35, 35], + text: [255, 255, 255], + title: [200, 200, 200], + subtext: [170, 170, 170], + placeholder: [120, 120, 120], +} as const satisfies Record; + export function rgb([r, g, b]: Rgb): string { return `rgb(${r}, ${g}, ${b})`; } +export function font(size: number, family: string = FONT): string { + return `${size}px ${family}`; +} + const registeredFonts = new Set(); export function registerFont(file: URL, family: string): void { @@ -41,6 +61,14 @@ export function loadAsset(file: URL): Promise { return image; } +export function loadMedals(): Promise { + return Promise.all( + ["gold", "silver", "bronze"].map((medal) => + loadAsset(new URL(`medal_${medal}.png`, ASSETS)) + ) + ); +} + export async function fetchImage(url: string | null): Promise { if (!url) { return null; @@ -66,6 +94,28 @@ export function fetchAvatar( return fetchImage(user.displayAvatarURL({ extension: "png", size })); } +export function fillBand( + ctx: SKRSContext2D, + color: Rgb, + top: number, + bottom: number +): void { + ctx.fillStyle = rgb(color); + ctx.fillRect(0, top, ctx.canvas.width, bottom - top + 1); +} + +export function createBoard( + width: number, + height: number, + headerHeight: number +): SKRSContext2D { + registerFont(new URL("outfit.ttf", ASSETS), FONT); + const ctx = createCanvas(width, height).getContext("2d"); + fillBand(ctx, PALETTE.background, 0, height - 1); + fillBand(ctx, PALETTE.header, 0, headerHeight); + return ctx; +} + export function drawCircularImage( ctx: SKRSContext2D, image: Image | null, @@ -81,20 +131,29 @@ export function drawCircularImage( if (image) { ctx.drawImage(image, x, y, size, size); } else { - ctx.fillStyle = rgb([120, 120, 120]); + ctx.fillStyle = rgb(PALETTE.placeholder); ctx.fillRect(x, y, size, size); } ctx.restore(); } -export type TextAnchor = "top" | "ascender"; +export function fillTextCentered( + ctx: SKRSContext2D, + text: string, + x: number, + centerY: number +): void { + ctx.textBaseline = "alphabetic"; + const capHeight = ctx.measureText("H").actualBoundingBoxAscent; + ctx.fillText(text, x, Math.round(centerY + capHeight / 2)); +} export function fillTextAnchored( ctx: SKRSContext2D, text: string, x: number, y: number, - anchor: TextAnchor = "top" + anchor: "top" | "ascender" = "top" ): void { ctx.textBaseline = "alphabetic"; const metrics = ctx.measureText(text); @@ -104,29 +163,3 @@ export function fillTextAnchored( : metrics.fontBoundingBoxAscent; ctx.fillText(text, x, y + ascent); } - -export interface FittedTextOptions { - family: string; - size: number; - maxWidth: number; - color: string; - align?: "left" | "right"; -} - -export function drawFittedText( - ctx: SKRSContext2D, - text: string, - x: number, - y: number, - { family, size, maxWidth, color, align = "left" }: FittedTextOptions -): void { - let fontSize = size; - ctx.font = `${fontSize}px ${family}`; - while (fontSize > 1 && ctx.measureText(text).width > maxWidth) { - fontSize -= 1; - ctx.font = `${fontSize}px ${family}`; - } - ctx.fillStyle = color; - ctx.textAlign = align; - fillTextAnchored(ctx, text, x, y); -} diff --git a/src/lib/keyed-queue.test.ts b/src/lib/keyed-queue.test.ts new file mode 100644 index 00000000..20ae09f6 --- /dev/null +++ b/src/lib/keyed-queue.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { KeyedQueue } from "./keyed-queue.js"; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +describe("KeyedQueue", () => { + it("runs tasks of the same key one after another, in order", async () => { + const queue = new KeyedQueue(); + const gate = deferred(); + const order: string[] = []; + + const first = queue.run("guild", async () => { + await gate.promise; + order.push("first"); + }); + const second = queue.run("guild", async () => { + order.push("second"); + }); + + await Promise.resolve(); + expect(order).toEqual([]); + gate.resolve(); + await Promise.all([first, second]); + expect(order).toEqual(["first", "second"]); + }); + + it("does not make other keys wait", async () => { + const queue = new KeyedQueue(); + const gate = deferred(); + + const blocked = queue.run("a", () => gate.promise); + await expect(queue.run("b", async () => "free")).resolves.toBe("free"); + + gate.resolve(); + await blocked; + }); + + it("keeps going after a failed task and returns each result", async () => { + const queue = new KeyedQueue(); + + await expect( + queue.run("guild", async () => { + throw new Error("boom"); + }) + ).rejects.toThrow("boom"); + await expect(queue.run("guild", async () => 42)).resolves.toBe(42); + }); +}); diff --git a/src/lib/keyed-queue.ts b/src/lib/keyed-queue.ts new file mode 100644 index 00000000..1043c045 --- /dev/null +++ b/src/lib/keyed-queue.ts @@ -0,0 +1,15 @@ +export class KeyedQueue { + private readonly tails = new Map>(); + + run(key: string, task: () => Promise): Promise { + const next = (this.tails.get(key) ?? Promise.resolve()).then(task, task); + const tail = next.catch(() => undefined); + this.tails.set(key, tail); + void tail.then(() => { + if (this.tails.get(key) === tail) { + this.tails.delete(key); + } + }); + return next; + } +} diff --git a/src/lib/leaderboard-table.ts b/src/lib/leaderboard-table.ts new file mode 100644 index 00000000..5e9989d9 --- /dev/null +++ b/src/lib/leaderboard-table.ts @@ -0,0 +1,155 @@ +import type { Image, SKRSContext2D } from "@napi-rs/canvas"; +import { + createBoard, + drawCircularImage, + fillBand, + fillTextCentered, + font, + FONT, + loadMedals, + PALETTE, + rgb, + type Rgb, +} from "./imaging.js"; + +const DEFAULT_WIDTH = 800; +const HEADER_HEIGHT = 100; +const ROW_HEIGHT = 90; +const FONT_SIZE = 30; +const RANK_X = 15; +const RANK_TEXT_X = 30; +const MEDAL_SIZE = 50; +const AVATAR_X = 100; +const AVATAR_SIZE = 60; +const PLAYER_X = 190; +const PLAYER_MAX_WIDTH = 320; +const ICON_SIZE = 40; +const ICON_GAP = 10; +const CALLER_GAP = 20; +const SEPARATOR_HEIGHT = 2; +const SEPARATOR_COLOR: Rgb = [80, 80, 80]; + +export interface LeaderboardEntry { + rank: number; + name: string; + avatar: Image | null; +} + +export interface Cell { + text: string; + color?: Rgb; + family?: string; + icon?: Image; +} + +export interface Column { + header: string; + x: number; + maxWidth: number; + align?: "left" | "right"; + cell: (row: Row) => Cell; +} + +export interface LeaderboardTable { + headers: { rank: string; player: string }; + columns: Column[]; + rows: Row[]; + caller?: Row | undefined; + width?: number; +} + +function fitFont( + ctx: SKRSContext2D, + text: string, + family: string, + maxWidth: number +): number { + for (let size = FONT_SIZE; ; size--) { + ctx.font = font(size, family); + const { width } = ctx.measureText(text); + if (size <= 1 || width <= maxWidth) { + return width; + } + } +} + +function drawCell( + ctx: SKRSContext2D, + { text, color = PALETTE.text, family = FONT, icon }: Cell, + x: number, + centerY: number, + maxWidth: number, + align: "left" | "right" = "left" +): void { + const width = fitFont(ctx, text, family, maxWidth); + if (icon) { + ctx.drawImage( + icon, + Math.trunc(x - width - ICON_GAP - ICON_SIZE), + centerY - ICON_SIZE / 2, + ICON_SIZE, + ICON_SIZE + ); + } + ctx.fillStyle = rgb(color); + ctx.textAlign = align; + fillTextCentered(ctx, text, x, centerY); +} + +export async function renderLeaderboardTable({ + headers, + columns, + rows, + caller, + width = DEFAULT_WIDTH, +}: LeaderboardTable): Promise { + const bottom = HEADER_HEIGHT + rows.length * ROW_HEIGHT; + const callerTop = bottom + CALLER_GAP; + const height = caller ? callerTop + ROW_HEIGHT : bottom; + const ctx = createBoard(width, height, HEADER_HEIGHT); + const medals = await loadMedals(); + + const headerY = HEADER_HEIGHT / 2; + ctx.font = font(FONT_SIZE); + ctx.fillStyle = rgb(PALETTE.text); + ctx.textAlign = "left"; + fillTextCentered(ctx, headers.rank, RANK_X, headerY); + fillTextCentered(ctx, headers.player, PLAYER_X, headerY); + for (const { header, x, align = "left" } of columns) { + ctx.textAlign = align; + fillTextCentered(ctx, header, x, headerY); + } + + const drawRow = (row: Row, top: number, color: Rgb): void => { + fillBand(ctx, color, top, top + ROW_HEIGHT); + const y = top + ROW_HEIGHT / 2; + const medal = medals[row.rank - 1]; + if (medal) { + ctx.drawImage(medal, RANK_X, y - MEDAL_SIZE / 2, MEDAL_SIZE, MEDAL_SIZE); + } else { + ctx.font = font(FONT_SIZE); + ctx.fillStyle = rgb(PALETTE.text); + ctx.textAlign = "left"; + fillTextCentered(ctx, String(row.rank), RANK_TEXT_X, y); + } + const avatarY = top + (ROW_HEIGHT - AVATAR_SIZE) / 2; + drawCircularImage(ctx, row.avatar, AVATAR_X, avatarY, AVATAR_SIZE); + drawCell(ctx, { text: row.name }, PLAYER_X, y, PLAYER_MAX_WIDTH); + for (const { x, maxWidth, align, cell } of columns) { + drawCell(ctx, cell(row), x, y, maxWidth, align); + } + }; + + rows.forEach((row, index) => { + const color = index % 2 === 0 ? PALETTE.rowEven : PALETTE.rowOdd; + drawRow(row, HEADER_HEIGHT + index * ROW_HEIGHT, color); + }); + if (caller) { + const separatorTop = bottom + CALLER_GAP / 2; + const separatorBottom = separatorTop + SEPARATOR_HEIGHT - 1; + fillBand(ctx, SEPARATOR_COLOR, separatorTop, separatorBottom); + drawRow(caller, callerTop, PALETTE.rowEven); + } + + return ctx.canvas.encode("png"); +} diff --git a/src/lib/revision-cache.test.ts b/src/lib/revision-cache.test.ts new file mode 100644 index 00000000..8aff87b1 --- /dev/null +++ b/src/lib/revision-cache.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { RevisionCache } from "./revision-cache.js"; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("RevisionCache", () => { + it("loads once for repeated and concurrent reads of the same revision", async () => { + const cache = new RevisionCache(1_000); + const load = vi.fn(async () => "value"); + + await Promise.all([cache.get("k", 0, load), cache.get("k", 0, load)]); + await cache.get("k", 0, load); + + expect(load).toHaveBeenCalledTimes(1); + }); + + it("loads again when the revision changes or the entry expires", async () => { + const cache = new RevisionCache(1_000); + const load = vi.fn(async () => "value"); + + await cache.get("k", 0, load); + await cache.get("k", 1, load); + vi.advanceTimersByTime(1_001); + await cache.get("k", 1, load); + + expect(load).toHaveBeenCalledTimes(3); + }); + + it("keeps keys apart and forgets failed loads", async () => { + const cache = new RevisionCache(1_000); + const load = vi.fn(async () => "value"); + + await cache.get("a", 0, load); + await cache.get("b", 0, load); + await expect( + cache.get("c", 0, async () => { + throw new Error("boom"); + }) + ).rejects.toThrow("boom"); + await cache.get("c", 0, load); + + expect(load).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/lib/revision-cache.ts b/src/lib/revision-cache.ts new file mode 100644 index 00000000..62179bfd --- /dev/null +++ b/src/lib/revision-cache.ts @@ -0,0 +1,35 @@ +interface Entry { + revision: number; + expiresAt: number; + value: Promise; +} + +export class RevisionCache { + private readonly entries = new Map(); + + constructor(private readonly ttlMs: number) {} + + get(key: string, revision: number, load: () => Promise): Promise { + const now = Date.now(); + const cached = this.entries.get(key); + if (cached && cached.revision === revision && cached.expiresAt > now) { + return cached.value as Promise; + } + + for (const [entryKey, entry] of this.entries) { + if (entry.expiresAt <= now) { + this.entries.delete(entryKey); + } + } + + const value = load(); + const entry = { revision, expiresAt: now + this.ttlMs, value }; + this.entries.set(key, entry); + value.catch(() => { + if (this.entries.get(key) === entry) { + this.entries.delete(key); + } + }); + return value; + } +} diff --git a/src/lib/stat-card.test.ts b/src/lib/stat-card.test.ts new file mode 100644 index 00000000..b2a902c6 --- /dev/null +++ b/src/lib/stat-card.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderStatCard, type StatCard } from "./stat-card.js"; + +function pngSize(png: Buffer) { + return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; +} + +const box = { title: "Title", value: "42" }; + +function card(overrides: Partial = {}): StatCard { + return { + image: null, + title: { text: "Player", y: 35, size: 50 }, + valueSize: 30, + rows: [[box, box]], + ...overrides, + }; +} + +describe("renderStatCard", () => { + it("grows with its rows and its footer", async () => { + const twoRows = await renderStatCard( + card({ rows: [[box], [box, box, box]] }) + ); + const withFooter = await renderStatCard( + card({ footer: { height: 240, draw: vi.fn() } }) + ); + + expect(pngSize(twoRows)).toEqual({ width: 800, height: 450 }); + expect(pngSize(withFooter)).toEqual({ width: 800, height: 580 }); + }); + + it("hands the footer its top position", async () => { + const draw = vi.fn(); + + await renderStatCard( + card({ rows: [[box], [box]], footer: { height: 10, draw } }) + ); + + expect(draw).toHaveBeenCalledWith(expect.anything(), 440); + }); + + it("renders a rank, with or without a medal", async () => { + for (const position of [1, 7]) { + const png = await renderStatCard(card({ rank: { position, total: 12 } })); + expect(png.subarray(1, 4).toString("ascii")).toBe("PNG"); + } + }); + + it("rejects a row that cannot be laid out", async () => { + await expect( + renderStatCard(card({ rows: [[box, box, box, box]] })) + ).rejects.toThrow("1 to 3 boxes"); + }); +}); diff --git a/src/lib/stat-card.ts b/src/lib/stat-card.ts new file mode 100644 index 00000000..958977b0 --- /dev/null +++ b/src/lib/stat-card.ts @@ -0,0 +1,228 @@ +import type { Image, SKRSContext2D } from "@napi-rs/canvas"; +import { + createBoard, + drawCircularImage, + fillTextAnchored, + font, + loadMedals, + PALETTE, + rgb, + type Rgb, +} from "./imaging.js"; + +const WIDTH = 800; +const HEADER_HEIGHT = 150; +export const CARD_MARGIN = 40; +export const CARD_CONTENT_WIDTH = WIDTH - 2 * CARD_MARGIN; +const PORTRAIT_Y = 25; +const PORTRAIT_SIZE = 100; +const TITLE_X = 170; + +const ROWS_TOP = 180; +const ROW_STEP = 130; +const BOX_HEIGHT = 110; +const BOTTOM_MARGIN = 30; +const PANEL_RADIUS = 12; +export const PANEL_PADDING = 20; +const BOX_TITLE_Y = 15; +const BOX_VALUE_Y = 45; +const BOX_SUBTEXT_Y = 80; +const BOX_AVATAR_SIZE = 32; +const BOX_AVATAR_INSET = { x: 15, y: 10 }; +export const LABEL_SIZE = 22; +const SUBTEXT_SIZE = 18; +const SUFFIX_COLOR: Rgb = [215, 215, 215]; +const ROW_LAYOUTS: Record = { + 1: { width: CARD_CONTENT_WIDTH, xs: [CARD_MARGIN] }, + 2: { width: 345, xs: [40, 415] }, + 3: { width: 226, xs: [40, 286, 532] }, +}; + +const RANK_RIGHT = 760; +const RANK_SIZE = 80; +const RANK_Y = 25; +const RANK_COLOR: Rgb = [150, 150, 150]; +const TOTAL_SIZE = 30; +const TOTAL_Y = 70; +const MEDAL_Y = 15; +const MEDAL_HEIGHT = 90; +const MEDAL_WIDTHS = [90, 93, 93]; + +export interface StatBox { + title: string; + value: string; + color?: Rgb; + suffix?: string; + subtext?: string; + avatar?: Image | null; +} + +export interface CardSection { + height: number; + draw: (ctx: SKRSContext2D, top: number) => void; +} + +export interface StatCard { + image: Image | null; + title: { text: string; y: number; size: number }; + rank?: { position: number; total: number }; + valueSize: number; + rows: StatBox[][]; + footer?: CardSection; +} + +export function drawText( + ctx: SKRSContext2D, + value: string, + x: number, + y: number, + size: number, + color: Rgb +): void { + ctx.font = font(size); + ctx.fillStyle = rgb(color); + fillTextAnchored(ctx, value, x, y, "ascender"); +} + +export function measureText( + ctx: SKRSContext2D, + value: string, + size: number +): number { + ctx.font = font(size); + return ctx.measureText(value).width; +} + +export function drawPanel( + ctx: SKRSContext2D, + x: number, + y: number, + width: number, + height: number, + outline?: Rgb +): void { + ctx.beginPath(); + ctx.roundRect(x, y, width + 1, height + 1, PANEL_RADIUS); + ctx.fillStyle = rgb(PALETTE.box); + ctx.fill(); + if (outline) { + ctx.beginPath(); + ctx.roundRect(x + 1, y + 1, width - 1, height - 1, PANEL_RADIUS - 1); + ctx.lineWidth = 2; + ctx.strokeStyle = rgb(outline); + ctx.stroke(); + } +} + +export function drawPanelTitle( + ctx: SKRSContext2D, + title: string, + x: number, + y: number +): void { + drawText( + ctx, + title, + x + PANEL_PADDING, + y + BOX_TITLE_Y, + LABEL_SIZE, + PALETTE.title + ); +} + +function drawStatBox( + ctx: SKRSContext2D, + valueSize: number, + x: number, + y: number, + width: number, + { title, value, color, suffix, subtext, avatar }: StatBox +): void { + const left = x + PANEL_PADDING; + drawPanel(ctx, x, y, width, BOX_HEIGHT, color); + drawPanelTitle(ctx, title, x, y); + if (avatar !== undefined) { + const avatarX = x + width - BOX_AVATAR_SIZE - BOX_AVATAR_INSET.x; + const avatarY = y + BOX_AVATAR_INSET.y; + drawCircularImage(ctx, avatar, avatarX, avatarY, BOX_AVATAR_SIZE); + } + const valueY = y + BOX_VALUE_Y; + drawText(ctx, value, left, valueY, valueSize, color ?? PALETTE.text); + if (suffix) { + const suffixX = left + measureText(ctx, `${value} `, valueSize); + drawText(ctx, suffix, suffixX, valueY, valueSize, SUFFIX_COLOR); + } + if (subtext) { + drawText( + ctx, + subtext, + left, + y + BOX_SUBTEXT_Y, + SUBTEXT_SIZE, + PALETTE.subtext + ); + } +} + +function drawRank( + ctx: SKRSContext2D, + { position, total }: { position: number; total: number }, + medals: Image[] +): void { + const totalText = ` / ${total}`; + const right = RANK_RIGHT - measureText(ctx, totalText, TOTAL_SIZE); + const medal = medals[position - 1]; + const width = MEDAL_WIDTHS[position - 1]; + if (medal && width) { + ctx.drawImage( + medal, + Math.trunc(right - width), + MEDAL_Y, + width, + MEDAL_HEIGHT + ); + } else { + const rankText = `#${position}`; + const rankX = right - measureText(ctx, rankText, RANK_SIZE); + drawText(ctx, rankText, rankX, RANK_Y, RANK_SIZE, RANK_COLOR); + } + drawText(ctx, totalText, right, TOTAL_Y, TOTAL_SIZE, PALETTE.subtext); +} + +export async function renderStatCard(card: StatCard): Promise { + const medals = card.rank ? await loadMedals() : []; + const footerTop = ROWS_TOP + card.rows.length * ROW_STEP; + const height = + footerTop + (card.footer?.height ?? BOX_HEIGHT - ROW_STEP) + BOTTOM_MARGIN; + const ctx = createBoard(WIDTH, height, HEADER_HEIGHT); + ctx.textAlign = "left"; + + drawCircularImage(ctx, card.image, CARD_MARGIN, PORTRAIT_Y, PORTRAIT_SIZE); + const { text, y, size } = card.title; + drawText(ctx, text, TITLE_X, y, size, PALETTE.text); + if (card.rank && card.rank.position > 0) { + drawRank(ctx, card.rank, medals); + } + + card.rows.forEach((boxes, row) => { + const layout = ROW_LAYOUTS[boxes.length]; + if (!layout) { + throw new Error( + `A stat card row holds 1 to 3 boxes, got ${boxes.length}` + ); + } + boxes.forEach((box, index) => { + drawStatBox( + ctx, + card.valueSize, + layout.xs[index]!, + ROWS_TOP + row * ROW_STEP, + layout.width, + box + ); + }); + }); + card.footer?.draw(ctx, footerTop); + + return ctx.canvas.encode("png"); +} diff --git a/src/lib/testing.ts b/src/lib/testing.ts index 667801b8..5b4998d0 100644 --- a/src/lib/testing.ts +++ b/src/lib/testing.ts @@ -2,11 +2,11 @@ * Test-only helpers for module authors. Import from `*.test.ts` files only — * never from runtime code. * - * What this file does NOT do: `vi.mock("#index.js")` and + * What this file does NOT do: `vi.mock("#core/runtime.js")` and * `vi.mock("#lib/database.js")` must still be declared at the top of each * test file. Vitest hoists those calls above imports, so no helper can hide - * them. Forgetting the `#index.js` mock boots the real bot (Prisma + login), - * forgetting the database mock hits a real database. + * them. Forgetting the `#core/runtime.js` mock leaves `modules` empty and + * `client` undefined, forgetting the database mock hits a real database. */ import { ChannelType } from "discord.js"; import { vi } from "vitest"; diff --git a/src/modules/four-hour-game/commands/jd4h.command.ts b/src/modules/four-hour-game/commands/jd4h.command.ts index 145373d9..7e496d3c 100644 --- a/src/modules/four-hour-game/commands/jd4h.command.ts +++ b/src/modules/four-hour-game/commands/jd4h.command.ts @@ -8,14 +8,16 @@ import { import { declareCommand } from "#lib/command.js"; import type { ConfigProvider } from "#lib/config.js"; import { fetchAvatar } from "#lib/imaging.js"; +import { RevisionCache } from "#lib/revision-cache.js"; import type { FourHourGameConfigSchema } from "#modules/four-hour-game/four-hour-game.config.js"; import { renderLeaderboard } from "#modules/four-hour-game/rendering/leaderboard-image.js"; import fourHourGameService from "#modules/four-hour-game/services/four-hour-game.service.js"; -import leaderboardCache from "#modules/four-hour-game/services/leaderboard-cache.service.js"; import { Colors } from "#utils/colors.js"; const LEADERBOARD_SIZE = 10; +const leaderboardCache = new RevisionCache(15_000); + type Config = ConfigProvider; async function showScore( @@ -94,8 +96,8 @@ async function showLeaderboard( await interaction.deferReply(); const image = await leaderboardCache.get( - interaction.guildId, - config.locale, + `${interaction.guildId}:${config.locale}`, + fourHourGameService.getRevision(interaction.guildId), () => buildLeaderboardImage(interaction, config) ); diff --git a/src/modules/four-hour-game/rendering/leaderboard-image.test.ts b/src/modules/four-hour-game/rendering/leaderboard-image.test.ts index cec8e0a9..5f7305f5 100644 --- a/src/modules/four-hour-game/rendering/leaderboard-image.test.ts +++ b/src/modules/four-hour-game/rendering/leaderboard-image.test.ts @@ -1,12 +1,6 @@ import { describe, expect, it } from "vitest"; import { renderLeaderboard } from "./leaderboard-image.js"; -const headers = { rank: "Rank", player: "Player", score: "Score" }; - -function pngSize(png: Buffer) { - return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; -} - describe("renderLeaderboard", () => { it("renders a PNG with one header and one 90px row per player", async () => { const rows = Array.from({ length: 10 }, (_, i) => ({ @@ -16,9 +10,14 @@ describe("renderLeaderboard", () => { avatar: null, })); - const png = await renderLeaderboard(rows, headers); + const png = await renderLeaderboard(rows, { + rank: "Rank", + player: "Player", + score: "Score", + }); expect(png.subarray(1, 4).toString("ascii")).toBe("PNG"); - expect(pngSize(png)).toEqual({ width: 800, height: 100 + 10 * 90 }); + expect(png.readUInt32BE(16)).toBe(800); + expect(png.readUInt32BE(20)).toBe(100 + 10 * 90); }); }); diff --git a/src/modules/four-hour-game/rendering/leaderboard-image.ts b/src/modules/four-hour-game/rendering/leaderboard-image.ts index f604890c..9ab50766 100644 --- a/src/modules/four-hour-game/rendering/leaderboard-image.ts +++ b/src/modules/four-hour-game/rendering/leaderboard-image.ts @@ -1,42 +1,10 @@ -import { createCanvas, type Image } from "@napi-rs/canvas"; import { - drawCircularImage, - drawFittedText, - fillTextAnchored, - loadAsset, - registerFont, - rgb, - type Rgb, -} from "#lib/imaging.js"; + renderLeaderboardTable, + type LeaderboardEntry, +} from "#lib/leaderboard-table.js"; -const FONT_FAMILY = "Outfit"; -const FONT_SIZE = 30; - -const WIDTH = 800; -const HEADER_HEIGHT = 100; -const ROW_HEIGHT = 90; -const AVATAR_SIZE = 60; -const MEDAL_SIZE = 50; - -const BACKGROUND: Rgb = [25, 25, 25]; -const HEADER_BACKGROUND: Rgb = [50, 50, 50]; -const ROW_EVEN: Rgb = [35, 35, 35]; -const ROW_ODD: Rgb = [45, 45, 45]; -const TEXT: Rgb = [255, 255, 255]; - -const RANK_X = 15; -const PLAYER_X = 190; -const SCORE_X = 650; -const PLAYER_MAX_WIDTH = 320; -const SCORE_MAX_WIDTH = 140; - -const assets = new URL("../assets/", import.meta.url); - -export interface LeaderboardRow { - rank: number; - name: string; +export interface LeaderboardRow extends LeaderboardEntry { score: number; - avatar: Image | null; } export interface LeaderboardHeaders { @@ -45,62 +13,20 @@ export interface LeaderboardHeaders { score: string; } -export async function renderLeaderboard( +export function renderLeaderboard( rows: LeaderboardRow[], headers: LeaderboardHeaders ): Promise { - registerFont(new URL("outfit.ttf", assets), FONT_FAMILY); - const medals = await Promise.all( - ["medal_gold.png", "medal_silver.png", "medal_bronze.png"].map((file) => - loadAsset(new URL(file, assets)) - ) - ); - - const canvas = createCanvas(WIDTH, HEADER_HEIGHT + rows.length * ROW_HEIGHT); - const ctx = canvas.getContext("2d"); - - ctx.fillStyle = rgb(BACKGROUND); - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = rgb(HEADER_BACKGROUND); - ctx.fillRect(0, 0, canvas.width, HEADER_HEIGHT); - - const headerY = HEADER_HEIGHT / 2 - 15; - ctx.font = `${FONT_SIZE}px ${FONT_FAMILY}`; - ctx.fillStyle = rgb(TEXT); - ctx.textAlign = "left"; - fillTextAnchored(ctx, headers.rank, RANK_X, headerY); - fillTextAnchored(ctx, headers.player, PLAYER_X, headerY); - fillTextAnchored(ctx, headers.score, SCORE_X, headerY); - - rows.forEach((row, index) => { - const top = HEADER_HEIGHT + index * ROW_HEIGHT; - const textY = top + ROW_HEIGHT / 2 - 15; - - ctx.fillStyle = rgb(index % 2 === 0 ? ROW_EVEN : ROW_ODD); - ctx.fillRect(0, top, canvas.width, ROW_HEIGHT); - - const medal = medals[row.rank - 1]; - if (medal) { - ctx.drawImage(medal, RANK_X, textY - 10, MEDAL_SIZE, MEDAL_SIZE); - } else { - ctx.font = `${FONT_SIZE}px ${FONT_FAMILY}`; - ctx.fillStyle = rgb(TEXT); - ctx.textAlign = "left"; - fillTextAnchored(ctx, String(row.rank), 30, textY, "ascender"); - } - - drawCircularImage(ctx, row.avatar, 100, top + 15, AVATAR_SIZE); - - const text = { family: FONT_FAMILY, size: FONT_SIZE, color: rgb(TEXT) }; - drawFittedText(ctx, row.name, PLAYER_X, textY, { - ...text, - maxWidth: PLAYER_MAX_WIDTH, - }); - drawFittedText(ctx, String(row.score), SCORE_X, textY, { - ...text, - maxWidth: SCORE_MAX_WIDTH, - }); + return renderLeaderboardTable({ + headers, + rows, + columns: [ + { + header: headers.score, + x: 650, + maxWidth: 140, + cell: (row) => ({ text: String(row.score) }), + }, + ], }); - - return canvas.encode("png"); } diff --git a/src/modules/four-hour-game/services/four-hour-game.service.ts b/src/modules/four-hour-game/services/four-hour-game.service.ts index 501c7b74..5c83d1a8 100644 --- a/src/modules/four-hour-game/services/four-hour-game.service.ts +++ b/src/modules/four-hour-game/services/four-hour-game.service.ts @@ -1,4 +1,5 @@ import prisma from "#lib/database.js"; +import { KeyedQueue } from "#lib/keyed-queue.js"; import { declareService, type Service } from "#lib/service.js"; import { evaluateMessage, type MessageOutcome } from "./game-rules.js"; @@ -31,7 +32,7 @@ export interface LeaderboardEntry { } class FourHourGameService implements Service { - private readonly guildChains = new Map>(); + private readonly queue = new KeyedQueue(); private readonly revisions = new Map(); getRevision(guildId: string): number { @@ -42,24 +43,11 @@ class FourHourGameService implements Service { this.revisions.set(guildId, this.getRevision(guildId) + 1); } - private serialize(guildId: string, task: () => Promise): Promise { - const previous = this.guildChains.get(guildId) ?? Promise.resolve(); - const next = previous.then(task, task); - const settled = next.catch(() => undefined); - this.guildChains.set(guildId, settled); - void settled.then(() => { - if (this.guildChains.get(guildId) === settled) { - this.guildChains.delete(guildId); - } - }); - return next; - } - handleMessage( message: GameMessage, delaySeconds: number ): Promise { - return this.serialize(message.guildId, () => + return this.queue.run(message.guildId, () => this.processMessage(message, delaySeconds) ); } @@ -106,7 +94,7 @@ class FourHourGameService implements Service { } resetRound(guildId: string): Promise { - return this.serialize(guildId, async () => { + return this.queue.run(guildId, async () => { await prisma.fourHourGameLastMessage.deleteMany({ where: { guildId } }); }); } @@ -116,7 +104,7 @@ class FourHourGameService implements Service { channelId: string, fetchLatest: LatestMessageFetcher ): Promise { - return this.serialize(guildId, () => + return this.queue.run(guildId, () => this.replaceLastMessage(guildId, channelId, fetchLatest) ); } @@ -127,7 +115,7 @@ class FourHourGameService implements Service { messageIds: readonly string[], fetchLatest: LatestMessageFetcher ): Promise { - return this.serialize(guildId, async () => { + return this.queue.run(guildId, async () => { const last = await prisma.fourHourGameLastMessage.findUnique({ where: { guildId }, }); diff --git a/src/modules/four-hour-game/services/leaderboard-cache.service.test.ts b/src/modules/four-hour-game/services/leaderboard-cache.service.test.ts deleted file mode 100644 index 1776074b..00000000 --- a/src/modules/four-hour-game/services/leaderboard-cache.service.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const revisions = vi.hoisted(() => new Map()); -vi.mock("./four-hour-game.service.js", () => ({ - default: { getRevision: (guildId: string) => revisions.get(guildId) ?? 0 }, -})); - -const { default: cache } = await import("./leaderboard-cache.service.js"); - -let guild = 0; -let guildId = ""; - -beforeEach(() => { - guildId = `guild-${++guild}`; - vi.useFakeTimers(); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe("LeaderboardCacheService", () => { - it("renders once for repeated and concurrent requests", async () => { - const render = vi.fn(async () => Buffer.from("png")); - - await Promise.all([ - cache.get(guildId, "fr", render), - cache.get(guildId, "fr", render), - ]); - await cache.get(guildId, "fr", render); - - expect(render).toHaveBeenCalledTimes(1); - }); - - it("renders again once a score has changed", async () => { - const render = vi.fn(async () => Buffer.from("png")); - - await cache.get(guildId, "fr", render); - revisions.set(guildId, 1); - await cache.get(guildId, "fr", render); - - expect(render).toHaveBeenCalledTimes(2); - }); - - it("renders again after the cache lifetime", async () => { - const render = vi.fn(async () => Buffer.from("png")); - - await cache.get(guildId, "fr", render); - vi.advanceTimersByTime(16_000); - await cache.get(guildId, "fr", render); - - expect(render).toHaveBeenCalledTimes(2); - }); - - it("keeps one image per locale", async () => { - const render = vi.fn(async () => Buffer.from("png")); - - await cache.get(guildId, "fr", render); - await cache.get(guildId, "en", render); - - expect(render).toHaveBeenCalledTimes(2); - }); - - it("does not keep a failed render", async () => { - const failing = vi.fn(async () => { - throw new Error("render failed"); - }); - const render = vi.fn(async () => Buffer.from("png")); - - await expect(cache.get(guildId, "fr", failing)).rejects.toThrow(); - await expect(cache.get(guildId, "fr", render)).resolves.toBeInstanceOf( - Buffer - ); - }); -}); diff --git a/src/modules/four-hour-game/services/leaderboard-cache.service.ts b/src/modules/four-hour-game/services/leaderboard-cache.service.ts deleted file mode 100644 index 15909513..00000000 --- a/src/modules/four-hour-game/services/leaderboard-cache.service.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { declareService, type Service } from "#lib/service.js"; -import fourHourGameService from "./four-hour-game.service.js"; - -const CACHE_TTL_MS = 15_000; - -interface CachedImage { - revision: number; - expiresAt: number; - image: Promise; -} - -class LeaderboardCacheService implements Service { - private readonly entries = new Map(); - - get( - guildId: string, - locale: string, - render: () => Promise - ): Promise { - const key = `${guildId}:${locale}`; - const revision = fourHourGameService.getRevision(guildId); - const cached = this.entries.get(key); - if ( - cached && - cached.revision === revision && - cached.expiresAt > Date.now() - ) { - return cached.image; - } - - const image = render(); - const entry = { revision, expiresAt: Date.now() + CACHE_TTL_MS, image }; - this.entries.set(key, entry); - image.catch(() => { - if (this.entries.get(key) === entry) { - this.entries.delete(key); - } - }); - return image; - } -} - -export default declareService(new LeaderboardCacheService()); diff --git a/src/modules/four-hour-game/services/round-sync.ts b/src/modules/four-hour-game/services/round-sync.ts index cc68fd0e..3b722f3f 100644 --- a/src/modules/four-hour-game/services/round-sync.ts +++ b/src/modules/four-hour-game/services/round-sync.ts @@ -1,3 +1,5 @@ +import configService from "#core/services/config.service.js"; +import moduleService from "#core/services/module.service.js"; import { loggerMaker } from "#lib/logger.js"; import type { Module } from "#lib/module.js"; import type { FourHourGameConfigSchema } from "#modules/four-hour-game/four-hour-game.config.js"; @@ -10,8 +12,6 @@ export async function syncRound( module: Module, guildId: string ): Promise { - const { default: configService } = - await import("#core/services/config.service.js"); const config = await configService.getConfigForModuleIn(module, guildId); const channel = config.get("channel"); if (!channel?.isTextBased()) { @@ -26,8 +26,6 @@ export async function syncRound( export async function syncAllRounds( module: Module ): Promise { - const { default: moduleService } = - await import("#core/services/module.service.js"); const guildIds = await moduleService.getActivatedGuildIds(module.id); for (const guildId of guildIds) { diff --git a/src/modules/rngdle/assets/medal_bronze.png b/src/modules/rngdle/assets/medal_bronze.png deleted file mode 100644 index ceb8a44537bb18c10c34363e255f024a2f93274b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1319 zcmeAS@N?(olHy`uVBq!ia0vp^u|OQf!3HF6l|)qoDb50q$YKTtZeb8+WSBKa0w~B> z9OUlAujXNZ5}#gS4+qApB2moO}c_FMRnYNHm&b0 zn;7R5I7R=4*)7*9PS?PF3AZ!EU$Ta{{%p!!6uU$`K=dj1w$8GbGEXby|4P)b&vy>k z(l6CEyN(YaR?>F7G zs*6y$qwuyQqN80z$8nX-dNmscwr0l#0{0Z0JW@7lx|b|vjQ{%k@0L#?4FWliPb)3^ zOO*F4OgL1(Z4H;Xf}Hk(lShxbu597II9cq5&a?%g)855cx&PQ&db3jC`z+qC+rEe> z+L zbD4Zm)%*faWQyaeiN4NtH~yDZ)S17}+@}8{?Wm1@R@y?V*ZZSmQY-c6wz9tt{xV^8 zqlNl!xn<6*#Xlt_xY6?1!_&B_VzS&q?K2YQ?%C3!w!hXqoS%DruDt7H`-j+^_pR{{kDKIowtKOVwVL%LOMsPYzEIRK6XOsrG(T=wbKglle67x-Jk|zopr0Ojpt9smFU diff --git a/src/modules/rngdle/assets/medal_gold.png b/src/modules/rngdle/assets/medal_gold.png deleted file mode 100644 index 647d394de268b4155b53c138165a0e1f2a90ad2b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1190 zcmV;X1X=ruP)Jib?h1Mpj)r+1Q z&3~y$RO^rE*&=HZ)v86$jOM@8AgXmo^w|Zfk7{-O=rW2uCnIyXh5>fY@zy^7ewVN4 z8-Ais%e+-Y64Cwz=5d)La40k5UE#Gj+QuOW_bea0CEgz4Q`kp$aexGfi{^;kOx&gx z`~fVn@B)I!x6uxdkd~0ji~kMvNx@@6Ws@R`ylCP=5GB1)^>K)o5GKvd@EzayJofoD z4_W&7!R>Q`9!Zj>gKqH!%SZNZD#?MQ{pd2X2Y7*)-MY!_a_#yd5|TV&85Q*RQJw7M zk^?F-!wO}qkQ`8xf|s%#K1RDi5_ae|)}RD6{peAqRv;E$HuIZ>u7t%$eX_0UDY+Wa z6_h#KP=ua{cF~iU42sayblgN_{Ch6LDnXBEUqPGtTTk<$M>L&L^m)>DujhuSLbTuK z_IXzSnaU?6n_PKY;NM+9s9nq^gA(STfu9_C(^98l1n;omr^gCHVf&DKD&5i3nw2T< zwunAOHf4VhyucY=cT&|YBspb@J&f>!v3JXtVW*-)bPIbpnHBB^c`R}5EM?7+@Fl*Q z_KD-Tb3H|`6kWBBIb3JKU&?y*MUM5Z>?(f0=y43gFbu;m48t%C!>Apa2GBe!i~n&e zZhL&ZHR(a;lh83FEat$Ou@i9hZ-6Dbm$tx^zD!CL58ov|_N8(lWm1xg9*yP5P(4}d z>2YZz+QlDYFcsojcS4#ohlSEU)@tTtp#+#{AxyLoCR&Jp1891^UYiIH5V5<(pJH+3 z(arO`K*K3TKk4-4YSn@Rl@m$gMZX$(Ozm|KGFTA_c}7j(Cnr|5)&TUpbLrW<>xYvBEh)GbiY9gYFXybAqNfL4ifc0wJ-Q8%+8HO`m|w2|9g(_NEy|xkv?q`n z=@Q-iA|0O%j`BY#JAI{8hhGQszE-OZ2m}PuS?m(OGgE z4A@LLxiuato%VG&sRKEVM<5`rb7XRaAF~BQVx@ButU(6h>ZL&-AXfTtI5gc+ zatQ>)O0QHZugNgCx3?Pv66N%?y+JQDT4B`(sFBJ#Uz?(B3VVI^&P>1k$=k4UE&>D+R0C+(MB<*4lF z_oMRpV{bwcMW>GEl8Mr}<#IwJy-|r?5h>mHEU8$LFY{h_D6kPJXPVgrKP`#5m&L`! zR}T*l|46=_egM8lyykLlvXpalbmTgU{!&V2BN+T;Xno?7df#fbJhIHp^V9P{gjXE; zY*h9rS`Qw5y|ur;Z*@#|UIwNEIKoeJ6D}yVRINB4$ue^zouj-##iR<{@L1NE8|jXt zji(*r;2ZoavdCOjFGZo0Z?)-KO6j(NbPGVb1t8r5)k!-!D(H>7lqWne z?vQ>5=^^^*DIocy)Eh|u50{Do)r$=v-2#wq0Z6w1q+0;eEdc2jfOHE$x&nRQRzjk#py+N42Sv)6$A_JMfYi4%(-)7tv4p!_x%r407|KDbYH90 z>XE^R`Xt~rn|pspclw$NI(OY2lfJsT+Rav4oXXogf96s)%boaKcfROd z%3D02`|j@EhZi?e7QfA6cF26xc~WlDbG(|(W`F3@Z=~pi3?j1SXDwwXts~uSwYHU` j@;P@>JG-sD;N1TOk{P=Jr;hHP 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 Yi_+;6pf5_VCmb$Dw_o(2@Xmez2QTNB>Hq)$ diff --git a/src/modules/rngdle/commands/replies.ts b/src/modules/rngdle/commands/replies.ts deleted file mode 100644 index 81155418..00000000 --- a/src/modules/rngdle/commands/replies.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { MessageFlags, type RepliableInteraction } from "discord.js"; -import { loggerMaker } from "#lib/logger.js"; - -const logger = loggerMaker("rngdle"); - -export async function replyWithError( - interaction: RepliableInteraction, - content: string -): Promise { - try { - if (interaction.deferred || interaction.replied) { - await interaction.editReply({ - content, - files: [], - attachments: [], - components: [], - embeds: [], - }); - } else { - await interaction.reply({ content, flags: MessageFlags.Ephemeral }); - } - } catch (err) { - logger.warn({ err }, "Could not report an error to the user"); - } -} diff --git a/src/modules/rngdle/commands/rngdle-admin.command.ts b/src/modules/rngdle/commands/rngdle-admin.command.ts index e1d05219..5bdb3c51 100644 --- a/src/modules/rngdle/commands/rngdle-admin.command.ts +++ b/src/modules/rngdle/commands/rngdle-admin.command.ts @@ -13,64 +13,53 @@ import type { RngdleConfigSchema } from "#modules/rngdle/rngdle.config.js"; import { fetchUserRolls, RngdleUserNotFoundError, -} from "#modules/rngdle/services/rngdle-api.js"; -import rngdleService from "#modules/rngdle/services/rngdle.service.js"; -import syncService, { - FullSyncCooldownError, -} from "#modules/rngdle/services/sync.service.js"; +} from "#modules/rngdle/services/api.js"; +import store from "#modules/rngdle/services/store.js"; +import sync, { FullSyncCooldownError } from "#modules/rngdle/services/sync.js"; import { Colors } from "#utils/colors.js"; -import { replyWithError } from "./replies.js"; const PERMISSION_ADMINISTRATOR = 0x8; const USERNAME_MAX_LENGTH = 64; const logger = loggerMaker("rngdle"); +type Interaction = ChatInputCommandInteraction<"cached" | "raw">; type Config = ConfigProvider; -type GuildCommand = ChatInputCommandInteraction<"cached" | "raw">; -async function register(interaction: GuildCommand, config: Config) { +const ephemeral = { flags: MessageFlags.Ephemeral } as const; +const noPings = { allowedMentions: { parse: [] } } as const; + +async function register(interaction: Interaction, config: Config) { + const { guildId } = interaction; const member = interaction.options.getUser("member", true); const username = interaction.options.getString("username", true).trim(); - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + await interaction.deferReply(ephemeral); - let rolls; - try { - rolls = await fetchUserRolls(username, null); - } catch (err) { - if (err instanceof RngdleUserNotFoundError) { - await interaction.editReply( - config.t("admin.register.notFound", { username }) - ); - return; + const rolls = await fetchUserRolls(username, null).catch((err: unknown) => { + if (!(err instanceof RngdleUserNotFoundError)) { + logger.warn({ err }, `rngdle.com unreachable | username = ${username}`); } - logger.warn({ err }, `Could not reach rngdle.com | username = ${username}`); - await interaction.editReply(config.t("admin.register.unreachable")); + return err instanceof RngdleUserNotFoundError ? "notFound" : "unreachable"; + }); + if (typeof rolls === "string") { + await interaction.editReply( + config.t(`admin.register.${rolls}`, { username }) + ); return; } - const outcome = await syncService.runExclusive( - interaction.guildId, - async () => { - const holder = await rngdleService.findAccountByUsername( - interaction.guildId, - username - ); - if (holder && holder.userId !== member.id) { - return { conflict: holder.userId } as const; - } - const result = await rngdleService.register( - interaction.guildId, - member.id, - username - ); - const saved = await rngdleService.saveRolls( - { guildId: interaction.guildId, userId: member.id, username }, - rolls - ); - return { result, saved } as const; + const outcome = await sync.exclusive(guildId, async () => { + const holder = await store.accountByUsername(guildId, username); + if (holder && holder.userId !== member.id) { + return { conflict: holder.userId }; } - ); + const result = await store.register(guildId, member.id, username); + const { inserted } = await store.saveRolls( + { guildId, userId: member.id, username }, + rolls + ); + return { result, inserted }; + }); if ("conflict" in outcome) { await interaction.editReply({ @@ -78,98 +67,89 @@ async function register(interaction: GuildCommand, config: Config) { username, user: `<@${outcome.conflict}>`, }), - allowedMentions: { parse: [] }, + ...noPings, }); return; } - const { result, saved } = outcome; logger.info( - `Account ${result} | guildId = ${interaction.guildId} | userId = ${member.id} | username = ${username} | rolls = ${saved.inserted} | by = ${interaction.user.id}` + `Account ${outcome.result} | guildId = ${guildId} | userId = ${member.id} | username = ${username} | by = ${interaction.user.id}` ); await interaction.editReply({ - content: config.t(`admin.register.${result}`, { + content: config.t(`admin.register.${outcome.result}`, { user: `<@${member.id}>`, username, - count: saved.inserted, + count: outcome.inserted, }), - allowedMentions: { parse: [] }, + ...noPings, }); } -async function unregister(interaction: GuildCommand, config: Config) { +async function unregister(interaction: Interaction, config: Config) { const member = interaction.options.getUser("member", true); - const deleted = await syncService.runExclusive(interaction.guildId, () => - rngdleService.unregister(interaction.guildId, member.id) + const deleted = await sync.exclusive(interaction.guildId, () => + store.unregister(interaction.guildId, member.id) ); - if (deleted) { - logger.info( - `Account deleted | guildId = ${interaction.guildId} | userId = ${member.id} | by = ${interaction.user.id}` - ); - } await interaction.reply({ content: config.t(deleted ? "admin.delete.done" : "admin.delete.none", { user: `<@${member.id}>`, }), - flags: MessageFlags.Ephemeral, - allowedMentions: { parse: [] }, + ...ephemeral, + ...noPings, }); } -async function show(interaction: GuildCommand, config: Config) { - const accounts = await rngdleService.listAccounts(interaction.guildId); - if (accounts.length === 0) { - await interaction.reply({ - content: config.t("admin.show.empty"), - flags: MessageFlags.Ephemeral, - }); - return; - } - await interaction.reply({ - embeds: [ - new EmbedBuilder() - .setTitle(config.t("admin.show.title", { count: accounts.length })) - .setDescription( - accounts - .map((account) => `<@${account.userId}> → \`${account.username}\``) - .join("\n") - .slice(0, 4096) - ) - .setColor(Colors.SkyBlue), - ], - flags: MessageFlags.Ephemeral, - allowedMentions: { parse: [] }, - }); +async function show(interaction: Interaction, config: Config) { + const accounts = await store.accounts(interaction.guildId); + await interaction.reply( + accounts.length === 0 + ? { content: config.t("admin.show.empty"), ...ephemeral } + : { + embeds: [ + new EmbedBuilder() + .setTitle( + config.t("admin.show.title", { count: accounts.length }) + ) + .setDescription( + accounts + .map((a) => `<@${a.userId}> → \`${a.username}\``) + .join("\n") + .slice(0, 4096) + ) + .setColor(Colors.SkyBlue), + ], + ...ephemeral, + ...noPings, + } + ); } -async function refresh(interaction: GuildCommand, config: Config) { +async function refresh(interaction: Interaction, config: Config) { + await interaction.deferReply(ephemeral); const full = interaction.options.getBoolean("full") ?? false; - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - let report; try { - report = await syncService.syncGuild(interaction.guildId, { full }); + const report = await sync.syncGuild(interaction.guildId, full); + await interaction.editReply( + report.accounts === 0 + ? config.t("admin.show.empty") + : config.t("admin.refresh.done", { ...report }) + ); } catch (err) { - if (err instanceof FullSyncCooldownError) { - await interaction.editReply( - config.t("admin.refresh.cooldown", { - time: ``, - }) - ); - return; + if (!(err instanceof FullSyncCooldownError)) { + throw err; } - throw err; + await interaction.editReply( + config.t("admin.refresh.cooldown", { + time: ``, + }) + ); } - await interaction.editReply( - report.accounts === 0 - ? config.t("admin.show.empty") - : config.t("admin.refresh.done", { ...report }) - ); } -async function clear(interaction: GuildCommand, config: Config) { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - const count = await syncService.runExclusive(interaction.guildId, () => - rngdleService.clearRolls(interaction.guildId) +async function clear(interaction: Interaction, config: Config) { + await interaction.deferReply(ephemeral); + const count = await sync.exclusive(interaction.guildId, () => + store.clearRolls(interaction.guildId) ); logger.info( `Rolls cleared | guildId = ${interaction.guildId} | count = ${count} | by = ${interaction.user.id}` @@ -177,6 +157,11 @@ async function clear(interaction: GuildCommand, config: Config) { await interaction.editReply(config.t("admin.clear.done", { count })); } +const subcommands: Record< + string, + (interaction: Interaction, config: Config) => Promise +> = { register, delete: unregister, show, refresh, clear }; + export default declareCommand({ data: new SlashCommandBuilder() .setName("rngdle-admin") @@ -254,37 +239,11 @@ export default declareCommand({ ), async execute(interaction, config) { - if (!interaction.inGuild()) { - return; - } - if (!(await requireAdmin(interaction, config.t))) { - return; - } - - try { - switch (interaction.options.getSubcommand()) { - case "register": - await register(interaction, config); - break; - case "delete": - await unregister(interaction, config); - break; - case "show": - await show(interaction, config); - break; - case "refresh": - await refresh(interaction, config); - break; - case "clear": - await clear(interaction, config); - break; - } - } catch (err) { - logger.error( - { err }, - `RNGdle admin command failed | guildId = ${interaction.guildId}` + if (interaction.inGuild() && (await requireAdmin(interaction, config.t))) { + await subcommands[interaction.options.getSubcommand()]?.( + interaction, + config ); - await replyWithError(interaction, config.t("error.generic")); } }, }); diff --git a/src/modules/rngdle/commands/rngdle.command.ts b/src/modules/rngdle/commands/rngdle.command.ts index 7aafaadb..52376c49 100644 --- a/src/modules/rngdle/commands/rngdle.command.ts +++ b/src/modules/rngdle/commands/rngdle.command.ts @@ -7,155 +7,109 @@ import { } from "discord.js"; import { declareCommand } from "#lib/command.js"; import type { ConfigProvider } from "#lib/config.js"; -import { loggerMaker } from "#lib/logger.js"; -import { overallPageComponents } from "#modules/rngdle/interactions/overall-page.button.js"; +import { overallPageMessage } from "#modules/rngdle/interactions/overall-page.button.js"; import type { RngdleConfigSchema } from "#modules/rngdle/rngdle.config.js"; +import store from "#modules/rngdle/services/store.js"; +import sync from "#modules/rngdle/services/sync.js"; import { - buildDailyLeaderboard, - buildProfile, - buildServerStats, - cachedOverallPage, - utcDayRange, -} from "#modules/rngdle/services/boards.js"; -import imageCache from "#modules/rngdle/services/image-cache.service.js"; -import rngdleService from "#modules/rngdle/services/rngdle.service.js"; -import syncService from "#modules/rngdle/services/sync.service.js"; -import { replyWithError } from "./replies.js"; + dailyLeaderboard, + profileCard, + serverCard, + type ViewContext, +} from "#modules/rngdle/services/views.js"; -const logger = loggerMaker("rngdle"); +const AUTOCOMPLETE_LIMIT = 25; +type Interaction = ChatInputCommandInteraction<"cached" | "raw">; type Config = ConfigProvider; -type GuildCommand = ChatInputCommandInteraction<"cached" | "raw">; - -const AUTOCOMPLETE_LIMIT = 25; -function attachment(image: Buffer, name: string) { - return [new AttachmentBuilder(image, { name })]; +function viewContext(interaction: Interaction, config: Config): ViewContext { + return { + client: interaction.client, + guildId: interaction.guildId, + t: config.t, + locale: config.locale, + }; } -async function showToday(interaction: GuildCommand, config: Config) { - await interaction.deferReply(); - await syncService.syncGuildIfStale(interaction.guildId); - - const range = utcDayRange(0); - const board = await imageCache.get( - interaction.guildId, - `daily:${config.locale}:${range[0].toISOString()}`, - () => - buildDailyLeaderboard( - interaction.client, - interaction.guildId, - range, - config.t - ) +async function replyWithImage( + interaction: Interaction, + image: Buffer | null, + name: string, + emptyMessage: string +) { + await interaction.editReply( + image + ? { files: [new AttachmentBuilder(image, { name })] } + : { content: emptyMessage } ); - if (!board) { - await interaction.editReply(config.t("leaderboard.empty")); - return; - } - await interaction.editReply({ - files: attachment(board.image, "leaderboard.png"), - }); } -async function showProfile(interaction: GuildCommand, config: Config) { - const member = interaction.options.getUser("member"); - const username = interaction.options.getString("username"); - if (member && username) { - await interaction.reply({ - content: config.t("profile.bothOptions"), - flags: MessageFlags.Ephemeral, - }); - return; - } +async function leaderboard(interaction: Interaction, config: Config) { + const board = await dailyLeaderboard(viewContext(interaction, config), 0); + await replyWithImage( + interaction, + board?.image ?? null, + "leaderboard.png", + config.t("leaderboard.empty") + ); +} - const account = username - ? await rngdleService.findAccountByUsername(interaction.guildId, username) - : await rngdleService.getAccount( +async function profile(interaction: Interaction, config: Config) { + const account = interaction.options.getString("username") + ? await store.accountByUsername( interaction.guildId, - (member ?? interaction.user).id + interaction.options.getString("username", true) + ) + : await store.account( + interaction.guildId, + (interaction.options.getUser("member") ?? interaction.user).id ); if (!account) { - await interaction.reply({ - content: config.t("profile.notRegistered"), - flags: MessageFlags.Ephemeral, - }); - return; - } - - await interaction.deferReply(); - await syncService.syncGuildIfStale(interaction.guildId); - - const user = await interaction.client.users - .fetch(account.userId) - .catch(() => null); - if (!user) { await interaction.editReply(config.t("profile.notRegistered")); return; } - - const image = await imageCache.get( - interaction.guildId, - `profile:${config.locale}:${account.userId}`, - () => buildProfile(account, user, config.t, config.locale) + await replyWithImage( + interaction, + await profileCard(viewContext(interaction, config), account), + `profile-${account.username}.png`, + config.t("profile.noRolls", { username: account.username }) ); - if (!image) { - await interaction.editReply( - config.t("profile.noRolls", { username: account.username }) - ); - return; - } - await interaction.editReply({ - files: attachment(image, `profile-${account.username}.png`), - }); } -async function showServerStats(interaction: GuildCommand, config: Config) { - await interaction.deferReply(); - await syncService.syncGuildIfStale(interaction.guildId); - +async function serverStats(interaction: Interaction, config: Config) { const guild = interaction.guild ?? (await interaction.client.guilds.fetch(interaction.guildId)); - const image = await imageCache.get( - interaction.guildId, - `server:${config.locale}`, - () => buildServerStats(interaction.client, guild, config.t) + await replyWithImage( + interaction, + await serverCard(viewContext(interaction, config), guild), + "server-stats.png", + config.t("server.empty") ); - if (!image) { - await interaction.editReply(config.t("server.empty")); - return; - } - await interaction.editReply({ files: attachment(image, "server-stats.png") }); } -async function showOverall(interaction: GuildCommand, config: Config) { - await interaction.deferReply(); - await syncService.syncGuildIfStale(interaction.guildId); - - const result = await cachedOverallPage( - interaction.client, - interaction.guildId, - interaction.options.getInteger("page") ?? 1, - interaction.user.id, - config.t, - config.locale +async function leaderboardAll(interaction: Interaction, config: Config) { + await interaction.editReply( + await overallPageMessage( + viewContext(interaction, config), + interaction.options.getInteger("page") ?? 1, + interaction.user.id, + "public" + ) ); - if (!result) { - await interaction.editReply(config.t("overall.empty")); - return; - } - await interaction.editReply({ - files: attachment(result.image, "leaderboard.png"), - components: overallPageComponents( - result.page, - result.pageCount, - "public", - config.t - ), - }); } +const subcommands: Record< + string, + (interaction: Interaction, config: Config) => Promise +> = { + leaderboard, + profile, + "server-stats": serverStats, + "leaderboard-all": leaderboardAll, +}; + export default declareCommand({ data: new SlashCommandBuilder() .setName("rngdle") @@ -215,38 +169,30 @@ export default declareCommand({ if (!interaction.inGuild()) { return; } - - try { - switch (interaction.options.getSubcommand()) { - case "leaderboard": - await showToday(interaction, config); - break; - case "profile": - await showProfile(interaction, config); - break; - case "server-stats": - await showServerStats(interaction, config); - break; - case "leaderboard-all": - await showOverall(interaction, config); - break; - } - } catch (err) { - logger.error( - { err }, - `RNGdle command failed | guildId = ${interaction.guildId}` - ); - await replyWithError(interaction, config.t("error.generic")); + if ( + interaction.options.getUser("member") && + interaction.options.getString("username") + ) { + await interaction.reply({ + content: config.t("profile.bothOptions"), + flags: MessageFlags.Ephemeral, + }); + return; } + + await interaction.deferReply(); + await sync.refreshIfStale(interaction.guildId); + await subcommands[interaction.options.getSubcommand()]?.( + interaction, + config + ); }, async complete(interaction) { - if (!interaction.inGuild()) { - await interaction.respond([]); - return; - } const typed = interaction.options.getFocused().toLowerCase(); - const accounts = await rngdleService.listAccounts(interaction.guildId); + const accounts = interaction.guildId + ? await store.accounts(interaction.guildId) + : []; await interaction.respond( accounts .filter((account) => account.username.toLowerCase().includes(typed)) diff --git a/src/modules/rngdle/interactions/overall-page.button.ts b/src/modules/rngdle/interactions/overall-page.button.ts index 6a2f6c20..db45f3e9 100644 --- a/src/modules/rngdle/interactions/overall-page.button.ts +++ b/src/modules/rngdle/interactions/overall-page.button.ts @@ -6,43 +6,69 @@ import { MessageFlags, type ButtonInteraction, } from "discord.js"; -import type { TFunction } from "#lib/i18n.js"; import { declareInteractionHandler } from "#lib/interaction.js"; -import { loggerMaker } from "#lib/logger.js"; -import { replyWithError } from "#modules/rngdle/commands/replies.js"; -import { cachedOverallPage } from "#modules/rngdle/services/boards.js"; - -const logger = loggerMaker("rngdle"); +import { + overallPage, + type ViewContext, +} from "#modules/rngdle/services/views.js"; const CUSTOM_ID = "rngdle-overall"; type Visibility = "public" | "private"; -export function overallPageComponents( +function pageButton(customId: string, label: string, disabled: boolean) { + return new ButtonBuilder() + .setCustomId(customId) + .setLabel(label) + .setStyle(ButtonStyle.Primary) + .setDisabled(disabled); +} + +export async function overallPageMessage( + context: ViewContext, page: number, - pageCount: number, - visibility: Visibility, - t: TFunction -): ActionRowBuilder[] { - return [ - new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId(`${CUSTOM_ID}:${page - 1}:${visibility}`) - .setLabel(t("config.previous")) - .setStyle(ButtonStyle.Primary) - .setDisabled(page <= 1), - new ButtonBuilder() - .setCustomId(`${CUSTOM_ID}:indicator:${visibility}`) - .setLabel(t("overall.page", { current: page, total: pageCount })) - .setStyle(ButtonStyle.Success) - .setDisabled(true), - new ButtonBuilder() - .setCustomId(`${CUSTOM_ID}:${page + 1}:${visibility}`) - .setLabel(t("config.next")) - .setStyle(ButtonStyle.Primary) - .setDisabled(page >= pageCount) - ), - ]; + callerId: string, + visibility: Visibility +) { + const result = await overallPage(context, page, callerId); + if (!result) { + return { + content: context.t("overall.empty"), + files: [], + attachments: [], + components: [], + }; + } + + const id = (target: number | string) => + `${CUSTOM_ID}:${target}:${visibility}`; + return { + content: "", + files: [new AttachmentBuilder(result.image, { name: "leaderboard.png" })], + attachments: [], + components: [ + new ActionRowBuilder().addComponents( + pageButton( + id(result.page - 1), + context.t("config.previous"), + result.page <= 1 + ), + pageButton( + id("indicator"), + context.t("overall.page", { + current: result.page, + total: result.pageCount, + }), + true + ).setStyle(ButtonStyle.Success), + pageButton( + id(result.page + 1), + context.t("config.next"), + result.page >= result.pageCount + ) + ), + ], + }; } export default declareInteractionHandler({ @@ -56,50 +82,23 @@ export default declareInteractionHandler({ return; } - try { - if (visibility === "private") { - await interaction.deferUpdate(); - } else { - await interaction.deferReply({ flags: MessageFlags.Ephemeral }); - } - - const result = await cachedOverallPage( - interaction.client, - interaction.guildId, + if (visibility === "private") { + await interaction.deferUpdate(); + } else { + await interaction.deferReply({ flags: MessageFlags.Ephemeral }); + } + await interaction.editReply( + await overallPageMessage( + { + client: interaction.client, + guildId: interaction.guildId, + t: config.t, + locale: config.locale, + }, page, interaction.user.id, - config.t, - config.locale - ); - if (!result) { - await interaction.editReply({ - content: config.t("overall.empty"), - files: [], - attachments: [], - components: [], - }); - return; - } - - await interaction.editReply({ - content: "", - files: [ - new AttachmentBuilder(result.image, { name: "leaderboard.png" }), - ], - attachments: [], - components: overallPageComponents( - result.page, - result.pageCount, - "private", - config.t - ), - }); - } catch (err) { - logger.error( - { err }, - `RNGdle page button failed | guildId = ${interaction.guildId}` - ); - await replyWithError(interaction, config.t("error.generic")); - } + "private" + ) + ); }, }); diff --git a/src/modules/rngdle/rendering/cards.test.ts b/src/modules/rngdle/rendering/cards.test.ts new file mode 100644 index 00000000..0dbb78fd --- /dev/null +++ b/src/modules/rngdle/rendering/cards.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { + renderProfile, + renderServerStats, + type ProfileImageData, + type ServerStatsImageData, +} from "./cards.js"; + +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +function pngSize(png: Buffer) { + expect(png.subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true); + return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; +} + +const tierCounts = { + MYTHIC: 1, + ANOMALY: 20, + EPIC: 300, + RARE: 4000, + UNCOMMON: 50000, + COMMON: 600000, + TRASH: 7000000, +}; + +function profile(serverRank: number): ProfileImageData { + return { + username: "virilegamer", + avatar: null, + serverRank, + totalPlayers: 12, + best: { number: 1234567, score: 98765, tier: "MYTHIC", dateText: "01/02" }, + worst: { number: 42, score: 3, tier: "TRASH", dateText: "02/03" }, + totalRolls: 321, + averageScore: 4567, + averageTier: "RARE", + maxBadges: 4, + totalScore: 1466007, + tierCounts, + }; +} + +function serverStats(leaders: number): ServerStatsImageData { + return { + icon: null, + best: { + number: 131081274, + score: 4200000, + tier: "MYTHIC", + playerName: "Alice", + avatar: null, + }, + worst: { + number: 12, + score: 3, + tier: "ERROR", + playerName: "Bob", + avatar: null, + }, + totalRolls: 123456, + averageScore: 98765, + averageTier: "UNCOMMON", + overallScore: 123456789, + tierCounts, + tierLeaders: Object.fromEntries( + Object.keys(tierCounts).map((tier) => [tier, Array(leaders).fill(null)]) + ) as ServerStatsImageData["tierLeaders"], + }; +} + +describe("renderProfile", () => { + const labels = { + bestRoll: "Best Roll", + worstRoll: "Worst Roll", + date: (date: string) => `Date : ${date}`, + totalRolls: "Total Rolls", + averageScore: "Average Score", + maxBadges: "Max Badges", + maxBadgesValue: (count: number) => `${count} badges at once`, + overallScore: "Overall Score", + tierBreakdown: "Tier Breakdown", + }; + + it("renders an 800x840 PNG for every kind of rank", async () => { + const pngs = await Promise.all( + [0, 1, 2, 3, 7].map((rank) => renderProfile(profile(rank), labels)) + ); + for (const png of pngs) { + expect(pngSize(png)).toEqual({ width: 800, height: 840 }); + } + expect(new Set(pngs.map((png) => png.toString("base64"))).size).toBe(5); + }); +}); + +describe("renderServerStats", () => { + const labels = { + title: "RNGdle - Server Stats", + bestRoll: "Best Roll OAT", + worstRoll: "Worst Roll OAT", + by: (name: string) => `by ${name}`, + totalRolls: "Total Rolls", + averageScore: "Average Score", + overallScore: "Overall Score", + tierBreakdown: "Tier Breakdown", + }; + + it.each([0, 3, 5])("renders an 800x710 PNG with %i leaders", async (n) => { + const png = await renderServerStats(serverStats(n), labels); + expect(pngSize(png)).toEqual({ width: 800, height: 710 }); + }); +}); diff --git a/src/modules/rngdle/rendering/cards.ts b/src/modules/rngdle/rendering/cards.ts new file mode 100644 index 00000000..c2bc1741 --- /dev/null +++ b/src/modules/rngdle/rendering/cards.ts @@ -0,0 +1,224 @@ +import type { Image, SKRSContext2D } from "@napi-rs/canvas"; +import { drawCircularImage, PALETTE, rgb } from "#lib/imaging.js"; +import { + CARD_CONTENT_WIDTH, + CARD_MARGIN, + drawPanel, + drawPanelTitle, + drawText, + LABEL_SIZE, + measureText, + renderStatCard, + type CardSection, + type StatBox, +} from "#lib/stat-card.js"; +import { + TIER_COLORS, + TIERS, + type Tier, + type TierOrError, +} from "#modules/rngdle/services/scoring.js"; +import { formatSpaced } from "./format.js"; + +const BREAKDOWN_HEIGHT = 240; +const BREAKDOWN_ROWS_Y = 60; +const BREAKDOWN_COLUMNS = [80, 440]; +const BREAKDOWN_PER_COLUMN = 4; +const BREAKDOWN_TIERS = [...TIERS].reverse(); +const COUNT_RIGHT = 220; +const LEADERS_X = 235; +const LEADER_SIZE = 24; +const LEADER_STEP = 14; +const LEADER_BORDER = 2; +const MAX_LEADERS = 3; + +interface Roll { + number: number; + score: number; + tier: TierOrError; +} + +export interface ProfileRoll extends Roll { + dateText: string; +} + +export interface ProfileImageData { + username: string; + avatar: Image | null; + serverRank: number; + totalPlayers: number; + best: ProfileRoll; + worst: ProfileRoll; + totalRolls: number; + averageScore: number; + averageTier: TierOrError; + maxBadges: number; + totalScore: number; + tierCounts: Record; +} + +export interface ProfileLabels { + bestRoll: string; + worstRoll: string; + date: (date: string) => string; + totalRolls: string; + averageScore: string; + maxBadges: string; + maxBadgesValue: (count: number) => string; + overallScore: string; + tierBreakdown: string; +} + +export interface ServerStatsRoll extends Roll { + playerName: string; + avatar: Image | null; +} + +export interface ServerStatsImageData { + icon: Image | null; + best: ServerStatsRoll; + worst: ServerStatsRoll; + totalRolls: number; + averageScore: number; + averageTier: TierOrError; + overallScore: number; + tierCounts: Record; + tierLeaders: Record; +} + +export interface ServerStatsLabels { + title: string; + bestRoll: string; + worstRoll: string; + by: (name: string) => string; + totalRolls: string; + averageScore: string; + overallScore: string; + tierBreakdown: string; +} + +function drawLeaders( + ctx: SKRSContext2D, + leaders: (Image | null)[], + x: number, + y: number +): void { + const radius = LEADER_SIZE / 2; + for (let index = leaders.length - 1; index >= 0; index--) { + const left = x + index * LEADER_STEP; + ctx.beginPath(); + ctx.arc(left + radius, y + radius, radius + LEADER_BORDER, 0, Math.PI * 2); + ctx.fillStyle = rgb(PALETTE.box); + ctx.fill(); + drawCircularImage(ctx, leaders[index] ?? null, left, y, LEADER_SIZE); + } +} + +function tierBreakdown( + title: string, + counts: Record, + rowStep: number, + leaders?: Record +): CardSection { + return { + height: BREAKDOWN_HEIGHT, + draw(ctx, top) { + drawPanel(ctx, CARD_MARGIN, top, CARD_CONTENT_WIDTH, BREAKDOWN_HEIGHT); + drawPanelTitle(ctx, title, CARD_MARGIN, top); + BREAKDOWN_TIERS.forEach((tier, index) => { + const x = BREAKDOWN_COLUMNS[Math.floor(index / BREAKDOWN_PER_COLUMN)]!; + const y = + top + BREAKDOWN_ROWS_Y + rowStep * (index % BREAKDOWN_PER_COLUMN); + drawText(ctx, `${tier}:`, x, y, LABEL_SIZE, TIER_COLORS[tier]); + const count = formatSpaced(counts[tier] ?? 0); + const countX = x + COUNT_RIGHT - measureText(ctx, count, LABEL_SIZE); + drawText(ctx, count, countX, y, LABEL_SIZE, PALETTE.text); + if (leaders) { + const tierLeaders = (leaders[tier] ?? []).slice(0, MAX_LEADERS); + drawLeaders(ctx, tierLeaders, x + LEADERS_X, y - 1); + } + }); + }, + }; +} + +function ep(value: number): string { + return `${formatSpaced(value)} EP`; +} + +function rollBox(title: string, roll: Roll, subtext: string): StatBox { + return { + title, + value: formatSpaced(roll.number), + color: TIER_COLORS[roll.tier], + suffix: `(${ep(roll.score)})`, + subtext, + }; +} + +export function renderProfile( + data: ProfileImageData, + labels: ProfileLabels +): Promise { + return renderStatCard({ + image: data.avatar, + title: { text: data.username, y: 35, size: 50 }, + rank: { position: data.serverRank, total: data.totalPlayers }, + valueSize: 30, + rows: [ + [ + rollBox(labels.bestRoll, data.best, labels.date(data.best.dateText)), + rollBox(labels.worstRoll, data.worst, labels.date(data.worst.dateText)), + ], + [ + { title: labels.totalRolls, value: formatSpaced(data.totalRolls) }, + { + title: labels.averageScore, + value: ep(data.averageScore), + color: TIER_COLORS[data.averageTier], + }, + ], + [ + { + title: labels.maxBadges, + value: labels.maxBadgesValue(data.maxBadges), + }, + { title: labels.overallScore, value: ep(data.totalScore) }, + ], + ], + footer: tierBreakdown(labels.tierBreakdown, data.tierCounts, 40), + }); +} + +export function renderServerStats( + data: ServerStatsImageData, + labels: ServerStatsLabels +): Promise { + const roll = (title: string, value: ServerStatsRoll): StatBox => ({ + ...rollBox(title, value, labels.by(value.playerName)), + avatar: value.avatar, + }); + return renderStatCard({ + image: data.icon, + title: { text: labels.title, y: 45, size: 45 }, + valueSize: 28, + rows: [ + [roll(labels.bestRoll, data.best), roll(labels.worstRoll, data.worst)], + [ + { title: labels.totalRolls, value: formatSpaced(data.totalRolls) }, + { + title: labels.averageScore, + value: ep(data.averageScore), + color: TIER_COLORS[data.averageTier], + }, + { title: labels.overallScore, value: ep(data.overallScore) }, + ], + ], + footer: tierBreakdown( + labels.tierBreakdown, + data.tierCounts, + 45, + data.tierLeaders + ), + }); +} diff --git a/src/modules/rngdle/rendering/common.ts b/src/modules/rngdle/rendering/common.ts deleted file mode 100644 index 66e445ae..00000000 --- a/src/modules/rngdle/rendering/common.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { Image, SKRSContext2D } from "@napi-rs/canvas"; -import { loadAsset, registerFont, rgb, type Rgb } from "#lib/imaging.js"; - -const ASSETS = new URL("../assets/", import.meta.url); - -export const FONT = "RngdleOutfit"; -export const MONO_FONT = "RngdleSpaceMonoBold"; - -export const PALETTE = { - background: [25, 25, 25], - header: [50, 50, 50], - rowEven: [35, 35, 35], - rowOdd: [45, 45, 45], - box: [35, 35, 35], - text: [255, 255, 255], - title: [200, 200, 200], - subtext: [170, 170, 170], -} as const satisfies Record; - -export function registerFonts(): void { - registerFont(new URL("outfit.ttf", ASSETS), FONT); - registerFont(new URL("spacemono_bold.ttf", ASSETS), MONO_FONT); -} - -export function font(size: number, family: string = FONT): string { - return `${size}px ${family}`; -} - -export function loadMedals(): Promise<[Image, Image, Image]> { - return Promise.all([ - loadAsset(new URL("medal_gold.png", ASSETS)), - loadAsset(new URL("medal_silver.png", ASSETS)), - loadAsset(new URL("medal_bronze.png", ASSETS)), - ]); -} - -export function loadIcon(name: "arrow_up" | "trash"): Promise { - return loadAsset(new URL(`${name}.png`, ASSETS)); -} - -export function fillRoundedBox( - ctx: SKRSContext2D, - x: number, - y: number, - width: number, - height: number, - outline?: Rgb -): void { - ctx.beginPath(); - ctx.roundRect(x, y, width, height, 12); - ctx.fillStyle = rgb(PALETTE.box); - ctx.fill(); - if (outline) { - ctx.beginPath(); - ctx.roundRect(x + 1, y + 1, width - 2, height - 2, 11); - ctx.lineWidth = 2; - ctx.strokeStyle = rgb(outline); - ctx.stroke(); - } -} diff --git a/src/modules/rngdle/rendering/daily-leaderboard-image.test.ts b/src/modules/rngdle/rendering/daily-leaderboard-image.test.ts deleted file mode 100644 index 4281bc52..00000000 --- a/src/modules/rngdle/rendering/daily-leaderboard-image.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - renderDailyLeaderboard, - type DailyLeaderboardLabels, - type DailyLeaderboardRow, -} from "./daily-leaderboard-image.js"; - -const labels: DailyLeaderboardLabels = { - rank: "Rang", - player: "Pseudo", - number: "Tirage", - score: "Score", - placement: "Placement", -}; - -const rows: DailyLeaderboardRow[] = [ - { - rank: 1, - name: "Alice", - avatar: null, - number: 131081274, - score: 131081274, - percent: 99.9, - tier: "MYTHIC", - }, - { - rank: 2, - name: "A very very long player name that must be shrunk", - avatar: null, - number: 1234567, - score: 2000000, - percent: 80, - tier: "EPIC", - }, - { - rank: 3, - name: "Carol", - avatar: null, - number: 42, - score: 9000, - percent: 40, - tier: "COMMON", - }, - { - rank: 4, - name: "Dave", - avatar: null, - number: 7, - score: 50, - percent: 2, - tier: "ERROR", - }, -]; - -describe("renderDailyLeaderboard", () => { - it("renders a PNG with the expected dimensions", async () => { - const png = await renderDailyLeaderboard(rows, labels); - expect(png.subarray(0, 8)).toEqual( - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - ); - expect(png.readUInt32BE(16)).toBe(1010); - expect(png.readUInt32BE(20)).toBe(100 + rows.length * 90); - }); -}); diff --git a/src/modules/rngdle/rendering/daily-leaderboard-image.ts b/src/modules/rngdle/rendering/daily-leaderboard-image.ts deleted file mode 100644 index 671adaad..00000000 --- a/src/modules/rngdle/rendering/daily-leaderboard-image.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { createCanvas, type Image, type SKRSContext2D } from "@napi-rs/canvas"; -import { - drawCircularImage, - drawFittedText, - fillTextAnchored, - rgb, -} from "#lib/imaging.js"; -import { formatCompact, formatSpaced } from "../services/format.js"; -import { - formatPercent, - TIER_COLORS, - type TierOrError, -} from "../services/score-table.js"; -import { - FONT, - font, - loadIcon, - loadMedals, - MONO_FONT, - PALETTE, - registerFonts, -} from "./common.js"; - -const MIN_WIDTH = 800; -const HEADER_HEIGHT = 100; -const ROW_HEIGHT = 90; -const FONT_SIZE = 30; -const AVATAR_SIZE = 60; -const MEDAL_SIZE = 50; -const ICON_SIZE = 40; - -const RANK_X = 15; -const RANK_TEXT_X = 30; -const AVATAR_X = 100; -const PLAYER_X = 190; -const PLAYER_MAX_WIDTH = 320; -const COLUMN_X = [500, 650, 810] as const; -const COLUMN_WIDTHS = [140, 130, 180] as const; -const RIGHT_MARGIN = 20; -const WIDTH = Math.max( - MIN_WIDTH, - COLUMN_X[2] + COLUMN_WIDTHS[2] + RIGHT_MARGIN -); - -export interface DailyLeaderboardRow { - rank: number; - name: string; - avatar: Image | null; - number: number; - score: number; - percent: number; - tier: TierOrError; -} - -export interface DailyLeaderboardLabels { - rank: string; - player: string; - number: string; - score: string; - placement: string; -} - -function fittedWidth( - ctx: SKRSContext2D, - text: string, - family: string, - maxWidth: number -): number { - let size = FONT_SIZE; - ctx.font = font(size, family); - while (size > 1 && ctx.measureText(text).width > maxWidth) { - size -= 1; - ctx.font = font(size, family); - } - return ctx.measureText(text).width; -} - -export async function renderDailyLeaderboard( - rows: DailyLeaderboardRow[], - labels: DailyLeaderboardLabels -): Promise { - registerFonts(); - const [medals, arrowUp, trash] = await Promise.all([ - loadMedals(), - loadIcon("arrow_up"), - loadIcon("trash"), - ]); - - const canvas = createCanvas(WIDTH, HEADER_HEIGHT + rows.length * ROW_HEIGHT); - const ctx = canvas.getContext("2d"); - - ctx.fillStyle = rgb(PALETTE.background); - ctx.fillRect(0, 0, canvas.width, canvas.height); - ctx.fillStyle = rgb(PALETTE.header); - ctx.fillRect(0, 0, canvas.width, HEADER_HEIGHT); - - const headerY = HEADER_HEIGHT / 2 - 15; - ctx.font = font(FONT_SIZE); - ctx.fillStyle = rgb(PALETTE.text); - ctx.textAlign = "left"; - fillTextAnchored(ctx, labels.rank, RANK_X, headerY); - fillTextAnchored(ctx, labels.player, PLAYER_X, headerY); - ctx.textAlign = "right"; - [labels.number, labels.score, labels.placement].forEach((label, i) => { - fillTextAnchored(ctx, label, COLUMN_X[i]! + COLUMN_WIDTHS[i]!, headerY); - }); - - rows.forEach((row, index) => { - const top = HEADER_HEIGHT + index * ROW_HEIGHT; - const textY = top + ROW_HEIGHT / 2 - 15; - const tierColor = rgb(TIER_COLORS[row.tier]); - - ctx.fillStyle = rgb(index % 2 === 0 ? PALETTE.rowEven : PALETTE.rowOdd); - ctx.fillRect(0, top, canvas.width, ROW_HEIGHT); - - const medal = medals[row.rank - 1]; - if (medal) { - ctx.drawImage( - medal, - RANK_X, - Math.trunc(textY - 10), - MEDAL_SIZE, - MEDAL_SIZE - ); - } else { - ctx.font = font(FONT_SIZE); - ctx.fillStyle = rgb(PALETTE.text); - ctx.textAlign = "left"; - fillTextAnchored(ctx, String(row.rank), RANK_TEXT_X, textY, "ascender"); - } - - drawCircularImage(ctx, row.avatar, AVATAR_X, top + 15, AVATAR_SIZE); - - drawFittedText(ctx, row.name, PLAYER_X, textY, { - family: FONT, - size: FONT_SIZE, - maxWidth: PLAYER_MAX_WIDTH, - color: rgb(PALETTE.text), - }); - - drawFittedText( - ctx, - formatSpaced(row.number).padStart(7), - COLUMN_X[0] + COLUMN_WIDTHS[0], - textY, - { - family: MONO_FONT, - size: FONT_SIZE, - maxWidth: COLUMN_WIDTHS[0], - color: tierColor, - align: "right", - } - ); - - drawFittedText( - ctx, - formatCompact(row.score), - COLUMN_X[1] + COLUMN_WIDTHS[1], - textY, - { - family: FONT, - size: FONT_SIZE, - maxWidth: COLUMN_WIDTHS[1], - color: rgb(PALETTE.text), - align: "right", - } - ); - - const placement = formatPercent(row.percent); - const right = COLUMN_X[2] + COLUMN_WIDTHS[2]; - const textWidth = fittedWidth(ctx, placement, FONT, COLUMN_WIDTHS[2]); - const icon = row.percent > 50 ? arrowUp : trash; - ctx.drawImage( - icon, - Math.trunc(right - textWidth - 10 - ICON_SIZE), - Math.trunc(textY - 10), - ICON_SIZE, - ICON_SIZE - ); - drawFittedText(ctx, placement, right, textY, { - family: FONT, - size: FONT_SIZE, - maxWidth: COLUMN_WIDTHS[2], - color: tierColor, - align: "right", - }); - }); - - return canvas.encode("png"); -} diff --git a/src/modules/rngdle/services/format.test.ts b/src/modules/rngdle/rendering/format.test.ts similarity index 90% rename from src/modules/rngdle/services/format.test.ts rename to src/modules/rngdle/rendering/format.test.ts index c53e4b42..f8b511a4 100644 --- a/src/modules/rngdle/services/format.test.ts +++ b/src/modules/rngdle/rendering/format.test.ts @@ -8,7 +8,6 @@ describe("formatSpaced", () => { [1000, "1 000"], [1_234_567, "1 234 567"], [-1_234_567, "-1 234 567"], - [-5, "-5"], [1234.9, "1 234"], ])("formats %s as %s", (value, text) => { expect(formatSpaced(value)).toBe(text); @@ -17,10 +16,9 @@ describe("formatSpaced", () => { describe("formatCompact", () => { it.each([ - [0, "0"], [999, "999"], [1000, "1.0k"], - [1500, "1.5k"], + [999_999, "1000.0k"], [2_000_000, "2.0M"], [1_234_567_890, "1.2B"], ])("formats %s as %s", (value, text) => { @@ -31,14 +29,12 @@ describe("formatCompact", () => { describe("formatShort", () => { it.each([ [999, "en", "999"], - [1500, "en", "1.5k"], [1000, "en", "1k"], - [2_000_000, "en", "2M"], + [1500, "en", "1.5k"], [2_500_000, "fr", "2.5M"], [1_234_567_890, "en", "1.2B"], [1_234_567_890, "fr", "1.2Md"], [3_000_000_000, "fr", "3Md"], - [3_000_000_000, "de", "3B"], ])("formats %s in %s as %s", (value, locale, text) => { expect(formatShort(value, locale)).toBe(text); }); diff --git a/src/modules/rngdle/rendering/format.ts b/src/modules/rngdle/rendering/format.ts new file mode 100644 index 00000000..214b0b97 --- /dev/null +++ b/src/modules/rngdle/rendering/format.ts @@ -0,0 +1,30 @@ +const THRESHOLDS = [1_000_000_000, 1_000_000, 1_000] as const; + +function abbreviate( + value: number, + suffixes: readonly string[], + format: (scaled: number) => string +): string { + const index = THRESHOLDS.findIndex((threshold) => value >= threshold); + return index < 0 + ? String(value) + : format(value / THRESHOLDS[index]!) + suffixes[index]; +} + +export function formatSpaced(value: number): string { + const sign = value < 0 ? "-" : ""; + return ( + sign + + String(Math.trunc(Math.abs(value))).replace(/\B(?=(\d{3})+(?!\d))/g, " ") + ); +} + +export function formatCompact(value: number): string { + return abbreviate(value, ["B", "M", "k"], (scaled) => scaled.toFixed(1)); +} + +export function formatShort(value: number, locale: string): string { + return abbreviate(value, [locale === "fr" ? "Md" : "B", "M", "k"], (scaled) => + scaled.toFixed(1).replace(".0", "") + ); +} diff --git a/src/modules/rngdle/rendering/leaderboards.test.ts b/src/modules/rngdle/rendering/leaderboards.test.ts new file mode 100644 index 00000000..e8a0dac5 --- /dev/null +++ b/src/modules/rngdle/rendering/leaderboards.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + renderDailyLeaderboard, + renderOverallLeaderboard, + type DailyLeaderboardRow, + type OverallRow, +} from "./leaderboards.js"; + +const PNG_SIGNATURE = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); + +function pngSize(png: Buffer) { + expect(png.subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true); + return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; +} + +const LONG_NAME = "A very very long player name that must be shrunk to fit"; + +describe("renderDailyLeaderboard", () => { + it("renders a 1010px wide PNG with one 90px row per player", async () => { + const rows: DailyLeaderboardRow[] = ( + ["MYTHIC", "EPIC", "COMMON", "ERROR"] as const + ).map((tier, index) => ({ + rank: index + 1, + name: index === 1 ? LONG_NAME : `Player ${index}`, + avatar: null, + number: 1234567 * index, + score: 999_999 * index, + percent: 99.9 - index * 30, + tier, + })); + const png = await renderDailyLeaderboard(rows, { + rank: "Rang", + player: "Pseudo", + number: "Tirage", + score: "Score", + placement: "Placement", + }); + expect(pngSize(png)).toEqual({ width: 1010, height: 100 + 4 * 90 }); + }); +}); + +describe("renderOverallLeaderboard", () => { + const labels = { rank: "Rang", player: "Pseudo", overallScore: "Score" }; + const rows: OverallRow[] = [999, 1_500, 2_000_000, 1_234_567_890].map( + (totalScore, index) => ({ + rank: index + 1, + name: index === 3 ? LONG_NAME : `Player ${index}`, + avatar: null, + totalScore, + }) + ); + const caller: OverallRow = { + rank: 14, + name: "Me", + avatar: null, + totalScore: 42, + }; + + it.each([ + ["without caller", rows, undefined, 100 + 4 * 90], + ["with a caller row", rows, caller, 100 + 5 * 90 + 20], + ["for an empty page", [], undefined, 100], + ["for a podium caller alone", [], { ...caller, rank: 2 }, 100 + 90 + 20], + ])("renders %s", async (_, page, extra, height) => { + const png = await renderOverallLeaderboard(page, labels, "fr", extra); + expect(pngSize(png)).toEqual({ width: 800, height }); + }); +}); diff --git a/src/modules/rngdle/rendering/leaderboards.ts b/src/modules/rngdle/rendering/leaderboards.ts new file mode 100644 index 00000000..50d03e73 --- /dev/null +++ b/src/modules/rngdle/rendering/leaderboards.ts @@ -0,0 +1,115 @@ +import { loadAsset, registerFont, type Rgb } from "#lib/imaging.js"; +import { + renderLeaderboardTable, + type LeaderboardEntry, +} from "#lib/leaderboard-table.js"; +import { + formatPercent, + TIER_COLORS, + type TierOrError, +} from "#modules/rngdle/services/scoring.js"; +import { formatCompact, formatShort, formatSpaced } from "./format.js"; + +const ASSETS = new URL("../assets/", import.meta.url); +const MONO_FONT = "SpaceMonoBold"; +const DAILY_WIDTH = 1010; +const OVERALL_SCORE_COLOR: Rgb = [251, 251, 251]; + +const loadIcon = (name: string) => loadAsset(new URL(`${name}.png`, ASSETS)); + +export interface DailyLeaderboardRow extends LeaderboardEntry { + number: number; + score: number; + percent: number; + tier: TierOrError; +} + +export interface DailyLeaderboardLabels { + rank: string; + player: string; + number: string; + score: string; + placement: string; +} + +export interface OverallRow extends LeaderboardEntry { + totalScore: number; +} + +export interface OverallLabels { + rank: string; + player: string; + overallScore: string; +} + +export async function renderDailyLeaderboard( + rows: DailyLeaderboardRow[], + labels: DailyLeaderboardLabels +): Promise { + registerFont(new URL("spacemono_bold.ttf", ASSETS), MONO_FONT); + const [arrowUp, trash] = await Promise.all([ + loadIcon("arrow_up"), + loadIcon("trash"), + ]); + return renderLeaderboardTable({ + headers: labels, + rows, + width: DAILY_WIDTH, + columns: [ + { + header: labels.number, + x: 640, + maxWidth: 140, + align: "right", + cell: (row) => ({ + text: formatSpaced(row.number).padStart(7), + color: TIER_COLORS[row.tier], + family: MONO_FONT, + }), + }, + { + header: labels.score, + x: 780, + maxWidth: 130, + align: "right", + cell: (row) => ({ text: formatCompact(row.score) }), + }, + { + header: labels.placement, + x: 990, + maxWidth: 180, + align: "right", + cell: (row) => ({ + text: formatPercent(row.percent), + color: TIER_COLORS[row.tier], + icon: row.percent > 50 ? arrowUp : trash, + }), + }, + ], + }); +} + +export function renderOverallLeaderboard( + rows: OverallRow[], + labels: OverallLabels, + locale: string, + caller?: OverallRow +): Promise { + return renderLeaderboardTable({ + headers: labels, + rows, + caller, + columns: [ + { + header: labels.overallScore, + x: 770, + maxWidth: 250, + align: "right", + cell: (row) => ({ + text: `${formatShort(row.totalScore, locale)} EP`, + color: OVERALL_SCORE_COLOR, + }), + }, + ], + }); +} diff --git a/src/modules/rngdle/rendering/overall-leaderboard-image.test.ts b/src/modules/rngdle/rendering/overall-leaderboard-image.test.ts deleted file mode 100644 index 23ab010c..00000000 --- a/src/modules/rngdle/rendering/overall-leaderboard-image.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - renderOverallLeaderboard, - type OverallLabels, - type OverallRow, -} from "./overall-leaderboard-image.js"; - -const labels: OverallLabels = { - rank: "Rang", - player: "Pseudo", - overallScore: "Overall Score", -}; - -const rows: OverallRow[] = [ - { rank: 1, name: "Alice", avatar: null, totalScore: 999 }, - { rank: 2, name: "Bob", avatar: null, totalScore: 1500 }, - { rank: 3, name: "Carol", avatar: null, totalScore: 2_000_000 }, - { - rank: 4, - name: "A very very long player name that must be shrunk to fit", - avatar: null, - totalScore: 1_234_567_890, - }, - { rank: 5, name: "Eve", avatar: null, totalScore: Number.MAX_SAFE_INTEGER }, -]; - -const caller: OverallRow = { - rank: 14, - name: "Me", - avatar: null, - totalScore: 42, -}; - -function dimensions(png: Buffer): { width: number; height: number } { - return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) }; -} - -describe("renderOverallLeaderboard", () => { - it("renders a PNG with the expected dimensions", async () => { - const png = await renderOverallLeaderboard(rows, labels, "fr"); - expect(png.subarray(0, 8)).toEqual( - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - ); - expect(dimensions(png)).toEqual({ width: 800, height: 100 + 5 * 90 }); - }); - - it("adds room for the caller row", async () => { - const png = await renderOverallLeaderboard(rows, labels, "en", caller); - expect(dimensions(png)).toEqual({ - width: 800, - height: 100 + 5 * 90 + 90 + 20, - }); - }); - - it("renders ranks beyond the podium and an empty page", async () => { - const paged = rows.map((row, index) => ({ ...row, rank: 11 + index })); - const png = await renderOverallLeaderboard(paged, labels, "en"); - expect(dimensions(png).height).toBe(100 + 5 * 90); - const empty = await renderOverallLeaderboard([], labels, "en"); - expect(dimensions(empty).height).toBe(100); - }); - - it("renders a podium caller row", async () => { - const png = await renderOverallLeaderboard([], labels, "fr", { - ...caller, - rank: 2, - }); - expect(dimensions(png).height).toBe(100 + 90 + 20); - }); -}); diff --git a/src/modules/rngdle/rendering/overall-leaderboard-image.ts b/src/modules/rngdle/rendering/overall-leaderboard-image.ts deleted file mode 100644 index 736ef861..00000000 --- a/src/modules/rngdle/rendering/overall-leaderboard-image.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { createCanvas, type Image, type SKRSContext2D } from "@napi-rs/canvas"; -import { - drawCircularImage, - drawFittedText, - fillTextAnchored, - rgb, -} from "#lib/imaging.js"; -import { formatShort } from "../services/format.js"; -import { FONT, font, loadMedals, PALETTE, registerFonts } from "./common.js"; - -const WIDTH = 800; -const ROW_HEIGHT = 90; -const HEADER_HEIGHT = 100; -const GAP = 20; -const FONT_SIZE = 30; -const AVATAR_SIZE = 60; -const MEDAL_SIZE = 50; -const NAME_MAX_WIDTH = 320; -const SCORE_MAX_WIDTH = 250; -const SCORE_COLOR = rgb([251, 251, 251]); -const SEPARATOR_COLOR = rgb([80, 80, 80]); - -export interface OverallRow { - rank: number; - name: string; - avatar: Image | null; - totalScore: number; -} - -export interface OverallLabels { - rank: string; - player: string; - overallScore: string; -} - -function drawRow( - ctx: SKRSContext2D, - medals: Image[], - row: OverallRow, - top: number, - color: string, - locale: string -): void { - ctx.fillStyle = color; - ctx.fillRect(0, top, WIDTH, ROW_HEIGHT + 1); - - const textY = top + ROW_HEIGHT / 2 - 15; - const medal = medals[row.rank - 1]; - if (medal) { - ctx.drawImage(medal, 15, Math.trunc(textY - 10), MEDAL_SIZE, MEDAL_SIZE); - } else { - ctx.font = font(FONT_SIZE); - ctx.fillStyle = rgb(PALETTE.text); - ctx.textAlign = "left"; - fillTextAnchored(ctx, String(row.rank), 30, textY, "ascender"); - } - - drawCircularImage(ctx, row.avatar, 100, top + 15, AVATAR_SIZE); - - drawFittedText(ctx, row.name, 190, textY, { - family: FONT, - size: FONT_SIZE, - maxWidth: NAME_MAX_WIDTH, - color: rgb(PALETTE.text), - }); - drawFittedText(ctx, `${formatShort(row.totalScore, locale)} EP`, 770, textY, { - family: FONT, - size: FONT_SIZE, - maxWidth: SCORE_MAX_WIDTH, - color: SCORE_COLOR, - align: "right", - }); -} - -export async function renderOverallLeaderboard( - rows: OverallRow[], - labels: OverallLabels, - locale: string, - caller?: OverallRow -): Promise { - registerFonts(); - const medals = await loadMedals(); - - const height = - HEADER_HEIGHT + rows.length * ROW_HEIGHT + (caller ? ROW_HEIGHT + GAP : 0); - const canvas = createCanvas(WIDTH, height); - const ctx = canvas.getContext("2d"); - - ctx.fillStyle = rgb(PALETTE.background); - ctx.fillRect(0, 0, WIDTH, height); - ctx.fillStyle = rgb(PALETTE.header); - ctx.fillRect(0, 0, WIDTH, HEADER_HEIGHT + 1); - - const headerY = HEADER_HEIGHT / 2 - 15; - ctx.font = font(FONT_SIZE); - ctx.fillStyle = rgb(PALETTE.text); - ctx.textAlign = "left"; - fillTextAnchored(ctx, labels.rank, 15, headerY); - fillTextAnchored(ctx, labels.player, 190, headerY); - ctx.textAlign = "right"; - fillTextAnchored(ctx, labels.overallScore, 770, headerY); - - rows.forEach((row, index) => { - const color = rgb(index % 2 === 0 ? PALETTE.rowEven : PALETTE.rowOdd); - drawRow( - ctx, - medals, - row, - HEADER_HEIGHT + index * ROW_HEIGHT, - color, - locale - ); - }); - - if (caller) { - const baseY = HEADER_HEIGHT + rows.length * ROW_HEIGHT; - const lineY = baseY + GAP / 2; - ctx.fillStyle = SEPARATOR_COLOR; - ctx.fillRect(0, lineY, WIDTH, 2); - drawRow(ctx, medals, caller, baseY + GAP, rgb(PALETTE.rowEven), locale); - } - - return canvas.encode("png"); -} diff --git a/src/modules/rngdle/rendering/profile-image.test.ts b/src/modules/rngdle/rendering/profile-image.test.ts deleted file mode 100644 index 4f42dbbf..00000000 --- a/src/modules/rngdle/rendering/profile-image.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - renderProfile, - type ProfileImageData, - type ProfileLabels, -} from "./profile-image.js"; - -const PNG_SIGNATURE = Buffer.from([ - 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, -]); - -const labels: ProfileLabels = { - bestRoll: "Best Roll", - worstRoll: "Worst Roll", - date: (date) => `Date : ${date}`, - totalRolls: "Total Rolls", - averageScore: "Average Score", - maxBadges: "Max Badges", - maxBadgesValue: (count) => `${count} badges at once`, - overallScore: "Overall Score", - tierBreakdown: "Tier Breakdown", -}; - -function data(serverRank: number): ProfileImageData { - return { - username: "virilegamer", - avatar: null, - serverRank, - totalPlayers: 12, - best: { - number: 1234567, - score: 98765, - tier: "MYTHIC", - dateText: "2026-01-02", - }, - worst: { number: 42, score: 3, tier: "TRASH", dateText: "2026-02-03" }, - totalRolls: 321, - averageScore: 4567, - averageTier: "RARE", - maxBadges: 4, - totalScore: 1466007, - tierCounts: { - MYTHIC: 1, - ANOMALY: 2, - EPIC: 3, - RARE: 4, - UNCOMMON: 5, - COMMON: 6, - TRASH: 7, - }, - }; -} - -describe("renderProfile", () => { - it.each([0, 1, 2, 3, 7])( - "renders an 800x840 PNG for rank %i", - async (rank) => { - const png = await renderProfile(data(rank), labels); - expect(png.subarray(0, 8).equals(PNG_SIGNATURE)).toBe(true); - expect(png.readUInt32BE(16)).toBe(800); - expect(png.readUInt32BE(20)).toBe(840); - } - ); - - it("draws a different image depending on the rank", async () => { - const [none, gold, silver, other] = await Promise.all( - [0, 1, 2, 7].map((rank) => renderProfile(data(rank), labels)) - ); - expect(none?.equals(gold as Buffer)).toBe(false); - expect(gold?.equals(silver as Buffer)).toBe(false); - expect(silver?.equals(other as Buffer)).toBe(false); - }); -}); diff --git a/src/modules/rngdle/rendering/profile-image.ts b/src/modules/rngdle/rendering/profile-image.ts deleted file mode 100644 index 5cb9c854..00000000 --- a/src/modules/rngdle/rendering/profile-image.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { createCanvas, type Image, type SKRSContext2D } from "@napi-rs/canvas"; -import { - drawCircularImage, - fillTextAnchored, - rgb, - type Rgb, -} from "#lib/imaging.js"; -import { formatSpaced } from "../services/format.js"; -import { - TIER_COLORS, - type Tier, - type TierOrError, -} from "../services/score-table.js"; -import { - PALETTE, - fillRoundedBox, - font, - loadMedals, - registerFonts, -} from "./common.js"; - -const WIDTH = 800; -const HEIGHT = 840; -const HEADER_HEIGHT = 150; - -const RANK_COLOR: Rgb = [150, 150, 150]; -const SUFFIX_COLOR: Rgb = [215, 215, 215]; - -const BREAKDOWN_TIERS: Tier[] = [ - "MYTHIC", - "ANOMALY", - "EPIC", - "RARE", - "UNCOMMON", - "COMMON", - "TRASH", -]; - -export interface ProfileRoll { - number: number; - score: number; - tier: TierOrError; - dateText: string; -} - -export interface ProfileImageData { - username: string; - avatar: Image | null; - serverRank: number; - totalPlayers: number; - best: ProfileRoll; - worst: ProfileRoll; - totalRolls: number; - averageScore: number; - averageTier: TierOrError; - maxBadges: number; - totalScore: number; - tierCounts: Record; -} - -export interface ProfileLabels { - bestRoll: string; - worstRoll: string; - date: (date: string) => string; - totalRolls: string; - averageScore: string; - maxBadges: string; - maxBadgesValue: (count: number) => string; - overallScore: string; - tierBreakdown: string; -} - -interface BoxOptions { - subtext?: string; - outline?: Rgb; - valueColor?: Rgb; - scoreSuffix?: string; -} - -function text( - ctx: SKRSContext2D, - value: string, - x: number, - y: number, - size: number, - color: Rgb -): void { - ctx.font = font(size); - ctx.fontKerning = "none"; - ctx.fillStyle = rgb(color); - ctx.textAlign = "left"; - fillTextAnchored(ctx, value, x, y, "ascender"); -} - -function textWidth(ctx: SKRSContext2D, value: string, size: number): number { - ctx.font = font(size); - ctx.fontKerning = "none"; - return ctx.measureText(value).width; -} - -function drawBox( - ctx: SKRSContext2D, - x: number, - y: number, - title: string, - value: string, - { subtext, outline, valueColor = PALETTE.text, scoreSuffix }: BoxOptions = {} -): void { - fillRoundedBox(ctx, x, y, 346, 111, outline); - text(ctx, title, x + 20, y + 15, 22, PALETTE.title); - text(ctx, value, x + 20, y + 45, 30, valueColor); - if (scoreSuffix) { - const valueWidth = textWidth(ctx, `${value} `, 30); - text(ctx, scoreSuffix, x + 20 + valueWidth, y + 45, 30, SUFFIX_COLOR); - } - if (subtext) { - text(ctx, subtext, x + 20, y + 80, 18, PALETTE.subtext); - } -} - -function drawRank( - ctx: SKRSContext2D, - rank: number, - totalPlayers: number, - medals: Image[] -): void { - const totalText = ` / ${totalPlayers}`; - const totalWidth = textWidth(ctx, totalText, 30); - const medal = medals[rank - 1]; - if (medal) { - const width = rank === 1 ? 90 : 93; - ctx.drawImage(medal, Math.trunc(760 - totalWidth - width), 15, width, 90); - } else { - const rankText = `#${rank}`; - const rankWidth = textWidth(ctx, rankText, 80); - text(ctx, rankText, 760 - totalWidth - rankWidth, 25, 80, RANK_COLOR); - } - text(ctx, totalText, 760 - totalWidth, 70, 30, PALETTE.subtext); -} - -function rollOptions(roll: ProfileRoll, labels: ProfileLabels): BoxOptions { - const color = TIER_COLORS[roll.tier]; - return { - subtext: labels.date(roll.dateText), - outline: color, - valueColor: color, - scoreSuffix: `(${formatSpaced(roll.score)} EP)`, - }; -} - -export async function renderProfile( - data: ProfileImageData, - labels: ProfileLabels -): Promise { - registerFonts(); - const medals = await loadMedals(); - - const canvas = createCanvas(WIDTH, HEIGHT); - const ctx = canvas.getContext("2d"); - - ctx.fillStyle = rgb(PALETTE.background); - ctx.fillRect(0, 0, WIDTH, HEIGHT); - ctx.fillStyle = rgb(PALETTE.header); - ctx.fillRect(0, 0, WIDTH, HEADER_HEIGHT + 1); - - drawCircularImage(ctx, data.avatar, 40, 25, 100); - - text(ctx, data.username, 170, 35, 50, PALETTE.text); - - if (data.serverRank > 0) { - drawRank(ctx, data.serverRank, data.totalPlayers, medals); - } - - drawBox( - ctx, - 40, - 180, - labels.bestRoll, - formatSpaced(data.best.number), - rollOptions(data.best, labels) - ); - drawBox( - ctx, - 415, - 180, - labels.worstRoll, - formatSpaced(data.worst.number), - rollOptions(data.worst, labels) - ); - - const averageColor = TIER_COLORS[data.averageTier]; - drawBox(ctx, 40, 310, labels.totalRolls, String(data.totalRolls)); - drawBox( - ctx, - 415, - 310, - labels.averageScore, - `${formatSpaced(data.averageScore)} EP`, - { outline: averageColor, valueColor: averageColor } - ); - drawBox( - ctx, - 40, - 440, - labels.maxBadges, - labels.maxBadgesValue(data.maxBadges) - ); - drawBox( - ctx, - 415, - 440, - labels.overallScore, - `${formatSpaced(data.totalScore)} EP` - ); - - ctx.beginPath(); - ctx.roundRect(40, 570, 721, 241, 12); - ctx.fillStyle = rgb(PALETTE.box); - ctx.fill(); - text(ctx, labels.tierBreakdown, 60, 585, 22, PALETTE.title); - - const columns = [80, 440]; - BREAKDOWN_TIERS.forEach((tier, index) => { - const x = columns[index < 4 ? 0 : 1] as number; - const y = 630 + 40 * (index < 4 ? index : index - 4); - text(ctx, `${tier}:`, x, y, 22, TIER_COLORS[tier]); - const count = formatSpaced(data.tierCounts[tier] ?? 0); - text(ctx, count, x + 220 - textWidth(ctx, count, 22), y, 22, PALETTE.text); - }); - - return canvas.encode("png"); -} diff --git a/src/modules/rngdle/rendering/server-stats-image.test.ts b/src/modules/rngdle/rendering/server-stats-image.test.ts deleted file mode 100644 index a9975ac3..00000000 --- a/src/modules/rngdle/rendering/server-stats-image.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { Tier } from "../services/score-table.js"; -import { - renderServerStats, - type ServerStatsImageData, - type ServerStatsLabels, -} from "./server-stats-image.js"; - -const labels: ServerStatsLabels = { - title: "RNGdle - Server Stats", - bestRoll: "Best Roll OAT", - worstRoll: "Worst Roll OAT", - by: (name) => `by ${name}`, - totalRolls: "Total Rolls", - averageScore: "Average Score", - overallScore: "Overall Score", - tierBreakdown: "Tier Breakdown", -}; - -function buildData(leaders: Record): ServerStatsImageData { - const tierLeaders = Object.fromEntries( - Object.entries(leaders).map(([tier, count]) => [ - tier, - Array.from({ length: count }, () => null), - ]) - ) as ServerStatsImageData["tierLeaders"]; - return { - icon: null, - best: { - number: 131081274, - score: 4200000, - tier: "MYTHIC", - playerName: "Alice", - avatar: null, - }, - worst: { - number: 12, - score: 3, - tier: "TRASH", - playerName: "Bob", - avatar: null, - }, - totalRolls: 123456, - averageScore: 98765, - averageTier: "UNCOMMON", - overallScore: 123456789, - tierCounts: { - MYTHIC: 1, - ANOMALY: 20, - EPIC: 300, - RARE: 4000, - UNCOMMON: 50000, - COMMON: 600000, - TRASH: 7000000, - }, - tierLeaders, - }; -} - -function expectPng(png: Buffer): void { - expect(png.subarray(0, 8)).toEqual( - Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) - ); - expect(png.readUInt32BE(16)).toBe(800); - expect(png.readUInt32BE(20)).toBe(710); -} - -describe("renderServerStats", () => { - it("renders a PNG with no tier leaders", async () => { - const png = await renderServerStats( - buildData({ - MYTHIC: 0, - ANOMALY: 0, - EPIC: 0, - RARE: 0, - UNCOMMON: 0, - COMMON: 0, - TRASH: 0, - }), - labels - ); - expectPng(png); - }); - - it("renders a PNG with one leader per tier", async () => { - const png = await renderServerStats( - buildData({ - MYTHIC: 1, - ANOMALY: 1, - EPIC: 1, - RARE: 1, - UNCOMMON: 1, - COMMON: 1, - TRASH: 1, - }), - labels - ); - expectPng(png); - }); - - it("renders a PNG with three leaders per tier and null images", async () => { - const png = await renderServerStats( - buildData({ - MYTHIC: 3, - ANOMALY: 3, - EPIC: 3, - RARE: 3, - UNCOMMON: 3, - COMMON: 3, - TRASH: 3, - }), - labels - ); - expectPng(png); - }); -}); diff --git a/src/modules/rngdle/rendering/server-stats-image.ts b/src/modules/rngdle/rendering/server-stats-image.ts deleted file mode 100644 index 04a39c83..00000000 --- a/src/modules/rngdle/rendering/server-stats-image.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { createCanvas, type Image } from "@napi-rs/canvas"; -import { drawCircularImage, fillTextAnchored, rgb } from "#lib/imaging.js"; -import { formatSpaced } from "../services/format.js"; -import { - TIER_COLORS, - type Tier, - type TierOrError, -} from "../services/score-table.js"; -import { fillRoundedBox, font, PALETTE, registerFonts } from "./common.js"; - -const WIDTH = 800; -const HEIGHT = 710; -const HEADER_HEIGHT = 150; - -const SUFFIX_COLOR = "rgb(215, 215, 215)"; - -const BREAKDOWN_TIERS: Tier[] = [ - "MYTHIC", - "ANOMALY", - "EPIC", - "RARE", - "UNCOMMON", - "COMMON", - "TRASH", -]; - -export interface ServerStatsRoll { - number: number; - score: number; - tier: TierOrError; - playerName: string; - avatar: Image | null; -} - -export interface ServerStatsImageData { - icon: Image | null; - best: ServerStatsRoll; - worst: ServerStatsRoll; - totalRolls: number; - averageScore: number; - averageTier: TierOrError; - overallScore: number; - tierCounts: Record; - tierLeaders: Record; -} - -export interface ServerStatsLabels { - title: string; - bestRoll: string; - worstRoll: string; - by: (name: string) => string; - totalRolls: string; - averageScore: string; - overallScore: string; - tierBreakdown: string; -} - -export async function renderServerStats( - data: ServerStatsImageData, - labels: ServerStatsLabels -): Promise { - registerFonts(); - - const canvas = createCanvas(WIDTH, HEIGHT); - const ctx = canvas.getContext("2d"); - ctx.textAlign = "left"; - - ctx.fillStyle = rgb(PALETTE.background); - ctx.fillRect(0, 0, WIDTH, HEIGHT); - ctx.fillStyle = rgb(PALETTE.header); - ctx.fillRect(0, 0, WIDTH, HEADER_HEIGHT + 1); - - drawCircularImage(ctx, data.icon, 40, 25, 100); - - const text = ( - value: string, - x: number, - y: number, - size: number, - color: string - ): void => { - ctx.font = font(size); - ctx.fillStyle = color; - ctx.textAlign = "left"; - fillTextAnchored(ctx, value, x, y, "ascender"); - }; - - text(labels.title, 170, 45, 45, rgb(PALETTE.text)); - - const drawBox = ( - x: number, - y: number, - w: number, - h: number, - title: string, - value: string, - options: { - subtext?: string; - tier?: TierOrError; - suffix?: string; - avatar?: Image | null; - } = {} - ): void => { - const tierColor = options.tier ? TIER_COLORS[options.tier] : undefined; - fillRoundedBox(ctx, x, y, w + 1, h + 1, tierColor); - - text(title, x + 20, y + 15, 22, rgb(PALETTE.title)); - - if (options.avatar !== undefined) { - drawCircularImage(ctx, options.avatar, x + w - 32 - 15, y + 10, 32); - } - - text(value, x + 20, y + 45, 28, rgb(tierColor ?? PALETTE.text)); - - if (options.suffix) { - ctx.font = font(28); - const valueWidth = ctx.measureText(`${value} `).width; - text(options.suffix, x + 20 + valueWidth, y + 45, 28, SUFFIX_COLOR); - } - - if (options.subtext) { - text(options.subtext, x + 20, y + 80, 18, rgb(PALETTE.subtext)); - } - }; - - drawBox(40, 180, 345, 110, labels.bestRoll, formatSpaced(data.best.number), { - subtext: labels.by(data.best.playerName), - tier: data.best.tier, - suffix: `(${formatSpaced(data.best.score)} EP)`, - avatar: data.best.avatar, - }); - drawBox( - 415, - 180, - 345, - 110, - labels.worstRoll, - formatSpaced(data.worst.number), - { - subtext: labels.by(data.worst.playerName), - tier: data.worst.tier, - suffix: `(${formatSpaced(data.worst.score)} EP)`, - avatar: data.worst.avatar, - } - ); - - const boxWidth = 226; - drawBox( - 40, - 310, - boxWidth, - 110, - labels.totalRolls, - formatSpaced(data.totalRolls) - ); - drawBox( - 286, - 310, - boxWidth, - 110, - labels.averageScore, - `${formatSpaced(data.averageScore)} EP`, - { tier: data.averageTier } - ); - drawBox( - 532, - 310, - boxWidth, - 110, - labels.overallScore, - `${formatSpaced(data.overallScore)} EP` - ); - - fillRoundedBox(ctx, 40, 440, 721, 241); - text(labels.tierBreakdown, 60, 455, 22, rgb(PALETTE.title)); - - const columnX = [80, 440] as const; - const startY = 500; - - BREAKDOWN_TIERS.forEach((tier, i) => { - const color = rgb(TIER_COLORS[tier]); - const x = columnX[i < 4 ? 0 : 1]; - const y = startY + 45 * (i < 4 ? i : i - 4); - - text(`${tier}:`, x, y, 22, color); - - const count = formatSpaced(data.tierCounts[tier] ?? 0); - ctx.font = font(22); - const countWidth = ctx.measureText(count).width; - text(count, x + 220 - countWidth, y, 22, rgb(PALETTE.text)); - - const leaders = (data.tierLeaders[tier] ?? []).slice(0, 3); - for (let idx = leaders.length - 1; idx >= 0; idx--) { - const avatarX = x + 235 + idx * 14; - const avatarY = y - 1; - ctx.beginPath(); - ctx.arc(avatarX + 12, avatarY + 12, 14, 0, Math.PI * 2); - ctx.fillStyle = rgb(PALETTE.box); - ctx.fill(); - drawCircularImage(ctx, leaders[idx] ?? null, avatarX, avatarY, 24); - } - }); - - return canvas.encode("png"); -} diff --git a/src/modules/rngdle/rngdle.module.ts b/src/modules/rngdle/rngdle.module.ts index 0b9bbabf..e8c6f908 100644 --- a/src/modules/rngdle/rngdle.module.ts +++ b/src/modules/rngdle/rngdle.module.ts @@ -5,9 +5,7 @@ import rngdleAdminCommand from "./commands/rngdle-admin.command.js"; import rngdleCommand from "./commands/rngdle.command.js"; import overallPageButton from "./interactions/overall-page.button.js"; import { rngdleConfigSchema } from "./rngdle.config.js"; -import { createDailyLeaderboardTask } from "./tasks/daily-leaderboard.task.js"; -import scoreTableTask from "./tasks/score-table.task.js"; -import syncTask from "./tasks/sync.task.js"; +import { rngdleTasks } from "./tasks.js"; const logger = loggerMaker("rngdle"); @@ -27,9 +25,9 @@ const rngdleModule = defineModule({ registry.register(rngdleCommand); registry.register(rngdleAdminCommand); registry.register(overallPageButton); - registry.register(syncTask); - registry.register(scoreTableTask); - registry.register(createDailyLeaderboardTask(rngdleModule)); + for (const task of rngdleTasks(rngdleModule)) { + registry.register(task); + } }, onInstall(_client, guild) { diff --git a/src/modules/rngdle/services/api.test.ts b/src/modules/rngdle/services/api.test.ts new file mode 100644 index 00000000..45948f0e --- /dev/null +++ b/src/modules/rngdle/services/api.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const sleep = vi.hoisted(() => vi.fn(async (_ms: number) => undefined)); +vi.mock("node:timers/promises", () => ({ setTimeout: sleep })); + +import { + fetchScoreTable, + fetchUserRolls, + RngdleApiError, + RngdleUserNotFoundError, +} from "./api.js"; + +const fetchMock = vi.fn(); + +function reply(body: unknown, init: ResponseInit = {}) { + return new Response( + typeof body === "string" ? body : JSON.stringify(body), + init + ); +} + +function apiRoll(index: number, extra: Record = {}) { + return { + id: `r${index}`, + number: index, + totalScore: index * 10, + rolledAt: new Date(Date.UTC(2026, 0, 30 - index)).toISOString(), + ...extra, + }; +} + +function page(rolls: unknown[], hasMore = true) { + return reply({ rolls, hasMore }); +} + +function requestedQueries() { + return fetchMock.mock.calls.map(([url]) => new URL(url as string).search); +} + +beforeEach(() => { + fetchMock.mockReset(); + sleep.mockClear(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("fetchUserRolls", () => { + it("paginates 10 then 100 rolls with the running offset and stops on hasMore=false", async () => { + fetchMock + .mockResolvedValueOnce(page([apiRoll(1), apiRoll(2)])) + .mockResolvedValueOnce(page([apiRoll(3)])) + .mockResolvedValueOnce(page([apiRoll(4)], false)); + + const rolls = await fetchUserRolls("Some User", null); + + expect(rolls.map((roll) => roll.id)).toEqual(["r1", "r2", "r3", "r4"]); + expect(requestedQueries()).toEqual([ + "?limit=10&offset=0", + "?limit=100&offset=2", + "?limit=100&offset=3", + ]); + expect(fetchMock.mock.calls[0]![0]).toContain("/users/Some%20User/rolls"); + expect(fetchMock.mock.calls[0]![1].headers["User-Agent"]).toContain( + "OmniBot" + ); + }); + + it("stops on an empty page", async () => { + fetchMock.mockResolvedValueOnce(page([])); + + expect(await fetchUserRolls("u", null)).toEqual([]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("stops once the oldest roll of a page is not newer than since", async () => { + fetchMock.mockResolvedValue(page([apiRoll(1), apiRoll(2)])); + + const rolls = await fetchUserRolls("u", new Date(Date.UTC(2026, 0, 28))); + + expect(rolls).toHaveLength(2); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("keeps paginating while rolls are newer than since", async () => { + fetchMock + .mockResolvedValueOnce(page([apiRoll(1)])) + .mockResolvedValueOnce(page([apiRoll(2)], false)); + + await fetchUserRolls("u", new Date(Date.UTC(2026, 0, 1))); + + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("maps the fields and defaults badgeCount to 0", async () => { + fetchMock.mockResolvedValueOnce( + page( + [apiRoll(1, { badgeCount: 3 }), apiRoll(2, { badgeCount: "x" })], + false + ) + ); + + const rolls = await fetchUserRolls("u", null); + + expect(rolls[0]).toEqual({ + id: "r1", + number: 1, + score: 10, + badgeCount: 3, + rolledAt: new Date(Date.UTC(2026, 0, 29)), + }); + expect(rolls[1]!.badgeCount).toBe(0); + }); + + it("throws RngdleUserNotFoundError on 404", async () => { + fetchMock.mockResolvedValueOnce(reply("", { status: 404 })); + + await expect(fetchUserRolls("ghost", null)).rejects.toThrow( + RngdleUserNotFoundError + ); + }); + + it("throws RngdleApiError on other client errors without retrying", async () => { + fetchMock.mockResolvedValue(reply("", { status: 403 })); + + await expect(fetchUserRolls("u", null)).rejects.toThrow(RngdleApiError); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it.each([ + [[]], + [{ rolls: 1 }], + [{ rolls: [null] }], + [{ rolls: [apiRoll(1, { id: 5 })] }], + [{ rolls: [apiRoll(1, { totalScore: 1.5 })] }], + [{ rolls: [apiRoll(1, { number: undefined })] }], + [{ rolls: [apiRoll(1, { rolledAt: "nope" })] }], + ])("rejects the unexpected payload %j", async (body) => { + fetchMock.mockResolvedValueOnce(reply(body)); + + await expect(fetchUserRolls("u", null)).rejects.toThrow(RngdleApiError); + }); + + describe("retries", () => { + it("gives up after 3 attempts with an exponential backoff", async () => { + fetchMock.mockImplementation(async () => reply("", { status: 503 })); + + await expect(fetchUserRolls("u", null)).rejects.toThrow("503"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(sleep.mock.calls.map(([ms]) => ms)).toEqual([1000, 2000]); + }); + + it("honors Retry-After capped at 30 seconds and recovers", async () => { + const limited = (seconds: string) => + reply("", { status: 429, headers: { "retry-after": seconds } }); + fetchMock + .mockResolvedValueOnce(limited("7")) + .mockResolvedValueOnce(limited("999")) + .mockResolvedValueOnce(page([apiRoll(1)], false)); + + expect(await fetchUserRolls("u", null)).toHaveLength(1); + expect(sleep.mock.calls.map(([ms]) => ms)).toEqual([7000, 30_000]); + }); + }); +}); + +describe("fetchScoreTable", () => { + function tableSource(size: number, percent: (i: number) => number) { + const entries = Array.from( + { length: size }, + (_, i) => `${1000 + i}:${percent(i)}` + ); + return `var t={${entries.join(",")}};`; + } + + function mockSite(scripts: Record) { + const html = Object.keys(scripts) + .map((src) => ``) + .join(""); + fetchMock.mockImplementation(async (url: string) => { + const path = new URL(url).pathname; + return path === "/" + ? reply(html) + : reply(scripts[path] ?? "", { status: path in scripts ? 200 : 404 }); + }); + } + + it("keeps the biggest table found in the page scripts, compressed", async () => { + mockSite({ + "/a.js": tableSource(1100, () => 10), + "/b.js": tableSource(1500, (i) => (i * 100) / 1500), + "/c.js": "console.log(1)", + }); + + const table = await fetchScoreTable(); + + expect(table).not.toBeNull(); + expect(Object.keys(table!).length).toBeGreaterThanOrEqual(20); + expect(table!["1000"]).toBe(0); + }); + + it("returns null when no script has a plausible table", async () => { + mockSite({ "/a.js": "var x=1", "/b.js": tableSource(1200, () => 10) }); + + expect(await fetchScoreTable()).toBeNull(); + }); + + it("throws when the home page is unavailable", async () => { + fetchMock.mockResolvedValue(reply("", { status: 404 })); + + await expect(fetchScoreTable()).rejects.toThrow(RngdleApiError); + }); +}); diff --git a/src/modules/rngdle/services/api.ts b/src/modules/rngdle/services/api.ts new file mode 100644 index 00000000..5f60ec53 --- /dev/null +++ b/src/modules/rngdle/services/api.ts @@ -0,0 +1,151 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import { + compressScoreTable, + extractScoreTable, + isPlausibleTable, + type CompressedTable, +} from "./scoring.js"; + +const SITE_URL = "https://www.rngdle.com"; +const USER_AGENT = "OmniBot (+https://github.com/GravenDev/OmniBot)"; +const REQUEST_TIMEOUT_MS = 30_000; +const MAX_ATTEMPTS = 3; +const MAX_RETRY_DELAY_MS = 30_000; +const RETRYABLE_STATUSES = new Set([429, 500, 502, 503, 504]); +const FIRST_PAGE_SIZE = 10; +const PAGE_SIZE = 100; +const MAX_PAGES = 100; + +export interface RemoteRoll { + id: string; + number: number; + score: number; + badgeCount: number; + rolledAt: Date; +} + +export class RngdleUserNotFoundError extends Error { + constructor(readonly username: string) { + super(`RNGdle user not found: ${username}`); + } +} + +export class RngdleApiError extends Error {} + +function retryDelayMs(response: Response, attempt: number): number { + const retryAfter = Number(response.headers.get("retry-after")); + const delay = + Number.isFinite(retryAfter) && retryAfter > 0 + ? retryAfter * 1000 + : 1000 * 2 ** (attempt - 1); + return Math.min(delay, MAX_RETRY_DELAY_MS); +} + +async function request(url: string): Promise { + for (let attempt = 1; ; attempt++) { + const response = await fetch(url, { + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + headers: { "User-Agent": USER_AGENT }, + }); + if (!RETRYABLE_STATUSES.has(response.status) || attempt >= MAX_ATTEMPTS) { + return response; + } + await sleep(retryDelayMs(response, attempt)); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseRoll(value: unknown): RemoteRoll { + if ( + !isRecord(value) || + typeof value["id"] !== "string" || + !Number.isSafeInteger(value["number"]) || + !Number.isSafeInteger(value["totalScore"]) || + typeof value["rolledAt"] !== "string" + ) { + throw new RngdleApiError("Unexpected roll in the RNGdle API response"); + } + const rolledAt = new Date(value["rolledAt"]); + if (Number.isNaN(rolledAt.getTime())) { + throw new RngdleApiError("Invalid roll date in the RNGdle API response"); + } + const badgeCount = value["badgeCount"]; + return { + id: value["id"], + number: value["number"] as number, + score: value["totalScore"] as number, + badgeCount: Number.isSafeInteger(badgeCount) ? (badgeCount as number) : 0, + rolledAt, + }; +} + +function parseRollsPage(body: unknown): { + rolls: RemoteRoll[]; + hasMore: boolean; +} { + if (!isRecord(body) || !Array.isArray(body["rolls"])) { + throw new RngdleApiError("Unexpected RNGdle API response"); + } + return { + rolls: body["rolls"].map(parseRoll), + hasMore: body["hasMore"] === true, + }; +} + +export async function fetchUserRolls( + username: string, + since: Date | null +): Promise { + const rolls: RemoteRoll[] = []; + let limit = FIRST_PAGE_SIZE; + + for (let page = 0; page < MAX_PAGES; page++) { + const response = await request( + `${SITE_URL}/api/users/${encodeURIComponent(username)}/rolls?limit=${limit}&offset=${rolls.length}` + ); + if (response.status === 404) { + throw new RngdleUserNotFoundError(username); + } + if (!response.ok) { + throw new RngdleApiError( + `RNGdle API responded ${response.status} for ${username}` + ); + } + + const { rolls: pageRolls, hasMore } = parseRollsPage(await response.json()); + rolls.push(...pageRolls); + + const oldest = pageRolls.at(-1); + if (!hasMore || !oldest || (since && oldest.rolledAt <= since)) { + break; + } + limit = PAGE_SIZE; + } + + return rolls; +} + +export async function fetchScoreTable(): Promise { + const home = await request(SITE_URL); + if (!home.ok) { + throw new RngdleApiError(`rngdle.com responded ${home.status}`); + } + const scripts = [ + ...(await home.text()).matchAll(/]*\bsrc=["']([^"']+)["']/g), + ].map((match) => new URL(match[1]!, SITE_URL).href); + + let best: Map | null = null; + for (const script of scripts) { + const response = await request(script); + const table = response.ok ? extractScoreTable(await response.text()) : null; + if (table && (!best || table.size > best.size)) { + best = table; + } + } + + const compressed = best && compressScoreTable(best); + return compressed && isPlausibleTable(compressed) ? compressed : null; +} diff --git a/src/modules/rngdle/services/boards.test.ts b/src/modules/rngdle/services/boards.test.ts deleted file mode 100644 index e1d67ec5..00000000 --- a/src/modules/rngdle/services/boards.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -vi.mock("#lib/database.js", () => ({ default: {} })); - -const { utcDayRange } = await import("./boards.js"); - -const iso = ([from, to]: [Date, Date]) => [ - from.toISOString(), - to.toISOString(), -]; - -describe("utcDayRange", () => { - it("returns the current UTC day for day 0", () => { - const now = new Date("2026-05-15T13:45:10.123Z"); - - expect(iso(utcDayRange(0, now))).toEqual([ - "2026-05-15T00:00:00.000Z", - "2026-05-16T00:00:00.000Z", - ]); - }); - - it("returns the previous UTC day for day 1", () => { - const now = new Date("2026-05-15T13:45:10.123Z"); - - expect(iso(utcDayRange(1, now))).toEqual([ - "2026-05-14T00:00:00.000Z", - "2026-05-15T00:00:00.000Z", - ]); - }); - - it("works just after midnight UTC", () => { - const now = new Date("2026-05-15T00:00:00.001Z"); - - expect(iso(utcDayRange(0, now))).toEqual([ - "2026-05-15T00:00:00.000Z", - "2026-05-16T00:00:00.000Z", - ]); - expect(iso(utcDayRange(1, now))).toEqual([ - "2026-05-14T00:00:00.000Z", - "2026-05-15T00:00:00.000Z", - ]); - }); - - it("works just before midnight UTC", () => { - const now = new Date("2026-05-15T23:59:59.999Z"); - - expect(iso(utcDayRange(1, now))).toEqual([ - "2026-05-14T00:00:00.000Z", - "2026-05-15T00:00:00.000Z", - ]); - }); - - it("crosses month boundaries", () => { - const now = new Date("2026-03-01T00:30:00.000Z"); - - expect(iso(utcDayRange(1, now))).toEqual([ - "2026-02-28T00:00:00.000Z", - "2026-03-01T00:00:00.000Z", - ]); - }); - - it("crosses year boundaries", () => { - const now = new Date("2027-01-01T00:00:05.000Z"); - - expect(iso(utcDayRange(1, now))).toEqual([ - "2026-12-31T00:00:00.000Z", - "2027-01-01T00:00:00.000Z", - ]); - }); - - it("handles leap days", () => { - const now = new Date("2028-03-01T10:00:00.000Z"); - - expect(iso(utcDayRange(1, now))).toEqual([ - "2028-02-29T00:00:00.000Z", - "2028-03-01T00:00:00.000Z", - ]); - }); - - it("defaults to the current time", () => { - vi.useFakeTimers(); - vi.setSystemTime(new Date("2026-07-04T12:00:00.000Z")); - try { - expect(iso(utcDayRange(0))).toEqual([ - "2026-07-04T00:00:00.000Z", - "2026-07-05T00:00:00.000Z", - ]); - } finally { - vi.useRealTimers(); - } - }); -}); diff --git a/src/modules/rngdle/services/boards.ts b/src/modules/rngdle/services/boards.ts deleted file mode 100644 index 8620c59a..00000000 --- a/src/modules/rngdle/services/boards.ts +++ /dev/null @@ -1,300 +0,0 @@ -import type { Client, Guild, User } from "discord.js"; -import type { TFunction } from "#lib/i18n.js"; -import { fetchAvatar, fetchImage } from "#lib/imaging.js"; -import { renderDailyLeaderboard } from "#modules/rngdle/rendering/daily-leaderboard-image.js"; -import { - renderOverallLeaderboard, - type OverallRow, -} from "#modules/rngdle/rendering/overall-leaderboard-image.js"; -import { renderProfile } from "#modules/rngdle/rendering/profile-image.js"; -import { renderServerStats } from "#modules/rngdle/rendering/server-stats-image.js"; -import imageCache from "./image-cache.service.js"; -import rngdleService, { type Account } from "./rngdle.service.js"; -import { TIERS, type Tier } from "./score-table.js"; -import scoreTableService from "./score-table.service.js"; -import { computeRollStats, computeServerStats } from "./stats.js"; - -export const DAILY_LEADERBOARD_SIZE = 25; -export const OVERALL_PAGE_SIZE = 10; - -export interface DailyLeaderboard { - image: Buffer; - winnerIds: string[]; -} - -export interface OverallPage { - image: Buffer; - page: number; - pageCount: number; -} - -function fetchUser(client: Client, userId: string): Promise { - return client.users.fetch(userId).catch(() => null); -} - -export function utcDayRange(daysAgo: number, now = new Date()): [Date, Date] { - const start = new Date( - Date.UTC( - now.getUTCFullYear(), - now.getUTCMonth(), - now.getUTCDate() - daysAgo - ) - ); - const end = new Date(start.getTime() + 24 * 60 * 60 * 1000); - return [start, end]; -} - -export async function buildDailyLeaderboard( - client: Client, - guildId: string, - [from, to]: [Date, Date], - t: TFunction -): Promise { - const rolls = (await rngdleService.rollsBetween(guildId, from, to)).slice( - 0, - DAILY_LEADERBOARD_SIZE - ); - const table = await scoreTableService.get(); - - const rows = ( - await Promise.all( - rolls.map(async (roll, index) => { - const user = await fetchUser(client, roll.userId); - if (!user) { - return null; - } - return { - rank: index + 1, - name: user.username, - avatar: await fetchAvatar(user), - number: roll.number, - score: roll.score, - percent: table.percentOf(roll.score), - tier: table.tierOf(roll.score), - }; - }) - ) - ).filter((row) => row !== null); - - const [top] = rolls; - if (!top || rows.length === 0) { - return null; - } - - const image = await renderDailyLeaderboard(rows, { - rank: t("leaderboard.header.rank"), - player: t("leaderboard.header.player"), - number: t("leaderboard.header.number"), - score: t("leaderboard.header.score"), - placement: t("leaderboard.header.placement"), - }); - const winnerIds = [ - ...new Set( - rolls - .filter((roll) => roll.score === top.score) - .map((roll) => roll.userId) - ), - ]; - return { image, winnerIds }; -} - -export async function buildOverallPage( - client: Client, - guildId: string, - requestedPage: number, - callerId: string, - t: TFunction, - locale: string -): Promise { - const totals = await rngdleService.totalsByUser(guildId); - if (totals.length === 0) { - return null; - } - const accounts = new Map( - (await rngdleService.listAccounts(guildId)).map((account) => [ - account.userId, - account.username, - ]) - ); - - const pageCount = Math.ceil(totals.length / OVERALL_PAGE_SIZE); - const page = Math.min(Math.max(requestedPage, 1), pageCount); - const start = (page - 1) * OVERALL_PAGE_SIZE; - - const toRow = async (index: number): Promise => { - const entry = totals[index]!; - const user = await fetchUser(client, entry.userId); - return { - rank: index + 1, - name: user?.username ?? accounts.get(entry.userId) ?? "?", - avatar: user ? await fetchAvatar(user) : null, - totalScore: entry.total, - }; - }; - - const indexes = totals - .slice(start, start + OVERALL_PAGE_SIZE) - .map((_, offset) => start + offset); - const rows = await Promise.all(indexes.map(toRow)); - - const callerIndex = totals.findIndex((entry) => entry.userId === callerId); - const callerOnPage = - callerIndex >= start && callerIndex < start + OVERALL_PAGE_SIZE; - const caller = - callerIndex >= 0 && !callerOnPage ? await toRow(callerIndex) : undefined; - - const image = await renderOverallLeaderboard( - rows, - { - rank: t("leaderboard.header.rank"), - player: t("leaderboard.header.player"), - overallScore: t("overall.header.score"), - }, - locale, - caller - ); - return { image, page, pageCount }; -} - -export function cachedOverallPage( - client: Client, - guildId: string, - page: number, - callerId: string, - t: TFunction, - locale: string -): Promise { - return imageCache.get(guildId, `overall:${locale}:${page}:${callerId}`, () => - buildOverallPage(client, guildId, page, callerId, t, locale) - ); -} - -export async function buildProfile( - account: Account, - user: User, - t: TFunction, - locale: string -): Promise { - const [rolls, totals, accounts, table] = await Promise.all([ - rngdleService.userRolls(account.guildId, account.userId), - rngdleService.totalsByUser(account.guildId), - rngdleService.listAccounts(account.guildId), - scoreTableService.get(), - ]); - const stats = computeRollStats(rolls, table); - if (!stats) { - return null; - } - - const formatDate = new Intl.DateTimeFormat(locale, { - day: "2-digit", - month: "short", - year: "numeric", - timeZone: "UTC", - }); - const toRoll = (roll: typeof stats.best) => ({ - number: roll.number, - score: roll.score, - tier: table.tierOf(roll.score), - dateText: formatDate.format(roll.rolledAt), - }); - - return renderProfile( - { - username: account.username, - avatar: await fetchAvatar(user), - serverRank: - totals.findIndex((entry) => entry.userId === account.userId) + 1, - totalPlayers: accounts.length, - best: toRoll(stats.best), - worst: toRoll(stats.worst), - totalRolls: stats.totalRolls, - averageScore: stats.averageScore, - averageTier: table.tierOf(stats.averageScore), - maxBadges: stats.maxBadges, - totalScore: stats.totalScore, - tierCounts: stats.tierCounts, - }, - { - bestRoll: t("profile.bestRoll"), - worstRoll: t("profile.worstRoll"), - date: (date) => t("profile.date", { date }), - totalRolls: t("profile.totalRolls"), - averageScore: t("profile.averageScore"), - maxBadges: t("profile.maxBadges"), - maxBadgesValue: (count) => t("profile.maxBadgesValue", { count }), - overallScore: t("profile.overallScore"), - tierBreakdown: t("profile.tierBreakdown"), - } - ); -} - -export async function buildServerStats( - client: Client, - guild: Guild, - t: TFunction -): Promise { - const [rolls, accounts, table] = await Promise.all([ - rngdleService.guildRolls(guild.id), - rngdleService.listAccounts(guild.id), - scoreTableService.get(), - ]); - const stats = computeServerStats(rolls, table); - if (!stats) { - return null; - } - - const usernames = new Map( - accounts.map((account) => [account.userId, account.username]) - ); - const avatarOf = async (userId: string, size: 64 | 128) => { - const user = await fetchUser(client, userId); - return user ? fetchAvatar(user, size) : null; - }; - const toRoll = async (roll: typeof stats.best) => ({ - number: roll.number, - score: roll.score, - tier: table.tierOf(roll.score), - playerName: usernames.get(roll.userId) ?? "?", - avatar: await avatarOf(roll.userId, 64), - }); - - const leaderAvatars = await Promise.all( - TIERS.map( - async (tier) => - [ - tier, - await Promise.all( - stats.tierLeaders[tier].map((userId) => avatarOf(userId, 64)) - ), - ] as const - ) - ); - - return renderServerStats( - { - icon: await fetchImage(guild.iconURL({ extension: "png", size: 128 })), - best: await toRoll(stats.best), - worst: await toRoll(stats.worst), - totalRolls: stats.totalRolls, - averageScore: stats.averageScore, - averageTier: table.tierOf(stats.averageScore), - overallScore: stats.totalScore, - tierCounts: stats.tierCounts, - tierLeaders: Object.fromEntries(leaderAvatars) as Record< - Tier, - Awaited>[] - >, - }, - { - title: t("server.title"), - bestRoll: t("server.bestRoll"), - worstRoll: t("server.worstRoll"), - by: (name) => t("server.by", { name }), - totalRolls: t("server.totalRolls"), - averageScore: t("server.averageScore"), - overallScore: t("server.overallScore"), - tierBreakdown: t("server.tierBreakdown"), - } - ); -} diff --git a/src/modules/rngdle/services/format.ts b/src/modules/rngdle/services/format.ts deleted file mode 100644 index 9fa1cac8..00000000 --- a/src/modules/rngdle/services/format.ts +++ /dev/null @@ -1,36 +0,0 @@ -export function formatSpaced(value: number): string { - const sign = value < 0 ? "-" : ""; - return ( - sign + - String(Math.trunc(Math.abs(value))).replace(/\B(?=(\d{3})+(?!\d))/g, " ") - ); -} - -const COMPACT_SUFFIXES = [ - [1_000_000_000, "B"], - [1_000_000, "M"], - [1_000, "k"], -] as const; - -export function formatCompact(value: number): string { - for (const [threshold, suffix] of COMPACT_SUFFIXES) { - if (value >= threshold) { - return `${(value / threshold).toFixed(1)}${suffix}`; - } - } - return String(value); -} - -export function formatShort(value: number, locale: string): string { - const suffixes = [ - [1_000_000_000, locale === "fr" ? "Md" : "B"], - [1_000_000, "M"], - [1_000, "k"], - ] as const; - for (const [threshold, suffix] of suffixes) { - if (value >= threshold) { - return `${(value / threshold).toFixed(1).replace(".0", "")}${suffix}`; - } - } - return String(value); -} diff --git a/src/modules/rngdle/services/image-cache.service.test.ts b/src/modules/rngdle/services/image-cache.service.test.ts deleted file mode 100644 index 1fe455c6..00000000 --- a/src/modules/rngdle/services/image-cache.service.test.ts +++ /dev/null @@ -1,88 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -const revisions = vi.hoisted(() => new Map()); -vi.mock("./rngdle.service.js", () => ({ - default: { getRevision: (guildId: string) => revisions.get(guildId) ?? 0 }, -})); - -const { default: cache } = await import("./image-cache.service.js"); - -let guild = 0; -let guildId = ""; - -beforeEach(() => { - guildId = `guild-${++guild}`; - vi.useFakeTimers(); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe("ImageCacheService", () => { - it("renders once for repeated and concurrent requests", async () => { - const render = vi.fn(async () => Buffer.from("png")); - - await Promise.all([ - cache.get(guildId, "daily", render), - cache.get(guildId, "daily", render), - ]); - await cache.get(guildId, "daily", render); - - expect(render).toHaveBeenCalledTimes(1); - }); - - it("renders again once the guild data has changed", async () => { - const render = vi.fn(async () => Buffer.from("png")); - - await cache.get(guildId, "daily", render); - revisions.set(guildId, 1); - await cache.get(guildId, "daily", render); - - expect(render).toHaveBeenCalledTimes(2); - }); - - it("renders again after the cache lifetime", async () => { - const render = vi.fn(async () => Buffer.from("png")); - - await cache.get(guildId, "daily", render); - vi.advanceTimersByTime(14_000); - await cache.get(guildId, "daily", render); - expect(render).toHaveBeenCalledTimes(1); - - vi.advanceTimersByTime(2_000); - await cache.get(guildId, "daily", render); - expect(render).toHaveBeenCalledTimes(2); - }); - - it("keeps one image per key and per guild", async () => { - const render = vi.fn(async () => Buffer.from("png")); - - await cache.get(guildId, "a", render); - await cache.get(guildId, "b", render); - await cache.get(`${guildId}-other`, "a", render); - - expect(render).toHaveBeenCalledTimes(3); - }); - - it("does not keep a failed render", async () => { - const failing = vi.fn(async () => { - throw new Error("render failed"); - }); - const render = vi.fn(async () => Buffer.from("png")); - - await expect(cache.get(guildId, "daily", failing)).rejects.toThrow(); - await expect(cache.get(guildId, "daily", render)).resolves.toBeInstanceOf( - Buffer - ); - expect(render).toHaveBeenCalledTimes(1); - }); - - it("returns the rendered value", async () => { - const buffer = Buffer.from("png"); - - await expect(cache.get(guildId, "daily", async () => buffer)).resolves.toBe( - buffer - ); - }); -}); diff --git a/src/modules/rngdle/services/image-cache.service.ts b/src/modules/rngdle/services/image-cache.service.ts deleted file mode 100644 index 75f01052..00000000 --- a/src/modules/rngdle/services/image-cache.service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { declareService, type Service } from "#lib/service.js"; -import rngdleService from "./rngdle.service.js"; - -const CACHE_TTL_MS = 15_000; - -interface CachedValue { - revision: number; - expiresAt: number; - value: Promise; -} - -class ImageCacheService implements Service { - private readonly entries = new Map(); - - get(guildId: string, key: string, render: () => Promise): Promise { - const fullKey = `${guildId}:${key}`; - const revision = rngdleService.getRevision(guildId); - const cached = this.entries.get(fullKey); - if ( - cached && - cached.revision === revision && - cached.expiresAt > Date.now() - ) { - return cached.value as Promise; - } - - for (const [entryKey, entry] of this.entries) { - if (entry.expiresAt <= Date.now()) { - this.entries.delete(entryKey); - } - } - - const value = render(); - const entry = { revision, expiresAt: Date.now() + CACHE_TTL_MS, value }; - this.entries.set(fullKey, entry); - value.catch(() => { - if (this.entries.get(fullKey) === entry) { - this.entries.delete(fullKey); - } - }); - return value; - } -} - -export default declareService(new ImageCacheService()); diff --git a/src/modules/rngdle/services/rngdle-api.test.ts b/src/modules/rngdle/services/rngdle-api.test.ts deleted file mode 100644 index 8c3843fe..00000000 --- a/src/modules/rngdle/services/rngdle-api.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - fetchScoreTableFromSite, - fetchUserRolls, - RngdleUserNotFoundError, -} from "./rngdle-api.js"; - -const fetchMock = - vi.fn<(url: string, init?: RequestInit) => Promise>(); - -beforeEach(() => { - fetchMock.mockReset(); - vi.stubGlobal("fetch", fetchMock); -}); - -afterEach(() => { - vi.unstubAllGlobals(); -}); - -function json(body: unknown, status = 200) { - return new Response(JSON.stringify(body), { status }); -} - -function remoteRoll(id: string, rolledAt: string, extra: object = {}) { - return { id, number: 1, totalScore: 10, rolledAt, ...extra }; -} - -function urlOf(call: number) { - return new URL(fetchMock.mock.calls[call]![0]); -} - -describe("fetchUserRolls", () => { - it("maps the API fields", async () => { - fetchMock.mockResolvedValueOnce( - json({ - rolls: [ - { - id: "r1", - number: 42, - totalScore: 1234, - badgeCount: 3, - rolledAt: "2026-03-04T05:06:07.000Z", - }, - remoteRoll("r2", "2026-03-03T00:00:00.000Z"), - ], - hasMore: false, - }) - ); - - const rolls = await fetchUserRolls("alice", null); - - expect(rolls).toEqual([ - { - id: "r1", - number: 42, - score: 1234, - badgeCount: 3, - rolledAt: new Date("2026-03-04T05:06:07.000Z"), - }, - { - id: "r2", - number: 1, - score: 10, - badgeCount: 0, - rolledAt: new Date("2026-03-03T00:00:00.000Z"), - }, - ]); - }); - - it("encodes the username and uses limit 10 on the first page", async () => { - fetchMock.mockResolvedValueOnce(json({ rolls: [], hasMore: false })); - - await fetchUserRolls("a b/c", null); - - expect(urlOf(0).pathname).toBe("/api/users/a%20b%2Fc/rolls"); - expect(urlOf(0).origin).toBe("https://www.rngdle.com"); - expect(urlOf(0).searchParams.get("limit")).toBe("10"); - expect(urlOf(0).searchParams.get("offset")).toBe("0"); - }); - - it("uses limit 100 and increasing offsets on later pages until hasMore is false", async () => { - const page = (ids: string[], hasMore: boolean) => - json({ - rolls: ids.map((id) => remoteRoll(id, "2026-01-01T00:00:00.000Z")), - hasMore, - }); - fetchMock - .mockResolvedValueOnce(page(["1", "2", "3"], true)) - .mockResolvedValueOnce(page(["4", "5"], true)) - .mockResolvedValueOnce(page(["6"], false)); - - const rolls = await fetchUserRolls("alice", null); - - expect(rolls.map((roll) => roll.id)).toEqual([ - "1", - "2", - "3", - "4", - "5", - "6", - ]); - expect(fetchMock).toHaveBeenCalledTimes(3); - expect( - [0, 1, 2].map((call) => [ - urlOf(call).searchParams.get("limit"), - urlOf(call).searchParams.get("offset"), - ]) - ).toEqual([ - ["10", "0"], - ["100", "3"], - ["100", "5"], - ]); - }); - - it("stops when the oldest roll of a page is at or before since", async () => { - fetchMock.mockResolvedValue( - json({ - rolls: [ - remoteRoll("new", "2026-02-02T00:00:00.000Z"), - remoteRoll("old", "2026-02-01T00:00:00.000Z"), - ], - hasMore: true, - }) - ); - - const rolls = await fetchUserRolls( - "alice", - new Date("2026-02-01T00:00:00.000Z") - ); - - expect(fetchMock).toHaveBeenCalledTimes(1); - expect(rolls).toHaveLength(2); - }); - - it("keeps fetching while the oldest roll is newer than since", async () => { - fetchMock - .mockResolvedValueOnce( - json({ - rolls: [remoteRoll("a", "2026-02-03T00:00:00.000Z")], - hasMore: true, - }) - ) - .mockResolvedValueOnce( - json({ - rolls: [remoteRoll("b", "2026-02-01T00:00:00.000Z")], - hasMore: true, - }) - ); - - const rolls = await fetchUserRolls( - "alice", - new Date("2026-02-02T00:00:00.000Z") - ); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(rolls.map((roll) => roll.id)).toEqual(["a", "b"]); - }); - - it("stops on an empty page even if hasMore is true", async () => { - fetchMock.mockResolvedValue(json({ rolls: [], hasMore: true })); - - const rolls = await fetchUserRolls("alice", null); - - expect(rolls).toEqual([]); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - it("throws RngdleUserNotFoundError on 404", async () => { - fetchMock.mockResolvedValueOnce(new Response("nope", { status: 404 })); - - const error = await fetchUserRolls("ghost", null).catch( - (err: unknown) => err - ); - - expect(error).toBeInstanceOf(RngdleUserNotFoundError); - expect((error as RngdleUserNotFoundError).username).toBe("ghost"); - }); - - it("throws on any other non-OK status", async () => { - fetchMock.mockResolvedValueOnce(new Response("boom", { status: 500 })); - - const error = await fetchUserRolls("alice", null).catch( - (err: unknown) => err - ); - - expect(error).toBeInstanceOf(Error); - expect(error).not.toBeInstanceOf(RngdleUserNotFoundError); - expect((error as Error).message).toContain("500"); - }); - - it("sends an abort signal with every request", async () => { - fetchMock.mockResolvedValueOnce(json({ rolls: [], hasMore: false })); - - await fetchUserRolls("alice", null); - - expect(fetchMock.mock.calls[0]![1]?.signal).toBeInstanceOf(AbortSignal); - }); -}); - -function table(size: number, firstKey: number) { - const entries: string[] = []; - for (let i = 0; i < size; i++) { - entries.push(`${firstKey + i}:${((i / size) * 100).toFixed(4)}`); - } - return `{${entries.join(",")}}`; -} - -describe("fetchScoreTableFromSite", () => { - const home = ` - - - - `; - - function route(files: Record) { - fetchMock.mockImplementation(async (url) => { - const path = new URL(url).pathname; - if (path === "/") return new Response(home); - return files[path] ?? new Response("missing", { status: 404 }); - }); - } - - it("keeps the bigger valid table among the scripts", async () => { - route({ - "/a.js": new Response(`var t=${table(1100, 1000)};`), - "/b.js": new Response(`var t=${table(1500, 5000)};`), - }); - - const compressed = await fetchScoreTableFromSite(); - - expect(compressed).not.toBeNull(); - expect(Object.keys(compressed!).every((key) => Number(key) >= 5000)).toBe( - true - ); - expect(Object.keys(compressed!).length).toBeGreaterThan(0); - }); - - it("resolves relative script urls against the site", async () => { - route({ - "/a.js": new Response(`var t=${table(1100, 1000)};`), - "/b.js": new Response("nothing"), - }); - - await fetchScoreTableFromSite(); - - const requested = fetchMock.mock.calls.map(([url]) => url); - expect(requested).toContain("https://www.rngdle.com/a.js"); - expect(requested).toContain("https://www.rngdle.com/b.js"); - }); - - it("skips scripts that fail to load", async () => { - route({ "/b.js": new Response(`var t=${table(1100, 1000)};`) }); - - expect(await fetchScoreTableFromSite()).not.toBeNull(); - }); - - it("returns null when no script contains a table", async () => { - route({ - "/a.js": new Response("var a={1:2}"), - "/b.js": new Response("console.log(1)"), - }); - - expect(await fetchScoreTableFromSite()).toBeNull(); - }); - - it("throws when the home page fails", async () => { - fetchMock.mockResolvedValueOnce(new Response("down", { status: 503 })); - - await expect(fetchScoreTableFromSite()).rejects.toThrow("503"); - }); -}); - -describe("fetchScoreTableFromSite plausibility", () => { - it("returns null when the compressed table is not plausible", async () => { - const entries: string[] = []; - for (let i = 0; i < 1500; i++) { - entries.push(`${1000 + i}:${(100 - (i / 1500) * 100).toFixed(4)}`); - } - fetchMock.mockImplementation(async (url) => { - const path = new URL(url).pathname; - if (path === "/") return new Response(''); - return new Response(`var t={${entries.join(",")}};`); - }); - - expect(await fetchScoreTableFromSite()).toBeNull(); - }); - - it("returns null when the table has too few distinct steps", async () => { - const entries: string[] = []; - for (let i = 0; i < 1500; i++) { - entries.push(`${1000 + i}:${i < 750 ? "10.1" : "10.2"}`); - } - fetchMock.mockImplementation(async (url) => { - const path = new URL(url).pathname; - if (path === "/") return new Response(''); - return new Response(`var t={${entries.join(",")}};`); - }); - - expect(await fetchScoreTableFromSite()).toBeNull(); - }); -}); diff --git a/src/modules/rngdle/services/rngdle-api.ts b/src/modules/rngdle/services/rngdle-api.ts deleted file mode 100644 index b933e1be..00000000 --- a/src/modules/rngdle/services/rngdle-api.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { - compressScoreTable, - extractScoreTable, - isPlausibleTable, -} from "./score-table.js"; -import type { CompressedTable } from "./score-table.js"; - -const SITE_URL = "https://www.rngdle.com"; -const REQUEST_TIMEOUT_MS = 30_000; -const FIRST_PAGE_SIZE = 10; -const PAGE_SIZE = 100; -const MAX_PAGES = 100; - -export interface RemoteRoll { - id: string; - number: number; - score: number; - badgeCount: number; - rolledAt: Date; -} - -interface RollsPage { - rolls: { - id: string; - number: number; - totalScore: number; - badgeCount?: number; - rolledAt: string; - }[]; - hasMore: boolean; -} - -export class RngdleUserNotFoundError extends Error { - constructor(readonly username: string) { - super(`RNGdle user not found: ${username}`); - } -} - -async function get(url: string): Promise { - const response = await fetch(url, { - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - headers: { Accept: "application/json, text/html, */*" }, - }); - return response; -} - -export async function fetchUserRolls( - username: string, - since: Date | null -): Promise { - const rolls: RemoteRoll[] = []; - let offset = 0; - let limit = FIRST_PAGE_SIZE; - - for (let page = 0; page < MAX_PAGES; page++) { - const url = `${SITE_URL}/api/users/${encodeURIComponent(username)}/rolls?limit=${limit}&offset=${offset}`; - const response = await get(url); - if (response.status === 404) { - throw new RngdleUserNotFoundError(username); - } - if (!response.ok) { - throw new Error( - `RNGdle API responded ${response.status} for ${username}` - ); - } - - const body = (await response.json()) as RollsPage; - for (const roll of body.rolls) { - rolls.push({ - id: roll.id, - number: roll.number, - score: roll.totalScore, - badgeCount: roll.badgeCount ?? 0, - rolledAt: new Date(roll.rolledAt), - }); - } - - const oldest = rolls.at(-1); - const reachedKnownRolls = - since !== null && oldest !== undefined && oldest.rolledAt <= since; - if (!body.hasMore || body.rolls.length === 0 || reachedKnownRolls) { - break; - } - offset += body.rolls.length; - limit = PAGE_SIZE; - } - - return rolls; -} - -export async function fetchScoreTableFromSite(): Promise { - const home = await get(SITE_URL); - if (!home.ok) { - throw new Error(`rngdle.com responded ${home.status}`); - } - const html = await home.text(); - const scripts = [ - ...html.matchAll(/]*\bsrc=["']([^"']+)["']/g), - ].map((match) => new URL(match[1]!, SITE_URL).href); - - let best: Map | null = null; - for (const script of scripts) { - const response = await get(script); - if (!response.ok) { - continue; - } - const table = extractScoreTable(await response.text()); - if (table && (!best || table.size > best.size)) { - best = table; - } - } - - if (!best) { - return null; - } - const compressed = compressScoreTable(best); - return isPlausibleTable(compressed) ? compressed : null; -} diff --git a/src/modules/rngdle/services/rngdle.service.test.ts b/src/modules/rngdle/services/rngdle.service.test.ts deleted file mode 100644 index 289543a4..00000000 --- a/src/modules/rngdle/services/rngdle.service.test.ts +++ /dev/null @@ -1,507 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { RemoteRoll } from "./rngdle-api.js"; - -interface AccountRow { - guildId: string; - userId: string; - username: string; -} - -interface RollRow { - guildId: string; - userId: string; - rollId: string; - number: number; - score: bigint; - badgeCount: number; - rolledAt: Date; -} - -const db = vi.hoisted(() => ({ - accounts: new Map(), - rolls: [] as unknown[], -})); - -const accounts = db.accounts as Map; -const accountKey = (guildId: string, userId: string) => `${guildId}:${userId}`; - -function rolls() { - return db.rolls as RollRow[]; -} - -vi.mock("#lib/database.js", () => ({ - default: { - async $transaction(operations: Promise[]) { - return Promise.all(operations); - }, - rngdleAccount: { - async findUnique({ - where, - }: { - where: { guildId_userId: { guildId: string; userId: string } }; - }) { - const { guildId, userId } = where.guildId_userId; - return accounts.get(accountKey(guildId, userId)) ?? null; - }, - async upsert({ - where, - create, - update, - }: { - where: { guildId_userId: { guildId: string; userId: string } }; - create: AccountRow; - update: Partial; - }) { - const { guildId, userId } = where.guildId_userId; - const key = accountKey(guildId, userId); - const existing = accounts.get(key); - const row = existing ? { ...existing, ...update } : create; - accounts.set(key, row); - return row; - }, - async deleteMany({ - where, - }: { - where: { guildId: string; userId: string }; - }) { - const removed = accounts.delete( - accountKey(where.guildId, where.userId) - ); - return { count: removed ? 1 : 0 }; - }, - }, - rngdleRoll: { - async deleteMany({ - where, - }: { - where: { guildId: string; userId?: string }; - }) { - const before = rolls().length; - db.rolls = rolls().filter( - (row) => - !( - row.guildId === where.guildId && - (where.userId === undefined || row.userId === where.userId) - ) - ); - return { count: before - rolls().length }; - }, - async findMany({ - where, - orderBy, - }: { - where: { - guildId: string; - userId?: string; - rollId?: { in: string[] }; - rolledAt?: { gte: Date; lt: Date }; - }; - orderBy?: unknown; - }) { - const matching = rolls().filter( - (row) => - row.guildId === where.guildId && - (where.userId === undefined || row.userId === where.userId) && - (where.rollId === undefined || - where.rollId.in.includes(row.rollId)) && - (where.rolledAt === undefined || - (row.rolledAt >= where.rolledAt.gte && - row.rolledAt < where.rolledAt.lt)) - ); - if (Array.isArray(orderBy)) { - matching.sort( - (a, b) => - Number(b.score - a.score) || - a.rolledAt.getTime() - b.rolledAt.getTime() - ); - } else if (orderBy) { - matching.sort((a, b) => a.rolledAt.getTime() - b.rolledAt.getTime()); - } - return matching.map((row) => ({ ...row })); - }, - async groupBy({ where }: { where: { guildId: string } }) { - const sums = new Map(); - for (const row of rolls()) { - if (row.guildId === where.guildId) { - sums.set(row.userId, (sums.get(row.userId) ?? 0n) + row.score); - } - } - return [...sums].map(([userId, sum]) => ({ - userId, - _sum: { score: sum }, - })); - }, - async createMany({ - data, - skipDuplicates, - }: { - data: RollRow[]; - skipDuplicates?: boolean; - }) { - let count = 0; - for (const row of data) { - const duplicate = rolls().some( - (existing) => - existing.guildId === row.guildId && existing.rollId === row.rollId - ); - if (duplicate) { - if (!skipDuplicates) throw new Error("unique constraint"); - continue; - } - rolls().push({ ...row }); - count++; - } - return { count }; - }, - async update({ - where, - data, - }: { - where: { guildId_rollId: { guildId: string; rollId: string } }; - data: Partial; - }) { - const { guildId, rollId } = where.guildId_rollId; - const row = rolls().find( - (existing) => - existing.guildId === guildId && existing.rollId === rollId - ); - if (!row) throw new Error("record not found"); - Object.assign(row, data); - return row; - }, - }, - }, -})); - -const { default: service } = await import("./rngdle.service.js"); - -let guild = 0; -let guildId = ""; - -beforeEach(() => { - db.accounts.clear(); - db.rolls = []; - guildId = `guild-${++guild}`; -}); - -const account = (userId: string, id = guildId) => ({ - guildId: id, - userId, - username: `name-${userId}`, -}); - -function remote( - id: string, - score = 100, - badgeCount = 0, - number = 1 -): RemoteRoll { - return { - id, - number, - score, - badgeCount, - rolledAt: new Date("2026-01-01T00:00:00.000Z"), - }; -} - -function rollIds(forGuild = guildId, userId?: string) { - return rolls() - .filter( - (row) => - row.guildId === forGuild && - (userId === undefined || row.userId === userId) - ) - .map((row) => row.rollId) - .sort(); -} - -describe("register", () => { - it("creates an account", async () => { - await expect(service.register(guildId, "u1", "alice")).resolves.toBe( - "created" - ); - - expect(accounts.get(accountKey(guildId, "u1"))?.username).toBe("alice"); - }); - - it("is unchanged when the username is the same", async () => { - await service.register(guildId, "u1", "alice"); - const revision = service.getRevision(guildId); - await service.saveRolls(account("u1"), [remote("r1")]); - const afterSave = service.getRevision(guildId); - - await expect(service.register(guildId, "u1", "alice")).resolves.toBe( - "unchanged" - ); - - expect(rollIds()).toEqual(["r1"]); - expect(service.getRevision(guildId)).toBe(afterSave); - expect(afterSave).toBeGreaterThan(revision); - }); - - it("renames the account and deletes the previous rolls of that guild only", async () => { - const other = `${guildId}-other`; - await service.register(guildId, "u1", "alice"); - await service.register(other, "u1", "alice"); - await service.register(guildId, "u2", "bob"); - await service.saveRolls(account("u1"), [remote("a1"), remote("a2")]); - await service.saveRolls(account("u1", other), [remote("b1")]); - await service.saveRolls(account("u2"), [remote("c1")]); - - await expect(service.register(guildId, "u1", "alice2")).resolves.toBe( - "renamed" - ); - - expect(accounts.get(accountKey(guildId, "u1"))?.username).toBe("alice2"); - expect(rollIds(guildId, "u1")).toEqual([]); - expect(rollIds(guildId, "u2")).toEqual(["c1"]); - expect(rollIds(other, "u1")).toEqual(["b1"]); - }); - - it("bumps the revision when something changes", async () => { - const before = service.getRevision(guildId); - - await service.register(guildId, "u1", "alice"); - - expect(service.getRevision(guildId)).toBeGreaterThan(before); - }); -}); - -describe("unregister", () => { - it("deletes the account and its rolls", async () => { - await service.register(guildId, "u1", "alice"); - await service.register(guildId, "u2", "bob"); - await service.saveRolls(account("u1"), [remote("a1")]); - await service.saveRolls(account("u2"), [remote("c1")]); - const before = service.getRevision(guildId); - - await expect(service.unregister(guildId, "u1")).resolves.toBe(true); - - expect(accounts.has(accountKey(guildId, "u1"))).toBe(false); - expect(rollIds()).toEqual(["c1"]); - expect(service.getRevision(guildId)).toBeGreaterThan(before); - }); - - it("returns false and keeps the revision when the account is absent", async () => { - const before = service.getRevision(guildId); - - await expect(service.unregister(guildId, "ghost")).resolves.toBe(false); - - expect(service.getRevision(guildId)).toBe(before); - }); -}); - -describe("saveRolls", () => { - beforeEach(async () => { - await service.register(guildId, "u1", "alice"); - }); - - it("returns zeros for an empty list without touching the revision", async () => { - const before = service.getRevision(guildId); - - await expect(service.saveRolls(account("u1"), [])).resolves.toEqual({ - inserted: 0, - updated: 0, - }); - - expect(service.getRevision(guildId)).toBe(before); - }); - - it("inserts new rolls with their owner", async () => { - const result = await service.saveRolls(account("u1"), [ - remote("r1", 10, 1, 5), - remote("r2", 20), - ]); - - expect(result).toEqual({ inserted: 2, updated: 0 }); - expect(rolls()).toHaveLength(2); - expect(rolls()[0]).toMatchObject({ - guildId, - userId: "u1", - rollId: "r1", - number: 5, - score: 10n, - badgeCount: 1, - }); - }); - - it("updates only the rolls whose score or badge count changed", async () => { - await service.saveRolls(account("u1"), [ - remote("r1", 10, 0), - remote("r2", 20, 0), - remote("r3", 30, 0), - ]); - - const result = await service.saveRolls(account("u1"), [ - remote("r1", 10, 0), - remote("r2", 21, 0), - remote("r3", 30, 2), - remote("r4", 40, 0), - ]); - - expect(result).toEqual({ inserted: 1, updated: 2 }); - const byId = new Map(rolls().map((row) => [row.rollId, row])); - expect(byId.get("r1")).toMatchObject({ score: 10n, badgeCount: 0 }); - expect(byId.get("r2")).toMatchObject({ score: 21n, badgeCount: 0 }); - expect(byId.get("r3")).toMatchObject({ score: 30n, badgeCount: 2 }); - expect(byId.get("r4")).toMatchObject({ score: 40n, badgeCount: 0 }); - }); - - it("is idempotent", async () => { - const batch = [remote("r1", 10, 1), remote("r2", 20, 0)]; - await service.saveRolls(account("u1"), batch); - const revision = service.getRevision(guildId); - - const result = await service.saveRolls(account("u1"), batch); - - expect(result).toEqual({ inserted: 0, updated: 0 }); - expect(rolls()).toHaveLength(2); - expect(service.getRevision(guildId)).toBe(revision); - }); - - it("treats the same roll id in another guild as a new roll", async () => { - const other = `${guildId}-other`; - await service.saveRolls(account("u1"), [remote("r1")]); - - const result = await service.saveRolls(account("u1", other), [ - remote("r1"), - ]); - - expect(result).toEqual({ inserted: 1, updated: 0 }); - expect(rolls()).toHaveLength(2); - }); -}); - -describe("getRevision", () => { - it("starts at 0 and is tracked per guild", async () => { - const other = `${guildId}-other`; - - expect(service.getRevision(guildId)).toBe(0); - - await service.register(guildId, "u1", "alice"); - - expect(service.getRevision(guildId)).toBe(1); - expect(service.getRevision(other)).toBe(0); - }); - - it("increases on every data change", async () => { - const seen = [service.getRevision(guildId)]; - const record = () => seen.push(service.getRevision(guildId)); - - await service.register(guildId, "u1", "alice"); - record(); - await service.saveRolls(account("u1"), [remote("r1", 10)]); - record(); - await service.saveRolls(account("u1"), [remote("r1", 11)]); - record(); - await service.clearRolls(guildId); - record(); - await service.unregister(guildId, "u1"); - record(); - - expect(seen).toEqual([0, 1, 2, 3, 4, 5]); - }); -}); - -describe("clearRolls", () => { - it("only removes the rolls of its guild and keeps accounts", async () => { - const other = `${guildId}-other`; - await service.register(guildId, "u1", "alice"); - await service.register(other, "u1", "alice"); - await service.saveRolls(account("u1"), [remote("a1"), remote("a2")]); - await service.saveRolls(account("u1", other), [remote("b1")]); - const otherRevision = service.getRevision(other); - - await expect(service.clearRolls(guildId)).resolves.toBe(2); - - expect(rollIds(guildId)).toEqual([]); - expect(rollIds(other)).toEqual(["b1"]); - expect(accounts.has(accountKey(guildId, "u1"))).toBe(true); - expect(service.getRevision(other)).toBe(otherRevision); - }); -}); - -describe("bigint scores", () => { - const BIG = 9_007_199_254_740_000; - - beforeEach(async () => { - await service.register(guildId, "u1", "alice"); - await service.register(guildId, "u2", "bob"); - }); - - const at = (id: string, score: number, day: number): RemoteRoll => ({ - id, - number: day, - score, - badgeCount: 0, - rolledAt: new Date(Date.UTC(2026, 0, day)), - }); - - it("stores scores as bigint", async () => { - await service.saveRolls(account("u1"), [at("r1", 5_000_000_000, 1)]); - - expect(rolls()[0]!.score).toBe(5_000_000_000n); - }); - - it("returns scores as numbers from userRolls and guildRolls", async () => { - await service.saveRolls(account("u1"), [ - at("r1", 5_000_000_000, 1), - at("r2", 7, 2), - ]); - await service.saveRolls(account("u2"), [at("r3", BIG, 3)]); - - const user = await service.userRolls(guildId, "u1"); - const all = await service.guildRolls(guildId); - - expect(user.map((roll) => roll.score)).toEqual([5_000_000_000, 7]); - expect(all.map((roll) => roll.score)).toEqual([5_000_000_000, 7, BIG]); - expect(typeof all[0]!.score).toBe("number"); - }); - - it("returns scores as numbers from rollsBetween, best first", async () => { - await service.saveRolls(account("u1"), [ - at("r1", 10, 1), - at("r2", 5_000_000_000, 2), - at("r3", 99, 5), - ]); - - const between = await service.rollsBetween( - guildId, - new Date(Date.UTC(2026, 0, 1)), - new Date(Date.UTC(2026, 0, 3)) - ); - - expect(between.map((roll) => roll.score)).toEqual([5_000_000_000, 10]); - }); - - it("compares stored bigint with incoming number when saving", async () => { - await service.saveRolls(account("u1"), [at("r1", 5_000_000_000, 1)]); - const revision = service.getRevision(guildId); - - const same = await service.saveRolls(account("u1"), [ - at("r1", 5_000_000_000, 1), - ]); - const changed = await service.saveRolls(account("u1"), [ - at("r1", 5_000_000_001, 1), - ]); - - expect(same).toEqual({ inserted: 0, updated: 0 }); - expect(changed).toEqual({ inserted: 0, updated: 1 }); - expect(rolls()[0]!.score).toBe(5_000_000_001n); - expect(service.getRevision(guildId)).toBe(revision + 1); - }); - - it("sums totals as numbers, best user first", async () => { - await service.saveRolls(account("u1"), [ - at("r1", 4_000_000_000, 1), - at("r2", 4_000_000_000, 2), - ]); - await service.saveRolls(account("u2"), [at("r3", 100, 3)]); - - await expect(service.totalsByUser(guildId)).resolves.toEqual([ - { userId: "u1", total: 8_000_000_000 }, - { userId: "u2", total: 100 }, - ]); - }); -}); diff --git a/src/modules/rngdle/services/score-table.service.ts b/src/modules/rngdle/services/score-table.service.ts deleted file mode 100644 index 89eef50e..00000000 --- a/src/modules/rngdle/services/score-table.service.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { readFileSync } from "node:fs"; -import prisma from "#lib/database.js"; -import { loggerMaker } from "#lib/logger.js"; -import { declareService, type Service } from "#lib/service.js"; -import { fetchScoreTableFromSite } from "./rngdle-api.js"; -import { ScoreTable, type CompressedTable } from "./score-table.js"; - -const logger = loggerMaker("rngdle"); - -const SNAPSHOT = new URL("../assets/score-table.json", import.meta.url); - -function loadSnapshot(): CompressedTable { - return JSON.parse(readFileSync(SNAPSHOT, "utf8")) as CompressedTable; -} - -class ScoreTableService implements Service { - private current: Promise | null = null; - - get(): Promise { - if (!this.current) { - this.current = this.load(); - this.current.catch(() => { - this.current = null; - }); - } - return this.current; - } - - private async load(): Promise { - const stored = await prisma.rngdleScoreTable.findUnique({ - where: { id: 1 }, - }); - return new ScoreTable( - stored ? (stored.data as CompressedTable) : loadSnapshot() - ); - } - - async refresh(): Promise { - const fetched = await fetchScoreTableFromSite(); - if (!fetched) { - logger.warn( - "Score table not found on rngdle.com, keeping the current one" - ); - return false; - } - - const current = await this.get(); - if (current.equals(fetched)) { - return false; - } - - await prisma.rngdleScoreTable.upsert({ - where: { id: 1 }, - create: { id: 1, data: fetched }, - update: { data: fetched }, - }); - this.current = Promise.resolve(new ScoreTable(fetched)); - logger.info( - `Score table updated | entries = ${Object.keys(fetched).length}` - ); - return true; - } -} - -export default declareService(new ScoreTableService()); diff --git a/src/modules/rngdle/services/score-table.test.ts b/src/modules/rngdle/services/score-table.test.ts deleted file mode 100644 index fd18dc87..00000000 --- a/src/modules/rngdle/services/score-table.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { - compressScoreTable, - extractScoreTable, - formatPercent, - isPlausibleTable, - ScoreTable, - type CompressedTable, -} from "./score-table.js"; - -function bigTable(size: number) { - const entries: string[] = []; - for (let i = 0; i < size; i++) { - const key = 100_000 + i; - const rendered = - i === 0 ? "1e5" : i === 1 ? `0x${key.toString(16)}` : String(key); - const value = i === 2 ? ".0083" : "0.5"; - entries.push(`${rendered}:${value}`); - } - return `{${entries.join(",")}}`; -} - -describe("extractScoreTable", () => { - it("finds the table inside a larger blob and parses exotic notations", () => { - const source = `var a=function(){return 1};const t=${bigTable(1200)};export{t};`; - - const table = extractScoreTable(source); - - expect(table).not.toBeNull(); - expect(table!.size).toBe(1200); - expect(table!.get(100_000)).toBe(0.5); - expect(table!.get(100_001)).toBe(0.5); - expect(table!.get(100_002)).toBe(0.0083); - }); - - it("ignores small dict-like objects", () => { - expect( - extractScoreTable("var a={1:2,3:4,5:6};var b={0x10:1.5}") - ).toBeNull(); - }); - - it("returns null when there is no table", () => { - expect(extractScoreTable("console.log('hello')")).toBeNull(); - expect(extractScoreTable("")).toBeNull(); - }); - - it("ignores a table that is never closed", () => { - const unclosed = bigTable(1200).slice(0, -1); - - expect(extractScoreTable(`var a=${unclosed}`)).toBeNull(); - }); - - it("keeps the biggest valid table", () => { - const source = `var a=${bigTable(1100)};var b=${bigTable(1500)};var c=${bigTable(1050)};var d={1:2}`; - - expect(extractScoreTable(source)!.size).toBe(1500); - }); -}); - -describe("compressScoreTable", () => { - it("truncates to 0.5% steps and keeps the lowest score per step", () => { - const table = new Map([ - [100, 0.1], - [200, 0.4], - [50, 0.7], - [300, 0.6], - [400, 1.2], - [500, 99.99], - ]); - - expect(compressScoreTable(table)).toEqual({ - "100": 0, - "50": 0.5, - "400": 1, - "500": 99.5, - }); - }); - - it("returns an empty object for an empty table", () => { - expect(compressScoreTable(new Map())).toEqual({}); - }); -}); - -describe("ScoreTable", () => { - const table = new ScoreTable({ "100": 10, "200": 50, "300": 90 }); - - it("throws on an empty table", () => { - expect(() => new ScoreTable({})).toThrow("empty"); - }); - - describe("percentOf", () => { - it("returns the percent of an exact key", () => { - expect(table.percentOf(200)).toBe(50); - }); - - it("returns the percent of the closest lower key between keys", () => { - expect(table.percentOf(250)).toBe(50); - expect(table.percentOf(101)).toBe(10); - }); - - it("returns the lowest percent below the lowest key", () => { - expect(table.percentOf(5)).toBe(10); - expect(table.percentOf(-50)).toBe(10); - }); - - it("returns the highest percent above the highest key", () => { - expect(table.percentOf(10_000)).toBe(90); - }); - - it("sorts keys numerically", () => { - const unsorted = new ScoreTable({ "1000": 80, "9": 5, "100": 40 }); - - expect(unsorted.percentOf(500)).toBe(40); - expect(unsorted.percentOf(1000)).toBe(80); - }); - }); - - describe("tierOf", () => { - const tierAt = (percent: number) => - new ScoreTable({ "0": percent }).tierOf(0); - - it.each([ - [0, "TRASH"], - [0.99, "TRASH"], - [1, "COMMON"], - [49.5, "COMMON"], - [50, "UNCOMMON"], - [74.5, "UNCOMMON"], - [75, "RARE"], - [89.5, "RARE"], - [90, "EPIC"], - [94.5, "EPIC"], - [95, "ANOMALY"], - [98.5, "ANOMALY"], - [99, "MYTHIC"], - [99.5, "MYTHIC"], - [100, "ERROR"], - [-1, "ERROR"], - ])("maps %s%% to %s", (percent, tier) => { - expect(tierAt(percent)).toBe(tier); - }); - - it("returns ERROR for a negative score", () => { - expect(table.tierOf(-1)).toBe("ERROR"); - }); - - it("returns ERROR for a negative score even when the percent is valid", () => { - expect(new ScoreTable({ "-5": 50 }).tierOf(-1)).toBe("ERROR"); - }); - }); - - describe("equals", () => { - it("is true for the same content", () => { - expect(table.equals({ "300": 90, "100": 10, "200": 50 })).toBe(true); - }); - - it("is false for a different value", () => { - expect(table.equals({ "100": 10, "200": 51, "300": 90 })).toBe(false); - }); - - it("is false for a different size", () => { - expect(table.equals({ "100": 10, "200": 50 })).toBe(false); - expect(table.equals({ "100": 10, "200": 50, "300": 90, "400": 95 })).toBe( - false - ); - }); - - it("is false for different keys with the same size", () => { - expect(table.equals({ "100": 10, "200": 50, "301": 90 })).toBe(false); - }); - }); -}); - -describe("formatPercent", () => { - it.each([ - [99.5, "0%"], - [99, "1%"], - [90.5, "9%"], - [50.5, "49%"], - [50, "50%"], - [49.5, "50%"], - [0.5, "1%"], - [0, "0%"], - ])("formats %s as %s", (percent, text) => { - expect(formatPercent(percent)).toBe(text); - }); -}); - -describe("real score table snapshot", () => { - const snapshot = JSON.parse( - readFileSync(new URL("../assets/score-table.json", import.meta.url), "utf8") - ) as CompressedTable; - const table = new ScoreTable(snapshot); - - it("maps a very high score to MYTHIC", () => { - expect(table.tierOf(131_081_274)).toBe("MYTHIC"); - }); - - it("maps a zero score to TRASH", () => { - expect(table.tierOf(0)).toBe("TRASH"); - }); - - it("never lowers the percent as the score grows", () => { - let previous = -1; - for (const score of [0, 2_000, 10_000, 50_000, 162_292, 1_000_000]) { - const percent = table.percentOf(score); - expect(percent).toBeGreaterThanOrEqual(previous); - previous = percent; - } - }); - - it("round-trips through equals", () => { - expect(table.equals(snapshot)).toBe(true); - }); -}); - -describe("isPlausibleTable", () => { - const build = (size: number, percent = (i: number) => i * 2) => - Object.fromEntries( - Array.from({ length: size }, (_, i) => [String(i * 100), percent(i)]) - ) as CompressedTable; - - it("accepts a monotonic table of 20 entries", () => { - expect(isPlausibleTable(build(20))).toBe(true); - }); - - it("accepts equal consecutive percents", () => { - expect(isPlausibleTable(build(30, () => 50))).toBe(true); - }); - - it("accepts the bounds 0 and 100", () => { - expect(isPlausibleTable(build(21, (i) => (i * 100) / 20))).toBe(true); - }); - - it("rejects fewer than 20 entries", () => { - expect(isPlausibleTable(build(19))).toBe(false); - expect(isPlausibleTable({})).toBe(false); - }); - - it("rejects a percent above 100", () => { - expect(isPlausibleTable(build(25, (i) => (i === 24 ? 100.5 : i)))).toBe( - false - ); - }); - - it("rejects a negative percent", () => { - expect(isPlausibleTable(build(25, (i) => (i === 0 ? -1 : i)))).toBe(false); - }); - - it("rejects percents decreasing as the score increases", () => { - expect(isPlausibleTable(build(25, (i) => (i === 10 ? 1 : i * 2)))).toBe( - false - ); - }); - - it("accepts the real snapshot", () => { - const snapshot = JSON.parse( - readFileSync( - new URL("../assets/score-table.json", import.meta.url), - "utf8" - ) - ) as CompressedTable; - - expect(isPlausibleTable(snapshot)).toBe(true); - }); -}); diff --git a/src/modules/rngdle/services/scoring.test.ts b/src/modules/rngdle/services/scoring.test.ts new file mode 100644 index 00000000..86cf7958 --- /dev/null +++ b/src/modules/rngdle/services/scoring.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from "vitest"; +import { + compressScoreTable, + extractScoreTable, + formatPercent, + isPlausibleTable, + ScoreTable, + type CompressedTable, +} from "./scoring.js"; + +function bigTable(size: number) { + const entries = Array.from({ length: size }, (_, i) => { + const key = 100_000 + i; + const rendered = + i === 0 ? "1e5" : i === 1 ? `0x${key.toString(16)}` : String(key); + return `${rendered}:${i === 2 ? ".0083" : "0.5"}`; + }); + return `{${entries.join(",")}}`; +} + +describe("extractScoreTable", () => { + it("finds the table in a larger blob, parses exotic notations and keeps the biggest", () => { + const source = `var a=${bigTable(1100)};var b=${bigTable(1500)};var d={1:2}`; + + const table = extractScoreTable(source)!; + + expect(table.size).toBe(1500); + expect(table.get(100_000)).toBe(0.5); + expect(table.get(100_001)).toBe(0.5); + expect(table.get(100_002)).toBe(0.0083); + }); + + it("ignores small objects, unclosed tables and sources without a table", () => { + expect(extractScoreTable("var a={1:2,3:4};var b={0x10:1.5}")).toBeNull(); + expect( + extractScoreTable(`var a=${bigTable(1200).slice(0, -1)}`) + ).toBeNull(); + expect(extractScoreTable("console.log('hello')")).toBeNull(); + }); +}); + +describe("compressScoreTable", () => { + it("truncates to 0.5% steps and keeps the lowest score per step", () => { + const table = new Map([ + [100, 0.1], + [200, 0.4], + [50, 0.7], + [300, 0.6], + [400, 1.2], + [500, 99.99], + ]); + + expect(compressScoreTable(table)).toEqual({ + "100": 0, + "50": 0.5, + "400": 1, + "500": 99.5, + }); + }); +}); + +describe("ScoreTable", () => { + const table = new ScoreTable({ "100": 10, "200": 50, "300": 90 }); + + it("throws on an empty table", () => { + expect(() => new ScoreTable({})).toThrow("empty"); + }); + + it("percentOf uses the closest lower key, clamping at both ends, with numeric key order", () => { + expect(table.percentOf(200)).toBe(50); + expect(table.percentOf(250)).toBe(50); + expect(table.percentOf(-50)).toBe(10); + expect(table.percentOf(10_000)).toBe(90); + expect( + new ScoreTable({ "1000": 80, "9": 5, "100": 40 }).percentOf(500) + ).toBe(40); + }); + + it.each([ + [0.99, "TRASH"], + [1, "COMMON"], + [50, "UNCOMMON"], + [75, "RARE"], + [90, "EPIC"], + [95, "ANOMALY"], + [99, "MYTHIC"], + [100, "ERROR"], + [-1, "ERROR"], + ])("tierOf maps %s%% to %s", (percent, tier) => { + expect(new ScoreTable({ "0": percent }).tierOf(0)).toBe(tier); + }); + + it("tierOf returns ERROR for a negative score even when the percent is valid", () => { + expect(new ScoreTable({ "-5": 50 }).tierOf(-1)).toBe("ERROR"); + }); + + it("equals compares keys and values", () => { + expect(table.equals({ "300": 90, "100": 10, "200": 50 })).toBe(true); + expect(table.equals({ "100": 10, "200": 51, "300": 90 })).toBe(false); + expect(table.equals({ "100": 10, "200": 50 })).toBe(false); + expect(table.equals({ "100": 10, "200": 50, "301": 90 })).toBe(false); + }); +}); + +describe("formatPercent", () => { + it.each([ + [90.5, "9%"], + [50.5, "49%"], + [50, "50%"], + [49.5, "50%"], + [0.5, "1%"], + ])("formats %s as %s", (percent, text) => { + expect(formatPercent(percent)).toBe(text); + }); +}); + +describe("isPlausibleTable", () => { + const build = (size: number, percent = (i: number) => i * 2) => + Object.fromEntries( + Array.from({ length: size }, (_, i) => [String(i * 100), percent(i)]) + ) as CompressedTable; + + it("accepts monotonic tables of 20+ entries, equal percents and the 0 and 100 bounds", () => { + expect(isPlausibleTable(build(20))).toBe(true); + expect(isPlausibleTable(build(30, () => 50))).toBe(true); + expect(isPlausibleTable(build(21, (i) => (i * 100) / 20))).toBe(true); + }); + + it("rejects short, out-of-range and decreasing tables", () => { + expect(isPlausibleTable(build(19))).toBe(false); + expect(isPlausibleTable(build(25, (i) => (i === 24 ? 100.5 : i)))).toBe( + false + ); + expect(isPlausibleTable(build(25, (i) => (i === 0 ? -1 : i)))).toBe(false); + expect(isPlausibleTable(build(25, (i) => (i === 10 ? 1 : i * 2)))).toBe( + false + ); + }); +}); diff --git a/src/modules/rngdle/services/score-table.ts b/src/modules/rngdle/services/scoring.ts similarity index 100% rename from src/modules/rngdle/services/score-table.ts rename to src/modules/rngdle/services/scoring.ts diff --git a/src/modules/rngdle/services/stats.test.ts b/src/modules/rngdle/services/stats.test.ts index 72247d1e..3921652c 100644 --- a/src/modules/rngdle/services/stats.test.ts +++ b/src/modules/rngdle/services/stats.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import type { StoredRoll } from "./rngdle.service.js"; -import { ScoreTable } from "./score-table.js"; +import { ScoreTable } from "./scoring.js"; import { computeRollStats, computeServerStats } from "./stats.js"; +import type { StoredRoll } from "./store.js"; const table = new ScoreTable({ "0": 0, @@ -14,12 +14,8 @@ const table = new ScoreTable({ }); let counter = 0; -function roll( - userId: string, - score: number, - badgeCount = 0, - number = ++counter -): StoredRoll { +function roll(userId: string, score: number, badgeCount = 0): StoredRoll { + const number = ++counter; return { userId, number, @@ -34,72 +30,40 @@ describe("computeRollStats", () => { expect(computeRollStats([], table)).toBeNull(); }); - it("aggregates a single roll", () => { - const only = roll("a", 250, 2, 7); - - const stats = computeRollStats([only], table)!; - - expect(stats.totalRolls).toBe(1); - expect(stats.totalScore).toBe(250); - expect(stats.averageScore).toBe(250); - expect(stats.best).toEqual({ - userId: "a", - number: 7, - score: 250, - rolledAt: only.rolledAt, - }); - expect(stats.worst).toEqual(stats.best); - expect(stats.maxBadges).toBe(2); - }); - - it("computes totals, truncated average, extremes and max badges", () => { + it("computes totals, truncated average, extremes (first on ties) and max badges", () => { const rolls = [ roll("a", 100, 1), roll("b", 601, 5), roll("a", 50, 3), roll("c", 10, 0), + roll("d", 601, 0), + roll("e", 10, 0), ]; const stats = computeRollStats(rolls, table)!; - expect(stats.totalRolls).toBe(4); - expect(stats.totalScore).toBe(761); - expect(stats.averageScore).toBe(190); - expect(stats.best.score).toBe(601); - expect(stats.best.userId).toBe("b"); - expect(stats.worst.score).toBe(10); + expect(stats).toMatchObject({ + totalRolls: 6, + totalScore: 1372, + averageScore: 228, + maxBadges: 5, + }); + expect(stats.best).toEqual({ + userId: "b", + number: rolls[1]!.number, + score: 601, + rolledAt: rolls[1]!.rolledAt, + }); expect(stats.worst.userId).toBe("c"); - expect(stats.maxBadges).toBe(5); - }); - - it("picks the first occurrence on ties", () => { - const rolls = [ - roll("a", 300, 0, 1), - roll("b", 300, 0, 2), - roll("c", 300, 0, 3), - ]; - - const stats = computeRollStats(rolls, table)!; - - expect(stats.best.userId).toBe("a"); - expect(stats.worst.userId).toBe("a"); }); - it("counts tiers and excludes ERROR rolls", () => { - const rolls = [ - roll("a", 0), - roll("a", 5), - roll("a", 100), - roll("a", 250), - roll("a", 350), - roll("a", 450), - roll("a", 550), - roll("a", 650), - roll("a", 700), - roll("a", -1), - ]; + it("counts every tier, with zeros, and leaves ERROR rolls out of the tiers", () => { + const scores = [0, 5, 100, 250, 350, 450, 550, 650, 700, -1]; - const stats = computeRollStats(rolls, table)!; + const stats = computeRollStats( + scores.map((score) => roll("a", score)), + table + )!; expect(stats.totalRolls).toBe(10); expect(stats.tierCounts).toEqual({ @@ -112,20 +76,6 @@ describe("computeRollStats", () => { MYTHIC: 2, }); }); - - it("reports every tier with a zero count when nothing matches", () => { - const stats = computeRollStats([roll("a", 100)], table)!; - - expect(stats.tierCounts).toEqual({ - TRASH: 0, - COMMON: 1, - UNCOMMON: 0, - RARE: 0, - EPIC: 0, - ANOMALY: 0, - MYTHIC: 0, - }); - }); }); describe("computeServerStats", () => { @@ -133,61 +83,25 @@ describe("computeServerStats", () => { expect(computeServerStats([], table)).toBeNull(); }); - it("includes the roll stats", () => { - const stats = computeServerStats([roll("a", 100), roll("b", 300)], table)!; - - expect(stats.totalRolls).toBe(2); - expect(stats.best.userId).toBe("b"); - }); - - it("orders tier leaders by count and gives [] to empty tiers", () => { + it("includes the roll stats and orders tier leaders by count, at most 3, skipping ERROR rolls", () => { const rolls = [ roll("a", 100), - roll("b", 110), - roll("b", 120), - roll("c", 130), - roll("c", 140), - roll("c", 150), - roll("a", 650), - ]; - - const { tierLeaders } = computeServerStats(rolls, table)!; - - expect(tierLeaders.COMMON).toEqual(["c", "b", "a"]); - expect(tierLeaders.MYTHIC).toEqual(["a"]); - expect(tierLeaders.TRASH).toEqual([]); - expect(tierLeaders.UNCOMMON).toEqual([]); - expect(tierLeaders.RARE).toEqual([]); - expect(tierLeaders.EPIC).toEqual([]); - expect(tierLeaders.ANOMALY).toEqual([]); - }); - - it("keeps at most 3 leaders per tier", () => { - const rolls = [ - roll("a", 100), - roll("a", 100), - roll("a", 100), - roll("a", 100), - roll("b", 100), roll("b", 100), roll("b", 100), roll("c", 100), roll("c", 100), + roll("c", 100), roll("d", 100), - roll("e", 100), + roll("a", 650), + roll("z", -10), ]; - const { tierLeaders } = computeServerStats(rolls, table)!; - - expect(tierLeaders.COMMON).toEqual(["a", "b", "c"]); - }); - - it("ignores ERROR rolls for leaders", () => { - const { tierLeaders } = computeServerStats( - [roll("a", -10), roll("b", 100)], - table - )!; + const stats = computeServerStats(rolls, table)!; - expect(Object.values(tierLeaders).flat()).toEqual(["b"]); + expect(stats.totalRolls).toBe(9); + expect(stats.tierLeaders.COMMON).toEqual(["c", "b", "a"]); + expect(stats.tierLeaders.MYTHIC).toEqual(["a"]); + expect(stats.tierLeaders.TRASH).toEqual([]); + expect(Object.values(stats.tierLeaders).flat()).not.toContain("z"); }); }); diff --git a/src/modules/rngdle/services/stats.ts b/src/modules/rngdle/services/stats.ts index d570fec9..66892cd9 100644 --- a/src/modules/rngdle/services/stats.ts +++ b/src/modules/rngdle/services/stats.ts @@ -1,5 +1,5 @@ -import type { StoredRoll } from "./rngdle.service.js"; -import { TIERS, type ScoreTable, type Tier } from "./score-table.js"; +import { TIERS, type ScoreTable, type Tier } from "./scoring.js"; +import type { StoredRoll } from "./store.js"; export interface RollSummary { userId: string; diff --git a/src/modules/rngdle/services/store.test.ts b/src/modules/rngdle/services/store.test.ts new file mode 100644 index 00000000..25029ae1 --- /dev/null +++ b/src/modules/rngdle/services/store.test.ts @@ -0,0 +1,311 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Account } from "./store.js"; + +interface Row extends Record {} + +const db = vi.hoisted(() => { + const state = { + accounts: [] as Row[], + rolls: [] as Row[], + table: null as Row | null, + }; + + const matches = (row: Row, where: Row = {}) => + Object.entries(where).every(([key, cond]) => { + if (key.includes("_")) + return key.split("_").every((k) => row[k] === cond[k]); + if (cond instanceof Date || typeof cond !== "object") + return row[key] === cond; + const value = row[key]; + if ("in" in cond) return cond.in.includes(value); + if ("equals" in cond) + return `${value}`.toLowerCase() === cond.equals.toLowerCase(); + return ( + (cond.gte === undefined || value >= cond.gte) && + (cond.lt === undefined || value < cond.lt) + ); + }); + + const sorted = (rows: Row[], orderBy: Row | Row[] = []) => + rows.sort((a, b) => { + for (const order of [orderBy].flat()) { + const [key, dir] = Object.entries(order)[0] as [string, string]; + const diff = a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0; + if (diff) return dir === "desc" ? -diff : diff; + } + return 0; + }); + + const collection = (name: "accounts" | "rolls") => { + const find = ({ where, orderBy }: Row) => + sorted( + state[name].filter((row) => matches(row, where)), + orderBy + ); + return { + findMany: async (args: Row) => find(args), + findFirst: async (args: Row) => find(args)[0] ?? null, + findUnique: async (args: Row) => find(args)[0] ?? null, + deleteMany: async ({ where }: Row) => { + const removed = find({ where }); + state[name] = state[name].filter((row) => !removed.includes(row)); + return { count: removed.length }; + }, + }; + }; + + const prisma = { + $transaction: (operations: Promise[]) => Promise.all(operations), + rngdleAccount: { + ...collection("accounts"), + upsert: async ({ where, create, update }: Row) => { + const existing = state.accounts.find((row) => matches(row, where)); + if (existing) Object.assign(existing, update); + else state.accounts.push({ ...create }); + }, + }, + rngdleRoll: { + ...collection("rolls"), + createMany: async ({ data }: Row) => { + state.rolls.push(...data); + return { count: data.length }; + }, + update: async ({ where, data }: Row) => { + Object.assign( + state.rolls.find((row) => matches(row, where))!, + data + ); + }, + groupBy: async ({ where }: Row) => { + const sums = new Map(); + for (const row of state.rolls.filter((r) => matches(r, where))) { + sums.set( + row["userId"], + (sums.get(row["userId"]) ?? 0n) + row["score"] + ); + } + return [...sums].map(([userId, sum]) => ({ + userId, + _sum: { score: sum }, + })); + }, + }, + rngdleScoreTable: { + findUnique: async () => state.table, + upsert: async ({ create }: Row) => void (state.table = { ...create }), + }, + }; + + return { state, prisma }; +}); + +vi.mock("#lib/database.js", () => ({ default: db.prisma })); + +const account = (guildId: string, userId: string): Account => ({ + guildId, + userId, + username: userId, +}); +const alice = account("g1", "alice"); +const bob = account("g1", "bob"); +const zoe = account("g2", "zoe"); + +const remote = (id: string, score: number, badgeCount = 0, day = 1) => ({ + id, + number: day, + score, + badgeCount, + rolledAt: new Date(Date.UTC(2026, 0, day)), +}); + +let store: (typeof import("./store.js"))["default"]; + +async function seed(who: Account, ...rolls: ReturnType[]) { + await store.register(who.guildId, who.userId, who.username); + await store.saveRolls(who, rolls); +} + +const scores = async (guildId = "g1", filter = {}) => + (await store.rolls(guildId, filter)).map((roll) => roll.score); + +beforeEach(async () => { + db.state.accounts = []; + db.state.rolls = []; + db.state.table = null; + vi.resetModules(); + store = (await import("./store.js")).default; +}); + +describe("accounts", () => { + it("reports created, unchanged and renamed, bumping the revision only on change", async () => { + expect(await store.register("g1", "alice", "Alice")).toBe("created"); + expect(await store.register("g1", "alice", "Alice")).toBe("unchanged"); + expect(store.revision("g1")).toBe(1); + expect(await store.register("g1", "alice", "Alicia")).toBe("renamed"); + expect(store.revision("g1")).toBe(2); + expect(store.revision("other")).toBe(0); + }); + + it("drops the rolls of an account when it is renamed", async () => { + await seed(alice, remote("r1", 10)); + await seed(bob, remote("r2", 20)); + + await store.register("g1", "alice", "Alicia"); + + expect(await scores()).toEqual([20]); + }); + + it("unregisters an account with its rolls, true only when something was removed", async () => { + await seed(alice, remote("r1", 10)); + const revision = store.revision("g1"); + + expect(await store.unregister("g1", "alice")).toBe(true); + expect(await scores()).toEqual([]); + expect(await store.account("g1", "alice")).toBeNull(); + expect(await store.unregister("g1", "alice")).toBe(false); + expect(store.revision("g1")).toBe(revision + 1); + }); + + it("lists accounts by username and finds them case-insensitively within a guild", async () => { + await store.register("g1", "u2", "zed"); + await store.register("g1", "u1", "Amy"); + await store.register("g2", "u3", "Amy"); + + const list = await store.accounts("g1"); + expect(list.map((a) => a.username)).toEqual(["Amy", "zed"]); + expect((await store.accountByUsername("g1", "ZED"))?.userId).toBe("u2"); + expect(await store.accountByUsername("g1", "nobody")).toBeNull(); + }); +}); + +describe("rolls", () => { + it("inserts new rolls, updates a changed score or badgeCount, ignores identical ones", async () => { + await seed( + alice, + remote("r1", 10), + remote("r2", 20, 1), + remote("r3", 30, 2) + ); + const revision = store.revision("g1"); + + expect(await store.saveRolls(alice, [])).toEqual({ + inserted: 0, + updated: 0, + }); + expect(await store.saveRolls(alice, [remote("r1", 10)])).toEqual({ + inserted: 0, + updated: 0, + }); + expect(store.revision("g1")).toBe(revision); + + const result = await store.saveRolls(alice, [ + remote("r2", 20, 5), + remote("r3", 31, 2), + remote("r4", 40, 0, 4), + ]); + + expect(result).toEqual({ inserted: 1, updated: 2 }); + expect(store.revision("g1")).toBe(revision + 1); + const stored = await store.rolls("g1"); + expect(stored.map((r) => [r.score, r.badgeCount])).toEqual([ + [10, 0], + [20, 5], + [31, 2], + [40, 0], + ]); + expect(typeof stored[0]!.score).toBe("number"); + }); + + it("finds the latest roll date of a user", async () => { + await seed( + alice, + remote("r1", 10, 0, 1), + remote("r2", 20, 0, 3), + remote("r3", 5, 0, 2) + ); + + expect(await store.latestRollDate("g1", "alice")).toEqual( + new Date(Date.UTC(2026, 0, 3)) + ); + expect(await store.latestRollDate("g1", "bob")).toBeNull(); + }); + + it("filters by user and by a half-open date range, and orders by score then date", async () => { + await seed(alice, remote("a1", 50, 0, 1), remote("a2", 70, 0, 2)); + await seed(bob, remote("b1", 70, 0, 1), remote("b2", 10, 0, 3)); + const range = { + from: new Date(Date.UTC(2026, 0, 1)), + to: new Date(Date.UTC(2026, 0, 3)), + }; + + expect(await scores("g1", { userId: "bob" })).toEqual([70, 10]); + expect(await scores("g1", range)).toEqual([50, 70, 70]); + const ranked = await store.rolls("g1", { ...range, byScore: true }); + expect(ranked.map((r) => r.userId)).toEqual(["bob", "alice", "alice"]); + }); + + it("sums totals per user, highest first, ties by user id", async () => { + await seed(alice, remote("a1", 5), remote("a2", 5)); + await seed(bob, remote("b1", 10)); + await seed(account("g1", "abe"), remote("c1", 90)); + await seed(zoe, remote("z1", 999)); + + expect(await store.totals("g1")).toEqual([ + { userId: "abe", total: 90 }, + { userId: "alice", total: 10 }, + { userId: "bob", total: 10 }, + ]); + }); + + it("clears the rolls of one guild only, keeping the accounts", async () => { + await seed(alice, remote("a1", 5)); + await seed(zoe, remote("z1", 5)); + const revision = store.revision("g1"); + + expect(await store.clearRolls("g1")).toBe(1); + + expect(await scores("g1")).toHaveLength(0); + expect(await scores("g2")).toHaveLength(1); + expect(await store.accounts("g1")).toHaveLength(1); + expect(store.revision("g1")).toBe(revision + 1); + }); +}); + +describe("score table", () => { + const stored = Object.fromEntries( + Array.from({ length: 25 }, (_, i) => [String(i * 100), i * 4]) + ); + + it("uses the stored table when it is valid", async () => { + db.state.table = { id: 1, data: stored }; + + expect((await store.scoreTable()).compressed).toEqual(stored); + }); + + it.each([ + ["missing", null], + ["not numeric", { id: 1, data: { ...stored, "5": "x" } }], + ["not monotonic", { id: 1, data: { ...stored, "0": 99 } }], + ])( + "falls back to the bundled snapshot when the stored table is %s", + async (_label, row) => { + db.state.table = row; + + const { compressed } = await store.scoreTable(); + + expect(compressed).not.toEqual(stored); + expect(Object.keys(compressed).length).toBeGreaterThan(20); + } + ); + + it("replaces the table only when it differs", async () => { + db.state.table = { id: 1, data: stored }; + + expect(await store.replaceScoreTable({ ...stored })).toBe(false); + + const updated = { ...stored, "0": 1 }; + expect(await store.replaceScoreTable(updated)).toBe(true); + expect(db.state.table!["data"]).toEqual(updated); + expect((await store.scoreTable()).compressed).toEqual(updated); + }); +}); diff --git a/src/modules/rngdle/services/rngdle.service.ts b/src/modules/rngdle/services/store.ts similarity index 54% rename from src/modules/rngdle/services/rngdle.service.ts rename to src/modules/rngdle/services/store.ts index 3dbe8799..fa1ffebd 100644 --- a/src/modules/rngdle/services/rngdle.service.ts +++ b/src/modules/rngdle/services/store.ts @@ -1,6 +1,14 @@ +import { readFileSync } from "node:fs"; import prisma from "#lib/database.js"; import { declareService, type Service } from "#lib/service.js"; -import type { RemoteRoll } from "./rngdle-api.js"; +import type { RemoteRoll } from "./api.js"; +import { + isPlausibleTable, + ScoreTable, + type CompressedTable, +} from "./scoring.js"; + +const SNAPSHOT = new URL("../assets/score-table.json", import.meta.url); export interface Account { guildId: string; @@ -16,40 +24,34 @@ export interface StoredRoll { rolledAt: Date; } -export interface UserTotal { - userId: string; - total: number; +export interface RollFilter { + userId?: string; + from?: Date; + to?: Date; + byScore?: boolean; } export type RegisterResult = "created" | "renamed" | "unchanged"; -const rollFields = { - userId: true, - number: true, - score: true, - badgeCount: true, - rolledAt: true, -} as const; - -function toStoredRoll(roll: { - userId: string; - number: number; - score: bigint; - badgeCount: number; - rolledAt: Date; -}): StoredRoll { - return { ...roll, score: Number(roll.score) }; +function loadTable(data: unknown): ScoreTable | null { + const valid = + typeof data === "object" && + data !== null && + Object.values(data).every((percent) => typeof percent === "number") && + isPlausibleTable(data as CompressedTable); + return valid ? new ScoreTable(data as CompressedTable) : null; } -class RngdleService implements Service { +class RngdleStore implements Service { private readonly revisions = new Map(); + private table: Promise | null = null; - getRevision(guildId: string): number { + revision(guildId: string): number { return this.revisions.get(guildId) ?? 0; } - private bumpRevision(guildId: string): void { - this.revisions.set(guildId, this.getRevision(guildId) + 1); + private touch(guildId: string): void { + this.revisions.set(guildId, this.revision(guildId) + 1); } async register( @@ -57,9 +59,8 @@ class RngdleService implements Service { userId: string, username: string ): Promise { - const existing = await prisma.rngdleAccount.findUnique({ - where: { guildId_userId: { guildId, userId } }, - }); + const key = { guildId_userId: { guildId, userId } }; + const existing = await prisma.rngdleAccount.findUnique({ where: key }); if (existing?.username === username) { return "unchanged"; } @@ -67,12 +68,12 @@ class RngdleService implements Service { await prisma.$transaction([ prisma.rngdleRoll.deleteMany({ where: { guildId, userId } }), prisma.rngdleAccount.upsert({ - where: { guildId_userId: { guildId, userId } }, + where: key, create: { guildId, userId, username }, update: { username }, }), ]); - this.bumpRevision(guildId); + this.touch(guildId); return existing ? "renamed" : "created"; } @@ -81,31 +82,26 @@ class RngdleService implements Service { prisma.rngdleRoll.deleteMany({ where: { guildId, userId } }), prisma.rngdleAccount.deleteMany({ where: { guildId, userId } }), ]); - if (deleted.count === 0) { - return false; + if (deleted.count > 0) { + this.touch(guildId); } - this.bumpRevision(guildId); - return true; + return deleted.count > 0; } - listAccounts(guildId: string): Promise { + accounts(guildId: string): Promise { return prisma.rngdleAccount.findMany({ where: { guildId }, orderBy: { username: "asc" }, }); } - listAllAccounts(): Promise { - return prisma.rngdleAccount.findMany(); - } - - getAccount(guildId: string, userId: string): Promise { + account(guildId: string, userId: string): Promise { return prisma.rngdleAccount.findUnique({ where: { guildId_userId: { guildId, userId } }, }); } - findAccountByUsername( + accountByUsername( guildId: string, username: string ): Promise { @@ -118,7 +114,7 @@ class RngdleService implements Service { const { count } = await prisma.rngdleRoll.deleteMany({ where: { guildId }, }); - this.bumpRevision(guildId); + this.touch(guildId); return count; } @@ -132,20 +128,21 @@ class RngdleService implements Service { } async saveRolls( - account: Account, + { guildId, userId }: Account, rolls: RemoteRoll[] ): Promise<{ inserted: number; updated: number }> { - const { guildId, userId } = account; if (rolls.length === 0) { return { inserted: 0, updated: 0 }; } - const existing = await prisma.rngdleRoll.findMany({ - where: { guildId, rollId: { in: rolls.map((roll) => roll.id) } }, - select: { rollId: true, score: true, badgeCount: true }, - }); - const known = new Map(existing.map((roll) => [roll.rollId, roll])); - + const known = new Map( + ( + await prisma.rngdleRoll.findMany({ + where: { guildId, rollId: { in: rolls.map((roll) => roll.id) } }, + select: { rollId: true, score: true, badgeCount: true }, + }) + ).map((roll) => [roll.rollId, roll]) + ); const fresh = rolls.filter((roll) => !known.has(roll.id)); const changed = rolls.filter((roll) => { const stored = known.get(roll.id); @@ -178,43 +175,38 @@ class RngdleService implements Service { ]); if (created.count > 0 || changed.length > 0) { - this.bumpRevision(guildId); + this.touch(guildId); } return { inserted: created.count, updated: changed.length }; } - async rollsBetween( - guildId: string, - from: Date, - to: Date - ): Promise { - const rolls = await prisma.rngdleRoll.findMany({ - where: { guildId, rolledAt: { gte: from, lt: to } }, - orderBy: [{ score: "desc" }, { rolledAt: "asc" }], - select: rollFields, - }); - return rolls.map(toStoredRoll); - } - - async userRolls(guildId: string, userId: string): Promise { + async rolls(guildId: string, filter: RollFilter = {}): Promise { const rolls = await prisma.rngdleRoll.findMany({ - where: { guildId, userId }, - orderBy: { rolledAt: "asc" }, - select: rollFields, - }); - return rolls.map(toStoredRoll); - } - - async guildRolls(guildId: string): Promise { - const rolls = await prisma.rngdleRoll.findMany({ - where: { guildId }, - orderBy: { rolledAt: "asc" }, - select: rollFields, + where: { + guildId, + ...(filter.userId && { userId: filter.userId }), + ...((filter.from || filter.to) && { + rolledAt: { + ...(filter.from && { gte: filter.from }), + ...(filter.to && { lt: filter.to }), + }, + }), + }, + orderBy: filter.byScore + ? [{ score: "desc" }, { rolledAt: "asc" }] + : { rolledAt: "asc" }, + select: { + userId: true, + number: true, + score: true, + badgeCount: true, + rolledAt: true, + }, }); - return rolls.map(toStoredRoll); + return rolls.map((roll) => ({ ...roll, score: Number(roll.score) })); } - async totalsByUser(guildId: string): Promise { + async totals(guildId: string): Promise<{ userId: string; total: number }[]> { const groups = await prisma.rngdleRoll.groupBy({ by: ["userId"], where: { guildId }, @@ -227,6 +219,35 @@ class RngdleService implements Service { })) .sort((a, b) => b.total - a.total || a.userId.localeCompare(b.userId)); } + + scoreTable(): Promise { + if (!this.table) { + this.table = prisma.rngdleScoreTable + .findUnique({ where: { id: 1 } }) + .then( + (stored) => + loadTable(stored?.data) ?? + new ScoreTable(JSON.parse(readFileSync(SNAPSHOT, "utf8"))) + ); + this.table.catch(() => { + this.table = null; + }); + } + return this.table; + } + + async replaceScoreTable(data: CompressedTable): Promise { + if ((await this.scoreTable()).equals(data)) { + return false; + } + await prisma.rngdleScoreTable.upsert({ + where: { id: 1 }, + create: { id: 1, data }, + update: { data }, + }); + this.table = Promise.resolve(new ScoreTable(data)); + return true; + } } -export default declareService(new RngdleService()); +export default declareService(new RngdleStore()); diff --git a/src/modules/rngdle/services/sync.service.test.ts b/src/modules/rngdle/services/sync.service.test.ts deleted file mode 100644 index c15b5a82..00000000 --- a/src/modules/rngdle/services/sync.service.test.ts +++ /dev/null @@ -1,622 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { RemoteRoll } from "./rngdle-api.js"; -import type { Account } from "./rngdle.service.js"; - -const mocks = vi.hoisted(() => ({ - fetchUserRolls: vi.fn(), - listAccounts: vi.fn(), - listAllAccounts: vi.fn(), - getActivatedGuildIds: vi.fn(), - latestRollDate: vi.fn(), - saveRolls: vi.fn(), -})); - -vi.mock("#lib/logger.js", () => { - const logger = { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - }; - return { loggerMaker: () => logger }; -}); - -vi.mock("#core/services/module.service.js", () => ({ - default: { getActivatedGuildIds: mocks.getActivatedGuildIds }, -})); - -vi.mock("./rngdle-api.js", async () => { - const actual = - await vi.importActual("./rngdle-api.js"); - return { ...actual, fetchUserRolls: mocks.fetchUserRolls }; -}); - -vi.mock("./rngdle.service.js", () => ({ - default: { - listAccounts: mocks.listAccounts, - listAllAccounts: mocks.listAllAccounts, - latestRollDate: mocks.latestRollDate, - saveRolls: mocks.saveRolls, - }, -})); - -const { RngdleUserNotFoundError } = await import("./rngdle-api.js"); -const { default: sync, FullSyncCooldownError } = - await import("./sync.service.js"); - -const NOW = new Date("2026-05-15T12:00:00.000Z"); -let guild = 0; -let guildId = ""; - -const flush = () => new Promise((resolve) => setImmediate(resolve)); - -function accountsFor(id: string, ...names: string[]): Account[] { - return names.map((name) => ({ - guildId: id, - userId: `u-${name}`, - username: name, - })); -} - -interface Pending { - username: string; - since: Date | null; - resolve: (rolls: RemoteRoll[]) => void; - reject: (error: unknown) => void; -} - -function deferFetches() { - const pending: Pending[] = []; - let inFlight = 0; - let maxInFlight = 0; - mocks.fetchUserRolls.mockImplementation( - (username: string, since: Date | null) => - new Promise((resolve, reject) => { - inFlight++; - maxInFlight = Math.max(maxInFlight, inFlight); - pending.push({ - username, - since, - resolve: (rolls) => { - inFlight--; - resolve(rolls); - }, - reject: (error) => { - inFlight--; - reject(error); - }, - }); - }) - ); - return { - pending, - get maxInFlight() { - return maxInFlight; - }, - async drain() { - for (let i = 0; i < 100; i++) { - await flush(); - const next = pending.shift(); - if (!next) { - if (i > 3) return; - continue; - } - next.resolve([]); - } - }, - }; -} - -beforeEach(() => { - guildId = `guild-${++guild}`; - vi.useFakeTimers({ toFake: ["Date", "setTimeout", "clearTimeout"] }); - vi.setSystemTime(NOW); - for (const mock of Object.values(mocks)) mock.mockReset(); - mocks.latestRollDate.mockResolvedValue(null); - mocks.fetchUserRolls.mockResolvedValue([]); - mocks.saveRolls.mockResolvedValue({ inserted: 0, updated: 0 }); -}); - -afterEach(() => { - vi.useRealTimers(); -}); - -describe("syncGuild", () => { - it("fetches, saves and reports", async () => { - const rolls: RemoteRoll[] = [ - { id: "r1", number: 1, score: 1, badgeCount: 0, rolledAt: NOW }, - { id: "r2", number: 2, score: 2, badgeCount: 0, rolledAt: NOW }, - ]; - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice", "bob")); - mocks.fetchUserRolls.mockResolvedValue(rolls); - mocks.saveRolls.mockResolvedValue({ inserted: 1, updated: 2 }); - - const report = await sync.syncGuild(guildId); - - expect(report).toEqual({ - accounts: 2, - fetched: 4, - inserted: 2, - updated: 4, - failed: 0, - }); - expect(mocks.listAccounts).toHaveBeenCalledWith(guildId); - expect(mocks.saveRolls).toHaveBeenCalledTimes(2); - }); - - it("passes the latest stored roll date as since", async () => { - const latest = new Date("2026-05-14T08:00:00.000Z"); - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - mocks.latestRollDate.mockResolvedValue(latest); - - await sync.syncGuild(guildId); - - expect(mocks.fetchUserRolls).toHaveBeenCalledWith("alice", latest); - }); - - it("skips an account whose latest roll is today (UTC)", async () => { - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - mocks.latestRollDate.mockResolvedValue( - new Date("2026-05-15T00:00:00.000Z") - ); - - const report = await sync.syncGuild(guildId); - - expect(mocks.fetchUserRolls).not.toHaveBeenCalled(); - expect(mocks.saveRolls).not.toHaveBeenCalled(); - expect(report).toEqual({ - accounts: 1, - fetched: 0, - inserted: 0, - updated: 0, - failed: 0, - }); - }); - - it("does not skip an account whose latest roll is just before today", async () => { - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - mocks.latestRollDate.mockResolvedValue( - new Date("2026-05-14T23:59:59.999Z") - ); - - await sync.syncGuild(guildId); - - expect(mocks.fetchUserRolls).toHaveBeenCalledTimes(1); - }); - - it("fetches everything with full and does not read the latest roll", async () => { - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - mocks.latestRollDate.mockResolvedValue(NOW); - - await sync.syncGuild(guildId, { full: true }); - - expect(mocks.latestRollDate).not.toHaveBeenCalled(); - expect(mocks.fetchUserRolls).toHaveBeenCalledWith("alice", null); - }); - - it("counts a failure per account without stopping the others", async () => { - mocks.listAccounts.mockResolvedValue( - accountsFor(guildId, "ghost", "broken", "alice", "bob") - ); - mocks.fetchUserRolls.mockImplementation(async (username: string) => { - if (username === "ghost") throw new RngdleUserNotFoundError(username); - if (username === "broken") throw new Error("network down"); - return [ - { id: username, number: 1, score: 1, badgeCount: 0, rolledAt: NOW }, - ]; - }); - mocks.saveRolls.mockResolvedValue({ inserted: 1, updated: 0 }); - - const report = await sync.syncGuild(guildId); - - expect(report).toEqual({ - accounts: 4, - fetched: 2, - inserted: 2, - updated: 0, - failed: 2, - }); - }); - - it("counts a save failure as one failure", async () => { - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - mocks.saveRolls.mockRejectedValue(new Error("db down")); - - const report = await sync.syncGuild(guildId); - - expect(report.failed).toBe(1); - }); - - it("runs at most 4 fetches concurrently", async () => { - const names = Array.from({ length: 10 }, (_, i) => `user${i}`); - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, ...names)); - const fetches = deferFetches(); - - const done = sync.syncGuild(guildId); - await flush(); - expect(fetches.pending).toHaveLength(4); - - await fetches.drain(); - const report = await done; - - expect(fetches.maxInFlight).toBe(4); - expect(mocks.fetchUserRolls).toHaveBeenCalledTimes(10); - expect(report.accounts).toBe(10); - }); - - it("handles a guild without accounts", async () => { - mocks.listAccounts.mockResolvedValue([]); - - await expect(sync.syncGuild(guildId)).resolves.toEqual({ - accounts: 0, - fetched: 0, - inserted: 0, - updated: 0, - failed: 0, - }); - }); -}); - -const MINUTE = 60 * 1000; - -describe("per-guild exclusivity", () => { - it("never overlaps two syncs of the same guild", async () => { - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - const fetches = deferFetches(); - - const first = sync.syncGuild(guildId); - const second = sync.syncGuild(guildId, { full: true }); - await flush(); - - expect(fetches.pending).toHaveLength(1); - fetches.pending.shift()!.resolve([]); - await first; - await flush(); - expect(fetches.pending).toHaveLength(1); - fetches.pending.shift()!.resolve([]); - await second; - expect(fetches.maxInFlight).toBe(1); - }); - - it("makes a queued task wait for an in-flight sync of the same guild", async () => { - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - const fetches = deferFetches(); - const order: string[] = []; - - const syncing = sync.syncGuild(guildId).then(() => order.push("sync done")); - await flush(); - const clearing = sync.runExclusive(guildId, async () => { - order.push("clear"); - }); - await flush(); - - expect(order).toEqual([]); - fetches.pending.shift()!.resolve([]); - await Promise.all([syncing, clearing]); - - expect(order).toEqual(["sync done", "clear"]); - }); - - it("does not block another guild", async () => { - const other = `${guildId}-other`; - mocks.listAccounts.mockImplementation(async (id: string) => - accountsFor(id, id === guildId ? "alice" : "bob") - ); - const fetches = deferFetches(); - - const blocked = sync.syncGuild(guildId); - await flush(); - const otherTask = sync.runExclusive(other, async () => "ran"); - const otherSync = sync.syncGuild(other); - await flush(); - - await expect(otherTask).resolves.toBe("ran"); - expect(fetches.pending.map((p) => p.username).sort()).toEqual([ - "alice", - "bob", - ]); - - await fetches.drain(); - await Promise.all([blocked, otherSync]); - }); - - it("keeps running queued tasks after a failure", async () => { - const failing = sync.runExclusive(guildId, async () => { - throw new Error("boom"); - }); - const next = sync.runExclusive(guildId, async () => "ok"); - - await expect(failing).rejects.toThrow("boom"); - await expect(next).resolves.toBe("ok"); - }); - - it("returns the task result", async () => { - await expect(sync.runExclusive(guildId, async () => 42)).resolves.toBe(42); - }); -}); - -describe("pending sync reuse", () => { - it("reuses a pending sync instead of starting a duplicate", async () => { - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - const fetches = deferFetches(); - - const first = sync.syncGuild(guildId); - const second = sync.syncGuild(guildId); - await flush(); - fetches.pending.shift()!.resolve([]); - const [a, b] = await Promise.all([first, second]); - - expect(mocks.listAccounts).toHaveBeenCalledTimes(1); - expect(b).toBe(a); - }); - - it("starts a new sync once the previous one has finished", async () => { - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - - await sync.syncGuild(guildId); - await flush(); - await sync.syncGuild(guildId); - - expect(mocks.listAccounts).toHaveBeenCalledTimes(2); - }); - - it("does not reuse a pending sync for a full request", async () => { - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - const fetches = deferFetches(); - - const first = sync.syncGuild(guildId); - const full = sync.syncGuild(guildId, { full: true }); - await flush(); - await fetches.drain(); - await Promise.all([first, full]); - - expect(mocks.listAccounts).toHaveBeenCalledTimes(2); - expect(mocks.fetchUserRolls).toHaveBeenLastCalledWith("alice", null); - }); -}); - -describe("full sync cooldown", () => { - beforeEach(() => { - mocks.listAccounts.mockResolvedValue([]); - }); - - it("rejects a second full sync within 15 minutes with retryAt", async () => { - await sync.syncGuild(guildId, { full: true }); - vi.setSystemTime(new Date(NOW.getTime() + 5 * MINUTE)); - - const error = await sync - .syncGuild(guildId, { full: true }) - .catch((err: unknown) => err); - - expect(error).toBeInstanceOf(FullSyncCooldownError); - expect( - (error as InstanceType).retryAt - ).toEqual(new Date(NOW.getTime() + 15 * MINUTE)); - expect(mocks.listAccounts).toHaveBeenCalledTimes(1); - }); - - it("rejects right up to the 15 minute mark and accepts after it", async () => { - await sync.syncGuild(guildId, { full: true }); - - vi.setSystemTime(new Date(NOW.getTime() + 15 * MINUTE - 1)); - await expect( - sync.syncGuild(guildId, { full: true }) - ).rejects.toBeInstanceOf(FullSyncCooldownError); - - vi.setSystemTime(new Date(NOW.getTime() + 15 * MINUTE)); - await expect( - sync.syncGuild(guildId, { full: true }) - ).resolves.toBeDefined(); - }); - - it("does not limit a normal sync", async () => { - await sync.syncGuild(guildId, { full: true }); - - await expect(sync.syncGuild(guildId)).resolves.toBeDefined(); - }); - - it("tracks guilds independently", async () => { - await sync.syncGuild(guildId, { full: true }); - - await expect( - sync.syncGuild(`${guildId}-other`, { full: true }) - ).resolves.toBeDefined(); - }); - - it("is not triggered by a normal sync", async () => { - await sync.syncGuild(guildId); - - await expect( - sync.syncGuild(guildId, { full: true }) - ).resolves.toBeDefined(); - }); -}); - -describe("syncAll", () => { - it("only syncs activated guilds, without listing every account", async () => { - const other = `${guildId}-other`; - mocks.getActivatedGuildIds.mockResolvedValue([guildId, other]); - mocks.listAccounts.mockImplementation(async (id: string) => - accountsFor(id, "alice") - ); - mocks.saveRolls.mockResolvedValue({ inserted: 3, updated: 1 }); - - const report = await sync.syncAll(); - - expect(mocks.getActivatedGuildIds).toHaveBeenCalledWith("rngdle"); - expect(mocks.listAccounts.mock.calls.map(([id]) => id)).toEqual([ - guildId, - other, - ]); - expect(mocks.listAllAccounts).not.toHaveBeenCalled(); - expect(report).toEqual({ - accounts: 2, - fetched: 0, - inserted: 6, - updated: 2, - failed: 0, - }); - }); - - it("does nothing without activated guilds", async () => { - mocks.getActivatedGuildIds.mockResolvedValue([]); - - await expect(sync.syncAll()).resolves.toEqual({ - accounts: 0, - fetched: 0, - inserted: 0, - updated: 0, - failed: 0, - }); - expect(mocks.listAccounts).not.toHaveBeenCalled(); - }); - - it("syncs guilds one after another", async () => { - const other = `${guildId}-other`; - mocks.getActivatedGuildIds.mockResolvedValue([guildId, other]); - mocks.listAccounts.mockImplementation(async (id: string) => - accountsFor(id, id === guildId ? "alice" : "bob") - ); - const fetches = deferFetches(); - - const done = sync.syncAll(); - await flush(); - - expect(fetches.pending.map((p) => p.username)).toEqual(["alice"]); - expect(mocks.listAccounts).toHaveBeenCalledTimes(1); - - fetches.pending.shift()!.resolve([]); - await flush(); - expect(fetches.pending.map((p) => p.username)).toEqual(["bob"]); - fetches.pending.shift()!.resolve([]); - await done; - }); - - it("waits for a running sync of the same guild", async () => { - mocks.getActivatedGuildIds.mockResolvedValue([guildId]); - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - const fetches = deferFetches(); - - const running = sync.syncGuild(guildId); - await flush(); - const all = sync.syncAll(); - await flush(); - - expect(fetches.pending).toHaveLength(1); - fetches.pending.shift()!.resolve([]); - await Promise.all([running, all]); - expect(mocks.listAccounts).toHaveBeenCalledTimes(1); - }); - - it("passes full to each guild", async () => { - mocks.getActivatedGuildIds.mockResolvedValue([guildId]); - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - mocks.latestRollDate.mockResolvedValue(NOW); - - await sync.syncAll({ full: true }); - - expect(mocks.latestRollDate).not.toHaveBeenCalled(); - expect(mocks.fetchUserRolls).toHaveBeenCalledWith("alice", null); - }); - - it("marks synced guilds as fresh", async () => { - mocks.getActivatedGuildIds.mockResolvedValue([guildId]); - mocks.listAccounts.mockResolvedValue([]); - await sync.syncAll(); - mocks.listAccounts.mockClear(); - - await sync.syncGuildIfStale(guildId); - - expect(mocks.listAccounts).not.toHaveBeenCalled(); - }); -}); - -describe("syncGuildIfStale", () => { - beforeEach(() => { - mocks.listAccounts.mockResolvedValue([]); - }); - - it("syncs a guild that was never synced", async () => { - await sync.syncGuildIfStale(guildId); - - expect(mocks.listAccounts).toHaveBeenCalledTimes(1); - }); - - it("skips within 5 minutes of the last sync", async () => { - await sync.syncGuild(guildId); - mocks.listAccounts.mockClear(); - - vi.setSystemTime(new Date(NOW.getTime() + 5 * MINUTE - 1)); - await sync.syncGuildIfStale(guildId); - - expect(mocks.listAccounts).not.toHaveBeenCalled(); - }); - - it("syncs again once 5 minutes have passed", async () => { - await sync.syncGuild(guildId); - await flush(); - mocks.listAccounts.mockClear(); - - vi.setSystemTime(new Date(NOW.getTime() + 5 * MINUTE)); - await sync.syncGuildIfStale(guildId); - - expect(mocks.listAccounts).toHaveBeenCalledTimes(1); - }); - - it("tracks guilds independently", async () => { - await sync.syncGuild(guildId); - mocks.listAccounts.mockClear(); - - await sync.syncGuildIfStale(`${guildId}-other`); - - expect(mocks.listAccounts).toHaveBeenCalledWith(`${guildId}-other`); - }); - - it("reuses a pending sync", async () => { - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - const fetches = deferFetches(); - - const running = sync.syncGuild(guildId); - await flush(); - const stale = sync.syncGuildIfStale(guildId); - await flush(); - fetches.pending.shift()!.resolve([]); - await Promise.all([running, stale]); - - expect(mocks.listAccounts).toHaveBeenCalledTimes(1); - }); - - it("waits for a quick sync and leaves no timer behind", async () => { - await sync.syncGuildIfStale(guildId); - - expect(vi.getTimerCount()).toBe(0); - }); - - it("does not wait for more than 10 s while the sync keeps running", async () => { - mocks.listAccounts.mockResolvedValue(accountsFor(guildId, "alice")); - const fetches = deferFetches(); - let resolved = false; - - const stale = sync.syncGuildIfStale(guildId).then(() => { - resolved = true; - }); - await flush(); - await vi.advanceTimersByTimeAsync(9_999); - expect(resolved).toBe(false); - - await vi.advanceTimersByTimeAsync(1); - await stale; - expect(resolved).toBe(true); - expect(fetches.pending).toHaveLength(1); - - fetches.pending.shift()!.resolve([]); - await flush(); - expect(mocks.saveRolls).toHaveBeenCalledTimes(1); - }); - - it("swallows a failing sync", async () => { - mocks.listAccounts.mockRejectedValue(new Error("db down")); - - await expect(sync.syncGuildIfStale(guildId)).resolves.toBeUndefined(); - }); -}); diff --git a/src/modules/rngdle/services/sync.service.ts b/src/modules/rngdle/services/sync.service.ts deleted file mode 100644 index 2fbf1cf0..00000000 --- a/src/modules/rngdle/services/sync.service.ts +++ /dev/null @@ -1,200 +0,0 @@ -import { loggerMaker } from "#lib/logger.js"; -import { declareService, type Service } from "#lib/service.js"; -import { fetchUserRolls, RngdleUserNotFoundError } from "./rngdle-api.js"; -import rngdleService, { type Account } from "./rngdle.service.js"; - -const logger = loggerMaker("rngdle"); - -const MAX_PARALLEL_FETCHES = 4; -const GUILD_COOLDOWN_MS = 5 * 60 * 1000; -const FULL_SYNC_COOLDOWN_MS = 15 * 60 * 1000; -const DISPLAY_SYNC_WAIT_MS = 10_000; - -export interface SyncOptions { - full?: boolean; -} - -export interface SyncReport { - accounts: number; - fetched: number; - inserted: number; - updated: number; - failed: number; -} - -export class FullSyncCooldownError extends Error { - constructor(readonly retryAt: Date) { - super("A full sync ran recently on this guild"); - } -} - -function emptyReport(): SyncReport { - return { accounts: 0, fetched: 0, inserted: 0, updated: 0, failed: 0 }; -} - -function addReports(target: SyncReport, source: SyncReport): void { - target.accounts += source.accounts; - target.fetched += source.fetched; - target.inserted += source.inserted; - target.updated += source.updated; - target.failed += source.failed; -} - -function startOfUtcDay(date: Date): Date { - return new Date( - Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) - ); -} - -class SyncService implements Service { - private readonly guildQueues = new Map>(); - private readonly lastGuildSync = new Map(); - private readonly lastFullSync = new Map(); - private readonly pendingSyncs = new Map>(); - - runExclusive(guildId: string, task: () => Promise): Promise { - const previous = this.guildQueues.get(guildId) ?? Promise.resolve(); - const next = previous.then(task, task); - const settled = next.catch(() => undefined); - this.guildQueues.set(guildId, settled); - void settled.then(() => { - if (this.guildQueues.get(guildId) === settled) { - this.guildQueues.delete(guildId); - } - }); - return next; - } - - syncGuild(guildId: string, options: SyncOptions = {}): Promise { - if (options.full) { - const last = this.lastFullSync.get(guildId); - if (last !== undefined && Date.now() - last < FULL_SYNC_COOLDOWN_MS) { - return Promise.reject( - new FullSyncCooldownError(new Date(last + FULL_SYNC_COOLDOWN_MS)) - ); - } - this.lastFullSync.set(guildId, Date.now()); - } - return this.queueGuildSync(guildId, options); - } - - async syncAll(options: SyncOptions = {}): Promise { - const { default: moduleService } = - await import("#core/services/module.service.js"); - const guildIds = await moduleService.getActivatedGuildIds("rngdle"); - - const report = emptyReport(); - for (const guildId of guildIds) { - addReports(report, await this.queueGuildSync(guildId, options)); - } - logger.info( - `Sync done | guilds = ${guildIds.length} | accounts = ${report.accounts} | inserted = ${report.inserted} | updated = ${report.updated} | failed = ${report.failed}` - ); - return report; - } - - async syncGuildIfStale(guildId: string): Promise { - const last = this.lastGuildSync.get(guildId); - const pending = - this.pendingSyncs.get(guildId) ?? - (last !== undefined && Date.now() - last < GUILD_COOLDOWN_MS - ? undefined - : this.queueGuildSync(guildId, {})); - if (!pending) { - return; - } - - let timeout: NodeJS.Timeout | undefined; - await Promise.race([ - pending.catch(() => undefined), - new Promise((resolve) => { - timeout = setTimeout(resolve, DISPLAY_SYNC_WAIT_MS); - }), - ]); - clearTimeout(timeout); - } - - private queueGuildSync( - guildId: string, - options: SyncOptions - ): Promise { - const pending = this.pendingSyncs.get(guildId); - if (pending && !options.full) { - return pending; - } - - this.lastGuildSync.set(guildId, Date.now()); - const sync = this.runExclusive(guildId, async () => - this.syncAccounts(await rngdleService.listAccounts(guildId), options) - ); - this.pendingSyncs.set(guildId, sync); - void sync - .catch(() => undefined) - .then(() => { - if (this.pendingSyncs.get(guildId) === sync) { - this.pendingSyncs.delete(guildId); - } - }); - return sync; - } - - private async syncAccounts( - accounts: Account[], - options: SyncOptions - ): Promise { - const report = emptyReport(); - report.accounts = accounts.length; - - const queue = [...accounts]; - const worker = async () => { - for (let account = queue.shift(); account; account = queue.shift()) { - const result = await this.syncAccount(account, options); - addReports(report, { ...result, accounts: 0 }); - } - }; - await Promise.all( - Array.from( - { length: Math.min(MAX_PARALLEL_FETCHES, accounts.length) }, - worker - ) - ); - - return report; - } - - private async syncAccount( - account: Account, - { full = false }: SyncOptions - ): Promise> { - const result = { fetched: 0, inserted: 0, updated: 0, failed: 0 }; - try { - const latest = full - ? null - : await rngdleService.latestRollDate(account.guildId, account.userId); - if (latest && latest >= startOfUtcDay(new Date())) { - return result; - } - - const rolls = await fetchUserRolls(account.username, latest); - result.fetched = rolls.length; - const saved = await rngdleService.saveRolls(account, rolls); - result.inserted = saved.inserted; - result.updated = saved.updated; - } catch (err) { - result.failed = 1; - if (err instanceof RngdleUserNotFoundError) { - logger.warn( - `RNGdle account not found | guildId = ${account.guildId} | userId = ${account.userId} | username = ${account.username}` - ); - } else { - logger.error( - { err }, - `Failed to sync RNGdle rolls | guildId = ${account.guildId} | username = ${account.username}` - ); - } - } - return result; - } -} - -export default declareService(new SyncService()); diff --git a/src/modules/rngdle/services/sync.test.ts b/src/modules/rngdle/services/sync.test.ts new file mode 100644 index 00000000..662e8cd5 --- /dev/null +++ b/src/modules/rngdle/services/sync.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + sleep: vi.fn((): Promise => new Promise(() => {})), + activated: vi.fn(async (_module: string): Promise => []), + accounts: vi.fn(), + latestRollDate: vi.fn(), + saveRolls: vi.fn(), + fetchUserRolls: vi.fn(), +})); + +vi.mock("node:timers/promises", () => ({ setTimeout: mocks.sleep })); +vi.mock("#lib/logger.js", () => { + const logger = { info: vi.fn(), warn: vi.fn() }; + return { loggerMaker: () => logger }; +}); +vi.mock("#core/services/module.service.js", () => ({ + default: { getActivatedGuildIds: mocks.activated }, +})); +vi.mock("./store.js", () => ({ + default: { + accounts: mocks.accounts, + latestRollDate: mocks.latestRollDate, + saveRolls: mocks.saveRolls, + }, +})); +vi.mock("./api.js", async (importOriginal) => ({ + ...(await importOriginal()), + fetchUserRolls: mocks.fetchUserRolls, +})); + +import { RngdleUserNotFoundError } from "./api.js"; +import sync, { FullSyncCooldownError } from "./sync.js"; + +const MINUTE = 60_000; +const NOW = new Date("2026-03-10T12:00:00Z"); + +function accountsOf(guildId: string, count: number) { + return Array.from({ length: count }, (_, i) => ({ + guildId, + userId: `u${i}`, + username: `user${i}`, + })); +} + +const tick = () => new Promise((resolve) => setImmediate(resolve)); + +let guild = 0; +let guildId: string; + +beforeEach(() => { + guildId = `g${++guild}`; + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + mocks.sleep.mockClear(); + mocks.accounts.mockReset().mockResolvedValue(accountsOf(guildId, 2)); + mocks.latestRollDate.mockReset().mockResolvedValue(null); + mocks.saveRolls.mockReset().mockResolvedValue({ inserted: 1, updated: 2 }); + mocks.fetchUserRolls.mockReset().mockResolvedValue([]); + mocks.activated.mockReset().mockResolvedValue([]); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("syncGuild", () => { + it("aggregates the report and counts failures without stopping the other accounts", async () => { + mocks.accounts.mockResolvedValue(accountsOf(guildId, 3)); + mocks.fetchUserRolls.mockImplementation(async (username: string) => { + if (username === "user0") throw new RngdleUserNotFoundError(username); + if (username === "user1") throw new Error("boom"); + return []; + }); + + expect(await sync.syncGuild(guildId)).toEqual({ + accounts: 3, + inserted: 1, + updated: 2, + failed: 2, + }); + }); + + it("fetches only what is newer than the latest stored roll", async () => { + const latest = new Date("2026-03-09T20:00:00Z"); + mocks.latestRollDate.mockResolvedValue(latest); + + await sync.syncGuild(guildId); + + expect(mocks.fetchUserRolls).toHaveBeenCalledWith("user0", latest); + }); + + it("skips accounts whose latest roll is today (UTC), unless the sync is full", async () => { + mocks.latestRollDate.mockResolvedValue(new Date("2026-03-10T00:00:00Z")); + + expect(await sync.syncGuild(guildId)).toMatchObject({ + inserted: 0, + updated: 0, + }); + expect(mocks.fetchUserRolls).not.toHaveBeenCalled(); + + await sync.syncGuild(guildId, true); + expect(mocks.fetchUserRolls).toHaveBeenCalledWith("user0", null); + }); + + it("limits a guild to 4 parallel fetches", async () => { + mocks.accounts.mockResolvedValue(accountsOf(guildId, 10)); + let active = 0; + let peak = 0; + mocks.fetchUserRolls.mockImplementation(async () => { + peak = Math.max(peak, ++active); + await tick(); + active -= 1; + return []; + }); + + await sync.syncGuild(guildId); + + expect(mocks.fetchUserRolls).toHaveBeenCalledTimes(10); + expect(peak).toBe(4); + }); + + it("reuses the pending sync of a guild", async () => { + const first = sync.syncGuild(guildId); + const second = sync.syncGuild(guildId); + + expect(second).toBe(first); + await first; + expect(mocks.accounts).toHaveBeenCalledTimes(1); + }); + + it("rejects a second full sync within 15 minutes, per guild, with the retry date", async () => { + await sync.syncGuild(guildId, true); + vi.setSystemTime(NOW.getTime() + 14 * MINUTE); + + const error = await sync.syncGuild(guildId, true).catch((err) => err); + expect(error).toBeInstanceOf(FullSyncCooldownError); + expect(error.retryAt).toEqual(new Date(NOW.getTime() + 15 * MINUTE)); + await expect( + sync.syncGuild(`${guildId}-other`, true) + ).resolves.toBeDefined(); + + vi.setSystemTime(NOW.getTime() + 15 * MINUTE); + await expect(sync.syncGuild(guildId, true)).resolves.toBeDefined(); + }); +}); + +describe("syncAll", () => { + it("syncs only the guilds where the module is activated and sums their reports", async () => { + const other = `${guildId}-b`; + mocks.activated.mockResolvedValue([guildId, other]); + + const report = await sync.syncAll(); + + expect(mocks.activated).toHaveBeenCalledWith("rngdle"); + expect(mocks.accounts.mock.calls.map(([id]) => id)).toEqual([ + guildId, + other, + ]); + expect(report).toEqual({ accounts: 4, inserted: 4, updated: 8, failed: 0 }); + }); +}); + +describe("refreshIfStale", () => { + it("syncs when stale and not again within 5 minutes", async () => { + await sync.refreshIfStale(guildId); + await sync.refreshIfStale(guildId); + expect(mocks.accounts).toHaveBeenCalledTimes(1); + + vi.setSystemTime(NOW.getTime() + 5 * MINUTE); + await sync.refreshIfStale(guildId); + expect(mocks.accounts).toHaveBeenCalledTimes(2); + }); + + it("joins a sync that is still running instead of starting another", async () => { + let release!: () => void; + mocks.fetchUserRolls.mockReturnValue( + new Promise((resolve) => (release = () => resolve([]))) + ); + const running = sync.syncGuild(guildId); + vi.setSystemTime(NOW.getTime() + 6 * MINUTE); + + const refresh = sync.refreshIfStale(guildId); + await tick(); + release(); + await Promise.all([running, refresh]); + + expect(mocks.accounts).toHaveBeenCalledTimes(1); + }); + + it("never waits more than 10 seconds for a slow sync", async () => { + mocks.fetchUserRolls.mockReturnValue(new Promise(() => {})); + mocks.sleep.mockResolvedValueOnce(undefined); + + await sync.refreshIfStale(guildId); + + expect(mocks.sleep.mock.calls[0]).toContain(10_000); + }); + + it("resolves even when the sync fails", async () => { + mocks.accounts.mockRejectedValue(new Error("db down")); + + await expect(sync.refreshIfStale(guildId)).resolves.toBeUndefined(); + }); +}); diff --git a/src/modules/rngdle/services/sync.ts b/src/modules/rngdle/services/sync.ts new file mode 100644 index 00000000..89e3e7ca --- /dev/null +++ b/src/modules/rngdle/services/sync.ts @@ -0,0 +1,169 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import moduleService from "#core/services/module.service.js"; +import { KeyedQueue } from "#lib/keyed-queue.js"; +import { loggerMaker } from "#lib/logger.js"; +import { declareService, type Service } from "#lib/service.js"; +import { fetchUserRolls, RngdleUserNotFoundError } from "./api.js"; +import store, { type Account } from "./store.js"; + +const logger = loggerMaker("rngdle"); + +const MAX_PARALLEL_FETCHES = 4; +const STALE_AFTER_MS = 5 * 60 * 1000; +const FULL_SYNC_COOLDOWN_MS = 15 * 60 * 1000; +const DISPLAY_WAIT_MS = 10_000; + +export interface SyncReport { + accounts: number; + inserted: number; + updated: number; + failed: number; +} + +export class FullSyncCooldownError extends Error { + constructor(readonly retryAt: Date) { + super("A full sync ran recently on this guild"); + } +} + +function startOfUtcDay(date: Date): Date { + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()) + ); +} + +class RngdleSync implements Service { + private readonly queue = new KeyedQueue(); + private readonly lastSync = new Map(); + private readonly lastFullSync = new Map(); + private readonly pending = new Map>(); + + exclusive(guildId: string, task: () => Promise): Promise { + return this.queue.run(guildId, task); + } + + syncGuild(guildId: string, full = false): Promise { + if (full) { + const last = this.lastFullSync.get(guildId) ?? -Infinity; + if (Date.now() - last < FULL_SYNC_COOLDOWN_MS) { + return Promise.reject( + new FullSyncCooldownError(new Date(last + FULL_SYNC_COOLDOWN_MS)) + ); + } + this.lastFullSync.set(guildId, Date.now()); + } + return this.start(guildId, full); + } + + async syncAll(full = false): Promise { + const total: SyncReport = { + accounts: 0, + inserted: 0, + updated: 0, + failed: 0, + }; + for (const guildId of await moduleService.getActivatedGuildIds("rngdle")) { + const report = await this.start(guildId, full); + total.accounts += report.accounts; + total.inserted += report.inserted; + total.updated += report.updated; + total.failed += report.failed; + } + logger.info( + `Sync done | accounts = ${total.accounts} | inserted = ${total.inserted} | updated = ${total.updated} | failed = ${total.failed}` + ); + return total; + } + + async refreshIfStale(guildId: string): Promise { + const fresh = + Date.now() - (this.lastSync.get(guildId) ?? -Infinity) < STALE_AFTER_MS; + const sync = + this.pending.get(guildId) ?? (fresh ? null : this.start(guildId, false)); + if (sync) { + const timeout = new AbortController(); + await Promise.race([ + sync.catch(() => undefined), + sleep(DISPLAY_WAIT_MS, undefined, { signal: timeout.signal }).catch( + () => undefined + ), + ]); + timeout.abort(); + } + } + + private start(guildId: string, full: boolean): Promise { + const pending = this.pending.get(guildId); + if (pending && !full) { + return pending; + } + + this.lastSync.set(guildId, Date.now()); + const sync = this.exclusive(guildId, async () => + this.syncAccounts(await store.accounts(guildId), full) + ); + this.pending.set(guildId, sync); + void sync + .catch(() => undefined) + .then(() => { + if (this.pending.get(guildId) === sync) { + this.pending.delete(guildId); + } + }); + return sync; + } + + private async syncAccounts( + accounts: Account[], + full: boolean + ): Promise { + const report: SyncReport = { + accounts: accounts.length, + inserted: 0, + updated: 0, + failed: 0, + }; + const remaining = [...accounts]; + const worker = async () => { + for ( + let account = remaining.shift(); + account; + account = remaining.shift() + ) { + try { + const saved = await this.syncAccount(account, full); + report.inserted += saved.inserted; + report.updated += saved.updated; + } catch (err) { + report.failed += 1; + logger.warn( + err instanceof RngdleUserNotFoundError ? {} : { err }, + `Failed to sync RNGdle rolls | guildId = ${account.guildId} | username = ${account.username}` + ); + } + } + }; + await Promise.all( + Array.from( + { length: Math.min(MAX_PARALLEL_FETCHES, accounts.length) }, + worker + ) + ); + return report; + } + + private async syncAccount(account: Account, full: boolean) { + const latest = full + ? null + : await store.latestRollDate(account.guildId, account.userId); + if (latest && latest >= startOfUtcDay(new Date())) { + return { inserted: 0, updated: 0 }; + } + return store.saveRolls( + account, + await fetchUserRolls(account.username, latest) + ); + } +} + +export default declareService(new RngdleSync()); diff --git a/src/modules/rngdle/services/views.test.ts b/src/modules/rngdle/services/views.test.ts new file mode 100644 index 00000000..8808deb3 --- /dev/null +++ b/src/modules/rngdle/services/views.test.ts @@ -0,0 +1,225 @@ +import type { Client } from "discord.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + rolls: vi.fn(), + totals: vi.fn(), + accounts: vi.fn(), + renderDaily: vi.fn(async (..._args: unknown[]) => Buffer.from("daily")), + renderOverall: vi.fn(async (..._args: unknown[]) => Buffer.from("overall")), +})); + +vi.mock("./store.js", () => ({ + default: { + revision: () => 0, + rolls: mocks.rolls, + totals: mocks.totals, + accounts: mocks.accounts, + scoreTable: async () => ({ + percentOf: (score: number) => score / 10, + tierOf: () => "COMMON", + }), + }, +})); +vi.mock("#modules/rngdle/rendering/leaderboards.js", () => ({ + renderDailyLeaderboard: mocks.renderDaily, + renderOverallLeaderboard: mocks.renderOverall, +})); +vi.mock("#modules/rngdle/rendering/cards.js", () => ({ + renderProfile: vi.fn(), + renderServerStats: vi.fn(), +})); +vi.mock("#lib/imaging.js", () => ({ + fetchAvatar: async () => null, + fetchImage: async () => null, +})); + +import { + dailyLeaderboard, + overallPage, + utcDayRange, + type ViewContext, +} from "./views.js"; + +const KNOWN = new Set([ + "a", + "b", + "c", + ...Array.from({ length: 40 }, (_, i) => `p${i}`), +]); + +let guild = 0; +let context: ViewContext; + +function setup() { + context = { + guildId: `g${++guild}`, + locale: "en", + t: ((key: string) => key) as ViewContext["t"], + client: { + users: { + fetch: async (id: string) => { + if (!KNOWN.has(id)) throw new Error("unknown user"); + return { username: `name-${id}` }; + }, + }, + } as unknown as Client, + }; +} + +const roll = (userId: string, score: number) => ({ userId, number: 1, score }); + +beforeEach(() => { + setup(); + mocks.rolls.mockReset().mockResolvedValue([]); + mocks.totals.mockReset().mockResolvedValue([]); + mocks.accounts.mockReset().mockResolvedValue([]); + mocks.renderDaily.mockClear(); + mocks.renderOverall.mockClear(); +}); + +describe("utcDayRange", () => { + const now = new Date("2026-03-01T15:30:00Z"); + + it("covers the current UTC day", () => { + const [from, to] = utcDayRange(0, now); + + expect(from.toISOString()).toBe("2026-03-01T00:00:00.000Z"); + expect(to.toISOString()).toBe("2026-03-02T00:00:00.000Z"); + }); + + it("goes back across a month boundary", () => { + const [from, to] = utcDayRange(1, now); + + expect(from.toISOString()).toBe("2026-02-28T00:00:00.000Z"); + expect(to.toISOString()).toBe("2026-03-01T00:00:00.000Z"); + }); +}); + +describe("dailyLeaderboard", () => { + const rowsOf = () => + mocks.renderDaily.mock.calls[0]![0] as { rank: number; name: string }[]; + + it("ranks the rolls and returns every distinct player tied with the best score as winner", async () => { + mocks.rolls.mockResolvedValue([ + roll("a", 100), + roll("b", 100), + roll("a", 100), + roll("c", 50), + ]); + + const board = await dailyLeaderboard(context, 0); + + expect(rowsOf().map((row) => [row.rank, row.name])).toEqual([ + [1, "name-a"], + [2, "name-b"], + [3, "name-a"], + [4, "name-c"], + ]); + expect(board).toEqual({ + image: Buffer.from("daily"), + winnerIds: ["a", "b"], + }); + }); + + it("shows at most 25 rows", async () => { + mocks.rolls.mockResolvedValue( + Array.from({ length: 30 }, (_, i) => roll(`p${i}`, 1000 - i)) + ); + + await dailyLeaderboard(context, 0); + + expect(rowsOf()).toHaveLength(25); + }); + + it("skips players that cannot be resolved but keeps the ranks", async () => { + mocks.rolls.mockResolvedValue([ + roll("a", 30), + roll("ghost", 20), + roll("b", 10), + ]); + + await dailyLeaderboard(context, 0); + + expect(rowsOf().map((row) => [row.rank, row.name])).toEqual([ + [1, "name-a"], + [3, "name-b"], + ]); + }); + + it("returns null without rolls or when nobody can be resolved", async () => { + expect(await dailyLeaderboard(context, 0)).toBeNull(); + + setup(); + mocks.rolls.mockResolvedValue([roll("ghost", 5)]); + expect(await dailyLeaderboard(context, 0)).toBeNull(); + expect(mocks.renderDaily).not.toHaveBeenCalled(); + }); +}); + +describe("overallPage", () => { + const totals = Array.from({ length: 25 }, (_, i) => ({ + userId: `p${i}`, + total: 1000 - i, + })); + + const rendered = () => { + const [rows, , , caller] = mocks.renderOverall.mock.calls.at(-1)! as [ + { rank: number }[], + unknown, + string, + { rank: number; name: string } | undefined, + ]; + return { ranks: rows.map((row) => row.rank), caller }; + }; + + beforeEach(() => { + mocks.totals.mockResolvedValue(totals); + }); + + it("returns null when nobody has a score", async () => { + mocks.totals.mockResolvedValue([]); + + expect(await overallPage(context, 1, "p0")).toBeNull(); + }); + + it("renders 10 rows per page and clamps the requested page", async () => { + expect(await overallPage(context, 99, "x")).toMatchObject({ + page: 3, + pageCount: 3, + }); + expect(rendered().ranks).toEqual([21, 22, 23, 24, 25]); + + expect(await overallPage(context, 0, "x")).toMatchObject({ + page: 1, + pageCount: 3, + }); + expect(rendered().ranks).toHaveLength(10); + }); + + it("adds the caller row only when the caller is off the page", async () => { + await overallPage(context, 1, "p14"); + expect(rendered().caller).toMatchObject({ rank: 15, name: "name-p14" }); + + await overallPage(context, 2, "p14"); + expect(rendered().caller).toBeUndefined(); + + await overallPage(context, 1, "not-ranked"); + expect(rendered().caller).toBeUndefined(); + }); + + it("falls back to the registered username, then to a placeholder, for unresolved players", async () => { + mocks.totals.mockResolvedValue([ + { userId: "ghost1", total: 2 }, + { userId: "ghost2", total: 1 }, + ]); + mocks.accounts.mockResolvedValue([ + { userId: "ghost1", username: "Registered" }, + ]); + + await overallPage(context, 1, "x"); + + const rows = mocks.renderOverall.mock.calls[0]![0] as { name: string }[]; + expect(rows.map((row) => row.name)).toEqual(["Registered", "?"]); + }); +}); diff --git a/src/modules/rngdle/services/views.ts b/src/modules/rngdle/services/views.ts new file mode 100644 index 00000000..0827c124 --- /dev/null +++ b/src/modules/rngdle/services/views.ts @@ -0,0 +1,316 @@ +import type { Image } from "@napi-rs/canvas"; +import type { Client, Guild } from "discord.js"; +import type { TFunction } from "#lib/i18n.js"; +import { fetchAvatar, fetchImage } from "#lib/imaging.js"; +import { RevisionCache } from "#lib/revision-cache.js"; +import { + renderProfile, + renderServerStats, +} from "#modules/rngdle/rendering/cards.js"; +import { + renderDailyLeaderboard, + renderOverallLeaderboard, +} from "#modules/rngdle/rendering/leaderboards.js"; +import { TIERS, type Tier } from "./scoring.js"; +import { computeRollStats, computeServerStats } from "./stats.js"; +import store, { type Account } from "./store.js"; + +const DAILY_SIZE = 25; +const OVERALL_PAGE_SIZE = 10; +const CACHE_TTL_MS = 15_000; +const DAY_MS = 24 * 60 * 60 * 1000; + +const cache = new RevisionCache(CACHE_TTL_MS); + +export interface ViewContext { + client: Client; + guildId: string; + t: TFunction; + locale: string; +} + +export interface DailyLeaderboard { + image: Buffer; + winnerIds: string[]; +} + +export interface OverallPage { + image: Buffer; + page: number; + pageCount: number; +} + +interface Player { + name: string; + avatar: Image | null; +} + +function cached(context: ViewContext, key: string, load: () => Promise) { + return cache.get( + `${context.guildId}:${context.locale}:${key}`, + store.revision(context.guildId), + load + ); +} + +async function resolvePlayers( + client: Client, + userIds: Iterable +): Promise> { + const entries = await Promise.all( + [...new Set(userIds)].map(async (userId) => { + const user = await client.users.fetch(userId).catch(() => null); + return [ + userId, + user && { name: user.username, avatar: await fetchAvatar(user) }, + ] as const; + }) + ); + return new Map( + entries.filter((entry): entry is [string, Player] => entry[1] !== null) + ); +} + +export function utcDayRange(daysAgo: number, now = new Date()): [Date, Date] { + const start = Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate() - daysAgo + ); + return [new Date(start), new Date(start + DAY_MS)]; +} + +export function dailyLeaderboard( + context: ViewContext, + daysAgo: number +): Promise { + const [from, to] = utcDayRange(daysAgo); + return cached(context, `daily:${from.toISOString()}`, async () => { + const [rolls, table] = await Promise.all([ + store.rolls(context.guildId, { from, to, byScore: true }), + store.scoreTable(), + ]); + const top = rolls.slice(0, DAILY_SIZE); + const players = await resolvePlayers( + context.client, + top.map((roll) => roll.userId) + ); + const rows = top.flatMap((roll, index) => { + const player = players.get(roll.userId); + return player + ? [ + { + rank: index + 1, + ...player, + number: roll.number, + score: roll.score, + percent: table.percentOf(roll.score), + tier: table.tierOf(roll.score), + }, + ] + : []; + }); + const best = top[0]?.score; + if (rows.length === 0 || best === undefined) { + return null; + } + + const { t } = context; + return { + image: await renderDailyLeaderboard(rows, { + rank: t("leaderboard.header.rank"), + player: t("leaderboard.header.player"), + number: t("leaderboard.header.number"), + score: t("leaderboard.header.score"), + placement: t("leaderboard.header.placement"), + }), + winnerIds: [ + ...new Set( + top.filter((roll) => roll.score === best).map((roll) => roll.userId) + ), + ], + }; + }); +} + +export function overallPage( + context: ViewContext, + requestedPage: number, + callerId: string +): Promise { + return cached(context, `overall:${requestedPage}:${callerId}`, async () => { + const [totals, accounts] = await Promise.all([ + store.totals(context.guildId), + store.accounts(context.guildId), + ]); + if (totals.length === 0) { + return null; + } + + const pageCount = Math.ceil(totals.length / OVERALL_PAGE_SIZE); + const page = Math.min(Math.max(requestedPage, 1), pageCount); + const start = (page - 1) * OVERALL_PAGE_SIZE; + const callerIndex = totals.findIndex((entry) => entry.userId === callerId); + const indexes = totals + .slice(start, start + OVERALL_PAGE_SIZE) + .map((_, offset) => start + offset); + const callerOffPage = + callerIndex >= 0 && + (callerIndex < start || callerIndex >= start + OVERALL_PAGE_SIZE); + + const usernames = new Map(accounts.map((a) => [a.userId, a.username])); + const shown = callerOffPage ? [...indexes, callerIndex] : indexes; + const players = await resolvePlayers( + context.client, + shown.map((index) => totals[index]!.userId) + ); + const toRow = (index: number) => { + const { userId, total } = totals[index]!; + const player = players.get(userId); + return { + rank: index + 1, + name: player?.name ?? usernames.get(userId) ?? "?", + avatar: player?.avatar ?? null, + totalScore: total, + }; + }; + + const image = await renderOverallLeaderboard( + indexes.map(toRow), + { + rank: context.t("leaderboard.header.rank"), + player: context.t("leaderboard.header.player"), + overallScore: context.t("overall.header.score"), + }, + context.locale, + callerOffPage ? toRow(callerIndex) : undefined + ); + return { image, page, pageCount }; + }); +} + +export function profileCard( + context: ViewContext, + account: Account +): Promise { + return cached(context, `profile:${account.userId}`, async () => { + const [rolls, totals, accounts, table] = await Promise.all([ + store.rolls(account.guildId, { userId: account.userId }), + store.totals(account.guildId), + store.accounts(account.guildId), + store.scoreTable(), + ]); + const stats = computeRollStats(rolls, table); + if (!stats) { + return null; + } + + const player = (await resolvePlayers(context.client, [account.userId])).get( + account.userId + ); + const formatDate = new Intl.DateTimeFormat(context.locale, { + day: "2-digit", + month: "short", + year: "numeric", + timeZone: "UTC", + }); + const toRoll = (roll: typeof stats.best) => ({ + number: roll.number, + score: roll.score, + tier: table.tierOf(roll.score), + dateText: formatDate.format(roll.rolledAt), + }); + const { t } = context; + + return renderProfile( + { + username: account.username, + avatar: player?.avatar ?? null, + serverRank: + totals.findIndex((entry) => entry.userId === account.userId) + 1, + totalPlayers: accounts.length, + best: toRoll(stats.best), + worst: toRoll(stats.worst), + totalRolls: stats.totalRolls, + averageScore: stats.averageScore, + averageTier: table.tierOf(stats.averageScore), + maxBadges: stats.maxBadges, + totalScore: stats.totalScore, + tierCounts: stats.tierCounts, + }, + { + bestRoll: t("profile.bestRoll"), + worstRoll: t("profile.worstRoll"), + date: (date) => t("profile.date", { date }), + totalRolls: t("profile.totalRolls"), + averageScore: t("profile.averageScore"), + maxBadges: t("profile.maxBadges"), + maxBadgesValue: (count) => t("profile.maxBadgesValue", { count }), + overallScore: t("profile.overallScore"), + tierBreakdown: t("profile.tierBreakdown"), + } + ); + }); +} + +export function serverCard( + context: ViewContext, + guild: Guild +): Promise { + return cached(context, "server", async () => { + const [rolls, accounts, table] = await Promise.all([ + store.rolls(guild.id), + store.accounts(guild.id), + store.scoreTable(), + ]); + const stats = computeServerStats(rolls, table); + if (!stats) { + return null; + } + + const players = await resolvePlayers(context.client, [ + stats.best.userId, + stats.worst.userId, + ...TIERS.flatMap((tier) => stats.tierLeaders[tier]), + ]); + const usernames = new Map(accounts.map((a) => [a.userId, a.username])); + const toRoll = (roll: typeof stats.best) => ({ + number: roll.number, + score: roll.score, + tier: table.tierOf(roll.score), + playerName: usernames.get(roll.userId) ?? "?", + avatar: players.get(roll.userId)?.avatar ?? null, + }); + const tierLeaders = Object.fromEntries( + TIERS.map((tier) => [ + tier, + stats.tierLeaders[tier].map((id) => players.get(id)?.avatar ?? null), + ]) + ) as Record; + const { t } = context; + + return renderServerStats( + { + icon: await fetchImage(guild.iconURL({ extension: "png", size: 128 })), + best: toRoll(stats.best), + worst: toRoll(stats.worst), + totalRolls: stats.totalRolls, + averageScore: stats.averageScore, + averageTier: table.tierOf(stats.averageScore), + overallScore: stats.totalScore, + tierCounts: stats.tierCounts, + tierLeaders, + }, + { + title: t("server.title"), + bestRoll: t("server.bestRoll"), + worstRoll: t("server.worstRoll"), + by: (name) => t("server.by", { name }), + totalRolls: t("server.totalRolls"), + averageScore: t("server.averageScore"), + overallScore: t("server.overallScore"), + tierBreakdown: t("server.tierBreakdown"), + } + ); + }); +} diff --git a/src/modules/rngdle/tasks.ts b/src/modules/rngdle/tasks.ts new file mode 100644 index 00000000..5479dd06 --- /dev/null +++ b/src/modules/rngdle/tasks.ts @@ -0,0 +1,84 @@ +import { AttachmentBuilder, type Client } from "discord.js"; +import configService from "#core/services/config.service.js"; +import moduleService from "#core/services/module.service.js"; +import { loggerMaker } from "#lib/logger.js"; +import type { Module } from "#lib/module.js"; +import { declareTask } from "#lib/task.js"; +import type { RngdleConfigSchema } from "./rngdle.config.js"; +import { fetchScoreTable } from "./services/api.js"; +import store from "./services/store.js"; +import sync from "./services/sync.js"; +import { dailyLeaderboard } from "./services/views.js"; + +const logger = loggerMaker("rngdle"); + +async function postDailyLeaderboard( + client: Client, + module: Module, + guildId: string +): Promise { + const config = await configService.getConfigForModuleIn(module, guildId); + const channel = config.get("leaderboardChannel"); + if (!channel?.isSendable()) { + return; + } + + const board = await dailyLeaderboard( + { client, guildId, t: config.t, locale: config.locale }, + 1 + ); + if (!board) { + return; + } + + await channel.send({ + content: config.t("daily.message", { + winners: board.winnerIds.map((id) => `<@${id}>`).join(" "), + }), + files: [new AttachmentBuilder(board.image, { name: "leaderboard.png" })], + allowedMentions: { users: board.winnerIds }, + }); + logger.info(`Daily leaderboard posted | guildId = ${guildId}`); +} + +export function rngdleTasks(module: Module) { + return [ + declareTask({ + id: "sync", + schedule: "0 6,18 * * *", + run: () => sync.syncAll().then(() => undefined), + }), + declareTask({ + id: "score-table", + schedule: "0 3 * * 1", + runOnStart: true, + async run() { + const table = await fetchScoreTable(); + if (!table) { + logger.warn("No usable score table on rngdle.com, keeping ours"); + } else if (await store.replaceScoreTable(table)) { + logger.info("Score table changed, downloading every roll again"); + await sync.syncAll(true); + } + }, + }), + declareTask({ + id: "daily-leaderboard", + schedule: "0 1 * * *", + async run(client) { + await sync.syncAll(); + for (const guildId of await moduleService.getActivatedGuildIds( + module.id + )) { + await postDailyLeaderboard(client, module, guildId).catch( + (err: unknown) => + logger.error( + { err }, + `Failed to post the daily leaderboard | guildId = ${guildId}` + ) + ); + } + }, + }), + ]; +} diff --git a/src/modules/rngdle/tasks/daily-leaderboard.task.ts b/src/modules/rngdle/tasks/daily-leaderboard.task.ts deleted file mode 100644 index 8755e798..00000000 --- a/src/modules/rngdle/tasks/daily-leaderboard.task.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { AttachmentBuilder, type Client } from "discord.js"; -import { loggerMaker } from "#lib/logger.js"; -import type { Module } from "#lib/module.js"; -import { declareTask } from "#lib/task.js"; -import type { RngdleConfigSchema } from "#modules/rngdle/rngdle.config.js"; -import { - buildDailyLeaderboard, - utcDayRange, -} from "#modules/rngdle/services/boards.js"; -import syncService from "#modules/rngdle/services/sync.service.js"; - -const logger = loggerMaker("rngdle"); - -export async function postDailyLeaderboard( - client: Client, - module: Module, - guildId: string -): Promise { - const { default: configService } = - await import("#core/services/config.service.js"); - const config = await configService.getConfigForModuleIn(module, guildId); - const channel = config.get("leaderboardChannel"); - if (!channel?.isSendable()) { - return false; - } - - const board = await buildDailyLeaderboard( - client, - guildId, - utcDayRange(1), - config.t - ); - if (!board) { - return false; - } - - await channel.send({ - content: config.t("daily.message", { - winners: board.winnerIds.map((id) => `<@${id}>`).join(" "), - }), - files: [new AttachmentBuilder(board.image, { name: "leaderboard.png" })], - allowedMentions: { users: board.winnerIds }, - }); - return true; -} - -export function createDailyLeaderboardTask(module: Module) { - return declareTask({ - id: "daily-leaderboard", - schedule: "0 1 * * *", - async run(client) { - await syncService.syncAll(); - - const { default: moduleService } = - await import("#core/services/module.service.js"); - for (const guildId of await moduleService.getActivatedGuildIds( - module.id - )) { - try { - if (await postDailyLeaderboard(client, module, guildId)) { - logger.info(`Daily leaderboard posted | guildId = ${guildId}`); - } - } catch (err) { - logger.error( - { err }, - `Failed to post the daily leaderboard | guildId = ${guildId}` - ); - } - } - }, - }); -} diff --git a/src/modules/rngdle/tasks/score-table.task.ts b/src/modules/rngdle/tasks/score-table.task.ts deleted file mode 100644 index ff204b9b..00000000 --- a/src/modules/rngdle/tasks/score-table.task.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { declareTask } from "#lib/task.js"; -import scoreTableService from "#modules/rngdle/services/score-table.service.js"; -import syncService from "#modules/rngdle/services/sync.service.js"; - -export default declareTask({ - id: "score-table", - schedule: "0 3 * * 1", - runOnStart: true, - async run() { - if (await scoreTableService.refresh()) { - await syncService.syncAll({ full: true }); - } - }, -}); diff --git a/src/modules/rngdle/tasks/sync.task.ts b/src/modules/rngdle/tasks/sync.task.ts deleted file mode 100644 index e5ed1410..00000000 --- a/src/modules/rngdle/tasks/sync.task.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { declareTask } from "#lib/task.js"; -import syncService from "#modules/rngdle/services/sync.service.js"; - -export default declareTask({ - id: "sync", - schedule: "0 6,18 * * *", - async run() { - await syncService.syncAll(); - }, -}); From 278f4b61ccca57ad3ce7cf4e6a59008bea1608c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?RAMELOT=20Lo=C3=AFc?= Date: Sat, 3 Oct 2026 11:25:57 +0200 Subject: [PATCH 3/5] feat(rngdle): fiches en paysage, plus lisibles --- docs/site/en/guide/images.md | 26 +-- docs/site/fr/guide/images.md | 26 +-- src/lib/stat-card.test.ts | 29 ++- src/lib/stat-card.ts | 209 +++++++++++++-------- src/modules/rngdle/i18n/en.json | 3 +- src/modules/rngdle/i18n/fr.json | 3 +- src/modules/rngdle/rendering/cards.test.ts | 11 +- src/modules/rngdle/rendering/cards.ts | 172 ++++++++++------- src/modules/rngdle/services/views.ts | 3 +- 9 files changed, 288 insertions(+), 194 deletions(-) diff --git a/docs/site/en/guide/images.md b/docs/site/en/guide/images.md index 0f0f1f48..2d77f2cd 100644 --- a/docs/site/en/guide/images.md +++ b/docs/site/en/guide/images.md @@ -2,11 +2,11 @@ Leaderboards and stat cards are rendered as PNG images with [`@napi-rs/canvas`](https://github.com/Brooooooklyn/canvas) (prebuilt binaries, nothing to install). Two ready-made renderers cover most needs; lower-level helpers are there for anything else. -| Module | Provides | -| ------------------------ | ---------------------------------------------------------------------------------------------------- | -| `#lib/leaderboard-table` | `renderLeaderboardTable`: ranked table with medals, avatars, names and your own columns | -| `#lib/stat-card` | `renderStatCard`: card with a portrait, a title, an optional rank, a grid of stat boxes and a footer | -| `#lib/imaging` | Shared palette and font, avatar download, circular images, fitted text, assets | +| Module | Provides | +| ------------------------ | -------------------------------------------------------------------------------------------------------- | +| `#lib/leaderboard-table` | `renderLeaderboardTable`: ranked table with medals, avatars, names and your own columns | +| `#lib/stat-card` | `renderStatCard`: card with a portrait, a title, an optional rank, a grid of stat boxes and a side panel | +| `#lib/imaging` | Shared palette and font, avatar download, circular images, fitted text, assets | The Outfit font and the gold, silver and bronze medals ship in `src/lib/assets/` and are loaded for you. @@ -57,9 +57,9 @@ import { renderStatCard } from "#lib/stat-card.js"; const png = await renderStatCard({ image: await fetchAvatar(user), - title: { text: user.username, y: 35, size: 50 }, + title: user.username, + subtitle: t("stats.memberSince", { date }), rank: { position: 3, total: 42 }, - valueSize: 30, rows: [ [ { title: t("stats.messages"), value: "1 204" }, @@ -69,17 +69,19 @@ const png = await renderStatCard({ { title: t("stats.joined"), value: "2024-03-01", - subtext: t("stats.joinedHint"), + subtext: t("stats.hint"), }, ], ], }); ``` -- **Rows** hold one to three boxes, laid out on the full width, in halves or in thirds. -- **A box** has a `title` and a `value`, and optionally a `color` (value and outline), a `suffix` after the value, a `subtext` and a small corner `avatar`. -- **The rank** shows a medal for the podium, `#N / total` otherwise; it is hidden when `position` is 0. -- **A footer** (`{ height, draw(ctx, top) }`) adds a custom panel under the boxes. `drawPanel`, `drawPanelTitle`, `drawText` and `measureText` from `#lib/stat-card` keep it consistent with the rest of the card; the RNGdle tier breakdown is built this way (`src/modules/rngdle/rendering/cards.ts`). +The card is 1000 px wide, landscape, so Discord displays it large enough to read. + +- **The header** shows the round `image`, the `title`, an optional `subtitle` and an optional `rank`: a medal for the podium, `#N / total` otherwise, hidden when `position` is 0. +- **Rows** hold one to three boxes, which share the row's width. +- **A box** has a `title` and a `value`, and optionally a `color` (value and outline; `outline: false` keeps the color without the frame), a `subtext` and a small corner `avatar`. Text that is too long shrinks to fit the box. +- **An aside** (`{ width, draw(ctx, x, y, width, height) }`) adds a custom panel on the right of the boxes, as tall as them. `drawPanel`, `drawPanelTitle`, `drawText`, `drawFittedText` and `measureText` from `#lib/stat-card` keep it consistent with the rest of the card; the RNGdle tier breakdown is built this way (`src/modules/rngdle/rendering/cards.ts`). ## Good Practices diff --git a/docs/site/fr/guide/images.md b/docs/site/fr/guide/images.md index f3303bfa..0cd27ebe 100644 --- a/docs/site/fr/guide/images.md +++ b/docs/site/fr/guide/images.md @@ -2,11 +2,11 @@ Les classements et les fiches de statistiques sont rendus en images PNG avec [`@napi-rs/canvas`](https://github.com/Brooooooklyn/canvas) (binaires précompilés, rien à installer). Deux générateurs prêts à l'emploi couvrent l'essentiel des besoins ; des fonctions de plus bas niveau sont là pour le reste. -| Module | Fournit | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------- | -| `#lib/leaderboard-table` | `renderLeaderboardTable` : tableau classé avec médailles, avatars, pseudos et vos propres colonnes | -| `#lib/stat-card` | `renderStatCard` : fiche avec portrait, titre, rang optionnel, grille de cases de statistiques et pied personnalisé | -| `#lib/imaging` | Palette et police communes, téléchargement d'avatars, images rondes, texte ajusté, ressources | +| Module | Fournit | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| `#lib/leaderboard-table` | `renderLeaderboardTable` : tableau classé avec médailles, avatars, pseudos et vos propres colonnes | +| `#lib/stat-card` | `renderStatCard` : fiche avec portrait, titre, rang optionnel, grille de cases de statistiques et panneau latéral | +| `#lib/imaging` | Palette et police communes, téléchargement d'avatars, images rondes, texte ajusté, ressources | La police Outfit et les médailles or, argent et bronze sont fournies dans `src/lib/assets/` et chargées pour vous. @@ -57,9 +57,9 @@ import { renderStatCard } from "#lib/stat-card.js"; const png = await renderStatCard({ image: await fetchAvatar(user), - title: { text: user.username, y: 35, size: 50 }, + title: user.username, + subtitle: t("stats.memberSince", { date }), rank: { position: 3, total: 42 }, - valueSize: 30, rows: [ [ { title: t("stats.messages"), value: "1 204" }, @@ -69,17 +69,19 @@ const png = await renderStatCard({ { title: t("stats.joined"), value: "01/03/2024", - subtext: t("stats.joinedHint"), + subtext: t("stats.hint"), }, ], ], }); ``` -- **Les lignes** contiennent une à trois cases, disposées sur toute la largeur, en moitiés ou en tiers. -- **Une case** a un `title` et une `value`, et éventuellement une `color` (valeur et contour), un `suffix` après la valeur, un `subtext` et un petit `avatar` dans le coin. -- **Le rang** affiche une médaille pour le podium, `#N / total` sinon ; il est masqué quand `position` vaut 0. -- **Un pied** (`{ height, draw(ctx, top) }`) ajoute un panneau personnalisé sous les cases. `drawPanel`, `drawPanelTitle`, `drawText` et `measureText` de `#lib/stat-card` le gardent cohérent avec le reste de la fiche ; la répartition par palier de RNGdle est construite ainsi (`src/modules/rngdle/rendering/cards.ts`). +La fiche fait 1000 px de large, en paysage, pour que Discord l'affiche assez grande pour être lue. + +- **L'en-tête** affiche l'`image` ronde, le `title`, un `subtitle` optionnel et un `rank` optionnel : une médaille pour le podium, `#N / total` sinon, masqué quand `position` vaut 0. +- **Les lignes** contiennent une à trois cases, qui se partagent la largeur de la ligne. +- **Une case** a un `title` et une `value`, et éventuellement une `color` (valeur et contour ; `outline: false` garde la couleur sans le cadre), un `subtext` et un petit `avatar` dans le coin. Un texte trop long rétrécit pour tenir dans la case. +- **Un panneau latéral** (`{ width, draw(ctx, x, y, width, height) }`) ajoute un panneau personnalisé à droite des cases, de la même hauteur qu'elles. `drawPanel`, `drawPanelTitle`, `drawText`, `drawFittedText` et `measureText` de `#lib/stat-card` le gardent cohérent avec le reste de la fiche ; la répartition par palier de RNGdle est construite ainsi (`src/modules/rngdle/rendering/cards.ts`). ## Bonnes pratiques diff --git a/src/lib/stat-card.test.ts b/src/lib/stat-card.test.ts index b2a902c6..191e2ef1 100644 --- a/src/lib/stat-card.test.ts +++ b/src/lib/stat-card.test.ts @@ -10,39 +10,38 @@ const box = { title: "Title", value: "42" }; function card(overrides: Partial = {}): StatCard { return { image: null, - title: { text: "Player", y: 35, size: 50 }, - valueSize: 30, + title: "Player", rows: [[box, box]], ...overrides, }; } describe("renderStatCard", () => { - it("grows with its rows and its footer", async () => { - const twoRows = await renderStatCard( - card({ rows: [[box], [box, box, box]] }) - ); - const withFooter = await renderStatCard( - card({ footer: { height: 240, draw: vi.fn() } }) + it("grows with its rows", async () => { + const oneRow = await renderStatCard(card()); + const threeRows = await renderStatCard( + card({ rows: [[box], [box, box], [box, box, box]] }) ); - expect(pngSize(twoRows)).toEqual({ width: 800, height: 450 }); - expect(pngSize(withFooter)).toEqual({ width: 800, height: 580 }); + expect(pngSize(oneRow)).toEqual({ width: 1000, height: 300 }); + expect(pngSize(threeRows)).toEqual({ width: 1000, height: 560 }); }); - it("hands the footer its top position", async () => { + it("gives the aside panel the column right of the boxes", async () => { const draw = vi.fn(); await renderStatCard( - card({ rows: [[box], [box]], footer: { height: 10, draw } }) + card({ rows: [[box], [box]], aside: { width: 300, draw } }) ); - expect(draw).toHaveBeenCalledWith(expect.anything(), 440); + expect(draw).toHaveBeenCalledWith(expect.anything(), 660, 160, 300, 240); }); - it("renders a rank, with or without a medal", async () => { + it("renders a subtitle and a rank, with or without a medal", async () => { for (const position of [1, 7]) { - const png = await renderStatCard(card({ rank: { position, total: 12 } })); + const png = await renderStatCard( + card({ subtitle: "Subtitle", rank: { position, total: 12 } }) + ); expect(png.subarray(1, 4).toString("ascii")).toBe("PNG"); } }); diff --git a/src/lib/stat-card.ts b/src/lib/stat-card.ts index 958977b0..9fb65f6d 100644 --- a/src/lib/stat-card.ts +++ b/src/lib/stat-card.ts @@ -3,6 +3,7 @@ import { createBoard, drawCircularImage, fillTextAnchored, + fillTextCentered, font, loadMedals, PALETTE, @@ -10,65 +11,63 @@ import { type Rgb, } from "./imaging.js"; -const WIDTH = 800; -const HEADER_HEIGHT = 150; -export const CARD_MARGIN = 40; -export const CARD_CONTENT_WIDTH = WIDTH - 2 * CARD_MARGIN; -const PORTRAIT_Y = 25; -const PORTRAIT_SIZE = 100; -const TITLE_X = 170; +const WIDTH = 1000; +const MARGIN = 40; +const HEADER_HEIGHT = 130; +const HEADER_CENTER = HEADER_HEIGHT / 2; +const PORTRAIT_SIZE = 90; +const TITLE_X = MARGIN + PORTRAIT_SIZE + 24; +const TITLE_SIZE = 44; +const SUBTITLE_SIZE = 20; -const ROWS_TOP = 180; -const ROW_STEP = 130; -const BOX_HEIGHT = 110; +const BODY_TOP = HEADER_HEIGHT + 30; const BOTTOM_MARGIN = 30; +const GAP = 20; +const BOX_HEIGHT = 110; const PANEL_RADIUS = 12; export const PANEL_PADDING = 20; -const BOX_TITLE_Y = 15; -const BOX_VALUE_Y = 45; -const BOX_SUBTEXT_Y = 80; -const BOX_AVATAR_SIZE = 32; -const BOX_AVATAR_INSET = { x: 15, y: 10 }; -export const LABEL_SIZE = 22; -const SUBTEXT_SIZE = 18; -const SUFFIX_COLOR: Rgb = [215, 215, 215]; -const ROW_LAYOUTS: Record = { - 1: { width: CARD_CONTENT_WIDTH, xs: [CARD_MARGIN] }, - 2: { width: 345, xs: [40, 415] }, - 3: { width: 226, xs: [40, 286, 532] }, -}; - -const RANK_RIGHT = 760; -const RANK_SIZE = 80; -const RANK_Y = 25; +export const LABEL_SIZE = 19; +const VALUE_SIZE = 32; +const SUBTEXT_SIZE = 17; +const BOX_TITLE_Y = 16; +const BOX_VALUE_Y = 42; +const BOX_SUBTEXT_Y = 84; +const BOX_AVATAR_SIZE = 30; +const BOX_AVATAR_INSET = 14; + +const RANK_SIZE = 64; +const TOTAL_SIZE = 26; const RANK_COLOR: Rgb = [150, 150, 150]; -const TOTAL_SIZE = 30; -const TOTAL_Y = 70; -const MEDAL_Y = 15; -const MEDAL_HEIGHT = 90; -const MEDAL_WIDTHS = [90, 93, 93]; +const MEDAL_HEIGHT = 76; +const MEDAL_WIDTHS = [76, 78, 78]; export interface StatBox { title: string; value: string; color?: Rgb; - suffix?: string; + outline?: boolean; subtext?: string; avatar?: Image | null; } -export interface CardSection { - height: number; - draw: (ctx: SKRSContext2D, top: number) => void; +export interface CardAside { + width: number; + draw: ( + ctx: SKRSContext2D, + x: number, + y: number, + width: number, + height: number + ) => void; } export interface StatCard { image: Image | null; - title: { text: string; y: number; size: number }; + title: string; + subtitle?: string; rank?: { position: number; total: number }; - valueSize: number; rows: StatBox[][]; - footer?: CardSection; + aside?: CardAside; } export function drawText( @@ -84,6 +83,22 @@ export function drawText( fillTextAnchored(ctx, value, x, y, "ascender"); } +export function drawFittedText( + ctx: SKRSContext2D, + value: string, + x: number, + y: number, + size: number, + color: Rgb, + maxWidth: number +): void { + let fitted = size; + while (fitted > 10 && measureText(ctx, value, fitted) > maxWidth) { + fitted -= 1; + } + drawText(ctx, value, x, y + (size - fitted) * 0.5, fitted, color); +} + export function measureText( ctx: SKRSContext2D, value: string, @@ -102,12 +117,12 @@ export function drawPanel( outline?: Rgb ): void { ctx.beginPath(); - ctx.roundRect(x, y, width + 1, height + 1, PANEL_RADIUS); + ctx.roundRect(x, y, width, height, PANEL_RADIUS); ctx.fillStyle = rgb(PALETTE.box); ctx.fill(); if (outline) { ctx.beginPath(); - ctx.roundRect(x + 1, y + 1, width - 1, height - 1, PANEL_RADIUS - 1); + ctx.roundRect(x + 1, y + 1, width - 2, height - 2, PANEL_RADIUS - 1); ctx.lineWidth = 2; ctx.strokeStyle = rgb(outline); ctx.stroke(); @@ -132,34 +147,43 @@ export function drawPanelTitle( function drawStatBox( ctx: SKRSContext2D, - valueSize: number, x: number, y: number, width: number, - { title, value, color, suffix, subtext, avatar }: StatBox + { title, value, color, outline = true, subtext, avatar }: StatBox ): void { const left = x + PANEL_PADDING; - drawPanel(ctx, x, y, width, BOX_HEIGHT, color); + drawPanel(ctx, x, y, width, BOX_HEIGHT, outline ? color : undefined); drawPanelTitle(ctx, title, x, y); if (avatar !== undefined) { - const avatarX = x + width - BOX_AVATAR_SIZE - BOX_AVATAR_INSET.x; - const avatarY = y + BOX_AVATAR_INSET.y; - drawCircularImage(ctx, avatar, avatarX, avatarY, BOX_AVATAR_SIZE); - } - const valueY = y + BOX_VALUE_Y; - drawText(ctx, value, left, valueY, valueSize, color ?? PALETTE.text); - if (suffix) { - const suffixX = left + measureText(ctx, `${value} `, valueSize); - drawText(ctx, suffix, suffixX, valueY, valueSize, SUFFIX_COLOR); + const avatarX = x + width - BOX_AVATAR_SIZE - BOX_AVATAR_INSET; + drawCircularImage( + ctx, + avatar, + avatarX, + y + BOX_AVATAR_INSET, + BOX_AVATAR_SIZE + ); } + const innerWidth = width - 2 * PANEL_PADDING; + drawFittedText( + ctx, + value, + left, + y + BOX_VALUE_Y, + VALUE_SIZE, + color ?? PALETTE.text, + innerWidth + ); if (subtext) { - drawText( + drawFittedText( ctx, subtext, left, y + BOX_SUBTEXT_Y, SUBTEXT_SIZE, - PALETTE.subtext + PALETTE.subtext, + innerWidth ); } } @@ -169,60 +193,87 @@ function drawRank( { position, total }: { position: number; total: number }, medals: Image[] ): void { + const right = WIDTH - MARGIN; const totalText = ` / ${total}`; - const right = RANK_RIGHT - measureText(ctx, totalText, TOTAL_SIZE); + ctx.textAlign = "right"; + ctx.font = font(TOTAL_SIZE); + ctx.fillStyle = rgb(PALETTE.subtext); + fillTextCentered(ctx, totalText, right, HEADER_CENTER + 14); + const before = right - measureText(ctx, totalText, TOTAL_SIZE); + const medal = medals[position - 1]; - const width = MEDAL_WIDTHS[position - 1]; - if (medal && width) { + const medalWidth = MEDAL_WIDTHS[position - 1]; + if (medal && medalWidth) { ctx.drawImage( medal, - Math.trunc(right - width), - MEDAL_Y, - width, + Math.round(before - medalWidth), + HEADER_CENTER - MEDAL_HEIGHT / 2, + medalWidth, MEDAL_HEIGHT ); } else { - const rankText = `#${position}`; - const rankX = right - measureText(ctx, rankText, RANK_SIZE); - drawText(ctx, rankText, rankX, RANK_Y, RANK_SIZE, RANK_COLOR); + ctx.font = font(RANK_SIZE); + ctx.fillStyle = rgb(RANK_COLOR); + fillTextCentered(ctx, `#${position}`, before, HEADER_CENTER); } - drawText(ctx, totalText, right, TOTAL_Y, TOTAL_SIZE, PALETTE.subtext); + ctx.textAlign = "left"; } export async function renderStatCard(card: StatCard): Promise { const medals = card.rank ? await loadMedals() : []; - const footerTop = ROWS_TOP + card.rows.length * ROW_STEP; - const height = - footerTop + (card.footer?.height ?? BOX_HEIGHT - ROW_STEP) + BOTTOM_MARGIN; - const ctx = createBoard(WIDTH, height, HEADER_HEIGHT); + const bodyHeight = + card.rows.length * BOX_HEIGHT + (card.rows.length - 1) * GAP; + const ctx = createBoard( + WIDTH, + BODY_TOP + bodyHeight + BOTTOM_MARGIN, + HEADER_HEIGHT + ); ctx.textAlign = "left"; - drawCircularImage(ctx, card.image, CARD_MARGIN, PORTRAIT_Y, PORTRAIT_SIZE); - const { text, y, size } = card.title; - drawText(ctx, text, TITLE_X, y, size, PALETTE.text); + const portraitY = HEADER_CENTER - PORTRAIT_SIZE / 2; + drawCircularImage(ctx, card.image, MARGIN, portraitY, PORTRAIT_SIZE); + ctx.font = font(TITLE_SIZE); + ctx.fillStyle = rgb(PALETTE.text); + const titleY = card.subtitle ? HEADER_CENTER - 12 : HEADER_CENTER; + fillTextCentered(ctx, card.title, TITLE_X, titleY); + if (card.subtitle) { + ctx.font = font(SUBTITLE_SIZE); + ctx.fillStyle = rgb(PALETTE.subtext); + fillTextCentered(ctx, card.subtitle, TITLE_X, HEADER_CENTER + 26); + } if (card.rank && card.rank.position > 0) { drawRank(ctx, card.rank, medals); } + const contentWidth = WIDTH - 2 * MARGIN; + const asideWidth = card.aside ? card.aside.width + GAP : 0; + const boxesWidth = contentWidth - asideWidth; card.rows.forEach((boxes, row) => { - const layout = ROW_LAYOUTS[boxes.length]; - if (!layout) { + if (boxes.length === 0 || boxes.length > 3) { throw new Error( `A stat card row holds 1 to 3 boxes, got ${boxes.length}` ); } + const width = (boxesWidth - GAP * (boxes.length - 1)) / boxes.length; boxes.forEach((box, index) => { drawStatBox( ctx, - card.valueSize, - layout.xs[index]!, - ROWS_TOP + row * ROW_STEP, - layout.width, + MARGIN + index * (width + GAP), + BODY_TOP + row * (BOX_HEIGHT + GAP), + width, box ); }); }); - card.footer?.draw(ctx, footerTop); + if (card.aside) { + card.aside.draw( + ctx, + MARGIN + boxesWidth + GAP, + BODY_TOP, + card.aside.width, + bodyHeight + ); + } return ctx.canvas.encode("png"); } diff --git a/src/modules/rngdle/i18n/en.json b/src/modules/rngdle/i18n/en.json index ac2b0f0c..8b767c03 100644 --- a/src/modules/rngdle/i18n/en.json +++ b/src/modules/rngdle/i18n/en.json @@ -20,7 +20,7 @@ "profile.noRolls": "No roll found for `{{username}}` yet.", "profile.bestRoll": "Best Roll", "profile.worstRoll": "Worst Roll", - "profile.date": "Date: {{date}}", + "profile.roll": "roll {{number}}", "profile.totalRolls": "Total Rolls", "profile.averageScore": "Average Score", "profile.maxBadges": "Max Badges", @@ -33,6 +33,7 @@ "server.bestRoll": "Best Roll OAT", "server.worstRoll": "Worst Roll OAT", "server.by": "by {{name}}", + "server.roll": "roll {{number}}", "server.totalRolls": "Total Rolls", "server.averageScore": "Average Score", "server.overallScore": "Overall Score", diff --git a/src/modules/rngdle/i18n/fr.json b/src/modules/rngdle/i18n/fr.json index 72bbf59d..e908bc9d 100644 --- a/src/modules/rngdle/i18n/fr.json +++ b/src/modules/rngdle/i18n/fr.json @@ -20,7 +20,7 @@ "profile.noRolls": "Aucun tirage trouvé pour `{{username}}` pour l'instant.", "profile.bestRoll": "Meilleur tirage", "profile.worstRoll": "Pire tirage", - "profile.date": "Date : {{date}}", + "profile.roll": "tirage {{number}}", "profile.totalRolls": "Tirages", "profile.averageScore": "Score moyen", "profile.maxBadges": "Badges max", @@ -33,6 +33,7 @@ "server.bestRoll": "Meilleur tirage", "server.worstRoll": "Pire tirage", "server.by": "par {{name}}", + "server.roll": "tirage {{number}}", "server.totalRolls": "Tirages", "server.averageScore": "Score moyen", "server.overallScore": "Score total", diff --git a/src/modules/rngdle/rendering/cards.test.ts b/src/modules/rngdle/rendering/cards.test.ts index 0dbb78fd..1dcae65d 100644 --- a/src/modules/rngdle/rendering/cards.test.ts +++ b/src/modules/rngdle/rendering/cards.test.ts @@ -74,7 +74,7 @@ describe("renderProfile", () => { const labels = { bestRoll: "Best Roll", worstRoll: "Worst Roll", - date: (date: string) => `Date : ${date}`, + roll: (number: string) => `roll ${number}`, totalRolls: "Total Rolls", averageScore: "Average Score", maxBadges: "Max Badges", @@ -83,12 +83,12 @@ describe("renderProfile", () => { tierBreakdown: "Tier Breakdown", }; - it("renders an 800x840 PNG for every kind of rank", async () => { + it("renders a 1000x560 PNG for every kind of rank", async () => { const pngs = await Promise.all( [0, 1, 2, 3, 7].map((rank) => renderProfile(profile(rank), labels)) ); for (const png of pngs) { - expect(pngSize(png)).toEqual({ width: 800, height: 840 }); + expect(pngSize(png)).toEqual({ width: 1000, height: 560 }); } expect(new Set(pngs.map((png) => png.toString("base64"))).size).toBe(5); }); @@ -100,14 +100,15 @@ describe("renderServerStats", () => { bestRoll: "Best Roll OAT", worstRoll: "Worst Roll OAT", by: (name: string) => `by ${name}`, + roll: (number: string) => `roll ${number}`, totalRolls: "Total Rolls", averageScore: "Average Score", overallScore: "Overall Score", tierBreakdown: "Tier Breakdown", }; - it.each([0, 3, 5])("renders an 800x710 PNG with %i leaders", async (n) => { + it.each([0, 3, 5])("renders a 1000x560 PNG with %i leaders", async (n) => { const png = await renderServerStats(serverStats(n), labels); - expect(pngSize(png)).toEqual({ width: 800, height: 710 }); + expect(pngSize(png)).toEqual({ width: 1000, height: 560 }); }); }); diff --git a/src/modules/rngdle/rendering/cards.ts b/src/modules/rngdle/rendering/cards.ts index c2bc1741..0659c993 100644 --- a/src/modules/rngdle/rendering/cards.ts +++ b/src/modules/rngdle/rendering/cards.ts @@ -1,15 +1,14 @@ import type { Image, SKRSContext2D } from "@napi-rs/canvas"; import { drawCircularImage, PALETTE, rgb } from "#lib/imaging.js"; import { - CARD_CONTENT_WIDTH, - CARD_MARGIN, drawPanel, drawPanelTitle, drawText, LABEL_SIZE, measureText, + PANEL_PADDING, renderStatCard, - type CardSection, + type CardAside, type StatBox, } from "#lib/stat-card.js"; import { @@ -20,17 +19,26 @@ import { } from "#modules/rngdle/services/scoring.js"; import { formatSpaced } from "./format.js"; -const BREAKDOWN_HEIGHT = 240; -const BREAKDOWN_ROWS_Y = 60; -const BREAKDOWN_COLUMNS = [80, 440]; -const BREAKDOWN_PER_COLUMN = 4; +const OUTLINED_TIERS = new Set([ + "MYTHIC", + "ANOMALY", + "EPIC", + "RARE", + "TRASH", +]); const BREAKDOWN_TIERS = [...TIERS].reverse(); -const COUNT_RIGHT = 220; -const LEADERS_X = 235; -const LEADER_SIZE = 24; +const BREAKDOWN_TOP = 52; +const BREAKDOWN_BOTTOM = 18; +const TIER_LABEL_SIZE = 17; +const TIER_LABEL_WIDTH = 128; +const COUNT_WIDTH = 66; +const BAR_HEIGHT = 10; +const BAR_TRACK: [number, number, number] = [50, 50, 50]; +const LEADER_SIZE = 22; const LEADER_STEP = 14; const LEADER_BORDER = 2; const MAX_LEADERS = 3; +const LEADERS_WIDTH = LEADER_SIZE + (MAX_LEADERS - 1) * LEADER_STEP + 8; interface Roll { number: number; @@ -60,7 +68,7 @@ export interface ProfileImageData { export interface ProfileLabels { bestRoll: string; worstRoll: string; - date: (date: string) => string; + roll: (number: string) => string; totalRolls: string; averageScore: string; maxBadges: string; @@ -91,102 +99,132 @@ export interface ServerStatsLabels { bestRoll: string; worstRoll: string; by: (name: string) => string; + roll: (number: string) => string; totalRolls: string; averageScore: string; overallScore: string; tierBreakdown: string; } +function ep(value: number): string { + return `${formatSpaced(value)} EP`; +} + +function scoreBox(title: string, score: number, tier: TierOrError): StatBox { + return { + title, + value: ep(score), + color: TIER_COLORS[tier], + outline: OUTLINED_TIERS.has(tier), + }; +} + function drawLeaders( ctx: SKRSContext2D, leaders: (Image | null)[], x: number, - y: number + centerY: number ): void { const radius = LEADER_SIZE / 2; + const top = centerY - radius; for (let index = leaders.length - 1; index >= 0; index--) { const left = x + index * LEADER_STEP; ctx.beginPath(); - ctx.arc(left + radius, y + radius, radius + LEADER_BORDER, 0, Math.PI * 2); + ctx.arc(left + radius, centerY, radius + LEADER_BORDER, 0, Math.PI * 2); ctx.fillStyle = rgb(PALETTE.box); ctx.fill(); - drawCircularImage(ctx, leaders[index] ?? null, left, y, LEADER_SIZE); + drawCircularImage(ctx, leaders[index] ?? null, left, top, LEADER_SIZE); } } function tierBreakdown( title: string, counts: Record, - rowStep: number, leaders?: Record -): CardSection { +): CardAside { return { - height: BREAKDOWN_HEIGHT, - draw(ctx, top) { - drawPanel(ctx, CARD_MARGIN, top, CARD_CONTENT_WIDTH, BREAKDOWN_HEIGHT); - drawPanelTitle(ctx, title, CARD_MARGIN, top); + width: leaders ? 420 : 350, + draw(ctx, x, y, width, height) { + drawPanel(ctx, x, y, width, height); + drawPanelTitle(ctx, title, x, y); + + const max = Math.max(1, ...Object.values(counts)); + const step = + (height - BREAKDOWN_TOP - BREAKDOWN_BOTTOM) / BREAKDOWN_TIERS.length; + const labelX = x + PANEL_PADDING; + const barX = labelX + TIER_LABEL_WIDTH; + const countRight = + x + width - PANEL_PADDING - (leaders ? LEADERS_WIDTH : 0); + const barWidth = countRight - COUNT_WIDTH - barX; + BREAKDOWN_TIERS.forEach((tier, index) => { - const x = BREAKDOWN_COLUMNS[Math.floor(index / BREAKDOWN_PER_COLUMN)]!; - const y = - top + BREAKDOWN_ROWS_Y + rowStep * (index % BREAKDOWN_PER_COLUMN); - drawText(ctx, `${tier}:`, x, y, LABEL_SIZE, TIER_COLORS[tier]); - const count = formatSpaced(counts[tier] ?? 0); - const countX = x + COUNT_RIGHT - measureText(ctx, count, LABEL_SIZE); - drawText(ctx, count, countX, y, LABEL_SIZE, PALETTE.text); + const count = counts[tier] ?? 0; + const centerY = y + BREAKDOWN_TOP + step * (index + 0.5); + const color = count > 0 ? TIER_COLORS[tier] : PALETTE.subtext; + const labelTop = centerY - TIER_LABEL_SIZE * 0.62; + drawText(ctx, tier, labelX, labelTop, TIER_LABEL_SIZE, color); + + ctx.beginPath(); + ctx.roundRect(barX, centerY - BAR_HEIGHT / 2, barWidth, BAR_HEIGHT, 5); + ctx.fillStyle = rgb(BAR_TRACK); + ctx.fill(); + if (count > 0) { + const filled = Math.max(BAR_HEIGHT, (count / max) * barWidth); + ctx.beginPath(); + ctx.roundRect(barX, centerY - BAR_HEIGHT / 2, filled, BAR_HEIGHT, 5); + ctx.fillStyle = rgb(TIER_COLORS[tier]); + ctx.fill(); + } + + const text = formatSpaced(count); + const countX = countRight - measureText(ctx, text, LABEL_SIZE); + drawText( + ctx, + text, + countX, + centerY - LABEL_SIZE * 0.62, + LABEL_SIZE, + count > 0 ? PALETTE.text : PALETTE.subtext + ); if (leaders) { const tierLeaders = (leaders[tier] ?? []).slice(0, MAX_LEADERS); - drawLeaders(ctx, tierLeaders, x + LEADERS_X, y - 1); + drawLeaders(ctx, tierLeaders, countRight + 12, centerY); } }); }, }; } -function ep(value: number): string { - return `${formatSpaced(value)} EP`; -} - -function rollBox(title: string, roll: Roll, subtext: string): StatBox { - return { - title, - value: formatSpaced(roll.number), - color: TIER_COLORS[roll.tier], - suffix: `(${ep(roll.score)})`, - subtext, - }; -} - export function renderProfile( data: ProfileImageData, labels: ProfileLabels ): Promise { + const rollBox = (title: string, roll: ProfileRoll): StatBox => ({ + ...scoreBox(title, roll.score, roll.tier), + subtext: `${labels.roll(formatSpaced(roll.number))} · ${roll.dateText}`, + }); return renderStatCard({ image: data.avatar, - title: { text: data.username, y: 35, size: 50 }, + title: data.username, rank: { position: data.serverRank, total: data.totalPlayers }, - valueSize: 30, rows: [ [ - rollBox(labels.bestRoll, data.best, labels.date(data.best.dateText)), - rollBox(labels.worstRoll, data.worst, labels.date(data.worst.dateText)), + rollBox(labels.bestRoll, data.best), + rollBox(labels.worstRoll, data.worst), ], [ - { title: labels.totalRolls, value: formatSpaced(data.totalRolls) }, - { - title: labels.averageScore, - value: ep(data.averageScore), - color: TIER_COLORS[data.averageTier], - }, + scoreBox(labels.averageScore, data.averageScore, data.averageTier), + { title: labels.overallScore, value: ep(data.totalScore) }, ], [ + { title: labels.totalRolls, value: formatSpaced(data.totalRolls) }, { title: labels.maxBadges, value: labels.maxBadgesValue(data.maxBadges), }, - { title: labels.overallScore, value: ep(data.totalScore) }, ], ], - footer: tierBreakdown(labels.tierBreakdown, data.tierCounts, 40), + aside: tierBreakdown(labels.tierBreakdown, data.tierCounts), }); } @@ -194,30 +232,28 @@ export function renderServerStats( data: ServerStatsImageData, labels: ServerStatsLabels ): Promise { - const roll = (title: string, value: ServerStatsRoll): StatBox => ({ - ...rollBox(title, value, labels.by(value.playerName)), - avatar: value.avatar, + const rollBox = (title: string, roll: ServerStatsRoll): StatBox => ({ + ...scoreBox(title, roll.score, roll.tier), + subtext: `${labels.by(roll.playerName)} · ${labels.roll(formatSpaced(roll.number))}`, + avatar: roll.avatar, }); return renderStatCard({ image: data.icon, - title: { text: labels.title, y: 45, size: 45 }, - valueSize: 28, + title: labels.title, rows: [ - [roll(labels.bestRoll, data.best), roll(labels.worstRoll, data.worst)], [ - { title: labels.totalRolls, value: formatSpaced(data.totalRolls) }, - { - title: labels.averageScore, - value: ep(data.averageScore), - color: TIER_COLORS[data.averageTier], - }, + rollBox(labels.bestRoll, data.best), + rollBox(labels.worstRoll, data.worst), + ], + [ + scoreBox(labels.averageScore, data.averageScore, data.averageTier), { title: labels.overallScore, value: ep(data.overallScore) }, ], + [{ title: labels.totalRolls, value: formatSpaced(data.totalRolls) }], ], - footer: tierBreakdown( + aside: tierBreakdown( labels.tierBreakdown, data.tierCounts, - 45, data.tierLeaders ), }); diff --git a/src/modules/rngdle/services/views.ts b/src/modules/rngdle/services/views.ts index 0827c124..3554ecbf 100644 --- a/src/modules/rngdle/services/views.ts +++ b/src/modules/rngdle/services/views.ts @@ -241,7 +241,7 @@ export function profileCard( { bestRoll: t("profile.bestRoll"), worstRoll: t("profile.worstRoll"), - date: (date) => t("profile.date", { date }), + roll: (number) => t("profile.roll", { number }), totalRolls: t("profile.totalRolls"), averageScore: t("profile.averageScore"), maxBadges: t("profile.maxBadges"), @@ -306,6 +306,7 @@ export function serverCard( bestRoll: t("server.bestRoll"), worstRoll: t("server.worstRoll"), by: (name) => t("server.by", { name }), + roll: (number) => t("server.roll", { number }), totalRolls: t("server.totalRolls"), averageScore: t("server.averageScore"), overallScore: t("server.overallScore"), From be92c7c1d7a9246c9995c5bb95d8bd8afd467e71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?RAMELOT=20Lo=C3=AFc?= Date: Sat, 3 Oct 2026 11:29:58 +0200 Subject: [PATCH 4/5] =?UTF-8?q?fix(images):=20m=C3=A9daille=20seule=20pour?= =?UTF-8?q?=20le=20podium?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/stat-card.ts | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/lib/stat-card.ts b/src/lib/stat-card.ts index 9fb65f6d..efdf86e9 100644 --- a/src/lib/stat-card.ts +++ b/src/lib/stat-card.ts @@ -194,28 +194,28 @@ function drawRank( medals: Image[] ): void { const right = WIDTH - MARGIN; - const totalText = ` / ${total}`; - ctx.textAlign = "right"; - ctx.font = font(TOTAL_SIZE); - ctx.fillStyle = rgb(PALETTE.subtext); - fillTextCentered(ctx, totalText, right, HEADER_CENTER + 14); - const before = right - measureText(ctx, totalText, TOTAL_SIZE); - const medal = medals[position - 1]; const medalWidth = MEDAL_WIDTHS[position - 1]; if (medal && medalWidth) { ctx.drawImage( medal, - Math.round(before - medalWidth), + right - medalWidth, HEADER_CENTER - MEDAL_HEIGHT / 2, medalWidth, MEDAL_HEIGHT ); - } else { - ctx.font = font(RANK_SIZE); - ctx.fillStyle = rgb(RANK_COLOR); - fillTextCentered(ctx, `#${position}`, before, HEADER_CENTER); + return; } + + const totalText = ` / ${total}`; + ctx.textAlign = "right"; + ctx.font = font(TOTAL_SIZE); + ctx.fillStyle = rgb(PALETTE.subtext); + fillTextCentered(ctx, totalText, right, HEADER_CENTER + 14); + const rankRight = right - measureText(ctx, totalText, TOTAL_SIZE); + ctx.font = font(RANK_SIZE); + ctx.fillStyle = rgb(RANK_COLOR); + fillTextCentered(ctx, `#${position}`, rankRight, HEADER_CENTER); ctx.textAlign = "left"; } From 197a799bb02f404a3e6906719ccac1758ce5f4e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?RAMELOT=20Lo=C3=AFc?= Date: Sat, 3 Oct 2026 11:43:34 +0200 Subject: [PATCH 5/5] fix(rngdle): corrige les retours de la revue --- .../commands/rngdle-admin.command.test.ts | 64 +++++++++++++++++++ .../rngdle/commands/rngdle-admin.command.ts | 4 +- src/modules/rngdle/services/views.test.ts | 25 ++++---- src/modules/rngdle/services/views.ts | 29 ++++----- 4 files changed, 93 insertions(+), 29 deletions(-) create mode 100644 src/modules/rngdle/commands/rngdle-admin.command.test.ts diff --git a/src/modules/rngdle/commands/rngdle-admin.command.test.ts b/src/modules/rngdle/commands/rngdle-admin.command.test.ts new file mode 100644 index 00000000..cb08e497 --- /dev/null +++ b/src/modules/rngdle/commands/rngdle-admin.command.test.ts @@ -0,0 +1,64 @@ +import type { ChatInputCommandInteraction } from "discord.js"; +import { describe, expect, it, vi } from "vitest"; +import type { ConfigProvider } from "#lib/config.js"; +import type { RngdleConfigSchema } from "#modules/rngdle/rngdle.config.js"; + +const order = vi.hoisted(() => [] as string[]); + +vi.mock("#core/utils/require-admin.js", () => ({ + requireAdmin: async () => true, +})); +vi.mock("#modules/rngdle/services/store.js", () => ({ + default: { + unregister: async () => { + order.push("unregister"); + return true; + }, + }, +})); +vi.mock("#modules/rngdle/services/sync.js", () => ({ + default: { + exclusive: async (_guildId: string, task: () => Promise) => { + order.push("queue"); + return task(); + }, + }, + FullSyncCooldownError: class extends Error {}, +})); +vi.mock("#modules/rngdle/services/api.js", () => ({ + fetchUserRolls: vi.fn(), + RngdleUserNotFoundError: class extends Error {}, +})); + +const { default: command } = await import("./rngdle-admin.command.js"); + +describe("/rngdle-admin delete", () => { + it("defers the reply before waiting for the guild's sync queue", async () => { + const interaction = { + guildId: "guild", + inGuild: () => true, + user: { id: "admin" }, + options: { + getSubcommand: () => "delete", + getUser: () => ({ id: "member" }), + }, + deferReply: vi.fn(async () => order.push("defer")), + editReply: vi.fn(async () => order.push("edit")), + reply: vi.fn(), + }; + const config = { + t: (key: string) => key, + } as unknown as ConfigProvider; + + await command.execute( + interaction as unknown as ChatInputCommandInteraction, + config + ); + + expect(order).toEqual(["defer", "queue", "unregister", "edit"]); + expect(interaction.reply).not.toHaveBeenCalled(); + expect(interaction.editReply).toHaveBeenCalledWith( + expect.objectContaining({ content: "admin.delete.done" }) + ); + }); +}); diff --git a/src/modules/rngdle/commands/rngdle-admin.command.ts b/src/modules/rngdle/commands/rngdle-admin.command.ts index 5bdb3c51..10cbe24e 100644 --- a/src/modules/rngdle/commands/rngdle-admin.command.ts +++ b/src/modules/rngdle/commands/rngdle-admin.command.ts @@ -87,14 +87,14 @@ async function register(interaction: Interaction, config: Config) { async function unregister(interaction: Interaction, config: Config) { const member = interaction.options.getUser("member", true); + await interaction.deferReply(ephemeral); const deleted = await sync.exclusive(interaction.guildId, () => store.unregister(interaction.guildId, member.id) ); - await interaction.reply({ + await interaction.editReply({ content: config.t(deleted ? "admin.delete.done" : "admin.delete.none", { user: `<@${member.id}>`, }), - ...ephemeral, ...noPings, }); } diff --git a/src/modules/rngdle/services/views.test.ts b/src/modules/rngdle/services/views.test.ts index 8808deb3..499af852 100644 --- a/src/modules/rngdle/services/views.test.ts +++ b/src/modules/rngdle/services/views.test.ts @@ -132,26 +132,27 @@ describe("dailyLeaderboard", () => { expect(rowsOf()).toHaveLength(25); }); - it("skips players that cannot be resolved but keeps the ranks", async () => { + it("keeps players Discord cannot resolve, under their RNGdle username", async () => { + mocks.accounts.mockResolvedValue([ + { guildId: "g", userId: "ghost", username: "ghost-rngdle" }, + ]); mocks.rolls.mockResolvedValue([ - roll("a", 30), - roll("ghost", 20), - roll("b", 10), + roll("ghost", 30), + roll("a", 20), + roll("unknown", 10), ]); - await dailyLeaderboard(context, 0); + const board = await dailyLeaderboard(context, 0); expect(rowsOf().map((row) => [row.rank, row.name])).toEqual([ - [1, "name-a"], - [3, "name-b"], + [1, "ghost-rngdle"], + [2, "name-a"], + [3, "?"], ]); + expect(board?.winnerIds).toEqual(["ghost"]); }); - it("returns null without rolls or when nobody can be resolved", async () => { - expect(await dailyLeaderboard(context, 0)).toBeNull(); - - setup(); - mocks.rolls.mockResolvedValue([roll("ghost", 5)]); + it("returns null without rolls", async () => { expect(await dailyLeaderboard(context, 0)).toBeNull(); expect(mocks.renderDaily).not.toHaveBeenCalled(); }); diff --git a/src/modules/rngdle/services/views.ts b/src/modules/rngdle/services/views.ts index 3554ecbf..b7cb5ddc 100644 --- a/src/modules/rngdle/services/views.ts +++ b/src/modules/rngdle/services/views.ts @@ -86,32 +86,31 @@ export function dailyLeaderboard( ): Promise { const [from, to] = utcDayRange(daysAgo); return cached(context, `daily:${from.toISOString()}`, async () => { - const [rolls, table] = await Promise.all([ + const [rolls, table, accounts] = await Promise.all([ store.rolls(context.guildId, { from, to, byScore: true }), store.scoreTable(), + store.accounts(context.guildId), ]); const top = rolls.slice(0, DAILY_SIZE); + const usernames = new Map(accounts.map((a) => [a.userId, a.username])); const players = await resolvePlayers( context.client, top.map((roll) => roll.userId) ); - const rows = top.flatMap((roll, index) => { + const rows = top.map((roll, index) => { const player = players.get(roll.userId); - return player - ? [ - { - rank: index + 1, - ...player, - number: roll.number, - score: roll.score, - percent: table.percentOf(roll.score), - tier: table.tierOf(roll.score), - }, - ] - : []; + return { + rank: index + 1, + name: player?.name ?? usernames.get(roll.userId) ?? "?", + avatar: player?.avatar ?? null, + number: roll.number, + score: roll.score, + percent: table.percentOf(roll.score), + tier: table.tierOf(roll.score), + }; }); const best = top[0]?.score; - if (rows.length === 0 || best === undefined) { + if (best === undefined) { return null; }