mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-23 07:34:07 -05:00
Merge branch 'main' into css-rework-sidenav
This commit is contained in:
commit
a76e06f0bc
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
|
|
@ -9,6 +9,8 @@ updates:
|
|||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
exclude-patterns:
|
||||
- "@biomejs/*"
|
||||
ignore:
|
||||
- dependency-name: "tldraw"
|
||||
- dependency-name: "@tldraw/*"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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", {
|
||||
|
|
|
|||
33
app/components/icons/MainSlot.tsx
Normal file
33
app/components/icons/MainSlot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
20
app/components/icons/SideSlot.tsx
Normal file
20
app/components/icons/SideSlot.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
76
app/features/badges/BadgeRepository.server.test.ts
Normal file
76
app/features/badges/BadgeRepository.server.test.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"]));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,11 @@ const PERKS = [
|
|||
name: "tournamentsBeta",
|
||||
extraInfo: false,
|
||||
},
|
||||
{
|
||||
tier: 2,
|
||||
name: "earlyAccess",
|
||||
extraInfo: false,
|
||||
},
|
||||
{
|
||||
tier: 2,
|
||||
name: "previewQ",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,7 @@
|
|||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.post {
|
||||
scroll-margin-top: 6rem;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
40
app/features/mmr/SkillRepository.server.ts
Normal file
40
app/features/mmr/SkillRepository.server.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}),
|
||||
|
|
|
|||
58
app/features/sendouq-match/PlayerStatRepository.server.ts
Normal file
58
app/features/sendouq-match/PlayerStatRepository.server.ts
Normal 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();
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
456
app/features/sendouq-match/SQMatchRepository.server.test.ts
Normal file
456
app/features/sendouq-match/SQMatchRepository.server.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
})),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
139
app/features/sendouq-match/SkillRepository.server.ts
Normal file
139
app/features/sendouq-match/SkillRepository.server.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -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 }[];
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ const weapons = z.preprocess(
|
|||
groupMatchMapId: id,
|
||||
}),
|
||||
)
|
||||
.nullish()
|
||||
.optional()
|
||||
.default([]),
|
||||
);
|
||||
export const matchSchema = z.union([
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ export function findAllMemberOfByUserId(userId: number) {
|
|||
"Team.id",
|
||||
"Team.customUrl",
|
||||
"Team.name",
|
||||
"TeamMemberWithSecondary.role",
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"logoUrl",
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
63
app/features/user-page/components/SubPageHeader.module.css
Normal file
63
app/features/user-page/components/SubPageHeader.module.css
Normal 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);
|
||||
}
|
||||
34
app/features/user-page/components/SubPageHeader.tsx
Normal file
34
app/features/user-page/components/SubPageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
app/features/user-page/components/UserPageIconNav.module.css
Normal file
51
app/features/user-page/components/UserPageIconNav.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
51
app/features/user-page/components/UserPageIconNav.tsx
Normal file
51
app/features/user-page/components/UserPageIconNav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
312
app/features/user-page/components/Widget.module.css
Normal file
312
app/features/user-page/components/Widget.module.css
Normal 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
Loading…
Reference in New Issue
Block a user