mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Seeding points (#1969)
* Initial * Seed page initial * Progress * tests * Fix e2e tests
This commit is contained in:
parent
e59b2718c8
commit
feebdfaf54
|
|
@ -366,8 +366,8 @@ export interface Skill {
|
|||
matchesCount: number;
|
||||
mu: number;
|
||||
ordinal: number;
|
||||
season: number;
|
||||
sigma: number;
|
||||
season: number;
|
||||
tournamentId: number | null;
|
||||
userId: number | null;
|
||||
}
|
||||
|
|
@ -377,6 +377,14 @@ export interface SkillTeamUser {
|
|||
userId: number;
|
||||
}
|
||||
|
||||
export interface SeedingSkill {
|
||||
mu: number;
|
||||
ordinal: number;
|
||||
sigma: number;
|
||||
userId: number;
|
||||
type: "RANKED" | "UNRANKED";
|
||||
}
|
||||
|
||||
export interface SplatoonPlayer {
|
||||
id: GeneratedAlways<number>;
|
||||
splId: string;
|
||||
|
|
@ -878,7 +886,6 @@ export interface DB {
|
|||
LFGPost: LFGPost;
|
||||
MapPoolMap: MapPoolMap;
|
||||
MapResult: MapResult;
|
||||
migrations: Migrations;
|
||||
PlayerResult: PlayerResult;
|
||||
PlusSuggestion: PlusSuggestion;
|
||||
PlusTier: PlusTier;
|
||||
|
|
@ -887,6 +894,7 @@ export interface DB {
|
|||
ReportedWeapon: ReportedWeapon;
|
||||
Skill: Skill;
|
||||
SkillTeamUser: SkillTeamUser;
|
||||
SeedingSkill: SeedingSkill;
|
||||
SplatoonPlayer: SplatoonPlayer;
|
||||
TaggedArt: TaggedArt;
|
||||
Team: Team;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { rating } from "openskill";
|
||||
import type { Tables } from "../../db/tables";
|
||||
import { identifierToUserIds } from "./mmr-utils";
|
||||
import { findCurrentSkillByUserId } from "./queries/findCurrentSkillByUserId.server";
|
||||
import { findCurrentTeamSkillByIdentifier } from "./queries/findCurrentTeamSkillByIdentifier.server";
|
||||
import { findSeedingSkill } from "./queries/findSeedingSkill.server";
|
||||
|
||||
export function queryCurrentUserRating({
|
||||
userId,
|
||||
|
|
@ -19,6 +21,17 @@ export function queryCurrentUserRating({
|
|||
return { rating: rating(skill), matchesCount: skill.matchesCount };
|
||||
}
|
||||
|
||||
export function queryCurrentUserSeedingRating(args: {
|
||||
userId: number;
|
||||
type: Tables["SeedingSkill"]["type"];
|
||||
}) {
|
||||
const skill = findSeedingSkill(args);
|
||||
|
||||
if (!skill) return rating();
|
||||
|
||||
return skill;
|
||||
}
|
||||
|
||||
export function queryCurrentTeamRating({
|
||||
identifier,
|
||||
season,
|
||||
|
|
|
|||
21
app/features/mmr/queries/findSeedingSkill.server.ts
Normal file
21
app/features/mmr/queries/findSeedingSkill.server.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { sql } from "~/db/sql";
|
||||
import type { Tables } from "../../../db/tables";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select
|
||||
"mu",
|
||||
"sigma"
|
||||
from
|
||||
"SeedingSkill"
|
||||
where
|
||||
"userId" = @userId
|
||||
and
|
||||
"type" = @type
|
||||
`);
|
||||
|
||||
export function findSeedingSkill(args: {
|
||||
userId: number;
|
||||
type: Tables["SeedingSkill"]["type"];
|
||||
}) {
|
||||
return stm.get(args) as Pick<Tables["SeedingSkill"], "mu" | "sigma"> | null;
|
||||
}
|
||||
|
|
@ -81,22 +81,16 @@ export class Tournament {
|
|||
a: TournamentData["ctx"]["teams"][number],
|
||||
b: TournamentData["ctx"]["teams"][number],
|
||||
) {
|
||||
const aPlus = a.members
|
||||
.flatMap((a) => (a.plusTier ? [a.plusTier] : []))
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, 4);
|
||||
const bPlus = b.members
|
||||
.flatMap((b) => (b.plusTier ? [b.plusTier] : []))
|
||||
.sort((a, b) => a - b)
|
||||
.slice(0, 4);
|
||||
if (a.avgSeedingSkillOrdinal && b.avgSeedingSkillOrdinal) {
|
||||
return b.avgSeedingSkillOrdinal - a.avgSeedingSkillOrdinal;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (aPlus[i] && !bPlus[i]) return -1;
|
||||
if (!aPlus[i] && bPlus[i]) return 1;
|
||||
if (a.avgSeedingSkillOrdinal && !b.avgSeedingSkillOrdinal) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (aPlus[i] !== bPlus[i]) {
|
||||
return aPlus[i] - bPlus[i];
|
||||
}
|
||||
if (!a.avgSeedingSkillOrdinal && b.avgSeedingSkillOrdinal) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return a.createdAt - b.createdAt;
|
||||
|
|
@ -528,6 +522,20 @@ export class Tournament {
|
|||
});
|
||||
}
|
||||
|
||||
/** What seeding skill rating this tournament counts for */
|
||||
get skillCountsFor() {
|
||||
if (this.ranked) {
|
||||
return "RANKED";
|
||||
}
|
||||
|
||||
// exclude gimmicky tournaments
|
||||
if (this.minMembersPerTeam === 4 && !this.ctx.tags?.includes("SPECIAL")) {
|
||||
return "UNRANKED";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
get minMembersPerTeam() {
|
||||
return this.ctx.settings.minMembersPerTeam ?? 4;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import shuffle from "just-shuffle";
|
||||
import type { Rating } from "node_modules/openskill/dist/types";
|
||||
import { ordinal } from "openskill";
|
||||
import type {
|
||||
MapResult,
|
||||
PlayerResult,
|
||||
|
|
@ -13,6 +14,7 @@ import {
|
|||
} from "~/features/mmr/mmr-utils";
|
||||
import { removeDuplicates } from "~/utils/arrays";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { Tables } from "../../../db/tables";
|
||||
import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server";
|
||||
import type { Standing } from "./Bracket";
|
||||
|
||||
|
|
@ -21,6 +23,7 @@ export interface TournamentSummary {
|
|||
Skill,
|
||||
"tournamentId" | "id" | "ordinal" | "season" | "groupMatchId"
|
||||
>[];
|
||||
seedingSkills: Tables["SeedingSkill"][];
|
||||
mapResultDeltas: Omit<MapResult, "season">[];
|
||||
playerResultDeltas: Omit<PlayerResult, "season">[];
|
||||
tournamentResults: Omit<TournamentResult, "tournamentId" | "isHighlight">[];
|
||||
|
|
@ -40,6 +43,8 @@ export function tournamentSummary({
|
|||
queryCurrentTeamRating,
|
||||
queryTeamPlayerRatingAverage,
|
||||
queryCurrentUserRating,
|
||||
queryCurrentSeedingRating,
|
||||
seedingSkillCountsFor,
|
||||
calculateSeasonalStats = true,
|
||||
}: {
|
||||
results: AllMatchResult[];
|
||||
|
|
@ -48,6 +53,8 @@ export function tournamentSummary({
|
|||
queryCurrentTeamRating: (identifier: string) => Rating;
|
||||
queryTeamPlayerRatingAverage: (identifier: string) => Rating;
|
||||
queryCurrentUserRating: (userId: number) => Rating;
|
||||
queryCurrentSeedingRating: (userId: number) => Rating;
|
||||
seedingSkillCountsFor: Tables["SeedingSkill"]["type"] | null;
|
||||
calculateSeasonalStats?: boolean;
|
||||
}): TournamentSummary {
|
||||
const userIdsToTeamId = userIdsToTeamIdRecord(teams);
|
||||
|
|
@ -62,6 +69,17 @@ export function tournamentSummary({
|
|||
queryTeamPlayerRatingAverage,
|
||||
})
|
||||
: [],
|
||||
seedingSkills: seedingSkillCountsFor
|
||||
? calculateIndividualPlayerSkills({
|
||||
queryCurrentUserRating: queryCurrentSeedingRating,
|
||||
results,
|
||||
userIdsToTeamId,
|
||||
}).map((skill) => ({
|
||||
...skill,
|
||||
type: seedingSkillCountsFor,
|
||||
ordinal: ordinal(skill),
|
||||
}))
|
||||
: [],
|
||||
mapResultDeltas: calculateSeasonalStats
|
||||
? mapResultDeltas({ results, userIdsToTeamId })
|
||||
: [],
|
||||
|
|
@ -75,7 +93,7 @@ export function tournamentSummary({
|
|||
};
|
||||
}
|
||||
|
||||
function userIdsToTeamIdRecord(teams: TeamsArg) {
|
||||
export function userIdsToTeamIdRecord(teams: TeamsArg) {
|
||||
const result: UserIdToTeamId = {};
|
||||
|
||||
for (const team of teams) {
|
||||
|
|
@ -102,7 +120,7 @@ function skills(args: {
|
|||
return result;
|
||||
}
|
||||
|
||||
function calculateIndividualPlayerSkills({
|
||||
export function calculateIndividualPlayerSkills({
|
||||
results,
|
||||
userIdsToTeamId,
|
||||
queryCurrentUserRating,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { ordinal, rating } from "openskill";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { Tables } from "../../../db/tables";
|
||||
import type { AllMatchResult } from "../queries/allMatchResultsByTournamentId.server";
|
||||
import type { TournamentDataTeam } from "./Tournament.server";
|
||||
import { tournamentSummary } from "./summarizer.server";
|
||||
|
|
@ -14,6 +15,7 @@ describe("tournamentSummary()", () => {
|
|||
createdAt: 0,
|
||||
id: teamId,
|
||||
inviteCode: null,
|
||||
avgSeedingSkillOrdinal: null,
|
||||
mapPool: [],
|
||||
members: userIds.map((userId) => ({
|
||||
country: null,
|
||||
|
|
@ -38,7 +40,13 @@ describe("tournamentSummary()", () => {
|
|||
pickupAvatarUrl: null,
|
||||
});
|
||||
|
||||
function summarize({ results }: { results?: AllMatchResult[] } = {}) {
|
||||
function summarize({
|
||||
results,
|
||||
seedingSkillCountsFor,
|
||||
}: {
|
||||
results?: AllMatchResult[];
|
||||
seedingSkillCountsFor?: Tables["SeedingSkill"]["type"];
|
||||
} = {}) {
|
||||
return tournamentSummary({
|
||||
finalStandings: [
|
||||
{
|
||||
|
|
@ -123,6 +131,8 @@ describe("tournamentSummary()", () => {
|
|||
queryCurrentTeamRating: () => rating(),
|
||||
queryCurrentUserRating: () => rating(),
|
||||
queryTeamPlayerRatingAverage: () => rating(),
|
||||
queryCurrentSeedingRating: () => rating(),
|
||||
seedingSkillCountsFor: seedingSkillCountsFor ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -141,6 +151,31 @@ describe("tournamentSummary()", () => {
|
|||
expect(ordinal(winnerSkill)).toBeGreaterThan(ordinal(loserSkill));
|
||||
});
|
||||
|
||||
test("seeding skill is calculated the same as normal skill", () => {
|
||||
const summary = summarize({ seedingSkillCountsFor: "RANKED" });
|
||||
const winnerSkill = summary.skills.find((s) => s.userId === 1);
|
||||
const winnerSeedingSkill = summary.skills.find((s) => s.userId === 1);
|
||||
|
||||
invariant(winnerSkill, "winnerSkill should be defined");
|
||||
invariant(winnerSeedingSkill, "winnerSeedingSkill should be defined");
|
||||
|
||||
expect(ordinal(winnerSkill)).toBe(ordinal(winnerSeedingSkill));
|
||||
});
|
||||
|
||||
test("no seeding skill calculated if seedingSkillCountsFor is null", () => {
|
||||
const summary = summarize();
|
||||
|
||||
expect(summary.seedingSkills.length).toBe(0);
|
||||
});
|
||||
|
||||
test("seeding skills type matches the given seedingSkillCountsFor", () => {
|
||||
const summary = summarize({ seedingSkillCountsFor: "RANKED" });
|
||||
expect(summary.seedingSkills[0].type).toBe("RANKED");
|
||||
|
||||
const summary2 = summarize({ seedingSkillCountsFor: "UNRANKED" });
|
||||
expect(summary2.seedingSkills[0].type).toBe("UNRANKED");
|
||||
});
|
||||
|
||||
const resultsWith20: AllMatchResult[] = [
|
||||
{
|
||||
maps: [
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -13,6 +13,7 @@ const tournamentCtxTeam = (
|
|||
createdAt: 0,
|
||||
id: teamId,
|
||||
inviteCode: null,
|
||||
avgSeedingSkillOrdinal: null,
|
||||
team: null,
|
||||
mapPool: [],
|
||||
members: [],
|
||||
|
|
@ -58,6 +59,7 @@ export const testTournament = ({
|
|||
ctx: {
|
||||
eventId: 1,
|
||||
id: 1,
|
||||
tags: null,
|
||||
description: null,
|
||||
organization: null,
|
||||
rules: null,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,23 @@ const addSkillStm = sql.prepare(/* sql */ `
|
|||
) returning *
|
||||
`);
|
||||
|
||||
// on conflict it replaces (set in migration)
|
||||
const addSeedingSkillStm = sql.prepare(/* sql */ `
|
||||
insert into "SeedingSkill" (
|
||||
"type",
|
||||
"mu",
|
||||
"sigma",
|
||||
"ordinal",
|
||||
"userId"
|
||||
) values (
|
||||
@type,
|
||||
@mu,
|
||||
@sigma,
|
||||
@ordinal,
|
||||
@userId
|
||||
)
|
||||
`);
|
||||
|
||||
const addSkillTeamUserStm = sql.prepare(/* sql */ `
|
||||
insert into "SkillTeamUser" (
|
||||
"skillId",
|
||||
|
|
@ -135,6 +152,16 @@ export const addSummary = sql.transaction(
|
|||
}
|
||||
}
|
||||
|
||||
for (const seedingSkill of summary.seedingSkills) {
|
||||
addSeedingSkillStm.run({
|
||||
type: seedingSkill.type,
|
||||
mu: seedingSkill.mu,
|
||||
sigma: seedingSkill.sigma,
|
||||
ordinal: seedingSkill.ordinal,
|
||||
userId: seedingSkill.userId,
|
||||
});
|
||||
}
|
||||
|
||||
for (const mapResultDelta of summary.mapResultDeltas) {
|
||||
addMapResultDeltaStm.run({
|
||||
mode: mapResultDelta.mode,
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { requireUser } from "~/features/auth/core/user.server";
|
|||
import {
|
||||
queryCurrentTeamRating,
|
||||
queryCurrentUserRating,
|
||||
queryCurrentUserSeedingRating,
|
||||
queryTeamPlayerRatingAverage,
|
||||
} from "~/features/mmr/mmr-utils.server";
|
||||
import { currentSeason } from "~/features/mmr/season";
|
||||
|
|
@ -208,23 +209,36 @@ export const action: ActionFunction = async ({ params, request }) => {
|
|||
|
||||
const season = currentSeason(tournament.ctx.startTime)?.nth;
|
||||
|
||||
const seedingSkillCountsFor = tournament.skillCountsFor;
|
||||
const summary = tournamentSummary({
|
||||
teams: tournament.ctx.teams,
|
||||
finalStandings: _finalStandings,
|
||||
results,
|
||||
calculateSeasonalStats: tournament.ranked,
|
||||
queryCurrentTeamRating: (identifier) =>
|
||||
queryCurrentTeamRating({ identifier, season: season! }).rating,
|
||||
queryCurrentUserRating: (userId) =>
|
||||
queryCurrentUserRating({ userId, season: season! }).rating,
|
||||
queryTeamPlayerRatingAverage: (identifier) =>
|
||||
queryTeamPlayerRatingAverage({
|
||||
identifier,
|
||||
season: season!,
|
||||
}),
|
||||
queryCurrentSeedingRating: (userId) =>
|
||||
queryCurrentUserSeedingRating({
|
||||
userId,
|
||||
type: seedingSkillCountsFor!,
|
||||
}),
|
||||
seedingSkillCountsFor,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`Inserting tournament summary. Tournament id: ${tournamentId}, mapResultDeltas.lenght: ${summary.mapResultDeltas.length}, playerResultDeltas.length ${summary.playerResultDeltas.length}, tournamentResults.length ${summary.tournamentResults.length}, skills.length ${summary.skills.length}, seedingSkills.length ${summary.seedingSkills.length}`,
|
||||
);
|
||||
|
||||
addSummary({
|
||||
tournamentId,
|
||||
summary: tournamentSummary({
|
||||
teams: tournament.ctx.teams,
|
||||
finalStandings: _finalStandings,
|
||||
results,
|
||||
calculateSeasonalStats: tournament.ranked,
|
||||
queryCurrentTeamRating: (identifier) =>
|
||||
queryCurrentTeamRating({ identifier, season: season! }).rating,
|
||||
queryCurrentUserRating: (userId) =>
|
||||
queryCurrentUserRating({ userId, season: season! }).rating,
|
||||
queryTeamPlayerRatingAverage: (identifier) =>
|
||||
queryTeamPlayerRatingAverage({
|
||||
identifier,
|
||||
season: season!,
|
||||
}),
|
||||
}),
|
||||
summary,
|
||||
season,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,13 @@ import { HACKY_resolvePicture } from "./tournament-utils";
|
|||
|
||||
export type FindById = NonNullable<Unwrapped<typeof findById>>;
|
||||
export async function findById(id: number) {
|
||||
const isSetAsRanked = await db
|
||||
.selectFrom("Tournament")
|
||||
.select("settings")
|
||||
.where("id", "=", id)
|
||||
.executeTakeFirst()
|
||||
.then((row) => row?.settings.isRanked ?? false);
|
||||
|
||||
const result = await db
|
||||
.selectFrom("Tournament")
|
||||
.innerJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
|
||||
|
|
@ -33,6 +40,7 @@ export async function findById(id: number) {
|
|||
"Tournament.id",
|
||||
"CalendarEvent.id as eventId",
|
||||
"CalendarEvent.discordUrl",
|
||||
"CalendarEvent.tags",
|
||||
"Tournament.settings",
|
||||
"Tournament.castTwitchAccounts",
|
||||
"Tournament.castedMatchesInfo",
|
||||
|
|
@ -154,7 +162,15 @@ export async function findById(id: number) {
|
|||
innerEb
|
||||
.selectFrom("TournamentTeamMember")
|
||||
.innerJoin("User", "TournamentTeamMember.userId", "User.id")
|
||||
.leftJoin("PlusTier", "User.id", "PlusTier.userId")
|
||||
.leftJoin("SeedingSkill", (join) =>
|
||||
join
|
||||
.onRef("User.id", "=", "SeedingSkill.userId")
|
||||
.on(
|
||||
"SeedingSkill.type",
|
||||
"=",
|
||||
isSetAsRanked ? "RANKED" : "UNRANKED",
|
||||
),
|
||||
)
|
||||
.select([
|
||||
"User.id as userId",
|
||||
"User.username",
|
||||
|
|
@ -163,7 +179,7 @@ export async function findById(id: number) {
|
|||
"User.customUrl",
|
||||
"User.country",
|
||||
"User.twitch",
|
||||
"PlusTier.tier as plusTier",
|
||||
"SeedingSkill.ordinal",
|
||||
"TournamentTeamMember.isOwner",
|
||||
"TournamentTeamMember.createdAt",
|
||||
sql<string | null> /*sql*/`coalesce(
|
||||
|
|
@ -269,13 +285,27 @@ export async function findById(id: number) {
|
|||
|
||||
return {
|
||||
...result,
|
||||
teams: result.teams.map((team) => ({
|
||||
...team,
|
||||
members: team.members.map(({ ordinal, ...member }) => member),
|
||||
avgSeedingSkillOrdinal: nullifyingAvg(
|
||||
team.members
|
||||
.map((member) => member.ordinal)
|
||||
.filter((ordinal) => typeof ordinal === "number"),
|
||||
),
|
||||
})),
|
||||
logoSrc: result.logoUrl
|
||||
? userSubmittedImage(result.logoUrl)
|
||||
: `${import.meta.env.VITE_SITE_DOMAIN}${HACKY_resolvePicture(result)}`,
|
||||
: HACKY_resolvePicture(result),
|
||||
participatedUsers: result.participatedUsers.map((user) => user.userId),
|
||||
};
|
||||
}
|
||||
|
||||
function nullifyingAvg(values: number[]) {
|
||||
if (values.length === 0) return null;
|
||||
return values.reduce((acc, cur) => acc + cur, 0) / values.length;
|
||||
}
|
||||
|
||||
export async function findTOSetMapPoolById(tournamentId: number) {
|
||||
return (
|
||||
await db
|
||||
|
|
|
|||
|
|
@ -13,18 +13,9 @@ import {
|
|||
sortableKeyboardCoordinates,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import type {
|
||||
ActionFunction,
|
||||
LoaderFunctionArgs,
|
||||
SerializeFrom,
|
||||
} from "@remix-run/node";
|
||||
import type { ActionFunction, LoaderFunctionArgs } from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import {
|
||||
Link,
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
useNavigation,
|
||||
} from "@remix-run/react";
|
||||
import { Link, useFetcher, useNavigation } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import clone from "just-clone";
|
||||
import * as React from "react";
|
||||
|
|
@ -32,13 +23,8 @@ import { Alert } from "~/components/Alert";
|
|||
import { Button } from "~/components/Button";
|
||||
import { Catcher } from "~/components/Catcher";
|
||||
import { Draggable } from "~/components/Draggable";
|
||||
import { Image, TierImage } from "~/components/Image";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { Toggle } from "~/components/Toggle";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { cachedFullUserLeaderboard } from "~/features/leaderboards/core/leaderboards.server";
|
||||
import { currentOrPreviousSeason } from "~/features/mmr/season";
|
||||
import {
|
||||
type TournamentDataTeam,
|
||||
clearTournamentDataCache,
|
||||
|
|
@ -47,11 +33,10 @@ import {
|
|||
import { useTimeoutState } from "~/hooks/useTimeoutState";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { parseRequestPayload, validate } from "~/utils/remix.server";
|
||||
import {
|
||||
navIconUrl,
|
||||
tournamentBracketsPage,
|
||||
userResultsPage,
|
||||
} from "~/utils/urls";
|
||||
import { tournamentBracketsPage, userResultsPage } from "~/utils/urls";
|
||||
import { Avatar } from "../../../components/Avatar";
|
||||
import { InfoPopover } from "../../../components/InfoPopover";
|
||||
import { ordinalToRoundedSp } from "../../mmr/mmr-utils";
|
||||
import { updateTeamSeeds } from "../queries/updateTeamSeeds.server";
|
||||
import { seedsActionSchema } from "../tournament-schemas.server";
|
||||
import { tournamentIdFromParams } from "../tournament-utils";
|
||||
|
|
@ -85,28 +70,10 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
|||
throw redirect(tournamentBracketsPage({ tournamentId }));
|
||||
}
|
||||
|
||||
const powers = async (season: number) => {
|
||||
const leaderboard = await cachedFullUserLeaderboard(season);
|
||||
|
||||
return Object.fromEntries(
|
||||
leaderboard.map((entry) => {
|
||||
return [entry.id, { power: entry.power, tier: entry.tier }];
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const currentSeason = currentOrPreviousSeason(new Date())!.nth;
|
||||
|
||||
return {
|
||||
powers: {
|
||||
current: await powers(currentSeason),
|
||||
previous: await powers(currentSeason - 1),
|
||||
},
|
||||
};
|
||||
return null;
|
||||
};
|
||||
|
||||
export default function TournamentSeedsPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const tournament = useTournament();
|
||||
const navigation = useNavigation();
|
||||
const [teamOrder, setTeamOrder] = React.useState(
|
||||
|
|
@ -115,8 +82,6 @@ export default function TournamentSeedsPage() {
|
|||
const [activeTeam, setActiveTeam] = React.useState<TournamentDataTeam | null>(
|
||||
null,
|
||||
);
|
||||
const [usingPreviousSeasonPowers, setUsingPreviousSeasonPowers] =
|
||||
React.useState(false);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
|
|
@ -132,51 +97,71 @@ export default function TournamentSeedsPage() {
|
|||
(a, b) => teamOrder.indexOf(a.id) - teamOrder.indexOf(b.id),
|
||||
);
|
||||
|
||||
const activePowers = usingPreviousSeasonPowers
|
||||
? data.powers.previous
|
||||
: data.powers.current;
|
||||
const isOutOfOrder = (
|
||||
team: TournamentDataTeam,
|
||||
previousTeam?: TournamentDataTeam,
|
||||
) => {
|
||||
if (!previousTeam) return false;
|
||||
|
||||
const rankTeam = (team: TournamentDataTeam) => {
|
||||
const powers = team.members
|
||||
.map((m) => activePowers[m.userId]?.power)
|
||||
.filter(Boolean);
|
||||
if (
|
||||
typeof team.avgSeedingSkillOrdinal === "number" &&
|
||||
typeof previousTeam.avgSeedingSkillOrdinal === "number"
|
||||
) {
|
||||
return team.avgSeedingSkillOrdinal > previousTeam.avgSeedingSkillOrdinal;
|
||||
}
|
||||
|
||||
if (powers.length === 0) return 0;
|
||||
|
||||
return powers.reduce((acc, cur) => acc + cur, 0) / powers.length;
|
||||
return Boolean(previousTeam.avgSeedingSkillOrdinal);
|
||||
};
|
||||
|
||||
const noOrganizerSetSeeding = tournament.ctx.teams.every(
|
||||
(team) => !team.seed,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="stack lg">
|
||||
<SeedAlert teamOrder={teamOrder} />
|
||||
<div className="stack horizontal justify-between">
|
||||
<Button
|
||||
className="tournament__seeds__order-button"
|
||||
variant="minimal"
|
||||
size="tiny"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTeamOrder(
|
||||
clone(tournament.ctx.teams)
|
||||
.sort((a, b) => rankTeam(b) - rankTeam(a))
|
||||
.map((t) => t.id),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Sort automatically
|
||||
</Button>
|
||||
<div className="stack horizontal sm items-center">
|
||||
<Label spaced={false}>Previous season powers</Label>
|
||||
<Toggle
|
||||
checked={usingPreviousSeasonPowers}
|
||||
setChecked={setUsingPreviousSeasonPowers}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
{noOrganizerSetSeeding ? (
|
||||
<div className="text-lighter text-xs">
|
||||
As long as you don't manually set the seeding, the teams are
|
||||
automatically sorted by their seeding points value as participating
|
||||
players change
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
className="tournament__seeds__order-button"
|
||||
variant="minimal"
|
||||
size="tiny"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTeamOrder(
|
||||
clone(tournament.ctx.teams)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(b.avgSeedingSkillOrdinal ?? Number.NEGATIVE_INFINITY) -
|
||||
(a.avgSeedingSkillOrdinal ?? Number.NEGATIVE_INFINITY),
|
||||
)
|
||||
.map((t) => t.id),
|
||||
);
|
||||
}}
|
||||
>
|
||||
Sort automatically
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<ul>
|
||||
<li className="tournament__seeds__teams-list-row">
|
||||
<div className="tournament__seeds__teams-container__header">Seed</div>
|
||||
<div className="tournament__seeds__teams-container__header" />
|
||||
<div className="tournament__seeds__teams-container__header" />
|
||||
<div className="tournament__seeds__teams-container__header">Name</div>
|
||||
<div className="tournament__seeds__teams-container__header stack horizontal xxs">
|
||||
SP
|
||||
<InfoPopover tiny>
|
||||
Seeding point is a value that tracks players' head-to-head
|
||||
performances in tournaments. Ranked and unranked tournaments have
|
||||
different points.
|
||||
</InfoPopover>
|
||||
</div>
|
||||
<div className="tournament__seeds__teams-container__header">
|
||||
Players
|
||||
</div>
|
||||
|
|
@ -225,7 +210,16 @@ export default function TournamentSeedsPage() {
|
|||
},
|
||||
)}
|
||||
>
|
||||
<RowContents team={team} seed={i + 1} powers={activePowers} />
|
||||
<RowContents
|
||||
team={team}
|
||||
seed={i + 1}
|
||||
teamSeedingSkill={{
|
||||
sp: team.avgSeedingSkillOrdinal
|
||||
? ordinalToRoundedSp(team.avgSeedingSkillOrdinal)
|
||||
: null,
|
||||
outOfOrder: isOutOfOrder(team, teamsSorted[i - 1]),
|
||||
}}
|
||||
/>
|
||||
</Draggable>
|
||||
))}
|
||||
</SortableContext>
|
||||
|
|
@ -233,7 +227,15 @@ export default function TournamentSeedsPage() {
|
|||
<DragOverlay>
|
||||
{activeTeam && (
|
||||
<li className="tournament__seeds__teams-list-row active">
|
||||
<RowContents team={activeTeam} powers={activePowers} />
|
||||
<RowContents
|
||||
team={activeTeam}
|
||||
teamSeedingSkill={{
|
||||
sp: activeTeam.avgSeedingSkillOrdinal
|
||||
? ordinalToRoundedSp(activeTeam.avgSeedingSkillOrdinal)
|
||||
: null,
|
||||
outOfOrder: false,
|
||||
}}
|
||||
/>
|
||||
</li>
|
||||
)}
|
||||
</DragOverlay>
|
||||
|
|
@ -296,24 +298,33 @@ function SeedAlert({ teamOrder }: { teamOrder: number[] }) {
|
|||
function RowContents({
|
||||
team,
|
||||
seed,
|
||||
powers,
|
||||
teamSeedingSkill,
|
||||
}: {
|
||||
team: TournamentDataTeam;
|
||||
seed?: number;
|
||||
powers: SerializeFrom<typeof loader>["powers"]["current"];
|
||||
teamSeedingSkill: {
|
||||
sp: number | null;
|
||||
outOfOrder: boolean;
|
||||
};
|
||||
}) {
|
||||
const tournament = useTournament();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>{seed}</div>
|
||||
<div>
|
||||
{team.team?.logoUrl ? (
|
||||
<Avatar url={tournament.tournamentTeamLogoSrc(team)} size="xxs" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="tournament__seeds__team-name">
|
||||
{team.checkIns.length > 0 ? "✅ " : "❌ "} {team.name}
|
||||
</div>
|
||||
<div className={clsx({ "text-warning": teamSeedingSkill.outOfOrder })}>
|
||||
{teamSeedingSkill.sp}
|
||||
</div>
|
||||
<div className="stack horizontal sm">
|
||||
{team.members.map((member) => {
|
||||
const { power, tier } = powers[member.userId] ?? {};
|
||||
const lonely =
|
||||
(!power && member.plusTier) || (!member.plusTier && power);
|
||||
|
||||
return (
|
||||
<div key={member.userId} className="tournament__seeds__team-member">
|
||||
<Link
|
||||
|
|
@ -323,27 +334,6 @@ function RowContents({
|
|||
>
|
||||
{member.username}
|
||||
</Link>
|
||||
{member.plusTier ? (
|
||||
<div
|
||||
className={clsx("stack horizontal items-center xxs", {
|
||||
"add tournament__seeds__lonely-stat": lonely,
|
||||
})}
|
||||
>
|
||||
<Image path={navIconUrl("plus")} alt="" width={16} /> +
|
||||
{member.plusTier}
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
{power ? (
|
||||
<div
|
||||
className={clsx("stack horizontal items-center xxs", {
|
||||
"add tournament__seeds__lonely-stat": lonely,
|
||||
})}
|
||||
>
|
||||
<TierImage tier={tier} width={32} /> {power}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -55,6 +55,18 @@ export const meta: MetaFunction = (args) => {
|
|||
|
||||
const title = makeTitle(data.tournament.ctx.name);
|
||||
|
||||
const ogImage = () => {
|
||||
if (
|
||||
!data.tournament.ctx.logoSrc ||
|
||||
data.tournament.ctx.logoSrc.startsWith("https")
|
||||
) {
|
||||
return data.tournament.ctx.logoSrc;
|
||||
}
|
||||
|
||||
// opengraph does not support relative urls
|
||||
return `${import.meta.env.VITE_SITE_DOMAIN}${data.tournament.ctx.logoSrc}`;
|
||||
};
|
||||
|
||||
return [
|
||||
{ title },
|
||||
{
|
||||
|
|
@ -71,7 +83,7 @@ export const meta: MetaFunction = (args) => {
|
|||
},
|
||||
{
|
||||
property: "og:image",
|
||||
content: data.tournament.ctx.logoSrc,
|
||||
content: ogImage(),
|
||||
},
|
||||
// Twitter special snowflake tags, see https://developer.x.com/en/docs/twitter-for-websites/cards/overview/summary
|
||||
{
|
||||
|
|
|
|||
|
|
@ -362,7 +362,7 @@
|
|||
border-radius: var(--rounded);
|
||||
column-gap: var(--s-1);
|
||||
font-size: var(--fonts-xs);
|
||||
grid-template-columns: 3rem 8rem 1fr;
|
||||
grid-template-columns: 1.5rem 2rem 8rem 3rem 1fr;
|
||||
list-style: none;
|
||||
row-gap: var(--s-1-5);
|
||||
}
|
||||
|
|
|
|||
BIN
db-test.sqlite3
BIN
db-test.sqlite3
Binary file not shown.
|
|
@ -190,7 +190,7 @@ test.describe("Tournament bracket", () => {
|
|||
});
|
||||
|
||||
// 1)
|
||||
await navigateToMatch(page, 6);
|
||||
await navigateToMatch(page, 5);
|
||||
await reportResult({ page, amountOfMapsToReport: 2 });
|
||||
await backToBracket(page);
|
||||
|
||||
|
|
@ -200,7 +200,7 @@ test.describe("Tournament bracket", () => {
|
|||
page,
|
||||
url: tournamentBracketsPage({ tournamentId }),
|
||||
});
|
||||
await navigateToMatch(page, 5);
|
||||
await navigateToMatch(page, 6);
|
||||
await reportResult({ page, amountOfMapsToReport: 2 });
|
||||
await backToBracket(page);
|
||||
|
||||
|
|
@ -214,7 +214,7 @@ test.describe("Tournament bracket", () => {
|
|||
await backToBracket(page);
|
||||
|
||||
// 4)
|
||||
await navigateToMatch(page, 6);
|
||||
await navigateToMatch(page, 5);
|
||||
await isNotVisible(page.getByTestId("reopen-match-button"));
|
||||
await backToBracket(page);
|
||||
|
||||
|
|
@ -225,7 +225,7 @@ test.describe("Tournament bracket", () => {
|
|||
await backToBracket(page);
|
||||
|
||||
// 6)
|
||||
await navigateToMatch(page, 6);
|
||||
await navigateToMatch(page, 5);
|
||||
await page.getByTestId("reopen-match-button").click();
|
||||
await expectScore(page, [1, 0]);
|
||||
|
||||
|
|
@ -235,7 +235,7 @@ test.describe("Tournament bracket", () => {
|
|||
page,
|
||||
url: tournamentBracketsPage({ tournamentId }),
|
||||
});
|
||||
await navigateToMatch(page, 6);
|
||||
await navigateToMatch(page, 5);
|
||||
await page.getByTestId("undo-score-button").click();
|
||||
await expectScore(page, [0, 0]);
|
||||
await reportResult({
|
||||
|
|
@ -731,11 +731,11 @@ test.describe("Tournament bracket", () => {
|
|||
await page.getByTestId("finalize-bracket-button").click();
|
||||
await page.getByTestId("confirm-finalize-bracket-button").click();
|
||||
|
||||
await page.locator('[data-match-id="7"]').click();
|
||||
await page.locator('[data-match-id="2"]').click();
|
||||
await expect(page.getByTestId("screen-allowed")).toBeVisible();
|
||||
await backToBracket(page);
|
||||
|
||||
await page.locator('[data-match-id="8"]').click();
|
||||
await page.locator('[data-match-id="1"]').click();
|
||||
await expect(page.getByTestId("screen-banned")).toBeVisible();
|
||||
});
|
||||
|
||||
|
|
|
|||
17
migrations/074-seeding-skill.js
Normal file
17
migrations/074-seeding-skill.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
export function up(db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
/*sql*/ `
|
||||
create table "SeedingSkill" (
|
||||
"mu" real not null,
|
||||
"sigma" real not null,
|
||||
"ordinal" real not null,
|
||||
"userId" integer not null,
|
||||
"type" text not null,
|
||||
foreign key ("userId") references "User"("id") on delete cascade,
|
||||
unique("userId", "type") on conflict replace
|
||||
) strict
|
||||
`,
|
||||
).run();
|
||||
})();
|
||||
}
|
||||
94
scripts/calc-seeding-skills.ts
Normal file
94
scripts/calc-seeding-skills.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import "dotenv/config";
|
||||
import { type Rating, ordinal, rating } from "openskill";
|
||||
import { db } from "../app/db/sql";
|
||||
import type { Tables } from "../app/db/tables";
|
||||
import { tournamentFromDB } from "../app/features/tournament-bracket/core/Tournament.server";
|
||||
import {
|
||||
calculateIndividualPlayerSkills,
|
||||
userIdsToTeamIdRecord,
|
||||
} from "../app/features/tournament-bracket/core/summarizer.server";
|
||||
import { allMatchResultsByTournamentId } from "../app/features/tournament-bracket/queries/allMatchResultsByTournamentId.server";
|
||||
import invariant from "../app/utils/invariant";
|
||||
import { logger } from "../app/utils/logger";
|
||||
|
||||
async function main() {
|
||||
const result: Tables["SeedingSkill"][] = [];
|
||||
|
||||
for (const type of ["RANKED", "UNRANKED"] as const) {
|
||||
const ratings = new Map<number, Rating>();
|
||||
let count = 0;
|
||||
|
||||
console.time(`Tournament skills: ${type}`);
|
||||
for await (const tournament of tournaments(type)) {
|
||||
count++;
|
||||
const results = allMatchResultsByTournamentId(tournament.ctx.id);
|
||||
invariant(results.length > 0, "No results found");
|
||||
|
||||
const skills = calculateIndividualPlayerSkills({
|
||||
queryCurrentUserRating(userId) {
|
||||
return ratings.get(userId) ?? rating();
|
||||
},
|
||||
results,
|
||||
userIdsToTeamId: userIdsToTeamIdRecord(tournament.ctx.teams),
|
||||
});
|
||||
|
||||
for (const { userId, mu, sigma } of skills) {
|
||||
ratings.set(userId, rating({ mu, sigma }));
|
||||
}
|
||||
}
|
||||
console.timeEnd(`Tournament skills: ${type}`);
|
||||
logger.info(`Processed ${count} tournaments`);
|
||||
|
||||
for (const [userId, { mu, sigma }] of ratings) {
|
||||
result.push({
|
||||
mu,
|
||||
sigma,
|
||||
ordinal: ordinal(rating({ mu, sigma })),
|
||||
type,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await trx.deleteFrom("SeedingSkill").execute();
|
||||
for (const skill of result) {
|
||||
await trx.insertInto("SeedingSkill").values(skill).execute();
|
||||
}
|
||||
});
|
||||
logger.info(`Done. Total of ${result.length} seeding skills inserted`);
|
||||
}
|
||||
|
||||
async function* tournaments(type: "RANKED" | "UNRANKED") {
|
||||
const maxId = await db
|
||||
.selectFrom("Tournament")
|
||||
.select(({ fn }) => fn.max("id").as("maxId"))
|
||||
.executeTakeFirstOrThrow()
|
||||
.then((row) => row.maxId);
|
||||
|
||||
for (let tournamentId = 1; tournamentId <= maxId; tournamentId++) {
|
||||
try {
|
||||
const tournament = await tournamentFromDB({
|
||||
tournamentId,
|
||||
user: undefined,
|
||||
});
|
||||
|
||||
if (!tournament.ctx.isFinalized) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tournament.skillCountsFor === "RANKED" && type === "RANKED") {
|
||||
yield tournament;
|
||||
} else if (
|
||||
tournament.skillCountsFor === "UNRANKED" &&
|
||||
type === "UNRANKED"
|
||||
) {
|
||||
yield tournament;
|
||||
}
|
||||
} catch (err) {
|
||||
// logger.info(`Skipped tournament with id ${tournamentId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Loading…
Reference in New Issue
Block a user