From 8667c04ae0bb40afc1e8680ae8048be608b4ba08 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 30 Jan 2022 16:21:16 +0200 Subject: [PATCH] Closes #715 - Can unite groups --- app/components/play/GroupCard.tsx | 68 +++++++ app/core/play/utils.test.ts | 32 ++++ app/core/play/utils.ts | 20 ++ app/core/play/validators.ts | 25 +++ app/models/LFGGroup.server.ts | 32 ++++ app/routes/play/looking.tsx | 178 +++++++++--------- app/styles/global.css | 11 +- app/styles/play-looking.css | 2 + app/utils/index.ts | 3 +- .../migration.sql | 13 +- prisma/schema.prisma | 4 +- 11 files changed, 295 insertions(+), 93 deletions(-) create mode 100644 app/components/play/GroupCard.tsx create mode 100644 app/core/play/utils.test.ts create mode 100644 app/core/play/utils.ts rename prisma/migrations/{20220127073153_initial => 20220130134144_initial}/migration.sql (97%) diff --git a/app/components/play/GroupCard.tsx b/app/components/play/GroupCard.tsx new file mode 100644 index 000000000..f4c40d6e7 --- /dev/null +++ b/app/components/play/GroupCard.tsx @@ -0,0 +1,68 @@ +import { Form } from "remix"; +import { Avatar } from "~/components/Avatar"; +import { Button } from "~/components/Button"; +import type { + LookingActionSchema, + LookingLoaderDataGroup, +} from "~/routes/play/looking"; + +export function GroupCard({ + group, + isGroupAdmin = false, + type, +}: { + group: LookingLoaderDataGroup; + isGroupAdmin?: boolean; + type: "LIKES_GIVEN" | "NEUTRAL" | "LIKES_RECEIVED"; +}) { + const buttonText = () => { + if (type === "LIKES_GIVEN") return "Undo"; + if (type === "NEUTRAL") return "Let's play!"; + + return "Group up"; + }; + const buttonValue = (): LookingActionSchema["_action"] => { + if (type === "LIKES_GIVEN") return "UNLIKE"; + if (type === "NEUTRAL") return "LIKE"; + + return "UNITE_GROUP"; + }; + + return ( +
+
+
+ {group.members?.map((member) => { + return ( +
+ + + {member.discordName} + +
+ ); + })} +
+ + {type === "LIKES_RECEIVED" && ( + + )} + {isGroupAdmin && ( + + )} +
+
+ ); +} diff --git a/app/core/play/utils.test.ts b/app/core/play/utils.test.ts new file mode 100644 index 000000000..97136ac1b --- /dev/null +++ b/app/core/play/utils.test.ts @@ -0,0 +1,32 @@ +import { suite } from "uvu"; +import * as assert from "uvu/assert"; +import { uniteGroupInfo, UniteGroupInfoArg } from "./utils"; + +const UniteGroupInfo = suite("uniteGroupInfo()"); + +const SMALL_GROUP: UniteGroupInfoArg = { id: "small", memberCount: 1 }; +const BIG_GROUP: UniteGroupInfoArg = { id: "big", memberCount: 3 }; + +UniteGroupInfo("Removes captain if other group is smaller", () => { + const { removeCaptainsFromOther } = uniteGroupInfo(SMALL_GROUP, BIG_GROUP); + + assert.ok(removeCaptainsFromOther); +}); + +UniteGroupInfo("Doesn't remove captain if groups are same size", () => { + const { removeCaptainsFromOther } = uniteGroupInfo(SMALL_GROUP, SMALL_GROUP); + + assert.not.ok(removeCaptainsFromOther); +}); + +UniteGroupInfo("Bigger group survives", () => { + let { otherGroupId, survivingGroupId } = uniteGroupInfo( + BIG_GROUP, + SMALL_GROUP + ); + + assert.equal(survivingGroupId, "big"); + assert.equal(otherGroupId, "small"); +}); + +UniteGroupInfo.run(); diff --git a/app/core/play/utils.ts b/app/core/play/utils.ts new file mode 100644 index 000000000..50e454a3f --- /dev/null +++ b/app/core/play/utils.ts @@ -0,0 +1,20 @@ +import type { UniteGroupsArgs } from "~/models/LFGGroup.server"; + +export interface UniteGroupInfoArg { + id: string; + memberCount: number; +} +export function uniteGroupInfo( + groupA: UniteGroupInfoArg, + groupB: UniteGroupInfoArg +): UniteGroupsArgs { + const survivingGroupId = + groupA.memberCount > groupB.memberCount ? groupA.id : groupB.id; + const otherGroupId = survivingGroupId === groupA.id ? groupB.id : groupA.id; + + return { + survivingGroupId, + otherGroupId, + removeCaptainsFromOther: groupA.memberCount !== groupB.memberCount, + }; +} diff --git a/app/core/play/validators.ts b/app/core/play/validators.ts index 8cbcad294..db7775f21 100644 --- a/app/core/play/validators.ts +++ b/app/core/play/validators.ts @@ -1,3 +1,6 @@ +import { LfgGroupType } from "@prisma/client"; +import { LFG_GROUP_FULL_SIZE } from "~/constants"; + export function isGroupAdmin({ group, user, @@ -9,3 +12,25 @@ export function isGroupAdmin({ (member) => member.captain && member.memberId === user.id ); } + +/** + * Checks that group size is suitable to be united with. E.g. if type of group + * is QUAD and your group has 2 members then a legal group to unite with would have + * 1 or 2 members. + */ +export function canUniteWithGroup({ + ownGroupType, + ownGroupSize, + otherGroupSize, +}: { + ownGroupType: LfgGroupType; + ownGroupSize: number; + otherGroupSize: number; +}): boolean { + const maxGroupSizeToConsider = + ownGroupType === "TWIN" + ? 2 - ownGroupSize + : LFG_GROUP_FULL_SIZE - ownGroupSize; + + return maxGroupSizeToConsider >= otherGroupSize; +} diff --git a/app/models/LFGGroup.server.ts b/app/models/LFGGroup.server.ts index de5b0960f..ecc6bf98d 100644 --- a/app/models/LFGGroup.server.ts +++ b/app/models/LFGGroup.server.ts @@ -59,6 +59,38 @@ export function unlike({ }); } +export interface UniteGroupsArgs { + survivingGroupId: string; + otherGroupId: string; + removeCaptainsFromOther: boolean; +} +export function uniteGroups({ + survivingGroupId, + otherGroupId, + removeCaptainsFromOther, +}: UniteGroupsArgs) { + return db.$transaction([ + db.lfgGroupMember.updateMany({ + where: { groupId: otherGroupId }, + data: { + groupId: survivingGroupId, + captain: removeCaptainsFromOther ? false : undefined, + // TODO: also reset message + }, + }), + db.lfgGroup.delete({ where: { id: otherGroupId } }), + db.lfgGroupLike.deleteMany({ + where: { + OR: [{ likerId: survivingGroupId }, { targetId: survivingGroupId }], + }, + }), + ]); +} + +export function findById(id: string) { + return db.lfgGroup.findUnique({ where: { id }, include: { members: true } }); +} + export function findActiveByMember(user: { id: string }) { return db.lfgGroup.findFirst({ where: { diff --git a/app/routes/play/looking.tsx b/app/routes/play/looking.tsx index d1a8a3af7..b85a3f03b 100644 --- a/app/routes/play/looking.tsx +++ b/app/routes/play/looking.tsx @@ -1,17 +1,17 @@ import { ActionFunction, - Form, json, LinksFunction, LoaderFunction, redirect, useLoaderData, } from "remix"; +import invariant from "tiny-invariant"; import { z } from "zod"; -import { Avatar } from "~/components/Avatar"; -import { Button } from "~/components/Button"; +import { GroupCard } from "~/components/play/GroupCard"; import { LFG_GROUP_FULL_SIZE } from "~/constants"; -import { isGroupAdmin } from "~/core/play/validators"; +import { uniteGroupInfo } from "~/core/play/utils"; +import { canUniteWithGroup, isGroupAdmin } from "~/core/play/validators"; import * as LFGGroup from "~/models/LFGGroup.server"; import styles from "~/styles/play-looking.css"; import { parseRequestFormData, requireUser, validate } from "~/utils"; @@ -20,36 +20,86 @@ export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; }; -type ActionSchema = z.infer; -const actionSchema = z.object({ - _action: z.enum(["LIKE", "UNLIKE", "UNITE_GROUP", "MATCH_UP"]), - targetGroupId: z.string().uuid(), -}); +export type LookingActionSchema = z.infer; +const lookingActionSchema = z.union([ + z.object({ + _action: z.enum(["LIKE", "UNLIKE", "MATCH_UP"]), + targetGroupId: z.string().uuid(), + }), + z.object({ + _action: z.literal("UNITE_GROUP"), + targetGroupId: z.string().uuid(), + // we also get target number size so that when you like or try to unite groups + // what you see on your screen will be guaranteed to match what the group + // actually is + targetGroupSize: z.preprocess(Number, z.number().min(1).max(4).int()), + }), +]); export const action: ActionFunction = async ({ request, context }) => { const data = await parseRequestFormData({ request, - schema: actionSchema, + schema: lookingActionSchema, }); const user = requireUser(context); - const group = await LFGGroup.findActiveByMember(user); - validate(group, "No active group"); - validate(group.looking, "Group is not looking"); - validate(isGroupAdmin({ group, user }), "Not group admin"); + const ownGroup = await LFGGroup.findActiveByMember(user); + validate(ownGroup, "No active group"); + validate(ownGroup.looking, "Group is not looking"); + validate(isGroupAdmin({ group: ownGroup, user }), "Not group admin"); switch (data._action) { - case "UNITE_GROUP": + case "UNITE_GROUP": { + validate( + canUniteWithGroup({ + ownGroupType: ownGroup.type, + ownGroupSize: ownGroup.members.length, + otherGroupSize: data.targetGroupSize, + }), + "Group to unite with too big" + ); + + const groupToUniteWith = await LFGGroup.findById(data.targetGroupId); + // let's just fail silently if they already + // matched up with someone else or stopped looking + if ( + !groupToUniteWith || + groupToUniteWith.members.length !== data.targetGroupSize || + !groupToUniteWith.looking + ) { + break; + } + + await LFGGroup.uniteGroups( + uniteGroupInfo( + { + id: ownGroup.id, + memberCount: ownGroup.members.length, + }, + { + id: groupToUniteWith.id, + memberCount: groupToUniteWith.members.length, + } + ) + ); + + break; + } case "MATCH_UP": case "UNLIKE": { await LFGGroup.unlike({ - likerId: group.id, + likerId: ownGroup.id, targetId: data.targetGroupId, }); break; } case "LIKE": { - await LFGGroup.like({ likerId: group.id, targetId: data.targetGroupId }); + // TODO: maybe also do the members.length check here? + // upside: handles edge case - downside: one extra db call per like + await LFGGroup.like({ + likerId: ownGroup.id, + targetId: data.targetGroupId, + }); break; } } @@ -59,7 +109,7 @@ export const action: ActionFunction = async ({ request, context }) => { // TODO: notify watchers }; -type LookingLoaderDataGroup = { +export type LookingLoaderDataGroup = { id: string; members?: { id: string; @@ -74,6 +124,7 @@ interface LookingLoaderData { likedGroups: LookingLoaderDataGroup[]; neutralGroups: LookingLoaderDataGroup[]; likerGroups: LookingLoaderDataGroup[]; + ownGroup: LookingLoaderDataGroup; } export const loader: LoaderFunction = async ({ context }) => { @@ -87,13 +138,6 @@ export const loader: LoaderFunction = async ({ context }) => { ownGroup.ranked ); - // For example if we have a group of 3 and group type is QUAD we only want to consider groups of size 1 - // .. if we have a group size of 2 then we can consider groups of size 1 or 2 and so on - const maxGroupSizeToConsider = (() => { - if (ownGroup.type === "TWIN") return 1; - - return LFG_GROUP_FULL_SIZE - ownGroup.members.length; - })(); const lookingForMatch = ownGroup.members.length === LFG_GROUP_FULL_SIZE; const likesGiven = ownGroup.likedGroups.reduce( (acc, lg) => acc.add(lg.targetId), @@ -104,9 +148,23 @@ export const loader: LoaderFunction = async ({ context }) => { new Set() ); - return json( - groups - .filter((group) => group.members.length <= maxGroupSizeToConsider) + const ownGroupWithMembers = groups.find((g) => g.id === ownGroup.id); + invariant(ownGroupWithMembers, "ownGroupWithMembers is undefined"); + const ownGroupForResponse: LookingLoaderDataGroup = { + id: ownGroup.id, + members: ownGroupWithMembers.members.map((m) => m.user), + }; + + return json({ + ownGroup: ownGroupForResponse, + ...groups + .filter((group) => + canUniteWithGroup({ + ownGroupType: ownGroup.type, + ownGroupSize: ownGroup.members.length, + otherGroupSize: group.members.length, + }) + ) .filter((group) => group.id !== ownGroup.id) .map((group) => ({ id: group.id, @@ -115,10 +173,10 @@ export const loader: LoaderFunction = async ({ context }) => { members: group.ranked && lookingForMatch ? undefined - : group.members.map((member) => member.user), + : group.members.map((m) => m.user), })) .reduce( - (acc: LookingLoaderData, group) => { + (acc: Omit, group) => { // likesReceived first so that if both received like and // given like then handle this edge case by just displaying the // group as waiting like back @@ -132,8 +190,8 @@ export const loader: LoaderFunction = async ({ context }) => { return acc; }, { likedGroups: [], neutralGroups: [], likerGroups: [] } - ) - ); + ), + }); }; export default function LookingPage() { @@ -141,6 +199,8 @@ export default function LookingPage() { return (
+ +

You want to play with

@@ -191,57 +251,3 @@ export default function LookingPage() {
); } - -function GroupCard({ - group, - isGroupAdmin = false, - type, -}: { - group: LookingLoaderDataGroup; - isGroupAdmin?: boolean; - type: "LIKES_GIVEN" | "NEUTRAL" | "LIKES_RECEIVED"; -}) { - const buttonText = () => { - if (type === "LIKES_GIVEN") return "Undo"; - if (type === "NEUTRAL") return "Let's play!"; - - return "Group up"; - }; - const buttonValue = (): ActionSchema["_action"] => { - if (type === "LIKES_GIVEN") return "UNLIKE"; - if (type === "NEUTRAL") return "LIKE"; - - return "MATCH_UP"; - }; - - return ( -
-
-
- {group.members?.map((member) => { - return ( -
- - - {member.discordName} - -
- ); - })} -
- - {isGroupAdmin && ( - - )} -
-
- ); -} diff --git a/app/styles/global.css b/app/styles/global.css index 2c3f25467..786157a05 100644 --- a/app/styles/global.css +++ b/app/styles/global.css @@ -280,6 +280,10 @@ select::selection { font-weight: bold; } +hr { + border-color: var(--theme-transparent); +} + /* Does not respect window getting resized */ .modal { position: fixed; @@ -473,8 +477,11 @@ select::selection { } .my-1-5 { - margin-block-end: var(--s-1-5); - margin-block-start: var(--s-1-5); + margin-block: var(--s-1-5); +} + +.my-4 { + margin-block: var(--s-4); } .text-xs { diff --git a/app/styles/play-looking.css b/app/styles/play-looking.css index e62b02d17..49b74eb78 100644 --- a/app/styles/play-looking.css +++ b/app/styles/play-looking.css @@ -19,9 +19,11 @@ display: flex; flex-direction: column; align-items: center; + margin: 0 auto; background-color: var(--bg-lighter); border-radius: var(--rounded); gap: var(--s-4); + max-inline-size: 24rem; padding-block: var(--s-3); padding-inline: var(--s-2); } diff --git a/app/utils/index.ts b/app/utils/index.ts index e37dc0188..cd87bc646 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -81,9 +81,10 @@ export async function parseRequestFormData({ schema: T; }): Promise> { try { + const formDataObj = Object.fromEntries(await request.formData()); // False alarm // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return schema.parse(Object.fromEntries(await request.formData())); + return schema.parse(formDataObj); } catch (e) { if (e instanceof z.ZodError) { console.error(e); diff --git a/prisma/migrations/20220127073153_initial/migration.sql b/prisma/migrations/20220130134144_initial/migration.sql similarity index 97% rename from prisma/migrations/20220127073153_initial/migration.sql rename to prisma/migrations/20220130134144_initial/migration.sql index c89621434..6f2b3bf4f 100644 --- a/prisma/migrations/20220127073153_initial/migration.sql +++ b/prisma/migrations/20220130134144_initial/migration.sql @@ -230,6 +230,15 @@ CREATE UNIQUE INDEX "TournamentMatchParticipant_teamId_matchId_key" ON "Tourname -- CreateIndex CREATE UNIQUE INDEX "TournamentMatchGameResult_matchId_roundStageId_key" ON "TournamentMatchGameResult"("matchId", "roundStageId"); +-- CreateIndex +CREATE INDEX "LfgGroup_active_idx" ON "LfgGroup"("active"); + +-- CreateIndex +CREATE INDEX "LfgGroup_looking_idx" ON "LfgGroup"("looking"); + +-- CreateIndex +CREATE INDEX "LfgGroup_type_idx" ON "LfgGroup"("type"); + -- CreateIndex CREATE UNIQUE INDEX "LfgGroupLike_likerId_targetId_key" ON "LfgGroupLike"("likerId", "targetId"); @@ -306,10 +315,10 @@ ALTER TABLE "TournamentMatchGameResult" ADD CONSTRAINT "TournamentMatchGameResul ALTER TABLE "LfgGroup" ADD CONSTRAINT "LfgGroup_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "LfgGroupMatch"("id") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "LfgGroupLike" ADD CONSTRAINT "LfgGroupLike_likerId_fkey" FOREIGN KEY ("likerId") REFERENCES "LfgGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "LfgGroupLike" ADD CONSTRAINT "LfgGroupLike_likerId_fkey" FOREIGN KEY ("likerId") REFERENCES "LfgGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey -ALTER TABLE "LfgGroupLike" ADD CONSTRAINT "LfgGroupLike_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "LfgGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "LfgGroupLike" ADD CONSTRAINT "LfgGroupLike_targetId_fkey" FOREIGN KEY ("targetId") REFERENCES "LfgGroup"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "LfgGroupMember" ADD CONSTRAINT "LfgGroupMember_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "LfgGroup"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 66e294818..e402be2c6 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -232,8 +232,8 @@ model LfgGroup { model LfgGroupLike { likerId String targetId String - liker LfgGroup @relation("liker", fields: [likerId], references: [id]) - target LfgGroup @relation("target", fields: [targetId], references: [id]) + liker LfgGroup @relation("liker", fields: [likerId], references: [id], onDelete: Cascade) + target LfgGroup @relation("target", fields: [targetId], references: [id], onDelete: Cascade) createdAt DateTime @default(now()) @@unique([likerId, targetId])