From 2029129f2db1c09cfc9d11173584a95ecc25a7c7 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:03:43 +0300 Subject: [PATCH] SendouQ collect ready checks before starting a match --- app/db/seed/factories/SQReadyCheckFactory.ts | 26 ++ app/db/tables.ts | 21 +- app/features/chat/chat-types.ts | 1 + app/features/chat/chat-utils.ts | 1 + .../NotificationRepository.server.ts | 5 +- .../components/NotificationList.tsx | 6 +- .../notifications/core/notify.server.test.ts | 7 +- .../notifications/core/notify.server.ts | 7 +- .../notifications/notifications-types.ts | 1 + .../notifications/notifications-utils.ts | 8 + .../sendouq-match/SQMatchRepository.server.ts | 14 + .../sendouq/SQGroupRepository.server.ts | 277 +++++++++++++++- .../sendouq/actions/q.looking.server.ts | 159 ++++----- .../sendouq/actions/q.ready.server.ts | 61 ++++ .../sendouq/components/GroupCard.module.css | 23 ++ app/features/sendouq/components/GroupCard.tsx | 153 +++++++-- app/features/sendouq/core/SendouQ.server.ts | 1 + .../sendouq/core/ready-check.server.test.ts | 305 ++++++++++++++++++ .../sendouq/core/ready-check.server.ts | 259 +++++++++++++++ .../sendouq/loaders/q.looking.server.ts | 5 + .../sendouq/loaders/q.ready.server.ts | 48 +++ app/features/sendouq/q-action-schemas.ts | 8 + app/features/sendouq/q-constants.ts | 2 + app/features/sendouq/q-utils.server.ts | 5 +- app/features/sendouq/routes/q.info.tsx | 9 + app/features/sendouq/routes/q.looking.test.ts | 24 +- app/features/sendouq/routes/q.looking.tsx | 6 +- .../sendouq/routes/q.ready.module.css | 27 ++ app/features/sendouq/routes/q.ready.test.ts | 126 ++++++++ app/features/sendouq/routes/q.ready.tsx | 117 +++++++ .../settings/components/SoundsTab.tsx | 1 + app/routes.ts | 1 + app/routines/expireReadyChecks.ts | 22 ++ app/routines/list.server.ts | 6 +- app/utils/urls.ts | 1 + e2e/helpers/factories.ts | 3 + e2e/pages/sendouq/group-card.ts | 11 +- e2e/pages/sendouq/sendouq-ready-page.ts | 34 ++ e2e/sendouq.spec.ts | 114 ++++++- locales/da/common.json | 2 + locales/da/q.json | 9 + locales/da/settings.json | 1 + locales/de/common.json | 2 + locales/de/q.json | 9 + locales/de/settings.json | 1 + locales/en/common.json | 2 + locales/en/q.json | 9 + locales/en/settings.json | 1 + locales/es-ES/common.json | 2 + locales/es-ES/q.json | 9 + locales/es-ES/settings.json | 1 + locales/es-US/common.json | 2 + locales/es-US/q.json | 9 + locales/es-US/settings.json | 1 + locales/fr-CA/common.json | 2 + locales/fr-CA/q.json | 9 + locales/fr-CA/settings.json | 1 + locales/fr-EU/common.json | 2 + locales/fr-EU/q.json | 9 + locales/fr-EU/settings.json | 1 + locales/he/common.json | 2 + locales/he/q.json | 9 + locales/he/settings.json | 1 + locales/it/common.json | 2 + locales/it/q.json | 9 + locales/it/settings.json | 1 + locales/ja/common.json | 2 + locales/ja/q.json | 9 + locales/ja/settings.json | 1 + locales/ko/common.json | 2 + locales/ko/q.json | 9 + locales/ko/settings.json | 1 + locales/nl/common.json | 2 + locales/nl/q.json | 9 + locales/nl/settings.json | 1 + locales/pl/common.json | 2 + locales/pl/q.json | 9 + locales/pl/settings.json | 1 + locales/pt-BR/common.json | 2 + locales/pt-BR/q.json | 9 + locales/pt-BR/settings.json | 1 + locales/ru/common.json | 2 + locales/ru/q.json | 9 + locales/ru/settings.json | 1 + locales/zh/common.json | 2 + locales/zh/q.json | 9 + locales/zh/settings.json | 1 + migrations/20260807143400-sq-ready-check.ts | 58 ++++ scripts/benchmark-db/cases.ts | 14 + 89 files changed, 2019 insertions(+), 150 deletions(-) create mode 100644 app/db/seed/factories/SQReadyCheckFactory.ts create mode 100644 app/features/sendouq/actions/q.ready.server.ts create mode 100644 app/features/sendouq/core/ready-check.server.test.ts create mode 100644 app/features/sendouq/core/ready-check.server.ts create mode 100644 app/features/sendouq/loaders/q.ready.server.ts create mode 100644 app/features/sendouq/routes/q.ready.module.css create mode 100644 app/features/sendouq/routes/q.ready.test.ts create mode 100644 app/features/sendouq/routes/q.ready.tsx create mode 100644 app/routines/expireReadyChecks.ts create mode 100644 e2e/pages/sendouq/sendouq-ready-page.ts create mode 100644 migrations/20260807143400-sq-ready-check.ts diff --git a/app/db/seed/factories/SQReadyCheckFactory.ts b/app/db/seed/factories/SQReadyCheckFactory.ts new file mode 100644 index 000000000..ba973053e --- /dev/null +++ b/app/db/seed/factories/SQReadyCheckFactory.ts @@ -0,0 +1,26 @@ +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import { defineFactory } from "../core/defineFactory"; + +type InsertArgs = Parameters[0]; + +type Options = { + /** Members who confirm they are ready right after the check starts. */ + confirmedByUserIds?: number[]; +}; + +/** + * Creates the ready check two matched up groups go through before their match is + * created. Both groups have to be active, since a ready check is what takes them + * out of the looking pool. + */ +export const { create } = defineFactory({ + insert: (args: InsertArgs) => SQGroupRepository.insertReadyCheck(args), + applyOptions: async (readyCheck, { confirmedByUserIds }: Options) => { + for (const userId of confirmedByUserIds ?? []) { + await SQGroupRepository.insertReadyCheckConfirmation({ + readyCheckId: readyCheck.id, + userId, + }); + } + }, +}); diff --git a/app/db/tables.ts b/app/db/tables.ts index 76cd10564..76e053852 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -303,7 +303,7 @@ export interface Group { latestActionAt: Generated; /** If truthy, group was at least partly made in the matchmaking UI (/q/looking) */ matchmade: Generated; - status: "PREPARING" | "ACTIVE" | "INACTIVE"; + status: "PREPARING" | "ACTIVE" | "INACTIVE" | "READY_CHECK"; teamId: number | null; } @@ -370,10 +370,27 @@ export interface GroupMatchMap { export interface GroupMember { createdAt: Generated; groupId: number; + /** When the member last let a {@link GroupReadyCheck} expire without confirming, letting the rest of the group kick them. `null` if they have not. */ + missedReadyCheckAt: number | null; note: string | null; userId: number; } +/** Both groups' members confirming they are ready to play, before their match is created */ +export interface GroupReadyCheck { + alphaGroupId: number; + bravoGroupId: number; + createdAt: Generated; + id: GeneratedAlways; +} + +/** One member confirming they are ready to play in a {@link GroupReadyCheck} */ +export interface GroupReadyCheckConfirmation { + createdAt: Generated; + readyCheckId: number; + userId: number; +} + /** A group member pointing their own group at another group, without inviting it */ export interface GroupSuggestion { createdAt: Generated; @@ -1266,6 +1283,8 @@ export interface DB { GroupMatchContinueVote: GroupMatchContinueVote; GroupMatchMap: GroupMatchMap; GroupMember: GroupMember; + GroupReadyCheck: GroupReadyCheck; + GroupReadyCheckConfirmation: GroupReadyCheckConfirmation; GroupSuggestion: GroupSuggestion; PrivateUserNote: PrivateUserNote; LogInLink: LogInLink; diff --git a/app/features/chat/chat-types.ts b/app/features/chat/chat-types.ts index 1e6537af5..51cf81088 100644 --- a/app/features/chat/chat-types.ts +++ b/app/features/chat/chat-types.ts @@ -4,6 +4,7 @@ export type SystemMessageType = | "NEW_GROUP" | "USER_LEFT" | "MATCH_STARTED" + | "READY_CHECK_STARTED" | "LIKE_RECEIVED" | "SCORE_REPORTED" | "SCORE_CONFIRMED" diff --git a/app/features/chat/chat-utils.ts b/app/features/chat/chat-utils.ts index cd70b78dd..ed28975ea 100644 --- a/app/features/chat/chat-utils.ts +++ b/app/features/chat/chat-utils.ts @@ -35,6 +35,7 @@ export function resolveDatePlaceholders( export function messageTypeToSound(type: ChatMessage["type"]) { if (type === "LIKE_RECEIVED") return "sq_like"; if (type === "MATCH_STARTED") return "sq_match"; + if (type === "READY_CHECK_STARTED") return "sq_ready-check"; if (type === "NEW_GROUP") return "sq_new-group"; return null; diff --git a/app/features/notifications/NotificationRepository.server.ts b/app/features/notifications/NotificationRepository.server.ts index f9a00e921..410175c57 100644 --- a/app/features/notifications/NotificationRepository.server.ts +++ b/app/features/notifications/NotificationRepository.server.ts @@ -6,6 +6,7 @@ import { actorId } from "~/features/auth/core/user.server"; import { dateToDatabaseTimestamp } from "../../utils/dates"; import { NOTIFICATIONS } from "./notifications-contants"; import type { Notification } from "./notifications-types"; +import { notificationMeta } from "./notifications-utils"; export function insert( notification: Notification, @@ -17,7 +18,9 @@ export function insert( .values({ type: notification.type, pictureUrl: notification.pictureUrl, - meta: notification.meta ? JSON.stringify(notification.meta) : null, + meta: notificationMeta(notification) + ? JSON.stringify(notificationMeta(notification)) + : null, }) .returning("id") .executeTakeFirstOrThrow(); diff --git a/app/features/notifications/components/NotificationList.tsx b/app/features/notifications/components/NotificationList.tsx index 2ec373477..2d13f7d0b 100644 --- a/app/features/notifications/components/NotificationList.tsx +++ b/app/features/notifications/components/NotificationList.tsx @@ -5,6 +5,7 @@ import { Image } from "~/components/Image"; import type { LoaderNotification } from "~/components/layout/NotificationPopover"; import { notificationLink, + notificationMeta, notificationNavIcon, } from "~/features/notifications/notifications-utils"; import { databaseTimestampToDate } from "~/utils/dates"; @@ -35,7 +36,10 @@ export function NotificationItem({ {!notification.seen ?
: null}
- {t(`common:notifications.text.${notification.type}`, notification.meta)} + {t( + `common:notifications.text.${notification.type}`, + notificationMeta(notification), + )}
{formatDistance( diff --git a/app/features/notifications/core/notify.server.test.ts b/app/features/notifications/core/notify.server.test.ts index 114063732..ecea0b87a 100644 --- a/app/features/notifications/core/notify.server.test.ts +++ b/app/features/notifications/core/notify.server.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import { APP_ICON_URL } from "~/utils/urls"; import * as NotificationRepository from "../NotificationRepository.server"; +import { notificationMeta } from "../notifications-utils"; import { clearSentNotificationsForTesting, notify } from "./notify.server"; const users = UserFactory.pool(); @@ -62,7 +63,9 @@ describe("notify()", () => { expect(user4Notifications).toHaveLength(1); expect(user1Notifications[0].type).toBe("SCRIM_NEW_REQUEST"); - expect(user1Notifications[0].meta).toEqual({ fromUsername: "alice" }); + expect(notificationMeta(user1Notifications[0])).toEqual({ + fromUsername: "alice", + }); }); test("same recipients and notification deduplicates", async () => { @@ -244,7 +247,7 @@ describe("notify()", () => { expect(user12Notifications).toHaveLength(2); expect(user13Notifications).toHaveLength(2); - const metas = user12Notifications.map((n) => n.meta); + const metas = user12Notifications.map(notificationMeta); expect(metas).toContainEqual({ fromUsername: "bob" }); expect(metas).toContainEqual({ fromUsername: "charlie" }); }); diff --git a/app/features/notifications/core/notify.server.ts b/app/features/notifications/core/notify.server.ts index 274489199..dfbcff340 100644 --- a/app/features/notifications/core/notify.server.ts +++ b/app/features/notifications/core/notify.server.ts @@ -8,12 +8,13 @@ import { getFixedTForLanguage } from "../../../modules/i18n/i18next.server"; import { logger } from "../../../utils/logger"; import * as NotificationRepository from "../NotificationRepository.server"; import type { Notification } from "../notifications-types"; -import { notificationLink } from "../notifications-utils"; +import { notificationLink, notificationMeta } from "../notifications-utils"; import webPush, { webPushEnabled } from "./webPush.server"; const NOTIFICATION_URGENCY: Record = { SQ_ADDED_TO_GROUP: "high", SQ_NEW_MATCH: "high", + SQ_READY_CHECK: "high", TO_ADDED_TO_TEAM: "normal", TO_BRACKET_STARTED: "high", TO_CHECK_IN_OPENED: "high", @@ -125,7 +126,7 @@ function isNotificationAlreadySent( } const sortedUserIds = [...userIds].sort((a, b) => a - b).join(","); - const key = `${notification.type}-${JSON.stringify(notification.meta)}-${sortedUserIds}`; + const key = `${notification.type}-${JSON.stringify(notificationMeta(notification))}-${sortedUserIds}`; const sentAt = sentNotifications.get(key); if (sentAt && Date.now() - sentAt < SENT_NOTIFICATION_TTL_MS) { return true; @@ -180,7 +181,7 @@ function pushNotificationOptions( title: t(`common:notifications.title.${notification.type}`), body: t( `common:notifications.text.${notification.type}`, - notification.meta, + notificationMeta(notification), ), icon: notification.pictureUrl ?? APP_ICON_URL, data: { url: notificationLink(notification) }, diff --git a/app/features/notifications/notifications-types.ts b/app/features/notifications/notifications-types.ts index 0cd12fdc5..43e0320ff 100644 --- a/app/features/notifications/notifications-types.ts +++ b/app/features/notifications/notifications-types.ts @@ -5,6 +5,7 @@ export type Notification = adderUsername: string; } > + | NotificationItem<"SQ_READY_CHECK"> | NotificationItem< "SQ_NEW_MATCH", { diff --git a/app/features/notifications/notifications-utils.ts b/app/features/notifications/notifications-utils.ts index 19f4a15ba..3933abb1a 100644 --- a/app/features/notifications/notifications-utils.ts +++ b/app/features/notifications/notifications-utils.ts @@ -6,6 +6,7 @@ import { PLUS_VOTING_PAGE, plusSuggestionPage, SENDOUQ_PAGE, + SENDOUQ_READY_PAGE, scrimPage, scrimsPage, sendouQMatchPage, @@ -19,6 +20,10 @@ import { } from "~/utils/urls"; import type { Notification } from "./notifications-types"; +/** Values the notification's title and text interpolate. Some notification types have none. */ +export const notificationMeta = (notification: Notification) => + "meta" in notification ? notification.meta : undefined; + export const notificationNavIcon = (type: Notification["type"]) => { switch (type) { case "BADGE_ADDED": @@ -33,6 +38,7 @@ export const notificationNavIcon = (type: Notification["type"]) => { return "plus"; case "SQ_ADDED_TO_GROUP": case "SQ_NEW_MATCH": + case "SQ_READY_CHECK": case "SEASON_STARTED": return "sendouq"; case "TAGGED_TO_ART": @@ -78,6 +84,8 @@ export const notificationLink = (notification: Notification) => { return SENDOUQ_PAGE; case "SQ_NEW_MATCH": return sendouQMatchPage(notification.meta.matchId); + case "SQ_READY_CHECK": + return SENDOUQ_READY_PAGE; case "TAGGED_TO_ART": return userArtPage( { discordId: notification.meta.adderDiscordId }, diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 8026e2d22..7ad6d7bc2 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -549,16 +549,23 @@ export async function findCancelNominationCountsByUserIds({ }); } +/** + * Creates a match between two groups. Every match made in the app comes from a + * ready check, which is resolved as part of the same transaction; only seeds and + * tests, which have no check to resolve, leave `readyCheckId` out. + */ export function insert({ alphaGroupId, bravoGroupId, mapList, memento, + readyCheckId, }: { alphaGroupId: number; bravoGroupId: number; mapList: TournamentMapListMap[]; memento: ParsedMemento; + readyCheckId?: number; }) { return db.transaction().execute(async (trx) => { const existingMatch = await trx @@ -622,6 +629,13 @@ export function insert({ trx, ); + if (typeof readyCheckId === "number") { + await SQGroupRepository.deleteReadyCheck( + { id: readyCheckId, markMissedMembers: false }, + trx, + ); + } + await validateCreatedMatch(trx, alphaGroupId, bravoGroupId); return match; diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index ff32ec5bd..be7d8e473 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -1,5 +1,10 @@ import { sub } from "date-fns"; -import { type NotNull, sql, type Transaction } from "kysely"; +import { + type ExpressionBuilder, + type NotNull, + sql, + type Transaction, +} from "kysely"; import { jsonArrayFrom } from "kysely/helpers/sqlite"; import { db } from "~/db/sql"; import type { DB, Tables } from "~/db/tables"; @@ -705,6 +710,11 @@ export function deleteAllLikesByGroupId(groupId: number) { return db.transaction().execute((trx) => deleteLikesByGroupId(groupId, trx)); } +/** + * Removes the user from their group, deleting the group if they were its last + * member. A ready check the group was in is called off, its groups returning to + * the looking pool. Returns the ids of the groups that were in that check. + */ export function leaveGroup(userId: number) { return db.transaction().execute(async (trx) => { const userGroup = await trx @@ -715,6 +725,34 @@ export function leaveGroup(userId: number) { .where("Group.status", "!=", "INACTIVE") .executeTakeFirstOrThrow(); + // the group can no longer field the match it was about to play, so the + // other group is freed to look again as well + const readyCheck = await trx + .selectFrom("GroupReadyCheck") + .select([ + "GroupReadyCheck.id", + "GroupReadyCheck.alphaGroupId", + "GroupReadyCheck.bravoGroupId", + ]) + .where((eb) => + eb.or([ + eb("GroupReadyCheck.alphaGroupId", "=", userGroup.id), + eb("GroupReadyCheck.bravoGroupId", "=", userGroup.id), + ]), + ) + .executeTakeFirst(); + + if (readyCheck) { + await deleteReadyCheckInTrx( + { id: readyCheck.id, markMissedMembers: false }, + trx, + ); + } + + const abortedReadyCheckGroupIds = readyCheck + ? [readyCheck.alphaGroupId, readyCheck.bravoGroupId] + : []; + await trx .deleteFrom("GroupMember") .where("userId", "=", userId) @@ -729,7 +767,7 @@ export function leaveGroup(userId: number) { if (!remainingMember) { await trx.deleteFrom("Group").where("id", "=", userGroup.id).execute(); - return; + return { abortedReadyCheckGroupIds }; } const match = await trx @@ -746,6 +784,8 @@ export function leaveGroup(userId: number) { if (match) { throw new SendouQError("Can't leave group when already in a match"); } + + return { abortedReadyCheckGroupIds }; }); } @@ -776,6 +816,183 @@ export function updateOwnMemberNote({ }); } +/** User ids of the group's members who let a ready check expire without confirming, and can thus be kicked by the rest of the group. */ +export async function findAllMissedReadyCheckUserIdsByGroupId(groupId: number) { + const rows = await db + .selectFrom("GroupMember") + .select("GroupMember.userId") + .where("GroupMember.groupId", "=", groupId) + .where("GroupMember.missedReadyCheckAt", "is not", null) + .execute(); + + return rows.map((row) => row.userId); +} + +/** The ready check the group is in, with each member of both groups and when (if at all) they confirmed being ready. */ +export async function findReadyCheckByGroupId(groupId: number) { + const readyCheck = await db + .selectFrom("GroupReadyCheck") + .select([ + "GroupReadyCheck.id", + "GroupReadyCheck.alphaGroupId", + "GroupReadyCheck.bravoGroupId", + "GroupReadyCheck.createdAt", + ]) + .where((eb) => + eb.or([ + eb("GroupReadyCheck.alphaGroupId", "=", groupId), + eb("GroupReadyCheck.bravoGroupId", "=", groupId), + ]), + ) + .executeTakeFirst(); + + if (!readyCheck) return; + + const members = await db + .selectFrom("GroupMember") + .leftJoin("GroupReadyCheckConfirmation", (join) => + join + .onRef("GroupReadyCheckConfirmation.userId", "=", "GroupMember.userId") + .on("GroupReadyCheckConfirmation.readyCheckId", "=", readyCheck.id), + ) + .select([ + "GroupMember.userId", + "GroupMember.groupId", + "GroupReadyCheckConfirmation.createdAt as confirmedAt", + ]) + .where("GroupMember.groupId", "in", [ + readyCheck.alphaGroupId, + readyCheck.bravoGroupId, + ]) + .execute(); + + return { ...readyCheck, members }; +} + +/** Ready checks that were started before the given time. */ +export function findAllReadyChecksStartedBefore(date: Date) { + return db + .selectFrom("GroupReadyCheck") + .select([ + "GroupReadyCheck.id", + "GroupReadyCheck.alphaGroupId", + "GroupReadyCheck.bravoGroupId", + ]) + .where("GroupReadyCheck.createdAt", "<", dateToDatabaseTimestamp(date)) + .execute(); +} + +/** + * Starts a ready check between two groups, taking both out of the looking pool + * along with everything they had pending. The user starting it counts as ready + * right away. + */ +export function insertReadyCheck({ + alphaGroupId, + bravoGroupId, + confirmedByUserId, +}: { + alphaGroupId: number; + bravoGroupId: number; + confirmedByUserId: number; +}) { + return db.transaction().execute(async (trx) => { + // the status doubles as the lock that keeps a group out of two ready checks at once + const { numUpdatedRows } = await trx + .updateTable("Group") + .set({ status: "READY_CHECK", latestActionAt: databaseTimestampNow() }) + .where("Group.id", "in", [alphaGroupId, bravoGroupId]) + .where("Group.status", "=", "ACTIVE") + .executeTakeFirstOrThrow(); + + if (Number(numUpdatedRows) !== 2) { + throw new SendouQError("Both groups are not available for a ready check"); + } + + await deleteLikesAndSuggestionsByGroupId(alphaGroupId, trx); + await deleteLikesAndSuggestionsByGroupId(bravoGroupId, trx); + + // a new ready check is a fresh chance to show up for everyone + await trx + .updateTable("GroupMember") + .set({ missedReadyCheckAt: null }) + .where("GroupMember.groupId", "in", [alphaGroupId, bravoGroupId]) + .execute(); + + const readyCheck = await trx + .insertInto("GroupReadyCheck") + .values({ alphaGroupId, bravoGroupId }) + .returning("id") + .executeTakeFirstOrThrow(); + + await trx + .insertInto("GroupReadyCheckConfirmation") + .values({ readyCheckId: readyCheck.id, userId: confirmedByUserId }) + .execute(); + + return readyCheck; + }); +} + +/** + * Records the user as ready to play. Confirming twice is a no-op. + * + * @returns Whether every member of both groups has now confirmed, or `null` if the ready check no longer exists + */ +export function insertReadyCheckConfirmation({ + readyCheckId, + userId, +}: { + readyCheckId: number; + userId: number; +}) { + return db.transaction().execute(async (trx) => { + const readyCheck = await trx + .selectFrom("GroupReadyCheck") + .select(["GroupReadyCheck.alphaGroupId", "GroupReadyCheck.bravoGroupId"]) + .where("GroupReadyCheck.id", "=", readyCheckId) + .executeTakeFirst(); + + // someone else's request resolved the ready check while this one was in flight + if (!readyCheck) return null; + + await trx + .insertInto("GroupReadyCheckConfirmation") + .values({ readyCheckId, userId }) + .onConflict((oc) => oc.columns(["readyCheckId", "userId"]).doNothing()) + .execute(); + + // read back rather than trusting the caller's view of who had confirmed, so + // that two last confirmations at once can't both conclude someone is missing + const unconfirmedMember = await trx + .selectFrom("GroupMember") + .select("GroupMember.userId") + .where("GroupMember.groupId", "in", [ + readyCheck.alphaGroupId, + readyCheck.bravoGroupId, + ]) + .where((eb) => didNotConfirmReadyCheck(eb, readyCheckId)) + .executeTakeFirst(); + + return { everyoneIsReady: !unconfirmedMember }; + }); +} + +/** + * Ends a ready check, returning both of its groups to the looking pool. With + * `markMissedMembers` the members who never confirmed are marked as having + * missed it, which is what lets the rest of their group kick them. + */ +export function deleteReadyCheck( + { id, markMissedMembers }: { id: number; markMissedMembers: boolean }, + trx?: Transaction, +) { + const run = (trx: Transaction) => + deleteReadyCheckInTrx({ id, markMissedMembers }, trx); + + return trx ? run(trx) : db.transaction().execute(run); +} + export function setPreparingGroupAsActive(groupId: number) { return db .updateTable("Group") @@ -792,6 +1009,42 @@ export function setAsInactive(groupId: number, trx?: Transaction) { .where("id", "=", groupId) .execute(); } +async function deleteReadyCheckInTrx( + { id, markMissedMembers }: { id: number; markMissedMembers: boolean }, + trx: Transaction, +) { + const readyCheck = await trx + .selectFrom("GroupReadyCheck") + .select(["GroupReadyCheck.alphaGroupId", "GroupReadyCheck.bravoGroupId"]) + .where("GroupReadyCheck.id", "=", id) + .executeTakeFirst(); + + if (!readyCheck) return; + + const groupIds = [readyCheck.alphaGroupId, readyCheck.bravoGroupId]; + + if (markMissedMembers) { + await trx + .updateTable("GroupMember") + .set({ missedReadyCheckAt: databaseTimestampNow() }) + .where("GroupMember.groupId", "in", groupIds) + .where((eb) => didNotConfirmReadyCheck(eb, id)) + .execute(); + } + + await trx + .deleteFrom("GroupReadyCheck") + .where("GroupReadyCheck.id", "=", id) + .execute(); + + await trx + .updateTable("Group") + .set({ status: "ACTIVE", latestActionAt: databaseTimestampNow() }) + .where("Group.id", "in", groupIds) + .where("Group.status", "=", "READY_CHECK") + .execute(); +} + async function recordImplicitRejoinNoVote( userId: number, trx: Transaction, @@ -840,3 +1093,23 @@ async function recordImplicitRejoinNoVote( return candidate.matchChatCode; } + +/** Matches the `GroupMember` rows that have no confirmation for the given ready check. */ +function didNotConfirmReadyCheck( + eb: ExpressionBuilder, + readyCheckId: number, +) { + return eb.not( + eb.exists( + eb + .selectFrom("GroupReadyCheckConfirmation") + .select("GroupReadyCheckConfirmation.userId") + .where("GroupReadyCheckConfirmation.readyCheckId", "=", readyCheckId) + .whereRef( + "GroupReadyCheckConfirmation.userId", + "=", + "GroupMember.userId", + ), + ), + ); +} diff --git a/app/features/sendouq/actions/q.looking.server.ts b/app/features/sendouq/actions/q.looking.server.ts index 51e03eb9e..530c946e9 100644 --- a/app/features/sendouq/actions/q.looking.server.ts +++ b/app/features/sendouq/actions/q.looking.server.ts @@ -2,27 +2,21 @@ import type { ActionFunction } from "react-router"; import { redirect } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; -import { notify } from "~/features/notifications/core/notify.server"; import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; -import { - createMatchMemento, - matchMapList, -} from "~/features/sendouq-match/core/match.server"; -import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; -import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server"; import { parseFormData } from "~/form/parse.server"; +import { errorToastIfFalsy } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; -import { SENDOUQ_PAGE, sendouQMatchPage } from "~/utils/urls"; +import { SENDOUQ_PAGE, SENDOUQ_READY_PAGE } from "~/utils/urls"; import { canSuggest, groupAfterMorph } from "../core/groups"; +import * as ReadyCheck from "../core/ready-check.server"; import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server"; import { lookingSchema } from "../q-action-schemas"; -import { SENDOUQ_LOOKING_ROOM, sqGroupWebsocketRoom } from "../q-constants"; -import { resolveFutureMatchModes } from "../q-utils"; import { - SendouQError, - setGroupChatMetadata, - setMatchChatMetadata, -} from "../q-utils.server"; + FULL_GROUP_SIZE, + SENDOUQ_LOOKING_ROOM, + sqGroupWebsocketRoom, +} from "../q-constants"; +import { SendouQError, setGroupChatMetadata } from "../q-utils.server"; // this function doesn't throw normally because we are assuming // if there is a validation error the user saw stale data @@ -183,101 +177,30 @@ export const action: ActionFunction = async ({ request }) => { return null; } - const ownGroupPreferences = - await SQGroupRepository.findMapModePreferencesByGroupId(ownGroup.id); - const theirGroupPreferences = - await SQGroupRepository.findMapModePreferencesByGroupId( - theirGroup.id, - ); - - const modesIncluded = resolveFutureMatchModes(ownGroup, theirGroup); - - const mapList = await matchMapList( - { - id: ownGroup.id, - preferences: ownGroupPreferences, - }, - { - id: theirGroup.id, - preferences: theirGroupPreferences, - }, - modesIncluded, + const bothCanPlay = [ownGroup, theirGroup].every( + (group) => group.members.length === FULL_GROUP_SIZE && !group.matchId, ); + if (!bothCanPlay) return null; - const createdMatch = await SQMatchRepository.insert({ - alphaGroupId: ownGroup.id, - bravoGroupId: theirGroup.id, - mapList, - memento: await createMatchMemento({ - own: { group: ownGroup, preferences: ownGroupPreferences }, - their: { group: theirGroup, preferences: theirGroupPreferences }, - mapList, - }), + await ReadyCheck.start({ + ownGroup, + theirGroup, + actorUserId: user.id, }); - await refreshSendouQInstance(); - refreshStreamsCache(); - - if (createdMatch.chatCode) { - setMatchChatMetadata({ - id: createdMatch.id, - chatCode: createdMatch.chatCode, - participantUserIds: [ - ...ownGroup.members.map((m) => m.id), - ...theirGroup.members.map((m) => m.id), - ], - }); - } - - // extend the group chat rooms' expiry so they last through the match - for (const group of [ownGroup, theirGroup]) { - if (group.chatCode) { - setGroupChatMetadata({ - chatCode: group.chatCode, - members: group.members, - }); - } - } - - // Both groups revalidate (→ redirected to the match by their looking - // loader) and play the match sound. Sent to the groups' topics so it - // reaches every member reliably, not just live chat participants. - ChatSystemMessage.send([ - { - room: sqGroupWebsocketRoom(ownGroup.id), - type: "MATCH_STARTED", - revalidateOnly: true, - }, - { - room: sqGroupWebsocketRoom(theirGroup.id), - type: "MATCH_STARTED", - revalidateOnly: true, - }, - ]); - - notify({ - userIds: [ - ...ownGroup.members.map((m) => m.id), - ...theirGroup.members.map((m) => m.id), - ], - defaultSeenUserIds: [user.id], - notification: { - type: "SQ_NEW_MATCH", - meta: { - matchId: createdMatch.id, - }, - }, - }); - - broadcastLookingUpdate(); - - throw redirect(sendouQMatchPage(createdMatch.id)); + throw redirect(SENDOUQ_READY_PAGE); } case "LEAVE_GROUP": { - await SQGroupRepository.leaveGroup(user.id); + const { abortedReadyCheckGroupIds } = + await SQGroupRepository.leaveGroup(user.id); await refreshSendouQInstance(); + // the group that was about to play them is free to look again + for (const groupId of abortedReadyCheckGroupIds) { + revalidateGroupTopic(groupId); + } + const remainingGroup = SendouQ.findUncensoredGroupById(currentGroup.id); if (remainingGroup?.chatCode) { ChatSystemMessage.send({ @@ -295,6 +218,42 @@ export const action: ActionFunction = async ({ request }) => { throw redirect(SENDOUQ_PAGE); } + case "KICK_FROM_GROUP": { + errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself"); + errorToastIfFalsy( + ( + await SQGroupRepository.findAllMissedReadyCheckUserIdsByGroupId( + currentGroup.id, + ) + ).includes(data.userId), + "Only a member who missed a ready check can be kicked", + ); + + const kickedMember = currentGroup.members.find( + (member) => member.id === data.userId, + ); + + await SQGroupRepository.leaveGroup(data.userId); + + await refreshSendouQInstance(); + + const groupAfterKick = SendouQ.findUncensoredGroupById(currentGroup.id); + if (groupAfterKick?.chatCode && kickedMember) { + ChatSystemMessage.send({ + room: groupAfterKick.chatCode, + type: "USER_LEFT", + context: { name: kickedMember.username }, + }); + setGroupChatMetadata({ + chatCode: groupAfterKick.chatCode, + members: groupAfterKick.members, + }); + } + + broadcastLookingUpdate(); + + break; + } case "REFRESH_GROUP": { await SQGroupRepository.refreshGroup(currentGroup.id); diff --git a/app/features/sendouq/actions/q.ready.server.ts b/app/features/sendouq/actions/q.ready.server.ts new file mode 100644 index 000000000..6c667f39d --- /dev/null +++ b/app/features/sendouq/actions/q.ready.server.ts @@ -0,0 +1,61 @@ +import type { ActionFunctionArgs } from "react-router"; +import { redirect } from "react-router"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import { parseRequestPayload } from "~/utils/remix.server"; +import { assertUnreachable } from "~/utils/types"; +import { SENDOUQ_LOOKING_PAGE, sendouQMatchPage } from "~/utils/urls"; +import * as ReadyCheck from "../core/ready-check.server"; +import { SendouQ } from "../core/SendouQ.server"; +import { readySchema } from "../q-action-schemas"; +import { SendouQError } from "../q-utils.server"; + +export const action = async ({ request }: ActionFunctionArgs) => { + const user = requireUser(); + const data = await parseRequestPayload({ + request, + schema: readySchema, + }); + + const ownGroup = SendouQ.findOwnGroup(user.id); + if (!ownGroup) return null; + + try { + switch (data._action) { + case "CONFIRM_READY": { + const readyCheck = await SQGroupRepository.findReadyCheckByGroupId( + ownGroup.id, + ); + if (!readyCheck) return null; + + if (ReadyCheck.hasExpired(readyCheck)) { + await ReadyCheck.expire(readyCheck); + + throw redirect(SENDOUQ_LOOKING_PAGE); + } + + const matchId = await ReadyCheck.confirm({ + readyCheck, + userId: user.id, + }); + + if (matchId) { + throw redirect(sendouQMatchPage(matchId)); + } + + return null; + } + default: { + assertUnreachable(data._action); + } + } + } catch (error) { + // e.g. the ready check was resolved by someone else's request in the + // meantime. return null so loaders re-run and the user sees the fresh state + if (error instanceof SendouQError) { + return null; + } + + throw error; + } +}; diff --git a/app/features/sendouq/components/GroupCard.module.css b/app/features/sendouq/components/GroupCard.module.css index 0a1299e7d..65f2cf6f7 100644 --- a/app/features/sendouq/components/GroupCard.module.css +++ b/app/features/sendouq/components/GroupCard.module.css @@ -125,3 +125,26 @@ font-weight: var(--weight-bold); height: 19.8281px; } + +.hiddenMember { + min-height: 2.75rem; +} + +.hiddenMemberName { + color: var(--color-text-high); + font-size: var(--font-xs); + font-weight: var(--weight-bold); + letter-spacing: 0.1em; + padding-block: var(--s-2); + padding-inline-start: var(--s-3); +} + +.readyIcon { + width: 18px; + height: 18px; + color: var(--color-text-high); +} + +.readyIconConfirmed { + color: var(--color-success); +} diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 060bc1566..7856d49be 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import type { SqlBool } from "kysely"; -import { Mic, Volume2, VolumeX } from "lucide-react"; +import { Check, Hourglass, Mic, Volume2, VolumeX } from "lucide-react"; import * as React from "react"; import { Flipped } from "react-flip-toolkit"; import { Trans, useTranslation } from "react-i18next"; @@ -23,6 +23,7 @@ import { import { SendouForm } from "~/form/SendouForm"; import { languagesUnified } from "~/modules/i18n/config"; import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids"; +import { nullFilledArray } from "~/utils/arrays"; import { inGameNameWithoutDiscriminator } from "~/utils/strings"; import { SENDOUQ_LOOKING_PAGE, @@ -57,6 +58,8 @@ export function GroupCard({ hideWeapons = false, hideNote: _hidenote = false, ownGroup, + readyUserIds, + kickableUserIds, layout = "desktop", }: { group: SQGroup | SQOwnGroup; @@ -69,6 +72,10 @@ export function GroupCard({ hideWeapons?: SqlBool; hideNote?: boolean; ownGroup?: SQOwnGroup; + /** Members who have confirmed they are ready to play, shown during a ready check. */ + readyUserIds?: number[]; + /** Members the viewer can kick out of the group. */ + kickableUserIds?: number[]; layout?: "mobile" | "desktop"; }) { const { t } = useTranslation(["q"]); @@ -102,6 +109,8 @@ export function GroupCard({ hideVc={hideVc} hideWeapons={hideWeapons} hideNote={hideNote} + isReady={readyUserIds?.includes(member.id)} + isKickable={kickableUserIds?.includes(member.id)} /> ); })} @@ -304,11 +313,15 @@ function GroupMember({ hideVc, hideWeapons, hideNote, + isReady, + isKickable, }: { member: SQGroupMember; hideVc?: SqlBool; hideWeapons?: SqlBool; hideNote?: boolean; + isReady?: boolean; + isKickable?: boolean; }) { const { t } = useTranslation(["q", "user"]); const user = useUser(); @@ -347,45 +360,52 @@ function GroupMember({ styles.memberActions, )} > + {typeof isReady === "boolean" ? ( + + ) : null} {member.skill ? : null}
-
-
- {member.vc && !hideVc ? ( + {isKickable ? ( + + ) : ( +
+
+ {member.vc && !hideVc ? ( +
+ +
+ ) : null} + {member.friendCode ? ( + + FC + + } + > + SW-{member.friendCode} + + ) : null} +
+ {member.weapons && member.weapons.length > 0 && !hideWeapons ? (
- + {member.weapons?.map((weapon) => { + return ( + + ); + })}
) : null} - {member.friendCode ? ( - - FC - - } - > - SW-{member.friendCode} - + {member.skillDifference ? ( + ) : null}
- {member.weapons && member.weapons.length > 0 && !hideWeapons ? ( -
- {member.weapons?.map((weapon) => { - return ( - - ); - })} -
- ) : null} - {member.skillDifference ? ( - - ) : null} -
+ )} {!hideNote ? ( ) : null} @@ -393,6 +413,75 @@ function GroupMember({ ); } +/** Stand-in for a group whose members are not revealed yet, showing only how many of them are ready to play. */ +export function HiddenGroupCard({ + memberCount, + readyCount, +}: { + memberCount: number; + readyCount: number; +}) { + return ( +
+
+ {nullFilledArray(memberCount).map((_, i) => ( +
+ ??? +
+ +
+
+ ))} +
+
+ ); +} + +function ReadyIndicator({ isReady }: { isReady: boolean }) { + const { t } = useTranslation(["q"]); + + const Icon = isReady ? Check : Hourglass; + + return ( + + ); +} + +function MemberKicker({ member }: { member: SQGroupMember }) { + const { t } = useTranslation(["common", "q"]); + + return ( +
+ {t("q:looking.groups.missedReadyCheck")} + + {t("q:looking.groups.actions.kick")} + +
+ ); +} + function MemberNote({ note, editable, diff --git a/app/features/sendouq/core/SendouQ.server.ts b/app/features/sendouq/core/SendouQ.server.ts index e50fb3eb2..bd5cf2af7 100644 --- a/app/features/sendouq/core/SendouQ.server.ts +++ b/app/features/sendouq/core/SendouQ.server.ts @@ -109,6 +109,7 @@ class SendouQClass { if (!ownGroup) return "default"; if (ownGroup.status === "PREPARING") return "preparing"; if (ownGroup.matchId) return "match"; + if (ownGroup.status === "READY_CHECK") return "ready"; return "looking"; } diff --git a/app/features/sendouq/core/ready-check.server.test.ts b/app/features/sendouq/core/ready-check.server.test.ts new file mode 100644 index 000000000..ce39b0b82 --- /dev/null +++ b/app/features/sendouq/core/ready-check.server.test.ts @@ -0,0 +1,305 @@ +import { subMinutes } from "date-fns"; +import { beforeEach, describe, expect, test, vi } from "vitest"; + +vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ + send: vi.fn(), + removeRoom: vi.fn(), + setMetadata: vi.fn(), +})); + +import { backdate } from "~/db/seed/core/backdate"; +import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { db } from "~/db/sql"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import invariant from "~/utils/invariant"; +import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants"; +import * as ReadyCheck from "./ready-check.server"; +import { refreshSendouQInstance, SendouQ } from "./SendouQ.server"; + +const setupMatchedUpGroups = async () => { + const ownMembers = await UserFactory.createMany(FULL_GROUP_SIZE); + const theirMembers = await UserFactory.createMany(FULL_GROUP_SIZE); + + const theirGroup = await SQGroupFactory.create({ + memberUserIds: theirMembers.map((member) => member.id), + }); + const ownGroup = await SQGroupFactory.create( + { memberUserIds: ownMembers.map((member) => member.id) }, + { likedByGroupIds: [theirGroup.id] }, + ); + + await refreshSendouQInstance(); + + await ReadyCheck.start({ + ownGroup: SendouQ.findUncensoredGroupById(ownGroup.id)!, + theirGroup: SendouQ.findUncensoredGroupById(theirGroup.id)!, + actorUserId: ownMembers[0].id, + }); + + return { ownGroup, theirGroup, ownMembers, theirMembers }; +}; + +const findReadyCheck = (groupId: number) => + SQGroupRepository.findReadyCheckByGroupId(groupId); + +const findGroupStatus = async (groupId: number) => { + const group = await db + .selectFrom("Group") + .select("Group.status") + .where("Group.id", "=", groupId) + .executeTakeFirstOrThrow(); + + return group.status; +}; + +const findMatch = () => + db.selectFrom("GroupMatch").selectAll().executeTakeFirst(); + +/** Confirms every member of both groups as ready, which is what creates the match. */ +const confirmEveryoneReady = async (groupId: number) => { + for (;;) { + const readyCheck = await findReadyCheck(groupId); + if (!readyCheck) return; + + const nextToConfirm = readyCheck.members.find( + (member) => !member.confirmedAt, + ); + invariant(nextToConfirm, "Everyone confirmed but no match was created"); + + await ReadyCheck.confirm({ readyCheck, userId: nextToConfirm.userId }); + } +}; + +describe("SendouQ ready check", () => { + let groups: Awaited>; + + beforeEach(async () => { + groups = await setupMatchedUpGroups(); + }); + + test("takes both groups out of the looking pool", async () => { + expect(await findGroupStatus(groups.ownGroup.id)).toBe("READY_CHECK"); + expect(await findGroupStatus(groups.theirGroup.id)).toBe("READY_CHECK"); + expect(SendouQ.lookingGroups(groups.ownMembers[0].id)).toHaveLength(0); + }); + + test("counts the user who started it as ready", async () => { + const readyCheck = await findReadyCheck(groups.ownGroup.id); + + const confirmed = readyCheck!.members.filter( + (member) => member.confirmedAt, + ); + expect(confirmed).toHaveLength(1); + expect(confirmed[0].userId).toBe(groups.ownMembers[0].id); + }); + + test("creates the match only once everyone has confirmed", async () => { + const membersToConfirm = [ + ...groups.ownMembers.slice(1), + ...groups.theirMembers, + ]; + + for (const member of membersToConfirm.slice(0, -1)) { + const readyCheck = await findReadyCheck(groups.ownGroup.id); + invariant(readyCheck, "Ready check ended early"); + + const matchId = await ReadyCheck.confirm({ + readyCheck, + userId: member.id, + }); + + expect(matchId).toBeNull(); + expect(await findMatch()).toBeUndefined(); + } + + const readyCheck = await findReadyCheck(groups.ownGroup.id); + invariant(readyCheck, "Ready check ended early"); + + const matchId = await ReadyCheck.confirm({ + readyCheck, + userId: membersToConfirm.at(-1)!.id, + }); + + const match = await findMatch(); + expect(match?.id).toBe(matchId); + + // the ready check is done and the groups are matched up + expect(await findReadyCheck(groups.ownGroup.id)).toBeUndefined(); + expect(await findGroupStatus(groups.ownGroup.id)).toBe("ACTIVE"); + expect(await findGroupStatus(groups.theirGroup.id)).toBe("ACTIVE"); + }); + + test("the last two confirming at the same time still creates the match", async () => { + const membersToConfirm = [ + ...groups.ownMembers.slice(1), + ...groups.theirMembers, + ]; + + for (const member of membersToConfirm.slice(0, -2)) { + const readyCheck = await findReadyCheck(groups.ownGroup.id); + invariant(readyCheck, "Ready check ended early"); + + await ReadyCheck.confirm({ readyCheck, userId: member.id }); + } + + // both of them read the state before either had confirmed, so neither one's + // view of it shows the other as ready + const staleReadyCheck = await findReadyCheck(groups.ownGroup.id); + invariant(staleReadyCheck, "Ready check ended early"); + + const [secondToLast, last] = membersToConfirm.slice(-2); + + expect( + await ReadyCheck.confirm({ + readyCheck: staleReadyCheck, + userId: secondToLast.id, + }), + ).toBeNull(); + + const matchId = await ReadyCheck.confirm({ + readyCheck: staleReadyCheck, + userId: last.id, + }); + + expect(matchId).not.toBeNull(); + expect((await findMatch())?.id).toBe(matchId); + }); + + test("confirming a ready check that already ended does nothing", async () => { + const readyCheck = await findReadyCheck(groups.ownGroup.id); + invariant(readyCheck); + + await ReadyCheck.expire(readyCheck); + + expect( + await ReadyCheck.confirm({ readyCheck, userId: groups.ownMembers[1].id }), + ).toBeNull(); + expect(await findMatch()).toBeUndefined(); + }); + + test("expiring sends both groups back to looking and marks who missed it", async () => { + const readyCheck = await findReadyCheck(groups.ownGroup.id); + invariant(readyCheck); + + await ReadyCheck.confirm({ readyCheck, userId: groups.ownMembers[1].id }); + + await ReadyCheck.expire(readyCheck); + + expect(await findMatch()).toBeUndefined(); + expect(await findReadyCheck(groups.ownGroup.id)).toBeUndefined(); + expect(await findGroupStatus(groups.ownGroup.id)).toBe("ACTIVE"); + expect(await findGroupStatus(groups.theirGroup.id)).toBe("ACTIVE"); + + const kickable = + await SQGroupRepository.findAllMissedReadyCheckUserIdsByGroupId( + groups.ownGroup.id, + ); + + // the two who confirmed are not kickable, the two who didn't are + expect(kickable.sort()).toEqual( + [groups.ownMembers[2].id, groups.ownMembers[3].id].sort(), + ); + }); + + test("the challenge is gone after expiring, so the groups have to match up again", async () => { + const readyCheck = await findReadyCheck(groups.ownGroup.id); + invariant(readyCheck); + + await ReadyCheck.expire(readyCheck); + + const likes = await SQGroupRepository.findAllLikesByGroupId( + groups.ownGroup.id, + ); + expect(likes.given).toHaveLength(0); + expect(likes.received).toHaveLength(0); + }); + + test("a new ready check gives everyone a fresh chance to show up", async () => { + const readyCheck = await findReadyCheck(groups.ownGroup.id); + invariant(readyCheck); + + await ReadyCheck.expire(readyCheck); + await refreshSendouQInstance(); + + await ReadyCheck.start({ + ownGroup: SendouQ.findUncensoredGroupById(groups.ownGroup.id)!, + theirGroup: SendouQ.findUncensoredGroupById(groups.theirGroup.id)!, + actorUserId: groups.ownMembers[0].id, + }); + + expect( + await SQGroupRepository.findAllMissedReadyCheckUserIdsByGroupId( + groups.ownGroup.id, + ), + ).toHaveLength(0); + }); + + test("getting into a match clears who missed the previous check", async () => { + const firstCheck = await findReadyCheck(groups.ownGroup.id); + invariant(firstCheck); + + await ReadyCheck.expire(firstCheck); + await refreshSendouQInstance(); + + // nobody was kicked, the same groups match up again and all show up + await ReadyCheck.start({ + ownGroup: SendouQ.findUncensoredGroupById(groups.ownGroup.id)!, + theirGroup: SendouQ.findUncensoredGroupById(groups.theirGroup.id)!, + actorUserId: groups.ownMembers[0].id, + }); + await confirmEveryoneReady(groups.ownGroup.id); + + expect(await findMatch()).toBeDefined(); + + for (const groupId of [groups.ownGroup.id, groups.theirGroup.id]) { + expect( + await SQGroupRepository.findAllMissedReadyCheckUserIdsByGroupId( + groupId, + ), + ).toHaveLength(0); + } + }); + + test("leaving the group calls the ready check off for both groups", async () => { + const { abortedReadyCheckGroupIds } = await SQGroupRepository.leaveGroup( + groups.theirMembers[0].id, + ); + + expect(abortedReadyCheckGroupIds.sort()).toEqual( + [groups.ownGroup.id, groups.theirGroup.id].sort(), + ); + expect(await findReadyCheck(groups.ownGroup.id)).toBeUndefined(); + expect(await findGroupStatus(groups.ownGroup.id)).toBe("ACTIVE"); + expect(await findGroupStatus(groups.theirGroup.id)).toBe("ACTIVE"); + + // nobody is blamed for a ready check that was called off + expect( + await SQGroupRepository.findAllMissedReadyCheckUserIdsByGroupId( + groups.ownGroup.id, + ), + ).toHaveLength(0); + }); + + test("a ready check that ran its course is found as expired", async () => { + expect( + await SQGroupRepository.findAllReadyChecksStartedBefore( + subMinutes(new Date(), SENDOUQ.READY_CHECK_MINUTES), + ), + ).toHaveLength(0); + + const readyCheck = await findReadyCheck(groups.ownGroup.id); + invariant(readyCheck); + + await backdate("GroupReadyCheck", readyCheck.id, { + createdAt: subMinutes(new Date(), SENDOUQ.READY_CHECK_MINUTES + 1), + }); + + expect( + await SQGroupRepository.findAllReadyChecksStartedBefore( + subMinutes(new Date(), SENDOUQ.READY_CHECK_MINUTES), + ), + ).toHaveLength(1); + expect(ReadyCheck.hasExpired({ ...readyCheck, createdAt: 0 })).toBe(true); + }); +}); diff --git a/app/features/sendouq/core/ready-check.server.ts b/app/features/sendouq/core/ready-check.server.ts new file mode 100644 index 000000000..8a19c9274 --- /dev/null +++ b/app/features/sendouq/core/ready-check.server.ts @@ -0,0 +1,259 @@ +import { addMinutes } from "date-fns"; +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import { notify } from "~/features/notifications/core/notify.server"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import { + createMatchMemento, + matchMapList, +} from "~/features/sendouq-match/core/match.server"; +import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; +import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { + SENDOUQ, + SENDOUQ_LOOKING_ROOM, + sqGroupWebsocketRoom, +} from "../q-constants"; +import { resolveFutureMatchModes } from "../q-utils"; +import { setGroupChatMetadata, setMatchChatMetadata } from "../q-utils.server"; +import { refreshSendouQInstance, SendouQ } from "./SendouQ.server"; + +export type ReadyCheck = NonNullable< + Awaited> +>; + +type ReadyCheckGroup = { + id: number; + chatCode: string | null; + members: Array<{ id: number }>; +}; + +/** When the ready check runs out, both groups going back to looking. */ +export function expiresAt(readyCheck: { createdAt: number }) { + return addMinutes( + databaseTimestampToDate(readyCheck.createdAt), + SENDOUQ.READY_CHECK_MINUTES, + ); +} + +/** Has the ready check run out of time to be confirmed? */ +export function hasExpired(readyCheck: { createdAt: number }) { + return expiresAt(readyCheck) <= new Date(); +} + +/** + * Starts a ready check between two groups that matched up. Both leave the + * looking pool while their members confirm they are ready to play. The user + * starting it counts as ready right away. + */ +export async function start({ + ownGroup, + theirGroup, + actorUserId, +}: { + ownGroup: ReadyCheckGroup; + theirGroup: ReadyCheckGroup; + actorUserId: number; +}) { + await SQGroupRepository.insertReadyCheck({ + alphaGroupId: ownGroup.id, + bravoGroupId: theirGroup.id, + confirmedByUserId: actorUserId, + }); + + await refreshSendouQInstance(); + + // extend the group chat rooms' expiry so they last through the match + for (const group of [ownGroup, theirGroup]) { + if (group.chatCode) { + setGroupChatMetadata({ + chatCode: group.chatCode, + members: group.members, + }); + } + } + + // Both groups revalidate (→ sent to the ready check by their looking loader) + // and play the ready check sound. Sent to the groups' topics so it reaches + // every member reliably, not just live chat participants. + ChatSystemMessage.send([ + { + room: sqGroupWebsocketRoom(ownGroup.id), + type: "READY_CHECK_STARTED", + revalidateOnly: true, + }, + { + room: sqGroupWebsocketRoom(theirGroup.id), + type: "READY_CHECK_STARTED", + revalidateOnly: true, + }, + { + room: SENDOUQ_LOOKING_ROOM, + revalidateOnly: true, + }, + ]); + + notify({ + userIds: [ + ...ownGroup.members.map((m) => m.id), + ...theirGroup.members.map((m) => m.id), + ], + defaultSeenUserIds: [actorUserId], + notification: { + type: "SQ_READY_CHECK", + }, + }); +} + +/** + * Records the user as ready to play. Once everyone from both groups has, the + * match is created. + * @returns Id of the created match, or `null` if others are still to confirm or the ready check already ended + */ +export async function confirm({ + readyCheck, + userId, +}: { + readyCheck: ReadyCheck; + userId: number; +}) { + const confirmation = await SQGroupRepository.insertReadyCheckConfirmation({ + readyCheckId: readyCheck.id, + userId, + }); + + // the ready check ended (e.g. ran out of time) while this request was in flight + if (!confirmation) return null; + + if (!confirmation.everyoneIsReady) { + revalidateGroups(readyCheck); + + return null; + } + + return createMatch({ readyCheck, actorUserId: userId }); +} + +/** + * Ends a ready check that ran out of time. Both groups go back to looking with + * their challenges gone, and the members who never confirmed are marked as + * having missed it so the rest of their group can kick them. + */ +export async function expire(readyCheck: { + id: number; + alphaGroupId: number; + bravoGroupId: number; +}) { + await SQGroupRepository.deleteReadyCheck({ + id: readyCheck.id, + markMissedMembers: true, + }); + + await refreshSendouQInstance(); + + revalidateGroups(readyCheck); +} + +async function createMatch({ + readyCheck, + actorUserId, +}: { + readyCheck: ReadyCheck; + actorUserId: number; +}) { + const alphaGroup = SendouQ.findUncensoredGroupById(readyCheck.alphaGroupId); + const bravoGroup = SendouQ.findUncensoredGroupById(readyCheck.bravoGroupId); + if (!alphaGroup || !bravoGroup) return null; + + const alphaPreferences = + await SQGroupRepository.findMapModePreferencesByGroupId(alphaGroup.id); + const bravoPreferences = + await SQGroupRepository.findMapModePreferencesByGroupId(bravoGroup.id); + + const modesIncluded = resolveFutureMatchModes(alphaGroup, bravoGroup); + + const mapList = await matchMapList( + { + id: alphaGroup.id, + preferences: alphaPreferences, + }, + { + id: bravoGroup.id, + preferences: bravoPreferences, + }, + modesIncluded, + ); + + const createdMatch = await SQMatchRepository.insert({ + alphaGroupId: alphaGroup.id, + bravoGroupId: bravoGroup.id, + mapList, + memento: await createMatchMemento({ + own: { group: alphaGroup, preferences: alphaPreferences }, + their: { group: bravoGroup, preferences: bravoPreferences }, + mapList, + }), + readyCheckId: readyCheck.id, + }); + + await refreshSendouQInstance(); + refreshStreamsCache(); + + if (createdMatch.chatCode) { + setMatchChatMetadata({ + id: createdMatch.id, + chatCode: createdMatch.chatCode, + participantUserIds: readyCheck.members.map((member) => member.userId), + }); + } + + ChatSystemMessage.send([ + { + room: sqGroupWebsocketRoom(readyCheck.alphaGroupId), + type: "MATCH_STARTED", + revalidateOnly: true, + }, + { + room: sqGroupWebsocketRoom(readyCheck.bravoGroupId), + type: "MATCH_STARTED", + revalidateOnly: true, + }, + { + room: SENDOUQ_LOOKING_ROOM, + revalidateOnly: true, + }, + ]); + + notify({ + userIds: readyCheck.members.map((member) => member.userId), + defaultSeenUserIds: [actorUserId], + notification: { + type: "SQ_NEW_MATCH", + meta: { + matchId: createdMatch.id, + }, + }, + }); + + return createdMatch.id; +} + +function revalidateGroups(readyCheck: { + alphaGroupId: number; + bravoGroupId: number; +}) { + ChatSystemMessage.send([ + { + room: sqGroupWebsocketRoom(readyCheck.alphaGroupId), + revalidateOnly: true, + }, + { + room: sqGroupWebsocketRoom(readyCheck.bravoGroupId), + revalidateOnly: true, + }, + { + room: SENDOUQ_LOOKING_ROOM, + revalidateOnly: true, + }, + ]); +} diff --git a/app/features/sendouq/loaders/q.looking.server.ts b/app/features/sendouq/loaders/q.looking.server.ts index e35aa9d9c..071bfa872 100644 --- a/app/features/sendouq/loaders/q.looking.server.ts +++ b/app/features/sendouq/loaders/q.looking.server.ts @@ -55,6 +55,11 @@ export const loader = async ({ url }: LoaderFunctionArgs) => { suggestions: ownGroup ? await SQGroupRepository.findAllSuggestionsByGroupId(ownGroup.id) : [], + kickableUserIds: ownGroup + ? await SQGroupRepository.findAllMissedReadyCheckUserIdsByGroupId( + ownGroup.id, + ) + : [], lastUpdated: Date.now(), streamsCount: (await cachedStreams()).length, chatCode: diff --git a/app/features/sendouq/loaders/q.ready.server.ts b/app/features/sendouq/loaders/q.ready.server.ts new file mode 100644 index 000000000..5b96c9b90 --- /dev/null +++ b/app/features/sendouq/loaders/q.ready.server.ts @@ -0,0 +1,48 @@ +import { redirect } from "react-router"; +import { requireUser } from "~/features/auth/core/user.server"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls"; +import * as ReadyCheck from "../core/ready-check.server"; +import { SendouQ } from "../core/SendouQ.server"; +import { sqRedirectIfNeeded } from "../q-utils.server"; + +export const loader = async () => { + const user = requireUser(); + + const ownGroup = SendouQ.findOwnGroup(user.id); + + sqRedirectIfNeeded({ + ownGroup, + currentLocation: "ready", + }); + + const readyCheck = await SQGroupRepository.findReadyCheckByGroupId( + ownGroup!.id, + ); + if (!readyCheck) throw redirect(SENDOUQ_LOOKING_PAGE); + + if (ReadyCheck.hasExpired(readyCheck)) { + await ReadyCheck.expire(readyCheck); + + throw redirect(SENDOUQ_LOOKING_PAGE); + } + + const theirMembers = readyCheck.members.filter( + (member) => member.groupId !== ownGroup!.id, + ); + + return { + group: ownGroup!, + expiresAt: dateToDatabaseTimestamp(ReadyCheck.expiresAt(readyCheck)), + readyUserIds: readyCheck.members + .filter((member) => member.groupId === ownGroup!.id && member.confirmedAt) + .map((member) => member.userId), + // who they are is only revealed once the match is created, so they are + // shown as a count of anonymous members + theirGroup: { + memberCount: theirMembers.length, + readyCount: theirMembers.filter((member) => member.confirmedAt).length, + }, + }; +}; diff --git a/app/features/sendouq/q-action-schemas.ts b/app/features/sendouq/q-action-schemas.ts index 8d6ed4038..c39030731 100644 --- a/app/features/sendouq/q-action-schemas.ts +++ b/app/features/sendouq/q-action-schemas.ts @@ -51,8 +51,16 @@ export const lookingSchema = z.union([ z.object({ _action: _action("LEAVE_GROUP"), }), + z.object({ + _action: _action("KICK_FROM_GROUP"), + userId: id, + }), z.object({ _action: _action("REFRESH_GROUP"), }), updateGroupNoteSchema, ]); + +export const readySchema = z.object({ + _action: _action("CONFIRM_READY"), +}); diff --git a/app/features/sendouq/q-constants.ts b/app/features/sendouq/q-constants.ts index 9df66bba2..920928bc4 100644 --- a/app/features/sendouq/q-constants.ts +++ b/app/features/sendouq/q-constants.ts @@ -5,6 +5,8 @@ export const SENDOUQ = { OWN_PUBLIC_NOTE_MAX_LENGTH: 160, PRIVATE_USER_NOTE_MAX_LENGTH: 280, CANCEL_REASON_MAX_LENGTH: 500, + /** How long the members of two matched up groups have to confirm they are ready to play */ + READY_CHECK_MINUTES: 7, } as const; const FRIEND_CODE_REGEXP_PATTERN = "^(SW-)?[0-9]{4}-?[0-9]{4}-?[0-9]{4}$"; diff --git a/app/features/sendouq/q-utils.server.ts b/app/features/sendouq/q-utils.server.ts index e09f54db0..412053f7d 100644 --- a/app/features/sendouq/q-utils.server.ts +++ b/app/features/sendouq/q-utils.server.ts @@ -8,6 +8,7 @@ import { SENDOUQ_LOOKING_PAGE, SENDOUQ_PAGE, SENDOUQ_PREPARING_PAGE, + SENDOUQ_READY_PAGE, sendouQMatchPage, } from "~/utils/urls"; import type { SQOwnGroup } from "./core/SendouQ.server"; @@ -44,6 +45,7 @@ export function clearSeasonSkillsCache() { function groupRedirectLocation(group?: SQOwnGroup) { if (group?.status === "PREPARING") return SENDOUQ_PREPARING_PAGE; if (group?.matchId) return sendouQMatchPage(group.matchId); + if (group?.status === "READY_CHECK") return SENDOUQ_READY_PAGE; if (group) return SENDOUQ_LOOKING_PAGE; return SENDOUQ_PAGE; @@ -55,7 +57,7 @@ export function sqRedirectIfNeeded({ currentLocation, }: { ownGroup?: SQOwnGroup; - currentLocation: "default" | "preparing" | "looking" | "match"; + currentLocation: "default" | "preparing" | "looking" | "ready" | "match"; }) { const newLocation = groupRedirectLocation(ownGroup); @@ -65,6 +67,7 @@ export function sqRedirectIfNeeded({ return; if (currentLocation === "looking" && newLocation === SENDOUQ_LOOKING_PAGE) return; + if (currentLocation === "ready" && newLocation === SENDOUQ_READY_PAGE) return; if (currentLocation === "match" && newLocation.includes("match")) return; throw redirect(newLocation); diff --git a/app/features/sendouq/routes/q.info.tsx b/app/features/sendouq/routes/q.info.tsx index 613b50d47..29a8ed646 100644 --- a/app/features/sendouq/routes/q.info.tsx +++ b/app/features/sendouq/routes/q.info.tsx @@ -14,6 +14,7 @@ import { SENDOUQ_RULES_PAGE, TIERS_PAGE, } from "~/utils/urls"; +import { SENDOUQ } from "../q-constants"; import styles from "./q.info.module.css"; export const meta: MetaFunction = (args) => { @@ -326,6 +327,14 @@ function FindingAnOpponent() { level (and they are free to not accept). You will also see the modes the set would have before deciding on challenging/accepting.

+

Ready check

+

+ Accepting a challenge doesn't start the match right away. First + every member of both groups has {SENDOUQ.READY_CHECK_MINUTES} minutes to + confirm that they are ready to play. Once everyone has confirmed the + match starts. If the time runs out, both groups go back to looking and + have to challenge again. +

Rechallenging

Sometimes it can take a while for a group to accept your challenge. If diff --git a/app/features/sendouq/routes/q.looking.test.ts b/app/features/sendouq/routes/q.looking.test.ts index b1aad6092..e69b33d9c 100644 --- a/app/features/sendouq/routes/q.looking.test.ts +++ b/app/features/sendouq/routes/q.looking.test.ts @@ -12,9 +12,11 @@ import { db } from "~/db/sql"; import type { UserMapModePreferences } from "~/db/tables-json"; import { BANNED_MAPS } from "~/features/match-profile/banned-maps"; import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; import { stageIds } from "~/modules/in-game-lists/stage-ids"; import invariant from "~/utils/invariant"; import { withUserId, wrappedAction } from "~/utils/Test"; +import * as ReadyCheck from "../core/ready-check.server"; import { refreshSendouQInstance } from "../core/SendouQ.server"; import type { lookingSchema } from "../q-action-schemas"; import { FULL_GROUP_SIZE } from "../q-constants"; @@ -82,6 +84,21 @@ const lookingAction = wrappedAction({ action: rawLookingAction, }); +/** Confirms every member of both groups as ready, which is what creates the match. */ +const confirmEveryoneReady = async (groupId: number) => { + for (;;) { + const readyCheck = await SQGroupRepository.findReadyCheckByGroupId(groupId); + if (!readyCheck) return; + + const nextToConfirm = readyCheck.members.find( + (member) => !member.confirmedAt, + ); + invariant(nextToConfirm, "Everyone confirmed but no match was created"); + + await ReadyCheck.confirm({ readyCheck, userId: nextToConfirm.userId }); + } +}; + const findMatch = () => db.selectFrom("GroupMatch").selectAll().executeTakeFirstOrThrow(); @@ -115,8 +132,8 @@ describe("SendouQ match creation validation", () => { describe("SendouQ match creation", () => { let groups: Awaited>; - const createMatch = () => - lookingAction( + const createMatch = async () => { + await lookingAction( { _action: "MATCH_UP", targetGroupId: groups.theirGroup.id, @@ -124,6 +141,9 @@ describe("SendouQ match creation", () => { { user: "admin" }, ); + await confirmEveryoneReady(groups.ownGroup.id); + }; + beforeEach(async () => { groups = await prepareGroups(); await refreshSendouQInstance(); diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index 6abc48a7e..f71827da3 100644 --- a/app/features/sendouq/routes/q.looking.tsx +++ b/app/features/sendouq/routes/q.looking.tsx @@ -301,7 +301,11 @@ function Groups() { {t("q:looking.columns.myGroup")} - + {data.ownGroup.inviteCode ? ( ({ + send: vi.fn(), + removeRoom: vi.fn(), + setMetadata: vi.fn(), +})); + +import { backdate } from "~/db/seed/core/backdate"; +import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import invariant from "~/utils/invariant"; +import { wrappedAction, wrappedLoader } from "~/utils/Test"; +import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls"; +import * as ReadyCheck from "../core/ready-check.server"; +import { refreshSendouQInstance, SendouQ } from "../core/SendouQ.server"; +import type { readySchema } from "../q-action-schemas"; +import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants"; +import { action as rawReadyAction, loader as rawReadyLoader } from "./q.ready"; + +const readyLoader = wrappedLoader>>({ + loader: rawReadyLoader, +}); + +const readyAction = wrappedAction({ + action: rawReadyAction, +}); + +const setupReadyCheck = async () => { + const admin = await UserFactory.createAdmin(); + const ownMembers = await UserFactory.createMany(FULL_GROUP_SIZE - 1); + const theirMembers = await UserFactory.createMany(FULL_GROUP_SIZE); + + const theirGroup = await SQGroupFactory.create({ + memberUserIds: theirMembers.map((member) => member.id), + }); + const ownGroup = await SQGroupFactory.create( + { memberUserIds: [admin.id, ...ownMembers.map((member) => member.id)] }, + { likedByGroupIds: [theirGroup.id] }, + ); + + await refreshSendouQInstance(); + + await ReadyCheck.start({ + ownGroup: SendouQ.findUncensoredGroupById(ownGroup.id)!, + theirGroup: SendouQ.findUncensoredGroupById(theirGroup.id)!, + actorUserId: admin.id, + }); + + return { admin, ownGroup, ownMembers, theirGroup, theirMembers }; +}; + +describe("SendouQ ready check page", () => { + test("doesn't reveal who the opponents are", async () => { + const { theirGroup, theirMembers } = await setupReadyCheck(); + + // one of them readies up, so that their id would have something to ride along with + const readyCheck = await SQGroupRepository.findReadyCheckByGroupId( + theirGroup.id, + ); + invariant(readyCheck); + await ReadyCheck.confirm({ readyCheck, userId: theirMembers[0].id }); + + const data = await readyLoader({ user: "admin" }); + + // all they are is a count of anonymous members + expect(data.theirGroup).toEqual({ + memberCount: FULL_GROUP_SIZE, + readyCount: 1, + }); + // so that a field carrying more about them can't be added unnoticed + expect(Object.keys(data).sort()).toEqual([ + "expiresAt", + "group", + "readyUserIds", + "theirGroup", + ]); + + const shownUserIds = [ + ...data.group.members.map((member) => member.id), + ...data.readyUserIds, + ]; + for (const member of theirMembers) { + expect(shownUserIds).not.toContain(member.id); + } + }); + + test("shows which of the own group's members are ready", async () => { + const { admin } = await setupReadyCheck(); + + const data = await readyLoader({ user: "admin" }); + + // starting the ready check counted as being ready + expect(data.readyUserIds).toEqual([admin.id]); + }); + + test("readying up after it ran out of time sends the group back to looking", async () => { + const { ownGroup, ownMembers } = await setupReadyCheck(); + + const readyCheck = await SQGroupRepository.findReadyCheckByGroupId( + ownGroup.id, + ); + invariant(readyCheck); + await backdate("GroupReadyCheck", readyCheck.id, { + createdAt: subMinutes(new Date(), SENDOUQ.READY_CHECK_MINUTES + 1), + }); + + const response = await readyAction( + { _action: "CONFIRM_READY" }, + { user: "admin" }, + ); + + expect(response.headers.get("Location")).toBe(SENDOUQ_LOOKING_PAGE); + // the check is over, not confirmed + expect( + await SQGroupRepository.findReadyCheckByGroupId(ownGroup.id), + ).toBeUndefined(); + expect( + await SQGroupRepository.findAllMissedReadyCheckUserIdsByGroupId( + ownGroup.id, + ), + ).toEqual(ownMembers.map((member) => member.id)); + }); +}); diff --git a/app/features/sendouq/routes/q.ready.tsx b/app/features/sendouq/routes/q.ready.tsx new file mode 100644 index 000000000..833718cb6 --- /dev/null +++ b/app/features/sendouq/routes/q.ready.tsx @@ -0,0 +1,117 @@ +import clsx from "clsx"; +import { differenceInSeconds } from "date-fns"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import type { MetaFunction } from "react-router"; +import { useLoaderData, useRevalidator } from "react-router"; +import { ActionButton } from "~/components/ActionButton"; +import { Main } from "~/components/Main"; +import { useUser } from "~/features/auth/core/user"; +import { useWebsocketRevalidation } from "~/features/chat/chat-hooks"; +import { useAutoRerender } from "~/hooks/useAutoRerender"; +import { databaseTimestampToDate } from "~/utils/dates"; +import { metaTags } from "~/utils/remix"; +import type { SendouRouteHandle } from "~/utils/remix.server"; +import { navIconUrl, SENDOUQ_READY_PAGE } from "~/utils/urls"; +import { action } from "../actions/q.ready.server"; +import { GroupCard, HiddenGroupCard } from "../components/GroupCard"; +import { GroupLeaver } from "../components/GroupLeaver"; +import { loader } from "../loaders/q.ready.server"; +import { readySchema } from "../q-action-schemas"; +import { sqGroupWebsocketRoom } from "../q-constants"; + +export { action, loader }; + +import styles from "./q.ready.module.css"; + +export const handle: SendouRouteHandle = { + i18n: ["q", "user"], + breadcrumb: () => ({ + imgPath: navIconUrl("sendouq"), + href: SENDOUQ_READY_PAGE, + type: "IMAGE", + }), +}; + +export const meta: MetaFunction = (args) => { + return metaTags({ + title: "SendouQ - Ready Check", + location: args.location, + }); +}; + +export default function QReadyPage() { + const { t } = useTranslation(["q"]); + const user = useUser(); + const data = useLoaderData(); + + useWebsocketRevalidation(sqGroupWebsocketRoom(data.group.id)); + + const ownIsReady = user ? data.readyUserIds.includes(user.id) : false; + + return ( +

+
+

{t("q:ready.header")}

+ +
+ {t("q:ready.explanation")} +
+
+
+ {ownIsReady ? ( +
+ {t("q:ready.waitingForOthers")} +
+ ) : ( + + {t("q:ready.actions.ready")} + + )} + +
+
+ + +
+
+ ); +} + +function Countdown({ expiresAt }: { expiresAt: number }) { + const now = useAutoRerender("second"); + const revalidator = useRevalidator(); + + const secondsLeft = Math.max( + 0, + differenceInSeconds(databaseTimestampToDate(expiresAt), now), + ); + + const isOver = secondsLeft === 0; + React.useEffect(() => { + // the ready check is resolved when someone asks for its state, so being + // the one who ran out of time we ask + if (isOver && revalidator.state === "idle") { + revalidator.revalidate(); + } + }, [isOver, revalidator]); + + return ( +
+ {Math.floor(secondsLeft / 60)}:{String(secondsLeft % 60).padStart(2, "0")} +
+ ); +} diff --git a/app/features/settings/components/SoundsTab.tsx b/app/features/settings/components/SoundsTab.tsx index cf5d9d175..dfb00be12 100644 --- a/app/features/settings/components/SoundsTab.tsx +++ b/app/features/settings/components/SoundsTab.tsx @@ -26,6 +26,7 @@ function SoundCheckboxes() { const sounds = [ { code: "sq_like", name: t("settings:sounds.likeReceived") }, { code: "sq_new-group", name: t("settings:sounds.groupNewMember") }, + { code: "sq_ready-check", name: t("settings:sounds.readyCheckStarted") }, { code: "sq_match", name: t("settings:sounds.matchStarted") }, { code: "tournament_match", diff --git a/app/routes.ts b/app/routes.ts index 2b138d7ae..cf0d4af58 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -274,6 +274,7 @@ export default [ route("info", "features/sendouq/routes/q.info.tsx"), route("looking", "features/sendouq/routes/q.looking.tsx"), route("preparing", "features/sendouq/routes/q.preparing.tsx"), + route("ready", "features/sendouq/routes/q.ready.tsx"), route("match/:id", "features/sendouq-match/routes/q.match.$id.tsx"), route("settings", "features/match-profile/routes/q.settings.tsx"), route("streams", "features/sendouq-streams/routes/q.streams.tsx"), diff --git a/app/routines/expireReadyChecks.ts b/app/routines/expireReadyChecks.ts new file mode 100644 index 000000000..4176e3fb1 --- /dev/null +++ b/app/routines/expireReadyChecks.ts @@ -0,0 +1,22 @@ +import { subMinutes } from "date-fns"; +import * as ReadyCheck from "~/features/sendouq/core/ready-check.server"; +import { SENDOUQ } from "~/features/sendouq/q-constants"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import { logger } from "../utils/logger"; +import { Routine } from "./routine.server"; + +/** Backstop for ready checks nobody was around to run out the clock on in the browser. */ +export const ExpireReadyChecksRoutine = new Routine({ + name: "ExpireReadyChecks", + func: async () => { + const readyChecks = await SQGroupRepository.findAllReadyChecksStartedBefore( + subMinutes(new Date(), SENDOUQ.READY_CHECK_MINUTES), + ); + + for (const readyCheck of readyChecks) { + await ReadyCheck.expire(readyCheck); + } + + logger.info(`Expired ${readyChecks.length} ready check(s)`); + }, +}); diff --git a/app/routines/list.server.ts b/app/routines/list.server.ts index a3d07183c..4f4b52681 100644 --- a/app/routines/list.server.ts +++ b/app/routines/list.server.ts @@ -8,6 +8,7 @@ import { DeleteOldPendingFriendRequestsRoutine } from "./deleteOldPendingFriendR import { DeleteOldTournamentAuditLogsRoutine } from "./deleteOldTournamentAuditLogs"; import { DeleteOrphanArtTagsRoutine } from "./deleteOrphanArtTags"; import { EvictStaleRunningTournamentsRoutine } from "./evictStaleRunningTournaments"; +import { ExpireReadyChecksRoutine } from "./expireReadyChecks"; import { NotifyCheckInStartRoutine } from "./notifyCheckInStart"; import { NotifyPlusServerVotingRoutine } from "./notifyPlusServerVoting"; import { NotifyScrimStartingSoonRoutine } from "./notifyScrimStartingSoon"; @@ -53,4 +54,7 @@ export const daily = [ ]; /** List of Routines that should occur every 2 minutes */ -export const everyTwoMinutes = [SyncLiveStreamsRoutine]; +export const everyTwoMinutes = [ + SyncLiveStreamsRoutine, + ExpireReadyChecksRoutine, +]; diff --git a/app/utils/urls.ts b/app/utils/urls.ts index eaff31f90..0c509131f 100644 --- a/app/utils/urls.ts +++ b/app/utils/urls.ts @@ -176,6 +176,7 @@ export const SENDOUQ_INFO_PAGE = "/q/info"; export const MATCH_PROFILE_PAGE = "/settings?tab=match-profile"; export const SENDOUQ_PREPARING_PAGE = "/q/preparing"; export const SENDOUQ_LOOKING_PAGE = "/q/looking"; +export const SENDOUQ_READY_PAGE = "/q/ready"; export const SENDOUQ_LOOKING_PREVIEW_PAGE = "/q/looking?preview=true"; export const SENDOUQ_STREAMS_PAGE = "/q/streams"; export const TIERS_PAGE = "/tiers"; diff --git a/e2e/helpers/factories.ts b/e2e/helpers/factories.ts index 2c43e874f..c9b2eed9f 100644 --- a/e2e/helpers/factories.ts +++ b/e2e/helpers/factories.ts @@ -56,6 +56,9 @@ export async function loadFactories(parallelIndex: number) { SkillFactory: await import("~/db/seed/factories/SkillFactory"), SQGroupFactory: await import("~/db/seed/factories/SQGroupFactory"), SQMatchFactory: await import("~/db/seed/factories/SQMatchFactory"), + SQReadyCheckFactory: await import( + "~/db/seed/factories/SQReadyCheckFactory" + ), TeamFactory: await import("~/db/seed/factories/TeamFactory"), TournamentFactory: await import("~/db/seed/factories/TournamentFactory"), TournamentOrganizationFactory: await import( diff --git a/e2e/pages/sendouq/group-card.ts b/e2e/pages/sendouq/group-card.ts index 5f9d455ae..dafd5b4be 100644 --- a/e2e/pages/sendouq/group-card.ts +++ b/e2e/pages/sendouq/group-card.ts @@ -1,5 +1,5 @@ import type { Locator } from "@playwright/test"; -import { submit } from "../../helpers/playwright"; +import { modalClickConfirmButton, submit } from "../../helpers/playwright"; import { UserCard } from "../user/user-card"; export class GroupCard { @@ -9,6 +9,8 @@ export class GroupCard { readonly suggestButton: Locator; /** Note of who in the own group invited or suggested this group. */ readonly trail: Locator; + /** One per member who missed a ready check, and can thus be kicked. */ + readonly kickButtons: Locator; constructor(root: Locator) { this.root = root; @@ -16,6 +18,7 @@ export class GroupCard { this.actionButton = root.getByTestId("group-card-action-button"); this.suggestButton = root.getByTestId("group-card-suggest-button"); this.trail = root.getByTestId("group-card-trail"); + this.kickButtons = root.getByTestId("group-card-kick-button"); } /** Challenges or invites the group, accepts what it offered, or undoes either. */ @@ -27,6 +30,12 @@ export class GroupCard { return submit(this.root.page(), this.suggestButton); } + /** Kicks the first member the card offers a kick button for, confirming the dialog. */ + async pressKick() { + await this.kickButtons.first().click(); + await modalClickConfirmButton(this.root.page()); + } + openMemberCard(name: string) { return UserCard.open( this.root.page(), diff --git a/e2e/pages/sendouq/sendouq-ready-page.ts b/e2e/pages/sendouq/sendouq-ready-page.ts new file mode 100644 index 000000000..7192a5f5e --- /dev/null +++ b/e2e/pages/sendouq/sendouq-ready-page.ts @@ -0,0 +1,34 @@ +import type { Page } from "@playwright/test"; +import { SENDOUQ_READY_PAGE } from "~/utils/urls"; +import { navigate, submit } from "../../helpers/playwright"; +import { GroupCard } from "./group-card"; + +export class SendouQReadyPage { + private readonly page: Page; + readonly locators; + /** The own group's card, the only one showing who its members are. */ + readonly groupCard: GroupCard; + + constructor(page: Page) { + this.page = page; + this.locators = { + countdown: page.getByTestId("ready-check-countdown"), + readyButton: page.getByRole("button", { name: "Ready to play" }), + confirmedText: page.getByTestId("ready-confirmed"), + hiddenGroupCard: page.getByTestId("sendouq-hidden-group-card"), + membersReady: page.getByTestId("member-ready"), + membersNotReady: page.getByTestId("member-not-ready"), + }; + this.groupCard = new GroupCard( + page.getByTestId("sendouq-group-card").first(), + ); + } + + async goto() { + await navigate({ page: this.page, url: SENDOUQ_READY_PAGE }); + } + + confirmReady() { + return submit(this.page, this.locators.readyButton); + } +} diff --git a/e2e/sendouq.spec.ts b/e2e/sendouq.spec.ts index 1555a063b..7b88d8f8b 100644 --- a/e2e/sendouq.spec.ts +++ b/e2e/sendouq.spec.ts @@ -1,13 +1,21 @@ import { sub } from "date-fns"; -import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; +import { FULL_GROUP_SIZE, SENDOUQ } from "~/features/sendouq/q-constants"; import { SENDOUQ_LOOKING_PAGE, SENDOUQ_PAGE, SENDOUQ_PREPARING_PAGE, + SENDOUQ_READY_PAGE, } from "~/utils/urls"; -import { expect, impersonate, isNotVisible, test } from "./helpers/playwright"; +import { + expect, + impersonate, + isNotVisible, + runRoutine, + test, +} from "./helpers/playwright"; import { SendouQLookingPage } from "./pages/sendouq/sendouq-looking-page"; import { SendouQPage } from "./pages/sendouq/sendouq-page"; +import { SendouQReadyPage } from "./pages/sendouq/sendouq-ready-page"; import { MatchProfilePage } from "./pages/settings/match-profile-page"; test.describe("SendouQ", () => { @@ -184,6 +192,108 @@ test.describe("SendouQ", () => { await isNotVisible(looking.locators.suggestButtons); }); + test("Ready check flow - both groups confirm and the match starts", async ({ + page, + factories, + }) => { + const challengers = await factories.UserFactory.createMany(FULL_GROUP_SIZE); + const accepters = await factories.UserFactory.createMany(FULL_GROUP_SIZE); + const challengerGroup = await factories.SQGroupFactory.create({ + memberUserIds: challengers.map((member) => member.id), + }); + await factories.SQGroupFactory.create( + { memberUserIds: accepters.map((member) => member.id) }, + { likedByGroupIds: [challengerGroup.id] }, + ); + + await impersonate(page, accepters[0].id); + + const looking = new SendouQLookingPage(page); + await looking.goto(); + await looking.pressGroupAction(); + + // accepting the challenge starts the ready check instead of the match + await expect(page).toHaveURL(SENDOUQ_READY_PAGE); + + const ready = new SendouQReadyPage(page); + await expect(ready.locators.countdown).toBeVisible(); + // who they will be playing is not revealed yet + await expect(ready.locators.hiddenGroupCard).toBeVisible(); + // accepting counted as being ready, so it is 1 of the 8 + await expect(ready.locators.membersReady).toHaveCount(1); + await expect(ready.locators.confirmedText).toBeVisible(); + + const restOfTheQueue = [...accepters.slice(1), ...challengers]; + for (const member of restOfTheQueue.slice(0, -1)) { + await impersonate(page, member.id); + await ready.goto(); + await ready.confirmReady(); + + await expect(page).toHaveURL(SENDOUQ_READY_PAGE); + await expect(ready.locators.confirmedText).toBeVisible(); + } + + // the last one to confirm gets everyone into the match + await impersonate(page, restOfTheQueue.at(-1)!.id); + await ready.goto(); + await ready.confirmReady(); + + await expect(page).toHaveURL(/\/q\/match\/\d+/); + }); + + test("Ready check expiring sends the groups back to looking and lets them kick who missed it", async ({ + page, + factories, + }) => { + const ownMembers = await factories.UserFactory.createMany(FULL_GROUP_SIZE, { + profile: null, + }); + const theirMembers = await factories.UserFactory.createMany( + FULL_GROUP_SIZE, + { profile: null }, + ); + const ownGroup = await factories.SQGroupFactory.create({ + memberUserIds: ownMembers.map((member) => member.id), + }); + const theirGroup = await factories.SQGroupFactory.create({ + memberUserIds: theirMembers.map((member) => member.id), + }); + + // everyone but the last member of the own group confirms + const readyCheck = await factories.SQReadyCheckFactory.create( + { + alphaGroupId: ownGroup.id, + bravoGroupId: theirGroup.id, + confirmedByUserId: ownMembers[0].id, + }, + { + confirmedByUserIds: [ + ...ownMembers.slice(1, -1).map((member) => member.id), + ...theirMembers.map((member) => member.id), + ], + }, + ); + await factories.backdate("GroupReadyCheck", readyCheck.id, { + createdAt: sub(new Date(), { minutes: SENDOUQ.READY_CHECK_MINUTES + 1 }), + }); + + await runRoutine(page, "ExpireReadyChecks"); + + await impersonate(page, ownMembers[0].id); + + const looking = new SendouQLookingPage(page); + await looking.goto(); + + // the group is looking again and the one who never confirmed can be kicked + await expect(looking.ownGroupCard.members).toHaveCount(FULL_GROUP_SIZE); + await expect(looking.ownGroupCard.kickButtons).toHaveCount(1); + + await looking.ownGroupCard.pressKick(); + + await expect(looking.ownGroupCard.members).toHaveCount(FULL_GROUP_SIZE - 1); + await expect(looking.ownGroupCard.kickButtons).toHaveCount(0); + }); + test("Joining the queue is blocked when the season's initial powers were never seeded", async ({ page, factories, diff --git a/locales/da/common.json b/locales/da/common.json index acdcd693e..f657eaaf8 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "", "notifications.title.SQ_ADDED_TO_GROUP": "", "notifications.text.SQ_ADDED_TO_GROUP": "", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "", "notifications.text.SQ_NEW_MATCH": "", "notifications.title.TO_ADDED_TO_TEAM": "", diff --git a/locales/da/q.json b/locales/da/q.json index e6b7f8007..85a07f026 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", "looking.groups.actions.removeManager": "", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "", "looking.groups.addNote": "", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "", "looking.teamSP": "", "looking.teamSP.calculated": "", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "", "tiers.info.p1": "", "tiers.info.p2": "", diff --git a/locales/da/settings.json b/locales/da/settings.json index 6f53df715..f8ced7ae6 100644 --- a/locales/da/settings.json +++ b/locales/da/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/de/common.json b/locales/de/common.json index 804686334..0b71ae248 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "", "notifications.title.SQ_ADDED_TO_GROUP": "", "notifications.text.SQ_ADDED_TO_GROUP": "", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "", "notifications.text.SQ_NEW_MATCH": "", "notifications.title.TO_ADDED_TO_TEAM": "", diff --git a/locales/de/q.json b/locales/de/q.json index c77cc0087..9a8713b34 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", "looking.groups.actions.removeManager": "", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "", "looking.groups.addNote": "", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "", "looking.teamSP": "", "looking.teamSP.calculated": "", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "", "tiers.info.p1": "", "tiers.info.p2": "", diff --git a/locales/de/settings.json b/locales/de/settings.json index 6f53df715..f8ced7ae6 100644 --- a/locales/de/settings.json +++ b/locales/de/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/en/common.json b/locales/en/common.json index f48789227..2eccb2eea 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "Push notifications", "notifications.title.SQ_ADDED_TO_GROUP": "Added to SendouQ Group", "notifications.text.SQ_ADDED_TO_GROUP": "Added to a group by {{adderUsername}}", + "notifications.title.SQ_READY_CHECK": "Ready Check", + "notifications.text.SQ_READY_CHECK": "Confirm you are ready to play", "notifications.title.SQ_NEW_MATCH": "New SendouQ Match", "notifications.text.SQ_NEW_MATCH": "SendouQ match #{{matchId}} started", "notifications.title.TO_ADDED_TO_TEAM": "Added to Team", diff --git a/locales/en/q.json b/locales/en/q.json index 65b6de1e7..0d94aab00 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "Undo", "looking.groups.actions.giveManager": "Give manager", "looking.groups.actions.removeManager": "Remove manager", + "looking.groups.actions.kick": "Kick", + "looking.groups.actions.kick.confirm": "Kick {{name}} from the group?", "looking.groups.actions.leaveGroup": "Leave group", "looking.groups.actions.leaveGroup.confirm": "Leave this group?", "looking.groups.actions.stopLooking": "Stop looking", @@ -85,6 +87,7 @@ "looking.groups.editNote": "Edit note", "looking.groups.addNote": "Add note", "looking.groups.stayAsSub": "Sub", + "looking.groups.missedReadyCheck": "Didn't ready up", "looking.replay": "Replay", "looking.teamSP": "Team SP", "looking.teamSP.calculated": "Team SP calculated", @@ -203,6 +206,12 @@ "match.tabs.result": "Result", "match.tabs.stats": "Stats", "preparing.joinQ": "Join the queue", + "ready.header": "Ready to play?", + "ready.explanation": "A group was found. The match starts once everyone from both groups has confirmed. If the time runs out, both groups go back to looking.", + "ready.actions.ready": "Ready to play", + "ready.waitingForOthers": "Waiting for the others to confirm...", + "ready.member.ready": "Ready", + "ready.member.waiting": "Not ready yet", "tiers.currentCriteria": "Current criteria", "tiers.info.p1": "For example, Leviathan is the top 5% of players. Diamond is the 85th percentile etc.", "tiers.info.p2": "Note: Nobody has Leviathan rank before there are at least {{usersMin}} players on the leaderboard (or {{teamsMin}} for teams)", diff --git a/locales/en/settings.json b/locales/en/settings.json index 9b28e0a1a..fb5e353e2 100644 --- a/locales/en/settings.json +++ b/locales/en/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "Pick {{count}} stages per mode that you didn't avoid to save your preferences", "sounds.likeReceived": "Group invitation received", "sounds.groupNewMember": "Group invitation accepted", + "sounds.readyCheckStarted": "SendouQ ready check started", "sounds.matchStarted": "SendouQ match started", "sounds.tournamentMatchStarted": "Tournament match started" } diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index db0728568..fb976e04c 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "Notificaciones push", "notifications.title.SQ_ADDED_TO_GROUP": "Añadido a Grupo SendouQ", "notifications.text.SQ_ADDED_TO_GROUP": "Añadido a un grupo por {{adderUsername}}", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "Nueva Partida SendouQ", "notifications.text.SQ_NEW_MATCH": "Ha comenzado la partida de SendouQ #{{matchId}}", "notifications.title.TO_ADDED_TO_TEAM": "Añadido a un Equipo", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index 23a5e1055..c02017b4a 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "Deshacer", "looking.groups.actions.giveManager": "Hacer mánager", "looking.groups.actions.removeManager": "Quitar mánager", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "Dejar grupo", "looking.groups.actions.leaveGroup.confirm": "¿Abandonar este grupo?", "looking.groups.actions.stopLooking": "Dejar de buscar", @@ -85,6 +87,7 @@ "looking.groups.editNote": "Editar nota", "looking.groups.addNote": "Añadir nota", "looking.groups.stayAsSub": "Sub", + "looking.groups.missedReadyCheck": "", "looking.replay": "Repetir", "looking.teamSP": "Fuerza Sendou de Equipo", "looking.teamSP.calculated": "Fuerza Sendou de Equipo calculada", @@ -203,6 +206,12 @@ "match.tabs.result": "Resultado", "match.tabs.stats": "Estadísticas", "preparing.joinQ": "Unirte a la fila", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "Criterios actuales", "tiers.info.p1": "Por ejemplo, Leviathan se encuentra entre el 5% de los mejores jugadores. Diamond es el percentil 85, etc.", "tiers.info.p2": "NOTA: Nadie tiene rango Leviathan antes de tener al menos {{usersMin}} jugadores en las tablas (o al menos {{teamsMin}} para equipos)", diff --git a/locales/es-ES/settings.json b/locales/es-ES/settings.json index 05f51fd12..e357987e9 100644 --- a/locales/es-ES/settings.json +++ b/locales/es-ES/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "Elige {{count}} mapas por modo que no evitaste para guardar tus preferencias", "sounds.likeReceived": "Invitación de grupo recibida", "sounds.groupNewMember": "Invitación de grupo aceptada", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "Partida de SendouQ iniciada", "sounds.tournamentMatchStarted": "Set de torneo iniciado" } diff --git a/locales/es-US/common.json b/locales/es-US/common.json index d7e3ad1ac..7be4ebffd 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "", "notifications.title.SQ_ADDED_TO_GROUP": "", "notifications.text.SQ_ADDED_TO_GROUP": "", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "", "notifications.text.SQ_NEW_MATCH": "", "notifications.title.TO_ADDED_TO_TEAM": "", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index e4356fbee..83243ea58 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "Deshacer", "looking.groups.actions.giveManager": "Hacer mánager", "looking.groups.actions.removeManager": "Quitar mánager", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "Dejar grupo", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "Editar nota", "looking.groups.addNote": "Añadir nota", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "Repetir", "looking.teamSP": "Fuerza Sendou de Equipo", "looking.teamSP.calculated": "Fuerza Sendou de Equipo calculada", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "Unirte a la fila", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "Criterios actuales", "tiers.info.p1": "Por ejemplo, Leviathan se encuentra entre el 5% de los mejores jugadores. Diamond es el percentil 85, etc.", "tiers.info.p2": "NOTA: Nadie tiene rango Leviathan antes de tener al menos {{usersMin}} jugadores en las tablas (o al menos {{teamsMin}} para equipos)", diff --git a/locales/es-US/settings.json b/locales/es-US/settings.json index 503f7c12c..cd875f7f5 100644 --- a/locales/es-US/settings.json +++ b/locales/es-US/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "Elige {{count}} escenarios por estilo que no evitaste para guardar tus preferencias", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 1643a94dd..8d798a448 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "", "notifications.title.SQ_ADDED_TO_GROUP": "", "notifications.text.SQ_ADDED_TO_GROUP": "", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "", "notifications.text.SQ_NEW_MATCH": "", "notifications.title.TO_ADDED_TO_TEAM": "", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index 4f74ca379..b8a5a2413 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", "looking.groups.actions.removeManager": "", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "", "looking.groups.addNote": "", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "", "looking.teamSP": "", "looking.teamSP.calculated": "", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "", "tiers.info.p1": "", "tiers.info.p2": "", diff --git a/locales/fr-CA/settings.json b/locales/fr-CA/settings.json index 6f53df715..f8ced7ae6 100644 --- a/locales/fr-CA/settings.json +++ b/locales/fr-CA/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index 2a974fb35..c7b04ce74 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "Notifications push", "notifications.title.SQ_ADDED_TO_GROUP": "Ajouter au groupe", "notifications.text.SQ_ADDED_TO_GROUP": "Ajouter au groupe par {{adderUsername}}", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "Nouveau match SendouQ", "notifications.text.SQ_NEW_MATCH": "Le match SendouQ #{{matchId}} a commencé", "notifications.title.TO_ADDED_TO_TEAM": "Ajouter à l'équipe", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index 965bb1b26..877a560e7 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "Annulé", "looking.groups.actions.giveManager": "Promouvoir", "looking.groups.actions.removeManager": "Rétrograder", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "Quitter le groupe", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "Modifier le note", "looking.groups.addNote": "Ajouter la note", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "Rejouer", "looking.teamSP": "Team SP", "looking.teamSP.calculated": "Team SP calculé", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "Rejoindre la queue", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "Critères actuels", "tiers.info.p1": "Par exemple, Les Léviathans font partie des 5 % des meilleurs joueurs. Le diamant est le top 15%, etc.", "tiers.info.p2": "Note: personne n'a le rang Léviathan avant qu'il n'y ait au moins {{usersMin}} joueurs dans le classement (ou {{teamsMin}} pour les équipes)", diff --git a/locales/fr-EU/settings.json b/locales/fr-EU/settings.json index d91b7832a..b7b84a4fc 100644 --- a/locales/fr-EU/settings.json +++ b/locales/fr-EU/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "Sélectionner {{count}} stages par mode que vous n'avez pas évité pour enregistrer vos préférences", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/he/common.json b/locales/he/common.json index 2568baecd..ee88563de 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "", "notifications.title.SQ_ADDED_TO_GROUP": "", "notifications.text.SQ_ADDED_TO_GROUP": "", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "", "notifications.text.SQ_NEW_MATCH": "", "notifications.title.TO_ADDED_TO_TEAM": "", diff --git a/locales/he/q.json b/locales/he/q.json index d3ea75630..1629db379 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", "looking.groups.actions.removeManager": "", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "", "looking.groups.addNote": "", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "", "looking.teamSP": "", "looking.teamSP.calculated": "", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "", "tiers.info.p1": "", "tiers.info.p2": "", diff --git a/locales/he/settings.json b/locales/he/settings.json index 6f53df715..f8ced7ae6 100644 --- a/locales/he/settings.json +++ b/locales/he/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/it/common.json b/locales/it/common.json index 875b8af3c..d85f28b7f 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "Notifiche push", "notifications.title.SQ_ADDED_TO_GROUP": "Aggiunto a un gruppo SendouQ", "notifications.text.SQ_ADDED_TO_GROUP": "Aggiunto a un gruppo da {{adderUsername}}", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "Nuovo match SendouQ", "notifications.text.SQ_NEW_MATCH": "Match SendouQ #{{matchId}} iniziato", "notifications.title.TO_ADDED_TO_TEAM": "Aggiunto a un Team", diff --git a/locales/it/q.json b/locales/it/q.json index 0c5e64ad4..1ab669121 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "Annulla", "looking.groups.actions.giveManager": "Dai manager", "looking.groups.actions.removeManager": "Rimuovi manager", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "Lascia gruppo", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "Modifica nota", "looking.groups.addNote": "Aggiungi nota", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "Replay", "looking.teamSP": "SP del team", "looking.teamSP.calculated": "SP del team calcolato", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "Unisciti alla coda", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "Criterio corrente", "tiers.info.p1": "Per esempio Leviathan è la top 5% dei giocatori. Diamante è l' 85esimo percentile etc.", "tiers.info.p2": "Nota bene: Nessuno ha rango Leviathan prima che ci siano {{usersMin}} giocatori sulla classifica (o {{teamsMin}} per i team)", diff --git a/locales/it/settings.json b/locales/it/settings.json index 5ac4ab1ef..999724f26 100644 --- a/locales/it/settings.json +++ b/locales/it/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "Scegli {{count}} mappe per modalità che non hai evitato per salvare le tue preferenze", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/ja/common.json b/locales/ja/common.json index 1790b88d6..fb7bf943e 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "通知", "notifications.title.SQ_ADDED_TO_GROUP": "SendouQグループに参加しました", "notifications.text.SQ_ADDED_TO_GROUP": "{{adderUsername}}がグループにあなたを追加しました", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "新SendouQマッチ", "notifications.text.SQ_NEW_MATCH": "SendouQマッチ#{{matchId}}が開始しました", "notifications.title.TO_ADDED_TO_TEAM": "チームに参加しました", diff --git a/locales/ja/q.json b/locales/ja/q.json index 5e331e172..7b9596e7a 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "戻す", "looking.groups.actions.giveManager": "マネージャーにあげる", "looking.groups.actions.removeManager": "マネージャーを外す", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "グループを出る", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "メモを編集する", "looking.groups.addNote": "メモを追加する", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "リプレイ", "looking.teamSP": "チーム SP", "looking.teamSP.calculated": "チーム SP を計算しました。", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "列に入る", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "現在の基準", "tiers.info.p1": "例として、Leviathanはプレイヤーの上位5%、Diamondは上位15%", "tiers.info.p2": "注:{{usersMin}}のプレイヤーはLeviathanのランクを持っていません。(チームの場合{{teamsMin}})", diff --git a/locales/ja/settings.json b/locales/ja/settings.json index 2150e7e8f..01e5a2de3 100644 --- a/locales/ja/settings.json +++ b/locales/ja/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "モードにつきステージを {{count}} 個選んでください。(避けるステージに入っていない)", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/ko/common.json b/locales/ko/common.json index 5c4c677c8..469a3a21b 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "", "notifications.title.SQ_ADDED_TO_GROUP": "", "notifications.text.SQ_ADDED_TO_GROUP": "", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "", "notifications.text.SQ_NEW_MATCH": "", "notifications.title.TO_ADDED_TO_TEAM": "", diff --git a/locales/ko/q.json b/locales/ko/q.json index c77cc0087..9a8713b34 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", "looking.groups.actions.removeManager": "", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "", "looking.groups.addNote": "", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "", "looking.teamSP": "", "looking.teamSP.calculated": "", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "", "tiers.info.p1": "", "tiers.info.p2": "", diff --git a/locales/ko/settings.json b/locales/ko/settings.json index 6f53df715..f8ced7ae6 100644 --- a/locales/ko/settings.json +++ b/locales/ko/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/nl/common.json b/locales/nl/common.json index 18a312c5c..8531789d6 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "", "notifications.title.SQ_ADDED_TO_GROUP": "", "notifications.text.SQ_ADDED_TO_GROUP": "", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "", "notifications.text.SQ_NEW_MATCH": "", "notifications.title.TO_ADDED_TO_TEAM": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index c77cc0087..9a8713b34 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", "looking.groups.actions.removeManager": "", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "", "looking.groups.addNote": "", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "", "looking.teamSP": "", "looking.teamSP.calculated": "", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "", "tiers.info.p1": "", "tiers.info.p2": "", diff --git a/locales/nl/settings.json b/locales/nl/settings.json index 6f53df715..f8ced7ae6 100644 --- a/locales/nl/settings.json +++ b/locales/nl/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/pl/common.json b/locales/pl/common.json index 8908a59a4..52115bc5e 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "", "notifications.title.SQ_ADDED_TO_GROUP": "", "notifications.text.SQ_ADDED_TO_GROUP": "", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "", "notifications.text.SQ_NEW_MATCH": "", "notifications.title.TO_ADDED_TO_TEAM": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index c77cc0087..9a8713b34 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "", "looking.groups.actions.giveManager": "", "looking.groups.actions.removeManager": "", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "", "looking.groups.addNote": "", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "", "looking.teamSP": "", "looking.teamSP.calculated": "", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "", "tiers.info.p1": "", "tiers.info.p2": "", diff --git a/locales/pl/settings.json b/locales/pl/settings.json index 6f53df715..f8ced7ae6 100644 --- a/locales/pl/settings.json +++ b/locales/pl/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index f729a7418..1fc26caee 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "", "notifications.title.SQ_ADDED_TO_GROUP": "", "notifications.text.SQ_ADDED_TO_GROUP": "", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "", "notifications.text.SQ_NEW_MATCH": "", "notifications.title.TO_ADDED_TO_TEAM": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index 6f4f26317..02fd76cc0 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "Desfazer", "looking.groups.actions.giveManager": "Dar gerência", "looking.groups.actions.removeManager": "Remover gerência", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "Sair do grupo", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "Editar nota", "looking.groups.addNote": "Adicionar nota", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "Replay", "looking.teamSP": "SP do time", "looking.teamSP.calculated": "SP do time calculado", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "Entrar na fila", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "Critérios atuais", "tiers.info.p1": "Por exemplo, Leviathan é o top 5% dos jogadores. Diamond é top 15% e etc.", "tiers.info.p2": "Nota: Ninguém terá rank Leviathan antes de ter pelo menos {{usersMin}}jogadores nas classificações (ou {{teamsMin}} para times)", diff --git a/locales/pt-BR/settings.json b/locales/pt-BR/settings.json index b099c361a..203a49d34 100644 --- a/locales/pt-BR/settings.json +++ b/locales/pt-BR/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "Escolha {{count}} mapas por modo que você não evitou para salvar suas preferências", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/ru/common.json b/locales/ru/common.json index 5184f7c20..ff5100391 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "Push-уведомления", "notifications.title.SQ_ADDED_TO_GROUP": "Добавление в Группу SendouQ", "notifications.text.SQ_ADDED_TO_GROUP": "Добавление в группу {{adderUsername}}", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "Новый Матч SendouQ", "notifications.text.SQ_NEW_MATCH": "Матч SendouQ #{{matchId}} начался", "notifications.title.TO_ADDED_TO_TEAM": "Добавление в Команду", diff --git a/locales/ru/q.json b/locales/ru/q.json index 5ced74daa..7559a8284 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "Отменить", "looking.groups.actions.giveManager": "Дать роль менеджера", "looking.groups.actions.removeManager": "Удалить роль менеджера", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "Покинуть группу", "looking.groups.actions.leaveGroup.confirm": "", "looking.groups.actions.stopLooking": "", @@ -85,6 +87,7 @@ "looking.groups.editNote": "Изменить заметку", "looking.groups.addNote": "Добавить заметку", "looking.groups.stayAsSub": "", + "looking.groups.missedReadyCheck": "", "looking.replay": "Повтор", "looking.teamSP": "Командое SP", "looking.teamSP.calculated": "Командое SP рассчитано", @@ -203,6 +206,12 @@ "match.tabs.result": "", "match.tabs.stats": "", "preparing.joinQ": "Присоединиться к очереди", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "Текущие критерии", "tiers.info.p1": "Например, Leviathan - топ 5% игроков, Diamond - 85 процентиль и т.д.", "tiers.info.p2": "Учтите, что ни у кого нет ранга Leviathan пока как минимум {{usersMin}} игроков не появилось на таблице лидеров (или {{teamsMin}} для команд)", diff --git a/locales/ru/settings.json b/locales/ru/settings.json index b276da98a..14e897d61 100644 --- a/locales/ru/settings.json +++ b/locales/ru/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "Выберите по {{count}} арен за каждый режим, который вы не избегаете, чтобы сохранить настройки", "sounds.likeReceived": "", "sounds.groupNewMember": "", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "", "sounds.tournamentMatchStarted": "" } diff --git a/locales/zh/common.json b/locales/zh/common.json index 361b0ca63..af2f3506d 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -58,6 +58,8 @@ "notifications.managePush": "推送通知", "notifications.title.SQ_ADDED_TO_GROUP": "已加入 SendouQ 小组", "notifications.text.SQ_ADDED_TO_GROUP": "{{adderUsername}} 已将您加入小组", + "notifications.title.SQ_READY_CHECK": "", + "notifications.text.SQ_READY_CHECK": "", "notifications.title.SQ_NEW_MATCH": "新的 SendouQ 对局", "notifications.text.SQ_NEW_MATCH": "SendouQ 对局 #{{matchId}} 已开始", "notifications.title.TO_ADDED_TO_TEAM": "已加入队伍", diff --git a/locales/zh/q.json b/locales/zh/q.json index c1a68285e..6a477ed68 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -67,6 +67,8 @@ "looking.groups.actions.undo": "撤销", "looking.groups.actions.giveManager": "授予管理者权限", "looking.groups.actions.removeManager": "移除管理者权限", + "looking.groups.actions.kick": "", + "looking.groups.actions.kick.confirm": "", "looking.groups.actions.leaveGroup": "离开小组", "looking.groups.actions.leaveGroup.confirm": "要离开此小组吗?", "looking.groups.actions.stopLooking": "停止匹配", @@ -85,6 +87,7 @@ "looking.groups.editNote": "编辑备注", "looking.groups.addNote": "添加备注", "looking.groups.stayAsSub": "替补", + "looking.groups.missedReadyCheck": "", "looking.replay": "重赛", "looking.teamSP": "队伍 SP", "looking.teamSP.calculated": "队伍 SP 计算完毕", @@ -203,6 +206,12 @@ "match.tabs.result": "结果", "match.tabs.stats": "统计数据", "preparing.joinQ": "开始匹配", + "ready.header": "", + "ready.explanation": "", + "ready.actions.ready": "", + "ready.waitingForOthers": "", + "ready.member.ready": "", + "ready.member.waiting": "", "tiers.currentCriteria": "当前段位要求", "tiers.info.p1": "例如:Leviathan 代表前 5% 的玩家,Diamond 代表前 15% 的玩家等等。", "tiers.info.p2": "请注意:在玩家排行榜的人数达到至少 {{usersMin}} 人,或是队伍排行榜的队伍数达到 {{teamsMin}} 支之前,任何人均无法达到 Leviathan 段位。", diff --git a/locales/zh/settings.json b/locales/zh/settings.json index ae071134d..78fb528ec 100644 --- a/locales/zh/settings.json +++ b/locales/zh/settings.json @@ -23,6 +23,7 @@ "matchProfile.mapPool.notOk": "为每个模式选择 {{count}} 个您不想避开的场地以设置场地偏好", "sounds.likeReceived": "收到小组邀请", "sounds.groupNewMember": "小组邀请已通过", + "sounds.readyCheckStarted": "", "sounds.matchStarted": "SendouQ 对局开始", "sounds.tournamentMatchStarted": "赛事对局开始" } diff --git a/migrations/20260807143400-sq-ready-check.ts b/migrations/20260807143400-sq-ready-check.ts new file mode 100644 index 000000000..39697b5e9 --- /dev/null +++ b/migrations/20260807143400-sq-ready-check.ts @@ -0,0 +1,58 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx.schema + .createTable("GroupReadyCheck") + .addColumn("id", "integer", (col) => col.primaryKey()) + .addColumn("alphaGroupId", "integer", (col) => + col.notNull().references("Group.id").onDelete("cascade"), + ) + .addColumn("bravoGroupId", "integer", (col) => + col.notNull().references("Group.id").onDelete("cascade"), + ) + .addColumn("createdAt", "integer", (col) => + col.notNull().defaultTo(sql`(strftime('%s', 'now'))`), + ) + // every table in this schema is strict + .modifyEnd(sql`strict`) + .execute(); + + await trx.schema + .createIndex("group_ready_check_alpha_group_id") + .on("GroupReadyCheck") + .column("alphaGroupId") + .unique() + .execute(); + + await trx.schema + .createIndex("group_ready_check_bravo_group_id") + .on("GroupReadyCheck") + .column("bravoGroupId") + .unique() + .execute(); + + await trx.schema + .createTable("GroupReadyCheckConfirmation") + .addColumn("readyCheckId", "integer", (col) => + col.notNull().references("GroupReadyCheck.id").onDelete("cascade"), + ) + .addColumn("userId", "integer", (col) => + col.notNull().references("User.id").onDelete("cascade"), + ) + .addColumn("createdAt", "integer", (col) => + col.notNull().defaultTo(sql`(strftime('%s', 'now'))`), + ) + .addUniqueConstraint( + "group_ready_check_confirmation_ready_check_id_user_id", + ["readyCheckId", "userId"], + ) + .modifyEnd(sql`strict`) + .execute(); + + await trx.schema + .alterTable("GroupMember") + .addColumn("missedReadyCheckAt", "integer") + .execute(); + }); +} diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index a479e1956..188dd44bc 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -589,6 +589,20 @@ export function buildCases(fx: Fixtures): { fx.heavyGroupIds, (groupIds) => SQGroupRepository.findAllSuggestionsByGroupId(groupIds[0]), ); + add( + "SQGroupRepository.findAllMissedReadyCheckUserIdsByGroupId", + fx.heavyGroupIds, + (groupIds) => + SQGroupRepository.findAllMissedReadyCheckUserIdsByGroupId(groupIds[0]), + ); + add( + "SQGroupRepository.findReadyCheckByGroupId", + fx.heavyGroupIds, + (groupIds) => SQGroupRepository.findReadyCheckByGroupId(groupIds[0]), + ); + addStatic("SQGroupRepository.findAllReadyChecksStartedBefore", () => + SQGroupRepository.findAllReadyChecksStartedBefore(new Date()), + ); add("SQGroupRepository.findFriendsAndTeammates", fx.sq, (sq) => SQGroupRepository.findFriendsAndTeammates(sq.userId), );