mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-22 02:55:52 -05:00
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
This commit is contained in:
@@ -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 }));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<Skill, "mu" | "sigma"> | null;
|
||||
return stm.get({ userId, season }) as Pick<
|
||||
Skill,
|
||||
"mu" | "sigma" | "matchesCount"
|
||||
> | null;
|
||||
}
|
||||
|
||||
@@ -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<Skill, "mu" | "sigma"> | null;
|
||||
return stm.get({ identifier, season }) as Pick<
|
||||
Skill,
|
||||
"mu" | "sigma" | "matchesCount"
|
||||
> | null;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<section className="q__group">
|
||||
<section
|
||||
className={clsx("q__group", { "q__group__display-only": displayOnly })}
|
||||
>
|
||||
<div
|
||||
className={clsx("stack md", {
|
||||
"horizontal justify-center": !group.members,
|
||||
@@ -50,6 +57,9 @@ export function GroupCard({
|
||||
member={member}
|
||||
showActions={ownGroup && ownRole === "OWNER"}
|
||||
key={member.discordId}
|
||||
displayOnly={displayOnly}
|
||||
hideVc={hideVc}
|
||||
hideWeapons={hideWeapons}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -63,7 +73,7 @@ export function GroupCard({
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
{group.tier ? (
|
||||
{group.tier && !displayOnly ? (
|
||||
<div className="stack xs text-lighter font-bold items-center justify-center text-xs">
|
||||
<TierImage tier={group.tier} width={100} />
|
||||
<div>
|
||||
@@ -77,6 +87,16 @@ export function GroupCard({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{group.tier && displayOnly ? (
|
||||
<div className="q__group__display-group-tier">
|
||||
<TierImage tier={group.tier} width={38} />
|
||||
{group.tier.name}
|
||||
{group.tier.isPlus ? "+" : ""}
|
||||
</div>
|
||||
) : null}
|
||||
{group.skillDifference ? (
|
||||
<GroupSkillDifference skillDifference={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<LookingGroup["members"]>[number];
|
||||
showActions: boolean;
|
||||
displayOnly?: boolean;
|
||||
hideVc?: boolean;
|
||||
hideWeapons?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="stack xxs">
|
||||
@@ -135,13 +161,15 @@ function GroupMember({
|
||||
<span className="q__group-member__name">{member.discordName}</span>
|
||||
</Link>
|
||||
<div className="ml-auto stack horizontal sm items-center">
|
||||
{showActions ? <MemberRoleManager member={member} /> : null}
|
||||
{showActions || displayOnly ? (
|
||||
<MemberRoleManager member={member} displayOnly={displayOnly} />
|
||||
) : null}
|
||||
{member.skill ? <TierInfo skill={member.skill} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="stack horizontal justify-between">
|
||||
<div className="stack horizontal xxs">
|
||||
{member.vc ? (
|
||||
{member.vc && !hideVc ? (
|
||||
<div className="q__group-member__extra-info">
|
||||
<VoiceChatInfo member={member} />
|
||||
</div>
|
||||
@@ -153,7 +181,7 @@ function GroupMember({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{member.weapons ? (
|
||||
{member.weapons && member.weapons.length > 0 && !hideWeapons ? (
|
||||
<div className="q__group-member__extra-info">
|
||||
{member.weapons?.map((weapon) => {
|
||||
return (
|
||||
@@ -167,20 +195,99 @@ function GroupMember({
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{member.skillDifference ? (
|
||||
<MemberSkillDifference skillDifference={member.skillDifference} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupSkillDifference({
|
||||
skillDifference,
|
||||
}: {
|
||||
skillDifference: NonNullable<
|
||||
ParsedMemento["groups"][number]["skillDifference"]
|
||||
>;
|
||||
}) {
|
||||
if (skillDifference.calculated) {
|
||||
return (
|
||||
<div className="text-center font-semi-bold">
|
||||
Team SP {skillDifference.oldSp} ➜ {skillDifference.newSp}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skillDifference.newSp) {
|
||||
return (
|
||||
<div className="text-center font-semi-bold">
|
||||
Team SP calculated: {skillDifference.newSp}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center font-semi-bold">
|
||||
Team SP calculating... ({skillDifference.matchesCount}/
|
||||
{skillDifference.matchesCountNeeded})
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberSkillDifference({
|
||||
skillDifference,
|
||||
}: {
|
||||
skillDifference: NonNullable<
|
||||
ParsedMemento["users"][number]["skillDifference"]
|
||||
>;
|
||||
}) {
|
||||
if (skillDifference.calculated) {
|
||||
if (skillDifference.spDiff === 0) return null;
|
||||
|
||||
const symbol =
|
||||
skillDifference.spDiff > 0 ? (
|
||||
<span className="text-success">▲</span>
|
||||
) : (
|
||||
<span className="text-warning">▼</span>
|
||||
);
|
||||
return (
|
||||
<div className="q__group-member__extra-info">
|
||||
{symbol}
|
||||
{Math.abs(skillDifference.spDiff)}SP
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (skillDifference.matchesCount === skillDifference.matchesCountNeeded) {
|
||||
return (
|
||||
<div className="q__group-member__extra-info">
|
||||
<span className="text-lighter">Calculated:</span>{" "}
|
||||
{skillDifference.newSp ? <>{skillDifference.newSp}SP</> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="q__group-member__extra-info">
|
||||
<span className="text-lighter">Calculating...</span> (
|
||||
{skillDifference.matchesCount}/{skillDifference.matchesCountNeeded})
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemberRoleManager({
|
||||
member,
|
||||
displayOnly,
|
||||
}: {
|
||||
member: NonNullable<LookingGroup["members"]>[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 (
|
||||
<Popover
|
||||
buttonChildren={
|
||||
@@ -193,7 +300,7 @@ function MemberRoleManager({
|
||||
>
|
||||
<div className="stack md items-center">
|
||||
<div>{t(`q:roles.${member.role}`)}</div>
|
||||
{member.role !== "OWNER" ? (
|
||||
{member.role !== "OWNER" && !displayOnly ? (
|
||||
<fetcher.Form method="post" action={SENDOUQ_LOOKING_PAGE}>
|
||||
<input type="hidden" name="userId" value={member.id} />
|
||||
{member.role === "REGULAR" ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ParsedMemento> {
|
||||
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,
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
}[];
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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<GroupMatchMap, "id" | "mode" | "stageId" | "source" | "winnerGroupId">
|
||||
>;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<MainWeaponId>;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
<div className="stack md">
|
||||
<GroupCard
|
||||
group={data.groups.own}
|
||||
mapListPreference={data.groups.own.mapListPreference}
|
||||
ownRole={data.role}
|
||||
ownGroup
|
||||
/>
|
||||
<GroupCard group={data.groups.own} ownRole={data.role} ownGroup />
|
||||
{ownGroup.inviteCode ? (
|
||||
<MemberAdder
|
||||
inviteCode={ownGroup.inviteCode}
|
||||
@@ -570,18 +566,11 @@ function Groups() {
|
||||
element: (
|
||||
<div className="stack sm">
|
||||
{data.groups.neutral.map((group) => {
|
||||
const { mapListPreference } = groupAfterMorph({
|
||||
liker: "US",
|
||||
ourGroup: data.groups.own,
|
||||
theirGroup: group,
|
||||
});
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={group.isLiked ? "UNLIKE" : "LIKE"}
|
||||
mapListPreference={mapListPreference}
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
/>
|
||||
@@ -596,18 +585,11 @@ function Groups() {
|
||||
element: (
|
||||
<div className="stack sm">
|
||||
{data.groups.likesReceived.map((group) => {
|
||||
const { mapListPreference } = groupAfterMorph({
|
||||
liker: "THEM",
|
||||
ourGroup: data.groups.own,
|
||||
theirGroup: group,
|
||||
});
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
|
||||
mapListPreference={mapListPreference}
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
/>
|
||||
@@ -632,18 +614,11 @@ function Groups() {
|
||||
{!isMobile ? (
|
||||
<div className="stack sm q__groups-container__right">
|
||||
{data.groups.likesReceived.map((group) => {
|
||||
const { mapListPreference } = groupAfterMorph({
|
||||
liker: "THEM",
|
||||
ourGroup: data.groups.own,
|
||||
theirGroup: group,
|
||||
});
|
||||
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
|
||||
mapListPreference={mapListPreference}
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
})}
|
||||
>
|
||||
<MatchGroup
|
||||
group={data.groupAlpha}
|
||||
side="ALPHA"
|
||||
showWeapons={!data.match.isLocked}
|
||||
/>
|
||||
<MatchGroup
|
||||
group={data.groupBravo}
|
||||
side="BRAVO"
|
||||
showWeapons={!data.match.isLocked}
|
||||
/>
|
||||
{[data.groupAlpha, data.groupBravo].map((group, i) => {
|
||||
const side = i === 0 ? "ALPHA" : "BRAVO";
|
||||
|
||||
return (
|
||||
<div className="stack sm text-lighter text-xs" key={group.id}>
|
||||
<div className="stack horizontal justify-between items-center">
|
||||
{i === 0 ? "Alpha" : "Bravo"}
|
||||
{group.team ? (
|
||||
<Link
|
||||
to={teamPage(group.team.customUrl)}
|
||||
className="stack horizontal items-center xs font-bold"
|
||||
>
|
||||
{group.team.avatarUrl ? (
|
||||
<Avatar
|
||||
url={userSubmittedImage(group.team.avatarUrl)}
|
||||
size="xxs"
|
||||
/>
|
||||
) : null}
|
||||
{group.team.name}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<GroupCard
|
||||
group={group}
|
||||
displayOnly
|
||||
hideVc={data.match.isLocked || data.groupMemberOf !== side}
|
||||
hideWeapons={data.match.isLocked}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{chatRooms.length > 0 ? (
|
||||
<ConnectedChat
|
||||
users={chatUsers}
|
||||
@@ -863,86 +893,6 @@ function AfterMatchActions({
|
||||
);
|
||||
}
|
||||
|
||||
function MatchGroup({
|
||||
group,
|
||||
side,
|
||||
showWeapons,
|
||||
}: {
|
||||
group: Omit<GroupForMatch, "chatCode">;
|
||||
side: "ALPHA" | "BRAVO";
|
||||
showWeapons: boolean;
|
||||
}) {
|
||||
const roleString = (role: GroupMember["role"]) => {
|
||||
if (role === "REGULAR") return "";
|
||||
|
||||
return ` (${role.toLowerCase()})`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack sm items-center">
|
||||
<h3 className="text-lighter">{side}</h3>
|
||||
<div className="stack sm q-match__members-container">
|
||||
{group.team ? (
|
||||
<Link
|
||||
to={teamPage(group.team.customUrl)}
|
||||
className="stack horizontal xs font-bold"
|
||||
>
|
||||
{group.team.avatarUrl ? (
|
||||
<Avatar
|
||||
url={userSubmittedImage(group.team.avatarUrl)}
|
||||
size="xxs"
|
||||
/>
|
||||
) : null}
|
||||
{group.team.name}
|
||||
</Link>
|
||||
) : null}
|
||||
{group.members.map((member) => (
|
||||
<React.Fragment key={member.discordId}>
|
||||
<Link
|
||||
to={userPage(member)}
|
||||
className="stack horizontal xs items-center"
|
||||
title={`${member.discordName}${roleString(member.role)}`}
|
||||
>
|
||||
<Avatar size="xxs" user={member} />
|
||||
<div className="text-sm text-main-forced font-body">
|
||||
{member.inGameName ? (
|
||||
<>
|
||||
<span className="text-lighter font-semi-bold">IGN:</span>{" "}
|
||||
{inGameNameWithoutDiscriminator(member.inGameName)}
|
||||
</>
|
||||
) : (
|
||||
member.discordName
|
||||
)}
|
||||
</div>
|
||||
{member.role === "OWNER" ? (
|
||||
<StarFilledIcon className="q-match__star-icon" />
|
||||
) : null}
|
||||
{member.role === "MANAGER" ? (
|
||||
<StarIcon className="q-match__star-icon" />
|
||||
) : null}
|
||||
</Link>
|
||||
{showWeapons && member.weapons.length > 0 ? (
|
||||
<div className="q__group-member-weapons">
|
||||
{member.weapons.map((weapon) => {
|
||||
return (
|
||||
<WeaponImage
|
||||
key={weapon}
|
||||
weaponSplId={weapon}
|
||||
variant="badge"
|
||||
size={24}
|
||||
containerClassName="q__group-member-weapon bg-theme-transparent-important"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapList({
|
||||
canReportScore,
|
||||
isResubmission,
|
||||
@@ -1043,7 +993,6 @@ function MapListMap({
|
||||
canReportScore: boolean;
|
||||
weapons?: ReportedWeapon[];
|
||||
}) {
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
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 (
|
||||
|
||||
@@ -144,12 +144,7 @@ export default function QPreparingPage() {
|
||||
return (
|
||||
<Main className="stack lg items-center">
|
||||
<div className="q-preparing__card-container">
|
||||
<GroupCard
|
||||
group={data.group}
|
||||
mapListPreference={data.group.mapListPreference}
|
||||
ownRole={data.role}
|
||||
ownGroup
|
||||
/>
|
||||
<GroupCard group={data.group} ownRole={data.role} ownGroup />
|
||||
</div>
|
||||
{data.group.members.length < FULL_GROUP_SIZE &&
|
||||
hasGroupManagerPerms(data.role) ? (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<AdminPageLoaderData>({
|
||||
isImpersonating: await isImpersonating(request),
|
||||
@@ -157,7 +159,9 @@ export default function AdminPage() {
|
||||
{isMod(user) ? <GiveArtist /> : null}
|
||||
{isMod(user) ? <GiveVideoAdder /> : null}
|
||||
|
||||
{isAdmin(user) ? <Impersonate /> : null}
|
||||
{process.env.NODE_ENV !== "production" || isAdmin(user) ? (
|
||||
<Impersonate />
|
||||
) : null}
|
||||
{isAdmin(user) ? <MigrateUser /> : null}
|
||||
{isAdmin(user) ? <ForcePatron /> : null}
|
||||
{isAdmin(user) ? <RefreshPlusTiers /> : null}
|
||||
|
||||
@@ -114,6 +114,10 @@
|
||||
font-weight: var(--bold);
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
5
migrations/037-add-memento.js
Normal file
5
migrations/037-add-memento.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports.up = function (db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(/* sql */ `alter table "GroupMatch" add "memento" text`).run();
|
||||
})();
|
||||
};
|
||||
Reference in New Issue
Block a user