diff --git a/app/components/play/GroupCard.tsx b/app/components/play/GroupCard.tsx index 417b533c5..27e522be2 100644 --- a/app/components/play/GroupCard.tsx +++ b/app/components/play/GroupCard.tsx @@ -74,7 +74,10 @@ export function GroupCard({ {group.teamMMR.value} )} - {group.MMRRelation && } + {group.MMRRelation && !group.replay && ( + + )} + {group.replay &&
Replay
} {action === "UNITE_GROUPS" && ( { assert.equal(calculateDifference({ ourMMR: 10_000, theirMMR: 0 }), "LOWER"); }); -CalculateDifference.only("A bit higher/lower", () => { +CalculateDifference("A bit higher/lower", () => { assert.equal( calculateDifference({ ourMMR: 0, theirMMR: BIT_HIGHER_MMR_LIMIT }), "BIT_HIGHER" @@ -121,7 +123,67 @@ CalculateDifference.only("A bit higher/lower", () => { ); }); +const user = { id: "a" }; +const createMembers = (input: string[]) => input.map((v) => ({ memberId: v })); + +IsMatchReplay("Detects replays", () => { + assert.ok( + isMatchReplay({ + user, + recentMatch: { + groups: [ + { members: createMembers(["a", "b", "c", "d"]) }, + { members: createMembers(["e", "f", "g", "h"]) }, + ], + }, + group: { members: createMembers(["e", "f", "g", "h"]) }, + }) + ); + + assert.ok( + isMatchReplay({ + user, + recentMatch: { + groups: [ + { members: createMembers(["e", "f", "g", "h"]) }, + { members: createMembers(["a", "b", "c", "d"]) }, + ], + }, + group: { members: createMembers(["e", "f", "g", "1"]) }, + }) + ); +}); + +IsMatchReplay("Detects not replays", () => { + assert.not.ok( + isMatchReplay({ + user, + recentMatch: { + groups: [ + { members: createMembers(["a", "b", "c", "d"]) }, + { members: createMembers(["e", "f", "g", "h"]) }, + ], + }, + group: { members: createMembers(["1", "2", "3", "4"]) }, + }) + ); + + assert.not.ok( + isMatchReplay({ + user, + recentMatch: { + groups: [ + { members: createMembers(["a", "b", "c", "d"]) }, + { members: createMembers(["e", "f", "g", "h"]) }, + ], + }, + group: { members: createMembers(["e", "f", "3", "4"]) }, + }) + ); +}); + UniteGroupInfo.run(); ScoresAreIdentical.run(); GroupsToWinningAndLosingPlayerIds.run(); CalculateDifference.run(); +IsMatchReplay.run(); diff --git a/app/core/play/utils.ts b/app/core/play/utils.ts index fdf69e585..a718939cb 100644 --- a/app/core/play/utils.ts +++ b/app/core/play/utils.ts @@ -6,6 +6,7 @@ import { LFG_GROUP_INACTIVE_MINUTES, } from "~/constants"; import * as LFGGroup from "~/models/LFGGroup.server"; +import * as LFGMatch from "~/models/LFGMatch.server"; import { PlayFrontPageLoader } from "~/routes/play/index"; import { LookingLoaderData, @@ -132,6 +133,8 @@ export function otherGroupsForResponse({ lookingForMatch, ownGroup, useRelativeSkillLevel, + recentMatch, + user, }: { groups: LFGGroup.FindLookingAndOwnActive; likes: { @@ -141,6 +144,8 @@ export function otherGroupsForResponse({ lookingForMatch: boolean; ownGroup: Unpacked; useRelativeSkillLevel: boolean; + recentMatch: LFGMatch.RecentOfUser; + user: { id: string }; }) { return ( groups @@ -179,6 +184,7 @@ export function otherGroupsForResponse({ }; }), ranked: ranked(), + replay: isMatchReplay({ user, group, recentMatch }), MMRRelation: ownGroup.ranked && group.ranked && @@ -220,6 +226,34 @@ export function otherGroupsForResponse({ ); } +export function isMatchReplay({ + recentMatch, + user, + group, +}: { + recentMatch: { groups: { members: { memberId: string }[] }[] } | null; + user: { id: string }; + group: { members: { memberId: string }[] }; +}): boolean { + if (!recentMatch) return false; + + const opponentGroupOfRecent = recentMatch.groups.find((g) => + g.members.every((m) => m.memberId !== user.id) + ); + invariant( + opponentGroupOfRecent, + "Unexpected opponentGroupOfRecent undefined" + ); + + const memberIdsOfGroup = new Set(group.members.map((m) => m.memberId)); + let sameCount = 0; + for (const { memberId } of opponentGroupOfRecent.members) { + if (memberIdsOfGroup.has(memberId)) sameCount++; + } + + return sameCount > 2; +} + export function filterExpiredGroups(group: { lastActionAt: Date }) { const { EXPIRED: expiredDate } = groupExpiredDates(); diff --git a/app/models/LFGMatch.server.ts b/app/models/LFGMatch.server.ts index cde0c56e1..16483e514 100644 --- a/app/models/LFGMatch.server.ts +++ b/app/models/LFGMatch.server.ts @@ -64,6 +64,42 @@ export function findByUserId({ userId }: { userId: string }) { }); } +export type RecentOfUser = Prisma.PromiseReturnType; +export function recentOfUser(userId: string) { + const twoHoursAgo = () => { + const result = new Date(); + result.setHours(result.getHours() - 2); + + return result; + }; + return db.lfgGroupMatch.findFirst({ + where: { + groups: { + some: { + members: { + some: { + memberId: userId, + }, + }, + }, + }, + createdAt: { + gte: twoHoursAgo(), + }, + }, + orderBy: { + createdAt: "desc", + }, + include: { + groups: { + include: { + members: true, + }, + }, + }, + }); +} + export async function reportScore({ UNSAFE_matchId, UNSAFE_winnerGroupIds, diff --git a/app/routes/play/looking.tsx b/app/routes/play/looking.tsx index db7b91de8..f7457632f 100644 --- a/app/routes/play/looking.tsx +++ b/app/routes/play/looking.tsx @@ -26,6 +26,7 @@ import { import { canUniteWithGroup, isGroupAdmin } from "~/core/play/validators"; import { usePolling } from "~/hooks/common"; import * as LFGGroup from "~/models/LFGGroup.server"; +import * as LFGMatch from "~/models/LFGMatch.server"; import styles from "~/styles/play-looking.css"; import { makeTitle, @@ -219,6 +220,7 @@ export type LookingLoaderDataGroup = { }; MMRRelation?: "LOWER" | "BIT_LOWER" | "CLOSE" | "BIT_HIGHER" | "HIGHER"; ranked?: boolean; + replay?: boolean; }; export interface LookingLoaderData { @@ -233,7 +235,10 @@ export interface LookingLoaderData { export const loader: LoaderFunction = async ({ context }) => { const user = requireUser(context); - const { groups, ownGroup } = await LFGGroup.findLookingAndOwnActive(user.id); + const [{ groups, ownGroup }, recentMatch] = await Promise.all([ + LFGGroup.findLookingAndOwnActive(user.id), + LFGMatch.recentOfUser(user.id), + ]); if (!ownGroup) return redirect("/play"); if (ownGroup.status === "MATCH") { @@ -283,6 +288,8 @@ export const loader: LoaderFunction = async ({ context }) => { isCaptain: isGroupAdmin({ group: ownGroup, user }), lastActionAtTimestamp: ownGroup.lastActionAt.getTime(), ...otherGroupsForResponse({ + recentMatch, + user, useRelativeSkillLevel: USE_RELATIVE_SKILL_LEVEL, groups: groupsOfType, lookingForMatch, diff --git a/app/styles/play-layout.css b/app/styles/play-layout.css index a2afb6aac..c1a38fcd5 100644 --- a/app/styles/play-layout.css +++ b/app/styles/play-layout.css @@ -116,6 +116,12 @@ width: 1rem; } +.play__card__replay { + color: var(--theme-info); + font-size: var(--fonts-sm); + font-weight: var(--semi-bold); +} + .play__card__member-power { display: flex; margin-top: -4px; diff --git a/prisma/seed/script.ts b/prisma/seed/script.ts index 24d36a653..6d91fe848 100644 --- a/prisma/seed/script.ts +++ b/prisma/seed/script.ts @@ -80,6 +80,7 @@ export async function seed(variation?: SeedVariations) { } if (variation === "looking" || variation === "looking-match") { await ownGroup(variation === "looking-match", remainingUserIdsForGroups); + await lfgMatches(); } async function adminUser() { @@ -405,19 +406,32 @@ export async function seed(variation?: SeedVariations) { } for (let i = 0; i < 24; i++) { + const members = { + createMany: { + data: new Array(4).fill(null).map((_, i) => ({ + memberId: userIdsStack.shift()!, + captain: i === 0, + })), + }, + }; + if (i === 0) { + await prisma.lfgGroup.create({ + data: { + id: `quad-${i + 1}-past`, + status: "INACTIVE", + type: "VERSUS", + ranked: true, + members, + }, + }); + } await prisma.lfgGroup.create({ data: { + id: `quad-${i + 1}`, status: "LOOKING", type: "VERSUS", ranked: i < 12, - members: { - createMany: { - data: new Array(4).fill(null).map((_, i) => ({ - memberId: userIdsStack.shift()!, - captain: i === 0, - })), - }, - }, + members, }, }); } @@ -442,6 +456,20 @@ export async function seed(variation?: SeedVariations) { } } + await prisma.lfgGroup.create({ + data: { + id: "own-group-past", + status: "INACTIVE", + type: "VERSUS", + ranked: true, + members: { + createMany: { + data: members, + }, + }, + }, + }); + return prisma.lfgGroup.create({ data: { status: "LOOKING", @@ -463,6 +491,26 @@ export async function seed(variation?: SeedVariations) { }); } + async function lfgMatches() { + return prisma.lfgGroupMatch.create({ + data: { + groups: { + connect: [{ id: "quad-1-past" }, { id: "own-group-past" }], + }, + stages: { + createMany: { + data: [ + { order: 1, stageId: 1, winnerGroupId: "own-group-past" }, + { order: 2, stageId: 40, winnerGroupId: "own-group-past" }, + { order: 3, stageId: 60, winnerGroupId: "own-group-past" }, + { order: 4, stageId: 101, winnerGroupId: "own-group-past" }, + ], + }, + }, + }, + }); + } + async function tournamentRoundsCreate() { const stages = await prisma.stage.findMany({}); await createTournamentRounds({