Block joining SendouQ if season's initial powers were never seeded

Closes #2395
This commit is contained in:
Kalle
2026-08-04 10:48:13 +03:00
parent c00fdeb35b
commit ff60e34c55
8 changed files with 80 additions and 14 deletions

View File

@@ -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();

View File

@@ -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.

View File

@@ -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",
);
}

View File

@@ -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<number>();
/** 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);

View File

@@ -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"),

View File

@@ -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);
});
});

View File

@@ -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",

View File

@@ -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`,
);