Merge branch 'main' into css-rework-sidenav

This commit is contained in:
Kalle 2026-02-22 15:29:30 +02:00
commit a76e06f0bc
281 changed files with 10718 additions and 4340 deletions

View File

@ -9,6 +9,8 @@ updates:
update-types:
- "minor"
- "patch"
exclude-patterns:
- "@biomejs/*"
ignore:
- dependency-name: "tldraw"
- dependency-name: "@tldraw/*"

View File

@ -3,7 +3,7 @@
- only rarely use comments, prefer descriptive variable and function names (leave existing comments as is)
- if you encounter an existing TODO comment assume it is there for a reason and do not remove it
- task is not considered completely until `npm run checks` passes
- normal file structure has constants at the top immediately followed by the main function body of the file. Helpers are used to structure the code and they are at the bottom of the file (then hoisted to the top)
- normal file structure has constants at the top immediately followed by the main function body of the file. Helpers are used to structure the code and they are at the bottom of the file (main implementation first, at the top of the file)
- note: any formatting issue (such as tabs vs. spaces) can be resolved by running the `npm run biome:fix` command
## Commands
@ -17,7 +17,7 @@
## Typescript
- prefer early return over nesting if statements
- prefer early return over nesting if statements (bouncer pattern)
- do not use `any` type
- for constants use ALL_CAPS
- always use named exports

View File

@ -13,6 +13,7 @@ const dimensions = {
sm: 44,
xsm: 62,
md: 81,
xmd: 94,
lg: 125,
} as const;
@ -129,7 +130,7 @@ export function Avatar({
? discordAvatarUrl({
discordAvatar: user.discordAvatar,
discordId: user.discordId,
size: size === "lg" ? "lg" : "sm",
size: size === "lg" || size === "xmd" ? "lg" : "sm",
})
: isClient
? generateIdenticon(identiconSource(), dimensions[size], 7)

View File

@ -3,18 +3,39 @@ import * as React from "react";
// note: markdown-to-jsx also handles these, this is just to prevent them from appearing as plain text
const DANGEROUS_HTML_TAGS_REGEX =
/<(style|iframe|script|title|textarea|xmp|noembed|noframes|plaintext)[\s\S]*?<\/\1>|<(style|iframe|script|title|textarea|xmp|noembed|noframes|plaintext)[^>]*\/>/gi;
/<(style|link|head|iframe|script|title|textarea|xmp|noembed|noframes|plaintext)[\s\S]*?<\/\1>|<(style|link|head|iframe|script|title|textarea|xmp|noembed|noframes|plaintext)[^>]*\/>/gi;
// note: this is not handled by markdown-to-jsx currently
const INLINE_STYLE_REGEX = /\s*style\s*=\s*(?:"[^"]*"|'[^']*')/gi;
const CSS_URL_REGEX = /url\s*\([^)]*\)/gi;
export function Markdown({ children }: { children: string }) {
const sanitized = children
.replace(DANGEROUS_HTML_TAGS_REGEX, "")
.replace(INLINE_STYLE_REGEX, "");
.replace(/style\s*=\s*("[^"]*"|'[^']*')/gi, (_match, value) => {
const sanitized = value.replace(CSS_URL_REGEX, "");
return `style=${sanitized}`;
});
return (
<MarkdownToJsx options={{ wrapper: React.Fragment }}>
<MarkdownToJsx
options={{
wrapper: React.Fragment,
overrides: {
br: { component: () => <br /> },
hr: { component: () => <hr /> },
img: {
component: ({
children: _,
...props
}: React.ComponentProps<"img"> & {
children?: React.ReactNode;
}) => (
// biome-ignore lint/a11y/useAltText: parsed markdown, so we can't guarantee alt text is present
<img {...props} />
),
},
},
}}
>
{sanitized}
</MarkdownToJsx>
);

View File

@ -28,7 +28,7 @@ export default function TimePopover({
className?: string;
footerText?: string;
}) {
const { formatDateTime, formatTime } = useTimeFormat();
const { formatDateTimeSmartMinutes, formatTime } = useTimeFormat();
const [open, setOpen] = useState(false);
@ -63,7 +63,7 @@ export default function TimePopover({
setOpen(true);
}}
>
{formatDateTime(time, options)}
{formatDateTimeSmartMinutes(time, options)}
</button>
<Popover
isOpen={open}

View File

@ -9,6 +9,7 @@ import {
ModalOverlay,
} from "react-aria-components";
import { useNavigate } from "react-router";
import * as R from "remeda";
import { SendouButton } from "~/components/elements/Button";
import styles from "./Dialog.module.css";
@ -68,7 +69,9 @@ export function SendouDialog({
return (
<DialogTrigger>
{trigger}
<DialogModal {...rest}>{children}</DialogModal>
<DialogModal {...rest} isControlledByTrigger>
{children}
</DialogModal>
</DialogTrigger>
);
}
@ -79,8 +82,9 @@ function DialogModal({
showHeading = true,
className,
showCloseButton: showCloseButtonProp,
isControlledByTrigger,
...rest
}: Omit<SendouDialogProps, "trigger">) {
}: Omit<SendouDialogProps, "trigger"> & { isControlledByTrigger?: boolean }) {
const navigate = useNavigate();
const showCloseButton = showCloseButtonProp || rest.onClose || rest.onCloseTo;
@ -92,7 +96,7 @@ function DialogModal({
}
};
const onOpenChange = (isOpen: boolean) => {
const defaultOnOpenChange = (isOpen: boolean) => {
if (!isOpen) {
if (rest.onCloseTo) {
navigate(rest.onCloseTo);
@ -102,13 +106,16 @@ function DialogModal({
}
};
const overlayProps = isControlledByTrigger
? R.omit(rest, ["onOpenChange"])
: { ...rest, onOpenChange: rest.onOpenChange ?? defaultOnOpenChange };
return (
<ModalOverlay
className={clsx(rest.overlayClassName, styles.overlay, {
[styles.fullScreenOverlay]: rest.isFullScreen,
})}
onOpenChange={rest.onOpenChange ?? onOpenChange}
{...rest}
{...overlayProps}
>
<Modal
className={clsx(className, styles.modal, "scrollbar", {

View File

@ -0,0 +1,33 @@
export function MainSlotIcon({
className,
size,
}: {
className?: string;
size?: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
className={className}
width={size}
height={size}
>
{/* Left column - filled */}
<path
d="M3 6a3 3 0 0 1 3 -3h7v18h-7a3 3 0 0 1 -3 -3z"
fill="currentColor"
stroke="none"
/>
{/* Right column - outlined */}
<path
d="M13 4h5a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}

View File

@ -0,0 +1,20 @@
export function SideSlotIcon({
className,
size,
}: {
className?: string;
size?: number;
}) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
width={size}
height={size}
>
<path d="M6 21a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v12a3 3 0 0 1 -3 3zm8 -16h-8a1 1 0 0 0 -1 1v12a1 1 0 0 0 1 1h8z" />
</svg>
);
}

View File

@ -29,13 +29,10 @@ import {
summarizeMaps,
summarizePlayerResults,
} from "~/features/sendouq-match/core/summarizer.server";
import * as PlayerStatRepository from "~/features/sendouq-match/PlayerStatRepository.server";
import { winnersArrayToWinner } from "~/features/sendouq-match/q-match-utils";
import { addMapResults } from "~/features/sendouq-match/queries/addMapResults.server";
import { addPlayerResults } from "~/features/sendouq-match/queries/addPlayerResults.server";
import { addReportedWeapons } from "~/features/sendouq-match/queries/addReportedWeapons.server";
import { addSkills } from "~/features/sendouq-match/queries/addSkills.server";
import { reportScore } from "~/features/sendouq-match/queries/reportScore.server";
import { setGroupAsInactive } from "~/features/sendouq-match/queries/setGroupAsInactive.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SkillRepository from "~/features/sendouq-match/SkillRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
@ -179,6 +176,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [
fixAdminId,
makeArtists,
adminUserWeaponPool,
adminUserWidgets,
userProfiles,
userMapModePreferences,
userQWeaponPool,
@ -378,6 +376,30 @@ function adminUserWeaponPool() {
}
}
async function adminUserWidgets() {
await UserRepository.upsertWidgets(ADMIN_ID, [
{
id: "bio",
settings: { bio: "" },
},
{
id: "badges-owned",
},
{
id: "teams",
},
{
id: "organizations",
},
{
id: "peak-sp",
},
{
id: "peak-xp",
},
]);
}
function nzapUser() {
return UserRepository.upsert({
discordId: NZAP_TEST_DISCORD_ID,
@ -2343,28 +2365,28 @@ async function playedMatches() {
groupId: match.bravoGroupId,
})),
];
sql.transaction(() => {
reportScore({
matchId: match.id,
reportedByUserId:
faker.number.float(1) > 0.5
? groupAlphaMembers[0]
: groupBravoMembers[0],
winners,
});
addSkills({
skills: newSkills,
differences,
groupMatchId: match.id,
oldMatchMemento: { users: {}, groups: {}, pools: [] },
});
setGroupAsInactive(groupAlpha);
setGroupAsInactive(groupBravo);
addMapResults(summarizeMaps({ match: finishedMatch, members, winners }));
addPlayerResults(
summarizePlayerResults({ match: finishedMatch, members, winners }),
);
})();
await SQMatchRepository.updateScore({
matchId: match.id,
reportedByUserId:
faker.number.float(1) > 0.5
? groupAlphaMembers[0]
: groupBravoMembers[0],
winners,
});
await SkillRepository.createMatchSkills({
skills: newSkills,
differences,
groupMatchId: match.id,
oldMatchMemento: { users: {}, groups: {}, pools: [] },
});
await SQGroupRepository.setAsInactive(groupAlpha);
await SQGroupRepository.setAsInactive(groupBravo);
await PlayerStatRepository.upsertMapResults(
summarizeMaps({ match: finishedMatch, members, winners }),
);
await PlayerStatRepository.upsertPlayerResults(
summarizePlayerResults({ match: finishedMatch, members, winners }),
);
// -> add weapons for 90% of matches
if (faker.number.float(1) > 0.9) continue;
@ -2373,7 +2395,7 @@ async function playedMatches() {
finishedMatch.mapList.map((m) => ({ map: m, user: u })),
);
addReportedWeapons(
await ReportedWeaponRepository.createMany(
mapsWithUsers.map((mu) => {
const weapon = () => {
if (faker.number.float(1) < 0.9) return defaultWeapons[mu.user];

View File

@ -16,6 +16,7 @@ import type { TEAM_MEMBER_ROLES } from "~/features/team/team-constants";
import type { TournamentTierNumber } from "~/features/tournament/core/tiering";
import type * as PickBan from "~/features/tournament-bracket/core/PickBan";
import type * as Progression from "~/features/tournament-bracket/core/Progression";
import type { StoredWidget } from "~/features/user-page/core/widgets/types";
import type { ParticipantResult } from "~/modules/brackets-model";
import type {
Ability,
@ -509,6 +510,7 @@ export interface TournamentSettings {
/** Maximum number of team members that can be registered (only applies to 4v4 tournaments) */
maxMembersPerTeam?: number;
isTest?: boolean;
isDraft?: boolean;
}
export interface CastedMatchesInfo {
@ -912,6 +914,8 @@ export interface UserPreferences {
* "12h" = 12 hour format (e.g. 2:00 PM)
* */
clockFormat?: "24h" | "12h" | "auto";
/** Is the new widget based user page enabled? (Supporter early preview) */
newProfileEnabled?: boolean;
}
export const SUBJECT_PRONOUNS = ["he", "she", "they", "it", "any"] as const;
@ -1012,6 +1016,11 @@ export interface UserFriendCode {
createdAt: GeneratedAlways<number>;
}
export interface UserWidget {
userId: number;
index: number;
widget: JSONColumnType<StoredWidget>;
}
export type ApiTokenType = "read" | "write";
export interface ApiToken {
@ -1276,6 +1285,7 @@ export interface DB {
UserSubmittedImage: UserSubmittedImage;
UserWeapon: UserWeapon;
UserFriendCode: UserFriendCode;
UserWidget: UserWidget;
Video: Video;
VideoMatch: VideoMatch;
VideoMatchPlayer: VideoMatchPlayer;

View File

@ -1,10 +1,10 @@
import type { Transaction } from "kysely";
import { db, sql } from "~/db/sql";
import type { DB, Tables, TablesInsertable } from "~/db/tables";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import { syncXPBadges } from "../badges/queries/syncXPBadges.server";
const removeOldLikesStm = sql.prepare(/*sql*/ `
delete from
@ -239,7 +239,7 @@ export async function linkUserAndPlayer({
.where("SplatoonPlayer.id", "=", playerId)
.execute();
syncXPBadges();
await BadgeRepository.syncXPBadges();
await BuildRepository.recalculateAllTop500();
}

View File

@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { db } from "~/db/sql";
import { dbInsertUsers, dbReset } from "~/utils/Test";
import * as BadgeRepository from "./BadgeRepository.server";
import { SPLATOON_3_XP_BADGE_VALUES } from "./badges-constants";
describe("syncXPBadges", () => {
beforeEach(async () => {
await dbInsertUsers(3);
await insertXPBadges();
});
afterEach(() => {
dbReset();
});
test("assigns badge to user with qualifying peakXp", async () => {
await insertSplatoonPlayer({ splId: "abc123", userId: 1, peakXp: 3000 });
await BadgeRepository.syncXPBadges();
const badge = await findBadgeByCode("3000");
expect(badge?.owners).toHaveLength(1);
expect(badge?.owners[0].id).toBe(1);
});
test("assigns highest qualifying badge when peakXp exceeds threshold", async () => {
await insertSplatoonPlayer({ splId: "abc123", userId: 1, peakXp: 3250 });
await BadgeRepository.syncXPBadges();
const badge3200 = await findBadgeByCode("3200");
const badge3300 = await findBadgeByCode("3300");
expect(badge3200?.owners).toHaveLength(1);
expect(badge3300?.owners).toHaveLength(0);
});
test("does not assign badge when peakXp is below minimum threshold", async () => {
await insertSplatoonPlayer({ splId: "abc123", userId: 1, peakXp: 2500 });
await BadgeRepository.syncXPBadges();
const badge2600 = await findBadgeByCode("2600");
expect(badge2600?.owners).toHaveLength(0);
});
});
async function insertXPBadges() {
await db
.insertInto("Badge")
.values(
SPLATOON_3_XP_BADGE_VALUES.map((value) => ({
code: String(value),
displayName: `${value}+ XP`,
hue: null,
authorId: null,
})),
)
.execute();
}
async function insertSplatoonPlayer(args: {
splId: string;
userId: number | null;
peakXp: number | null;
}) {
await db.insertInto("SplatoonPlayer").values(args).execute();
}
async function findBadgeByCode(code: string) {
const badges = await BadgeRepository.all();
const badge = badges.find((b) => b.code === code);
if (!badge) return null;
return BadgeRepository.findById(badge.id);
}

View File

@ -1,8 +1,12 @@
import type { ExpressionBuilder } from "kysely";
import type { ExpressionBuilder, NotNull } from "kysely";
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { DB } from "~/db/tables";
import { sortBadgesByFavorites } from "~/features/user-page/core/badge-sorting.server";
import invariant from "~/utils/invariant";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
import { SPLATOON_3_XP_BADGE_VALUES } from "./badges-constants";
import { findSplatoon3XpBadgeValue } from "./badges-utils";
const addPermissions = <T extends { managers: { userId: number }[] }>(
row: T,
@ -107,6 +111,46 @@ export function findManagedByUserId(userId: number) {
.execute();
}
export async function findByOwnerUserId(userId: number) {
const rows = await db
.selectFrom("BadgeOwner")
.innerJoin("Badge", "Badge.id", "BadgeOwner.badgeId")
.innerJoin("User", "User.id", "BadgeOwner.userId")
.select(({ fn }) => [
fn.count<number>("BadgeOwner.badgeId").as("count"),
"Badge.id",
"Badge.displayName",
"Badge.code",
"Badge.hue",
"User.favoriteBadgeIds",
"User.patronTier",
])
.where("BadgeOwner.userId", "=", userId)
.groupBy(["BadgeOwner.badgeId", "BadgeOwner.userId"])
.execute();
if (rows.length === 0) return [];
const { favoriteBadgeIds, patronTier } = rows[0];
return sortBadgesByFavorites({
favoriteBadgeIds,
badges: rows.map(
({ favoriteBadgeIds: _, patronTier: __, ...badge }) => badge,
),
patronTier,
}).badges;
}
export function findByAuthorUserId(userId: number) {
return db
.selectFrom("Badge")
.select(["Badge.id", "Badge.displayName", "Badge.code", "Badge.hue"])
.where("Badge.authorId", "=", userId)
.groupBy("Badge.id")
.execute();
}
export function replaceManagers({
badgeId,
managerIds,
@ -160,3 +204,46 @@ export function replaceOwners({
}
});
}
export async function syncXPBadges() {
return db.transaction().execute(async (trx) => {
for (const value of SPLATOON_3_XP_BADGE_VALUES) {
const badge = await trx
.selectFrom("Badge")
.select("id")
.where("code", "=", String(value))
.executeTakeFirst();
invariant(badge, `Badge ${value} not found`);
await trx
.deleteFrom("TournamentBadgeOwner")
.where("badgeId", "=", badge.id)
.execute();
}
const userTopXPowers = await trx
.selectFrom("SplatoonPlayer")
.select(["userId", "peakXp"])
.where("userId", "is not", null)
.where("peakXp", "is not", null)
.$narrowType<{ userId: NotNull; peakXp: NotNull }>()
.execute();
for (const { userId, peakXp } of userTopXPowers) {
const badgeValue = findSplatoon3XpBadgeValue(peakXp!);
if (!badgeValue) continue;
await trx
.insertInto("TournamentBadgeOwner")
.values((eb) => ({
badgeId: eb
.selectFrom("Badge")
.select("id")
.where("code", "=", String(badgeValue)),
userId,
}))
.execute();
}
});
}

View File

@ -19,6 +19,14 @@
"displayName": "Silver Bullet (Winning Silver Bracket 50 Calibur)",
"authorDiscordId": "530093822806458368"
},
"abyssalcrystalblue": {
"displayName": "Abyssal Arena",
"authorDiscordId": "338806780446638082"
},
"abyssalcrystalred": {
"displayName": "Abyssal Arena Special Edition",
"authorDiscordId": "338806780446638082"
},
"academy_showcase": {
"displayName": "Academy Showcase",
"authorDiscordId": "643355948265766912"
@ -75,6 +83,10 @@
"displayName": "Barnacle Bash",
"authorDiscordId": "181270348262539265"
},
"bcoboon": {
"displayName": "Barracuda Boon",
"authorDiscordId": "338103199900893184"
},
"beach223": {
"displayName": "Summer Days",
"authorDiscordId": "1170249805373657093"
@ -935,6 +947,14 @@
"displayName": "The REC Center",
"authorDiscordId": "1320944066002681876"
},
"redcrimsonbucket": {
"displayName": "Crimson Bucket",
"authorDiscordId": "338806780446638082"
},
"redenrumble": {
"displayName": "Reden's Rumble",
"authorDiscordId": "338806780446638082"
},
"remix": {
"displayName": "Remix Rumble",
"authorDiscordId": "528851510222782474"
@ -1063,6 +1083,18 @@
"displayName": "Shrimp of the Day",
"authorDiscordId": "309327923129745409"
},
"shs01": {
"displayName": "Sweetheart Splats",
"authorDiscordId": "707620991500550235"
},
"shs02": {
"displayName": "Sweetheart Splats 2nd place",
"authorDiscordId": "707620991500550235"
},
"shs03": {
"displayName": "Sweetheart Splats 3rd place",
"authorDiscordId": "707620991500550235"
},
"shscap": {
"displayName": "Sheep Happens (Skill Capped)",
"authorDiscordId": "758345384585592902"
@ -1071,6 +1103,10 @@
"displayName": "Leblanc Hideout Beta Bracket",
"authorDiscordId": "338806780446638082"
},
"skycat": {
"displayName": "Skycat Achievements",
"authorDiscordId": "776557393848434718"
},
"slyeion": {
"displayName": "Sylveon",
"authorDiscordId": "338806780446638082"
@ -1359,6 +1395,10 @@
"displayName": "Urchin-Cup",
"authorDiscordId": "338806780446638082"
},
"valentines2v2tuesdays": {
"displayName": "Valentines 2v2 TUESDAYS",
"authorDiscordId": "754368445910614046"
},
"vchargersplash": {
"displayName": "Charger Assault",
"authorDiscordId": "338806780446638082"

View File

@ -1,56 +0,0 @@
import { sql } from "~/db/sql";
import invariant from "~/utils/invariant";
import { SPLATOON_3_XP_BADGE_VALUES } from "../badges-constants";
import { findSplatoon3XpBadgeValue } from "../badges-utils";
const badgeCodeToIdStm = sql.prepare(/* sql */ `
select "id"
from "Badge"
where "code" = @code
`);
const deleteBadgeOwnerStm = sql.prepare(/* sql */ `
delete from "TournamentBadgeOwner"
where "badgeId" = @badgeId
`);
const userTopXPowersStm = sql.prepare(/* sql */ `
select
"SplatoonPlayer"."userId",
"SplatoonPlayer"."peakXp" as "xPower"
from
"SplatoonPlayer"
where "SplatoonPlayer"."userId" is not null
and "SplatoonPlayer"."peakXp" is not null
`);
const addXPBadgeStm = sql.prepare(/* sql */ `
insert into "TournamentBadgeOwner" ("badgeId", "userId")
values (
(select "id" from "Badge" where "code" = @code),
@userId
)
`);
export const syncXPBadges = sql.transaction(() => {
for (const value of SPLATOON_3_XP_BADGE_VALUES) {
const badgeId = (badgeCodeToIdStm.get({ code: String(value) }) as any)
.id as number;
invariant(badgeId, `Badge ${value} not found`);
deleteBadgeOwnerStm.run({ badgeId });
}
const userTopXPowers = userTopXPowersStm.all() as Array<{
userId: number;
xPower: number;
}>;
for (const { userId, xPower } of userTopXPowers) {
const badgeValue = findSplatoon3XpBadgeValue(xPower);
if (!badgeValue) continue;
addXPBadgeStm.run({ code: String(badgeValue), userId });
}
});

View File

@ -40,6 +40,8 @@ export const DAMAGE_TYPE = [
"SPECIAL_CANNON",
"SPECIAL_BULLET_MAX",
"SPECIAL_BULLET_MIN",
"SPECIAL_SPLASH_MAX",
"SPECIAL_SPLASH_MIN",
"SPECIAL_BUMP",
"SPECIAL_JUMP",
"SPECIAL_TICK",
@ -90,6 +92,8 @@ export const damageTypeToWeaponType: Record<
SPECIAL_THROW_DIRECT: "SPECIAL",
SPECIAL_BULLET_MIN: "SPECIAL",
SPECIAL_BULLET_MAX: "SPECIAL",
SPECIAL_SPLASH_MAX: "SPECIAL",
SPECIAL_SPLASH_MIN: "SPECIAL",
SPECIAL_CANNON: "SPECIAL",
SPECIAL_BUMP: "SPECIAL",
SPECIAL_JUMP: "SPECIAL",

View File

@ -182,6 +182,8 @@ export type SpecialWeaponParams = SpecialWeaponParamsObject[SpecialWeaponId] & {
ThrowDirectDamage?: number;
BulletDamageMin?: number;
BulletDamageMax?: number;
SplashDamageMax?: Array<DistanceDamage>;
SplashDamageMin?: Array<DistanceDamage>;
CannonDamage?: Array<DistanceDamage>;
BumpDamage?: number;
JumpDamage?: number;

View File

@ -73,6 +73,27 @@ export const SPECIAL_EFFECTS = [
},
],
},
{
type: "AURA",
values: [
{
type: "RSU",
ap: 30,
},
{
type: "SSU",
ap: 30,
},
{
type: "RES",
ap: 30,
},
{
type: "IA",
ap: 30,
},
],
},
{
type: "TACTICOOLER",
values: [

View File

@ -442,6 +442,8 @@ const damageTypeToParamsKey: Record<
SPECIAL_THROW_DIRECT: "ThrowDirectDamage",
SPECIAL_BULLET_MAX: "BulletDamageMax",
SPECIAL_BULLET_MIN: "BulletDamageMin",
SPECIAL_SPLASH_MAX: "SplashDamageMax",
SPECIAL_SPLASH_MIN: "SplashDamageMin",
SPECIAL_CANNON: "CannonDamage",
SPECIAL_BUMP: "BumpDamage",
SPECIAL_JUMP: "JumpDamage",
@ -999,10 +1001,13 @@ function superJumpTimeGroundFrames(
};
}
const STEALTH_JUMP_EXTRA_FRAMES = 60;
function superJumpTimeTotal(
args: StatFunctionInput,
): AnalyzedBuild["stats"]["superJumpTimeTotal"] {
const SUPER_JUMP_TIME_TOTAL_ABILITY = "QSJ";
const hasStealthJump = args.mainOnlyAbilities.includes("SJ");
const stealthJumpExtraFrames = hasStealthJump ? STEALTH_JUMP_EXTRA_FRAMES : 0;
const charge = abilityPointsToEffects({
abilityPoints: apFromMap({
@ -1025,8 +1030,12 @@ function superJumpTimeTotal(
baseValue: framesToSeconds(
Math.ceil(charge.baseEffect) + Math.ceil(move.baseEffect),
),
value: framesToSeconds(Math.ceil(charge.effect) + Math.ceil(move.effect)),
modifiedBy: SUPER_JUMP_TIME_TOTAL_ABILITY,
value: framesToSeconds(
Math.ceil(charge.effect) +
Math.ceil(move.effect) +
stealthJumpExtraFrames,
),
modifiedBy: [SUPER_JUMP_TIME_TOTAL_ABILITY, "SJ"],
};
}

View File

@ -172,18 +172,18 @@ export const weaponParams = {
Range_GoStraightToBrakeStateFrame: 7,
Range_FreeGravity: 0.016,
Range_ZRate: 2,
BlastRadius: 3.8,
BlastRadius: 4,
WeaponSpeedType: "Fast",
MoveSpeed: 0.05,
DamageParam_ValueDirect: 1250,
BlastParam_DistanceDamage: [
{
Damage: 700,
Distance: 1.6,
Distance: 1.8,
},
{
Damage: 500,
Distance: 3.8,
Distance: 4,
},
],
Jump_DegSwerve: 6,
@ -202,17 +202,17 @@ export const weaponParams = {
Range_GoStraightToBrakeStateFrame: 9,
Range_FreeGravity: 0.016,
Range_ZRate: 2,
BlastRadius: 3.3,
BlastRadius: 3.47,
MoveSpeed: 0.045,
DamageParam_ValueDirect: 1250,
BlastParam_DistanceDamage: [
{
Damage: 700,
Distance: 0.94,
Distance: 1.11,
},
{
Damage: 500,
Distance: 3.3,
Distance: 3.47,
},
],
Jump_DegSwerve: 10,
@ -231,17 +231,17 @@ export const weaponParams = {
Range_GoStraightToBrakeStateFrame: 11,
Range_FreeGravity: 0.016,
Range_ZRate: 2,
BlastRadius: 3.3,
BlastRadius: 3.37,
MoveSpeed: 0.04,
DamageParam_ValueDirect: 1250,
BlastParam_DistanceDamage: [
{
Damage: 700,
Distance: 0.94,
Distance: 1.01,
},
{
Damage: 500,
Distance: 3.3,
Distance: 3.37,
},
],
Jump_DegSwerve: 8,
@ -260,18 +260,18 @@ export const weaponParams = {
Range_GoStraightToBrakeStateFrame: 8,
Range_FreeGravity: 0.016,
Range_ZRate: 2,
BlastRadius: 4,
BlastRadius: 4.17,
WeaponSpeedType: "Fast",
MoveSpeed: 0.068,
DamageParam_ValueDirect: 600,
BlastParam_DistanceDamage: [
{
Damage: 300,
Distance: 1,
Distance: 1.17,
},
{
Damage: 300,
Distance: 4,
Distance: 4.17,
},
],
Jump_DegSwerve: 8,
@ -290,17 +290,17 @@ export const weaponParams = {
Range_GoStraightToBrakeStateFrame: 11,
Range_FreeGravity: 0.016,
Range_ZRate: 2,
BlastRadius: 3.3,
BlastRadius: 3.37,
MoveSpeed: 0.055,
DamageParam_ValueDirect: 850,
BlastParam_DistanceDamage: [
{
Damage: 350,
Distance: 0.94,
Distance: 1.01,
},
{
Damage: 350,
Distance: 3.3,
Distance: 3.37,
},
],
Jump_DegSwerve: 8,
@ -319,23 +319,23 @@ export const weaponParams = {
Range_GoStraightToBrakeStateFrame: 11,
Range_FreeGravity: 0.016,
Range_ZRate: 2,
BlastRadius: 3.3,
BlastRadius: 3.35,
MoveSpeed: 0.05,
DamageParam_ValueDirect: 850,
BlastParam_DistanceDamage: [
{
Damage: 350,
Distance: 0.94,
Distance: 0.99,
},
{
Damage: 350,
Distance: 3.3,
Distance: 3.35,
},
],
Jump_DegSwerve: 8,
Stand_DegSwerve: 0,
InkRecoverStop: 50,
InkConsume: 0.08,
InkConsume: 0.0912,
},
"260": {
overwrites: {
@ -348,17 +348,17 @@ export const weaponParams = {
Range_GoStraightToBrakeStateFrame: 7,
Range_FreeGravity: 0.016,
Range_ZRate: 2,
BlastRadius: 2,
BlastRadius: 2.07,
MoveSpeed: 0.04,
DamageParam_ValueDirect: 1250,
BlastParam_DistanceDamage: [
{
Damage: 700,
Distance: 1,
Distance: 1.07,
},
{
Damage: 500,
Distance: 2,
Distance: 2.07,
},
],
Jump_DegSwerve: 8,
@ -367,9 +367,9 @@ export const weaponParams = {
InkConsume: 0.095,
},
"300": {
Range_SpawnSpeed: 2.75,
Range_GoStraightStateEndMaxSpeed: 1.568,
Range_GoStraightToBrakeStateFrame: 4,
Range_SpawnSpeed: 3.413,
Range_GoStraightStateEndMaxSpeed: 1.946,
Range_GoStraightToBrakeStateFrame: 3,
Range_ZRate: 2,
TripleShotSpanFrame: 8,
MoveSpeed: 0.08,
@ -393,8 +393,8 @@ export const weaponParams = {
Range_ZRate: 2,
TripleShotSpanFrame: 20,
MoveSpeed: 0.06,
DamageParam_ValueMax: 440,
DamageParam_ValueMin: 220,
DamageParam_ValueMax: 450,
DamageParam_ValueMin: 225,
Jump_DegSwerve: 1,
Stand_DegSwerve: 1,
InkRecoverStop: 25,
@ -462,7 +462,7 @@ export const weaponParams = {
VerticalSwingUnitGroupParam_DamageParam_DamageMaxValue: 1800,
WideSwingUnitGroupParam_DamageParam_DamageMinValue: 400,
WideSwingUnitGroupParam_DamageParam_DamageMaxValue: 1800,
InkConsume_WeaponSwingParam: 0.18,
InkConsume_WeaponSwingParam: 0.21,
},
"1030": {
Range_SpawnSpeed: 1.7026,
@ -710,7 +710,7 @@ export const weaponParams = {
BlastParam_DistanceDamage: [
{
Damage: 350,
Distance: 2.8,
Distance: 2.87,
},
],
InkRecoverStop: 70,
@ -926,9 +926,9 @@ export const weaponParams = {
InkConsume_SideStepParam: 0.08,
},
"5040": {
Range_SpawnSpeed: 2.21,
Range_GoStraightStateEndMaxSpeed: 2.205,
Range_GoStraightToBrakeStateFrame: 4,
Range_SpawnSpeed: 2.637,
Range_GoStraightStateEndMaxSpeed: 2.631,
Range_GoStraightToBrakeStateFrame: 3,
Range_FreeGravity: 0.016,
Range_ZRate: 2,
MoveSpeed: 0.072,
@ -964,7 +964,7 @@ export const weaponParams = {
DamageParam_ValueMax: 810,
CanopyHP: 5000,
InkConsumeUmbrella_WeaponShelterCanopyParam: 0.3,
InkConsume_WeaponShelterShotgunParam: 0.055,
InkConsume_WeaponShelterShotgunParam: 0.05,
},
"6010": {
overwrites: {
@ -1014,14 +1014,14 @@ export const weaponParams = {
Range_BrakeAirResist: 0.1,
Range_BrakeGravity: 0.04,
Range_BrakeToFreeStateFrame: 1,
BlastRadius: 2,
BlastRadius: 2.05,
MoveSpeedFullCharge: 0.068,
DamageParam_ValueMax: 350,
DamageParam_ValueMin: 300,
BlastParam_DistanceDamage: [
{
Damage: 300,
Distance: 2,
Distance: 2.05,
},
],
ChargeFrameFullCharge: 72,
@ -1051,7 +1051,7 @@ export const weaponParams = {
Range_BrakeAirResist: 0.1,
Range_BrakeGravity: 0.04,
Range_BrakeToFreeStateFrame: 1,
BlastRadius: 2,
BlastRadius: 2.05,
WeaponSpeedType: "Slow",
MoveSpeedFullCharge: 0.04,
DamageParam_ValueMax: 350,
@ -1059,7 +1059,7 @@ export const weaponParams = {
BlastParam_DistanceDamage: [
{
Damage: 300,
Distance: 2,
Distance: 2.05,
},
],
ChargeFrameFullCharge: 80,
@ -1217,7 +1217,7 @@ export const weaponParams = {
specialWeaponId: 16,
},
"70": {
SpecialPoint: 180,
SpecialPoint: 190,
subWeaponId: 12,
specialWeaponId: 12,
},
@ -1352,7 +1352,7 @@ export const weaponParams = {
specialWeaponId: 13,
},
"261": {
SpecialPoint: 200,
SpecialPoint: 210,
subWeaponId: 2,
specialWeaponId: 6,
},
@ -1517,7 +1517,7 @@ export const weaponParams = {
specialWeaponId: 2,
},
"2001": {
SpecialPoint: 200,
SpecialPoint: 190,
subWeaponId: 7,
specialWeaponId: 3,
},
@ -1527,7 +1527,7 @@ export const weaponParams = {
specialWeaponId: 8,
},
"2011": {
SpecialPoint: 210,
SpecialPoint: 200,
subWeaponId: 4,
specialWeaponId: 14,
},
@ -1547,7 +1547,7 @@ export const weaponParams = {
specialWeaponId: 8,
},
"2021": {
SpecialPoint: 210,
SpecialPoint: 200,
subWeaponId: 4,
specialWeaponId: 14,
},
@ -1627,7 +1627,7 @@ export const weaponParams = {
specialWeaponId: 10,
},
"3011": {
SpecialPoint: 210,
SpecialPoint: 200,
subWeaponId: 5,
specialWeaponId: 15,
},
@ -1637,7 +1637,7 @@ export const weaponParams = {
specialWeaponId: 19,
},
"3020": {
SpecialPoint: 220,
SpecialPoint: 210,
subWeaponId: 5,
specialWeaponId: 6,
},
@ -1682,7 +1682,7 @@ export const weaponParams = {
specialWeaponId: 12,
},
"4000": {
SpecialPoint: 190,
SpecialPoint: 200,
subWeaponId: 2,
specialWeaponId: 11,
},
@ -2569,9 +2569,9 @@ export const weaponParams = {
Mid: 1.1,
},
PaintRadius: {
High: 10.889999999999999,
Mid: 9.982,
Low: 9.075,
High: 10.212,
Mid: 9.36,
Low: 8.51,
},
SplashAroundVelocityMin: {
High: 0.7,
@ -2587,7 +2587,7 @@ export const weaponParams = {
DistanceDamage: [
{
Damage: 2200,
Distance: 7.5,
Distance: 9,
},
{
Damage: 700,
@ -2632,11 +2632,13 @@ export const weaponParams = {
Mid: 1.1,
},
},
DistanceDamage: [
SplashDamageMax: [
{
Damage: 700,
Distance: 3.6,
},
],
SplashDamageMin: [
{
Damage: 350,
Distance: 6,

View File

@ -89,7 +89,7 @@ import {
} from "../core/utils";
import styles from "./analyzer.module.css";
export const CURRENT_PATCH = "10.1";
export const CURRENT_PATCH = "11.0.1";
export const meta: MetaFunction = (args) => {
return metaTags({
@ -1275,6 +1275,8 @@ function EffectsSelector({
<div>
{isAbility(effect.type) ? (
<Ability ability={effect.type} size="SUB" />
) : effect.type === "AURA" ? (
<span className="text-xs font-bold">AURA</span>
) : (
<Image
path={specialWeaponImageUrl(15)}

View File

@ -21,10 +21,14 @@ export async function allByUserId(
options: {
showPrivate?: boolean;
sortAbilities?: boolean;
limit?: number;
} = {},
) {
const { showPrivate = false, sortAbilities: shouldSortAbilities = false } =
options;
const {
showPrivate = false,
sortAbilities: shouldSortAbilities = false,
limit,
} = options;
const rows = await db
.selectFrom("Build")
.select(({ eb }) => [
@ -48,6 +52,8 @@ export async function allByUserId(
])
.where("Build.ownerId", "=", userId)
.$if(!showPrivate, (qb) => qb.where("Build.private", "=", 0))
.$if(typeof limit === "number", (qb) => qb.limit(limit!))
.orderBy("Build.updatedAt", "desc")
.execute();
return rows.map((row) => {

View File

@ -5,6 +5,10 @@ export const MAX_BUILD_FILTERS = 6;
export const FILTER_SEARCH_PARAM_KEY = "f";
export const PATCHES = [
{
patch: "11.0.0",
date: "2026-01-29",
},
{
patch: "10.1.0",
date: "2025-09-03",
@ -13,10 +17,10 @@ export const PATCHES = [
patch: "10.0.0",
date: "2025-06-12",
},
{
patch: "9.3.0",
date: "2025-03-13",
},
// {
// patch: "9.3.0",
// date: "2025-03-13",
// },
// {
// patch: "9.2.0",
// date: "2024-11-20",

View File

@ -101,7 +101,7 @@ function tournamentOrganization(organizationId: Expression<number | null>) {
"TournamentOrganization.slug",
"TournamentOrganization.isEstablished",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
"logoUrl",
),
])
.whereRef("TournamentOrganization.id", "=", organizationId),
@ -438,6 +438,7 @@ type CreateArgs = Pick<
requireInGameNames?: boolean;
isRanked?: boolean;
isTest?: boolean;
isDraft?: boolean;
isInvitational?: boolean;
enableNoScreenToggle?: boolean;
enableSubs?: boolean;
@ -472,6 +473,7 @@ export async function create(args: CreateArgs) {
thirdPlaceMatch: args.thirdPlaceMatch,
isRanked: args.isRanked,
isTest: args.isTest,
isDraft: args.isDraft,
isInvitational: args.isInvitational,
enableNoScreenToggle: args.enableNoScreenToggle,
enableSubs: args.enableSubs,
@ -536,7 +538,7 @@ export async function create(args: CreateArgs) {
bracketUrl: args.bracketUrl,
avatarImgId: args.avatarImgId ?? avatarImgId,
organizationId: args.organizationId,
hidden: args.parentTournamentId || args.isTest ? 1 : 0,
hidden: args.parentTournamentId || args.isTest || args.isDraft ? 1 : 0,
tournamentId,
})
.returning("id")
@ -616,6 +618,22 @@ export async function update(args: UpdateArgs) {
? await updateTournamentTables(args, trx, tournamentId)
: null;
if (tournamentId) {
const { parentTournamentId, settings: existingSettings } = await trx
.selectFrom("Tournament")
.select(["parentTournamentId", "settings"])
.where("id", "=", tournamentId)
.executeTakeFirstOrThrow();
const hidden =
existingSettings.isTest || parentTournamentId || args.isDraft ? 1 : 0;
await trx
.updateTable("CalendarEvent")
.set({ hidden })
.where("id", "=", args.eventId)
.execute();
}
await trx
.deleteFrom("CalendarEventDate")
.where("eventId", "=", args.eventId)
@ -668,6 +686,7 @@ async function updateTournamentTables(
thirdPlaceMatch: args.thirdPlaceMatch,
isRanked: args.isRanked,
isTest: existingSettings.isTest, // this one is not editable after creation
isDraft: args.isDraft,
isInvitational: args.isInvitational,
enableNoScreenToggle: args.enableNoScreenToggle,
enableSubs: args.enableSubs,

View File

@ -96,6 +96,7 @@ export const action: ActionFunction = async ({ request }) => {
maxMembersPerTeam: data.maxMembersPerTeam ?? undefined,
isRanked: data.isRanked ?? undefined,
isTest: data.isTest ?? undefined,
isDraft: data.isDraft ?? undefined,
isInvitational: data.isInvitational ?? false,
enableNoScreenToggle: data.enableNoScreenToggle ?? undefined,
enableSubs: data.enableSubs ?? undefined,

View File

@ -72,6 +72,7 @@ export const newCalendarEventActionSchema = z
toToolsMode: z.enum(["ALL", "TO", "SZ", "TC", "RM", "CB"]).optional(),
isRanked: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
isTest: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
isDraft: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()),
regClosesAt: z.enum(REG_CLOSES_AT_OPTIONS).nullish(),
enableNoScreenToggle: z.preprocess(
checkboxValueToBoolean,

View File

@ -118,10 +118,15 @@ export async function findValidOrganizations(
});
if (isTournamentAdder) {
return ["NO_ORG", ...orgs.map((org) => R.omit(org, ["isEstablished"]))];
return [
"NO_ORG",
...orgs.map((org) =>
R.omit(org, ["isEstablished", "role", "roleDisplayName"]),
),
];
}
return orgs
.filter((org) => org.isEstablished)
.map((org) => R.omit(org, ["isEstablished"]));
.map((org) => R.omit(org, ["isEstablished", "role", "roleDisplayName"]));
}

View File

@ -258,6 +258,7 @@ function EventForm() {
setIsInvitational={setIsInvitational}
/>
{!eventToEdit ? <TestToggle /> : null}
<DraftToggle />
</>
) : null}
{data.isAddingTournament ? (
@ -989,6 +990,31 @@ function TestToggle() {
);
}
function DraftToggle() {
const { t } = useTranslation(["calendar"]);
const baseEvent = useBaseEvent();
const [isDraft, setIsDraft] = React.useState(
baseEvent?.tournament?.ctx.settings.isDraft ?? false,
);
const id = React.useId();
return (
<div>
<label htmlFor={id} className="w-max">
{t("calendar:forms.draft")}
</label>
<SendouSwitch
name="isDraft"
id={id}
size="small"
isSelected={isDraft}
onChange={setIsDraft}
/>
<FormMessage type="info">{t("calendar:forms.draftInfo")}</FormMessage>
</div>
);
}
function RegClosesAtSelect() {
const baseEvent = useBaseEvent();
const [regClosesAt, setRegClosesAt] = React.useState<RegClosesAtOption>(

View File

@ -367,6 +367,42 @@ describe("calculateDamageCombos - excessive combo filtering", () => {
});
});
const TRI_SLOSHER_ID = 3010;
const INKBRUSH_ID = 1100;
const GOLD_DYNAMO_ROLLER_ID = 1021;
const RAPID_BLASTER_PRO_WNT_R_ID = 252;
describe("calculateDamageCombos - deduplication", () => {
test("no duplicate combos with bug report weapons", () => {
const combos = calculateDamageCombos(
[
TRI_SLOSHER_ID,
INKBRUSH_ID,
GOLD_DYNAMO_ROLLER_ID,
RAPID_BLASTER_PRO_WNT_R_ID,
],
[],
0,
1000,
);
const canonicalKeys = combos.map((combo) => {
const grouped = new Map<string, number>();
for (const segment of combo.segments) {
const key = `${segment.damageType}:${segment.damageValue}`;
grouped.set(key, (grouped.get(key) ?? 0) + segment.count);
}
return [...grouped.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, count]) => `${key}:${count}`)
.join("|");
});
const uniqueKeys = new Set(canonicalKeys);
expect(uniqueKeys.size).toBe(canonicalKeys.length);
});
});
describe("virtual damage combos", () => {
test("Explosher has COMBO damage type combining DIRECT and DISTANCE", () => {
const sources = extractDamageSources([EXPLOSHER_ID]);

View File

@ -303,6 +303,62 @@ function backtrack(
}
}
function normalizeCombo(combo: DamageCombo): DamageCombo {
const grouped = new Map<
string,
{ segment: DamageSegment; totalCount: number }
>();
for (const segment of combo.segments) {
const key = `${segment.damageType}:${segment.damageValue}`;
const existing = grouped.get(key);
if (existing) {
existing.totalCount += segment.count;
} else {
grouped.set(key, { segment, totalCount: segment.count });
}
}
const segments: DamageSegment[] = [];
for (const { segment, totalCount } of grouped.values()) {
segments.push({ ...segment, count: totalCount });
}
segments.sort((a, b) => {
const typeCompare = a.damageType.localeCompare(b.damageType);
if (typeCompare !== 0) return typeCompare;
return a.damageValue - b.damageValue;
});
return {
segments,
totalDamage: combo.totalDamage,
hitCount: combo.hitCount,
};
}
function comboKey(normalized: DamageCombo): string {
return normalized.segments
.map((s) => `${s.damageType}:${s.damageValue}:${s.count}`)
.join("|");
}
function deduplicateCombos(combos: DamageCombo[]): DamageCombo[] {
const seen = new Map<string, DamageCombo>();
for (const combo of combos) {
const normalized = normalizeCombo(combo);
const key = comboKey(normalized);
const existing = seen.get(key);
if (!existing || combo.segments.length < existing.segments.length) {
seen.set(key, combo);
}
}
return [...seen.values()];
}
function filterAndSortCombos(
combos: DamageCombo[],
maxCombosDisplayed: number,
@ -323,7 +379,9 @@ function filterAndSortCombos(
return true;
});
filtered.sort((a, b) => {
const deduplicated = deduplicateCombos(filtered);
deduplicated.sort((a, b) => {
const aDistTo100 = Math.abs(a.totalDamage - 100);
const bDistTo100 = Math.abs(b.totalDamage - 100);
if (aDistTo100 !== bDistTo100) {
@ -332,7 +390,7 @@ function filterAndSortCombos(
return a.hitCount - b.hitCount;
});
return filtered.slice(0, maxCombosDisplayed);
return deduplicated.slice(0, maxCombosDisplayed);
}
function hasOneShot(combo: DamageCombo): boolean {

View File

@ -56,6 +56,11 @@ const PERKS = [
name: "tournamentsBeta",
extraInfo: false,
},
{
tier: 2,
name: "earlyAccess",
extraInfo: false,
},
{
tier: 2,
name: "previewQ",

View File

@ -157,3 +157,12 @@ export function deletePostsByTeamId(teamId: number, trx?: Transaction<DB>) {
.where("teamId", "=", teamId)
.execute();
}
export async function findByAuthorUserId(authorId: number) {
return db
.selectFrom("LFGPost")
.select(["id", "type"])
.where("authorId", "=", authorId)
.orderBy("updatedAt", "desc")
.execute();
}

View File

@ -7,3 +7,7 @@
display: flex;
}
}
.post {
scroll-margin-top: 6rem;
}

View File

@ -1,3 +1,4 @@
import clsx from "clsx";
import { add, sub } from "date-fns";
import { Funnel } from "lucide-react";
import * as React from "react";
@ -123,7 +124,11 @@ export default function LFGPage() {
<AddNewButton navIcon="lfg" to={lfgNewPostPage()} />
</div>
{filteredPosts.map((post) => (
<div key={post.id} className="stack sm">
<div
key={post.id}
id={String(post.id)}
className={clsx("stack sm", styles.post)}
>
{showExpiryAlert(post) ? <PostExpiryAlert postId={post.id} /> : null}
<LFGPost post={post} tiersMap={tiersMap} />
</div>

View File

@ -95,9 +95,9 @@
"description": "a guide to improving at Splatoon, beyond raw mechanical ability"
},
{
"title": "Sunken scrolls",
"url": "https://scrolls.tessaract.gay/",
"description": "an archive for Splatoon guides that would otherwise be lost to time"
"title": "Sunken Scrolls",
"url": "https://sunkenscrolls.ink",
"description": "an archive for Splatoon resources that would otherwise be lost to time"
},
{
"title": "Calculating weapon range sheet",

View File

@ -0,0 +1,40 @@
import { sql } from "kysely";
import { db } from "~/db/sql";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "../leaderboards/leaderboards-constants";
export async function seasonProgressionByUserId({
userId,
season,
}: {
userId: number;
season: number;
}) {
return db
.selectFrom("Skill")
.leftJoin("GroupMatch", "GroupMatch.id", "Skill.groupMatchId")
.leftJoin("Tournament", "Tournament.id", "Skill.tournamentId")
.leftJoin("CalendarEvent", "Tournament.id", "CalendarEvent.tournamentId")
.leftJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.select(({ fn }) => [
fn.max("Skill.ordinal").as("ordinal"),
sql<string>`date(coalesce("GroupMatch"."createdAt", "CalendarEventDate"."startTime"), 'unixepoch')`.as(
"date",
),
])
.where("Skill.userId", "=", userId)
.where("Skill.season", "=", season)
.where("Skill.matchesCount", ">=", MATCHES_COUNT_NEEDED_FOR_LEADERBOARD)
.where(({ or, eb }) =>
or([
eb("GroupMatch.id", "is not", null),
eb("Tournament.id", "is not", null),
]),
)
.groupBy("date")
.orderBy("date", "asc")
.execute();
}

View File

@ -181,3 +181,12 @@ export function allStarted(date = new Date()) {
return [0];
}
/**
* Retrieves a list of season numbers that have finished based on the provided date (defaults to now).
*
* @returns An array of season numbers in descending order. If no seasons have finished, returns an empty array.
*/
export function allFinished(date = new Date()) {
const finishedSeasons = list.filter((s) => date > s.ends);
return finishedSeasons.map((s) => s.nth).reverse();
}

View File

@ -12,7 +12,7 @@ export function queryCurrentUserRating({
userId: number;
season: number;
}) {
const skill = findCurrentSkillByUserId({ userId, season: season ?? null });
const skill = findCurrentSkillByUserId({ userId, season });
if (!skill) {
return { rating: rating(), matchesCount: 0 };

View File

@ -1,5 +1,5 @@
import { sql } from "~/db/sql";
import type { Tables } from "../../../db/tables";
import type { Tables } from "~/db/tables";
const stm = sql.prepare(/* sql */ `
select

View File

@ -21,36 +21,8 @@ const userStm = sql.prepare(/* sql */ `
"Skill"."ordinal" desc
`);
const teamStm = sql.prepare(/* sql */ `
select
"Skill"."ordinal",
"Skill"."matchesCount",
"Skill"."identifier"
from
"Skill"
inner join (
select "identifier", max("id") as "maxId"
from "Skill"
where "Skill"."season" = @season
group by "identifier"
) "Latest" on "Skill"."identifier" = "Latest"."identifier" and "Skill"."id" = "Latest"."maxId"
where
"Skill"."season" = @season
and "Skill"."identifier" is not null
order by
"Skill"."ordinal" desc
`);
export function orderedMMRBySeason({
season,
type,
}: {
season: number;
type: "team" | "user";
}) {
const stm = type === "team" ? teamStm : userStm;
return stm.all({ season }) as Array<
Pick<Tables["Skill"], "ordinal" | "matchesCount" | "userId" | "identifier">
export function orderedUserMMRBySeason(season: number) {
return userStm.all({ season }) as Array<
Pick<Tables["Skill"], "ordinal" | "matchesCount" | "userId">
>;
}

View File

@ -1,36 +0,0 @@
import { sql } from "~/db/sql";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
const groupedSkillsStm = sql.prepare(/* sql */ `
select
max("Skill"."ordinal") as "ordinal",
date(
coalesce("GroupMatch"."createdAt", "CalendarEventDate"."startTime"), 'unixepoch'
) as "date"
from
"Skill"
left join "GroupMatch" on "GroupMatch"."id" = "Skill"."groupMatchId"
left join "Tournament" on "Tournament"."id" = "Skill"."tournamentId"
left join "CalendarEvent" on "Tournament"."id" = "CalendarEvent"."tournamentId"
left join "CalendarEventDate" on "CalendarEvent"."id" = "CalendarEventDate"."eventId"
where
"Skill"."userId" = @userId
and "Skill"."season" = @season
and "Skill"."matchesCount" >= ${MATCHES_COUNT_NEEDED_FOR_LEADERBOARD}
and ("GroupMatch"."id" is not null or "Tournament"."id" is not null)
group by "date"
order by "date" asc
`);
export function seasonAllMMRByUserId({
userId,
season,
}: {
userId: number;
season: number;
}) {
return groupedSkillsStm.all({ userId, season }) as Array<{
ordinal: number;
date: string;
}>;
}

View File

@ -9,7 +9,7 @@ import {
type TierName,
USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN,
} from "./mmr-constants";
import { orderedMMRBySeason } from "./queries/orderedMMRBySeason.server";
import { orderedUserMMRBySeason } from "./queries/orderedMMRBySeason.server";
export interface TieredSkill {
ordinal: number;
@ -25,10 +25,7 @@ export function freshUserSkills(season: number): {
intervals: SkillTierInterval[];
isAccurateTiers: boolean;
} {
const points = orderedMMRBySeason({
season,
type: "user",
});
const points = orderedUserMMRBySeason(season);
const { intervals, isAccurateTiers } = skillTierIntervals(points, "user");

View File

@ -139,6 +139,8 @@ const damageTypePriorityList = [
"SPECIAL_CANNON",
"SPECIAL_BULLET_MAX",
"SPECIAL_BULLET_MIN",
"SPECIAL_SPLASH_MAX",
"SPECIAL_SPLASH_MIN",
"SPECIAL_BUMP",
"SPECIAL_JUMP",
"SPECIAL_TICK",

View File

@ -63,7 +63,7 @@
]
},
"Blaster_BlasterMiddle": {
"mainWeaponIds": [210, 211, 260, 261],
"mainWeaponIds": [210, 211, 212, 260, 261],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -189,7 +189,7 @@
]
},
"Blaster_KillOneShot": {
"mainWeaponIds": [220, 221, 210, 211, 260, 261],
"mainWeaponIds": [220, 221, 210, 211, 212, 260, 261],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -252,7 +252,7 @@
]
},
"Blaster": {
"mainWeaponIds": [250, 251, 240, 241, 220, 221],
"mainWeaponIds": [250, 251, 252, 240, 241, 220, 221],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -861,7 +861,7 @@
]
},
"BrushCore": {
"mainWeaponIds": [1120, 1121, 1100, 1101, 1110, 1111, 1115],
"mainWeaponIds": [1120, 1121, 1122, 1100, 1101, 1110, 1111, 1112, 1115],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -884,7 +884,7 @@
]
},
"BrushSplash_Heavy": {
"mainWeaponIds": [1120, 1121],
"mainWeaponIds": [1120, 1121, 1122],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -955,7 +955,7 @@
]
},
"BrushSplash": {
"mainWeaponIds": [1100, 1101, 1110, 1111, 1115],
"mainWeaponIds": [1100, 1101, 1110, 1111, 1112, 1115],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -1239,7 +1239,9 @@
]
},
"ChargerFull": {
"mainWeaponIds": [2060, 2061, 2020, 2021, 2010, 2011, 2015, 2000, 2001],
"mainWeaponIds": [
2060, 2061, 2020, 2021, 2022, 2010, 2011, 2012, 2015, 2000, 2001
],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -1452,7 +1454,9 @@
]
},
"Charger": {
"mainWeaponIds": [2060, 2061, 2020, 2021, 2010, 2011, 2015, 2000, 2001],
"mainWeaponIds": [
2060, 2061, 2020, 2021, 2022, 2010, 2011, 2012, 2015, 2000, 2001
],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -1669,10 +1673,11 @@
},
"Default": {
"mainWeaponIds": [
220, 221, 210, 211, 260, 261, 1120, 1121, 1100, 1101, 1110, 1111, 1115,
2060, 2061, 2050, 2051, 2040, 2041, 2030, 2031, 2020, 2021, 2010, 2011,
2015, 2070, 2071, 2000, 2001, 1000, 1001, 1020, 1021, 1030, 1031, 1010,
1011, 1015, 1040, 1041, 6020, 6030, 6031, 6000, 6001, 6005, 6010, 6011
220, 221, 210, 211, 212, 260, 261, 1120, 1121, 1122, 1100, 1101, 1110,
1111, 1112, 1115, 2060, 2061, 2050, 2051, 2040, 2041, 2030, 2031, 2020,
2021, 2022, 2010, 2011, 2012, 2015, 2070, 2071, 2000, 2001, 1000, 1001,
1002, 1020, 1021, 1022, 1030, 1031, 1010, 1011, 1015, 1040, 1041, 1042,
6020, 6030, 6031, 6000, 6001, 6005, 6010, 6011, 6012
],
"subWeaponIds": [8, 6, 12, 9, 11],
"specialWeaponIds": [12, 19, 15, 16, 2, 7],
@ -2019,7 +2024,7 @@
]
},
"Maneuver_Short": {
"mainWeaponIds": [5000, 5001],
"mainWeaponIds": [5000, 5001, 5002],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -2075,7 +2080,8 @@
},
"Maneuver": {
"mainWeaponIds": [
5030, 5031, 5020, 5021, 5050, 5051, 5010, 5011, 5015, 5040, 5041
5030, 5031, 5032, 5020, 5021, 5050, 5051, 5010, 5011, 5012, 5015, 5040,
5041
],
"subWeaponIds": [],
"specialWeaponIds": [],
@ -2526,7 +2532,8 @@
},
"RollerCore": {
"mainWeaponIds": [
1000, 1001, 1020, 1021, 1030, 1031, 1010, 1011, 1015, 1040, 1041
1000, 1001, 1002, 1020, 1021, 1022, 1030, 1031, 1010, 1011, 1015, 1040,
1041, 1042
],
"subWeaponIds": [],
"specialWeaponIds": [],
@ -2550,7 +2557,7 @@
]
},
"RollerSplash_Compact": {
"mainWeaponIds": [1000, 1001],
"mainWeaponIds": [1000, 1001, 1002],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -2621,7 +2628,7 @@
]
},
"RollerSplash_Heavy": {
"mainWeaponIds": [1020, 1021],
"mainWeaponIds": [1020, 1021, 1022],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -2763,7 +2770,7 @@
]
},
"RollerSplash_Wide": {
"mainWeaponIds": [1040, 1041],
"mainWeaponIds": [1040, 1041, 1042],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -2913,7 +2920,7 @@
]
},
"Saber_ChargeShot": {
"mainWeaponIds": [8020, 8021, 8010, 8011, 8000, 8001, 8005],
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -2988,7 +2995,7 @@
]
},
"Saber_ChargeSlash": {
"mainWeaponIds": [8020, 8021, 8010, 8011, 8000, 8001, 8005],
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -3063,7 +3070,7 @@
]
},
"Saber_Shot": {
"mainWeaponIds": [8020, 8021, 8010, 8011, 8000, 8001, 8005],
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -3138,7 +3145,7 @@
]
},
"Saber_Slash": {
"mainWeaponIds": [8020, 8021, 8010, 8011, 8000, 8001, 8005],
"mainWeaponIds": [8020, 8021, 8010, 8011, 8012, 8000, 8001, 8002, 8005],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -3213,7 +3220,7 @@
]
},
"ShelterCanopy_Compact": {
"mainWeaponIds": [6020, 6021, 6031],
"mainWeaponIds": [6020, 6021, 6022, 6031],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -3280,7 +3287,7 @@
]
},
"ShelterCanopy_Wide": {
"mainWeaponIds": [6010, 6011],
"mainWeaponIds": [6010, 6011, 6012],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -3414,7 +3421,7 @@
]
},
"ShelterShot_Compact": {
"mainWeaponIds": [6020, 6021, 6031],
"mainWeaponIds": [6020, 6021, 6022, 6031],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -3489,7 +3496,7 @@
]
},
"ShelterShot_Wide": {
"mainWeaponIds": [6010, 6011],
"mainWeaponIds": [6010, 6011, 6012],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -3701,7 +3708,7 @@
]
},
"Shooter_Blaze": {
"mainWeaponIds": [30, 31],
"mainWeaponIds": [30, 31, 32],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -3756,7 +3763,7 @@
]
},
"Shooter_Expert": {
"mainWeaponIds": [70, 71],
"mainWeaponIds": [70, 71, 72],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -4047,7 +4054,7 @@
]
},
"Shooter_Heavy": {
"mainWeaponIds": [80, 81],
"mainWeaponIds": [80, 81, 82],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -4106,7 +4113,7 @@
]
},
"Shooter_Long": {
"mainWeaponIds": [90, 91],
"mainWeaponIds": [90, 91, 92],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -4157,7 +4164,7 @@
]
},
"Shooter_Precision": {
"mainWeaponIds": [20, 21],
"mainWeaponIds": [20, 21, 22],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -4267,7 +4274,7 @@
]
},
"Shooter_TripleMiddle": {
"mainWeaponIds": [310, 311],
"mainWeaponIds": [310, 311, 312],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -4330,7 +4337,7 @@
]
},
"Shooter_TripleQuick": {
"mainWeaponIds": [300, 301],
"mainWeaponIds": [300, 301, 302],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -4393,7 +4400,7 @@
]
},
"Shooter": {
"mainWeaponIds": [400, 401, 40, 41, 45, 47, 46, 100, 101, 60, 61],
"mainWeaponIds": [400, 401, 40, 41, 42, 45, 47, 46, 100, 101, 60, 61],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -4763,7 +4770,9 @@
]
},
"Slosher": {
"mainWeaponIds": [3010, 3011, 3050, 3051, 3020, 3021, 3000, 3001, 3005],
"mainWeaponIds": [
3010, 3011, 3012, 3050, 3051, 3052, 3020, 3021, 3000, 3001, 3005
],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -4827,8 +4836,8 @@
},
"Spinner": {
"mainWeaponIds": [
4030, 4031, 4050, 4051, 4020, 4021, 4000, 4001, 4040, 4041, 4010, 4011,
4015
4030, 4031, 4050, 4051, 4020, 4021, 4022, 4000, 4001, 4002, 4040, 4041,
4010, 4011, 4015
],
"subWeaponIds": [],
"specialWeaponIds": [],
@ -4903,7 +4912,7 @@
]
},
"Stringer_Short": {
"mainWeaponIds": [7020, 7021],
"mainWeaponIds": [7020, 7021, 7022],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [
@ -4966,7 +4975,7 @@
]
},
"Stringer": {
"mainWeaponIds": [7030, 7031, 7010, 7011, 7015],
"mainWeaponIds": [7030, 7031, 7010, 7011, 7012, 7015],
"subWeaponIds": [],
"specialWeaponIds": [],
"rates": [

View File

@ -42,7 +42,7 @@ import { useObjectDamage } from "../calculator-hooks";
import type { DamageReceiver } from "../calculator-types";
import styles from "./object-damage-calculator.module.css";
export const CURRENT_PATCH = "10.1";
export const CURRENT_PATCH = "11.0.1";
export const shouldRevalidate: ShouldRevalidateFunction = () => false;

View File

@ -8,11 +8,7 @@ import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseFormData } from "~/form/parse.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import {
actionError,
errorToast,
errorToastIfFalsy,
} from "~/utils/remix.server";
import { errorToast, errorToastIfFalsy } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { scrimsPage } from "~/utils/urls";
import * as SQGroupRepository from "../../sendouq/SQGroupRepository.server";
@ -21,7 +17,6 @@ import * as ScrimPostRepository from "../ScrimPostRepository.server";
import { LUTI_DIVS, SCRIM } from "../scrims-constants";
import {
type fromSchema,
type newRequestSchema,
type RANGE_END_OPTIONS,
scrimsNewFormSchema,
} from "../scrims-schemas";
@ -43,18 +38,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
if (data.from.mode === "PICKUP") {
if (data.from.users.includes(user.id)) {
return actionError<typeof newRequestSchema>({
msg: "Don't add yourself to the pickup member list",
field: "from.root",
});
return {
fieldErrors: { from: "Don't add yourself to the pickup member list" },
};
}
const pickupUserError = await validatePickup(data.from.users, user.id);
if (pickupUserError) {
return actionError<typeof newRequestSchema>({
msg: pickupUserError.error,
field: "from.root",
});
return { fieldErrors: { from: pickupUserError.error } };
}
}

View File

@ -222,8 +222,8 @@ export const scrimsNewFormSchema = z
at: datetimeRequired({
label: "labels.start",
bottomText: "bottomTexts.scrimStart",
min: sub(new Date(), { days: 1 }),
max: add(new Date(), { days: 15 }),
min: () => sub(new Date(), { days: 1 }),
max: () => add(new Date(), { days: 15 }),
minMessage: "errors.dateInPast",
maxMessage: "errors.dateTooFarInFuture",
}),

View File

@ -0,0 +1,58 @@
import type { Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
export function upsertMapResults(
results: Pick<
Tables["MapResult"],
"losses" | "wins" | "userId" | "mode" | "stageId" | "season"
>[],
trx?: Transaction<DB>,
) {
if (results.length === 0) return;
const executor = trx ?? db;
return executor
.insertInto("MapResult")
.values(results)
.onConflict((oc) =>
oc.columns(["userId", "stageId", "mode", "season"]).doUpdateSet((eb) => ({
wins: eb("MapResult.wins", "+", eb.ref("excluded.wins")),
losses: eb("MapResult.losses", "+", eb.ref("excluded.losses")),
})),
)
.execute();
}
export function upsertPlayerResults(
results: Tables["PlayerResult"][],
trx?: Transaction<DB>,
) {
if (results.length === 0) return;
const executor = trx ?? db;
return executor
.insertInto("PlayerResult")
.values(results)
.onConflict((oc) =>
oc
.columns(["ownerUserId", "otherUserId", "type", "season"])
.doUpdateSet((eb) => ({
mapWins: eb("PlayerResult.mapWins", "+", eb.ref("excluded.mapWins")),
mapLosses: eb(
"PlayerResult.mapLosses",
"+",
eb.ref("excluded.mapLosses"),
),
setWins: eb("PlayerResult.setWins", "+", eb.ref("excluded.setWins")),
setLosses: eb(
"PlayerResult.setLosses",
"+",
eb.ref("excluded.setLosses"),
),
})),
)
.execute();
}

View File

@ -0,0 +1,66 @@
import type { NotNull, Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, TablesInsertable } from "~/db/tables";
export function createMany(
weapons: TablesInsertable["ReportedWeapon"][],
trx?: Transaction<DB>,
) {
if (weapons.length === 0) return;
return (trx ?? db).insertInto("ReportedWeapon").values(weapons).execute();
}
export async function replaceByMatchId(
matchId: number,
weapons: TablesInsertable["ReportedWeapon"][],
trx?: Transaction<DB>,
) {
const executor = trx ?? db;
const groupMatchMaps = await executor
.selectFrom("GroupMatchMap")
.select("id")
.where("matchId", "=", matchId)
.execute();
if (groupMatchMaps.length > 0) {
await executor
.deleteFrom("ReportedWeapon")
.where(
"groupMatchMapId",
"in",
groupMatchMaps.map((m) => m.id),
)
.execute();
}
if (weapons.length > 0) {
await executor.insertInto("ReportedWeapon").values(weapons).execute();
}
}
export async function findByMatchId(matchId: number) {
const rows = await db
.selectFrom("ReportedWeapon")
.innerJoin(
"GroupMatchMap",
"GroupMatchMap.id",
"ReportedWeapon.groupMatchMapId",
)
.select([
"ReportedWeapon.groupMatchMapId",
"ReportedWeapon.weaponSplId",
"ReportedWeapon.userId",
"GroupMatchMap.index as mapIndex",
])
.where("GroupMatchMap.matchId", "=", matchId)
.orderBy("GroupMatchMap.index", "asc")
.orderBy("ReportedWeapon.userId", "asc")
.$narrowType<{ groupMatchMapId: NotNull }>()
.execute();
if (rows.length === 0) return null;
return rows;
}

View File

@ -0,0 +1,456 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { db } from "~/db/sql";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { dbInsertUsers, dbReset } from "~/utils/Test";
import * as SQGroupRepository from "../sendouq/SQGroupRepository.server";
import * as SQMatchRepository from "./SQMatchRepository.server";
const { mockSeasonCurrentOrPrevious } = vi.hoisted(() => ({
mockSeasonCurrentOrPrevious: vi.fn(() => ({
nth: 1,
starts: new Date("2023-01-01"),
ends: new Date("2030-12-31"),
})),
}));
vi.mock("~/features/mmr/core/Seasons", () => ({
currentOrPrevious: mockSeasonCurrentOrPrevious,
}));
const createGroup = async (userIds: number[]) => {
const groupResult = await SQGroupRepository.createGroup({
status: "ACTIVE",
userId: userIds[0],
});
for (let i = 1; i < userIds.length; i++) {
await SQGroupRepository.addMember(groupResult.id, {
userId: userIds[i],
role: "REGULAR",
});
}
return groupResult.id;
};
const createMatch = async (alphaGroupId: number, bravoGroupId: number) => {
const match = await db
.insertInto("GroupMatch")
.values({
alphaGroupId,
bravoGroupId,
chatCode: "test-chat-code",
})
.returningAll()
.executeTakeFirstOrThrow();
const mapList: Array<{
mode: ModeShort;
stageId: StageId;
}> = [
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 2 },
{ mode: "RM", stageId: 3 },
{ mode: "CB", stageId: 4 },
{ mode: "SZ", stageId: 5 },
{ mode: "TC", stageId: 6 },
{ mode: "RM", stageId: 7 },
];
await db
.insertInto("GroupMatchMap")
.values(
mapList.map((map, i) => ({
matchId: match.id,
index: i,
mode: map.mode,
stageId: map.stageId,
source: "TIEBREAKER",
})),
)
.execute();
return match;
};
const fetchMatch = async (matchId: number) => {
return db
.selectFrom("GroupMatch")
.selectAll()
.where("id", "=", matchId)
.executeTakeFirst();
};
const fetchMapResults = async (matchId: number) => {
return db
.selectFrom("GroupMatchMap")
.selectAll()
.where("matchId", "=", matchId)
.orderBy("index", "asc")
.execute();
};
const fetchGroup = async (groupId: number) => {
return db
.selectFrom("Group")
.selectAll()
.where("id", "=", groupId)
.executeTakeFirst();
};
const fetchSkills = async (matchId: number) => {
return db
.selectFrom("Skill")
.selectAll()
.where("groupMatchId", "=", matchId)
.execute();
};
const fetchReportedWeapons = async (matchId: number) => {
return db
.selectFrom("ReportedWeapon")
.innerJoin(
"GroupMatchMap",
"GroupMatchMap.id",
"ReportedWeapon.groupMatchMapId",
)
.selectAll("ReportedWeapon")
.where("GroupMatchMap.matchId", "=", matchId)
.execute();
};
describe("updateScore", () => {
beforeEach(async () => {
await dbInsertUsers(8);
});
afterEach(() => {
dbReset();
});
test("updates match reportedAt and reportedByUserId", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.updateScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
});
const updatedMatch = await fetchMatch(match.id);
expect(updatedMatch?.reportedAt).not.toBeNull();
expect(updatedMatch?.reportedByUserId).toBe(1);
});
test("sets winners correctly for each map", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.updateScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "BRAVO", "ALPHA", "BRAVO"],
});
const maps = await fetchMapResults(match.id);
expect(maps[0].winnerGroupId).toBe(alphaGroupId);
expect(maps[1].winnerGroupId).toBe(bravoGroupId);
expect(maps[2].winnerGroupId).toBe(alphaGroupId);
expect(maps[3].winnerGroupId).toBe(bravoGroupId);
expect(maps[4].winnerGroupId).toBeNull();
});
test("clears previous winners before setting new ones", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.updateScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "ALPHA", "ALPHA"],
});
await SQMatchRepository.updateScore({
matchId: match.id,
reportedByUserId: 5,
winners: ["BRAVO", "BRAVO", "BRAVO", "BRAVO"],
});
const maps = await fetchMapResults(match.id);
for (let i = 0; i < 4; i++) {
expect(maps[i].winnerGroupId).toBe(bravoGroupId);
}
});
});
describe("lockMatchWithoutSkillChange", () => {
beforeEach(async () => {
await dbInsertUsers(8);
});
afterEach(() => {
dbReset();
});
test("inserts dummy skill to lock match", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.lockMatchWithoutSkillChange(match.id);
const skills = await fetchSkills(match.id);
expect(skills).toHaveLength(1);
expect(skills[0].season).toBe(-1);
expect(skills[0].mu).toBe(-1);
expect(skills[0].sigma).toBe(-1);
expect(skills[0].ordinal).toBe(-1);
expect(skills[0].userId).toBeNull();
});
});
describe("adminReport", () => {
beforeEach(async () => {
await dbInsertUsers(8);
});
afterEach(() => {
dbReset();
});
test("sets both groups as inactive", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.adminReport({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
});
const alphaGroup = await fetchGroup(alphaGroupId);
const bravoGroup = await fetchGroup(bravoGroupId);
expect(alphaGroup?.status).toBe("INACTIVE");
expect(bravoGroup?.status).toBe("INACTIVE");
const updatedMatch = await fetchMatch(match.id);
expect(updatedMatch?.reportedAt).not.toBeNull();
});
test("creates skills to lock the match", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.adminReport({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
});
const skills = await fetchSkills(match.id);
expect(skills.length).toBeGreaterThan(0);
});
});
describe("reportScore", () => {
beforeEach(async () => {
await dbInsertUsers(8);
});
afterEach(() => {
dbReset();
});
test("first report sets reporter group as inactive", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
const groupMatchMaps = await db
.selectFrom("GroupMatchMap")
.select(["id", "index"])
.where("matchId", "=", match.id)
.orderBy("index", "asc")
.execute();
const result = await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [
{
groupMatchMapId: groupMatchMaps[0].id,
weaponSplId: 40,
userId: 1,
mapIndex: 0,
},
],
});
expect(result.status).toBe("REPORTED");
expect(result.shouldRefreshCaches).toBe(false);
const alphaGroup = await fetchGroup(alphaGroupId);
expect(alphaGroup?.status).toBe("INACTIVE");
const bravoGroup = await fetchGroup(bravoGroupId);
expect(bravoGroup?.status).toBe("ACTIVE");
const weapons = await fetchReportedWeapons(match.id);
expect(weapons).toHaveLength(1);
});
test("matching second report confirms score and creates skills", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [],
});
const result = await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 5,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [],
});
expect(result.status).toBe("CONFIRMED");
expect(result.shouldRefreshCaches).toBe(true);
const skills = await fetchSkills(match.id);
expect(skills.length).toBeGreaterThan(0);
});
test("different score returns DIFFERENT status", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [],
});
const result = await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 5,
winners: ["BRAVO", "BRAVO", "BRAVO", "BRAVO"],
weapons: [],
});
expect(result.status).toBe("DIFFERENT");
expect(result.shouldRefreshCaches).toBe(false);
});
test("duplicate report returns DUPLICATE status", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [],
});
const result = await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 2,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [],
});
expect(result.status).toBe("DUPLICATE");
expect(result.shouldRefreshCaches).toBe(false);
});
});
describe("cancelMatch", () => {
beforeEach(async () => {
await dbInsertUsers(8);
});
afterEach(() => {
dbReset();
});
test("first cancel report sets group inactive", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
const result = await SQMatchRepository.cancelMatch({
matchId: match.id,
reportedByUserId: 1,
});
expect(result.status).toBe("CANCEL_REPORTED");
expect(result.shouldRefreshCaches).toBe(false);
const alphaGroup = await fetchGroup(alphaGroupId);
expect(alphaGroup?.status).toBe("INACTIVE");
});
test("matching cancel confirms and locks match", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.cancelMatch({
matchId: match.id,
reportedByUserId: 1,
});
const result = await SQMatchRepository.cancelMatch({
matchId: match.id,
reportedByUserId: 5,
});
expect(result.status).toBe("CANCEL_CONFIRMED");
expect(result.shouldRefreshCaches).toBe(true);
const alphaGroup = await fetchGroup(alphaGroupId);
const bravoGroup = await fetchGroup(bravoGroupId);
expect(alphaGroup?.status).toBe("INACTIVE");
expect(bravoGroup?.status).toBe("INACTIVE");
const skills = await fetchSkills(match.id);
expect(skills).toHaveLength(1);
expect(skills[0].season).toBe(-1);
});
test("cant cancel after score reported", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [],
});
const result = await SQMatchRepository.cancelMatch({
matchId: match.id,
reportedByUserId: 5,
});
expect(result.status).toBe("CANT_CANCEL");
expect(result.shouldRefreshCaches).toBe(false);
});
});

View File

@ -5,6 +5,7 @@ import * as R from "remeda";
import { db } from "~/db/sql";
import type { DB, ParsedMemento } from "~/db/tables";
import * as Seasons from "~/features/mmr/core/Seasons";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
import { mostPopularArrayElement } from "~/utils/arrays";
import { dateToDatabaseTimestamp } from "~/utils/dates";
@ -18,7 +19,19 @@ import {
} from "~/utils/kysely.server";
import type { Unpacked } from "~/utils/types";
import { FULL_GROUP_SIZE } from "../sendouq/q-constants";
import * as SQGroupRepository from "../sendouq/SQGroupRepository.server";
import { MATCHES_PER_SEASONS_PAGE } from "../user-page/user-page-constants";
import { compareMatchToReportedScores } from "./core/match.server";
import { mergeReportedWeapons } from "./core/reported-weapons.server";
import { calculateMatchSkills } from "./core/skills.server";
import {
summarizeMaps,
summarizePlayerResults,
} from "./core/summarizer.server";
import * as PlayerStatRepository from "./PlayerStatRepository.server";
import { winnersArrayToWinner } from "./q-match-utils";
import * as ReportedWeaponRepository from "./ReportedWeaponRepository.server";
import * as SkillRepository from "./SkillRepository.server";
export async function findById(id: number) {
const result = await db
@ -492,3 +505,332 @@ async function validateCreatedMatch(
}
}
}
export async function updateScore(
{
matchId,
reportedByUserId,
winners,
}: {
matchId: number;
reportedByUserId: number;
winners: ("ALPHA" | "BRAVO")[];
},
trx?: Transaction<DB>,
) {
const executor = trx ?? db;
const match = await executor
.updateTable("GroupMatch")
.set({
reportedAt: dateToDatabaseTimestamp(new Date()),
reportedByUserId,
})
.where("id", "=", matchId)
.returningAll()
.executeTakeFirstOrThrow();
await executor
.updateTable("GroupMatchMap")
.set({ winnerGroupId: null })
.where("matchId", "=", matchId)
.execute();
for (const [index, winner] of winners.entries()) {
await executor
.updateTable("GroupMatchMap")
.set({
winnerGroupId:
winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId,
})
.where("matchId", "=", matchId)
.where("index", "=", index)
.execute();
}
}
export function lockMatchWithoutSkillChange(
groupMatchId: number,
trx?: Transaction<DB>,
) {
return (trx ?? db)
.insertInto("Skill")
.values({
groupMatchId,
identifier: null,
mu: -1,
season: -1,
sigma: -1,
ordinal: -1,
userId: null,
matchesCount: 0,
})
.execute();
}
export type ReportScoreResult =
| { status: "REPORTED"; shouldRefreshCaches: false }
| { status: "CONFIRMED"; shouldRefreshCaches: true }
| { status: "DIFFERENT"; shouldRefreshCaches: false }
| { status: "DUPLICATE"; shouldRefreshCaches: false };
export type CancelMatchResult =
| { status: "CANCEL_REPORTED"; shouldRefreshCaches: false }
| { status: "CANCEL_CONFIRMED"; shouldRefreshCaches: true }
| { status: "CANT_CANCEL"; shouldRefreshCaches: false }
| { status: "DUPLICATE"; shouldRefreshCaches: false };
type WeaponInput = {
groupMatchMapId: number;
weaponSplId: MainWeaponId;
userId: number;
mapIndex: number;
};
export async function adminReport({
matchId,
reportedByUserId,
winners,
}: {
matchId: number;
reportedByUserId: number;
winners: ("ALPHA" | "BRAVO")[];
}): Promise<void> {
const match = await findById(matchId);
invariant(match, "Match not found");
const members = buildMembers(match);
const winner = winnersArrayToWinner(winners);
const winnerGroupId =
winner === "ALPHA" ? match.groupAlpha.id : match.groupBravo.id;
const loserGroupId =
winner === "ALPHA" ? match.groupBravo.id : match.groupAlpha.id;
const { newSkills, differences } = calculateMatchSkills({
groupMatchId: match.id,
winner: (match.groupAlpha.id === winnerGroupId
? match.groupAlpha
: match.groupBravo
).members.map((m) => m.id),
loser: (match.groupAlpha.id === loserGroupId
? match.groupAlpha
: match.groupBravo
).members.map((m) => m.id),
winnerGroupId,
loserGroupId,
});
await db.transaction().execute(async (trx) => {
await updateScore({ matchId, reportedByUserId, winners }, trx);
await SQGroupRepository.setAsInactive(match.groupAlpha.id, trx);
await SQGroupRepository.setAsInactive(match.groupBravo.id, trx);
await PlayerStatRepository.upsertMapResults(
summarizeMaps({ match, members, winners }),
trx,
);
await PlayerStatRepository.upsertPlayerResults(
summarizePlayerResults({ match, members, winners }),
trx,
);
await SkillRepository.createMatchSkills(
{
skills: newSkills,
differences,
groupMatchId: match.id,
oldMatchMemento: match.memento,
},
trx,
);
});
}
export async function reportScore({
matchId,
reportedByUserId,
winners,
weapons,
}: {
matchId: number;
reportedByUserId: number;
winners: ("ALPHA" | "BRAVO")[];
weapons: WeaponInput[];
}): Promise<ReportScoreResult> {
const match = await findById(matchId);
invariant(match, "Match not found");
const members = buildMembers(match);
const reporterGroupId = members.find(
(m) => m.id === reportedByUserId,
)?.groupId;
invariant(reporterGroupId, "Reporter is not a member of any group");
const previousReporterGroupId = match.reportedByUserId
? members.find((m) => m.id === match.reportedByUserId)?.groupId
: undefined;
const compared = compareMatchToReportedScores({
match,
winners,
newReporterGroupId: reporterGroupId,
previousReporterGroupId,
});
const oldReportedWeapons =
(await ReportedWeaponRepository.findByMatchId(matchId)) ?? [];
const mergedWeapons = mergeReportedWeapons({
oldWeapons: oldReportedWeapons,
newWeapons: weapons,
newReportedMapsCount: winners.length,
});
const weaponsForDb = mergedWeapons.map((w) => ({
groupMatchMapId: w.groupMatchMapId,
userId: w.userId,
weaponSplId: w.weaponSplId,
}));
if (compared === "DUPLICATE") {
await ReportedWeaponRepository.replaceByMatchId(matchId, weaponsForDb);
return { status: "DUPLICATE", shouldRefreshCaches: false };
}
if (compared === "DIFFERENT") {
await SQGroupRepository.setAsInactive(reporterGroupId);
return { status: "DIFFERENT", shouldRefreshCaches: false };
}
if (compared === "FIRST_REPORT") {
await db.transaction().execute(async (trx) => {
await updateScore({ matchId, reportedByUserId, winners }, trx);
await SQGroupRepository.setAsInactive(reporterGroupId, trx);
if (weaponsForDb.length > 0) {
await ReportedWeaponRepository.createMany(weaponsForDb, trx);
}
});
return { status: "REPORTED", shouldRefreshCaches: false };
}
if (compared === "FIX_PREVIOUS") {
await db.transaction().execute(async (trx) => {
await updateScore({ matchId, reportedByUserId, winners }, trx);
await ReportedWeaponRepository.replaceByMatchId(
matchId,
weaponsForDb,
trx,
);
});
return { status: "REPORTED", shouldRefreshCaches: false };
}
const winner = winnersArrayToWinner(winners);
const winnerGroupId =
winner === "ALPHA" ? match.groupAlpha.id : match.groupBravo.id;
const loserGroupId =
winner === "ALPHA" ? match.groupBravo.id : match.groupAlpha.id;
const { newSkills, differences } = calculateMatchSkills({
groupMatchId: match.id,
winner: (match.groupAlpha.id === winnerGroupId
? match.groupAlpha
: match.groupBravo
).members.map((m) => m.id),
loser: (match.groupAlpha.id === loserGroupId
? match.groupAlpha
: match.groupBravo
).members.map((m) => m.id),
winnerGroupId,
loserGroupId,
});
await db.transaction().execute(async (trx) => {
await SQGroupRepository.setAsInactive(reporterGroupId, trx);
await PlayerStatRepository.upsertMapResults(
summarizeMaps({ match, members, winners }),
trx,
);
await PlayerStatRepository.upsertPlayerResults(
summarizePlayerResults({ match, members, winners }),
trx,
);
await SkillRepository.createMatchSkills(
{
skills: newSkills,
differences,
groupMatchId: match.id,
oldMatchMemento: match.memento,
},
trx,
);
await ReportedWeaponRepository.replaceByMatchId(matchId, weaponsForDb, trx);
});
return { status: "CONFIRMED", shouldRefreshCaches: true };
}
export async function cancelMatch({
matchId,
reportedByUserId,
}: {
matchId: number;
reportedByUserId: number;
}): Promise<CancelMatchResult> {
const match = await findById(matchId);
invariant(match, "Match not found");
const members = buildMembers(match);
const reporterGroupId = members.find(
(m) => m.id === reportedByUserId,
)?.groupId;
invariant(reporterGroupId, "Reporter is not a member of any group");
const previousReporterGroupId = match.reportedByUserId
? members.find((m) => m.id === match.reportedByUserId)?.groupId
: undefined;
const compared = compareMatchToReportedScores({
match,
winners: [],
newReporterGroupId: reporterGroupId,
previousReporterGroupId,
});
if (compared === "DUPLICATE") {
return { status: "DUPLICATE", shouldRefreshCaches: false };
}
if (compared === "DIFFERENT") {
await SQGroupRepository.setAsInactive(reporterGroupId);
return { status: "CANT_CANCEL", shouldRefreshCaches: false };
}
if (compared === "FIRST_REPORT" || compared === "FIX_PREVIOUS") {
await db.transaction().execute(async (trx) => {
await updateScore({ matchId, reportedByUserId, winners: [] }, trx);
await SQGroupRepository.setAsInactive(reporterGroupId, trx);
if (compared === "FIX_PREVIOUS") {
await ReportedWeaponRepository.replaceByMatchId(matchId, [], trx);
}
});
return { status: "CANCEL_REPORTED", shouldRefreshCaches: false };
}
await db.transaction().execute(async (trx) => {
await SQGroupRepository.setAsInactive(reporterGroupId, trx);
await lockMatchWithoutSkillChange(match.id, trx);
});
return { status: "CANCEL_CONFIRMED", shouldRefreshCaches: true };
}
function buildMembers(
match: NonNullable<Awaited<ReturnType<typeof findById>>>,
) {
return [
...match.groupAlpha.members.map((m) => ({
...m,
groupId: match.groupAlpha.id,
})),
...match.groupBravo.members.map((m) => ({
...m,
groupId: match.groupBravo.id,
})),
];
}

View File

@ -0,0 +1,139 @@
import type { Transaction } from "kysely";
import { ordinal } from "openskill";
import { db } from "~/db/sql";
import type { DB, ParsedMemento, Tables } from "~/db/tables";
import { identifierToUserIds } from "~/features/mmr/mmr-utils";
import { databaseTimestampNow } from "~/utils/dates";
import type { MementoSkillDifferences } from "./core/skills.server";
export async function createMatchSkills(
{
groupMatchId,
skills,
oldMatchMemento,
differences,
}: {
groupMatchId: number;
skills: Pick<
Tables["Skill"],
"groupMatchId" | "identifier" | "mu" | "season" | "sigma" | "userId"
>[];
oldMatchMemento: ParsedMemento | null;
differences: MementoSkillDifferences;
},
trx?: Transaction<DB>,
) {
const executor = trx ?? db;
const createdAt = databaseTimestampNow();
for (const skill of skills) {
const insertedSkill = await insertSkillWithOrdinal(
{
...skill,
createdAt,
ordinal: ordinal(skill),
},
executor,
);
if (insertedSkill.identifier) {
for (const userId of identifierToUserIds(insertedSkill.identifier)) {
await executor
.insertInto("SkillTeamUser")
.values({
skillId: insertedSkill.id,
userId,
})
.onConflict((oc) => oc.columns(["skillId", "userId"]).doNothing())
.execute();
}
}
}
if (!oldMatchMemento) return;
const newMemento: ParsedMemento = {
...oldMatchMemento,
groups: {},
users: {},
};
for (const [key, value] of Object.entries(oldMatchMemento.users)) {
newMemento.users[key as unknown as number] = {
...value,
skillDifference:
differences.users[key as unknown as number]?.skillDifference,
};
}
for (const [key, value] of Object.entries(oldMatchMemento.groups)) {
newMemento.groups[key as unknown as number] = {
...value,
skillDifference:
differences.groups[key as unknown as number]?.skillDifference,
};
}
await executor
.updateTable("GroupMatch")
.set({ memento: JSON.stringify(newMemento) })
.where("id", "=", groupMatchId)
.execute();
}
async function insertSkillWithOrdinal(
skill: {
groupMatchId: number | null;
identifier: string | null;
mu: number;
season: number;
sigma: number;
userId: number | null;
createdAt: number;
ordinal: number;
},
executor: Transaction<DB> | typeof db,
) {
const isUserSkill = skill.userId !== null;
const isTeamSkill = skill.identifier !== null;
let previousMatchesCount = 0;
if (isUserSkill) {
const previousSkill = await executor
.selectFrom("Skill")
.select(({ fn }) => fn.max("matchesCount").as("maxMatchesCount"))
.where("userId", "=", skill.userId)
.where("season", "=", skill.season)
.executeTakeFirst();
previousMatchesCount = previousSkill?.maxMatchesCount ?? 0;
} else if (isTeamSkill) {
const previousSkill = await executor
.selectFrom("Skill")
.select(({ fn }) => fn.max("matchesCount").as("maxMatchesCount"))
.where("identifier", "=", skill.identifier)
.where("season", "=", skill.season)
.executeTakeFirst();
previousMatchesCount = previousSkill?.maxMatchesCount ?? 0;
}
const insertedSkill = await executor
.insertInto("Skill")
.values({
groupMatchId: skill.groupMatchId,
identifier: skill.identifier,
mu: skill.mu,
season: skill.season,
sigma: skill.sigma,
ordinal: skill.ordinal,
userId: skill.userId,
createdAt: skill.createdAt,
matchesCount: previousMatchesCount + 1,
})
.returningAll()
.executeTakeFirstOrThrow();
return insertedSkill;
}

View File

@ -1,6 +1,5 @@
import type { ActionFunctionArgs } from "react-router";
import { redirect } from "react-router";
import { sql } from "~/db/sql";
import type { ReportedWeapon } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
@ -13,6 +12,7 @@ import {
} from "~/features/sendouq/core/SendouQ.server";
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
import invariant from "~/utils/invariant";
@ -25,25 +25,8 @@ import {
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_PREPARING_PAGE, sendouQMatchPage } from "~/utils/urls";
import { compareMatchToReportedScores } from "../core/match.server";
import { mergeReportedWeapons } from "../core/reported-weapons.server";
import { calculateMatchSkills } from "../core/skills.server";
import {
summarizeMaps,
summarizePlayerResults,
} from "../core/summarizer.server";
import { matchSchema, qMatchPageParamsSchema } from "../q-match-schemas";
import { winnersArrayToWinner } from "../q-match-utils";
import { addDummySkill } from "../queries/addDummySkill.server";
import { addMapResults } from "../queries/addMapResults.server";
import { addPlayerResults } from "../queries/addPlayerResults.server";
import { addReportedWeapons } from "../queries/addReportedWeapons.server";
import { addSkills } from "../queries/addSkills.server";
import { deleteReporterWeaponsByMatchId } from "../queries/deleteReportedWeaponsByMatchId.server";
import { findMatchById } from "../queries/findMatchById.server";
import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
import { reportScore } from "../queries/reportScore.server";
import { setGroupAsInactive } from "../queries/setGroupAsInactive.server";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const matchId = parseParams({
@ -58,30 +41,27 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
switch (data._action) {
case "REPORT_SCORE": {
const reportWeapons = () => {
const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? [];
const mergedWeapons = mergeReportedWeapons({
oldWeapons: oldReportedWeapons,
newWeapons: data.weapons as (ReportedWeapon & {
mapIndex: number;
groupMatchMapId: number;
})[],
newReportedMapsCount: data.winners.length,
});
sql.transaction(() => {
deleteReporterWeaponsByMatchId(matchId);
addReportedWeapons(mergedWeapons);
})();
};
const unmappedMatch = notFoundIfFalsy(
await SQMatchRepository.findById(matchId),
);
const match = SendouQ.mapMatch(unmappedMatch, user);
if (match.isLocked) {
reportWeapons();
const oldReportedWeapons =
(await ReportedWeaponRepository.findByMatchId(matchId)) ?? [];
const mergedWeapons = mergeReportedWeapons({
oldWeapons: oldReportedWeapons,
newWeapons: data.weapons,
newReportedMapsCount: data.winners.length,
});
await ReportedWeaponRepository.replaceByMatchId(
matchId,
mergedWeapons.map((w) => ({
groupMatchMapId: w.groupMatchMapId,
userId: w.userId,
weaponSplId: w.weaponSplId,
})),
);
return null;
}
@ -89,6 +69,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
!data.adminReport || user.roles.includes("STAFF"),
"Only mods can report scores as admin",
);
const members = [
...match.groupAlpha.members.map((m) => ({
...m,
@ -99,147 +80,116 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
groupId: match.groupBravo.id,
})),
];
const groupMemberOfId = members.find((m) => m.id === user.id)?.groupId;
invariant(
groupMemberOfId || data.adminReport,
members.some((m) => m.id === user.id) || data.adminReport,
"User is not a member of any group",
);
const winner = winnersArrayToWinner(data.winners);
const winnerGroupId =
winner === "ALPHA" ? match.groupAlpha.id : match.groupBravo.id;
const loserGroupId =
winner === "ALPHA" ? match.groupBravo.id : match.groupAlpha.id;
if (data.adminReport) {
await SQMatchRepository.adminReport({
matchId,
reportedByUserId: user.id,
winners: data.winners,
});
// when admin reports match gets locked right away
const compared = data.adminReport
? "SAME"
: compareMatchToReportedScores({
match,
winners: data.winners,
newReporterGroupId: groupMemberOfId!,
previousReporterGroupId: match.reportedByUserId
? members.find((m) => m.id === match.reportedByUserId)!.groupId
: undefined,
});
// same group reporting same score, probably by mistake
if (compared === "DUPLICATE") {
reportWeapons();
return null;
}
const matchIsBeingCanceled = data.winners.length === 0;
const { newSkills, differences } =
compared === "SAME" && !matchIsBeingCanceled
? calculateMatchSkills({
groupMatchId: match.id,
winner: (match.groupAlpha.id === winnerGroupId
? match.groupAlpha
: match.groupBravo
).members.map((m) => m.id),
loser: (match.groupAlpha.id === loserGroupId
? match.groupAlpha
: match.groupBravo
).members.map((m) => m.id),
winnerGroupId,
loserGroupId,
})
: { newSkills: null, differences: null };
const shouldLockMatchWithoutChangingRecords =
compared === "SAME" && matchIsBeingCanceled;
let clearCaches = false;
sql.transaction(() => {
if (
compared === "FIX_PREVIOUS" ||
compared === "FIRST_REPORT" ||
data.adminReport
) {
reportScore({
matchId,
reportedByUserId: user.id,
winners: data.winners,
});
}
// own group gets set inactive
if (groupMemberOfId) setGroupAsInactive(groupMemberOfId);
// skills & map/player results only update after both teams have reported
if (newSkills) {
addMapResults(
summarizeMaps({ match, members, winners: data.winners }),
);
addPlayerResults(
summarizePlayerResults({ match, members, winners: data.winners }),
);
addSkills({
skills: newSkills,
differences,
groupMatchId: match.id,
oldMatchMemento: match.memento,
});
clearCaches = true;
}
if (shouldLockMatchWithoutChangingRecords) {
addDummySkill(match.id);
clearCaches = true;
}
// fix edge case where they 1) report score 2) report weapons 3) report score again, but with different amount of maps played
if (compared === "FIX_PREVIOUS") {
deleteReporterWeaponsByMatchId(matchId);
}
// admin reporting, just set both groups inactive
if (data.adminReport) {
setGroupAsInactive(match.groupAlpha.id);
setGroupAsInactive(match.groupBravo.id);
}
})();
if (clearCaches) {
// this is kind of useless to do when admin reports since skills don't change
// but it's not the most common case so it's ok
try {
refreshUserSkills(Seasons.currentOrPrevious()!.nth);
} catch (error) {
logger.warn("Error refreshing user skills", error);
}
refreshStreamsCache();
await refreshSendouQInstance();
if (match.chatCode) {
ChatSystemMessage.send({
room: match.chatCode,
type: "SCORE_CONFIRMED",
context: { name: user.username },
});
}
break;
}
const matchIsBeingCanceled = data.winners.length === 0;
if (matchIsBeingCanceled) {
const result = await SQMatchRepository.cancelMatch({
matchId,
reportedByUserId: user.id,
});
if (result.shouldRefreshCaches) {
try {
refreshUserSkills(Seasons.currentOrPrevious()!.nth);
} catch (error) {
logger.warn("Error refreshing user skills", error);
}
refreshStreamsCache();
}
if (result.status === "CANT_CANCEL") {
return { error: "cant-cancel" as const };
}
if (result.status === "DUPLICATE") {
break;
}
await refreshSendouQInstance();
if (match.chatCode) {
const type: NonNullable<ChatMessage["type"]> =
result.status === "CANCEL_CONFIRMED"
? "CANCEL_CONFIRMED"
: "CANCEL_REPORTED";
ChatSystemMessage.send({
room: match.chatCode,
type,
context: { name: user.username },
});
}
break;
}
const result = await SQMatchRepository.reportScore({
matchId,
reportedByUserId: user.id,
winners: data.winners,
weapons: data.weapons as (ReportedWeapon & {
mapIndex: number;
groupMatchMapId: number;
})[],
});
if (result.shouldRefreshCaches) {
try {
refreshUserSkills(Seasons.currentOrPrevious()!.nth);
} catch (error) {
logger.warn("Error refreshing user skills", error);
}
refreshStreamsCache();
}
if (compared === "DIFFERENT") {
return {
error: matchIsBeingCanceled
? ("cant-cancel" as const)
: ("different" as const),
};
if (result.status === "DIFFERENT") {
return { error: "different" as const };
}
// in a different transaction but it's okay
reportWeapons();
if (result.status !== "DUPLICATE") {
await refreshSendouQInstance();
}
await refreshSendouQInstance();
if (match.chatCode) {
const type = (): NonNullable<ChatMessage["type"]> => {
if (compared === "SAME") {
return matchIsBeingCanceled
? "CANCEL_CONFIRMED"
: "SCORE_CONFIRMED";
}
return matchIsBeingCanceled ? "CANCEL_REPORTED" : "SCORE_REPORTED";
};
if (match.chatCode && result.status !== "DUPLICATE") {
const type: NonNullable<ChatMessage["type"]> =
result.status === "CONFIRMED" ? "SCORE_CONFIRMED" : "SCORE_REPORTED";
ChatSystemMessage.send({
room: match.chatCode,
type: type(),
context: {
name: user.username,
},
type,
context: { name: user.username },
});
}
@ -282,10 +232,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
throw redirect(SENDOUQ_PREPARING_PAGE);
}
case "REPORT_WEAPONS": {
const match = notFoundIfFalsy(findMatchById(matchId));
const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId));
errorToastIfFalsy(match.reportedAt, "Match has not been reported yet");
const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? [];
const oldReportedWeapons =
(await ReportedWeaponRepository.findByMatchId(matchId)) ?? [];
const mergedWeapons = mergeReportedWeapons({
oldWeapons: oldReportedWeapons,
@ -295,10 +246,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
})[],
});
sql.transaction(() => {
deleteReporterWeaponsByMatchId(matchId);
addReportedWeapons(mergedWeapons);
})();
await ReportedWeaponRepository.replaceByMatchId(
matchId,
mergedWeapons.map((w) => ({
groupMatchMapId: w.groupMatchMapId,
userId: w.userId,
weaponSplId: w.weaponSplId,
})),
);
break;
}

View File

@ -303,7 +303,10 @@ export function compareMatchToReportedScores({
newReporterGroupId,
previousReporterGroupId,
}: {
match: SQMatch;
match: Pick<SQMatch, "reportedByUserId" | "mapList"> & {
groupAlpha: { id: number };
groupBravo: { id: number };
};
winners: ("ALPHA" | "BRAVO")[];
newReporterGroupId: number;
previousReporterGroupId?: number;

View File

@ -1,7 +1,7 @@
import type { SQMatchGroup } from "~/features/sendouq/core/SendouQ.server";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { MatchById } from "../queries/findMatchById.server";
import type { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
import type * as ReportedWeaponRepository from "../ReportedWeaponRepository.server";
import type * as SQMatchRepository from "../SQMatchRepository.server";
export type ReportedWeaponForMerging = {
weaponSplId?: MainWeaponId;
@ -65,8 +65,12 @@ export function reportedWeaponsToArrayOfArrays({
groupAlpha,
groupBravo,
}: {
reportedWeapons: ReturnType<typeof reportedWeaponsByMatchId>;
mapList: MatchById["mapList"];
reportedWeapons: Awaited<
ReturnType<typeof ReportedWeaponRepository.findByMatchId>
>;
mapList: NonNullable<
Awaited<ReturnType<typeof SQMatchRepository.findById>>
>["mapList"];
groupAlpha: SQMatchGroup;
groupBravo: SQMatchGroup;
}) {

View File

@ -1,15 +1,21 @@
import type { Tables } from "~/db/tables";
import * as Seasons from "~/features/mmr/core/Seasons";
import type { SQMatch } from "~/features/sendouq/core/SendouQ.server";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import { winnersArrayToWinner } from "../q-match-utils";
type MatchForSummarizing = {
mapList: Array<{ mode: ModeShort; stageId: StageId }>;
groupAlpha: { id: number };
groupBravo: { id: number };
};
export function summarizeMaps({
match,
winners,
members,
}: {
match: SQMatch;
match: MatchForSummarizing;
winners: ("ALPHA" | "BRAVO")[];
members: { id: number; groupId: number }[];
}) {
@ -59,7 +65,7 @@ export function summarizePlayerResults({
winners,
members,
}: {
match: SQMatch;
match: MatchForSummarizing;
winners: ("ALPHA" | "BRAVO")[];
members: { id: number; groupId: number }[];
}) {

View File

@ -3,7 +3,7 @@ import { getUser } from "~/features/auth/core/user.server";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
import { reportedWeaponsToArrayOfArrays } from "~/features/sendouq-match/core/reported-weapons.server";
import { reportedWeaponsByMatchId } from "~/features/sendouq-match/queries/reportedWeaponsByMatchId.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { qMatchPageParamsSchema } from "../q-match-schemas";
@ -29,7 +29,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const match = SendouQ.mapMatch(matchUnmapped, user, privateNotes);
const rawReportedWeapons = match.reportedAt
? reportedWeaponsByMatchId(matchId)
? await ReportedWeaponRepository.findByMatchId(matchId)
: null;
return {

View File

@ -39,7 +39,7 @@ const weapons = z.preprocess(
groupMatchMapId: id,
}),
)
.nullish()
.optional()
.default([]),
);
export const matchSchema = z.union([

View File

@ -1,20 +0,0 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
insert into "Skill" ("groupMatchId", "identifier", "mu", "season", "sigma", "ordinal", "userId", "matchesCount")
values (
@groupMatchId,
null,
-1,
-1,
-1,
-1,
null,
0
)
`);
/** Adds a placeholder skill that makes the match locked */
export function addDummySkill(groupMatchId: number) {
stm.run({ groupMatchId });
}

View File

@ -1,34 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
const addMapResultDeltaStm = sql.prepare(/* sql */ `
insert into "MapResult" (
"mode",
"stageId",
"userId",
"wins",
"losses",
"season"
) values (
@mode,
@stageId,
@userId,
@wins,
@losses,
@season
) on conflict ("userId", "stageId", "mode", "season") do
update
set
"wins" = "wins" + @wins,
"losses" = "losses" + @losses
`);
export function addMapResults(
results: Array<
Pick<Tables["MapResult"], "losses" | "wins" | "userId" | "mode" | "stageId">
>,
) {
for (const result of results) {
addMapResultDeltaStm.run(result);
}
}

View File

@ -1,45 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
const addPlayerResultDeltaStm = sql.prepare(/* sql */ `
insert into "PlayerResult" (
"ownerUserId",
"otherUserId",
"mapWins",
"mapLosses",
"setWins",
"setLosses",
"type",
"season"
) values (
@ownerUserId,
@otherUserId,
@mapWins,
@mapLosses,
@setWins,
@setLosses,
@type,
@season
) on conflict ("ownerUserId", "otherUserId", "type", "season") do
update
set
"mapWins" = "mapWins" + @mapWins,
"mapLosses" = "mapLosses" + @mapLosses,
"setWins" = "setWins" + @setWins,
"setLosses" = "setLosses" + @setLosses
`);
export function addPlayerResults(results: Array<Tables["PlayerResult"]>) {
for (const result of results) {
addPlayerResultDeltaStm.run({
ownerUserId: result.ownerUserId,
otherUserId: result.otherUserId,
mapWins: result.mapWins,
mapLosses: result.mapLosses,
setWins: result.setWins,
setLosses: result.setLosses,
type: result.type,
season: result.season,
});
}
}

View File

@ -1,20 +0,0 @@
import { sql } from "~/db/sql";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
const insertStm = sql.prepare(/* sql */ `
insert into "ReportedWeapon"
("groupMatchMapId", "weaponSplId", "userId")
values (@groupMatchMapId, @weaponSplId, @userId)
`);
export const addReportedWeapons = (
args: {
groupMatchMapId: number;
weaponSplId: MainWeaponId;
userId: number;
}[],
) => {
for (const { groupMatchMapId, userId, weaponSplId } of args) {
insertStm.run({ groupMatchMapId, userId, weaponSplId });
}
};

View File

@ -1,110 +0,0 @@
import { ordinal } from "openskill";
import { sql } from "~/db/sql";
import type { ParsedMemento, Tables } from "~/db/tables";
import { identifierToUserIds } from "~/features/mmr/mmr-utils";
import { databaseTimestampNow } from "~/utils/dates";
import type { MementoSkillDifferences } from "../core/skills.server";
const getStm = (type: "user" | "team") =>
sql.prepare(/* sql */ `
insert into "Skill" ("groupMatchId", "identifier", "mu", "season", "sigma", "ordinal", "userId", "createdAt", "matchesCount")
values (
@groupMatchId,
@identifier,
@mu,
@season,
@sigma,
@ordinal,
@userId,
@createdAt,
1 + coalesce((
select max("matchesCount") from "Skill"
where
${type === "user" ? /* sql */ `"userId" = @userId` : ""}
${type === "team" ? /* sql */ `"identifier" = @identifier` : ""}
and "season" = @season
group by ${
type === "user" ? /* sql */ `"userId"` : /* sql */ `"identifier"`
}
), 0)
) returning *
`);
const addSkillTeamUserStm = sql.prepare(/* sql */ `
insert into "SkillTeamUser" (
"skillId",
"userId"
) values (
@skillId,
@userId
) on conflict("skillId", "userId") do nothing
`);
const userStm = getStm("user");
const teamStm = getStm("team");
const updateMatchMementoStm = sql.prepare(/* sql */ `
update "GroupMatch"
set "memento" = @memento
where "id" = @id
`);
export function addSkills({
groupMatchId,
skills,
oldMatchMemento,
differences,
}: {
groupMatchId: number;
skills: Pick<
Tables["Skill"],
"groupMatchId" | "identifier" | "mu" | "season" | "sigma" | "userId"
>[];
oldMatchMemento: ParsedMemento | null;
differences: MementoSkillDifferences;
}) {
for (const skill of skills) {
const stm = skill.userId ? userStm : teamStm;
const insertedSkill = stm.get({
...skill,
createdAt: databaseTimestampNow(),
ordinal: ordinal(skill),
}) as Tables["Skill"];
if (insertedSkill.identifier) {
for (const userId of identifierToUserIds(insertedSkill.identifier)) {
addSkillTeamUserStm.run({
skillId: insertedSkill.id,
userId,
});
}
}
}
if (!oldMatchMemento) return;
const newMemento: ParsedMemento = {
...oldMatchMemento,
groups: {},
users: {},
};
for (const [key, value] of Object.entries(oldMatchMemento.users)) {
newMemento.users[key as any] = {
...value,
skillDifference: differences.users[key as any]?.skillDifference,
};
}
for (const [key, value] of Object.entries(oldMatchMemento.groups)) {
newMemento.groups[key as any] = {
...value,
skillDifference: differences.groups[key as any]?.skillDifference,
};
}
updateMatchMementoStm.run({
id: groupMatchId,
memento: JSON.stringify(newMemento),
});
}

View File

@ -1,21 +0,0 @@
import { sql } from "~/db/sql";
const deleteStm = sql.prepare(/* sql */ `
delete from "ReportedWeapon"
where "groupMatchMapId" = @groupMatchMapId
`);
const getGroupMatchMapsStm = sql.prepare(/* sql */ `
select "id" from "GroupMatchMap"
where "matchId" = @matchId
`);
export const deleteReporterWeaponsByMatchId = (matchId: number) => {
const groupMatchMaps = getGroupMatchMapsStm.all({ matchId }) as Array<{
id: number;
}>;
for (const { id } of groupMatchMaps) {
deleteStm.run({ groupMatchMapId: id });
}
};

View File

@ -1,60 +0,0 @@
import { sql } from "~/db/sql";
import type { ParsedMemento, Tables } from "~/db/tables";
import { parseDBJsonArray } from "~/utils/sql";
const stm = sql.prepare(/* sql */ `
select
"GroupMatch"."id",
"GroupMatch"."alphaGroupId",
"GroupMatch"."bravoGroupId",
"GroupMatch"."createdAt",
"GroupMatch"."reportedAt",
"GroupMatch"."reportedByUserId",
"GroupMatch"."chatCode",
"GroupMatch"."memento",
(select exists (select 1 from "Skill" where "Skill"."groupMatchId" = @id)) as "isLocked",
json_group_array(
json_object(
'id', "GroupMatchMap"."id",
'mode', "GroupMatchMap"."mode",
'stageId', "GroupMatchMap"."stageId",
'source', "GroupMatchMap"."source",
'winnerGroupId', "GroupMatchMap"."winnerGroupId"
)
) as "mapList"
from "GroupMatch"
left join "GroupMatchMap" on "GroupMatchMap"."matchId" = "GroupMatch"."id"
where "GroupMatch"."id" = @id
group by "GroupMatch"."id"
order by "GroupMatchMap"."index" asc
`);
export interface MatchById {
id: Tables["GroupMatch"]["id"];
alphaGroupId: Tables["GroupMatch"]["alphaGroupId"];
bravoGroupId: Tables["GroupMatch"]["bravoGroupId"];
createdAt: Tables["GroupMatch"]["createdAt"];
reportedAt: Tables["GroupMatch"]["reportedAt"];
reportedByUserId: Tables["GroupMatch"]["reportedByUserId"];
chatCode: Tables["GroupMatch"]["chatCode"];
isLocked: boolean;
memento: ParsedMemento;
mapList: Array<
Pick<
Tables["GroupMatchMap"],
"id" | "mode" | "stageId" | "source" | "winnerGroupId"
>
>;
}
export function findMatchById(id: number) {
const row = stm.get({ id }) as any;
if (!row) return null;
return {
...row,
mapList: parseDBJsonArray(row.mapList),
isLocked: Boolean(row.isLocked),
memento: row.memento ? JSON.parse(row.memento) : null,
} as MatchById;
}

View File

@ -1,52 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { dateToDatabaseTimestamp } from "~/utils/dates";
const updateMatchStm = sql.prepare(/* sql */ `
update "GroupMatch"
set "reportedAt" = @reportedAt,
"reportedByUserId" = @reportedByUserId
where "id" = @matchId
returning *
`);
const clearMatchMapWinnersStm = sql.prepare(/* sql */ `
update "GroupMatchMap"
set "winnerGroupId" = null
where "matchId" = @matchId
`);
const updateMatchMapStm = sql.prepare(/* sql */ `
update "GroupMatchMap"
set "winnerGroupId" = @winnerGroupId
where "matchId" = @matchId and "index" = @index
`);
export const reportScore = ({
reportedByUserId,
winners,
matchId,
}: {
reportedByUserId: number;
winners: ("ALPHA" | "BRAVO")[];
matchId: number;
}) => {
const updatedMatch = updateMatchStm.get({
reportedAt: dateToDatabaseTimestamp(new Date()),
reportedByUserId,
matchId,
}) as Tables["GroupMatch"];
clearMatchMapWinnersStm.run({ matchId });
for (const [index, winner] of winners.entries()) {
updateMatchMapStm.run({
winnerGroupId:
winner === "ALPHA"
? updatedMatch.alphaGroupId
: updatedMatch.bravoGroupId,
matchId,
index,
});
}
};

View File

@ -1,27 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
const stm = sql.prepare(/* sql */ `
select
"ReportedWeapon"."groupMatchMapId",
"ReportedWeapon"."weaponSplId",
"ReportedWeapon"."userId",
"GroupMatchMap"."index" as "mapIndex"
from
"ReportedWeapon"
left join "GroupMatchMap" on "GroupMatchMap"."id" = "ReportedWeapon"."groupMatchMapId"
where "GroupMatchMap"."matchId" = @matchId
`);
export function reportedWeaponsByMatchId(matchId: number) {
const rows = stm.all({ matchId }) as Array<
Tables["ReportedWeapon"] & {
mapIndex: Tables["GroupMatchMap"]["index"];
groupMatchMapId: number;
}
>;
if (rows.length === 0) return null;
return rows;
}

View File

@ -1,11 +0,0 @@
import { sql } from "~/db/sql";
const groupToInactiveStm = sql.prepare(/* sql */ `
update "Group"
set "status" = 'INACTIVE'
where "id" = @groupId
`);
export function setGroupAsInactive(groupId: number) {
groupToInactiveStm.run({ groupId });
}

View File

@ -695,3 +695,11 @@ export function setPreparingGroupAsActive(groupId: number) {
.where("status", "=", "PREPARING")
.execute();
}
export function setAsInactive(groupId: number, trx?: Transaction<DB>) {
return (trx ?? db)
.updateTable("Group")
.set({ status: "INACTIVE" })
.where("id", "=", groupId)
.execute();
}

View File

@ -69,6 +69,7 @@ export function findAllMemberOfByUserId(userId: number) {
"Team.id",
"Team.customUrl",
"Team.name",
"TeamMemberWithSecondary.role",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"logoUrl",
),

View File

@ -1,15 +1,12 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
import { db } from "~/db/sql";
import type { SerializeFrom } from "~/utils/remix";
import {
assertResponseErrored,
dbInsertUsers,
dbReset,
wrappedAction,
wrappedLoader,
} from "~/utils/Test";
import { loader as userProfileLoader } from "../../user-page/loaders/u.$identifier.index.server";
import { action as _teamPageAction } from "../actions/t.$customUrl.index.server";
import { action as teamIndexPageAction } from "../actions/t.new.server";
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
@ -20,12 +17,6 @@ import type {
teamProfilePageActionSchema,
} from "../team-schemas.server";
const loadUserTeamLoader = wrappedLoader<
SerializeFrom<typeof userProfileLoader>
>({
loader: userProfileLoader,
});
const createTeamAction = wrappedAction<typeof createTeamSchema>({
action: teamIndexPageAction,
isJsonSubmission: true,
@ -40,14 +31,12 @@ const editTeamAction = wrappedAction<typeof editTeamSchema>({
});
async function loadTeams() {
const data = await loadUserTeamLoader({
user: "regular",
params: {
identifier: String(REGULAR_USER_TEST_ID),
},
});
const teams = await TeamRepository.teamsByMemberUserId(REGULAR_USER_TEST_ID);
return { team: data.user.team, secondaryTeams: data.user.secondaryTeams };
const mainTeam = teams.find((t) => t.isMainTeam);
const secondaryTeams = teams.filter((t) => !t.isMainTeam);
return { team: mainTeam, secondaryTeams };
}
describe("Secondary teams", () => {

View File

@ -1,6 +1,9 @@
import type { InferResult } from "kysely";
import { sql } from "kysely";
import { db } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { modesShort } from "~/modules/in-game-lists/modes";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
export function unlinkPlayerByUserId(userId: number) {
return db
@ -54,6 +57,26 @@ export async function findPlacementsByPlayerId(
return result.length ? result : null;
}
export async function findPlacementsByUserId(
userId: Tables["User"]["id"],
options?: { limit?: number; weaponId?: MainWeaponId },
) {
let query = xRankPlacementsQueryBase()
.where("SplatoonPlayer.userId", "=", userId)
.orderBy("XRankPlacement.power", "desc");
if (options?.weaponId) {
query = query.where("XRankPlacement.weaponSplId", "=", options.weaponId);
}
if (options?.limit) {
query = query.limit(options.limit);
}
const result = await query.execute();
return result.length ? result : null;
}
export async function monthYears() {
return await db
.selectFrom("XRankPlacement")
@ -64,6 +87,44 @@ export async function monthYears() {
.execute();
}
export async function findPeaksByUserId(
userId: Tables["User"]["id"],
division?: "both" | "tentatek" | "takoroka",
) {
let innerQuery = db
.selectFrom("XRankPlacement")
.innerJoin("SplatoonPlayer", "XRankPlacement.playerId", "SplatoonPlayer.id")
.where("SplatoonPlayer.userId", "=", userId)
.select([
"XRankPlacement.mode",
"XRankPlacement.rank",
"XRankPlacement.power",
"XRankPlacement.region",
"XRankPlacement.playerId",
sql<number>`ROW_NUMBER() OVER (PARTITION BY "XRankPlacement"."mode" ORDER BY "XRankPlacement"."power" DESC)`.as(
"rn",
),
]);
if (division === "tentatek") {
innerQuery = innerQuery.where("XRankPlacement.region", "=", "WEST");
} else if (division === "takoroka") {
innerQuery = innerQuery.where("XRankPlacement.region", "=", "JPN");
}
const rows = await db
.selectFrom(innerQuery.as("ranked"))
.selectAll()
.where("rn", "=", 1)
.execute();
const peaksByMode = new Map(rows.map((row) => [row.mode, row]));
return modesShort
.map((mode) => peaksByMode.get(mode))
.filter((p): p is NonNullable<typeof p> => p !== undefined);
}
export type FindPlacement = InferResult<
ReturnType<typeof xRankPlacementsQueryBase>
>[number];

View File

@ -1,6 +1,6 @@
import type { ActionFunctionArgs } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { syncXPBadges } from "~/features/badges/queries/syncXPBadges.server";
import * as BadgeRepository from "~/features/badges/BadgeRepository.server";
import { logger } from "~/utils/logger";
import {
errorToastIfFalsy,
@ -35,7 +35,7 @@ export const action = async ({ params }: ActionFunctionArgs) => {
await XRankPlacementRepository.unlinkPlayerByUserId(user.id);
syncXPBadges();
await BadgeRepository.syncXPBadges();
return successToast("Unlink successful");
};

View File

@ -108,7 +108,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
finalizeTournament(tournamentId);
}
await updateSeriesTierHistory(tournament);
if (!tournament.isTest) {
await updateSeriesTierHistory(tournament);
}
if (tournament.ranked) {
try {

View File

@ -51,6 +51,10 @@ export const action: ActionFunction = async ({ params, request }) => {
switch (data._action) {
case "START_BRACKET": {
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
errorToastIfFalsy(
!tournament.isDraft,
"Tournament must be opened before starting a bracket",
);
const bracket = tournament.bracketByIdx(data.bracketIdx);
invariant(bracket, "Bracket not found");
@ -154,7 +158,7 @@ export const action: ActionFunction = async ({ params, request }) => {
}
}
if (!tournament.isTest) {
if (!tournament.isTest && !tournament.isDraft) {
notify({
userIds: seeding.flatMap((tournamentTeamId) =>
tournament.teamById(tournamentTeamId)!.members.map((m) => m.userId),

View File

@ -593,6 +593,11 @@ export class Tournament {
return this.ctx.settings.isTest ?? false;
}
/** Draft tournament that is hidden during preparation, must be opened before bracket start */
get isDraft() {
return this.ctx.settings.isDraft ?? false;
}
/** What seeding skill rating this tournament counts for */
get skillCountsFor() {
if (this.ranked) {

View File

@ -6925,7 +6925,7 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
id: 3,
name: "Inkling Performance Labs",
slug: "inkling-performance-labs",
avatarUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
members: [
{
userId: 405,

View File

@ -2028,7 +2028,7 @@ export const SWIM_OR_SINK_167 = (
id: 3,
name: "Inkling Performance Labs",
slug: "inkling-performance-labs",
avatarUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
logoUrl: "fZrToLQrkqV3UZkdgwp0Q-1722263644749.webp",
members: [
{
userId: 405,

View File

@ -199,7 +199,11 @@ export default function TournamentBracketsPage() {
{bracket.participantTournamentTeamIds.length}/
{totalTeamsAvailableForTheBracket()} teams checked in
{bracket.canBeStarted ? (
<BracketStarter bracket={bracket} bracketIdx={bracketIdx} />
tournament.isDraft ? (
<DraftBracketStartPopover />
) : (
<BracketStarter bracket={bracket} bracketIdx={bracketIdx} />
)
) : null}
</Alert>
{!bracket.canBeStarted ? (
@ -283,6 +287,27 @@ function BracketStarter({
);
}
function DraftBracketStartPopover() {
const { t } = useTranslation(["calendar"]);
return (
<SendouPopover
popoverClassName="text-xs"
trigger={
<SendouButton
variant="outlined"
size="small"
data-testid="finalize-bracket-button"
>
Start the bracket
</SendouButton>
}
>
{t("calendar:forms.draftBracketStartBlocked")}
</SendouPopover>
);
}
function MapPreparer({
bracket,
bracketIdx,

View File

@ -4,6 +4,7 @@ import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
import { db } from "~/db/sql";
import type { Tables, TablesInsertable } from "~/db/tables";
import {
TIER_HISTORY_LENGTH,
type TournamentTierNumber,
updateTierHistory,
} from "~/features/tournament/core/tiering";
@ -150,10 +151,21 @@ export function findByUserId(
"TournamentOrganization.id",
"TournamentOrganizationMember.organizationId",
)
.select([
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"TournamentOrganization.avatarImgId",
)
.select(({ eb }) => [
"TournamentOrganization.id",
"TournamentOrganization.name",
"TournamentOrganization.slug",
"TournamentOrganization.isEstablished",
"TournamentOrganizationMember.role",
"TournamentOrganizationMember.roleDisplayName",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"logoUrl",
),
])
.where("TournamentOrganizationMember.userId", "=", userId)
.$if(roles.length > 0, (qb) =>
@ -450,7 +462,7 @@ export function update({
.execute();
if (series.length > 0) {
await trx
const insertedSeries = await trx
.insertInto("TournamentOrganizationSeries")
.values(
series.map((s) => ({
@ -461,7 +473,54 @@ export function update({
showLeaderboard: Number(s.showLeaderboard),
})),
)
.returning(["id", "substringMatches"])
.execute();
const finalizedTournaments = await trx
.selectFrom("Tournament")
.innerJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"Tournament.id",
)
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select([
"Tournament.id as tournamentId",
"CalendarEvent.name",
"Tournament.tier",
"CalendarEventDate.startTime",
])
.where("Tournament.isFinalized", "=", 1)
.where("CalendarEvent.organizationId", "=", id)
.where("CalendarEvent.hidden", "=", 0)
.orderBy("CalendarEventDate.startTime", "asc")
.execute();
for (const s of insertedSeries) {
const matchingTiers = finalizedTournaments
.filter((t) => {
const eventNameLower = t.name.toLowerCase();
return s.substringMatches.some((match) =>
eventNameLower.includes(match.toLowerCase()),
);
})
.filter((t) => t.tier !== null)
.map((t) => t.tier);
if (matchingTiers.length === 0) continue;
const tierHistory = matchingTiers.slice(-TIER_HISTORY_LENGTH);
await trx
.updateTable("TournamentOrganizationSeries")
.set({ tierHistory: JSON.stringify(tierHistory) })
.where("id", "=", s.id)
.execute();
}
}
await trx
@ -485,6 +544,20 @@ export function update({
});
}
export function removeMember({
organizationId,
userId,
}: {
organizationId: number;
userId: number;
}) {
return db
.deleteFrom("TournamentOrganizationMember")
.where("organizationId", "=", organizationId)
.where("userId", "=", userId)
.execute();
}
/**
* Inserts a user to the banned list for a tournament organization or updates the existing entry if already exists.
*/
@ -587,6 +660,13 @@ export function updateIsEstablished(
.execute();
}
export function deleteById(organizationId: number) {
return db
.deleteFrom("TournamentOrganization")
.where("id", "=", organizationId)
.execute();
}
export function findAllSeriesWithTierHistory() {
return db
.selectFrom("TournamentOrganizationSeries")

View File

@ -1,5 +1,5 @@
import { isFuture } from "date-fns";
import type { ActionFunctionArgs } from "react-router";
import { type ActionFunctionArgs, redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import {
requirePermission,
@ -10,7 +10,11 @@ import {
dateToDatabaseTimestamp,
} from "~/utils/dates";
import { logger } from "~/utils/logger";
import { errorToast, parseRequestPayload } from "~/utils/remix.server";
import {
errorToast,
errorToastIfFalsy,
parseRequestPayload,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import * as TournamentOrganizationRepository from "../TournamentOrganizationRepository.server";
import { TOURNAMENT_ORGANIZATION } from "../tournament-organization-constants";
@ -89,6 +93,39 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
break;
}
case "LEAVE_ORGANIZATION": {
const member = organization.members.find((m) => m.id === user.id);
errorToastIfFalsy(member, "You are not a member of this organization");
const adminCount = organization.members.filter(
(m) => m.role === "ADMIN",
).length;
if (member.role === "ADMIN" && adminCount === 1) {
errorToast("Cannot leave as the sole admin of the organization");
}
await TournamentOrganizationRepository.removeMember({
organizationId: organization.id,
userId: user.id,
});
logger.info(
`User left organization: organization=${organization.name} (${organization.id}), userId=${user.id}`,
);
break;
}
case "DELETE_ORGANIZATION": {
requireRole(user, "ADMIN");
await TournamentOrganizationRepository.deleteById(organization.id);
logger.info(
`Organization deleted: organization=${organization.name} (${organization.id}), deleted by userId=${user.id}`,
);
throw redirect("/");
}
default: {
assertUnreachable(data);
}

View File

@ -1,5 +1,5 @@
import clsx from "clsx";
import { isFuture } from "date-fns";
import { isPast } from "date-fns";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router";
@ -57,7 +57,7 @@ export function BannedUsersList({
{bannedUsers.map((bannedUser) => {
const isExpired =
bannedUser.expiresAt &&
isFuture(databaseTimestampToDate(bannedUser.expiresAt));
isPast(databaseTimestampToDate(bannedUser.expiresAt));
return (
<tr key={bannedUser.id}>

View File

@ -1,21 +1,24 @@
import { Link as LinkIcon, Lock, SquarePen, Users } from "lucide-react";
import { Link as LinkIcon, Lock, LogOut, SquarePen, Users } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { MetaFunction } from "react-router";
import { Link, useLoaderData, useSearchParams } from "react-router";
import { Avatar } from "~/components/Avatar";
import { Divider } from "~/components/Divider";
import { LinkButton } from "~/components/elements/Button";
import { LinkButton, SendouButton } from "~/components/elements/Button";
import { SendouDialog } from "~/components/elements/Dialog";
import {
SendouTab,
SendouTabList,
SendouTabPanel,
SendouTabs,
} from "~/components/elements/Tabs";
import { FormWithConfirm } from "~/components/FormWithConfirm";
import { Image } from "~/components/Image";
import { Main } from "~/components/Main";
import { Pagination } from "~/components/Pagination";
import { Placement } from "~/components/Placement";
import { TierPill } from "~/components/TierPill";
import { useUser } from "~/features/auth/core/user";
import { BadgeDisplay } from "~/features/badges/components/BadgeDisplay";
import { BannedUsersList } from "~/features/tournament-organization/components/BannedPlayersList";
import { SendouForm } from "~/form/SendouForm";
@ -92,7 +95,6 @@ export default function TournamentOrganizationPage() {
return (
<Main className="stack lg">
<LogoHeader />
<AdminControls />
<InfoTabs />
{data.organization.series.length > 0 ? (
<SeriesSelector series={data.organization.series} />
@ -107,26 +109,70 @@ export default function TournamentOrganizationPage() {
}
function LogoHeader() {
const { t } = useTranslation(["common"]);
const { t } = useTranslation(["common", "org"]);
const data = useLoaderData<typeof loader>();
const user = useUser();
const canEditOrganization = useHasPermission(data.organization, "EDIT");
const currentMember = user
? data.organization.members.find((m) => m.id === user.id)
: undefined;
const isSoleAdmin =
currentMember?.role === "ADMIN" &&
data.organization.members.filter((m) => m.role === "ADMIN").length === 1;
return (
<div className="stack horizontal md">
<Avatar size="lg" url={data.organization.avatarUrl ?? undefined} />
<div className="stack sm">
<div className="text-xl font-bold">{data.organization.name}</div>
{canEditOrganization ? (
<div className="stack items-start">
<LinkButton
to={tournamentOrganizationEditPage(data.organization.slug)}
icon={<SquarePen />}
size="small"
variant="outlined"
testId="edit-org-button"
>
{t("common:actions.edit")}
</LinkButton>
{canEditOrganization || currentMember ? (
<div className="stack horizontal sm items-start">
{canEditOrganization ? (
<LinkButton
to={tournamentOrganizationEditPage(data.organization.slug)}
icon={<SquarePen />}
size="small"
variant="outlined"
testId="edit-org-button"
>
{t("common:actions.edit")}
</LinkButton>
) : null}
{currentMember ? (
isSoleAdmin ? (
<SendouDialog
showHeading={false}
trigger={
<SendouButton
icon={<LogOut />}
size="small"
variant="destructive"
>
{t("org:leave.action")}
</SendouButton>
}
>
<p>{t("org:leave.soleAdmin")}</p>
</SendouDialog>
) : (
<FormWithConfirm
dialogHeading={t("org:leave.confirm", {
organizationName: data.organization.name,
})}
fields={[["_action", "LEAVE_ORGANIZATION"]]}
submitButtonText={t("org:leave.action")}
>
<SendouButton
icon={<LogOut />}
size="small"
variant="destructive"
>
{t("org:leave.action")}
</SendouButton>
</FormWithConfirm>
)
) : null}
</div>
) : null}
<div className="whitespace-pre-wrap text-sm text-lighter">
@ -137,32 +183,10 @@ function LogoHeader() {
);
}
function AdminControls() {
const data = useLoaderData<typeof loader>();
const isAdmin = useHasRole("ADMIN");
if (!isAdmin) return null;
return (
<div className="stack sm">
<div className="text-sm font-semi-bold">Admin Controls</div>
<SendouForm
className=""
schema={updateIsEstablishedSchema}
defaultValues={{
isEstablished: Boolean(data.organization.isEstablished),
}}
autoSubmit
>
{({ FormField }) => <FormField name="isEstablished" />}
</SendouForm>
</div>
);
}
function InfoTabs() {
const { t } = useTranslation(["org"]);
const data = useLoaderData<typeof loader>();
const isAdmin = useHasRole("ADMIN");
const canBanPlayers = useHasPermission(data.organization, "BAN");
const hasSocials =
@ -195,6 +219,11 @@ function InfoTabs() {
{t("org:banned.title")}
</SendouTab>
) : null}
{isAdmin ? (
<SendouTab id="admin" icon={<Lock />}>
Admin
</SendouTab>
) : null}
</SendouTabList>
<SendouTabPanel id="socials">
<SocialLinksList links={data.organization.socials ?? []} />
@ -210,11 +239,43 @@ function InfoTabs() {
<BannedUsersList bannedUsers={data.bannedUsers} />
</SendouTabPanel>
) : null}
{isAdmin ? (
<SendouTabPanel id="admin">
<AdminControls />
</SendouTabPanel>
) : null}
</SendouTabs>
</div>
);
}
function AdminControls() {
const data = useLoaderData<typeof loader>();
return (
<div className="stack sm">
<SendouForm
className=""
schema={updateIsEstablishedSchema}
defaultValues={{
isEstablished: Boolean(data.organization.isEstablished),
}}
autoSubmit
>
{({ FormField }) => <FormField name="isEstablished" />}
</SendouForm>
<FormWithConfirm
dialogHeading={`Delete organization "${data.organization.name}"?`}
fields={[["_action", "DELETE_ORGANIZATION"]]}
>
<SendouButton variant="minimal-destructive">
Delete organization
</SendouButton>
</FormWithConfirm>
</div>
);
}
function MembersList() {
const { t } = useTranslation(["org"]);
const data = useLoaderData<typeof loader>();

View File

@ -95,7 +95,7 @@ export const banUserActionSchema = z.object({
expiresAt: datetimeOptional({
label: "labels.banUserExpiresAt",
bottomText: "bottomTexts.banUserExpiresAtHelp",
min: new Date(),
min: () => new Date(),
minMessage: "errors.dateInPast",
}),
});
@ -112,8 +112,18 @@ export const updateIsEstablishedSchema = z.object({
}),
});
const deleteOrganizationActionSchema = z.object({
_action: _action("DELETE_ORGANIZATION"),
});
const leaveOrganizationActionSchema = z.object({
_action: _action("LEAVE_ORGANIZATION"),
});
export const orgPageActionSchema = z.union([
banUserActionSchema,
unbanUserActionSchema,
updateIsEstablishedSchema,
deleteOrganizationActionSchema,
leaveOrganizationActionSchema,
]);

View File

@ -1,5 +1,6 @@
import { type ActionFunction, redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import { requireNotBannedByOrganization } from "~/features/tournament/tournament-utils.server";
import {
clearTournamentDataCache,
tournamentFromDB,
@ -26,6 +27,7 @@ export const action: ActionFunction = async ({ params, request }) => {
});
const tournament = await tournamentFromDB({ tournamentId, user });
await requireNotBannedByOrganization({ tournament, user });
errorToastIfFalsy(!tournament.everyBracketOver, "Tournament is over");
errorToastIfFalsy(
tournament.canAddNewSubPost,

View File

@ -73,7 +73,7 @@ export async function findById(id: number) {
"TournamentOrganization.slug",
concatUserSubmittedImagePrefix(
innerEb.ref("UserSubmittedImage.url"),
).as("avatarUrl"),
).as("logoUrl"),
jsonArrayFrom(
innerEb
.selectFrom("TournamentOrganizationMember")

View File

@ -13,6 +13,7 @@ import {
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { deleteSub } from "~/features/tournament-subs/queries/deleteSub.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
@ -64,6 +65,10 @@ export const action: ActionFunction = async ({ request, params }) => {
!tournament.teamMemberOfByUser({ id: data.userId }),
"User already on a team",
);
errorToastIfFalsy(
(await UserRepository.findLeanById(data.userId))?.friendCode,
"User has no friend code set",
);
await TournamentTeamRepository.create({
ownerInGameName: await inGameNameIfNeeded({
@ -237,6 +242,11 @@ export const action: ActionFunction = async ({ request, params }) => {
"User trying to be added currently has an active ban from sendou.ink",
);
errorToastIfFalsy(
(await UserRepository.findLeanById(data.userId))?.friendCode,
"User has no friend code set",
);
joinTeam({
userId: data.userId,
newTeamId: team.id,
@ -261,7 +271,7 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: data.userId,
});
if (!tournament.isTest) {
if (!tournament.isTest && !tournament.isDraft) {
notify({
userIds: [data.userId],
notification: {

View File

@ -291,7 +291,7 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: data.userId,
});
if (!tournament.isTest) {
if (!tournament.isTest && !tournament.isDraft) {
notify({
userIds: [data.userId],
notification: {

View File

@ -47,6 +47,10 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
tournament.ctx.organization?.members.some(
(m) => m.userId === user?.id && m.role === "ORGANIZER",
);
if (tournament.ctx.settings.isDraft && !isTournamentOrganizer) {
throw new Response(null, { status: 404 });
}
const showFriendCodes = tournamentStartedInTheLastMonth && isTournamentAdmin;
// skip expensive rr7 data serialization (hot path loader)

View File

@ -69,8 +69,6 @@ export default function TournamentRegisterPage() {
const isMounted = useIsMounted();
const tournament = useTournament();
const startsAtEvenHour = tournament.ctx.startTime.getMinutes() === 0;
return (
<div className={clsx("stack lg", containerClassName("normal"))}>
<div className={styles.logoContainer}>
@ -93,7 +91,7 @@ export default function TournamentRegisterPage() {
className="stack horizontal sm items-center text-xs text-main-forced"
>
<Avatar
url={tournament.ctx.organization.avatarUrl ?? undefined}
url={tournament.ctx.organization.logoUrl ?? undefined}
size="xxs"
/>
{tournament.ctx.organization.name}
@ -116,7 +114,7 @@ export default function TournamentRegisterPage() {
<TimePopover
time={tournament.ctx.startTime}
options={{
minute: startsAtEvenHour ? undefined : "numeric",
minute: "numeric",
hour: "numeric",
day: "numeric",
month: "long",

View File

@ -19,6 +19,7 @@ import { removeMarkdown } from "~/utils/strings";
import { assertUnreachable } from "~/utils/types";
import {
tournamentDivisionsPage,
tournamentOrganizationPage,
tournamentPage,
tournamentRegisterPage,
} from "~/utils/urls";
@ -69,6 +70,17 @@ export const handle: SendouRouteHandle = {
const data = JSON.parse(rawData) as TournamentLoaderData;
return [
data.tournament.ctx.organization?.logoUrl
? {
imgPath: data.tournament.ctx.organization.logoUrl,
href: tournamentOrganizationPage({
organizationSlug: data.tournament.ctx.organization.slug,
}),
type: "IMAGE" as const,
text: "",
rounded: true,
}
: null,
{
imgPath: data.tournament.ctx.logoUrl,
href: tournamentPage(data.tournament.ctx.id),

View File

@ -1,5 +1,7 @@
import { TEAM } from "../team/team-constants";
export const TOURNAMENT = {
TEAM_NAME_MAX_LENGTH: 32,
TEAM_NAME_MAX_LENGTH: TEAM.NAME_MAX_LENGTH,
COUNTERPICK_MAPS_PER_MODE: 2,
COUNTERPICK_MAX_STAGE_REPEAT: 2,
COUNTERPICK_ONE_MODE_TOURNAMENT_MAPS_PER_MODE: 6,

View File

@ -22,8 +22,14 @@ import {
tournamentLogoOrNull,
userChatNameColor,
} from "~/utils/kysely.server";
import { logger } from "~/utils/logger";
import { safeNumberParse } from "~/utils/number";
import { bskyUrl, twitchUrl, youtubeUrl } from "~/utils/urls";
import type { ChatUser } from "../chat/chat-types";
import { sortBadgesByFavorites } from "./core/badge-sorting.server";
import { findWidgetById } from "./core/widgets/portfolio";
import { WIDGET_LOADERS } from "./core/widgets/portfolio-loaders.server";
import type { LoadedWidget } from "./core/widgets/types";
export const identifierToUserIdQuery = (identifier: string) =>
db
@ -79,6 +85,9 @@ export function findLayoutDataByIdentifier(
return identifierToUserIdQuery(identifier)
.select((eb) => [
...COMMON_USER_FIELDS,
"User.pronouns",
"User.country",
"User.inGameName",
"User.commissionText",
"User.commissionsOpen",
sql<Record<
@ -235,27 +244,12 @@ export async function findProfileByIdentifier(
return null;
}
const favoriteBadgeIds = favoriteBadgesOwnedAndSupporterStatusAdjusted(row);
return {
...row,
team: row.teams.find((t) => t.isMainTeam),
secondaryTeams: row.teams.filter((t) => !t.isMainTeam),
teams: undefined,
favoriteBadgeIds,
badges: row.badges.sort((a, b) => {
const aIdx = favoriteBadgeIds?.indexOf(a.id) ?? -1;
const bIdx = favoriteBadgeIds?.indexOf(b.id) ?? -1;
if (aIdx !== bIdx) {
if (aIdx === -1) return 1;
if (bIdx === -1) return -1;
return aIdx - bIdx;
}
return b.id - a.id;
}),
...sortBadgesByFavorites(row),
discordUniqueName:
forceShowDiscordUniqueName || row.showDiscordUniqueName
? row.discordUniqueName
@ -263,31 +257,100 @@ export async function findProfileByIdentifier(
};
}
function favoriteBadgesOwnedAndSupporterStatusAdjusted(row: {
favoriteBadgeIds: number[] | null;
badges: Array<{
id: number;
}>;
patronTier: number | null;
}) {
// filter out favorite badges no longer owner of
let favoriteBadgeIds =
row.favoriteBadgeIds?.filter((badgeId) =>
row.badges.some((badge) => badge.id === badgeId),
) ?? null;
export async function widgetsEnabledByIdentifier(identifier: string) {
const row = await identifierToUserIdQuery(identifier)
.select(["User.preferences", "User.patronTier"])
.executeTakeFirst();
if (favoriteBadgeIds?.length === 0) {
favoriteBadgeIds = null;
}
if (!row) return false;
if (!isSupporter(row)) return false;
// non-supporters can only have one favorite badge, handle losing supporter status
favoriteBadgeIds = isSupporter(row)
? favoriteBadgeIds
: favoriteBadgeIds
? [favoriteBadgeIds[0]]
: null;
return row?.preferences?.newProfileEnabled === true;
}
return favoriteBadgeIds;
export async function preferencesByUserId(userId: number) {
const row = await db
.selectFrom("User")
.select("User.preferences")
.where("User.id", "=", userId)
.executeTakeFirst();
return row?.preferences ?? null;
}
export async function upsertWidgets(
userId: number,
widgets: Array<Tables["UserWidget"]["widget"]>,
) {
return db.transaction().execute(async (trx) => {
await trx.deleteFrom("UserWidget").where("userId", "=", userId).execute();
await trx
.insertInto("UserWidget")
.values(
widgets.map((widget, index) => ({
userId,
index,
widget: JSON.stringify(widget),
})),
)
.execute();
});
}
export async function storedWidgetsByUserId(
userId: number,
): Promise<Array<Tables["UserWidget"]["widget"]>> {
const rows = await db
.selectFrom("UserWidget")
.select(["widget"])
.where("userId", "=", userId)
.orderBy("index", "asc")
.execute();
return rows.map((row) => row.widget);
}
export async function widgetsByUserId(
identifier: string,
): Promise<LoadedWidget[] | null> {
const user = await identifierToUserId(identifier);
if (!user) return null;
const widgets = await db
.selectFrom("UserWidget")
.select(["widget"])
.where("userId", "=", user.id)
.orderBy("index", "asc")
.execute();
const loadedWidgets = await Promise.all(
widgets.map(async ({ widget }) => {
const definition = findWidgetById(widget.id);
if (!definition) {
logger.warn(
`Unknown widget id found for user ${user.id}: ${widget.id}`,
);
return null;
}
const loader = WIDGET_LOADERS[widget.id as keyof typeof WIDGET_LOADERS];
const data = loader
? await loader(user.id, widget.settings as any)
: widget.settings;
return {
id: widget.id,
data,
settings: widget.settings,
slot: definition.slot,
} as LoadedWidget;
}),
);
return loadedWidgets.filter((w) => w !== null);
}
export function findByCustomUrl(customUrl: string) {
@ -677,6 +740,30 @@ export async function hasHighlightedResultsByUserId(userId: number) {
return !!highlightedCalendarEventResult;
}
export async function findResultPlacementsByUserId(userId: number) {
const tournamentResults = await db
.selectFrom("TournamentResult")
.select(["TournamentResult.placement"])
.where("userId", "=", userId)
.execute();
const calendarEventResults = await db
.selectFrom("CalendarEventResultPlayer")
.innerJoin(
"CalendarEventResultTeam",
"CalendarEventResultTeam.id",
"CalendarEventResultPlayer.teamId",
)
.select(["CalendarEventResultTeam.placement"])
.where("CalendarEventResultPlayer.userId", "=", userId)
.execute();
return [
...tournamentResults.map((r) => ({ placement: r.placement })),
...calendarEventResults.map((r) => ({ placement: r.placement })),
];
}
const searchSelectedFields = ({ fn }: { fn: FunctionModule<DB, "User"> }) =>
[
...COMMON_USER_FIELDS,
@ -864,6 +951,28 @@ export async function inGameNameByUserId(userId: number) {
)?.inGameName;
}
export async function patronSinceByUserId(userId: number) {
return (
await db
.selectFrom("User")
.select("User.patronSince")
.where("id", "=", userId)
.executeTakeFirst()
)?.patronSince;
}
export async function commissionsByUserId(userId: number) {
return await db
.selectFrom("User")
.select([
"User.commissionsOpen",
"User.commissionsOpenedAt",
"User.commissionText",
])
.where("id", "=", userId)
.executeTakeFirst();
}
export function insertFriendCode(args: TablesInsertable["UserFriendCode"]) {
cachedFriendCodes?.add(args.friendCode);
@ -1138,6 +1247,45 @@ export async function anyUserPrefersNoScreen(
return Boolean(result);
}
export async function socialLinksByUserId(userId: number) {
const user = await db
.selectFrom("User")
.select([
"User.twitch",
"User.youtubeId",
"User.bsky",
"User.discordUniqueName",
])
.where("User.id", "=", userId)
.executeTakeFirst();
if (!user) return [];
const links: Array<
| { type: "url"; value: string }
| { type: "popover"; platform: "discord"; value: string }
> = [];
if (user.twitch) {
links.push({ type: "url", value: twitchUrl(user.twitch) });
}
if (user.youtubeId) {
links.push({ type: "url", value: youtubeUrl(user.youtubeId) });
}
if (user.bsky) {
links.push({ type: "url", value: bskyUrl(user.bsky) });
}
if (user.discordUniqueName) {
links.push({
type: "popover",
platform: "discord",
value: user.discordUniqueName,
});
}
return links;
}
export function findIdsByTwitchUsernames(twitchUsernames: string[]) {
if (twitchUsernames.length === 0) return [];

View File

@ -0,0 +1,27 @@
import { redirect } from "react-router";
import { requireUser } from "~/features/auth/core/user.server";
import type { StoredWidget } from "~/features/user-page/core/widgets/types";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { widgetsEditSchema } from "~/features/user-page/user-page-schemas";
import { parseFormData } from "~/form/parse.server";
import { userPage } from "~/utils/urls";
export const action = async ({ request }: { request: Request }) => {
const user = requireUser();
const result = await parseFormData({
request,
schema: widgetsEditSchema,
});
if (!result.success) {
return { fieldErrors: result.fieldErrors };
}
await UserRepository.upsertWidgets(
user.id,
result.data.widgets as StoredWidget[],
);
return redirect(userPage(user));
};

View File

@ -20,7 +20,12 @@ export const action: ActionFunction = async ({ request }) => {
};
}
const { inGameNameText, inGameNameDiscriminator, ...data } = parsedInput.data;
const {
inGameNameText,
inGameNameDiscriminator,
newProfileEnabled,
...data
} = parsedInput.data;
const user = requireUser();
const inGameName =
@ -28,15 +33,15 @@ export const action: ActionFunction = async ({ request }) => {
? `${inGameNameText}#${inGameNameDiscriminator}`
: null;
const pronouns =
data.subjectPronoun && data.objectPronoun
? JSON.stringify({
subject: data.subjectPronoun,
object: data.objectPronoun,
})
: null;
try {
const pronouns =
data.subjectPronoun && data.objectPronoun
? JSON.stringify({
subject: data.subjectPronoun,
object: data.objectPronoun,
})
: null;
const editedUser = await UserRepository.updateProfile({
...data,
pronouns,
@ -44,6 +49,10 @@ export const action: ActionFunction = async ({ request }) => {
userId: user.id,
});
await UserRepository.updatePreferences(user.id, {
newProfileEnabled: Boolean(newProfileEnabled),
});
// TODO: to transaction
if (inGameName) {
const tournamentIdsAffected =

View File

@ -0,0 +1,63 @@
.subPageHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--s-4);
padding-block: var(--s-4);
flex-wrap: wrap;
}
.leftSection {
display: flex;
align-items: center;
gap: var(--s-2);
}
.actions {
display: flex;
align-items: center;
gap: var(--s-2);
flex-wrap: wrap;
}
.backButton {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 100%;
background-color: var(--bg);
border: 2px solid var(--bg-lightest);
color: var(--text);
transition: all 0.2s;
}
.backButton svg {
min-width: 30px;
}
.backButton:hover {
background-color: var(--bg-lightest);
}
.backIcon {
width: 24px;
height: 24px;
}
.avatar {
border-radius: 50%;
}
.userInfo {
display: flex;
align-items: center;
gap: var(--s-2);
color: inherit;
}
.username {
font-weight: var(--semi-bold);
font-size: var(--fonts-sm);
}

View File

@ -0,0 +1,34 @@
import { Link } from "react-router";
import { Avatar } from "~/components/Avatar";
import { ArrowLeftIcon } from "~/components/icons/ArrowLeft";
import type { Tables } from "~/db/tables";
import styles from "./SubPageHeader.module.css";
export function SubPageHeader({
user,
backTo,
children,
}: {
user: Pick<Tables["User"], "username" | "discordId" | "discordAvatar">;
backTo: string;
children?: React.ReactNode;
}) {
return (
<div className={styles.subPageHeader}>
<div className={styles.leftSection}>
<Link
to={backTo}
className={styles.backButton}
aria-label="Back to profile"
>
<ArrowLeftIcon className={styles.backIcon} />
</Link>
<Link to={backTo} className={styles.userInfo}>
<Avatar user={user} size="xs" className={styles.avatar} />
<span className={styles.username}>{user.username}</span>
</Link>
</div>
{children ? <div className={styles.actions}>{children}</div> : null}
</div>
);
}

View File

@ -0,0 +1,51 @@
.iconNav {
display: flex;
justify-content: center;
gap: var(--s-2);
overflow-x: auto;
scroll-snap-type: x mandatory;
padding-block: var(--s-2);
scrollbar-width: thin;
}
.iconNavItem {
flex: 0 0 auto;
scroll-snap-align: start;
display: flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
background-color: var(--bg);
border: 2px solid var(--bg-lightest);
border-radius: var(--rounded);
color: var(--text);
transition: all 0.2s;
cursor: pointer;
}
.iconNavItem:hover {
background-color: var(--bg-lightest);
}
.iconNavItem:focus-visible {
outline: 2px solid var(--theme-secondary);
outline-offset: 2px;
}
.iconNavItem.active {
border-color: var(--theme-secondary);
background-color: var(--bg-lightest);
}
@media screen and (min-width: 768px) {
.iconNav {
display: grid;
grid-template-columns: repeat(4, 42px);
grid-auto-rows: 42px;
gap: var(--s-2);
overflow-x: visible;
scroll-snap-type: none;
padding-block: 0;
}
}

View File

@ -0,0 +1,51 @@
import clsx from "clsx";
import { type LinkProps, NavLink } from "react-router";
import { Image } from "~/components/Image";
import { navIconUrl } from "~/utils/urls";
import styles from "./UserPageIconNav.module.css";
export interface UserPageNavItem {
to: string;
iconName: string;
label: string;
count?: number;
isVisible: boolean;
testId?: string;
end?: boolean;
prefetch?: LinkProps["prefetch"];
}
export function UserPageIconNav({ items }: { items: UserPageNavItem[] }) {
const visibleItems = items.filter((item) => item.isVisible);
return (
<nav className={styles.iconNav}>
{visibleItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end ?? true}
prefetch={item.prefetch}
data-testid={item.testId}
className={(state) =>
clsx(styles.iconNavItem, {
[styles.active]: state.isActive,
})
}
aria-label={
item.count !== undefined
? `${item.label} (${item.count})`
: item.label
}
>
<Image
path={navIconUrl(item.iconName)}
width={24}
height={24}
alt=""
/>
</NavLink>
))}
</nav>
);
}

View File

@ -0,0 +1,312 @@
.widget {
background-color: var(--bg);
border-radius: var(--rounded);
padding: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding-block: var(--s-2);
border-bottom: 2px solid var(--bg-lightest);
}
.headerText {
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
color: var(--text-lighter);
}
.headerLink {
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--theme);
}
.content {
display: flex;
flex-direction: column;
gap: var(--s-2);
padding-block: var(--s-4);
}
.memberships {
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.membership {
display: flex;
align-items: center;
gap: var(--s-2-5);
color: var(--text);
margin-block-end: var(--s-1);
border-radius: var(--rounded-sm);
transition: background-color 0.2s;
}
.membershipInfo {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
}
.membershipName {
font-weight: var(--semi-bold);
}
.membershipRole {
font-size: var(--fonts-xxs);
color: var(--text-lighter);
}
.peakValue {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
}
.widgetValueMain {
font-size: var(--fonts-xl);
font-weight: var(--semi-bold);
color: var(--text);
}
.widgetValueFooter {
font-size: var(--fonts-xxs);
font-weight: var(--semi-bold);
color: var(--text-lighter);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.highlightedResults {
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.result {
display: flex;
align-items: center;
gap: var(--s-2-5);
}
.resultPlacement {
flex-shrink: 0;
}
.resultInfo {
display: flex;
flex-direction: column;
gap: var(--s-0-5);
flex: 1;
line-height: 1.25;
}
.resultName {
font-weight: var(--semi-bold);
}
.tournamentName {
display: flex;
align-items: center;
gap: var(--s-1);
max-width: 100%;
overflow: hidden;
}
.tournamentName a {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
max-width: 175px;
}
.resultDate {
font-size: var(--fonts-xxs);
color: var(--text-lighter);
}
.videos {
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--s-3);
}
.weaponGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(40px, 1fr));
gap: var(--s-2);
justify-items: center;
}
.weaponCount {
margin-top: var(--s-3);
text-align: center;
font-size: var(--fonts-xs);
font-weight: var(--semi-bold);
color: var(--text-lighter);
}
.weaponCountComplete {
color: var(--theme-success);
}
.lfgPosts {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.lfgPost {
color: var(--text);
font-size: var(--fonts-sm);
border-radius: var(--rounded-sm);
padding: var(--s-2);
transition: background-color 0.2s;
background-color: var(--bg-lighter);
}
.lfgPost:hover {
background-color: var(--bg-lightest);
}
.xRankPeaks {
display: flex;
flex-wrap: wrap;
gap: var(--s-3);
justify-content: center;
font-size: var(--fonts-sm);
font-weight: var(--semi-bold);
}
.xRankPeakMode {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s-1);
}
.xRankPeakModeIconWrapper {
position: relative;
width: 24px;
height: 24px;
}
.xRankPeakDivision {
position: absolute;
bottom: -2px;
right: -2px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--bg-lightest);
border-radius: 50%;
padding: 1px;
}
.placementResults {
display: flex;
gap: var(--s-4);
justify-content: center;
align-items: center;
}
.placementResult {
display: flex;
align-items: center;
gap: var(--s-1);
font-size: var(--fonts-sm);
font-weight: var(--semi-bold);
}
.builds {
display: flex;
gap: var(--s-3);
overflow-x: auto;
padding-block: var(--s-2);
}
.artGrid {
display: flex;
gap: var(--s-3);
overflow-x: auto;
padding-block: var(--s-2);
}
.artThumbnail {
height: 300px;
object-fit: contain;
flex-shrink: 0;
}
.socialLinks {
display: flex;
flex-direction: column;
gap: var(--s-2);
}
.socialLink {
font-size: var(--fonts-sm);
display: flex;
gap: var(--s-2);
align-items: center;
word-break: break-all;
overflow-wrap: break-word;
}
.socialLink svg {
width: 18px;
}
.socialLinkIconContainer {
background-color: var(--bg-lightest);
display: grid;
place-items: center;
border-radius: var(--rounded);
padding: var(--s-2);
flex-shrink: 0;
}
.socialLinkIconContainer svg {
width: 18px;
height: 18px;
fill: var(--text);
}
.socialLinkIconContainer.twitch svg {
fill: #9146ff;
}
.socialLinkIconContainer.youtube svg {
fill: #f00;
}
.socialLinkIconContainer.bsky path {
fill: #1285fe;
}
.socialLinkIconContainer.discord svg {
fill: #5865f2;
}
.socialLinksIcons {
display: flex;
gap: var(--s-2);
justify-content: center;
flex-wrap: wrap;
}

Some files were not shown because too many files have changed in this diff Show More