diff --git a/app/models/LFGGroup.server.ts b/app/models/LFGGroup.server.ts index b61a3371d..a41cf6f6b 100644 --- a/app/models/LFGGroup.server.ts +++ b/app/models/LFGGroup.server.ts @@ -1,4 +1,4 @@ -import type { LfgGroupType, Prisma } from "@prisma/client"; +import type { LfgGroupStatus, LfgGroupType, Prisma } from "@prisma/client"; import { PrismaClientKnownRequestError } from "@prisma/client/runtime"; import { generateMapListForLfgMatch } from "~/core/play/mapList"; import { db } from "~/utils/db.server"; @@ -29,6 +29,25 @@ export function create({ }); } +export function createPrefilled({ + ranked, + members, +}: { + ranked: boolean | null; + members: { memberId: string; captain: boolean }[]; +}) { + return db.lfgGroup.create({ + data: { + type: "VERSUS", + ranked, + status: "PRE_ADD", + members: { + createMany: { data: members }, + }, + }, + }); +} + export async function like({ likerId, targetId, @@ -229,9 +248,25 @@ export function findActiveByMember(user: { id: string }) { }); } +export async function activeUserIds() { + const rows = (await db.$queryRaw` + SELECT "LfgGroupMember"."memberId", "LfgGroup".status + FROM "LfgGroupMember" + JOIN "LfgGroup" ON ("LfgGroupMember"."groupId" = "LfgGroup".id) + WHERE "LfgGroup".status != 'INACTIVE';`) as { + memberId: string; + status: LfgGroupStatus; + }[]; + + return new Map( + rows.map((r) => [r.memberId, r.status]) + ); +} + export type FindLookingAndOwnActive = Prisma.PromiseReturnType< typeof findLookingAndOwnActive >["groups"]; +// TODO: refactor -> true by default export async function findLookingAndOwnActive( userId?: string, showPreAddMatch = false diff --git a/app/models/LFGMatch.server.ts b/app/models/LFGMatch.server.ts index 16483e514..e11b3eed6 100644 --- a/app/models/LFGMatch.server.ts +++ b/app/models/LFGMatch.server.ts @@ -7,6 +7,7 @@ export function findById(id: string) { return db.lfgGroupMatch.findUnique({ where: { id }, select: { + createdAt: true, stages: { select: { stage: { diff --git a/app/routes/play/match.$id.tsx b/app/routes/play/match.$id.tsx index 311cf2206..28c9a22a9 100644 --- a/app/routes/play/match.$id.tsx +++ b/app/routes/play/match.$id.tsx @@ -39,7 +39,11 @@ import { UserLean, validate, } from "~/utils"; -import { oldSendouInkUserProfile } from "~/utils/urls"; +import { + oldSendouInkUserProfile, + sendouQAddPlayersPage, + sendouQFrontPage, +} from "~/utils/urls"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; @@ -77,10 +81,16 @@ const matchActionSchema = z.union([ .max(LFG_AMOUNT_OF_STAGES_TO_GENERATE) ), }), + z.object({ + _action: z.literal("PLAY_AGAIN_SAME_GROUP"), + }), + z.object({ + _action: z.literal("PLAY_AGAIN_DIFFERENT_GROUP"), + }), ]); type ActionData = { - error?: "DIFFERENT_SCORE"; + error?: "DIFFERENT_SCORE" | "ALREADY_IN_GROUP"; ok?: z.infer["_action"]; }; @@ -140,6 +150,40 @@ export const action: ActionFunction = async ({ await LFGGroup.setInactive(ownGroup.id); return redirect("/play"); } + case "PLAY_AGAIN_SAME_GROUP": { + const ids = await LFGGroup.activeUserIds(); + for (const member of ownGroup.members) { + if (ids.has(member.memberId)) { + return { error: "ALREADY_IN_GROUP" }; + } + } + + await LFGGroup.createPrefilled({ + ranked: ownGroup.ranked, + members: ownGroup.members.map((m) => ({ + memberId: m.memberId, + captain: m.captain, + })), + }); + return redirect(sendouQAddPlayersPage()); + } + case "PLAY_AGAIN_DIFFERENT_GROUP": { + const ids = await LFGGroup.activeUserIds(); + if (ids.has(user.id)) { + return redirect(sendouQFrontPage()); + } + + await LFGGroup.createPrefilled({ + ranked: ownGroup.ranked, + members: [ + { + memberId: user.id, + captain: true, + }, + ], + }); + return redirect(sendouQAddPlayersPage()); + } default: { const exhaustive: never = data; throw new Response(`Unknown action: ${JSON.stringify(exhaustive)}`, { @@ -156,6 +200,7 @@ interface LFGMatchLoaderData { isCaptain: boolean; isOwnMatch: boolean; isRanked: boolean; + createdAtTimestamp: number; groups: { id: string; members: UserLean[] }[]; mapList: { name: string; @@ -224,6 +269,7 @@ export const loader: LoaderFunction = async ({ params, context }) => { isOwnMatch, groups, scores, + createdAtTimestamp: new Date(match.createdAt).getTime(), mapList: match.stages .map(({ stage, winnerGroupId }) => { const winner = () => { @@ -246,6 +292,18 @@ export default function LFGMatchPage() { const transition = useTransition(); const actionData = useActionData(); + const showPlayAgainSection = () => { + if (!data.isCaptain) return false; + if (!data.scores) return false; + + const oneHourAgo = new Date( + new Date().getTime() - 60 * 60 * 1_000 + ).getTime(); + if (data.createdAtTimestamp < oneHourAgo) return false; + + return true; + }; + return (
{actionData?.error === "DIFFERENT_SCORE" && ( @@ -300,6 +358,38 @@ export default function LFGMatchPage() { #match-meetup channel.
)} + {showPlayAgainSection() && ( +
+
+ {actionData?.error === "ALREADY_IN_GROUP" ? ( + + Some group member is already in a new group + + ) : ( + <> + Want to play again? + + + + )} +
+
+ )} {data.scores && (
{data.mapList.map((stage) => { diff --git a/app/styles/global.css b/app/styles/global.css index bdc0e0ce4..42f4b45b6 100644 --- a/app/styles/global.css +++ b/app/styles/global.css @@ -529,6 +529,10 @@ hr { margin-block: var(--s-4); } +.text-color-error { + color: var(--theme-error); +} + .text-center { text-align: center; } diff --git a/app/styles/play-match.css b/app/styles/play-match.css index 14647b36c..745b9c2ae 100644 --- a/app/styles/play-match.css +++ b/app/styles/play-match.css @@ -100,6 +100,23 @@ place-items: center; } +.play-match__play-again-container { + display: flex; + max-width: calc(28rem + var(--s-4)); + flex-wrap: wrap; + align-items: center; + gap: var(--s-2); + margin-block-start: var(--s-4); + margin-inline: auto; +} + +.play-match__play-again-button { + width: min-content; + grid-column: 1 / 3; + place-items: center; + white-space: nowrap; +} + .play-match__score.winner { background-color: var(--theme-transparent); }