From a8833e5bd19abf87aa2eaf13ed2100cd51854c00 Mon Sep 17 00:00:00 2001 From: Antoine James Tournepiche Date: Tue, 10 Nov 2026 04:33:47 +0100 Subject: [PATCH 1/3] =?UTF-8?q?fix(rngdle):=20v=C3=A9rifie=20le=20tirage?= =?UTF-8?q?=20d=E2=80=99un=20membre=20qui=20lie=20son=20compte=20apr=C3=A8?= =?UTF-8?q?s=20sa=20carte=20du=20jour?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La carte vérifiée par le calculateur avant la liaison réservait le jour : les résultats collés ensuite, une fois lié, n’étaient plus cherchés sur rngdle.com et restaient « Non connecté » jusqu’à une synchronisation (/rngdle leaderboard). Pour un membre lié, la vérification s’arrête désormais une fois le tirage du jour enregistré. --- docs/functional.md | 4 +- .../rngdle/services/share-cards.test.ts | 74 ++++++++++++++++++- src/modules/rngdle/services/share-cards.ts | 47 +++++++----- 3 files changed, 102 insertions(+), 23 deletions(-) diff --git a/docs/functional.md b/docs/functional.md index 57009531..f160deaa 100644 --- a/docs/functional.md +++ b/docs/functional.md @@ -206,8 +206,8 @@ Inscrit ou désinscrit le membre au rappel quotidien. Réponse visible de lui se Le bot dessine une carte par tirage : numéro, score, palier et percentile, puis la liste complète des badges avec leurs EP, leur rareté et la part des numéros qui les obtiennent. Les badges englobés par un badge plus fort ne rapportent rien : ils sont grisés et marqués « inclus ». Au-delà de 24 badges, les suivants sont résumés en une ligne. La liste des badges vient du calculateur [rng.cubityfir.st](https://rng.cubityfir.st), qui recalcule les badges de n'importe quel numéro ; s'il ne répond pas en 5 secondes, la carte reprend les badges du résultat collé, sans leurs EP, leur probabilité ni la mention « inclus », ou n'affiche pas de badges. Sur une carte vérifiée, le score, le palier et le classement viennent toujours de rngdle.com : un texte retouché ne peut changer que la liste des badges, et seulement quand le calculateur ne répond pas. Pour un membre lié, la carte ajoute selon le cas : record personnel (au-dessus de tous ses tirages précédents), son rang du jour parmi les membres liés qui ont déjà tiré, et l'écart avec sa moyenne (à partir de 5 tirages précédents). -- **Résultat collé** : quand un membre colle dans le salon RNGdle le résultat partagé par rngdle.com (« RNGdle 🎲 … » jusqu'à la ligne des EP), le bot y répond avec la carte, sans le mentionner et en @silent : le message collé a déjà notifié le salon. Cela marche aussi pour les membres non liés. Les résultats collés ailleurs sont ignorés : le bot ne lit le contenu des messages que dans ce salon. Pour un membre lié, le bot cherche ce numéro parmi ses tirages enregistrés d'aujourd'hui et d'hier, et télécharge ses nouveaux tirages sur rngdle.com s'il ne le trouve pas. La carte reprend alors le score et le jour du tirage enregistré, même si le résultat est collé après minuit. Pour un membre non lié, le score collé doit correspondre au total d'EP que le calculateur donne pour ce numéro. Seul le premier résultat vérifié du jour compte : une fois qu'un membre a eu sa carte, ou si la vérification échoue (numéro inconnu, score qui ne correspond pas, site qui ne répond pas), le bot dessine la carte à partir du seul texte collé, sans aucune requête, et ne l'enregistre nulle part. Exception : un membre lié qui recolle le résultat d'un tirage déjà enregistré, avec le même score, retrouve la carte de ses vraies données. Toute carte qui ne vient pas d'un tirage enregistré d'un membre lié porte la mention « Non connecté » à côté de la date, y compris celle d'un membre non lié vérifiée par le calculateur. La vérification est faite au plus une fois toutes les 10 minutes par membre. -- **Une carte vérifiée par jour** : un membre a au plus une carte vérifiée par jour UTC et par serveur. Si l'envoi échoue, il peut recoller son résultat. +- **Résultat collé** : quand un membre colle dans le salon RNGdle le résultat partagé par rngdle.com (« RNGdle 🎲 … » jusqu'à la ligne des EP), le bot y répond avec la carte, sans le mentionner et en @silent : le message collé a déjà notifié le salon. Cela marche aussi pour les membres non liés. Les résultats collés ailleurs sont ignorés : le bot ne lit le contenu des messages que dans ce salon. Pour un membre lié, le bot cherche ce numéro parmi ses tirages enregistrés d'aujourd'hui et d'hier, et télécharge ses nouveaux tirages sur rngdle.com s'il ne le trouve pas. La carte reprend alors le score et le jour du tirage enregistré, même si le résultat est collé après minuit. Pour un membre non lié, le score collé doit correspondre au total d'EP que le calculateur donne pour ce numéro. Seul le premier résultat vérifié du jour compte : une fois qu'un membre non lié a eu sa carte, une fois que le tirage du jour d'un membre lié est enregistré (rngdle.com n'en enregistre qu'un par jour), ou si la vérification échoue (numéro inconnu, score qui ne correspond pas, site qui ne répond pas), le bot dessine la carte à partir du seul texte collé, sans aucune requête, et ne l'enregistre nulle part. Exceptions : un membre lié qui recolle le résultat d'un tirage déjà enregistré, avec le même score, retrouve la carte de ses vraies données ; un membre qui a eu sa carte avant de lier son compte voit quand même son tirage vérifié sur rngdle.com. Toute carte qui ne vient pas d'un tirage enregistré d'un membre lié porte la mention « Non connecté » à côté de la date, y compris celle d'un membre non lié vérifiée par le calculateur. La vérification est faite au plus une fois toutes les 10 minutes par membre. +- **Une carte vérifiée par jour** : un membre a au plus une carte vérifiée par jour UTC et par serveur, sauf celui qui lie son compte après une carte vérifiée par le calculateur : son tirage enregistré sur rngdle.com a aussi la sienne. Si l'envoi échoue, il peut recoller son résultat. ### Classement quotidien diff --git a/src/modules/rngdle/services/share-cards.test.ts b/src/modules/rngdle/services/share-cards.test.ts index 54f9a13d..e97924b2 100644 --- a/src/modules/rngdle/services/share-cards.test.ts +++ b/src/modules/rngdle/services/share-cards.test.ts @@ -232,17 +232,35 @@ describe("answerShare", () => { ); }); - it("sends no request once the member has a card today", async () => { - mocks.store.account.mockResolvedValue(ACCOUNT); + it("sends no request once an unlinked member has a card today", async () => { mocks.store.hasCard.mockResolvedValue(true); const message = shareMessage(); await answerShare(message as never, configWith(SHARING) as never); expect(mocks.store.hasCard).toHaveBeenCalledWith("g1", "u1", DAY); + expect(mocks.fetchBadges).not.toHaveBeenCalled(); + expect(mocks.store.claimCard).not.toHaveBeenCalled(); + expect(message.reply).toHaveBeenCalled(); + }); + + it("sends no request once a linked member's roll of today is recorded", async () => { + mocks.store.account.mockResolvedValue(ACCOUNT); + mocks.store.rolls.mockImplementation(async (_guildId, filter) => + filter?.from && filter.userId ? [roll("u1", 500)] : [] + ); + const message = shareMessage(); + await answerShare(message as never, configWith(SHARING) as never); + + expect(mocks.store.hasCard).not.toHaveBeenCalled(); expect(mocks.refreshAccount).not.toHaveBeenCalled(); expect(mocks.fetchBadges).not.toHaveBeenCalled(); expect(mocks.store.claimCard).not.toHaveBeenCalled(); expect(message.reply).toHaveBeenCalled(); + const [[data]] = mocks.render.mock.calls as unknown as [ + [{ subtitle: string; number: number }], + ]; + expect(data.number).toBe(111883); + expect(data.subtitle).toContain("card.notLinked"); }); it("checks a member's results at most once every 10 minutes", async () => { @@ -258,6 +276,16 @@ describe("answerShare", () => { expect(mocks.refreshAccount).toHaveBeenCalledTimes(2); }); + it("still checks a linked member whose last recorded roll is yesterday's", async () => { + mocks.store.account.mockResolvedValue(ACCOUNT); + mocks.store.rolls.mockResolvedValue([ + roll("u1", 500, new Date("2026-10-21T23:00:00Z")), + ]); + await answerShare(shareMessage() as never, configWith(SHARING) as never); + + expect(mocks.refreshAccount).toHaveBeenCalledWith(ACCOUNT); + }); + it.each([ [ "the feature is off", @@ -428,6 +456,48 @@ describe("answerShare", () => { } ); + describe("for a member who links after a card of the day", () => { + const LINKED_SHARE = "RNGdle 🎲 222222\n\n🟦 RARE • Top 12%\n\n52 000 EP"; + + async function pasteThenLink(minutesLater: number) { + const config = configWith(SHARING) as never; + // Not linked yet: the calculator verifies the result, the day is claimed. + mocks.fetchBadges.mockResolvedValue(CALCULATOR); + await answerShare(shareMessage() as never, config); + expect(mocks.store.claimCard).toHaveBeenCalledWith("g1", "u1", DAY); + + // Linked now: a new roll, recorded on rngdle.com but not downloaded yet. + let synced = false; + mocks.refreshAccount.mockImplementation(async () => { + synced = true; + }); + mocks.store.account.mockResolvedValue(ACCOUNT); + mocks.store.hasCard.mockResolvedValue(true); + mocks.store.claimCard.mockResolvedValue(false); + mocks.store.rolls.mockImplementation(async (_guildId, filter) => + synced && filter?.from && filter.userId + ? [{ ...roll("u1", 52000), number: 222222 }] + : [] + ); + mocks.render.mockClear(); + vi.setSystemTime(clock + minutesLater * MINUTE); + await answerShare(shareMessage(LINKED_SHARE) as never, config); + + const [[data]] = mocks.render.mock.calls as unknown as [ + [{ subtitle: string; number: number }], + ]; + return data; + } + + it("downloads the new roll despite the card already drawn today", async () => { + const data = await pasteThenLink(30); + + expect(mocks.refreshAccount).toHaveBeenCalledWith(ACCOUNT); + expect(data.number).toBe(222222); + expect(data.subtitle).not.toContain("card.notLinked"); + }); + }); + it("draws a result whose score does not match its number as pasted", async () => { mocks.fetchBadges.mockResolvedValue({ ...CALCULATOR, score: 41730 }); const message = shareMessage(); diff --git a/src/modules/rngdle/services/share-cards.ts b/src/modules/rngdle/services/share-cards.ts index 38a38043..d85b7328 100644 --- a/src/modules/rngdle/services/share-cards.ts +++ b/src/modules/rngdle/services/share-cards.ts @@ -249,21 +249,25 @@ function mayCheck(guildId: string, userId: string): boolean { return true; } -async function storedRoll( - account: Account, - pasted: CardRoll -): Promise { - const today = startOfUtcDay(pasted.rolledAt); - const recent = await store.rolls(account.guildId, { +// The member's rolls of the pasted result's day and of the day before. +function recentRolls(account: Account, today: Date): Promise { + return store.rolls(account.guildId, { userId: account.userId, from: new Date(today.getTime() - DAY_MS), }); - return recent.find((roll) => roll.number === pasted.number) ?? null; +} + +function findPasted( + recent: StoredRoll[], + pasted: CardRoll +): StoredRoll | undefined { + return recent.find((roll) => roll.number === pasted.number); } async function refreshedRoll( account: Account, - pasted: CardRoll + pasted: CardRoll, + today: Date ): Promise { try { await sync.refreshAccount(account); @@ -274,7 +278,7 @@ async function refreshedRoll( ); return null; } - return storedRoll(account, pasted); + return findPasted(await recentRolls(account, today), pasted) ?? null; } async function calculatorRoll(pasted: CardRoll): Promise { @@ -283,28 +287,33 @@ async function calculatorRoll(pasted: CardRoll): Promise { } // Checks a pasted result against the rolls rngdle.com recorded (linked -// member) or against the calculator (unlinked member). Only the first result -// of the day is checked, at most once per member every 10 minutes: any other -// one gets an unverified card, drawn without a single request. +// member) or against the calculator (unlinked member), at most once per +// member every 10 minutes. An unlinked member's result is +// checked only before the day's card; a linked member's, until the day's +// roll is downloaded: rngdle.com records one a day, so another number cannot +// be found there. A card drawn before linking does not count, the member's +// roll is still to be downloaded. Any other result gets an unverified card, +// drawn without a single request. async function verify( guildId: string, userId: string, pasted: CardRoll ): Promise { const account = await store.account(guildId, userId); - const known = account ? await storedRoll(account, pasted) : null; + const today = startOfUtcDay(pasted.rolledAt); + const recent = account ? await recentRolls(account, today) : []; + const known = findPasted(recent, pasted); if (known) { return { roll: known, linked: true }; } - const today = startOfUtcDay(pasted.rolledAt); - if ( - (await store.hasCard(guildId, userId, today)) || - !mayCheck(guildId, userId) - ) { + const settled = account + ? recent.some((roll) => roll.rolledAt >= today) + : await store.hasCard(guildId, userId, today); + if (settled || !mayCheck(guildId, userId)) { return null; } if (account) { - const roll = await refreshedRoll(account, pasted); + const roll = await refreshedRoll(account, pasted, today); return roll && { roll, linked: true }; } const roll = await calculatorRoll(pasted); From 820bdff1a96118aec89e536009dd8b03c1e631e6 Mon Sep 17 00:00:00 2001 From: Antoine James Tournepiche Date: Tue, 10 Nov 2026 04:33:51 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix(rngdle):=20attente=20de=20v=C3=A9rifica?= =?UTF-8?q?tion=20compt=C3=A9e=20par=20source?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L’attente de 10 minutes entre deux vérifications était comptée par membre : une vérification par le calculateur juste avant la liaison bloquait la première vérification sur rngdle.com. Elle est désormais comptée par source (calculateur, rngdle.com). --- docs/functional.md | 2 +- .../rngdle/services/share-cards.test.ts | 18 ++++++++++++++++++ src/modules/rngdle/services/share-cards.ts | 18 ++++++++++++++---- 3 files changed, 33 insertions(+), 5 deletions(-) diff --git a/docs/functional.md b/docs/functional.md index f160deaa..d33ed30c 100644 --- a/docs/functional.md +++ b/docs/functional.md @@ -206,7 +206,7 @@ Inscrit ou désinscrit le membre au rappel quotidien. Réponse visible de lui se Le bot dessine une carte par tirage : numéro, score, palier et percentile, puis la liste complète des badges avec leurs EP, leur rareté et la part des numéros qui les obtiennent. Les badges englobés par un badge plus fort ne rapportent rien : ils sont grisés et marqués « inclus ». Au-delà de 24 badges, les suivants sont résumés en une ligne. La liste des badges vient du calculateur [rng.cubityfir.st](https://rng.cubityfir.st), qui recalcule les badges de n'importe quel numéro ; s'il ne répond pas en 5 secondes, la carte reprend les badges du résultat collé, sans leurs EP, leur probabilité ni la mention « inclus », ou n'affiche pas de badges. Sur une carte vérifiée, le score, le palier et le classement viennent toujours de rngdle.com : un texte retouché ne peut changer que la liste des badges, et seulement quand le calculateur ne répond pas. Pour un membre lié, la carte ajoute selon le cas : record personnel (au-dessus de tous ses tirages précédents), son rang du jour parmi les membres liés qui ont déjà tiré, et l'écart avec sa moyenne (à partir de 5 tirages précédents). -- **Résultat collé** : quand un membre colle dans le salon RNGdle le résultat partagé par rngdle.com (« RNGdle 🎲 … » jusqu'à la ligne des EP), le bot y répond avec la carte, sans le mentionner et en @silent : le message collé a déjà notifié le salon. Cela marche aussi pour les membres non liés. Les résultats collés ailleurs sont ignorés : le bot ne lit le contenu des messages que dans ce salon. Pour un membre lié, le bot cherche ce numéro parmi ses tirages enregistrés d'aujourd'hui et d'hier, et télécharge ses nouveaux tirages sur rngdle.com s'il ne le trouve pas. La carte reprend alors le score et le jour du tirage enregistré, même si le résultat est collé après minuit. Pour un membre non lié, le score collé doit correspondre au total d'EP que le calculateur donne pour ce numéro. Seul le premier résultat vérifié du jour compte : une fois qu'un membre non lié a eu sa carte, une fois que le tirage du jour d'un membre lié est enregistré (rngdle.com n'en enregistre qu'un par jour), ou si la vérification échoue (numéro inconnu, score qui ne correspond pas, site qui ne répond pas), le bot dessine la carte à partir du seul texte collé, sans aucune requête, et ne l'enregistre nulle part. Exceptions : un membre lié qui recolle le résultat d'un tirage déjà enregistré, avec le même score, retrouve la carte de ses vraies données ; un membre qui a eu sa carte avant de lier son compte voit quand même son tirage vérifié sur rngdle.com. Toute carte qui ne vient pas d'un tirage enregistré d'un membre lié porte la mention « Non connecté » à côté de la date, y compris celle d'un membre non lié vérifiée par le calculateur. La vérification est faite au plus une fois toutes les 10 minutes par membre. +- **Résultat collé** : quand un membre colle dans le salon RNGdle le résultat partagé par rngdle.com (« RNGdle 🎲 … » jusqu'à la ligne des EP), le bot y répond avec la carte, sans le mentionner et en @silent : le message collé a déjà notifié le salon. Cela marche aussi pour les membres non liés. Les résultats collés ailleurs sont ignorés : le bot ne lit le contenu des messages que dans ce salon. Pour un membre lié, le bot cherche ce numéro parmi ses tirages enregistrés d'aujourd'hui et d'hier, et télécharge ses nouveaux tirages sur rngdle.com s'il ne le trouve pas. La carte reprend alors le score et le jour du tirage enregistré, même si le résultat est collé après minuit. Pour un membre non lié, le score collé doit correspondre au total d'EP que le calculateur donne pour ce numéro. Seul le premier résultat vérifié du jour compte : une fois qu'un membre non lié a eu sa carte, une fois que le tirage du jour d'un membre lié est enregistré (rngdle.com n'en enregistre qu'un par jour), ou si la vérification échoue (numéro inconnu, score qui ne correspond pas, site qui ne répond pas), le bot dessine la carte à partir du seul texte collé, sans aucune requête, et ne l'enregistre nulle part. Exceptions : un membre lié qui recolle le résultat d'un tirage déjà enregistré, avec le même score, retrouve la carte de ses vraies données ; un membre qui a eu sa carte avant de lier son compte voit quand même son tirage vérifié sur rngdle.com. Toute carte qui ne vient pas d'un tirage enregistré d'un membre lié porte la mention « Non connecté » à côté de la date, y compris celle d'un membre non lié vérifiée par le calculateur. La vérification est faite au plus une fois toutes les 10 minutes par membre et par source (le calculateur, rngdle.com) : un membre qui lie son compte juste après une vérification par le calculateur peut être vérifié sur rngdle.com tout de suite. - **Une carte vérifiée par jour** : un membre a au plus une carte vérifiée par jour UTC et par serveur, sauf celui qui lie son compte après une carte vérifiée par le calculateur : son tirage enregistré sur rngdle.com a aussi la sienne. Si l'envoi échoue, il peut recoller son résultat. ### Classement quotidien diff --git a/src/modules/rngdle/services/share-cards.test.ts b/src/modules/rngdle/services/share-cards.test.ts index e97924b2..4aa5f137 100644 --- a/src/modules/rngdle/services/share-cards.test.ts +++ b/src/modules/rngdle/services/share-cards.test.ts @@ -286,6 +286,17 @@ describe("answerShare", () => { expect(mocks.refreshAccount).toHaveBeenCalledWith(ACCOUNT); }); + it("keeps the cooldown when the member links another username", async () => { + mocks.store.account.mockResolvedValue(ACCOUNT); + const config = configWith(SHARING) as never; + await answerShare(shareMessage() as never, config); + mocks.store.account.mockResolvedValue({ ...ACCOUNT, username: "Bob" }); + vi.setSystemTime(clock + 2 * MINUTE); + await answerShare(shareMessage() as never, config); + + expect(mocks.refreshAccount).toHaveBeenCalledTimes(1); + }); + it.each([ [ "the feature is off", @@ -496,6 +507,13 @@ describe("answerShare", () => { expect(data.number).toBe(222222); expect(data.subtitle).not.toContain("card.notLinked"); }); + + it("does not wait for the cooldown of the check made before linking", async () => { + const data = await pasteThenLink(2); + + expect(mocks.refreshAccount).toHaveBeenCalledWith(ACCOUNT); + expect(data.subtitle).not.toContain("card.notLinked"); + }); }); it("draws a result whose score does not match its number as pasted", async () => { diff --git a/src/modules/rngdle/services/share-cards.ts b/src/modules/rngdle/services/share-cards.ts index d85b7328..3619cb15 100644 --- a/src/modules/rngdle/services/share-cards.ts +++ b/src/modules/rngdle/services/share-cards.ts @@ -239,8 +239,15 @@ interface Verified { linked: boolean; } -function mayCheck(guildId: string, userId: string): boolean { - const key = `${guildId}:${userId}`; +// The cooldown is kept per source: a member who links their account is +// checked on rngdle.com at once, even if the calculator checked a result a +// minute before. +function mayCheck( + guildId: string, + userId: string, + source: "rngdle" | "calculator" +): boolean { + const key = `${guildId}:${userId}:${source}`; const now = Date.now(); if (now - (lastChecks.get(key) ?? -Infinity) < CHECK_COOLDOWN_MS) { return false; @@ -288,7 +295,7 @@ async function calculatorRoll(pasted: CardRoll): Promise { // Checks a pasted result against the rolls rngdle.com recorded (linked // member) or against the calculator (unlinked member), at most once per -// member every 10 minutes. An unlinked member's result is +// member and per source every 10 minutes. An unlinked member's result is // checked only before the day's card; a linked member's, until the day's // roll is downloaded: rngdle.com records one a day, so another number cannot // be found there. A card drawn before linking does not count, the member's @@ -309,7 +316,10 @@ async function verify( const settled = account ? recent.some((roll) => roll.rolledAt >= today) : await store.hasCard(guildId, userId, today); - if (settled || !mayCheck(guildId, userId)) { + if ( + settled || + !mayCheck(guildId, userId, account ? "rngdle" : "calculator") + ) { return null; } if (account) { From fba4cf7325235611d856d8b1e4b7f8f29f0caefd Mon Sep 17 00:00:00 2001 From: Antoine James Tournepiche Date: Tue, 10 Nov 2026 04:33:51 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix(rngdle):=20un=20tirage=20coll=C3=A9=20h?= =?UTF-8?q?ors=20connexion=20ne=20bloque=20plus=20le=20tirage=20connect?= =?UTF-8?q?=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un membre lié qui collait d’abord un tirage fait sans être connecté au site, puis son tirage connecté, voyait ce dernier « Non connecté » : la première vérification lançait l’attente de 10 minutes. rngdle.com peut désormais être interrogé 3 fois en 10 minutes, sans délai ; un tirage introuvable recollé ne relance rien, et un site qui ne répond pas n’est plus interrogé avant la fin des 10 minutes. --- docs/functional.md | 2 +- .../rngdle/services/share-cards.test.ts | 117 +++++++++++++++++- src/modules/rngdle/services/share-cards.ts | 93 ++++++++++---- 3 files changed, 187 insertions(+), 25 deletions(-) diff --git a/docs/functional.md b/docs/functional.md index d33ed30c..52bf7727 100644 --- a/docs/functional.md +++ b/docs/functional.md @@ -206,7 +206,7 @@ Inscrit ou désinscrit le membre au rappel quotidien. Réponse visible de lui se Le bot dessine une carte par tirage : numéro, score, palier et percentile, puis la liste complète des badges avec leurs EP, leur rareté et la part des numéros qui les obtiennent. Les badges englobés par un badge plus fort ne rapportent rien : ils sont grisés et marqués « inclus ». Au-delà de 24 badges, les suivants sont résumés en une ligne. La liste des badges vient du calculateur [rng.cubityfir.st](https://rng.cubityfir.st), qui recalcule les badges de n'importe quel numéro ; s'il ne répond pas en 5 secondes, la carte reprend les badges du résultat collé, sans leurs EP, leur probabilité ni la mention « inclus », ou n'affiche pas de badges. Sur une carte vérifiée, le score, le palier et le classement viennent toujours de rngdle.com : un texte retouché ne peut changer que la liste des badges, et seulement quand le calculateur ne répond pas. Pour un membre lié, la carte ajoute selon le cas : record personnel (au-dessus de tous ses tirages précédents), son rang du jour parmi les membres liés qui ont déjà tiré, et l'écart avec sa moyenne (à partir de 5 tirages précédents). -- **Résultat collé** : quand un membre colle dans le salon RNGdle le résultat partagé par rngdle.com (« RNGdle 🎲 … » jusqu'à la ligne des EP), le bot y répond avec la carte, sans le mentionner et en @silent : le message collé a déjà notifié le salon. Cela marche aussi pour les membres non liés. Les résultats collés ailleurs sont ignorés : le bot ne lit le contenu des messages que dans ce salon. Pour un membre lié, le bot cherche ce numéro parmi ses tirages enregistrés d'aujourd'hui et d'hier, et télécharge ses nouveaux tirages sur rngdle.com s'il ne le trouve pas. La carte reprend alors le score et le jour du tirage enregistré, même si le résultat est collé après minuit. Pour un membre non lié, le score collé doit correspondre au total d'EP que le calculateur donne pour ce numéro. Seul le premier résultat vérifié du jour compte : une fois qu'un membre non lié a eu sa carte, une fois que le tirage du jour d'un membre lié est enregistré (rngdle.com n'en enregistre qu'un par jour), ou si la vérification échoue (numéro inconnu, score qui ne correspond pas, site qui ne répond pas), le bot dessine la carte à partir du seul texte collé, sans aucune requête, et ne l'enregistre nulle part. Exceptions : un membre lié qui recolle le résultat d'un tirage déjà enregistré, avec le même score, retrouve la carte de ses vraies données ; un membre qui a eu sa carte avant de lier son compte voit quand même son tirage vérifié sur rngdle.com. Toute carte qui ne vient pas d'un tirage enregistré d'un membre lié porte la mention « Non connecté » à côté de la date, y compris celle d'un membre non lié vérifiée par le calculateur. La vérification est faite au plus une fois toutes les 10 minutes par membre et par source (le calculateur, rngdle.com) : un membre qui lie son compte juste après une vérification par le calculateur peut être vérifié sur rngdle.com tout de suite. +- **Résultat collé** : quand un membre colle dans le salon RNGdle le résultat partagé par rngdle.com (« RNGdle 🎲 … » jusqu'à la ligne des EP), le bot y répond avec la carte, sans le mentionner et en @silent : le message collé a déjà notifié le salon. Cela marche aussi pour les membres non liés. Les résultats collés ailleurs sont ignorés : le bot ne lit le contenu des messages que dans ce salon. Pour un membre lié, le bot cherche ce numéro parmi ses tirages enregistrés d'aujourd'hui et d'hier, et télécharge ses nouveaux tirages sur rngdle.com s'il ne le trouve pas. La carte reprend alors le score et le jour du tirage enregistré, même si le résultat est collé après minuit. Pour un membre non lié, le score collé doit correspondre au total d'EP que le calculateur donne pour ce numéro. Seul le premier résultat vérifié du jour compte : une fois qu'un membre non lié a eu sa carte, une fois que le tirage du jour d'un membre lié est enregistré (rngdle.com n'en enregistre qu'un par jour), ou si la vérification échoue (numéro inconnu, score qui ne correspond pas, site qui ne répond pas), le bot dessine la carte à partir du seul texte collé, sans aucune requête, et ne l'enregistre nulle part. Exceptions : un membre lié qui recolle le résultat d'un tirage déjà enregistré, avec le même score, retrouve la carte de ses vraies données ; un membre qui a eu sa carte avant de lier son compte voit quand même son tirage vérifié sur rngdle.com. Toute carte qui ne vient pas d'un tirage enregistré d'un membre lié porte la mention « Non connecté » à côté de la date, y compris celle d'un membre non lié vérifiée par le calculateur. Sur 10 minutes, un membre lié peut déclencher jusqu'à 3 vérifications sur rngdle.com, sans délai entre elles : un tirage fait sans être connecté au site, éventuellement un autre, puis son tirage enregistré, qui est alors vérifié. Recoller un tirage que rngdle.com n'a pas trouvé ne relance pas de vérification. Si rngdle.com ne répond pas, il n'est plus interrogé avant la fin des 10 minutes. Le calculateur, pour un membre non lié, vérifie au plus un résultat toutes les 10 minutes. Ces limites sont comptées par membre et par source : un membre qui lie son compte juste après une vérification par le calculateur est vérifié sur rngdle.com tout de suite. - **Une carte vérifiée par jour** : un membre a au plus une carte vérifiée par jour UTC et par serveur, sauf celui qui lie son compte après une carte vérifiée par le calculateur : son tirage enregistré sur rngdle.com a aussi la sienne. Si l'envoi échoue, il peut recoller son résultat. ### Classement quotidien diff --git a/src/modules/rngdle/services/share-cards.test.ts b/src/modules/rngdle/services/share-cards.test.ts index 4aa5f137..90466855 100644 --- a/src/modules/rngdle/services/share-cards.test.ts +++ b/src/modules/rngdle/services/share-cards.test.ts @@ -263,8 +263,9 @@ describe("answerShare", () => { expect(data.subtitle).toContain("card.notLinked"); }); - it("checks a member's results at most once every 10 minutes", async () => { + it("checks rngdle.com at most once every 10 minutes while it fails", async () => { mocks.store.account.mockResolvedValue(ACCOUNT); + mocks.refreshAccount.mockRejectedValue(new Error("rngdle.com is down")); const config = configWith(SHARING) as never; await answerShare(shareMessage() as never, config); vi.setSystemTime(clock + 9 * MINUTE); @@ -288,10 +289,11 @@ describe("answerShare", () => { it("keeps the cooldown when the member links another username", async () => { mocks.store.account.mockResolvedValue(ACCOUNT); + mocks.refreshAccount.mockRejectedValue(new Error("rngdle.com is down")); const config = configWith(SHARING) as never; await answerShare(shareMessage() as never, config); mocks.store.account.mockResolvedValue({ ...ACCOUNT, username: "Bob" }); - vi.setSystemTime(clock + 2 * MINUTE); + vi.setSystemTime(clock + 10 * 1000); await answerShare(shareMessage() as never, config); expect(mocks.refreshAccount).toHaveBeenCalledTimes(1); @@ -516,6 +518,117 @@ describe("answerShare", () => { }); }); + describe("for a linked member who first pasted a roll made logged out", () => { + const LOGGED_IN = "RNGdle 🎲 222222\n\n🟦 RARE • Top 12%\n\n52 000 EP"; + + // The first paste is a roll rngdle.com never recorded (made logged out): + // the check finds nothing. The second, `secondsLater`, is the member's + // recorded roll. + async function loggedOutThenIn(secondsLater: number) { + const config = configWith(SHARING) as never; + let recorded = false; + mocks.store.account.mockResolvedValue(ACCOUNT); + mocks.refreshAccount.mockImplementation(async () => {}); + mocks.store.rolls.mockImplementation(async (_guildId, filter) => + recorded && filter?.from && filter.userId + ? [{ ...roll("u1", 52000), number: 222222 }] + : [] + ); + await answerShare(shareMessage() as never, config); + expect(mocks.refreshAccount).toHaveBeenCalledTimes(1); + + // Rolled logged in meanwhile: recorded on rngdle.com, not downloaded. + mocks.refreshAccount.mockImplementation(async () => { + recorded = true; + }); + mocks.render.mockClear(); + vi.setSystemTime(clock + secondsLater * 1000); + await answerShare(shareMessage(LOGGED_IN) as never, config); + const [[data]] = mocks.render.mock.calls as unknown as [ + [{ subtitle: string; number: number }], + ]; + return data; + } + + it("verifies the logged-in roll pasted a few minutes later", async () => { + const data = await loggedOutThenIn(2 * 60); + + expect(mocks.refreshAccount).toHaveBeenCalledTimes(2); + expect(data.number).toBe(222222); + expect(data.subtitle).not.toContain("card.notLinked"); + }); + + it("does not spend the quick retry on the same roll pasted again", async () => { + const config = configWith(SHARING) as never; + let recorded = false; + mocks.store.account.mockResolvedValue(ACCOUNT); + mocks.refreshAccount.mockImplementation(async () => {}); + mocks.store.rolls.mockImplementation(async (_guildId, filter) => + recorded && filter?.from && filter.userId + ? [{ ...roll("u1", 52000), number: 222222 }] + : [] + ); + // The roll made logged out, then the same by mistake 40 s later. + await answerShare(shareMessage() as never, config); + vi.setSystemTime(clock + 40 * 1000); + await answerShare(shareMessage() as never, config); + expect(mocks.refreshAccount).toHaveBeenCalledTimes(1); + + // Then the recorded one. + mocks.refreshAccount.mockImplementation(async () => { + recorded = true; + }); + mocks.render.mockClear(); + vi.setSystemTime(clock + 60 * 1000); + await answerShare( + shareMessage( + "RNGdle 🎲 222222\n\n🟦 RARE • Top 12%\n\n52 000 EP" + ) as never, + config + ); + expect(mocks.refreshAccount).toHaveBeenCalledTimes(2); + const [[data]] = mocks.render.mock.calls as unknown as [ + [{ subtitle: string; number: number }], + ]; + expect(data.number).toBe(222222); + expect(data.subtitle).not.toContain("card.notLinked"); + }); + + it("checks the logged-in roll pasted right after, at any pace", async () => { + const data = await loggedOutThenIn(5); + + expect(mocks.refreshAccount).toHaveBeenCalledTimes(2); + expect(data.subtitle).not.toContain("card.notLinked"); + }); + + it("checks rngdle.com at most 3 times in 10 minutes", async () => { + const config = configWith(SHARING) as never; + mocks.store.account.mockResolvedValue(ACCOUNT); + mocks.refreshAccount.mockImplementation(async () => {}); + mocks.store.rolls.mockResolvedValue([]); + // A different made-up number each time: none is on rngdle.com. + const pasteAt = async (seconds: number) => { + vi.setSystemTime(clock + seconds * 1000); + const number = 300000 + seconds; + await answerShare( + shareMessage( + `RNGdle 🎲 ${number}\n\n🟦 RARE • Top 12%\n\n52 000 EP` + ) as never, + config + ); + }; + await pasteAt(0); + await pasteAt(1); + await pasteAt(2); + expect(mocks.refreshAccount).toHaveBeenCalledTimes(3); + await pasteAt(3); + await pasteAt(9 * 60); + expect(mocks.refreshAccount).toHaveBeenCalledTimes(3); + await pasteAt(10 * 60); // a new window + expect(mocks.refreshAccount).toHaveBeenCalledTimes(4); + }); + }); + it("draws a result whose score does not match its number as pasted", async () => { mocks.fetchBadges.mockResolvedValue({ ...CALCULATOR, score: 41730 }); const message = shareMessage(); diff --git a/src/modules/rngdle/services/share-cards.ts b/src/modules/rngdle/services/share-cards.ts index 3619cb15..3341e28d 100644 --- a/src/modules/rngdle/services/share-cards.ts +++ b/src/modules/rngdle/services/share-cards.ts @@ -32,9 +32,20 @@ const logger = loggerMaker("rngdle"); const AVERAGE_MIN_ROLLS = 5; const RANK_ICONS = ["🥇", "🥈", "🥉"]; const CARD_NAME = "rngdle-roll.png"; -const CHECK_COOLDOWN_MS = 10 * 60 * 1000; +const CHECK_WINDOW_MS = 10 * 60 * 1000; +// Checks of rngdle.com a member may trigger in a window, back to back: a roll +// made logged out, maybe another, then the recorded one. The calculator gets +// one, as an unlinked member's result is checked only before the day's card. +const CHECKS_PER_WINDOW = { rngdle: 3, calculator: 1 } as const; -const lastChecks = new Map(); +/** + * Each member's current window of checks, per source: when it started, how + * many checks it allowed, and the rolls rngdle.com was asked for in vain. + */ +const checkWindows = new Map< + string, + { start: number; count: number; missed: Set } +>(); type Config = ConfigProvider; @@ -239,23 +250,52 @@ interface Verified { linked: boolean; } -// The cooldown is kept per source: a member who links their account is +type Source = keyof typeof CHECKS_PER_WINDOW; + +const checkKey = (guildId: string, userId: string, source: Source) => + `${guildId}:${userId}:${source}`; + +function windowOf(guildId: string, userId: string, source: Source) { + const key = checkKey(guildId, userId, source); + const now = Date.now(); + let current = checkWindows.get(key); + if (!current || now - current.start >= CHECK_WINDOW_MS) { + current = { start: now, count: 0, missed: new Set() }; + checkWindows.set(key, current); + } + return current; +} + +// The windows are kept per source: a member who links their account is // checked on rngdle.com at once, even if the calculator checked a result a // minute before. -function mayCheck( - guildId: string, - userId: string, - source: "rngdle" | "calculator" -): boolean { - const key = `${guildId}:${userId}:${source}`; - const now = Date.now(); - if (now - (lastChecks.get(key) ?? -Infinity) < CHECK_COOLDOWN_MS) { +function mayCheck(guildId: string, userId: string, source: Source): boolean { + const current = windowOf(guildId, userId, source); + if (current.count >= CHECKS_PER_WINDOW[source]) { return false; } - lastChecks.set(key, now); + current.count++; return true; } +/** + * rngdle.com answered without the pasted roll: pasted again by mistake, it is + * not asked for again, and the window's other checks are kept for the + * member's recorded roll. + */ +function rememberMiss(guildId: string, userId: string, number: number): void { + windowOf(guildId, userId, "rngdle").missed.add(number); +} + +function alreadyMissed(guildId: string, userId: string, number: number) { + return windowOf(guildId, userId, "rngdle").missed.has(number); +} + +/** rngdle.com did not answer: not asked again before the window ends. */ +function closeWindow(guildId: string, userId: string): void { + windowOf(guildId, userId, "rngdle").count = CHECKS_PER_WINDOW.rngdle; +} + // The member's rolls of the pasted result's day and of the day before. function recentRolls(account: Account, today: Date): Promise { return store.rolls(account.guildId, { @@ -271,11 +311,13 @@ function findPasted( return recent.find((roll) => roll.number === pasted.number); } +// The pasted roll once the member's rolls are downloaded, null when +// rngdle.com did not record it, undefined when it could not be asked. async function refreshedRoll( account: Account, pasted: CardRoll, today: Date -): Promise { +): Promise { try { await sync.refreshAccount(account); } catch (err) { @@ -283,7 +325,7 @@ async function refreshedRoll( { err }, `Could not check a shared roll | guildId = ${account.guildId} | username = ${account.username}` ); - return null; + return undefined; } return findPasted(await recentRolls(account, today), pasted) ?? null; } @@ -294,13 +336,14 @@ async function calculatorRoll(pasted: CardRoll): Promise { } // Checks a pasted result against the rolls rngdle.com recorded (linked -// member) or against the calculator (unlinked member), at most once per -// member and per source every 10 minutes. An unlinked member's result is -// checked only before the day's card; a linked member's, until the day's -// roll is downloaded: rngdle.com records one a day, so another number cannot -// be found there. A card drawn before linking does not count, the member's -// roll is still to be downloaded. Any other result gets an unverified card, -// drawn without a single request. +// member), up to 3 times in 10 minutes, or against the calculator (unlinked +// member), once in 10 minutes. A roll rngdle.com was just asked for in vain +// is not asked for again, and a request that fails closes the window. An +// unlinked member's result is checked only before the day's card; a linked +// member's, until the day's roll is downloaded: rngdle.com records one a +// day, so another number cannot be found there. A card drawn before linking +// does not count, the member's roll is still to be downloaded. Any other +// result gets an unverified card, drawn without a single request. async function verify( guildId: string, userId: string, @@ -318,13 +361,19 @@ async function verify( : await store.hasCard(guildId, userId, today); if ( settled || + (account && alreadyMissed(guildId, userId, pasted.number)) || !mayCheck(guildId, userId, account ? "rngdle" : "calculator") ) { return null; } if (account) { const roll = await refreshedRoll(account, pasted, today); - return roll && { roll, linked: true }; + if (roll === null) { + rememberMiss(guildId, userId, pasted.number); + } else if (roll === undefined) { + closeWindow(guildId, userId); + } + return roll ? { roll, linked: true } : null; } const roll = await calculatorRoll(pasted); return roll && { roll, linked: false };