Seeding points (#1969)
Some checks are pending
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run

* Initial

* Seed page initial

* Progress

* tests

* Fix e2e tests
This commit is contained in:
Kalle 2024-11-23 12:42:52 +02:00 committed by GitHub
parent e59b2718c8
commit feebdfaf54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 547 additions and 472 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

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

View 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();
})();
}

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