sendou.ink/app/features/tournament-bracket/core/summarizer.server.ts
Kalle e7bbb565be
SendouQ (#1455)
* Tables

* Clocks

* Maplist preference selector

* Fix SSR

* Nav icon

* RankedOrScrim

* Map pool

* Create group

* Redirect logic

* Persist map pool

* Advance from preparing page

* Rename query

* Fix merge

* Fix migration order

* Seed groups

* Find looking groups SQL

* Renders something

* More UI work

* Back to 30min

* Likes/dislikes

* Always return own group

* Fix like order

* 3 tc/rm/cb -> 2

* Show only 3 weapons

* Pass group size

* Handle both liked and liked by same group

* Fix SQL

* Group preference frontend work

* Morphing

* Styling

* Don't show group controls if not manager

* Give/remove manager

* Leave group

* Leave with confirm

* Delete likes when morphing groups

* Clocks consistency

* Remove bad invariant

* Persist settings to local storage

* Fix initial value flashing

* Fix never resolving loading indicator

* REFRESH_GROUP

* Flip animations

* Tweaks

* Auto refresh logic

* Groups of 4 seed

* Reduce throwing

* Load full groups initial

* Create match

* Match UI initial

* Score reporter initial

* Push footer down on match page

* Score reporter knows when set ended

* Score reporting untested

* Show score after report

* Align better

* Look again with same group functionality

* More migrations

* Team on match page

* Show confirmer before reporting score

* Report weapons

* Report weapos again by admin + skill changing

* Handle no tiebreaker given to MapPool

* Remove unranked

* Remove support for "team id skill"

* no-wrap -> nowrap

* Preparing page work

* Use common GroupCard component

* Add some metas

* MemberAdder in looking page

* Fix GroupCard actions

* Fix SZ only map list including other modes

* Add season info

* Prompt login

* Joining team

* Manage group on preparing page

* Manage group on preparing page

* Seed past matches

* Add to seed

* No map list preference when full group + fix expiry

* Fix skill matchesCount calculation

* Tiers initial work

* Some progress on tiers

* Tiering logic

* MMR in group cards

* Name to challenge

* Team MMR

* Big team rank icons

* Adjust todos

* Match score report with confirm

* Allow regular members to report score

* Handle reporting weapons edge cases

* Add tier images

* Improve GroupCard spacing

* Refactor looking page

* Looking mobile UI

* Calculate skill only for current season

* Divide groups visually when reporting weapons

* Fix match page weapons sorting

* Add cache to user skills+tier calculation

* Admin report match score

* Initial leaderboard

* Cached leaderboard

* Weapon category lb's

* Populate SkillTeamUser in SendouQ

* Team leaderboard filtered down

* Add TODOs

* Seasons initlal

* Season weapons initial

* Weapons stylized

* Show rest weapons as +

* Hide peak if same as current

* Load matches SQL initial

* Season matches UI initial

* Take user id in account

* Add weapons

* Paginated matches

* Fix pages count logic

* Scroll top on data change

* Day headers for matches

* Link from user page to user seasons page

* Summarize maps + ui initial

* Map stats

* Player info tabs

* MMR chart

* Chart adjustments

* Handle basing team MMR on player MMR

* Set initial MMR

* Add info about discord to match page

* Season support to tournaments

* Get tournament skills as well for the graph

* WIP

* New team rating logic + misc other

* tiered -> tiered.server

* Update season starting time

* TODOs

* Add rules page

* Hide elements correctly when off-season

* Fix crash when only one player with skill

* How-to video

* Fix StartRank showing when not logged in

* Make user leaderboard the default

* Make Skill season non-nullable

* Add suggested pass to match

* Add rule

* identifierToUserIds helper

* Fix tiers not showing
2023-08-12 22:42:54 +03:00

458 lines
13 KiB
TypeScript

import type {
MapResult,
PlayerResult,
Skill,
TournamentResult,
} from "~/db/types";
import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server";
import type { FindTeamsByTournamentIdItem } from "../../tournament/queries/findTeamsByTournamentId.server";
import invariant from "tiny-invariant";
import { removeDuplicates } from "~/utils/arrays";
import type { FinalStanding } from "./finalStandings.server";
import type { Rating } from "openskill/dist/types";
import { rate, userIdsToIdentifier } from "~/features/mmr/mmr-utils";
import shuffle from "just-shuffle";
import type { Unpacked } from "~/utils/types";
export interface TournamentSummary {
skills: Omit<
Skill,
"tournamentId" | "id" | "ordinal" | "season" | "groupMatchId"
>[];
mapResultDeltas: Omit<MapResult, "season">[];
playerResultDeltas: Omit<PlayerResult, "season">[];
tournamentResults: Omit<TournamentResult, "tournamentId" | "isHighlight">[];
}
type UserIdToTeamId = Record<number, number>;
type TeamsArg = Array<{
id: FindTeamsByTournamentIdItem["id"];
members: Array<
Pick<Unpacked<FindTeamsByTournamentIdItem["members"]>, "userId">
>;
}>;
type FinalStandingsArg = Array<{
placement: FinalStanding["placement"];
tournamentTeam: {
id: FinalStanding["tournamentTeam"]["id"];
};
players: Array<{ id: number }>;
}>;
export function tournamentSummary({
results,
teams,
finalStandings,
queryCurrentTeamRating,
queryTeamPlayerRatingAverage,
queryCurrentUserRating,
}: {
results: AllMatchResult[];
teams: TeamsArg;
finalStandings: FinalStandingsArg;
queryCurrentTeamRating: (identifier: string) => Rating;
queryTeamPlayerRatingAverage: (identifier: string) => Rating;
queryCurrentUserRating: (userId: number) => Rating;
}): TournamentSummary {
const userIdsToTeamId = userIdsToTeamIdRecord(teams);
return {
skills: skills({
results,
userIdsToTeamId,
queryCurrentTeamRating,
queryCurrentUserRating,
queryTeamPlayerRatingAverage,
}),
mapResultDeltas: mapResultDeltas({ results, userIdsToTeamId }),
playerResultDeltas: playerResultDeltas({ results, userIdsToTeamId }),
tournamentResults: tournamentResults({
participantCount: teams.length,
finalStandings,
}),
};
}
function userIdsToTeamIdRecord(teams: TeamsArg) {
const result: UserIdToTeamId = {};
for (const team of teams) {
for (const member of team.members) {
result[member.userId] = team.id;
}
}
return result;
}
function skills(args: {
results: AllMatchResult[];
userIdsToTeamId: UserIdToTeamId;
queryCurrentTeamRating: (identifier: string) => Rating;
queryTeamPlayerRatingAverage: (identifier: string) => Rating;
queryCurrentUserRating: (userId: number) => Rating;
}) {
const result: TournamentSummary["skills"] = [];
result.push(...calculateIndividualPlayerSkills(args));
result.push(...calculateTeamSkills(args));
return result;
}
function calculateIndividualPlayerSkills({
results,
userIdsToTeamId,
queryCurrentUserRating,
}: {
results: AllMatchResult[];
userIdsToTeamId: UserIdToTeamId;
queryCurrentUserRating: (userId: number) => Rating;
}) {
const userRatings = new Map<number, Rating>();
const userMatchesCount = new Map<number, number>();
const getUserRating = (userId: number) => {
const existingRating = userRatings.get(userId);
if (existingRating) return existingRating;
return queryCurrentUserRating(userId);
};
for (const match of results) {
const winnerTeamId =
match.opponentOne.result === "win"
? match.opponentOne.id
: match.opponentTwo.id;
const allUserIds = removeDuplicates(match.maps.flatMap((m) => m.userIds));
const loserUserIds = allUserIds.filter(
(userId) => userIdsToTeamId[userId] !== winnerTeamId
);
const winnerUserIds = allUserIds.filter(
(userId) => userIdsToTeamId[userId] === winnerTeamId
);
const [ratedWinners, ratedLosers] = rate([
winnerUserIds.map(getUserRating),
loserUserIds.map(getUserRating),
]);
for (const [i, rating] of ratedWinners.entries()) {
const userId = winnerUserIds[i];
invariant(userId, "userId should exist");
userRatings.set(userId, rating);
userMatchesCount.set(userId, (userMatchesCount.get(userId) ?? 0) + 1);
}
for (const [i, rating] of ratedLosers.entries()) {
const userId = loserUserIds[i];
invariant(userId, "userId should exist");
userRatings.set(userId, rating);
userMatchesCount.set(userId, (userMatchesCount.get(userId) ?? 0) + 1);
}
}
return Array.from(userRatings.entries()).map(([userId, rating]) => {
const matchesCount = userMatchesCount.get(userId);
invariant(matchesCount, "matchesCount should exist");
return {
mu: rating.mu,
sigma: rating.sigma,
userId,
identifier: null,
matchesCount,
};
});
}
function calculateTeamSkills({
results,
userIdsToTeamId,
queryCurrentTeamRating,
queryTeamPlayerRatingAverage,
}: {
results: AllMatchResult[];
userIdsToTeamId: UserIdToTeamId;
queryCurrentTeamRating: (identifier: string) => Rating;
queryTeamPlayerRatingAverage: (identifier: string) => Rating;
}) {
const teamRatings = new Map<string, Rating>();
const teamMatchesCount = new Map<string, number>();
const getTeamRating = (identifier: string) => {
const existingRating = teamRatings.get(identifier);
if (existingRating) return existingRating;
return queryCurrentTeamRating(identifier);
};
for (const match of results) {
const winnerTeamId =
match.opponentOne.result === "win"
? match.opponentOne.id
: match.opponentTwo.id;
const winnerTeamIdentifiers = match.maps.flatMap((m) => {
const winnerUserIds = m.userIds.filter(
(userId) => userIdsToTeamId[userId] === winnerTeamId
);
return userIdsToIdentifier(winnerUserIds);
});
const winnerTeamIdentifier = selectMostPopular(winnerTeamIdentifiers);
const loserTeamIdentifiers = match.maps.flatMap((m) => {
const loserUserIds = m.userIds.filter(
(userId) => userIdsToTeamId[userId] !== winnerTeamId
);
return userIdsToIdentifier(loserUserIds);
});
const loserTeamIdentifier = selectMostPopular(loserTeamIdentifiers);
const [[ratedWinner], [ratedLoser]] = rate(
[
[getTeamRating(winnerTeamIdentifier)],
[getTeamRating(loserTeamIdentifier)],
],
[
[queryTeamPlayerRatingAverage(winnerTeamIdentifier)],
[queryTeamPlayerRatingAverage(loserTeamIdentifier)],
]
);
teamRatings.set(winnerTeamIdentifier, ratedWinner);
teamRatings.set(loserTeamIdentifier, ratedLoser);
teamMatchesCount.set(
winnerTeamIdentifier,
(teamMatchesCount.get(winnerTeamIdentifier) ?? 0) + 1
);
teamMatchesCount.set(
loserTeamIdentifier,
(teamMatchesCount.get(loserTeamIdentifier) ?? 0) + 1
);
}
return Array.from(teamRatings.entries()).map(([identifier, rating]) => {
const matchesCount = teamMatchesCount.get(identifier);
invariant(matchesCount, "matchesCount should exist");
return {
mu: rating.mu,
sigma: rating.sigma,
userId: null,
identifier,
matchesCount,
};
});
}
function selectMostPopular<T>(items: T[]): T {
const counts = new Map<T, number>();
for (const item of items) {
counts.set(item, (counts.get(item) ?? 0) + 1);
}
const sorted = Array.from(counts.entries()).sort(
([, countA], [, countB]) => countB - countA
);
const mostPopularCount = sorted[0][1];
const mostPopularItems = sorted.filter(
([, count]) => count === mostPopularCount
);
if (mostPopularItems.length === 1) {
return mostPopularItems[0][0];
}
return shuffle(mostPopularItems)[0][0];
}
function mapResultDeltas({
results,
userIdsToTeamId,
}: {
results: AllMatchResult[];
userIdsToTeamId: UserIdToTeamId;
}): TournamentSummary["mapResultDeltas"] {
const result: TournamentSummary["mapResultDeltas"] = [];
const addMapResult = (
mapResult: Pick<MapResult, "stageId" | "mode" | "userId"> & {
type: "win" | "loss";
}
) => {
const existingResult = result.find(
(r) =>
r.userId === mapResult.userId &&
r.stageId == mapResult.stageId &&
r.mode === mapResult.mode
);
if (existingResult) {
existingResult[mapResult.type === "win" ? "wins" : "losses"] += 1;
} else {
result.push({
userId: mapResult.userId,
stageId: mapResult.stageId,
mode: mapResult.mode,
wins: mapResult.type === "win" ? 1 : 0,
losses: mapResult.type === "loss" ? 1 : 0,
});
}
};
for (const match of results) {
for (const map of match.maps) {
for (const userId of map.userIds) {
const tournamentTeamId = userIdsToTeamId[userId];
invariant(
tournamentTeamId,
`Couldn't resolve tournament team id for user id ${userId}`
);
addMapResult({
mode: map.mode,
stageId: map.stageId,
type: tournamentTeamId === map.winnerTeamId ? "win" : "loss",
userId,
});
}
}
}
return result;
}
function playerResultDeltas({
results,
userIdsToTeamId,
}: {
results: AllMatchResult[];
userIdsToTeamId: UserIdToTeamId;
}): TournamentSummary["playerResultDeltas"] {
const result: TournamentSummary["playerResultDeltas"] = [];
const addPlayerResult = (
playerResult: TournamentSummary["playerResultDeltas"][number]
) => {
const existingResult = result.find(
(r) =>
r.type === playerResult.type &&
r.otherUserId === playerResult.otherUserId &&
r.ownerUserId === playerResult.ownerUserId
);
if (existingResult) {
existingResult.mapLosses += playerResult.mapLosses;
existingResult.mapWins += playerResult.mapWins;
existingResult.setLosses += playerResult.setLosses;
existingResult.setWins += playerResult.setWins;
} else {
result.push(playerResult);
}
};
for (const match of results) {
for (const map of match.maps) {
for (const ownerUserId of map.userIds) {
for (const otherUserId of map.userIds) {
if (ownerUserId === otherUserId) continue;
const ownTournamentTeamId = userIdsToTeamId[ownerUserId];
invariant(
ownTournamentTeamId,
`Couldn't resolve tournament team id for user id ${ownerUserId}`
);
const otherTournamentTeamId = userIdsToTeamId[otherUserId];
invariant(
otherTournamentTeamId,
`Couldn't resolve tournament team id for user id ${otherUserId}`
);
const won = ownTournamentTeamId === map.winnerTeamId;
addPlayerResult({
ownerUserId,
otherUserId,
mapLosses: won ? 0 : 1,
mapWins: won ? 1 : 0,
setLosses: 0,
setWins: 0,
type:
ownTournamentTeamId === otherTournamentTeamId ? "MATE" : "ENEMY",
});
}
}
}
const allUserIds = removeDuplicates(match.maps.flatMap((m) => m.userIds));
for (const ownerUserId of allUserIds) {
for (const otherUserId of allUserIds) {
if (ownerUserId === otherUserId) continue;
const ownTournamentTeamId = userIdsToTeamId[ownerUserId];
invariant(
ownTournamentTeamId,
`Couldn't resolve tournament team id for user id ${ownerUserId}`
);
const otherTournamentTeamId = userIdsToTeamId[otherUserId];
invariant(
otherTournamentTeamId,
`Couldn't resolve tournament team id for user id ${otherUserId}`
);
const result =
match.opponentOne.id === ownTournamentTeamId
? match.opponentOne.result
: match.opponentTwo.result;
const won = result === "win";
addPlayerResult({
ownerUserId,
otherUserId,
mapLosses: 0,
mapWins: 0,
setLosses: won ? 0 : 1,
setWins: won ? 1 : 0,
type:
ownTournamentTeamId === otherTournamentTeamId ? "MATE" : "ENEMY",
});
}
}
}
return result;
}
function tournamentResults({
participantCount,
finalStandings,
}: {
participantCount: number;
finalStandings: FinalStandingsArg;
}) {
const result: TournamentSummary["tournamentResults"] = [];
for (const standing of finalStandings) {
for (const player of standing.players) {
result.push({
participantCount,
placement: standing.placement,
tournamentTeamId: standing.tournamentTeam.id,
userId: player.id,
});
}
}
return result;
}