SendouQ collect ready checks before starting a match

This commit is contained in:
Kalle
2026-08-07 19:03:43 +03:00
parent bea6d2166e
commit 2029129f2d
89 changed files with 2019 additions and 150 deletions

View File

@@ -0,0 +1,26 @@
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import { defineFactory } from "../core/defineFactory";
type InsertArgs = Parameters<typeof SQGroupRepository.insertReadyCheck>[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,
});
}
},
});

View File

@@ -303,7 +303,7 @@ export interface Group {
latestActionAt: Generated<number>;
/** If truthy, group was at least partly made in the matchmaking UI (/q/looking) */
matchmade: Generated<DBBoolean>;
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<number>;
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<number>;
id: GeneratedAlways<number>;
}
/** One member confirming they are ready to play in a {@link GroupReadyCheck} */
export interface GroupReadyCheckConfirmation {
createdAt: Generated<number>;
readyCheckId: number;
userId: number;
}
/** A group member pointing their own group at another group, without inviting it */
export interface GroupSuggestion {
createdAt: Generated<number>;
@@ -1266,6 +1283,8 @@ export interface DB {
GroupMatchContinueVote: GroupMatchContinueVote;
GroupMatchMap: GroupMatchMap;
GroupMember: GroupMember;
GroupReadyCheck: GroupReadyCheck;
GroupReadyCheckConfirmation: GroupReadyCheckConfirmation;
GroupSuggestion: GroupSuggestion;
PrivateUserNote: PrivateUserNote;
LogInLink: LogInLink;

View File

@@ -4,6 +4,7 @@ export type SystemMessageType =
| "NEW_GROUP"
| "USER_LEFT"
| "MATCH_STARTED"
| "READY_CHECK_STARTED"
| "LIKE_RECEIVED"
| "SCORE_REPORTED"
| "SCORE_CONFIRMED"

View File

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

View File

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

View File

@@ -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 ? <div className={styles.unseenDot} /> : null}
</NotificationImage>
<div className={styles.itemHeader}>
{t(`common:notifications.text.${notification.type}`, notification.meta)}
{t(
`common:notifications.text.${notification.type}`,
notificationMeta(notification),
)}
</div>
<div className={styles.timestamp}>
{formatDistance(

View File

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

View File

@@ -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<Notification["type"], Urgency> = {
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) },

View File

@@ -5,6 +5,7 @@ export type Notification =
adderUsername: string;
}
>
| NotificationItem<"SQ_READY_CHECK">
| NotificationItem<
"SQ_NEW_MATCH",
{

View File

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

View File

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

View File

@@ -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<DB>,
) {
const run = (trx: Transaction<DB>) =>
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<DB>) {
.where("id", "=", groupId)
.execute();
}
async function deleteReadyCheckInTrx(
{ id, markMissedMembers }: { id: number; markMissedMembers: boolean },
trx: Transaction<DB>,
) {
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<DB>,
@@ -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<DB, "GroupMember">,
readyCheckId: number,
) {
return eb.not(
eb.exists(
eb
.selectFrom("GroupReadyCheckConfirmation")
.select("GroupReadyCheckConfirmation.userId")
.where("GroupReadyCheckConfirmation.readyCheckId", "=", readyCheckId)
.whereRef(
"GroupReadyCheckConfirmation.userId",
"=",
"GroupMember.userId",
),
),
);
}

View File

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

View File

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

View File

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

View File

@@ -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" ? (
<ReadyIndicator isReady={isReady} />
) : null}
{member.skill ? <TierInfo skill={member.skill} /> : null}
</div>
</div>
<div className="stack horizontal justify-between">
<div className="stack horizontal items-center xxs">
{member.vc && !hideVc ? (
{isKickable ? (
<MemberKicker member={member} />
) : (
<div className="stack horizontal justify-between">
<div className="stack horizontal items-center xxs">
{member.vc && !hideVc ? (
<div className={styles.extraInfo}>
<VoiceChatInfo member={member} />
</div>
) : null}
{member.friendCode ? (
<SendouPopover
trigger={
<SendouButton className={styles.extraInfoButton}>
FC
</SendouButton>
}
>
SW-{member.friendCode}
</SendouPopover>
) : null}
</div>
{member.weapons && member.weapons.length > 0 && !hideWeapons ? (
<div className={styles.extraInfo}>
<VoiceChatInfo member={member} />
{member.weapons?.map((weapon) => {
return (
<WeaponImage
key={weapon.weaponSplId}
weapon={weapon}
size={26}
/>
);
})}
</div>
) : null}
{member.friendCode ? (
<SendouPopover
trigger={
<SendouButton className={styles.extraInfoButton}>
FC
</SendouButton>
}
>
SW-{member.friendCode}
</SendouPopover>
{member.skillDifference ? (
<MemberSkillDifference skillDifference={member.skillDifference} />
) : null}
</div>
{member.weapons && member.weapons.length > 0 && !hideWeapons ? (
<div className={styles.extraInfo}>
{member.weapons?.map((weapon) => {
return (
<WeaponImage
key={weapon.weaponSplId}
weapon={weapon}
size={26}
/>
);
})}
</div>
) : null}
{member.skillDifference ? (
<MemberSkillDifference skillDifference={member.skillDifference} />
) : null}
</div>
)}
{!hideNote ? (
<MemberNote note={member.note} editable={user?.id === member.id} />
) : 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 (
<section className={styles.group} data-testid="sendouq-hidden-group-card">
<div className="stack md">
{nullFilledArray(memberCount).map((_, i) => (
<div className={clsx(styles.member, styles.hiddenMember)} key={i}>
<span className={styles.hiddenMemberName}>???</span>
<div className={clsx("ml-auto", styles.memberActions)}>
<ReadyIndicator isReady={i < readyCount} />
</div>
</div>
))}
</div>
</section>
);
}
function ReadyIndicator({ isReady }: { isReady: boolean }) {
const { t } = useTranslation(["q"]);
const Icon = isReady ? Check : Hourglass;
return (
<Icon
className={clsx(styles.readyIcon, {
[styles.readyIconConfirmed]: isReady,
})}
aria-label={t(
isReady ? "q:ready.member.ready" : "q:ready.member.waiting",
)}
data-testid={isReady ? "member-ready" : "member-not-ready"}
/>
);
}
function MemberKicker({ member }: { member: SQGroupMember }) {
const { t } = useTranslation(["common", "q"]);
return (
<div className="stack horizontal sm items-center justify-between text-xxs text-warning">
{t("q:looking.groups.missedReadyCheck")}
<ActionButton
schema={lookingSchema}
action="KICK_FROM_GROUP"
fields={{ userId: member.id }}
formAction={SENDOUQ_LOOKING_PAGE}
variant="minimal-destructive"
size="miniscule"
testId="group-card-kick-button"
confirm={{
dialogHeading: t("q:looking.groups.actions.kick.confirm", {
name: member.username,
}),
submitButtonText: t("q:looking.groups.actions.kick"),
}}
>
{t("q:looking.groups.actions.kick")}
</ActionButton>
</div>
);
}
function MemberNote({
note,
editable,

View File

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

View File

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

View File

@@ -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<ReturnType<typeof SQGroupRepository.findReadyCheckByGroupId>>
>;
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,
},
]);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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.
</p>
<h3>Ready check</h3>
<p>
Accepting a challenge doesn&apos;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.
</p>
<h3>Rechallenging</h3>
<p>
Sometimes it can take a while for a group to accept your challenge. If

View File

@@ -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<typeof lookingSchema>({
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<ReturnType<typeof prepareGroups>>;
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();

View File

@@ -301,7 +301,11 @@ function Groups() {
<ColumnHeader isMobile={isMobile}>
{t("q:looking.columns.myGroup")}
</ColumnHeader>
<GroupCard group={data.ownGroup} ownGroup={data.ownGroup} />
<GroupCard
group={data.ownGroup}
ownGroup={data.ownGroup}
kickableUserIds={data.kickableUserIds}
/>
{data.ownGroup.inviteCode ? (
<MemberAdder
inviteCode={data.ownGroup.inviteCode}

View File

@@ -0,0 +1,27 @@
.header {
font-size: var(--font-lg);
color: var(--color-text);
}
.countdown {
font-size: var(--font-xl);
font-weight: var(--weight-bold);
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.countdownUrgent {
color: var(--color-error);
}
.groupsContainer {
display: grid;
grid-template-columns: 1fr;
gap: var(--s-4);
width: 100%;
max-width: 600px;
@container (min-width: 500px) {
grid-template-columns: 1fr 1fr;
}
}

View File

@@ -0,0 +1,126 @@
import { subMinutes } from "date-fns";
import { 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 * 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<Awaited<ReturnType<typeof rawReadyLoader>>>({
loader: rawReadyLoader,
});
const readyAction = wrappedAction<typeof readySchema>({
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));
});
});

View File

@@ -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<typeof loader>();
useWebsocketRevalidation(sqGroupWebsocketRoom(data.group.id));
const ownIsReady = user ? data.readyUserIds.includes(user.id) : false;
return (
<Main className="stack lg items-center">
<div className="stack sm items-center">
<h2 className={styles.header}>{t("q:ready.header")}</h2>
<Countdown expiresAt={data.expiresAt} />
<div className="text-xs text-lighter text-center">
{t("q:ready.explanation")}
</div>
</div>
<div className="stack md items-center">
{ownIsReady ? (
<div className="text-sm" data-testid="ready-confirmed">
{t("q:ready.waitingForOthers")}
</div>
) : (
<ActionButton schema={readySchema} action="CONFIRM_READY" size="big">
{t("q:ready.actions.ready")}
</ActionButton>
)}
<GroupLeaver type="LEAVE_GROUP" />
</div>
<div className={styles.groupsContainer}>
<GroupCard
group={data.group}
ownGroup={data.group}
hideNote
readyUserIds={data.readyUserIds}
/>
<HiddenGroupCard
memberCount={data.theirGroup.memberCount}
readyCount={data.theirGroup.readyCount}
/>
</div>
</Main>
);
}
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 (
<div
className={clsx(styles.countdown, {
[styles.countdownUrgent]: secondsLeft <= 60,
})}
data-testid="ready-check-countdown"
>
{Math.floor(secondsLeft / 60)}:{String(secondsLeft % 60).padStart(2, "0")}
</div>
);
}

View File

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

View File

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

View File

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

View File

@@ -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,
];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,6 +23,7 @@
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

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

View File

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

View File

@@ -23,6 +23,7 @@
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": ""
}

View File

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

View File

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

View File

@@ -23,6 +23,7 @@
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

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

View File

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

View File

@@ -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": ""
}

View File

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

View File

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

View File

@@ -23,6 +23,7 @@
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

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

View File

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

View File

@@ -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": ""
}

View File

@@ -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": "チームに参加しました",

View File

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

View File

@@ -23,6 +23,7 @@
"matchProfile.mapPool.notOk": "モードにつきステージを {{count}} 個選んでください。(避けるステージに入っていない)",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

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

View File

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

View File

@@ -23,6 +23,7 @@
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

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

View File

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

View File

@@ -23,6 +23,7 @@
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

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

View File

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

View File

@@ -23,6 +23,7 @@
"matchProfile.mapPool.notOk": "",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

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

View File

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

View File

@@ -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": ""
}

View File

@@ -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": "Добавление в Команду",

View File

@@ -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}} для команд)",

View File

@@ -23,6 +23,7 @@
"matchProfile.mapPool.notOk": "Выберите по {{count}} арен за каждый режим, который вы не избегаете, чтобы сохранить настройки",
"sounds.likeReceived": "",
"sounds.groupNewMember": "",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "",
"sounds.tournamentMatchStarted": ""
}

View File

@@ -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": "已加入队伍",

View File

@@ -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 段位。",

View File

@@ -23,6 +23,7 @@
"matchProfile.mapPool.notOk": "为每个模式选择 {{count}} 个您不想避开的场地以设置场地偏好",
"sounds.likeReceived": "收到小组邀请",
"sounds.groupNewMember": "小组邀请已通过",
"sounds.readyCheckStarted": "",
"sounds.matchStarted": "SendouQ 对局开始",
"sounds.tournamentMatchStarted": "赛事对局开始"
}

View File

@@ -0,0 +1,58 @@
import { type Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
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();
});
}

View File

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