mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-01 00:24:13 -05:00
* 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
458 lines
13 KiB
TypeScript
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;
|
|
}
|