From ff60e34c55208909dd0d21b48c73c4fc581e943d Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:48:13 +0300 Subject: [PATCH] Block joining SendouQ if season's initial powers were never seeded Closes #2395 --- .../api-private/core/refresh-caches.server.ts | 2 ++ app/features/mmr/SkillRepository.server.ts | 14 ++++++++++ app/features/sendouq/actions/q.server.ts | 8 +++++- app/features/sendouq/q-utils.server.ts | 22 +++++++++++++++ e2e/helpers/factories.ts | 1 + e2e/sendouq.spec.ts | 27 ++++++++++++++++++- scripts/benchmark-db/cases.ts | 3 +++ scripts/season-initial-powers.ts | 17 ++++-------- 8 files changed, 80 insertions(+), 14 deletions(-) diff --git a/app/features/api-private/core/refresh-caches.server.ts b/app/features/api-private/core/refresh-caches.server.ts index 6a49a3d5d..3860cb9a4 100644 --- a/app/features/api-private/core/refresh-caches.server.ts +++ b/app/features/api-private/core/refresh-caches.server.ts @@ -2,6 +2,7 @@ import { refreshApiTokensCache } from "~/features/api-public/api-public-utils.se import { refreshBannedCache } from "~/features/ban/core/banned.server"; import { clearParticipationInfoMap } from "~/features/front-page/core/ShowcaseTournaments.server"; import { refreshSendouQInstance } from "~/features/sendouq/core/SendouQ.server"; +import { clearSeasonSkillsCache } from "~/features/sendouq/q-utils.server"; import { clearAllTournamentDataCache, refreshRunningTournaments, @@ -17,6 +18,7 @@ import { cache } from "~/utils/cache.server"; export async function refreshCaches() { clearAllTournamentDataCache(); clearParticipationInfoMap(); + clearSeasonSkillsCache(); cache.clear(); await refreshBannedCache(); await refreshSendouQInstance(); diff --git a/app/features/mmr/SkillRepository.server.ts b/app/features/mmr/SkillRepository.server.ts index f7eecb86f..9c0ec0930 100644 --- a/app/features/mmr/SkillRepository.server.ts +++ b/app/features/mmr/SkillRepository.server.ts @@ -98,6 +98,20 @@ export async function findOrderedUserOrdinalsBySeason(season: number) { .execute(); } +/** + * Whether the season has any Skill rows. + */ +export async function existsBySeason(season: number) { + const row = await db + .selectFrom("Skill") + .select("Skill.id") + .where("Skill.season", "=", season) + .limit(1) + .executeTakeFirst(); + + return Boolean(row); +} + /** * Seeding skills of the given users for one seeding type, keyed by user id. Users without * a seeding skill of that type are absent from the map. diff --git a/app/features/sendouq/actions/q.server.ts b/app/features/sendouq/actions/q.server.ts index 51c4b25c8..48f6c65c9 100644 --- a/app/features/sendouq/actions/q.server.ts +++ b/app/features/sendouq/actions/q.server.ts @@ -23,6 +23,7 @@ import { qSearchParams } from "../q-search-params"; import { userCanJoinQueueAt } from "../q-utils"; import { SendouQError, + seasonInitialSkillsExist, setGroupChatMetadata, sqRedirectIfNeeded, } from "../q-utils.server"; @@ -196,6 +197,11 @@ async function validateCanJoinQ(user: { id: number; discordId: string }) { errorToastIfFalsy(friendCode, "No friend code"); const canJoinQueue = userCanJoinQueueAt(user, friendCode) === "NOW"; - errorToastIfFalsy(Seasons.current(), "Season is not active"); + const season = Seasons.current(); + errorToastIfFalsy(season, "Season is not active"); errorToastIfFalsy(canJoinQueue, "Can't join queue right now"); + errorToastIfFalsy( + await seasonInitialSkillsExist(season.nth), + "Season's starting powers are not set yet. Please contact staff on the Discord helpdesk", + ); } diff --git a/app/features/sendouq/q-utils.server.ts b/app/features/sendouq/q-utils.server.ts index f16eebda2..b7c2fc797 100644 --- a/app/features/sendouq/q-utils.server.ts +++ b/app/features/sendouq/q-utils.server.ts @@ -1,6 +1,7 @@ import { redirect } from "react-router"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { TIERS } from "~/features/mmr/mmr-constants"; +import * as SkillRepository from "~/features/mmr/SkillRepository.server"; import type { TieredSkill } from "~/features/mmr/tiered.server"; import { navIconUrl, @@ -19,6 +20,27 @@ export class SendouQError extends Error { } } +const seasonsKnownToHaveSkills = new Set(); + +/** Whether the season's initial skills were seeded (or there was no previous season's skills to seed them from). */ +export async function seasonInitialSkillsExist(season: number) { + if (seasonsKnownToHaveSkills.has(season)) return true; + + if (await SkillRepository.existsBySeason(season)) { + seasonsKnownToHaveSkills.add(season); + return true; + } + + // if the previous season has no skills either there was nothing to seed the + // new season's initial skills from (e.g. a fresh development database) + return !(await SkillRepository.existsBySeason(season - 1)); +} + +/** Clears the in-process cache backing `seasonInitialSkillsExist`. */ +export function clearSeasonSkillsCache() { + seasonsKnownToHaveSkills.clear(); +} + function groupRedirectLocation(group?: SQOwnGroup) { if (group?.status === "PREPARING") return SENDOUQ_PREPARING_PAGE; if (group?.matchId) return sendouQMatchPage(group.matchId); diff --git a/e2e/helpers/factories.ts b/e2e/helpers/factories.ts index cbf61cab8..2c43e874f 100644 --- a/e2e/helpers/factories.ts +++ b/e2e/helpers/factories.ts @@ -53,6 +53,7 @@ export async function loadFactories(parallelIndex: number) { "~/db/seed/factories/SavedCalendarEventFactory" ), ScrimPostFactory: await import("~/db/seed/factories/ScrimPostFactory"), + SkillFactory: await import("~/db/seed/factories/SkillFactory"), SQGroupFactory: await import("~/db/seed/factories/SQGroupFactory"), SQMatchFactory: await import("~/db/seed/factories/SQMatchFactory"), TeamFactory: await import("~/db/seed/factories/TeamFactory"), diff --git a/e2e/sendouq.spec.ts b/e2e/sendouq.spec.ts index 731d4b23f..cbd94f366 100644 --- a/e2e/sendouq.spec.ts +++ b/e2e/sendouq.spec.ts @@ -1,5 +1,9 @@ import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; -import { SENDOUQ_LOOKING_PAGE, SENDOUQ_PREPARING_PAGE } from "~/utils/urls"; +import { + SENDOUQ_LOOKING_PAGE, + SENDOUQ_PAGE, + SENDOUQ_PREPARING_PAGE, +} from "~/utils/urls"; import { expect, impersonate, test } from "./helpers/playwright"; import { SendouQLookingPage } from "./pages/sendouq/sendouq-looking-page"; import { SendouQPage } from "./pages/sendouq/sendouq-page"; @@ -112,4 +116,25 @@ test.describe("SendouQ", () => { await looking.goto(); await expect(looking.locators.undoButtons).toHaveCount(0); }); + + test("Joining the queue is blocked when the season's initial powers were never seeded", async ({ + page, + factories, + }) => { + const [user] = await factories.UserFactory.createMany(1); + // the previous season concluded with skills but the current one has none, + // meaning season-initial-powers was forgotten + await factories.SkillFactory.create({ userId: user.id, season: 0 }); + + await impersonate(page, user.id); + + const q = new SendouQPage(page); + await q.goto(); + await q.joinSolo(); + + await expect( + page.getByText("Season's starting powers are not set yet"), + ).toBeAttached(); + await expect(page).toHaveURL(SENDOUQ_PAGE); + }); }); diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 8694ec588..d8c52070b 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -360,6 +360,9 @@ export function buildCases(fx: Fixtures): { (skillBatch) => SkillRepository.findOrderedUserOrdinalsBySeason(skillBatch.season), ); + add("SkillRepository.existsBySeason", fx.skillBatch, (skillBatch) => + SkillRepository.existsBySeason(skillBatch.season), + ); add("SkillRepository.findSeedingSkills", fx.skillBatch, (skillBatch) => SkillRepository.findSeedingSkills({ type: "RANKED", diff --git a/scripts/season-initial-powers.ts b/scripts/season-initial-powers.ts index be10a0c1e..a7478c95b 100644 --- a/scripts/season-initial-powers.ts +++ b/scripts/season-initial-powers.ts @@ -14,19 +14,12 @@ invariant(rawNth, "nth of new season needed (argument 1)"); const nth = Number(rawNth); invariant(!Number.isNaN(nth), "nth of new season must be a number"); -const seasonHasSkills = async (season: number) => - Boolean( - await db - .selectFrom("Skill") - .select("id") - .where("season", "=", season) - .limit(1) - .executeTakeFirst(), - ); - -invariant(await seasonHasSkills(nth - 1), `No skills for season ${nth - 1}`); invariant( - !(await seasonHasSkills(nth)), + await SkillRepository.existsBySeason(nth - 1), + `No skills for season ${nth - 1}`, +); +invariant( + !(await SkillRepository.existsBySeason(nth)), `Skills for season ${nth} already exist`, );