Closes #715 - Can unite groups

This commit is contained in:
Kalle
2022-01-30 16:21:16 +02:00
parent 19855ebc3d
commit 8667c04ae0
11 changed files with 295 additions and 93 deletions

View File

@@ -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 (
<Form method="post">
<div className="play-looking__card">
<div className="play-looking__card__members">
{group.members?.map((member) => {
return (
<div key={member.id} className="play-looking__member-card">
<Avatar tiny user={member} />
<span className="play-looking__member-name">
{member.discordName}
</span>
</div>
);
})}
</div>
<input type="hidden" name="targetGroupId" value={group.id} />
{type === "LIKES_RECEIVED" && (
<input
type="hidden"
name="targetGroupSize"
value={group.members?.length ?? -1}
/>
)}
{isGroupAdmin && (
<Button
type="submit"
name="_action"
value={buttonValue()}
tiny
variant={type === "LIKES_GIVEN" ? "destructive" : undefined}
>
{buttonText()}
</Button>
)}
</div>
</Form>
);
}

View File

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

20
app/core/play/utils.ts Normal file
View File

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

View File

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

View File

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

View File

@@ -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<typeof actionSchema>;
const actionSchema = z.object({
_action: z.enum(["LIKE", "UNLIKE", "UNITE_GROUP", "MATCH_UP"]),
targetGroupId: z.string().uuid(),
});
export type LookingActionSchema = z.infer<typeof lookingActionSchema>;
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<string>()
);
return json<LookingLoaderData>(
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<LookingLoaderData>({
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<LookingLoaderData, "ownGroup">, 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 (
<div className="container">
<GroupCard group={data.ownGroup} type="LIKES_GIVEN" />
<hr className="my-4" />
<div className="play-looking__columns">
<div>
<h2 className="play-looking__column-header">You want to play with</h2>
@@ -191,57 +251,3 @@ export default function LookingPage() {
</div>
);
}
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 (
<Form method="post">
<div className="play-looking__card">
<div className="play-looking__card__members">
{group.members?.map((member) => {
return (
<div key={member.id} className="play-looking__member-card">
<Avatar tiny user={member} />
<span className="play-looking__member-name">
{member.discordName}
</span>
</div>
);
})}
</div>
<input type="hidden" name="targetGroupId" value={group.id} />
{isGroupAdmin && (
<Button
type="submit"
name="_action"
value={buttonValue()}
tiny
variant={type === "LIKES_GIVEN" ? "destructive" : undefined}
>
{buttonText()}
</Button>
)}
</div>
</Form>
);
}

View File

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

View File

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

View File

@@ -81,9 +81,10 @@ export async function parseRequestFormData<T extends z.ZodTypeAny>({
schema: T;
}): Promise<z.infer<T>> {
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);

View File

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

View File

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