diff --git a/AGENTS.md b/AGENTS.md index d8284f88..c24cbdef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,8 @@ 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) | +| 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/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..4be32013 100644 --- a/docs/site/.vitepress/config.ts +++ b/docs/site/.vitepress/config.ts @@ -51,6 +51,8 @@ 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: "Images", link: "/fr/guide/images" }, { text: "Base de données", link: "/fr/guide/database" }, { text: "Contribuer", link: "/fr/guide/contributing" }, ], @@ -106,6 +108,8 @@ 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: "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..2d77f2cd --- /dev/null +++ b/docs/site/en/guide/images.md @@ -0,0 +1,91 @@ +# 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 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. + +## 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: user.username, + subtitle: t("stats.memberSince", { date }), + rank: { position: 3, total: 42 }, + 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.hint"), + }, + ], + ], +}); +``` + +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 + +- **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 new file mode 100644 index 00000000..6369aac8 --- /dev/null +++ b/docs/site/en/guide/tasks.md @@ -0,0 +1,42 @@ +# 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. 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..0cd27ebe --- /dev/null +++ b/docs/site/fr/guide/images.md @@ -0,0 +1,91 @@ +# 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 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. + +## 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: user.username, + subtitle: t("stats.memberSince", { date }), + rank: { position: 3, total: 42 }, + 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.hint"), + }, + ], + ], +}); +``` + +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 + +- **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 new file mode 100644 index 00000000..66e2a444 --- /dev/null +++ b/docs/site/fr/guide/tasks.md @@ -0,0 +1,42 @@ +# 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. diff --git a/docs/specs/rngdle-migration.md b/docs/specs/rngdle-migration.md new file mode 100644 index 00000000..b1c78d2f --- /dev/null +++ b/docs/specs/rngdle-migration.md @@ -0,0 +1,58 @@ +# Spec — Module RNGdle : portage depuis DJ4H + +> **Statut : implémenté.** + +## 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 +├── 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 +├── services/ +│ ├── api.ts # client rngdle.com (validation, nouvelles tentatives) +│ ├── scoring.ts # table des percentiles, paliers (pur) +│ ├── stats.ts # agrégats profil / serveur (pur) +│ ├── store.ts # persistance : comptes, tirages, table +│ ├── sync.ts # synchronisation, file par serveur +│ └── views.ts # données des images, résolution Discord, cache +└── rendering/ + ├── 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 ». 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. + +## 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/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/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/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/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/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 f3d1e629..02e5e751 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,11 @@ import { loadModuleI18n, loadModules, } from "./core/loaders/module-loader.js"; +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"; @@ -33,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 ?? []) @@ -45,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(); @@ -66,6 +72,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 +118,7 @@ const shutdown = async () => { shuttingDown = true; logger.info("Shutting down..."); + stopAllTasks(); try { await client.destroy(); await prisma.$disconnect(); 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/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..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,25 +61,61 @@ export function loadAsset(file: URL): Promise { return image; } -export async function fetchAvatar( - user: User, - size: 64 | 128 | 256 = 128 -): Promise { +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; + } 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 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, @@ -75,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); @@ -98,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/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/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..191e2ef1 --- /dev/null +++ b/src/lib/stat-card.test.ts @@ -0,0 +1,54 @@ +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: "Player", + rows: [[box, box]], + ...overrides, + }; +} + +describe("renderStatCard", () => { + 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(oneRow)).toEqual({ width: 1000, height: 300 }); + expect(pngSize(threeRows)).toEqual({ width: 1000, height: 560 }); + }); + + it("gives the aside panel the column right of the boxes", async () => { + const draw = vi.fn(); + + await renderStatCard( + card({ rows: [[box], [box]], aside: { width: 300, draw } }) + ); + + expect(draw).toHaveBeenCalledWith(expect.anything(), 660, 160, 300, 240); + }); + + it("renders a subtitle and a rank, with or without a medal", async () => { + for (const position of [1, 7]) { + const png = await renderStatCard( + card({ subtitle: "Subtitle", 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..efdf86e9 --- /dev/null +++ b/src/lib/stat-card.ts @@ -0,0 +1,279 @@ +import type { Image, SKRSContext2D } from "@napi-rs/canvas"; +import { + createBoard, + drawCircularImage, + fillTextAnchored, + fillTextCentered, + font, + loadMedals, + PALETTE, + rgb, + type Rgb, +} from "./imaging.js"; + +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 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; +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 MEDAL_HEIGHT = 76; +const MEDAL_WIDTHS = [76, 78, 78]; + +export interface StatBox { + title: string; + value: string; + color?: Rgb; + outline?: boolean; + subtext?: string; + avatar?: Image | null; +} + +export interface CardAside { + width: number; + draw: ( + ctx: SKRSContext2D, + x: number, + y: number, + width: number, + height: number + ) => void; +} + +export interface StatCard { + image: Image | null; + title: string; + subtitle?: string; + rank?: { position: number; total: number }; + rows: StatBox[][]; + aside?: CardAside; +} + +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 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, + 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, height, PANEL_RADIUS); + ctx.fillStyle = rgb(PALETTE.box); + ctx.fill(); + if (outline) { + ctx.beginPath(); + ctx.roundRect(x + 1, y + 1, width - 2, height - 2, 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, + x: number, + y: number, + width: number, + { title, value, color, outline = true, subtext, avatar }: StatBox +): void { + const left = x + PANEL_PADDING; + 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; + 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) { + drawFittedText( + ctx, + subtext, + left, + y + BOX_SUBTEXT_Y, + SUBTEXT_SIZE, + PALETTE.subtext, + innerWidth + ); + } +} + +function drawRank( + ctx: SKRSContext2D, + { position, total }: { position: number; total: number }, + medals: Image[] +): void { + const right = WIDTH - MARGIN; + const medal = medals[position - 1]; + const medalWidth = MEDAL_WIDTHS[position - 1]; + if (medal && medalWidth) { + ctx.drawImage( + medal, + right - medalWidth, + HEADER_CENTER - MEDAL_HEIGHT / 2, + medalWidth, + MEDAL_HEIGHT + ); + 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"; +} + +export async function renderStatCard(card: StatCard): Promise { + const medals = card.rank ? await loadMedals() : []; + 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"; + + 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) => { + 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, + MARGIN + index * (width + GAP), + BODY_TOP + row * (BOX_HEIGHT + GAP), + width, + box + ); + }); + }); + 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/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/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/arrow_up.png b/src/modules/rngdle/assets/arrow_up.png new file mode 100644 index 00000000..2606e6e6 Binary files /dev/null and b/src/modules/rngdle/assets/arrow_up.png differ 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 00000000..7c38f236 Binary files /dev/null and b/src/modules/rngdle/assets/spacemono_bold.ttf differ diff --git a/src/modules/rngdle/assets/trash.png b/src/modules/rngdle/assets/trash.png new file mode 100644 index 00000000..60dc81b6 Binary files /dev/null and b/src/modules/rngdle/assets/trash.png differ 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 new file mode 100644 index 00000000..10cbe24e --- /dev/null +++ b/src/modules/rngdle/commands/rngdle-admin.command.ts @@ -0,0 +1,249 @@ +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/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"; + +const PERMISSION_ADMINISTRATOR = 0x8; +const USERNAME_MAX_LENGTH = 64; + +const logger = loggerMaker("rngdle"); + +type Interaction = ChatInputCommandInteraction<"cached" | "raw">; +type Config = ConfigProvider; + +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(ephemeral); + + const rolls = await fetchUserRolls(username, null).catch((err: unknown) => { + if (!(err instanceof RngdleUserNotFoundError)) { + logger.warn({ err }, `rngdle.com unreachable | username = ${username}`); + } + return err instanceof RngdleUserNotFoundError ? "notFound" : "unreachable"; + }); + if (typeof rolls === "string") { + await interaction.editReply( + config.t(`admin.register.${rolls}`, { username }) + ); + return; + } + + 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({ + content: config.t("admin.register.conflict", { + username, + user: `<@${outcome.conflict}>`, + }), + ...noPings, + }); + return; + } + + logger.info( + `Account ${outcome.result} | guildId = ${guildId} | userId = ${member.id} | username = ${username} | by = ${interaction.user.id}` + ); + await interaction.editReply({ + content: config.t(`admin.register.${outcome.result}`, { + user: `<@${member.id}>`, + username, + count: outcome.inserted, + }), + ...noPings, + }); +} + +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.editReply({ + content: config.t(deleted ? "admin.delete.done" : "admin.delete.none", { + user: `<@${member.id}>`, + }), + ...noPings, + }); +} + +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: Interaction, config: Config) { + await interaction.deferReply(ephemeral); + const full = interaction.options.getBoolean("full") ?? false; + try { + 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)) { + throw err; + } + await interaction.editReply( + config.t("admin.refresh.cooldown", { + time: ``, + }) + ); + } +} + +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}` + ); + 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") + .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() && (await requireAdmin(interaction, config.t))) { + await subcommands[interaction.options.getSubcommand()]?.( + interaction, + config + ); + } + }, +}); diff --git a/src/modules/rngdle/commands/rngdle.command.ts b/src/modules/rngdle/commands/rngdle.command.ts new file mode 100644 index 00000000..52376c49 --- /dev/null +++ b/src/modules/rngdle/commands/rngdle.command.ts @@ -0,0 +1,203 @@ +import { + AttachmentBuilder, + InteractionContextType, + MessageFlags, + SlashCommandBuilder, + type ChatInputCommandInteraction, +} from "discord.js"; +import { declareCommand } from "#lib/command.js"; +import type { ConfigProvider } from "#lib/config.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 { + dailyLeaderboard, + profileCard, + serverCard, + type ViewContext, +} from "#modules/rngdle/services/views.js"; + +const AUTOCOMPLETE_LIMIT = 25; + +type Interaction = ChatInputCommandInteraction<"cached" | "raw">; +type Config = ConfigProvider; + +function viewContext(interaction: Interaction, config: Config): ViewContext { + return { + client: interaction.client, + guildId: interaction.guildId, + t: config.t, + locale: config.locale, + }; +} + +async function replyWithImage( + interaction: Interaction, + image: Buffer | null, + name: string, + emptyMessage: string +) { + await interaction.editReply( + image + ? { files: [new AttachmentBuilder(image, { name })] } + : { content: emptyMessage } + ); +} + +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") + ); +} + +async function profile(interaction: Interaction, config: Config) { + const account = interaction.options.getString("username") + ? await store.accountByUsername( + interaction.guildId, + interaction.options.getString("username", true) + ) + : await store.account( + interaction.guildId, + (interaction.options.getUser("member") ?? interaction.user).id + ); + if (!account) { + await interaction.editReply(config.t("profile.notRegistered")); + return; + } + await replyWithImage( + interaction, + await profileCard(viewContext(interaction, config), account), + `profile-${account.username}.png`, + config.t("profile.noRolls", { username: account.username }) + ); +} + +async function serverStats(interaction: Interaction, config: Config) { + const guild = + interaction.guild ?? + (await interaction.client.guilds.fetch(interaction.guildId)); + await replyWithImage( + interaction, + await serverCard(viewContext(interaction, config), guild), + "server-stats.png", + config.t("server.empty") + ); +} + +async function leaderboardAll(interaction: Interaction, config: Config) { + await interaction.editReply( + await overallPageMessage( + viewContext(interaction, config), + interaction.options.getInteger("page") ?? 1, + interaction.user.id, + "public" + ) + ); +} + +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") + .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; + } + 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) { + const typed = interaction.options.getFocused().toLowerCase(); + const accounts = interaction.guildId + ? await store.accounts(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..8b767c03 --- /dev/null +++ b/src/modules/rngdle/i18n/en.json @@ -0,0 +1,59 @@ +{ + "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.roll": "roll {{number}}", + "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.roll": "roll {{number}}", + "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..e908bc9d --- /dev/null +++ b/src/modules/rngdle/i18n/fr.json @@ -0,0 +1,59 @@ +{ + "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.roll": "tirage {{number}}", + "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.roll": "tirage {{number}}", + "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..db45f3e9 --- /dev/null +++ b/src/modules/rngdle/interactions/overall-page.button.ts @@ -0,0 +1,104 @@ +import { + ActionRowBuilder, + AttachmentBuilder, + ButtonBuilder, + ButtonStyle, + MessageFlags, + type ButtonInteraction, +} from "discord.js"; +import { declareInteractionHandler } from "#lib/interaction.js"; +import { + overallPage, + type ViewContext, +} from "#modules/rngdle/services/views.js"; + +const CUSTOM_ID = "rngdle-overall"; + +type Visibility = "public" | "private"; + +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, + 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({ + 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; + } + + 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, + "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..1dcae65d --- /dev/null +++ b/src/modules/rngdle/rendering/cards.test.ts @@ -0,0 +1,114 @@ +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", + roll: (number: string) => `roll ${number}`, + totalRolls: "Total Rolls", + averageScore: "Average Score", + maxBadges: "Max Badges", + maxBadgesValue: (count: number) => `${count} badges at once`, + overallScore: "Overall Score", + tierBreakdown: "Tier Breakdown", + }; + + 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: 1000, height: 560 }); + } + 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}`, + roll: (number: string) => `roll ${number}`, + totalRolls: "Total Rolls", + averageScore: "Average Score", + overallScore: "Overall Score", + tierBreakdown: "Tier Breakdown", + }; + + 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: 1000, height: 560 }); + }); +}); diff --git a/src/modules/rngdle/rendering/cards.ts b/src/modules/rngdle/rendering/cards.ts new file mode 100644 index 00000000..0659c993 --- /dev/null +++ b/src/modules/rngdle/rendering/cards.ts @@ -0,0 +1,260 @@ +import type { Image, SKRSContext2D } from "@napi-rs/canvas"; +import { drawCircularImage, PALETTE, rgb } from "#lib/imaging.js"; +import { + drawPanel, + drawPanelTitle, + drawText, + LABEL_SIZE, + measureText, + PANEL_PADDING, + renderStatCard, + type CardAside, + 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 OUTLINED_TIERS = new Set([ + "MYTHIC", + "ANOMALY", + "EPIC", + "RARE", + "TRASH", +]); +const BREAKDOWN_TIERS = [...TIERS].reverse(); +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; + 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; + roll: (number: 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; + 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, + 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, centerY, radius + LEADER_BORDER, 0, Math.PI * 2); + ctx.fillStyle = rgb(PALETTE.box); + ctx.fill(); + drawCircularImage(ctx, leaders[index] ?? null, left, top, LEADER_SIZE); + } +} + +function tierBreakdown( + title: string, + counts: Record, + leaders?: Record +): CardAside { + return { + 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 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, countRight + 12, centerY); + } + }); + }, + }; +} + +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: data.username, + rank: { position: data.serverRank, total: data.totalPlayers }, + rows: [ + [ + rollBox(labels.bestRoll, data.best), + rollBox(labels.worstRoll, data.worst), + ], + [ + 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), + }, + ], + ], + aside: tierBreakdown(labels.tierBreakdown, data.tierCounts), + }); +} + +export function renderServerStats( + data: ServerStatsImageData, + labels: ServerStatsLabels +): Promise { + 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: labels.title, + rows: [ + [ + 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) }], + ], + aside: tierBreakdown( + labels.tierBreakdown, + data.tierCounts, + data.tierLeaders + ), + }); +} diff --git a/src/modules/rngdle/rendering/format.test.ts b/src/modules/rngdle/rendering/format.test.ts new file mode 100644 index 00000000..f8b511a4 --- /dev/null +++ b/src/modules/rngdle/rendering/format.test.ts @@ -0,0 +1,41 @@ +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"], + [1234.9, "1 234"], + ])("formats %s as %s", (value, text) => { + expect(formatSpaced(value)).toBe(text); + }); +}); + +describe("formatCompact", () => { + it.each([ + [999, "999"], + [1000, "1.0k"], + [999_999, "1000.0k"], + [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"], + [1000, "en", "1k"], + [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"], + ])("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/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..e8c6f908 --- /dev/null +++ b/src/modules/rngdle/rngdle.module.ts @@ -0,0 +1,42 @@ +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 { rngdleTasks } from "./tasks.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); + for (const task of rngdleTasks(rngdleModule)) { + registry.register(task); + } + }, + + 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/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/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/scoring.ts b/src/modules/rngdle/services/scoring.ts new file mode 100644 index 00000000..c6ff4a24 --- /dev/null +++ b/src/modules/rngdle/services/scoring.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..3921652c --- /dev/null +++ b/src/modules/rngdle/services/stats.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from "vitest"; +import { ScoreTable } from "./scoring.js"; +import { computeRollStats, computeServerStats } from "./stats.js"; +import type { StoredRoll } from "./store.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): StoredRoll { + const number = ++counter; + 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("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).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"); + }); + + 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( + scores.map((score) => roll("a", score)), + table + )!; + + expect(stats.totalRolls).toBe(10); + expect(stats.tierCounts).toEqual({ + TRASH: 2, + COMMON: 1, + UNCOMMON: 1, + RARE: 1, + EPIC: 1, + ANOMALY: 1, + MYTHIC: 2, + }); + }); +}); + +describe("computeServerStats", () => { + it("returns null without rolls", () => { + expect(computeServerStats([], table)).toBeNull(); + }); + + it("includes the roll stats and orders tier leaders by count, at most 3, skipping ERROR rolls", () => { + const rolls = [ + roll("a", 100), + roll("b", 100), + roll("b", 100), + roll("c", 100), + roll("c", 100), + roll("c", 100), + roll("d", 100), + roll("a", 650), + roll("z", -10), + ]; + + const stats = computeServerStats(rolls, table)!; + + 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 new file mode 100644 index 00000000..66892cd9 --- /dev/null +++ b/src/modules/rngdle/services/stats.ts @@ -0,0 +1,111 @@ +import { TIERS, type ScoreTable, type Tier } from "./scoring.js"; +import type { StoredRoll } from "./store.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/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/store.ts b/src/modules/rngdle/services/store.ts new file mode 100644 index 00000000..fa1ffebd --- /dev/null +++ b/src/modules/rngdle/services/store.ts @@ -0,0 +1,253 @@ +import { readFileSync } from "node:fs"; +import prisma from "#lib/database.js"; +import { declareService, type Service } from "#lib/service.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; + userId: string; + username: string; +} + +export interface StoredRoll { + userId: string; + number: number; + score: number; + badgeCount: number; + rolledAt: Date; +} + +export interface RollFilter { + userId?: string; + from?: Date; + to?: Date; + byScore?: boolean; +} + +export type RegisterResult = "created" | "renamed" | "unchanged"; + +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 RngdleStore implements Service { + private readonly revisions = new Map(); + private table: Promise | null = null; + + revision(guildId: string): number { + return this.revisions.get(guildId) ?? 0; + } + + private touch(guildId: string): void { + this.revisions.set(guildId, this.revision(guildId) + 1); + } + + async register( + guildId: string, + userId: string, + username: string + ): Promise { + const key = { guildId_userId: { guildId, userId } }; + const existing = await prisma.rngdleAccount.findUnique({ where: key }); + if (existing?.username === username) { + return "unchanged"; + } + + await prisma.$transaction([ + prisma.rngdleRoll.deleteMany({ where: { guildId, userId } }), + prisma.rngdleAccount.upsert({ + where: key, + create: { guildId, userId, username }, + update: { username }, + }), + ]); + this.touch(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) { + this.touch(guildId); + } + return deleted.count > 0; + } + + accounts(guildId: string): Promise { + return prisma.rngdleAccount.findMany({ + where: { guildId }, + orderBy: { username: "asc" }, + }); + } + + account(guildId: string, userId: string): Promise { + return prisma.rngdleAccount.findUnique({ + where: { guildId_userId: { guildId, userId } }, + }); + } + + accountByUsername( + 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.touch(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( + { guildId, userId }: Account, + rolls: RemoteRoll[] + ): Promise<{ inserted: number; updated: number }> { + if (rolls.length === 0) { + return { inserted: 0, updated: 0 }; + } + + 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); + 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.touch(guildId); + } + return { inserted: created.count, updated: changed.length }; + } + + async rolls(guildId: string, filter: RollFilter = {}): Promise { + const rolls = await prisma.rngdleRoll.findMany({ + 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((roll) => ({ ...roll, score: Number(roll.score) })); + } + + async totals(guildId: string): Promise<{ userId: string; total: number }[]> { + 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)); + } + + 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 RngdleStore()); 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..499af852 --- /dev/null +++ b/src/modules/rngdle/services/views.test.ts @@ -0,0 +1,226 @@ +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("keeps players Discord cannot resolve, under their RNGdle username", async () => { + mocks.accounts.mockResolvedValue([ + { guildId: "g", userId: "ghost", username: "ghost-rngdle" }, + ]); + mocks.rolls.mockResolvedValue([ + roll("ghost", 30), + roll("a", 20), + roll("unknown", 10), + ]); + + const board = await dailyLeaderboard(context, 0); + + expect(rowsOf().map((row) => [row.rank, row.name])).toEqual([ + [1, "ghost-rngdle"], + [2, "name-a"], + [3, "?"], + ]); + expect(board?.winnerIds).toEqual(["ghost"]); + }); + + it("returns null without rolls", async () => { + 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..b7cb5ddc --- /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, 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.map((roll, index) => { + const player = players.get(roll.userId); + 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 (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"), + roll: (number) => t("profile.roll", { number }), + 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 }), + roll: (number) => t("server.roll", { number }), + 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/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 +}