From b0913f0aa508aec47fe1a4cb860a7ccde08f605c Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 2 Oct 2023 01:13:30 +0300 Subject: [PATCH 1/9] Initial --- app/db/types.ts | 17 ++++++++ app/features/sendouq/core/groups.server.ts | 2 +- app/features/sendouq/core/match.server.ts | 41 ++++++++++++++++++- .../sendouq/queries/createMatch.server.ts | 11 +++-- app/features/sendouq/routes/q.looking.tsx | 3 +- app/features/sendouq/routes/q.match.$id.tsx | 5 +++ migrations/037-add-memento.js | 5 +++ 7 files changed, 77 insertions(+), 7 deletions(-) create mode 100644 migrations/037-add-memento.js diff --git a/app/db/types.ts b/app/db/types.ts index 667e276abc..fc9f6c4c06 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -7,6 +7,7 @@ import type { StageId, } from "~/modules/in-game-lists"; import type allTags from "../routes/calendar/tags.json"; +import type { TieredSkill } from "~/features/mmr/tiered.server"; export interface User { id: number; @@ -543,6 +544,21 @@ export interface GroupLike { createdAt: number; } +export type ParsedMemento = { + users: Record< + User["id"], + { + plusTier?: PlusTier["tier"]; + skill?: TieredSkill; + } + >; + groups: Record< + Group["id"], + { + tier?: TieredSkill["tier"]; + } + >; +}; export interface GroupMatch { id: number; alphaGroupId: number; @@ -551,6 +567,7 @@ export interface GroupMatch { reportedAt: number | null; reportedByUserId: number | null; chatCode: string | null; + memento: string | null; } export interface GroupMatchMap { diff --git a/app/features/sendouq/core/groups.server.ts b/app/features/sendouq/core/groups.server.ts index 032bfc4002..a48375f9b6 100644 --- a/app/features/sendouq/core/groups.server.ts +++ b/app/features/sendouq/core/groups.server.ts @@ -192,7 +192,7 @@ export function addSkillsToGroups({ // For Leviathan we don't specify if it's plus or not return tier.name === "LEVIATHAN" ? { name: "LEVIATHAN", isPlus: false } - : tier; + : { name: tier.name, isPlus: tier.isPlus }; }; const addSkill = (group: LookingGroupWithInviteCode) => ({ ...group, diff --git a/app/features/sendouq/core/match.server.ts b/app/features/sendouq/core/match.server.ts index 7ba896e5e3..0dd3688e3c 100644 --- a/app/features/sendouq/core/match.server.ts +++ b/app/features/sendouq/core/match.server.ts @@ -1,10 +1,13 @@ -import type { Group } from "~/db/types"; +import type { Group, ParsedMemento } from "~/db/types"; import { MapPool } from "~/modules/map-pool-serializer"; import { createTournamentMapList } from "~/modules/tournament-map-list-generator"; import { SENDOUQ_BEST_OF } from "../q-constants"; -import type { LookingGroup } from "../q-types"; +import type { LookingGroup, LookingGroupWithInviteCode } from "../q-types"; import invariant from "tiny-invariant"; import type { MatchById } from "../queries/findMatchById.server"; +import { addSkillsToGroups } from "./groups.server"; +import { userSkills } from "~/features/mmr/tiered.server"; +import { currentOrPreviousSeason } from "~/features/mmr/season"; const filterMapPoolToSZ = (mapPool: MapPool) => new MapPool(mapPool.stageModePairs.filter(({ mode }) => mode === "SZ")); @@ -137,3 +140,37 @@ export function compareMatchToReportedScores({ return "SAME"; } + +export async function createMatchMemento( + ownGroup: LookingGroupWithInviteCode, + theirGroup: LookingGroupWithInviteCode, +): Promise { + const skills = await userSkills(currentOrPreviousSeason(new Date())!.nth); + const withTiers = addSkillsToGroups({ + groups: { neutral: [], likesReceived: [theirGroup], own: ownGroup }, + ...skills, + }); + + const ownWithTier = withTiers.own; + const theirWithTier = withTiers.likesReceived[0]; + + return { + users: Object.fromEntries( + [...ownGroup.members, ...theirGroup.members].map((member) => [ + member.id, + { + plusTier: member.plusTier ?? undefined, + skill: skills.userSkills[member.id], + }, + ]), + ), + groups: Object.fromEntries( + [ownWithTier, theirWithTier].map((group) => [ + group.id, + { + tier: group.tier, + }, + ]), + ), + }; +} diff --git a/app/features/sendouq/queries/createMatch.server.ts b/app/features/sendouq/queries/createMatch.server.ts index afd12ac2da..c36eb17652 100644 --- a/app/features/sendouq/queries/createMatch.server.ts +++ b/app/features/sendouq/queries/createMatch.server.ts @@ -1,6 +1,6 @@ import { nanoid } from "nanoid"; import { sql } from "~/db/sql"; -import type { GroupMatch } from "~/db/types"; +import type { GroupMatch, ParsedMemento } from "~/db/types"; import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator"; import { syncGroupTeamId } from "./syncGroupTeamId.server"; @@ -8,11 +8,13 @@ const createMatchStm = sql.prepare(/* sql */ ` insert into "GroupMatch" ( "alphaGroupId", "bravoGroupId", - "chatCode" + "chatCode", + "memento" ) values ( @alphaGroupId, @bravoGroupId, - @chatCode + @chatCode, + @memento ) returning * `); @@ -38,15 +40,18 @@ export const createMatch = sql.transaction( alphaGroupId, bravoGroupId, mapList, + memento, }: { alphaGroupId: number; bravoGroupId: number; mapList: TournamentMapListMap[]; + memento: ParsedMemento; }) => { const match = createMatchStm.get({ alphaGroupId, bravoGroupId, chatCode: nanoid(10), + memento: JSON.stringify(memento), }) as GroupMatch; for (const [i, { mode, source, stageId }] of mapList.entries()) { diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index fae03b16b7..7bf1b99cd9 100644 --- a/app/features/sendouq/routes/q.looking.tsx +++ b/app/features/sendouq/routes/q.looking.tsx @@ -38,7 +38,7 @@ import { groupExpiryStatus, membersNeededForFull, } from "../core/groups.server"; -import { matchMapList } from "../core/match.server"; +import { createMatchMemento, matchMapList } from "../core/match.server"; import { FULL_GROUP_SIZE } from "../q-constants"; import { lookingSchema } from "../q-schemas.server"; import { groupRedirectLocationByCurrentLocation } from "../q-utils"; @@ -224,6 +224,7 @@ export const action: ActionFunction = async ({ request }) => { ourMapPool: new MapPool(mapPoolByGroupId(ourGroup.id)), theirMapPool: new MapPool(mapPoolByGroupId(theirGroup.id)), }), + memento: await createMatchMemento(ourGroup, theirGroup), }); throw redirect(sendouQMatchPage(createdMatch.id)); diff --git a/app/features/sendouq/routes/q.match.$id.tsx b/app/features/sendouq/routes/q.match.$id.tsx index 62a4786775..d0242df662 100644 --- a/app/features/sendouq/routes/q.match.$id.tsx +++ b/app/features/sendouq/routes/q.match.$id.tsx @@ -329,6 +329,11 @@ export const loader = async ({ params, request }: LoaderArgs) => { }; }; +// xxx: implement group cards +// - mic, weapons only when appropriate (during match etc.) +// - new group card +// - team to new group card +// - team skill to new group card export default function QMatchPage() { const user = useUser(); const isMounted = useIsMounted(); diff --git a/migrations/037-add-memento.js b/migrations/037-add-memento.js new file mode 100644 index 0000000000..a6ebc3176c --- /dev/null +++ b/migrations/037-add-memento.js @@ -0,0 +1,5 @@ +module.exports.up = function (db) { + db.transaction(() => { + db.prepare(/* sql */ `alter table "GroupMatch" add "memento" text`).run(); + })(); +}; From 57fd853e78926212ff7438b1e38b3628c762c29f Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 2 Oct 2023 23:23:19 +0300 Subject: [PATCH 2/9] New group cards initial --- app/db/seed/index.ts | 1 + app/features/sendouq/components/GroupCard.tsx | 31 +++- app/features/sendouq/q-types.ts | 2 +- app/features/sendouq/q.css | 28 ++-- .../sendouq/queries/groupForMatch.server.ts | 11 +- app/features/sendouq/routes/q.match.$id.tsx | 147 ++++-------------- app/styles/utils.css | 8 + 7 files changed, 97 insertions(+), 131 deletions(-) diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index c456f3ec03..3939ceb8cc 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -1732,6 +1732,7 @@ function playedMatches() { invariant(groupAlpha !== 0 && groupBravo !== 0, "groups not created"); + // @ts-expect-error creating without memento on purpose const match = createMatch({ alphaGroupId: groupAlpha, bravoGroupId: groupBravo, diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 92bded6a9d..e0c6116474 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -27,18 +27,23 @@ export function GroupCard({ ownRole, ownGroup = false, isExpired = false, + displayOnly = false, + className, }: { group: LookingGroup; action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP"; + // xxx: delete mapListPreference?: Group["mapListPreference"]; ownRole?: GroupMemberType["role"]; ownGroup?: boolean; isExpired?: boolean; + displayOnly?: boolean; + className?: string; }) { const fetcher = useFetcher(); return ( -
+
); })} @@ -63,7 +69,7 @@ export function GroupCard({ }) : null}
- {group.tier ? ( + {group.tier && !displayOnly ? (
@@ -77,6 +83,13 @@ export function GroupCard({
) : null} + {group.tier && displayOnly ? ( +
+ + {group.tier.name} + {group.tier.isPlus ? "+" : ""} +
+ ) : null} {action && (ownRole === "OWNER" || ownRole === "MANAGER") && !isExpired ? ( @@ -119,9 +132,11 @@ export function GroupCard({ function GroupMember({ member, showActions, + displayOnly, }: { member: NonNullable[number]; showActions: boolean; + displayOnly?: boolean; }) { return (
@@ -135,7 +150,9 @@ function GroupMember({ {member.discordName}
- {showActions ? : null} + {showActions || displayOnly ? ( + + ) : null} {member.skill ? : null}
@@ -153,7 +170,7 @@ function GroupMember({ ) : null} - {member.weapons ? ( + {member.weapons && member.weapons.length > 0 ? (
{member.weapons?.map((weapon) => { return ( @@ -174,13 +191,17 @@ function GroupMember({ function MemberRoleManager({ member, + displayOnly, }: { member: NonNullable[number]; + displayOnly?: boolean; }) { const fetcher = useFetcher(); const { t } = useTranslation(["q"]); const Icon = member.role === "OWNER" ? StarFilledIcon : StarIcon; + if (displayOnly && member.role !== "OWNER") return null; + return (
{t(`q:roles.${member.role}`)}
- {member.role !== "OWNER" ? ( + {member.role !== "OWNER" && !displayOnly ? ( {member.role === "REGULAR" ? ( diff --git a/app/features/sendouq/q-types.ts b/app/features/sendouq/q-types.ts index f8fde7e99d..af61958306 100644 --- a/app/features/sendouq/q-types.ts +++ b/app/features/sendouq/q-types.ts @@ -12,7 +12,7 @@ export type LookingGroup = { id: number; discordId: string; discordName: string; - discordAvatar: string; + discordAvatar: string | null; customUrl?: User["customUrl"]; plusTier?: PlusTier["tier"]; role: GroupMember["role"]; diff --git a/app/features/sendouq/q.css b/app/features/sendouq/q.css index aa5e9946a0..23957bcd15 100644 --- a/app/features/sendouq/q.css +++ b/app/features/sendouq/q.css @@ -131,6 +131,7 @@ display: flex; flex-direction: column; gap: var(--s-4); + position: relative; } .q__group-member { @@ -205,6 +206,21 @@ color: var(--text-lighter); } +.q__group__display-group-tier { + display: flex; + gap: var(--s-1); + align-items: center; + position: absolute; + border-radius: var(--rounded); + background-color: var(--bg-darker); + padding: var(--s-0-5) var(--s-2-5); + font-size: var(--fonts-xs); + font-weight: var(--semi-bold); + bottom: -36px; + left: 50%; + transform: translate(-50%, -50%); +} + .q__member-adder__input { --input-width: 11rem; width: 11rem; @@ -230,16 +246,6 @@ font-weight: var(--semi-bold); } -.q-match__members-container { - min-width: 180px; -} - -.q-match__star-icon { - width: 18px; - color: var(--theme-secondary); - stroke-width: 2; -} - .q-match__container { /** Push footer down to avoid it "flashing" when the score reporter animates */ padding-bottom: 14rem; @@ -252,7 +258,7 @@ .q-match__teams-container { display: grid; grid-template-columns: 1fr; - gap: var(--s-4); + gap: var(--s-8); } .q-match__report__user-name-container { diff --git a/app/features/sendouq/queries/groupForMatch.server.ts b/app/features/sendouq/queries/groupForMatch.server.ts index ed2542f31d..82491b1dd1 100644 --- a/app/features/sendouq/queries/groupForMatch.server.ts +++ b/app/features/sendouq/queries/groupForMatch.server.ts @@ -1,5 +1,5 @@ import { sql } from "~/db/sql"; -import type { Group, GroupMember, User } from "~/db/types"; +import type { Group, GroupMember, ParsedMemento, User } from "~/db/types"; import type { MainWeaponId } from "~/modules/in-game-lists"; import { parseDBArray } from "~/utils/sql"; @@ -18,6 +18,7 @@ const stm = sql.prepare(/* sql */ ` select "Group"."id", "Group"."chatCode", + "GroupMatch"."memento", "AllTeam"."name" as "teamName", "AllTeam"."customUrl" as "teamCustomUrl", "UserSubmittedImage"."url" as "teamAvatarUrl", @@ -40,6 +41,7 @@ const stm = sql.prepare(/* sql */ ` left join "User" on "User"."id" = "GroupMemberWithWeapon"."userId" left join "AllTeam" on "AllTeam"."id" = "Group"."teamId" left join "UserSubmittedImage" on "AllTeam"."avatarImgId" = "UserSubmittedImage"."id" + left join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id" or "GroupMatch"."bravoGroupId" = "Group"."id" where "Group"."id" = @id group by "Group"."id" @@ -71,9 +73,14 @@ export function groupForMatch(id: number) { const row = stm.get({ id }) as any; if (!row) return null; + const memento = row.memento + ? (JSON.parse(row.memento) as ParsedMemento) + : null; + return { id: row.id, chatCode: row.chatCode, + tier: memento?.groups[row.id]?.tier, team: row.teamName ? { name: row.teamName, @@ -84,6 +91,8 @@ export function groupForMatch(id: number) { members: JSON.parse(row.members).map((m: any) => ({ ...m, weapons: parseDBArray(m.weapons), + plusTier: memento?.users[m.id]?.plusTier, + skill: memento?.users[m.id]?.skill, })), } as GroupForMatch; } diff --git a/app/features/sendouq/routes/q.match.$id.tsx b/app/features/sendouq/routes/q.match.$id.tsx index d0242df662..1434c36756 100644 --- a/app/features/sendouq/routes/q.match.$id.tsx +++ b/app/features/sendouq/routes/q.match.$id.tsx @@ -13,20 +13,28 @@ import { Flipped, Flipper } from "react-flip-toolkit"; import invariant from "tiny-invariant"; import { Avatar } from "~/components/Avatar"; import { Button } from "~/components/Button"; +import { ConnectedChat, type ChatProps } from "~/components/Chat"; import { WeaponCombobox } from "~/components/Combobox"; +import { Divider } from "~/components/Divider"; +import { FormWithConfirm } from "~/components/FormWithConfirm"; import { ModeImage, StageImage, WeaponImage } from "~/components/Image"; import { Main } from "~/components/Main"; +import { Popover } from "~/components/Popover"; import { SubmitButton } from "~/components/SubmitButton"; +import { Toggle } from "~/components/Toggle"; import { ArchiveBoxIcon } from "~/components/icons/ArchiveBox"; import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows"; import { sql } from "~/db/sql"; import type { GroupMember, ReportedWeapon } from "~/db/types"; +import { currentSeason } from "~/features/mmr"; +import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils"; import { useIsMounted } from "~/hooks/useIsMounted"; import { useTranslation } from "~/hooks/useTranslation"; import { useUser } from "~/modules/auth"; import { getUserId, requireUserId } from "~/modules/auth/user.server"; import type { MainWeaponId } from "~/modules/in-game-lists"; import { isMod } from "~/permissions"; +import { cache } from "~/utils/cache.server"; import { databaseTimestampToDate } from "~/utils/dates"; import { animate } from "~/utils/flip"; import type { SendouRouteHandle } from "~/utils/remix"; @@ -36,6 +44,7 @@ import { parseRequestFormData, validate, } from "~/utils/remix"; +import { inGameNameWithoutDiscriminator } from "~/utils/strings"; import type { Unpacked } from "~/utils/types"; import { assertUnreachable } from "~/utils/types"; import { @@ -44,46 +53,32 @@ import { SENDOUQ_RULES_PAGE, SENDOU_INK_DISCORD_URL, navIconUrl, - teamPage, - userPage, - userSubmittedImage, } from "~/utils/urls"; +import { GroupCard } from "../components/GroupCard"; import { matchEndedAtIndex } from "../core/match"; import { compareMatchToReportedScores } from "../core/match.server"; import { calculateMatchSkills } from "../core/skills.server"; +import { + summarizeMaps, + summarizePlayerResults, +} from "../core/summarizer.server"; import { FULL_GROUP_SIZE, USER_SKILLS_CACHE_KEY } from "../q-constants"; import { matchSchema } from "../q-schemas.server"; import { matchIdFromParams, winnersArrayToWinner } from "../q-utils"; import styles from "../q.css"; +import { addDummySkill } from "../queries/addDummySkill.server"; +import { addMapResults } from "../queries/addMapResults.server"; +import { addPlayerResults } from "../queries/addPlayerResults.server"; import { addReportedWeapons } from "../queries/addReportedWeapons.server"; import { addSkills } from "../queries/addSkills.server"; import { createGroupFromPreviousGroup } from "../queries/createGroup.server"; +import { deleteReporterWeaponsByMatchId } from "../queries/deleteReportedWeaponsByMatchId.server"; import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server"; import { findMatchById } from "../queries/findMatchById.server"; -import type { GroupForMatch } from "../queries/groupForMatch.server"; import { groupForMatch } from "../queries/groupForMatch.server"; import { reportScore } from "../queries/reportScore.server"; import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server"; import { setGroupAsInactive } from "../queries/setGroupAsInactive.server"; -import { deleteReporterWeaponsByMatchId } from "../queries/deleteReportedWeaponsByMatchId.server"; -import { Divider } from "~/components/Divider"; -import { cache } from "~/utils/cache.server"; -import { Toggle } from "~/components/Toggle"; -import { addMapResults } from "../queries/addMapResults.server"; -import { - summarizeMaps, - summarizePlayerResults, -} from "../core/summarizer.server"; -import { addPlayerResults } from "../queries/addPlayerResults.server"; -import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils"; -import { FormWithConfirm } from "~/components/FormWithConfirm"; -import { addDummySkill } from "../queries/addDummySkill.server"; -import { inGameNameWithoutDiscriminator } from "~/utils/strings"; -import { ConnectedChat, type ChatProps } from "~/components/Chat"; -import { currentSeason } from "~/features/mmr"; -import { StarFilledIcon } from "~/components/icons/StarFilled"; -import { StarIcon } from "~/components/icons/Star"; -import { Popover } from "~/components/Popover"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; @@ -439,16 +434,22 @@ export default function QMatchPage() { "with-chat": data.matchChatCode || data.groupChatCode, })} > - - +
+ Alpha + +
+
+ Bravo + +
{chatRooms.length > 0 ? ( ; - side: "ALPHA" | "BRAVO"; - showWeapons: boolean; -}) { - const roleString = (role: GroupMember["role"]) => { - if (role === "REGULAR") return ""; - - return ` (${role.toLowerCase()})`; - }; - - return ( -
-

{side}

-
- {group.team ? ( - - {group.team.avatarUrl ? ( - - ) : null} - {group.team.name} - - ) : null} - {group.members.map((member) => ( - - - -
- {member.inGameName ? ( - <> - IGN:{" "} - {inGameNameWithoutDiscriminator(member.inGameName)} - - ) : ( - member.discordName - )} -
- {member.role === "OWNER" ? ( - - ) : null} - {member.role === "MANAGER" ? ( - - ) : null} - - {showWeapons && member.weapons.length > 0 ? ( -
- {member.weapons.map((weapon) => { - return ( - - ); - })} -
- ) : null} -
- ))} -
-
- ); -} - function MapList({ canReportScore, isResubmission, diff --git a/app/styles/utils.css b/app/styles/utils.css index 02feb6832f..a63aa3dff1 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -114,6 +114,10 @@ font-weight: var(--bold); } +.h-full { + height: 100%; +} + .w-full { width: 100%; } @@ -158,6 +162,10 @@ padding-block-end: var(--s-4); } +.pb-8 { + padding-block-end: var(--s-8); +} + .pt-12-forced { padding-block-start: var(--s-12) !important; } From 6d76606e04d20c72bc368745d8604cfaa0dd3cf4 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 2 Oct 2023 23:23:44 +0300 Subject: [PATCH 3/9] Remove unused prop --- app/features/sendouq/components/GroupCard.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index e0c6116474..ca51a1207b 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -9,7 +9,7 @@ import { SubmitButton } from "~/components/SubmitButton"; import { MicrophoneIcon } from "~/components/icons/Microphone"; import { SpeakerIcon } from "~/components/icons/Speaker"; import { SpeakerXIcon } from "~/components/icons/SpeakerX"; -import type { Group, GroupMember as GroupMemberType } from "~/db/types"; +import type { GroupMember as GroupMemberType } from "~/db/types"; import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils"; import type { TieredSkill } from "~/features/mmr/tiered.server"; import { useTranslation } from "~/hooks/useTranslation"; @@ -32,8 +32,6 @@ export function GroupCard({ }: { group: LookingGroup; action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP"; - // xxx: delete - mapListPreference?: Group["mapListPreference"]; ownRole?: GroupMemberType["role"]; ownGroup?: boolean; isExpired?: boolean; From fbe9ef1e037562362b0be03b2a933b469f9a19fc Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Mon, 2 Oct 2023 23:56:29 +0300 Subject: [PATCH 4/9] Vc --- app/features/sendouq/components/GroupCard.tsx | 13 ++++++--- app/features/sendouq/q.css | 6 ++++ .../sendouq/queries/groupForMatch.server.ts | 5 ++++ app/features/sendouq/routes/q.looking.tsx | 28 +------------------ app/features/sendouq/routes/q.match.$id.tsx | 20 ++++++------- app/features/sendouq/routes/q.preparing.tsx | 7 +---- app/styles/utils.css | 4 --- 7 files changed, 31 insertions(+), 52 deletions(-) diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index ca51a1207b..d634f324f3 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -28,7 +28,7 @@ export function GroupCard({ ownGroup = false, isExpired = false, displayOnly = false, - className, + hideVc = false, }: { group: LookingGroup; action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP"; @@ -36,12 +36,14 @@ export function GroupCard({ ownGroup?: boolean; isExpired?: boolean; displayOnly?: boolean; - className?: string; + hideVc?: boolean; }) { const fetcher = useFetcher(); return ( -
+
); })} @@ -131,10 +134,12 @@ function GroupMember({ member, showActions, displayOnly, + hideVc, }: { member: NonNullable[number]; showActions: boolean; displayOnly?: boolean; + hideVc?: boolean; }) { return (
@@ -156,7 +161,7 @@ function GroupMember({
- {member.vc ? ( + {member.vc && !hideVc ? (
diff --git a/app/features/sendouq/q.css b/app/features/sendouq/q.css index 23957bcd15..2a29c7300e 100644 --- a/app/features/sendouq/q.css +++ b/app/features/sendouq/q.css @@ -132,6 +132,12 @@ flex-direction: column; gap: var(--s-4); position: relative; + color: var(--text); +} + +.q__group__display-only { + height: 100%; + padding-block-end: var(--s-10); } .q__group-member { diff --git a/app/features/sendouq/queries/groupForMatch.server.ts b/app/features/sendouq/queries/groupForMatch.server.ts index 82491b1dd1..daaa1d6716 100644 --- a/app/features/sendouq/queries/groupForMatch.server.ts +++ b/app/features/sendouq/queries/groupForMatch.server.ts @@ -31,6 +31,8 @@ const stm = sql.prepare(/* sql */ ` 'role', "GroupMemberWithWeapon"."role", 'customUrl', "User"."customUrl", 'inGameName', "User"."inGameName", + 'vc', "User"."vc", + 'languages', "User"."languages", 'weapons', "GroupMemberWithWeapon"."weapons", 'chatNameColor', IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null) ) @@ -66,6 +68,8 @@ export interface GroupForMatch { inGameName: User["inGameName"]; weapons: Array; chatNameColor: string | null; + vc: User["vc"]; + languages: string[]; }>; } @@ -91,6 +95,7 @@ export function groupForMatch(id: number) { members: JSON.parse(row.members).map((m: any) => ({ ...m, weapons: parseDBArray(m.weapons), + languages: m.languages ? m.languages.split(",") : [], plusTier: memento?.users[m.id]?.plusTier, skill: memento?.users[m.id]?.skill, })), diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index 7bf1b99cd9..2f5c60350b 100644 --- a/app/features/sendouq/routes/q.looking.tsx +++ b/app/features/sendouq/routes/q.looking.tsx @@ -492,12 +492,7 @@ function Groups() { const ownGroupElement = (
- + {ownGroup.inviteCode ? ( {data.groups.neutral.map((group) => { - const { mapListPreference } = groupAfterMorph({ - liker: "US", - ourGroup: data.groups.own, - theirGroup: group, - }); - return ( @@ -597,18 +585,11 @@ function Groups() { element: (
{data.groups.likesReceived.map((group) => { - const { mapListPreference } = groupAfterMorph({ - liker: "THEM", - ourGroup: data.groups.own, - theirGroup: group, - }); - return ( @@ -633,18 +614,11 @@ function Groups() { {!isMobile ? (
{data.groups.likesReceived.map((group) => { - const { mapListPreference } = groupAfterMorph({ - liker: "THEM", - ourGroup: data.groups.own, - theirGroup: group, - }); - return ( diff --git a/app/features/sendouq/routes/q.match.$id.tsx b/app/features/sendouq/routes/q.match.$id.tsx index 1434c36756..24cfa7b6bd 100644 --- a/app/features/sendouq/routes/q.match.$id.tsx +++ b/app/features/sendouq/routes/q.match.$id.tsx @@ -318,6 +318,11 @@ export const loader = async ({ params, request }: LoaderArgs) => { groupChatCode: groupChatCode(), groupAlpha: censoredGroupAlpha, groupBravo: censoredGroupBravo, + groupMemberOf: isTeamAlphaMember + ? ("ALPHA" as const) + : isTeamBravoMember + ? ("BRAVO" as const) + : null, reportedWeapons: match.reportedAt ? reportedWeaponsByMatchId(matchId) : undefined, @@ -439,7 +444,7 @@ export default function QMatchPage() {
@@ -447,7 +452,7 @@ export default function QMatchPage() {
{chatRooms.length > 0 ? ( @@ -969,7 +974,6 @@ function MapListMap({ canReportScore: boolean; weapons?: ReportedWeapon[]; }) { - const user = useUser(); const data = useLoaderData(); const { t } = useTranslation(["game-misc", "tournament"]); @@ -1033,15 +1037,9 @@ function MapListMap({ }; const relativeSideText = (side: "ALPHA" | "BRAVO") => { - const ownSide = data.groupAlpha.members.some((m) => m.id === user?.id) - ? "ALPHA" - : data.groupBravo.members.some((m) => m.id === user?.id) - ? "BRAVO" - : null; - - if (!ownSide) return ""; + if (!data.groupMemberOf) return ""; - return ownSide === side ? " (us)" : " (them)"; + return data.groupMemberOf === side ? " (us)" : " (them)"; }; return ( diff --git a/app/features/sendouq/routes/q.preparing.tsx b/app/features/sendouq/routes/q.preparing.tsx index ac7c530a24..28f5df6fa6 100644 --- a/app/features/sendouq/routes/q.preparing.tsx +++ b/app/features/sendouq/routes/q.preparing.tsx @@ -144,12 +144,7 @@ export default function QPreparingPage() { return (
- +
{data.group.members.length < FULL_GROUP_SIZE && hasGroupManagerPerms(data.role) ? ( diff --git a/app/styles/utils.css b/app/styles/utils.css index a63aa3dff1..d2f6eb9af4 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -162,10 +162,6 @@ padding-block-end: var(--s-4); } -.pb-8 { - padding-block-end: var(--s-8); -} - .pt-12-forced { padding-block-start: var(--s-12) !important; } From 313bd78d4458d7f81790a433795d867bb651cdda Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 3 Oct 2023 00:03:06 +0300 Subject: [PATCH 5/9] Styling for after the match is locked --- app/features/sendouq/components/GroupCard.tsx | 7 ++++++- app/features/sendouq/queries/findMatchById.server.ts | 3 ++- app/features/sendouq/routes/q.match.$id.tsx | 9 ++++----- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index d634f324f3..6e154e0e41 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -29,6 +29,7 @@ export function GroupCard({ isExpired = false, displayOnly = false, hideVc = false, + hideWeapons = false, }: { group: LookingGroup; action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP"; @@ -37,6 +38,7 @@ export function GroupCard({ isExpired?: boolean; displayOnly?: boolean; hideVc?: boolean; + hideWeapons?: boolean; }) { const fetcher = useFetcher(); @@ -57,6 +59,7 @@ export function GroupCard({ key={member.discordId} displayOnly={displayOnly} hideVc={hideVc} + hideWeapons={hideWeapons} /> ); })} @@ -135,11 +138,13 @@ function GroupMember({ showActions, displayOnly, hideVc, + hideWeapons, }: { member: NonNullable[number]; showActions: boolean; displayOnly?: boolean; hideVc?: boolean; + hideWeapons?: boolean; }) { return (
@@ -173,7 +178,7 @@ function GroupMember({
) : null}
- {member.weapons && member.weapons.length > 0 ? ( + {member.weapons && member.weapons.length > 0 && !hideWeapons ? (
{member.weapons?.map((weapon) => { return ( diff --git a/app/features/sendouq/queries/findMatchById.server.ts b/app/features/sendouq/queries/findMatchById.server.ts index 2e775b0d15..a0f27a0ba6 100644 --- a/app/features/sendouq/queries/findMatchById.server.ts +++ b/app/features/sendouq/queries/findMatchById.server.ts @@ -36,7 +36,7 @@ export interface MatchById { reportedAt: GroupMatch["reportedAt"]; reportedByUserId: GroupMatch["reportedByUserId"]; chatCode: GroupMatch["chatCode"]; - isLocked: number; + isLocked: boolean; mapList: Array< Pick >; @@ -49,5 +49,6 @@ export function findMatchById(id: number) { return { ...row, mapList: parseDBJsonArray(row.mapList), + isLocked: Boolean(row.isLocked), } as MatchById; } diff --git a/app/features/sendouq/routes/q.match.$id.tsx b/app/features/sendouq/routes/q.match.$id.tsx index 24cfa7b6bd..e686ca22b1 100644 --- a/app/features/sendouq/routes/q.match.$id.tsx +++ b/app/features/sendouq/routes/q.match.$id.tsx @@ -330,10 +330,7 @@ export const loader = async ({ params, request }: LoaderArgs) => { }; // xxx: implement group cards -// - mic, weapons only when appropriate (during match etc.) -// - new group card // - team to new group card -// - team skill to new group card export default function QMatchPage() { const user = useUser(); const isMounted = useIsMounted(); @@ -444,7 +441,8 @@ export default function QMatchPage() {
@@ -452,7 +450,8 @@ export default function QMatchPage() {
{chatRooms.length > 0 ? ( From 70d699a957239ae4245082f684d88b3a1ef0ecf7 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 3 Oct 2023 00:32:11 +0300 Subject: [PATCH 6/9] Team --- app/features/sendouq/components/GroupCard.tsx | 5 +- app/features/sendouq/routes/q.match.$id.tsx | 53 ++++++++++++------- 2 files changed, 37 insertions(+), 21 deletions(-) diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index 6e154e0e41..aed6045a55 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -20,6 +20,7 @@ import { FULL_GROUP_SIZE } from "../q-constants"; import type { LookingGroup } from "../q-types"; import { StarIcon } from "~/components/icons/Star"; import { StarFilledIcon } from "~/components/icons/StarFilled"; +import type { GroupForMatch } from "../queries/groupForMatch.server"; export function GroupCard({ group, @@ -31,7 +32,9 @@ export function GroupCard({ hideVc = false, hideWeapons = false, }: { - group: LookingGroup; + group: LookingGroup & { + team?: GroupForMatch["team"]; + }; action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP"; ownRole?: GroupMemberType["role"]; ownGroup?: boolean; diff --git a/app/features/sendouq/routes/q.match.$id.tsx b/app/features/sendouq/routes/q.match.$id.tsx index e686ca22b1..741a5f5e0b 100644 --- a/app/features/sendouq/routes/q.match.$id.tsx +++ b/app/features/sendouq/routes/q.match.$id.tsx @@ -53,6 +53,8 @@ import { SENDOUQ_RULES_PAGE, SENDOU_INK_DISCORD_URL, navIconUrl, + teamPage, + userSubmittedImage, } from "~/utils/urls"; import { GroupCard } from "../components/GroupCard"; import { matchEndedAtIndex } from "../core/match"; @@ -329,8 +331,6 @@ export const loader = async ({ params, request }: LoaderArgs) => { }; }; -// xxx: implement group cards -// - team to new group card export default function QMatchPage() { const user = useUser(); const isMounted = useIsMounted(); @@ -436,24 +436,37 @@ export default function QMatchPage() { "with-chat": data.matchChatCode || data.groupChatCode, })} > -
- Alpha - -
-
- Bravo - -
+ {[data.groupAlpha, data.groupBravo].map((group, i) => { + const side = i === 0 ? "ALPHA" : "BRAVO"; + + return ( +
+
+ {i === 0 ? "Alpha" : "Bravo"} + {group.team ? ( + + {group.team.avatarUrl ? ( + + ) : null} + {group.team.name} + + ) : null} +
+ +
+ ); + })} {chatRooms.length > 0 ? ( Date: Tue, 3 Oct 2023 00:32:30 +0300 Subject: [PATCH 7/9] Impersonate always in dev --- app/routes/admin.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index 394ab0b532..8543a4dbd5 100644 --- a/app/routes/admin.tsx +++ b/app/routes/admin.tsx @@ -137,7 +137,9 @@ interface AdminPageLoaderData { export const loader: LoaderFunction = async ({ request }) => { const user = await getUserId(request); - if (!isMod(user)) throw redirect("/"); + if (process.env.NODE_ENV === "production" && !isMod(user)) { + throw redirect("/"); + } return json({ isImpersonating: await isImpersonating(request), @@ -157,7 +159,9 @@ export default function AdminPage() { {isMod(user) ? : null} {isMod(user) ? : null} - {isAdmin(user) ? : null} + {process.env.NODE_ENV !== "production" || isAdmin(user) ? ( + + ) : null} {isAdmin(user) ? : null} {isAdmin(user) ? : null} {isAdmin(user) ? : null} From 45a367c025a584b82f2cb7932840844cbbe62e57 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 3 Oct 2023 21:18:18 +0300 Subject: [PATCH 8/9] Diff --- app/db/seed/index.ts | 11 +- app/db/types.ts | 22 +++ app/features/mmr/mmr-utils.server.ts | 12 +- .../findCurrentSkillByUserId.server.ts | 8 +- ...findCurrentTeamSkillByIdentifier.server.ts | 8 +- app/features/sendouq/components/GroupCard.tsx | 85 ++++++++- app/features/sendouq/core/skills.server.ts | 173 ++++++++++++++++-- app/features/sendouq/q-types.ts | 12 +- .../sendouq/queries/addSkills.server.ts | 44 ++++- .../sendouq/queries/findMatchById.server.ts | 5 +- .../sendouq/queries/groupForMatch.server.ts | 13 +- app/features/sendouq/routes/q.match.$id.tsx | 21 ++- .../routes/to.$id.brackets.tsx | 6 +- 13 files changed, 369 insertions(+), 51 deletions(-) diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index 3939ceb8cc..599ea93d85 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -1773,10 +1773,12 @@ function playedMatches() { const winner = winnersArrayToWinner(winners); const finishedMatch = findMatchById(match.id)!; - const newSkills = calculateMatchSkills({ + const { newSkills, differences } = calculateMatchSkills({ groupMatchId: match.id, winner: winner === "ALPHA" ? groupAlphaMembers : groupBravoMembers, loser: winner === "ALPHA" ? groupBravoMembers : groupAlphaMembers, + loserGroupId: winner === "ALPHA" ? groupBravo : groupAlpha, + winnerGroupId: winner === "ALPHA" ? groupAlpha : groupBravo, }); const members = [ ...groupForMatch(match.alphaGroupId)!.members.map((m) => ({ @@ -1795,7 +1797,12 @@ function playedMatches() { Math.random() > 0.5 ? groupAlphaMembers[0] : groupBravoMembers[0], winners, }); - addSkills(newSkills); + addSkills({ + skills: newSkills, + differences, + groupMatchId: match.id, + oldMatchMemento: { users: {}, groups: {} }, + }); setGroupAsInactive(groupAlpha); setGroupAsInactive(groupBravo); addMapResults(summarizeMaps({ match: finishedMatch, members, winners })); diff --git a/app/db/types.ts b/app/db/types.ts index fc9f6c4c06..9880be0d51 100644 --- a/app/db/types.ts +++ b/app/db/types.ts @@ -544,18 +544,40 @@ export interface GroupLike { createdAt: number; } +type CalculatingSkill = { + calculated: false; + matchesCount: number; + matchesCountNeeded: number; + /** Freshly calculated skill */ + newSp?: number; +}; +export type UserSkillDifference = + | { + calculated: true; + spDiff: number; + } + | CalculatingSkill; +export type GroupSkillDifference = + | { + calculated: true; + oldSp: number; + newSp: number; + } + | CalculatingSkill; export type ParsedMemento = { users: Record< User["id"], { plusTier?: PlusTier["tier"]; skill?: TieredSkill; + skillDifference?: UserSkillDifference; } >; groups: Record< Group["id"], { tier?: TieredSkill["tier"]; + skillDifference?: GroupSkillDifference; } >; }; diff --git a/app/features/mmr/mmr-utils.server.ts b/app/features/mmr/mmr-utils.server.ts index 238d213e75..13b92479b4 100644 --- a/app/features/mmr/mmr-utils.server.ts +++ b/app/features/mmr/mmr-utils.server.ts @@ -13,10 +13,10 @@ export function queryCurrentUserRating({ const skill = findCurrentSkillByUserId({ userId, season: season ?? null }); if (!skill) { - return rating(); + return { rating: rating(), matchesCount: 0 }; } - return rating(skill); + return { rating: rating(skill), matchesCount: skill.matchesCount }; } export function queryCurrentTeamRating({ @@ -31,9 +31,9 @@ export function queryCurrentTeamRating({ season, }); - if (!skill) return rating(); + if (!skill) return { rating: rating(), matchesCount: 0 }; - return rating(skill); + return { rating: rating(skill), matchesCount: skill.matchesCount }; } export function queryTeamPlayerRatingAverage({ @@ -43,8 +43,8 @@ export function queryTeamPlayerRatingAverage({ identifier: string; season: number; }) { - const playerRatings = identifierToUserIds(identifier).map((userId) => - queryCurrentUserRating({ userId, season }), + const playerRatings = identifierToUserIds(identifier).map( + (userId) => queryCurrentUserRating({ userId, season }).rating, ); if (playerRatings.length === 0) return rating(); diff --git a/app/features/mmr/queries/findCurrentSkillByUserId.server.ts b/app/features/mmr/queries/findCurrentSkillByUserId.server.ts index 3b2425030a..cb38e19c85 100644 --- a/app/features/mmr/queries/findCurrentSkillByUserId.server.ts +++ b/app/features/mmr/queries/findCurrentSkillByUserId.server.ts @@ -4,7 +4,8 @@ import type { Skill } from "~/db/types"; const stm = sql.prepare(/* sql */ ` select "mu", - "sigma" + "sigma", + "matchesCount" from "Skill" where @@ -24,5 +25,8 @@ export function findCurrentSkillByUserId({ userId: number; season: number; }) { - return stm.get({ userId, season }) as Pick | null; + return stm.get({ userId, season }) as Pick< + Skill, + "mu" | "sigma" | "matchesCount" + > | null; } diff --git a/app/features/mmr/queries/findCurrentTeamSkillByIdentifier.server.ts b/app/features/mmr/queries/findCurrentTeamSkillByIdentifier.server.ts index ef9ca87535..6af6fb766f 100644 --- a/app/features/mmr/queries/findCurrentTeamSkillByIdentifier.server.ts +++ b/app/features/mmr/queries/findCurrentTeamSkillByIdentifier.server.ts @@ -4,7 +4,8 @@ import type { Skill } from "~/db/types"; const stm = sql.prepare(/* sql */ ` select "mu", - "sigma" + "sigma", + "matchesCount" from "Skill" where @@ -24,5 +25,8 @@ export function findCurrentTeamSkillByIdentifier({ identifier: string; season: number; }) { - return stm.get({ identifier, season }) as Pick | null; + return stm.get({ identifier, season }) as Pick< + Skill, + "mu" | "sigma" | "matchesCount" + > | null; } diff --git a/app/features/sendouq/components/GroupCard.tsx b/app/features/sendouq/components/GroupCard.tsx index aed6045a55..27dd90e6c4 100644 --- a/app/features/sendouq/components/GroupCard.tsx +++ b/app/features/sendouq/components/GroupCard.tsx @@ -9,7 +9,7 @@ import { SubmitButton } from "~/components/SubmitButton"; import { MicrophoneIcon } from "~/components/icons/Microphone"; import { SpeakerIcon } from "~/components/icons/Speaker"; import { SpeakerXIcon } from "~/components/icons/SpeakerX"; -import type { GroupMember as GroupMemberType } from "~/db/types"; +import type { GroupMember as GroupMemberType, ParsedMemento } from "~/db/types"; import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils"; import type { TieredSkill } from "~/features/mmr/tiered.server"; import { useTranslation } from "~/hooks/useTranslation"; @@ -20,7 +20,6 @@ import { FULL_GROUP_SIZE } from "../q-constants"; import type { LookingGroup } from "../q-types"; import { StarIcon } from "~/components/icons/Star"; import { StarFilledIcon } from "~/components/icons/StarFilled"; -import type { GroupForMatch } from "../queries/groupForMatch.server"; export function GroupCard({ group, @@ -32,9 +31,7 @@ export function GroupCard({ hideVc = false, hideWeapons = false, }: { - group: LookingGroup & { - team?: GroupForMatch["team"]; - }; + group: LookingGroup; action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP"; ownRole?: GroupMemberType["role"]; ownGroup?: boolean; @@ -97,6 +94,9 @@ export function GroupCard({ {group.tier.isPlus ? "+" : ""}
) : null} + {group.skillDifference ? ( + + ) : null} {action && (ownRole === "OWNER" || ownRole === "MANAGER") && !isExpired ? ( @@ -195,7 +195,82 @@ function GroupMember({ })}
) : null} + {member.skillDifference ? ( + + ) : null} +
+
+ ); +} + +function GroupSkillDifference({ + skillDifference, +}: { + skillDifference: NonNullable< + ParsedMemento["groups"][number]["skillDifference"] + >; +}) { + if (skillDifference.calculated) { + return ( +
+ Team SP {skillDifference.oldSp} ➜ {skillDifference.newSp} +
+ ); + } + + if (skillDifference.newSp) { + return ( +
+ Team SP calculated: {skillDifference.newSp} +
+ ); + } + + return ( +
+ Team SP calculating... ({skillDifference.matchesCount}/ + {skillDifference.matchesCountNeeded}) +
+ ); +} + +function MemberSkillDifference({ + skillDifference, +}: { + skillDifference: NonNullable< + ParsedMemento["users"][number]["skillDifference"] + >; +}) { + if (skillDifference.calculated) { + if (skillDifference.spDiff === 0) return null; + + const symbol = + skillDifference.spDiff > 0 ? ( + + ) : ( + + ); + return ( +
+ {symbol} + {Math.abs(skillDifference.spDiff)}SP +
+ ); + } + + if (skillDifference.matchesCount === skillDifference.matchesCountNeeded) { + return ( +
+ Calculated:{" "} + {skillDifference.newSp ? <>{skillDifference.newSp}SP : null}
+ ); + } + + return ( +
+ Calculating... ( + {skillDifference.matchesCount}/{skillDifference.matchesCountNeeded})
); } diff --git a/app/features/sendouq/core/skills.server.ts b/app/features/sendouq/core/skills.server.ts index eab7e8e929..69b0de3f70 100644 --- a/app/features/sendouq/core/skills.server.ts +++ b/app/features/sendouq/core/skills.server.ts @@ -1,6 +1,17 @@ +import { ordinal } from "openskill"; +import type { Rating } from "openskill/dist/types"; import invariant from "tiny-invariant"; -import type { GroupMatch, Skill, User } from "~/db/types"; +import type { + Group, + GroupMatch, + Skill, + GroupSkillDifference, + User, + UserSkillDifference, +} from "~/db/types"; +import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants"; import { + ordinalToSp, queryCurrentTeamRating, queryCurrentUserRating, rate, @@ -8,35 +19,63 @@ import { } from "~/features/mmr"; import { queryTeamPlayerRatingAverage } from "~/features/mmr/mmr-utils.server"; import { currentOrPreviousSeason } from "~/features/mmr/season"; +import { roundToNDecimalPlaces } from "~/utils/number"; + +export type MementoSkillDifferences = { + users: Record< + User["id"], + { + skillDifference?: UserSkillDifference; + } + >; + groups: Record< + Group["id"], + { + skillDifference?: GroupSkillDifference; + } + >; +}; export function calculateMatchSkills({ groupMatchId, winner, loser, + winnerGroupId, + loserGroupId, }: { groupMatchId: GroupMatch["id"]; winner: User["id"][]; loser: User["id"][]; + winnerGroupId: Group["id"]; + loserGroupId: Group["id"]; }) { - const result: Array< + const newSkills: Array< Pick< Skill, "groupMatchId" | "identifier" | "mu" | "season" | "sigma" | "userId" > > = []; + const differences: MementoSkillDifferences = { users: {}, groups: {} }; const season = currentOrPreviousSeason(new Date())?.nth; invariant(typeof season === "number", "No ranked season for skills"); { + const oldWinnerRatings = winner.map((userId) => + queryCurrentUserRating({ userId, season }), + ); + const oldLoserRatings = loser.map((userId) => + queryCurrentUserRating({ userId, season }), + ); + // individual skills const [winnerTeamNew, loserTeamNew] = rate([ - winner.map((userId) => queryCurrentUserRating({ userId, season })), - loser.map((userId) => queryCurrentUserRating({ userId, season })), + oldWinnerRatings.map(({ rating }) => rating), + oldLoserRatings.map(({ rating }) => rating), ]); for (const [index, userId] of winner.entries()) { - result.push({ + newSkills.push({ groupMatchId: groupMatchId, identifier: null, mu: winnerTeamNew[index].mu, @@ -44,10 +83,18 @@ export function calculateMatchSkills({ sigma: winnerTeamNew[index].sigma, userId, }); + + differences.users[userId] = { + skillDifference: userSkillDifference({ + oldRating: oldWinnerRatings[index].rating, + newRating: winnerTeamNew[index], + matchesCount: oldWinnerRatings[index].matchesCount, + }), + }; } for (const [index, userId] of loser.entries()) { - result.push({ + newSkills.push({ groupMatchId: groupMatchId, identifier: null, mu: loserTeamNew[index].mu, @@ -55,6 +102,14 @@ export function calculateMatchSkills({ sigma: loserTeamNew[index].sigma, userId, }); + + differences.users[userId] = { + skillDifference: userSkillDifference({ + oldRating: oldLoserRatings[index].rating, + newRating: loserTeamNew[index], + matchesCount: oldLoserRatings[index].matchesCount, + }), + }; } } @@ -62,11 +117,17 @@ export function calculateMatchSkills({ // team skills const winnerTeamIdentifier = userIdsToIdentifier(winner); const loserTeamIdentifier = userIdsToIdentifier(loser); - const [[winnerTeamNew], [loserTeamNew]] = rate( - [ - [queryCurrentTeamRating({ identifier: winnerTeamIdentifier, season })], - [queryCurrentTeamRating({ identifier: loserTeamIdentifier, season })], - ], + + const oldWinnerGroupRating = queryCurrentTeamRating({ + identifier: winnerTeamIdentifier, + season, + }); + const oldLoserGroupRating = queryCurrentTeamRating({ + identifier: loserTeamIdentifier, + season, + }); + const [[winnerGroupNew], [loserGroupNew]] = rate( + [[oldWinnerGroupRating.rating], [oldLoserGroupRating.rating]], [ [ queryTeamPlayerRatingAverage({ @@ -83,23 +144,99 @@ export function calculateMatchSkills({ ], ); - result.push({ + newSkills.push({ groupMatchId: groupMatchId, identifier: winnerTeamIdentifier, - mu: winnerTeamNew.mu, + mu: winnerGroupNew.mu, season, - sigma: winnerTeamNew.sigma, + sigma: winnerGroupNew.sigma, userId: null, }); - result.push({ + newSkills.push({ groupMatchId: groupMatchId, identifier: loserTeamIdentifier, - mu: loserTeamNew.mu, + mu: loserGroupNew.mu, season, - sigma: loserTeamNew.sigma, + sigma: loserGroupNew.sigma, userId: null, }); + + differences.groups[winnerGroupId] = { + skillDifference: groupSkillDifference({ + oldRating: oldWinnerGroupRating.rating, + newRating: winnerGroupNew, + matchesCount: oldWinnerGroupRating.matchesCount, + }), + }; + differences.groups[loserGroupId] = { + skillDifference: groupSkillDifference({ + oldRating: oldLoserGroupRating.rating, + newRating: loserGroupNew, + matchesCount: oldLoserGroupRating.matchesCount, + }), + }; + } + + return { newSkills, differences }; +} + +function userSkillDifference({ + oldRating, + newRating, + matchesCount, +}: { + oldRating: Rating; + newRating: Rating; + matchesCount: number; +}): UserSkillDifference { + const calculated = matchesCount >= MATCHES_COUNT_NEEDED_FOR_LEADERBOARD; + + if (calculated) { + return { + calculated, + spDiff: roundToNDecimalPlaces( + ordinalToSp(ordinal(newRating)) - ordinalToSp(ordinal(oldRating)), + ), + }; + } + + return { + calculated, + matchesCount: matchesCount + 1, + matchesCountNeeded: MATCHES_COUNT_NEEDED_FOR_LEADERBOARD, + newSp: + matchesCount + 1 === MATCHES_COUNT_NEEDED_FOR_LEADERBOARD + ? ordinalToSp(ordinal(newRating)) + : undefined, + }; +} + +function groupSkillDifference({ + oldRating, + newRating, + matchesCount, +}: { + oldRating: Rating; + newRating: Rating; + matchesCount: number; +}): GroupSkillDifference { + const calculated = matchesCount >= MATCHES_COUNT_NEEDED_FOR_LEADERBOARD; + + if (calculated) { + return { + calculated, + newSp: ordinalToSp(ordinal(newRating)), + oldSp: ordinalToSp(ordinal(oldRating)), + }; } - return result; + return { + calculated, + matchesCount: matchesCount + 1, + matchesCountNeeded: MATCHES_COUNT_NEEDED_FOR_LEADERBOARD, + newSp: + matchesCount + 1 === MATCHES_COUNT_NEEDED_FOR_LEADERBOARD + ? ordinalToSp(ordinal(newRating)) + : undefined, + }; } diff --git a/app/features/sendouq/q-types.ts b/app/features/sendouq/q-types.ts index af61958306..648bf2d180 100644 --- a/app/features/sendouq/q-types.ts +++ b/app/features/sendouq/q-types.ts @@ -1,6 +1,13 @@ -import type { Group, GroupMember, PlusTier, User } from "~/db/types"; +import type { + Group, + GroupMember, + ParsedMemento, + PlusTier, + User, +} from "~/db/types"; import type { MainWeaponId } from "~/modules/in-game-lists"; import type { TieredSkill } from "../mmr/tiered.server"; +import type { GroupForMatch } from "./queries/groupForMatch.server"; export type LookingGroup = { id: number; @@ -8,6 +15,8 @@ export type LookingGroup = { tier?: TieredSkill["tier"]; isReplay?: boolean; isLiked?: boolean; + team?: GroupForMatch["team"]; + skillDifference?: ParsedMemento["groups"][number]["skillDifference"]; members?: { id: number; discordId: string; @@ -21,6 +30,7 @@ export type LookingGroup = { vc?: User["vc"]; languages?: string[]; chatNameColor: string | null; + skillDifference?: ParsedMemento["users"][number]["skillDifference"]; }[]; }; diff --git a/app/features/sendouq/queries/addSkills.server.ts b/app/features/sendouq/queries/addSkills.server.ts index 089cdbbf70..e5a1472bf5 100644 --- a/app/features/sendouq/queries/addSkills.server.ts +++ b/app/features/sendouq/queries/addSkills.server.ts @@ -1,7 +1,8 @@ import { ordinal } from "openskill"; import { sql } from "~/db/sql"; -import type { Skill } from "~/db/types"; +import type { ParsedMemento, Skill } from "~/db/types"; import { identifierToUserIds } from "~/features/mmr/mmr-utils"; +import type { MementoSkillDifferences } from "../core/skills.server"; const getStm = (type: "user" | "team") => sql.prepare(/* sql */ ` @@ -40,12 +41,26 @@ const addSkillTeamUserStm = sql.prepare(/* sql */ ` const userStm = getStm("user"); const teamStm = getStm("team"); -export function addSkills( +const updateMatchMementoStm = sql.prepare(/* sql */ ` + update "GroupMatch" + set "memento" = @memento + where "id" = @id +`); + +export function addSkills({ + groupMatchId, + skills, + oldMatchMemento, + differences, +}: { + groupMatchId: number; skills: Pick< Skill, "groupMatchId" | "identifier" | "mu" | "season" | "sigma" | "userId" - >[], -) { + >[]; + oldMatchMemento: ParsedMemento; + differences: MementoSkillDifferences; +}) { for (const skill of skills) { const stm = skill.userId ? userStm : teamStm; const insertedSkill = stm.get({ @@ -62,4 +77,25 @@ export function addSkills( } } } + + const newMemento: ParsedMemento = { groups: {}, users: {} }; + + for (const [key, value] of Object.entries(oldMatchMemento.users)) { + newMemento.users[key as any] = { + ...value, + skillDifference: differences.users[key as any]?.skillDifference, + }; + } + + for (const [key, value] of Object.entries(oldMatchMemento.groups)) { + newMemento.groups[key as any] = { + ...value, + skillDifference: differences.groups[key as any]?.skillDifference, + }; + } + + updateMatchMementoStm.run({ + id: groupMatchId, + memento: JSON.stringify(newMemento), + }); } diff --git a/app/features/sendouq/queries/findMatchById.server.ts b/app/features/sendouq/queries/findMatchById.server.ts index a0f27a0ba6..d7cdd0890c 100644 --- a/app/features/sendouq/queries/findMatchById.server.ts +++ b/app/features/sendouq/queries/findMatchById.server.ts @@ -1,5 +1,5 @@ import { sql } from "~/db/sql"; -import type { GroupMatch, GroupMatchMap } from "~/db/types"; +import type { GroupMatch, GroupMatchMap, ParsedMemento } from "~/db/types"; import { parseDBJsonArray } from "~/utils/sql"; const stm = sql.prepare(/* sql */ ` @@ -11,6 +11,7 @@ const stm = sql.prepare(/* sql */ ` "GroupMatch"."reportedAt", "GroupMatch"."reportedByUserId", "GroupMatch"."chatCode", + "GroupMatch"."memento", (select exists (select 1 from "Skill" where "Skill"."groupMatchId" = @id)) as "isLocked", json_group_array( json_object( @@ -37,6 +38,7 @@ export interface MatchById { reportedByUserId: GroupMatch["reportedByUserId"]; chatCode: GroupMatch["chatCode"]; isLocked: boolean; + memento: ParsedMemento; mapList: Array< Pick >; @@ -50,5 +52,6 @@ export function findMatchById(id: number) { ...row, mapList: parseDBJsonArray(row.mapList), isLocked: Boolean(row.isLocked), + memento: row.memento ? JSON.parse(row.memento) : null, } as MatchById; } diff --git a/app/features/sendouq/queries/groupForMatch.server.ts b/app/features/sendouq/queries/groupForMatch.server.ts index daaa1d6716..dfae836a36 100644 --- a/app/features/sendouq/queries/groupForMatch.server.ts +++ b/app/features/sendouq/queries/groupForMatch.server.ts @@ -1,5 +1,11 @@ import { sql } from "~/db/sql"; -import type { Group, GroupMember, ParsedMemento, User } from "~/db/types"; +import type { + Group, + GroupMember, + ParsedMemento, + User, + UserSkillDifference, +} from "~/db/types"; import type { MainWeaponId } from "~/modules/in-game-lists"; import { parseDBArray } from "~/utils/sql"; @@ -53,6 +59,8 @@ const stm = sql.prepare(/* sql */ ` export interface GroupForMatch { id: Group["id"]; chatCode: Group["chatCode"]; + tier?: ParsedMemento["groups"][number]["tier"]; + skillDifference?: ParsedMemento["groups"][number]["skillDifference"]; team?: { name: string; avatarUrl: string | null; @@ -70,6 +78,7 @@ export interface GroupForMatch { chatNameColor: string | null; vc: User["vc"]; languages: string[]; + skillDifference?: UserSkillDifference; }>; } @@ -85,6 +94,7 @@ export function groupForMatch(id: number) { id: row.id, chatCode: row.chatCode, tier: memento?.groups[row.id]?.tier, + skillDifference: memento?.groups[row.id]?.skillDifference, team: row.teamName ? { name: row.teamName, @@ -98,6 +108,7 @@ export function groupForMatch(id: number) { languages: m.languages ? m.languages.split(",") : [], plusTier: memento?.users[m.id]?.plusTier, skill: memento?.users[m.id]?.skill, + skillDifference: memento?.users[m.id]?.skillDifference, })), } as GroupForMatch; } diff --git a/app/features/sendouq/routes/q.match.$id.tsx b/app/features/sendouq/routes/q.match.$id.tsx index 741a5f5e0b..1d433a6051 100644 --- a/app/features/sendouq/routes/q.match.$id.tsx +++ b/app/features/sendouq/routes/q.match.$id.tsx @@ -132,9 +132,9 @@ export const action = async ({ request, params }: ActionArgs) => { ); const winner = winnersArrayToWinner(data.winners); - const winnerTeamId = + const winnerGroupId = winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId; - const loserTeamId = + const loserGroupId = winner === "ALPHA" ? match.bravoGroupId : match.alphaGroupId; // when admin reports match gets locked right away @@ -156,14 +156,16 @@ export const action = async ({ request, params }: ActionArgs) => { const matchIsBeingCanceled = data.winners.length === 0; - const newSkills = + const { newSkills, differences } = compared === "SAME" && !matchIsBeingCanceled ? calculateMatchSkills({ groupMatchId: match.id, - winner: groupForMatch(winnerTeamId)!.members.map((m) => m.id), - loser: groupForMatch(loserTeamId)!.members.map((m) => m.id), + winner: groupForMatch(winnerGroupId)!.members.map((m) => m.id), + loser: groupForMatch(loserGroupId)!.members.map((m) => m.id), + winnerGroupId, + loserGroupId, }) - : null; + : { newSkills: null, differences: null }; const shouldLockMatchWithoutChangingRecords = compared === "SAME" && matchIsBeingCanceled; @@ -190,7 +192,12 @@ export const action = async ({ request, params }: ActionArgs) => { addPlayerResults( summarizePlayerResults({ match, members, winners: data.winners }), ); - addSkills(newSkills); + addSkills({ + skills: newSkills, + differences, + groupMatchId: match.id, + oldMatchMemento: match.memento, + }); cache.delete(USER_SKILLS_CACHE_KEY); } if (shouldLockMatchWithoutChangingRecords) { diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index df8a10db8a..d9945fa18a 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -192,9 +192,11 @@ export const action: ActionFunction = async ({ params, request }) => { finalStandings: _finalStandings, results, queryCurrentTeamRating: (identifier) => - queryCurrentTeamRating({ identifier, season: _currentSeason.nth }), + queryCurrentTeamRating({ identifier, season: _currentSeason.nth }) + .rating, queryCurrentUserRating: (userId) => - queryCurrentUserRating({ userId, season: _currentSeason.nth }), + queryCurrentUserRating({ userId, season: _currentSeason.nth }) + .rating, queryTeamPlayerRatingAverage: (identifier) => queryTeamPlayerRatingAverage({ identifier, From 3dfda1ff391ccf7485a81c7f3fb9877e2a8264cd Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 3 Oct 2023 21:21:26 +0300 Subject: [PATCH 9/9] Fix crash if match has no memento when inserting skill --- app/features/sendouq/queries/addSkills.server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/features/sendouq/queries/addSkills.server.ts b/app/features/sendouq/queries/addSkills.server.ts index e5a1472bf5..9c76161e4a 100644 --- a/app/features/sendouq/queries/addSkills.server.ts +++ b/app/features/sendouq/queries/addSkills.server.ts @@ -78,6 +78,8 @@ export function addSkills({ } } + if (!oldMatchMemento) return; + const newMemento: ParsedMemento = { groups: {}, users: {} }; for (const [key, value] of Object.entries(oldMatchMemento.users)) {