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:
Kalle
2023-10-03 21:25:03 +03:00
committed by GitHub
parent 4c6254d3e2
commit 218c854ffb
22 changed files with 595 additions and 237 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" ? (

View File

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

View File

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

View File

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

View File

@@ -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"];
}[];
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"}
/>

View File

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

View File

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

View File

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

View File

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

View File

@@ -114,6 +114,10 @@
font-weight: var(--bold);
}
.h-full {
height: 100%;
}
.w-full {
width: 100%;
}

View File

@@ -0,0 +1,5 @@
module.exports.up = function (db) {
db.transaction(() => {
db.prepare(/* sql */ `alter table "GroupMatch" add "memento" text`).run();
})();
};