From 218c854ffbd7ecd449626acc5e6792cba0322eba Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Tue, 3 Oct 2023 21:25:03 +0300 Subject: [PATCH] SendouQ match page show & save powers + plus tier status (#1519) * Initial * New group cards initial * Remove unused prop * Vc * Styling for after the match is locked * Team * Impersonate always in dev * Diff * Fix crash if match has no memento when inserting skill --- app/db/seed/index.ts | 12 +- app/db/types.ts | 39 ++++ app/features/mmr/mmr-utils.server.ts | 12 +- .../findCurrentSkillByUserId.server.ts | 8 +- ...findCurrentTeamSkillByIdentifier.server.ts | 8 +- app/features/sendouq/components/GroupCard.tsx | 123 +++++++++- app/features/sendouq/core/groups.server.ts | 2 +- app/features/sendouq/core/match.server.ts | 41 +++- app/features/sendouq/core/skills.server.ts | 173 ++++++++++++-- app/features/sendouq/q-types.ts | 14 +- app/features/sendouq/q.css | 34 ++- .../sendouq/queries/addSkills.server.ts | 46 +++- .../sendouq/queries/createMatch.server.ts | 11 +- .../sendouq/queries/findMatchById.server.ts | 8 +- .../sendouq/queries/groupForMatch.server.ts | 27 ++- app/features/sendouq/routes/q.looking.tsx | 31 +-- app/features/sendouq/routes/q.match.$id.tsx | 213 +++++++----------- app/features/sendouq/routes/q.preparing.tsx | 7 +- .../routes/to.$id.brackets.tsx | 6 +- app/routes/admin.tsx | 8 +- app/styles/utils.css | 4 + migrations/037-add-memento.js | 5 + 22 files changed, 595 insertions(+), 237 deletions(-) create mode 100644 migrations/037-add-memento.js diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index c456f3ec0..599ea93d8 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, @@ -1772,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) => ({ @@ -1794,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 667e276ab..9880be0d5 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,43 @@ 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; + } + >; +}; export interface GroupMatch { id: number; alphaGroupId: number; @@ -551,6 +589,7 @@ export interface GroupMatch { reportedAt: number | null; reportedByUserId: number | null; chatCode: string | null; + memento: string | null; } export interface GroupMatchMap { diff --git a/app/features/mmr/mmr-utils.server.ts b/app/features/mmr/mmr-utils.server.ts index 238d213e7..13b92479b 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 3b2425030..cb38e19c8 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 ef9ca8753..6af6fb766 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 92bded6a9..27dd90e6c 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, ParsedMemento } from "~/db/types"; import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils"; import type { TieredSkill } from "~/features/mmr/tiered.server"; import { useTranslation } from "~/hooks/useTranslation"; @@ -27,18 +27,25 @@ export function GroupCard({ ownRole, ownGroup = false, isExpired = false, + displayOnly = false, + hideVc = false, + hideWeapons = false, }: { group: LookingGroup; action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP"; - mapListPreference?: Group["mapListPreference"]; ownRole?: GroupMemberType["role"]; ownGroup?: boolean; isExpired?: boolean; + displayOnly?: boolean; + hideVc?: boolean; + hideWeapons?: boolean; }) { const fetcher = useFetcher(); return ( -
+
); })} @@ -63,7 +73,7 @@ export function GroupCard({ }) : null}
- {group.tier ? ( + {group.tier && !displayOnly ? (
@@ -77,6 +87,16 @@ export function GroupCard({
) : null} + {group.tier && displayOnly ? ( +
+ + {group.tier.name} + {group.tier.isPlus ? "+" : ""} +
+ ) : null} + {group.skillDifference ? ( + + ) : null} {action && (ownRole === "OWNER" || ownRole === "MANAGER") && !isExpired ? ( @@ -119,9 +139,15 @@ export function GroupCard({ function GroupMember({ member, showActions, + displayOnly, + hideVc, + hideWeapons, }: { member: NonNullable[number]; showActions: boolean; + displayOnly?: boolean; + hideVc?: boolean; + hideWeapons?: boolean; }) { return (
@@ -135,13 +161,15 @@ function GroupMember({ {member.discordName}
- {showActions ? : null} + {showActions || displayOnly ? ( + + ) : null} {member.skill ? : null}
- {member.vc ? ( + {member.vc && !hideVc ? (
@@ -153,7 +181,7 @@ function GroupMember({
) : null}
- {member.weapons ? ( + {member.weapons && member.weapons.length > 0 && !hideWeapons ? (
{member.weapons?.map((weapon) => { return ( @@ -167,20 +195,99 @@ 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}) +
+ ); +} + 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/core/groups.server.ts b/app/features/sendouq/core/groups.server.ts index 032bfc400..a48375f9b 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 7ba896e5e..0dd3688e3 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/core/skills.server.ts b/app/features/sendouq/core/skills.server.ts index eab7e8e92..69b0de3f7 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 result; + 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 { + 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 f8fde7e99..648bf2d18 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,11 +15,13 @@ export type LookingGroup = { tier?: TieredSkill["tier"]; isReplay?: boolean; isLiked?: boolean; + team?: GroupForMatch["team"]; + skillDifference?: ParsedMemento["groups"][number]["skillDifference"]; members?: { id: number; discordId: string; discordName: string; - discordAvatar: string; + discordAvatar: string | null; customUrl?: User["customUrl"]; plusTier?: PlusTier["tier"]; role: GroupMember["role"]; @@ -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/q.css b/app/features/sendouq/q.css index aa5e9946a..2a29c7300 100644 --- a/app/features/sendouq/q.css +++ b/app/features/sendouq/q.css @@ -131,6 +131,13 @@ display: flex; 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 { @@ -205,6 +212,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 +252,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 +264,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/addSkills.server.ts b/app/features/sendouq/queries/addSkills.server.ts index 089cdbbf7..9c76161e4 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,27 @@ export function addSkills( } } } + + if (!oldMatchMemento) return; + + 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/createMatch.server.ts b/app/features/sendouq/queries/createMatch.server.ts index afd12ac2d..c36eb1765 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/queries/findMatchById.server.ts b/app/features/sendouq/queries/findMatchById.server.ts index 2e775b0d1..d7cdd0890 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( @@ -36,7 +37,8 @@ export interface MatchById { reportedAt: GroupMatch["reportedAt"]; reportedByUserId: GroupMatch["reportedByUserId"]; chatCode: GroupMatch["chatCode"]; - isLocked: number; + isLocked: boolean; + memento: ParsedMemento; mapList: Array< Pick >; @@ -49,5 +51,7 @@ export function findMatchById(id: number) { return { ...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 ed2542f31..dfae836a3 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, 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"; @@ -18,6 +24,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", @@ -30,6 +37,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) ) @@ -40,6 +49,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" @@ -49,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; @@ -64,6 +76,9 @@ export interface GroupForMatch { inGameName: User["inGameName"]; weapons: Array; chatNameColor: string | null; + vc: User["vc"]; + languages: string[]; + skillDifference?: UserSkillDifference; }>; } @@ -71,9 +86,15 @@ 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, + skillDifference: memento?.groups[row.id]?.skillDifference, team: row.teamName ? { name: row.teamName, @@ -84,6 +105,10 @@ 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, + skillDifference: memento?.users[m.id]?.skillDifference, })), } as GroupForMatch; } diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx index fae03b16b..2f5c60350 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)); @@ -491,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 ( @@ -596,18 +585,11 @@ function Groups() { element: (
{data.groups.likesReceived.map((group) => { - const { mapListPreference } = groupAfterMorph({ - liker: "THEM", - ourGroup: data.groups.own, - theirGroup: group, - }); - return ( @@ -632,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 62a478677..1d433a605 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 { @@ -45,45 +54,33 @@ import { 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 { 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 { addReportedWeapons } from "../queries/addReportedWeapons.server"; -import { addSkills } from "../queries/addSkills.server"; -import { createGroupFromPreviousGroup } from "../queries/createGroup.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 { 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 { 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"; +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 { groupForMatch } from "../queries/groupForMatch.server"; +import { reportScore } from "../queries/reportScore.server"; +import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server"; +import { setGroupAsInactive } from "../queries/setGroupAsInactive.server"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: styles }]; @@ -135,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 @@ -159,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; @@ -193,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) { @@ -323,6 +327,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, @@ -434,16 +443,37 @@ export default function QMatchPage() { "with-chat": data.matchChatCode || data.groupChatCode, })} > - - + {[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 ? ( ; - 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, @@ -1043,7 +993,6 @@ function MapListMap({ canReportScore: boolean; weapons?: ReportedWeapon[]; }) { - const user = useUser(); const data = useLoaderData(); const { t } = useTranslation(["game-misc", "tournament"]); @@ -1107,15 +1056,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 (!data.groupMemberOf) return ""; - if (!ownSide) 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 ac7c530a2..28f5df6fa 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/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index df8a10db8..d9945fa18 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, diff --git a/app/routes/admin.tsx b/app/routes/admin.tsx index 394ab0b53..8543a4dbd 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} diff --git a/app/styles/utils.css b/app/styles/utils.css index 02feb6832..d2f6eb9af 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%; } diff --git a/migrations/037-add-memento.js b/migrations/037-add-memento.js new file mode 100644 index 000000000..a6ebc3176 --- /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(); + })(); +};