Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/functional.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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

Expand Down
207 changes: 204 additions & 3 deletions src/modules/rngdle/services/share-cards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,21 +232,40 @@ 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 () => {
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);
Expand All @@ -258,6 +277,28 @@ 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("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 + 10 * 1000);
await answerShare(shareMessage() as never, config);

expect(mocks.refreshAccount).toHaveBeenCalledTimes(1);
});

it.each([
[
"the feature is off",
Expand Down Expand Up @@ -428,6 +469,166 @@ 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("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");
});
});

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();
Expand Down
Loading
Loading