mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-15 07:36:27 -05:00
SendouQ collect ready checks before starting a match
This commit is contained in:
26
app/db/seed/factories/SQReadyCheckFactory.ts
Normal file
26
app/db/seed/factories/SQReadyCheckFactory.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -4,6 +4,7 @@ export type SystemMessageType =
|
||||
| "NEW_GROUP"
|
||||
| "USER_LEFT"
|
||||
| "MATCH_STARTED"
|
||||
| "READY_CHECK_STARTED"
|
||||
| "LIKE_RECEIVED"
|
||||
| "SCORE_REPORTED"
|
||||
| "SCORE_CONFIRMED"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -5,6 +5,7 @@ export type Notification =
|
||||
adderUsername: string;
|
||||
}
|
||||
>
|
||||
| NotificationItem<"SQ_READY_CHECK">
|
||||
| NotificationItem<
|
||||
"SQ_NEW_MATCH",
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
61
app/features/sendouq/actions/q.ready.server.ts
Normal file
61
app/features/sendouq/actions/q.ready.server.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
305
app/features/sendouq/core/ready-check.server.test.ts
Normal file
305
app/features/sendouq/core/ready-check.server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
259
app/features/sendouq/core/ready-check.server.ts
Normal file
259
app/features/sendouq/core/ready-check.server.ts
Normal 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,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
48
app/features/sendouq/loaders/q.ready.server.ts
Normal file
48
app/features/sendouq/loaders/q.ready.server.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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}$";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
27
app/features/sendouq/routes/q.ready.module.css
Normal file
27
app/features/sendouq/routes/q.ready.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
126
app/features/sendouq/routes/q.ready.test.ts
Normal file
126
app/features/sendouq/routes/q.ready.test.ts
Normal 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));
|
||||
});
|
||||
});
|
||||
117
app/features/sendouq/routes/q.ready.tsx
Normal file
117
app/features/sendouq/routes/q.ready.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
22
app/routines/expireReadyChecks.ts
Normal file
22
app/routines/expireReadyChecks.ts
Normal 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)`);
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
34
e2e/pages/sendouq/sendouq-ready-page.ts
Normal file
34
e2e/pages/sendouq/sendouq-ready-page.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"matchProfile.mapPool.notOk": "",
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.groupNewMember": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"matchProfile.mapPool.notOk": "",
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.groupNewMember": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"matchProfile.mapPool.notOk": "",
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.groupNewMember": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"matchProfile.mapPool.notOk": "",
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.groupNewMember": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "チームに参加しました",
|
||||
|
||||
@@ -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}})",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"matchProfile.mapPool.notOk": "モードにつきステージを {{count}} 個選んでください。(避けるステージに入っていない)",
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.groupNewMember": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"matchProfile.mapPool.notOk": "",
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.groupNewMember": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"matchProfile.mapPool.notOk": "",
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.groupNewMember": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"matchProfile.mapPool.notOk": "",
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.groupNewMember": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
}
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": ""
|
||||
}
|
||||
|
||||
@@ -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": "Добавление в Команду",
|
||||
|
||||
@@ -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}} для команд)",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"matchProfile.mapPool.notOk": "Выберите по {{count}} арен за каждый режим, который вы не избегаете, чтобы сохранить настройки",
|
||||
"sounds.likeReceived": "",
|
||||
"sounds.groupNewMember": "",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "",
|
||||
"sounds.tournamentMatchStarted": ""
|
||||
}
|
||||
|
||||
@@ -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": "已加入队伍",
|
||||
|
||||
@@ -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 段位。",
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"matchProfile.mapPool.notOk": "为每个模式选择 {{count}} 个您不想避开的场地以设置场地偏好",
|
||||
"sounds.likeReceived": "收到小组邀请",
|
||||
"sounds.groupNewMember": "小组邀请已通过",
|
||||
"sounds.readyCheckStarted": "",
|
||||
"sounds.matchStarted": "SendouQ 对局开始",
|
||||
"sounds.tournamentMatchStarted": "赛事对局开始"
|
||||
}
|
||||
|
||||
58
migrations/20260807143400-sq-ready-check.ts
Normal file
58
migrations/20260807143400-sq-ready-check.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user