mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-27 04:54:25 -05:00
Derive tournament 1v1/2v2/3v3 tags from team size
This commit is contained in:
@@ -10,7 +10,11 @@ import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import type { TeamPickSettings, TournamentSettings } from "~/db/tables-json";
|
||||
import { EXCLUDED_TAGS } from "~/features/calendar/calendar-constants";
|
||||
import {
|
||||
CALENDAR_EVENT,
|
||||
EXCLUDED_TAGS,
|
||||
} from "~/features/calendar/calendar-constants";
|
||||
import * as CalendarEvent from "~/features/calendar/core/CalendarEvent";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import * as TeamPick from "~/features/tournament/core/TeamPick";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
@@ -41,7 +45,10 @@ import {
|
||||
normalizedTeamCount,
|
||||
tournamentIsRanked,
|
||||
} from "../tournament/tournament-utils";
|
||||
import type { CalendarEvent, CalendarEventTag } from "./calendar-types";
|
||||
import type {
|
||||
CalendarEventTag,
|
||||
CalendarEvent as CalendarEventType,
|
||||
} from "./calendar-types";
|
||||
import { calendarEventSorter } from "./calendar-utils";
|
||||
|
||||
const RECENT_TOURNAMENTS_SHOWN = 10;
|
||||
@@ -202,18 +209,38 @@ function findAllBetweenTwoTimestampsQuery({
|
||||
.execute();
|
||||
}
|
||||
|
||||
// virtual tags: leagues and team sizes of tournaments are told apart by their settings, not by anything the organizer picks
|
||||
function calendarEventTags({
|
||||
tags,
|
||||
tournamentSettings,
|
||||
}: Pick<
|
||||
Awaited<ReturnType<typeof findAllBetweenTwoTimestampsQuery>>[number],
|
||||
"tags" | "tournamentSettings"
|
||||
>): Array<CalendarEventTag> {
|
||||
const versusTag = tournamentSettings
|
||||
? CalendarEvent.versusTag(tournamentSettings.minMembersPerTeam ?? 4)
|
||||
: null;
|
||||
const withVersusTag = versusTag
|
||||
? [...(tags ?? []), versusTag].toSorted(
|
||||
(a, b) =>
|
||||
CALENDAR_EVENT.TAGS.indexOf(a) - CALENDAR_EVENT.TAGS.indexOf(b),
|
||||
)
|
||||
: (tags ?? []);
|
||||
|
||||
return tournamentSettings?.isLeague
|
||||
? ["LEAGUE", ...withVersusTag]
|
||||
: withVersusTag;
|
||||
}
|
||||
|
||||
function findAllBetweenTwoTimestampsMapped(
|
||||
rows: Awaited<ReturnType<typeof findAllBetweenTwoTimestampsQuery>>,
|
||||
): Array<{
|
||||
at: number;
|
||||
events: Array<CalendarEvent>;
|
||||
events: Array<CalendarEventType>;
|
||||
}> {
|
||||
const mapped: Array<CalendarEvent & { startsAt: number }> = rows.map(
|
||||
const mapped: Array<CalendarEventType & { startsAt: number }> = rows.map(
|
||||
(row) => {
|
||||
// a virtual tag: leagues are told apart by their setting, not by anything the organizer picks
|
||||
const tags: Array<CalendarEventTag> = row.tournamentSettings?.isLeague
|
||||
? ["LEAGUE", ...(row.tags ?? [])]
|
||||
: (row.tags ?? []);
|
||||
const tags = calendarEventTags(row);
|
||||
|
||||
const isPastEvent =
|
||||
databaseTimestampToDate(row.startsAt) < sub(new Date(), { days: 1 });
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
teamPickSettingsFromFormValues,
|
||||
} from "../calendar-new-schemas";
|
||||
import { formValuesToInputBrackets } from "../calendar-progression-form";
|
||||
import type { CalendarEventTag } from "../calendar-types";
|
||||
import * as CalendarEvent from "../core/CalendarEvent";
|
||||
import { findValidOrganizations } from "../loaders/calendar.new.server";
|
||||
|
||||
export const action = defineAction(
|
||||
@@ -119,13 +121,11 @@ export const action = defineAction(
|
||||
discordInviteCode: data.discordInviteCode
|
||||
? pathnameFromPotentialURL(data.discordInviteCode)
|
||||
: data.discordInviteCode,
|
||||
tags:
|
||||
data.tags.length > 0
|
||||
? data.tags.toSorted(
|
||||
(a, b) =>
|
||||
CALENDAR_EVENT.TAGS.indexOf(a) - CALENDAR_EVENT.TAGS.indexOf(b),
|
||||
)
|
||||
: null,
|
||||
tags: sortedTagsOrNull(
|
||||
isAddingTournament
|
||||
? data.tags.filter((tag) => !CalendarEvent.isVersusTag(tag))
|
||||
: data.tags,
|
||||
),
|
||||
badges: data.trophyId
|
||||
? []
|
||||
: data.badges.filter((badge) => attachableBadgeIds.has(badge)),
|
||||
@@ -223,6 +223,14 @@ export const action = defineAction(
|
||||
},
|
||||
);
|
||||
|
||||
function sortedTagsOrNull(tags: Array<CalendarEventTag>) {
|
||||
if (tags.length === 0) return null;
|
||||
|
||||
return tags.toSorted(
|
||||
(a, b) => CALENDAR_EVENT.TAGS.indexOf(a) - CALENDAR_EVENT.TAGS.indexOf(b),
|
||||
);
|
||||
}
|
||||
|
||||
/** Bracket progression from the `brackets` + `progression` fields, already validated by the schema's refine. */
|
||||
function bracketProgressionFromFormData(data: {
|
||||
toToolsEnabled: boolean;
|
||||
|
||||
@@ -32,7 +32,20 @@ import {
|
||||
progressionFormField,
|
||||
validateBracketProgressionFormValues,
|
||||
} from "./calendar-progression-form";
|
||||
import type { CalendarEventTag } from "./calendar-types";
|
||||
import { calendarEventMaxDate, calendarEventMinDate } from "./calendar-utils";
|
||||
import * as CalendarEvent from "./core/CalendarEvent";
|
||||
|
||||
// the league tag is derived from the league setting, never picked by hand
|
||||
const PICKABLE_TAGS = CALENDAR_EVENT.TAGS.filter((tag) => tag !== "LEAGUE");
|
||||
|
||||
/** The `tags` field as rendered for tournaments, their team size tag is derived from `minMembersPerTeam` instead. */
|
||||
export const tournamentTagsField = checkboxGroup({
|
||||
label: "labels.tags",
|
||||
items: tagItems(
|
||||
PICKABLE_TAGS.filter((tag) => !CalendarEvent.isVersusTag(tag)),
|
||||
),
|
||||
});
|
||||
|
||||
/** Single date row of the {@link calendarNewBaseSchema} `date` array (calendar events). */
|
||||
const calendarEventDateField = datetime({
|
||||
@@ -126,11 +139,7 @@ export const calendarNewBaseSchema = v.object({
|
||||
}),
|
||||
tags: checkboxGroup({
|
||||
label: "labels.tags",
|
||||
// derived from the league setting, never picked by hand
|
||||
items: CALENDAR_EVENT.TAGS.filter((tag) => tag !== "LEAGUE").map((tag) => ({
|
||||
value: tag,
|
||||
label: `options.tag.${tag}` as const,
|
||||
})),
|
||||
items: tagItems(PICKABLE_TAGS),
|
||||
}),
|
||||
badges: badges({ label: "labels.badges", maxCount: 50 }),
|
||||
trophyId: customField({ initialValue: null }, v.nullish(id)),
|
||||
@@ -266,6 +275,13 @@ export const TOURNAMENT_FORM_STEPS: ReadonlyArray<
|
||||
},
|
||||
];
|
||||
|
||||
function tagItems<T extends CalendarEventTag>(tags: Array<T>) {
|
||||
return tags.map((tag) => ({
|
||||
value: tag,
|
||||
label: `options.tag.${tag}` as const,
|
||||
}));
|
||||
}
|
||||
|
||||
function calendarNewSyncRefine(
|
||||
data: v.InferOutput<typeof calendarNewBaseSchema>,
|
||||
ctx: ValidationCtx,
|
||||
|
||||
@@ -147,6 +147,24 @@ describe("CalendarEvent.applyFilters", () => {
|
||||
expect(result[0].events.shown.map((e) => e.id)).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("filters by preferredVersus using a tournament's team size when it has no versus tag", () => {
|
||||
const events = [
|
||||
{
|
||||
at: 123,
|
||||
events: [
|
||||
makeEvent({ id: 1, tags: [], minMembersPerTeam: 2 }),
|
||||
makeEvent({ id: 2, tags: [], minMembersPerTeam: 4 }),
|
||||
],
|
||||
},
|
||||
];
|
||||
const filters: CalendarFilters = {
|
||||
...CalendarEvent.defaultFilters(),
|
||||
preferredVersus: ["2v2"],
|
||||
};
|
||||
const result = CalendarEvent.applyFilters(events, filters);
|
||||
expect(result[0].events.shown.map((e) => e.id)).toEqual([1]);
|
||||
});
|
||||
|
||||
test("filters by modes (not exact)", () => {
|
||||
const events = [
|
||||
{
|
||||
|
||||
@@ -9,10 +9,19 @@ import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
|
||||
import { assertType } from "~/utils/types";
|
||||
import type {
|
||||
CalendarEvent,
|
||||
CalendarEventTag,
|
||||
CalendarFilters,
|
||||
GroupedCalendarEvents,
|
||||
} from "../calendar-types";
|
||||
|
||||
const MEMBERS_PER_TEAM_BY_VERSUS_TAG = {
|
||||
ONES: 1,
|
||||
DUOS: 2,
|
||||
TRIOS: 3,
|
||||
} as const satisfies Partial<Record<CalendarEventTag, number>>;
|
||||
|
||||
type VersusTag = keyof typeof MEMBERS_PER_TEAM_BY_VERSUS_TAG;
|
||||
|
||||
export const FILTERS_KEYS = [
|
||||
"preferredStartTime",
|
||||
"tagsIncluded",
|
||||
@@ -34,6 +43,22 @@ export const FILTERS_KEYS = [
|
||||
assertType<(typeof FILTERS_KEYS)[number], keyof CalendarFilters>();
|
||||
assertType<keyof CalendarFilters, (typeof FILTERS_KEYS)[number]>();
|
||||
|
||||
/** Whether the tag tells the team size (1v1, 2v2 or 3v3). Tournaments never store these, their tag is derived from `minMembersPerTeam` via {@link versusTag}. */
|
||||
export function isVersusTag(tag: CalendarEventTag): tag is VersusTag {
|
||||
return Object.hasOwn(MEMBERS_PER_TEAM_BY_VERSUS_TAG, tag);
|
||||
}
|
||||
|
||||
/** The tag telling the team size of a tournament with the given minimum members per team, null for 4v4. */
|
||||
export function versusTag(minMembersPerTeam: number): VersusTag | null {
|
||||
for (const [tag, membersPerTeam] of Object.entries(
|
||||
MEMBERS_PER_TEAM_BY_VERSUS_TAG,
|
||||
)) {
|
||||
if (membersPerTeam === minMembersPerTeam) return tag as VersusTag;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Default empty calendar filters. */
|
||||
export function defaultFilters(): CalendarFilters {
|
||||
return {
|
||||
@@ -200,23 +225,14 @@ function matchesFilter(
|
||||
return true;
|
||||
}
|
||||
|
||||
const eventType = event.tags.find(
|
||||
(tag) => tag === "ONES" || tag === "DUOS" || tag === "TRIOS",
|
||||
);
|
||||
const tag = event.tags.find(isVersusTag);
|
||||
const membersPerTeam = tag
|
||||
? MEMBERS_PER_TEAM_BY_VERSUS_TAG[tag]
|
||||
: event.minMembersPerTeam;
|
||||
|
||||
if (eventType === "ONES") {
|
||||
return preferredVersus.includes("1v1");
|
||||
}
|
||||
const versus = `${membersPerTeam}v${membersPerTeam}`;
|
||||
|
||||
if (eventType === "DUOS") {
|
||||
return preferredVersus.includes("2v2");
|
||||
}
|
||||
|
||||
if (eventType === "TRIOS") {
|
||||
return preferredVersus.includes("3v3");
|
||||
}
|
||||
|
||||
return preferredVersus.includes("4v4");
|
||||
return preferredVersus.some((preferred) => preferred === versus);
|
||||
}
|
||||
case "modes": {
|
||||
const modes = filters[key];
|
||||
|
||||
@@ -56,6 +56,7 @@ import {
|
||||
type TeamPickCountsFormValue,
|
||||
TOURNAMENT_FORM_STEPS,
|
||||
teamPickSettingsFromFormValues,
|
||||
tournamentTagsField,
|
||||
} from "../calendar-new-schemas";
|
||||
import {
|
||||
type BracketFormValue,
|
||||
@@ -403,7 +404,7 @@ function TournamentSteps() {
|
||||
<FormField name="rules" />
|
||||
<FormField name="avatarImgId" />
|
||||
<FormField name="discordInviteCode" />
|
||||
<FormField name="tags" />
|
||||
<FormField name="tags" field={tournamentTagsField} />
|
||||
</div>
|
||||
</FormStep>
|
||||
|
||||
|
||||
5
changelog/2026-10-21-tournament-versus-tags.md
Normal file
5
changelog/2026-10-21-tournament-versus-tags.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
navItem: calendar
|
||||
type: feature
|
||||
---
|
||||
Tournament 1v1, 2v2 and 3v3 tags now follow the picked team size automatically
|
||||
21
migrations/20261021070535-derive-tournament-versus-tags.ts
Normal file
21
migrations/20261021070535-derive-tournament-versus-tags.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { type Kysely, sql } from "kysely";
|
||||
|
||||
/** A tournament's 1v1, 2v2 and 3v3 tags are now derived from its `minMembersPerTeam` setting, this clears the stored ones. */
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.transaction().execute(async (trx) => {
|
||||
await sql`
|
||||
update "CalendarEvent"
|
||||
set "tags" = (
|
||||
select case when count(*) = 0 then null else json_group_array("value") end
|
||||
from json_each("CalendarEvent"."tags")
|
||||
where "value" not in ('ONES', 'DUOS', 'TRIOS')
|
||||
)
|
||||
where "tournamentId" is not null
|
||||
and exists (
|
||||
select 1
|
||||
from json_each("CalendarEvent"."tags")
|
||||
where "value" in ('ONES', 'DUOS', 'TRIOS')
|
||||
)
|
||||
`.execute(trx);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user