SendouQ Season 2 changes (#1542)
* Initial * Saves preferences * Include TW * mapModePreferencesToModeList * mapPoolFromPreferences initial * Preference to map pool * Adjust seed * q.looking tests * adds about created map preferences to memento in the correct spot (two preferrers) * Failing test about modes * Mode preferences to memento * Remove old Plus Voting code * Fix seeding * find match by id via kysely * View map memento * Fix up map list generation logic * Mode memento info * Future match modes * Add TODO * Migration number * Migrate test DB * Remove old map pool code * createGroupFromPrevious new * Settings styling * VC to settings * Weapon pool * Add TODOs * Progress * Adjust mode exclusion policy * Progress * Progress * Progress * Notes in progress * Note feedback after submit * Textarea styling * Unskip tests * Note sorting failing test * Private note in Q * Ownerpicksmaps later * New bottom section * Mobile layout initial * Add basic match meta * Tabs initial * Sticky tab * Unseen messages in match page * Front page i18n * Settings i18n * Looking 18n * Chat i18n * Progress * Tranfer weapon pools script * Sticky on match page * Match page translations * i18n - tiers page * Preparing page i18n * Icon * Show add note right after report
@@ -29,7 +29,7 @@ Competitive Splatoon Hub with over 20k registered users.
|
||||
- Sqlite3
|
||||
- CSS (plain)
|
||||
- E2E tests via Playwright
|
||||
- Unit tests via uvu
|
||||
- Unit/integration tests via uvu
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -58,6 +58,12 @@ There is a sequence of commands you need to run:
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md) for more information.
|
||||
|
||||
## Tests
|
||||
|
||||
### `db-test.sqlite3`
|
||||
|
||||
Empty DB with the latest migration run. When creating new migrations they should also be applied+committed to this file (add it in `.env` and then run the migration command as normal).
|
||||
|
||||
### Translations
|
||||
|
||||
[Translation Progress](https://github.com/Sendouc/sendou.ink/issues/1104)
|
||||
|
||||
@@ -96,7 +96,9 @@ export function LinkButton({
|
||||
>
|
||||
{icon &&
|
||||
React.cloneElement(icon, {
|
||||
className: clsx("button-icon", { lonely: !children }),
|
||||
className: clsx("button-icon", {
|
||||
lonely: !children,
|
||||
}),
|
||||
})}
|
||||
{children}
|
||||
</a>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Tab } from "@headlessui/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
|
||||
interface NewTabsProps {
|
||||
tabs: {
|
||||
@@ -17,16 +18,24 @@ interface NewTabsProps {
|
||||
setSelectedIndex?: (index: number) => void;
|
||||
/** Don't take space when no tabs to show? */
|
||||
disappearing?: boolean;
|
||||
type?: "divider";
|
||||
sticky?: boolean;
|
||||
}
|
||||
|
||||
export function NewTabs({
|
||||
tabs,
|
||||
content,
|
||||
scrolling = true,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
disappearing = false,
|
||||
}: NewTabsProps) {
|
||||
export function NewTabs(args: NewTabsProps) {
|
||||
if (args.type === "divider") {
|
||||
return <DividerTabs {...args} />;
|
||||
}
|
||||
|
||||
const {
|
||||
tabs,
|
||||
content,
|
||||
scrolling = true,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
disappearing = false,
|
||||
} = args;
|
||||
|
||||
const cantSwitchTabs = tabs.filter((t) => !t.hidden).length <= 1;
|
||||
|
||||
return (
|
||||
@@ -36,6 +45,7 @@ export function NewTabs({
|
||||
"overflow-x-auto": scrolling,
|
||||
invisible: cantSwitchTabs && !disappearing,
|
||||
hidden: cantSwitchTabs && disappearing,
|
||||
"tab__buttons-container__sticky": args.sticky,
|
||||
})}
|
||||
>
|
||||
{tabs
|
||||
@@ -49,13 +59,68 @@ export function NewTabs({
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.number === "number" && tab.number !== 0 && (
|
||||
<span className={clsx("tab__number")}>{tab.number}</span>
|
||||
<span className="tab__number">{tab.number}</span>
|
||||
)}
|
||||
</Tab>
|
||||
);
|
||||
})}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mt-4">
|
||||
<Tab.Panels
|
||||
className={clsx({ "mt-4": !cantSwitchTabs || !disappearing })}
|
||||
>
|
||||
{content
|
||||
.filter((c) => !c.hidden)
|
||||
.map((c) => {
|
||||
return <Tab.Panel key={c.key}>{c.element}</Tab.Panel>;
|
||||
})}
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function DividerTabs({
|
||||
tabs,
|
||||
content,
|
||||
scrolling = true,
|
||||
selectedIndex,
|
||||
setSelectedIndex,
|
||||
disappearing = false,
|
||||
}: NewTabsProps) {
|
||||
const cantSwitchTabs = tabs.filter((t) => !t.hidden).length <= 1;
|
||||
|
||||
return (
|
||||
<Tab.Group selectedIndex={selectedIndex} onChange={setSelectedIndex}>
|
||||
<Tab.List
|
||||
className={clsx("divider-tab__buttons-container", {
|
||||
"overflow-x-auto": scrolling,
|
||||
invisible: cantSwitchTabs && !disappearing,
|
||||
hidden: cantSwitchTabs && disappearing,
|
||||
})}
|
||||
>
|
||||
{tabs
|
||||
.filter((t) => !t.hidden)
|
||||
.map((tab, i) => {
|
||||
return (
|
||||
<React.Fragment key={tab.label}>
|
||||
<Tab
|
||||
className="divider-tab__button"
|
||||
data-testid={`tab-${tab.label}`}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.number === "number" && tab.number !== 0 && (
|
||||
<span className="ml-1">({tab.number})</span>
|
||||
)}
|
||||
</Tab>
|
||||
{i !== tabs.length - 1 && (
|
||||
<div className="divider-tab__line-guy" />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Tab.List>
|
||||
<Tab.Panels
|
||||
className={clsx({ "mt-4": !cantSwitchTabs || !disappearing })}
|
||||
>
|
||||
{content
|
||||
.filter((c) => !c.hidden)
|
||||
.map((c) => {
|
||||
|
||||
16
app/components/icons/Map.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export function MapIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M8.161 2.58a1.875 1.875 0 011.678 0l4.993 2.498c.106.052.23.052.336 0l3.869-1.935A1.875 1.875 0 0121.75 4.82v12.485c0 .71-.401 1.36-1.037 1.677l-4.875 2.437a1.875 1.875 0 01-1.676 0l-4.994-2.497a.375.375 0 00-.336 0l-3.868 1.935A1.875 1.875 0 012.25 19.18V6.695c0-.71.401-1.36 1.036-1.677l4.875-2.437zM9 6a.75.75 0 01.75.75V15a.75.75 0 01-1.5 0V6.75A.75.75 0 019 6zm6.75 3a.75.75 0 00-1.5 0v8.25a.75.75 0 001.5 0V9z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
13
app/components/icons/MicrophoneFilled.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export function MicrophoneFilledIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M8.25 4.5a3.75 3.75 0 117.5 0v8.25a3.75 3.75 0 11-7.5 0V4.5z" />
|
||||
<path d="M6 10.5a.75.75 0 01.75.75v1.5a5.25 5.25 0 1010.5 0v-1.5a.75.75 0 011.5 0v1.5a6.751 6.751 0 01-6 6.709v2.291h3a.75.75 0 010 1.5h-7.5a.75.75 0 010-1.5h3v-2.291a6.751 6.751 0 01-6-6.709v-1.5A.75.75 0 016 10.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
12
app/components/icons/Puzzle.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
export function PuzzleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M11.25 5.337c0-.355-.186-.676-.401-.959a1.647 1.647 0 01-.349-1.003c0-1.036 1.007-1.875 2.25-1.875S15 2.34 15 3.375c0 .369-.128.713-.349 1.003-.215.283-.401.604-.401.959 0 .332.278.598.61.578 1.91-.114 3.79-.342 5.632-.676a.75.75 0 01.878.645 49.17 49.17 0 01.376 5.452.657.657 0 01-.66.664c-.354 0-.675-.186-.958-.401a1.647 1.647 0 00-1.003-.349c-1.035 0-1.875 1.007-1.875 2.25s.84 2.25 1.875 2.25c.369 0 .713-.128 1.003-.349.283-.215.604-.401.959-.401.31 0 .557.262.534.571a48.774 48.774 0 01-.595 4.845.75.75 0 01-.61.61c-1.82.317-3.673.533-5.555.642a.58.58 0 01-.611-.581c0-.355.186-.676.401-.959.221-.29.349-.634.349-1.003 0-1.035-1.007-1.875-2.25-1.875s-2.25.84-2.25 1.875c0 .369.128.713.349 1.003.215.283.401.604.401.959a.641.641 0 01-.658.643 49.118 49.118 0 01-4.708-.36.75.75 0 01-.645-.878c.293-1.614.504-3.257.629-4.924A.53.53 0 005.337 15c-.355 0-.676.186-.959.401-.29.221-.634.349-1.003.349-1.036 0-1.875-1.007-1.875-2.25s.84-2.25 1.875-2.25c.369 0 .713.128 1.003.349.283.215.604.401.959.401a.656.656 0 00.659-.663 47.703 47.703 0 00-.31-4.82.75.75 0 01.83-.832c1.343.155 2.703.254 4.077.294a.64.64 0 00.657-.642z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
16
app/components/icons/Scale.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
export function ScaleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 2.25a.75.75 0 01.75.75v.756a49.106 49.106 0 019.152 1 .75.75 0 01-.152 1.485h-1.918l2.474 10.124a.75.75 0 01-.375.84A6.723 6.723 0 0118.75 18a6.723 6.723 0 01-3.181-.795.75.75 0 01-.375-.84l2.474-10.124H12.75v13.28c1.293.076 2.534.343 3.697.776a.75.75 0 01-.262 1.453h-8.37a.75.75 0 01-.262-1.453c1.162-.433 2.404-.7 3.697-.775V6.24H6.332l2.474 10.124a.75.75 0 01-.375.84A6.723 6.723 0 015.25 18a6.723 6.723 0 01-3.181-.795.75.75 0 01-.375-.84L4.168 6.241H2.25a.75.75 0 01-.152-1.485 49.105 49.105 0 019.152-1V3a.75.75 0 01.75-.75zm4.878 13.543l1.872-7.662 1.872 7.662h-3.744zm-9.756 0L5.25 8.131l-1.872 7.662h3.744z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
13
app/components/icons/SpeakerFilled.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
export function SpeakerFilledIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path d="M13.5 4.06c0-1.336-1.616-2.005-2.56-1.06l-4.5 4.5H4.508c-1.141 0-2.318.664-2.66 1.905A9.76 9.76 0 001.5 12c0 .898.121 1.768.35 2.595.341 1.24 1.518 1.905 2.659 1.905h1.93l4.5 4.5c.945.945 2.561.276 2.561-1.06V4.06zM18.584 5.106a.75.75 0 011.06 0c3.808 3.807 3.808 9.98 0 13.788a.75.75 0 11-1.06-1.06 8.25 8.25 0 000-11.668.75.75 0 010-1.06z" />
|
||||
<path d="M15.932 7.757a.75.75 0 011.061 0 6 6 0 010 8.486.75.75 0 01-1.06-1.061 4.5 4.5 0 000-6.364.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,14 @@ import shuffle from "just-shuffle";
|
||||
import { nanoid } from "nanoid";
|
||||
import invariant from "tiny-invariant";
|
||||
import { ADMIN_DISCORD_ID, ADMIN_ID, INVITE_CODE_LENGTH } from "~/constants";
|
||||
import { sql } from "~/db/sql";
|
||||
import { db, sql } from "~/db/sql";
|
||||
import allTags from "~/features/calendar/tags.json";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import {
|
||||
lastCompletedVoting,
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import { createVod } from "~/features/vods/queries/createVod.server";
|
||||
import type {
|
||||
AbilityType,
|
||||
@@ -22,43 +28,37 @@ import {
|
||||
stageIds,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import {
|
||||
lastCompletedVoting,
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { mySlugify } from "~/utils/urls";
|
||||
|
||||
import type { SeedVariation } from "~/features/api/routes/seed";
|
||||
import * as BuildRepository from "~/features/builds/BuildRepository.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import * as PlusVotingRepository from "~/features/plus-voting/PlusVotingRepository.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
|
||||
import { calculateMatchSkills } from "~/features/sendouq/core/skills.server";
|
||||
import {
|
||||
summarizeMaps,
|
||||
summarizePlayerResults,
|
||||
} from "~/features/sendouq/core/summarizer.server";
|
||||
import { MAP_LIST_PREFERENCE_OPTIONS } from "~/features/sendouq/q-constants";
|
||||
import { winnersArrayToWinner } from "~/features/sendouq/q-utils";
|
||||
import { addMapResults } from "~/features/sendouq/queries/addMapResults.server";
|
||||
import { addMember } from "~/features/sendouq/queries/addMember.server";
|
||||
import { addPlayerResults } from "~/features/sendouq/queries/addPlayerResults.server";
|
||||
import { addReportedWeapons } from "~/features/sendouq/queries/addReportedWeapons.server";
|
||||
import { addSkills } from "~/features/sendouq/queries/addSkills.server";
|
||||
import { createGroup } from "~/features/sendouq/queries/createGroup.server";
|
||||
import { createMatch } from "~/features/sendouq/queries/createMatch.server";
|
||||
import { findMatchById } from "~/features/sendouq/queries/findMatchById.server";
|
||||
import { groupForMatch } from "~/features/sendouq/queries/groupForMatch.server";
|
||||
import { reportScore } from "~/features/sendouq/queries/reportScore.server";
|
||||
import { setGroupAsInactive } from "~/features/sendouq/queries/setGroupAsInactive.server";
|
||||
import { updateVCStatus } from "~/features/sendouq/queries/updateVCStatus.server";
|
||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
|
||||
import type { SeedVariation } from "~/features/api/routes/seed";
|
||||
import { nullFilledArray, pickRandomItem } from "~/utils/arrays";
|
||||
import type { UserMapModePreferences } from "../tables";
|
||||
import type { Art, UserSubmittedImage } from "../types";
|
||||
import {
|
||||
ADMIN_TEST_AVATAR,
|
||||
@@ -81,6 +81,8 @@ const basicSeeds = (variation?: SeedVariation | null) => [
|
||||
nzapUser,
|
||||
users,
|
||||
userProfiles,
|
||||
userMapModePreferences,
|
||||
userQWeaponPool,
|
||||
lastMonthsVoting,
|
||||
syncPlusTiers,
|
||||
lastMonthSuggestions,
|
||||
@@ -229,7 +231,7 @@ async function users() {
|
||||
}
|
||||
}
|
||||
|
||||
function userProfiles() {
|
||||
async function userProfiles() {
|
||||
for (const args of [
|
||||
{
|
||||
userId: 1,
|
||||
@@ -316,7 +318,7 @@ function userProfiles() {
|
||||
if (Math.random() > 0.9) defaultLanguages.push("it");
|
||||
if (Math.random() > 0.9) defaultLanguages.push("ja");
|
||||
|
||||
updateVCStatus({
|
||||
await QSettingsRepository.updateVoiceChat({
|
||||
languages: defaultLanguages,
|
||||
userId: id,
|
||||
vc:
|
||||
@@ -327,6 +329,64 @@ function userProfiles() {
|
||||
}
|
||||
}
|
||||
|
||||
const randomPreferences = (): UserMapModePreferences => {
|
||||
return {
|
||||
modes: modesShort.flatMap((mode) => {
|
||||
if (Math.random() > 0.5 && mode !== "SZ") return [];
|
||||
|
||||
const criteria = mode === "SZ" ? 0.2 : 0.5;
|
||||
|
||||
return {
|
||||
mode,
|
||||
preference: Math.random() > criteria ? "PREFER" : "AVOID",
|
||||
};
|
||||
}),
|
||||
maps: stageIds.slice(0, 10).flatMap((stageId) => {
|
||||
return modesShort.flatMap((mode) => {
|
||||
if (Math.random() > 0.7) return { stageId, mode };
|
||||
|
||||
return {
|
||||
stageId,
|
||||
mode,
|
||||
preference: Math.random() > 0.3 ? "PREFER" : "AVOID",
|
||||
};
|
||||
});
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
async function userMapModePreferences() {
|
||||
for (let id = 1; id < 500; id++) {
|
||||
if (id !== ADMIN_ID && Math.random() < 0.2) continue; // 80% have maps && admin always
|
||||
|
||||
await db
|
||||
.updateTable("User")
|
||||
.where("User.id", "=", id)
|
||||
.set({
|
||||
mapModePreferences: JSON.stringify(randomPreferences()),
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
async function userQWeaponPool() {
|
||||
for (let id = 1; id < 500; id++) {
|
||||
if (id === 2) continue; // no weapons for N-ZAP
|
||||
if (Math.random() < 0.2) continue; // 80% have weapons
|
||||
|
||||
const weapons = shuffle([...mainWeaponIds]).slice(
|
||||
0,
|
||||
faker.helpers.arrayElement([1, 2, 3, 4]),
|
||||
);
|
||||
|
||||
await db
|
||||
.updateTable("User")
|
||||
.set({ qWeaponPool: JSON.stringify(weapons) })
|
||||
.where("User.id", "=", id)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
function fakeUser(usedNames: Set<string>) {
|
||||
return () => ({
|
||||
discordAvatar: null,
|
||||
@@ -1589,36 +1649,16 @@ function commissionsOpen() {
|
||||
}
|
||||
|
||||
const SENDOU_IN_FULL_GROUP = true;
|
||||
function groups() {
|
||||
async function groups() {
|
||||
const users = userIdsInAscendingOrderById()
|
||||
.slice(0, 100)
|
||||
.filter((id) => id !== ADMIN_ID && id !== NZAP_TEST_ID);
|
||||
users.push(NZAP_TEST_ID);
|
||||
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const group = createGroup({
|
||||
mapListPreference: faker.helpers.arrayElement(
|
||||
MAP_LIST_PREFERENCE_OPTIONS,
|
||||
),
|
||||
const group = await QRepository.createGroup({
|
||||
status: "ACTIVE",
|
||||
userId: users.pop()!,
|
||||
mapPool: new MapPool([
|
||||
{ mode: "SZ", stageId: 1 },
|
||||
{ mode: "SZ", stageId: 2 },
|
||||
{ mode: "SZ", stageId: 3 },
|
||||
{ mode: "SZ", stageId: 4 },
|
||||
{ mode: "SZ", stageId: 5 },
|
||||
{ mode: "SZ", stageId: 6 },
|
||||
{ mode: "TC", stageId: 7 },
|
||||
{ mode: "TC", stageId: 8 },
|
||||
{ mode: "TC", stageId: 15 },
|
||||
{ mode: "RM", stageId: 10 },
|
||||
{ mode: "RM", stageId: 11 },
|
||||
{ mode: "RM", stageId: 16 },
|
||||
{ mode: "CB", stageId: 13 },
|
||||
{ mode: "CB", stageId: 14 },
|
||||
{ mode: "CB", stageId: 17 },
|
||||
]),
|
||||
});
|
||||
|
||||
const amountOfAdditionalMembers = () => {
|
||||
@@ -1656,20 +1696,24 @@ const randomMapList = (
|
||||
groupBravo: number,
|
||||
): TournamentMapListMap[] => {
|
||||
const szOnly = faker.helpers.arrayElement([true, false]);
|
||||
const modePattern = shuffle([...rankedModesShort]);
|
||||
|
||||
let modePattern = shuffle([...modesShort]).filter(() => Math.random() > 0.15);
|
||||
if (modePattern.length === 0) {
|
||||
modePattern = shuffle([...rankedModesShort]);
|
||||
}
|
||||
|
||||
const mapList: TournamentMapListMap[] = [];
|
||||
const stageIdsShuffled = shuffle([...stageIds]);
|
||||
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const rankedMode = modePattern.pop()!;
|
||||
const mode = modePattern.pop()!;
|
||||
mapList.push({
|
||||
mode: szOnly ? "SZ" : rankedMode,
|
||||
mode: szOnly ? "SZ" : mode,
|
||||
stageId: stageIdsShuffled.pop()!,
|
||||
source: i === 6 ? "BOTH" : i % 2 === 0 ? groupAlpha : groupBravo,
|
||||
});
|
||||
|
||||
modePattern.unshift(rankedMode);
|
||||
modePattern.unshift(mode);
|
||||
}
|
||||
|
||||
return mapList;
|
||||
@@ -1677,7 +1721,7 @@ const randomMapList = (
|
||||
|
||||
const MATCHES_COUNT = 500;
|
||||
|
||||
function playedMatches() {
|
||||
async function playedMatches() {
|
||||
const _groupMembers = (() => {
|
||||
return new Array(50).fill(null).map(() => {
|
||||
const users = shuffle(userIdsInAscendingOrderById().slice(0, 50));
|
||||
@@ -1694,8 +1738,7 @@ function playedMatches() {
|
||||
}),
|
||||
);
|
||||
|
||||
// mid august 2021
|
||||
let matchDate = new Date(Date.UTC(2021, 7, 15, 0, 0, 0, 0));
|
||||
let matchDate = new Date(Date.UTC(2023, 9, 15, 0, 0, 0, 0));
|
||||
for (let i = 0; i < MATCHES_COUNT; i++) {
|
||||
const groupMembers = shuffle([..._groupMembers]);
|
||||
const groupAlphaMembers = groupMembers.pop()!;
|
||||
@@ -1717,10 +1760,7 @@ function playedMatches() {
|
||||
// -> create groups
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const users = i === 0 ? [...groupAlphaMembers] : [...groupBravoMembers];
|
||||
const group = createGroup({
|
||||
// these should not matter here
|
||||
mapListPreference: "NO_PREFERENCE",
|
||||
mapPool: new MapPool([]),
|
||||
const group = await QRepository.createGroup({
|
||||
status: "ACTIVE",
|
||||
userId: users.pop()!,
|
||||
});
|
||||
@@ -1791,11 +1831,15 @@ function playedMatches() {
|
||||
winnerGroupId: winner === "ALPHA" ? groupAlpha : groupBravo,
|
||||
});
|
||||
const members = [
|
||||
...groupForMatch(match.alphaGroupId)!.members.map((m) => ({
|
||||
...(await QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
}))!.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.alphaGroupId,
|
||||
})),
|
||||
...groupForMatch(match.bravoGroupId)!.members.map((m) => ({
|
||||
...(await QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
}))!.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.bravoGroupId,
|
||||
})),
|
||||
|
||||
@@ -5,12 +5,14 @@ import type {
|
||||
Selectable,
|
||||
SqlBool,
|
||||
} from "kysely";
|
||||
import type { TieredSkill } from "~/features/mmr/tiered.server";
|
||||
import type {
|
||||
Ability,
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists";
|
||||
import type { GroupSkillDifference, UserSkillDifference } from "./types";
|
||||
|
||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
||||
? ColumnType<S, I | undefined, U>
|
||||
@@ -174,8 +176,7 @@ export interface Group {
|
||||
id: GeneratedAlways<number>;
|
||||
inviteCode: string;
|
||||
latestActionAt: Generated<number>;
|
||||
mapListPreference: string;
|
||||
status: string;
|
||||
status: "PREPARING" | "ACTIVE" | "INACTIVE";
|
||||
teamId: number | null;
|
||||
}
|
||||
|
||||
@@ -185,13 +186,35 @@ export interface GroupLike {
|
||||
targetGroupId: number;
|
||||
}
|
||||
|
||||
export type ParsedMemento = {
|
||||
users: Record<
|
||||
number,
|
||||
{
|
||||
plusTier?: PlusTier["tier"];
|
||||
skill?: TieredSkill | "CALCULATING";
|
||||
skillDifference?: UserSkillDifference;
|
||||
}
|
||||
>;
|
||||
groups: Record<
|
||||
number,
|
||||
{
|
||||
tier?: TieredSkill["tier"];
|
||||
skillDifference?: GroupSkillDifference;
|
||||
}
|
||||
>;
|
||||
modePreferences?: Partial<
|
||||
Record<ModeShort, Array<{ userId: number; preference?: Preference }>>
|
||||
>;
|
||||
mapPreferences?: Array<{ userId: number; preference?: Preference }[]>;
|
||||
};
|
||||
|
||||
export interface GroupMatch {
|
||||
alphaGroupId: number;
|
||||
bravoGroupId: number;
|
||||
chatCode: string | null;
|
||||
createdAt: Generated<number>;
|
||||
id: GeneratedAlways<number>;
|
||||
memento: string | null;
|
||||
memento: ColumnType<ParsedMemento | null, string | null, string | null>;
|
||||
reportedAt: number | null;
|
||||
reportedByUserId: number | null;
|
||||
}
|
||||
@@ -210,10 +233,18 @@ export interface GroupMember {
|
||||
createdAt: Generated<number>;
|
||||
groupId: number;
|
||||
note: string | null;
|
||||
role: string;
|
||||
role: "OWNER" | "MANAGER" | "REGULAR";
|
||||
userId: number;
|
||||
}
|
||||
|
||||
export interface PrivateUserNote {
|
||||
authorId: number;
|
||||
targetId: number;
|
||||
text: string | null;
|
||||
sentiment: "POSITIVE" | "NEUTRAL" | "NEGATIVE";
|
||||
updatedAt: Generated<number>;
|
||||
}
|
||||
|
||||
export interface LogInLink {
|
||||
code: string;
|
||||
expiresAt: number;
|
||||
@@ -222,7 +253,6 @@ export interface LogInLink {
|
||||
|
||||
export interface MapPoolMap {
|
||||
calendarEventId: number | null;
|
||||
groupId: number | null;
|
||||
mode: ModeShort;
|
||||
stageId: StageId;
|
||||
tieBreakerCalendarEventId: number | null;
|
||||
@@ -467,6 +497,20 @@ export interface UnvalidatedVideo {
|
||||
youtubeId: string;
|
||||
}
|
||||
|
||||
// missing means "neutral"
|
||||
export type Preference = "AVOID" | "PREFER";
|
||||
export interface UserMapModePreferences {
|
||||
modes: Array<{
|
||||
mode: ModeShort;
|
||||
preference: Preference;
|
||||
}>;
|
||||
maps: Array<{
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
preference?: Preference;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface User {
|
||||
banned: Generated<number | null>;
|
||||
bio: string | null;
|
||||
@@ -494,8 +538,14 @@ export interface User {
|
||||
stickSens: number | null;
|
||||
twitch: string | null;
|
||||
twitter: string | null;
|
||||
vc: Generated<string | null>;
|
||||
vc: Generated<"YES" | "NO" | "LISTEN_ONLY">;
|
||||
youtubeId: string | null;
|
||||
mapModePreferences: ColumnType<
|
||||
UserMapModePreferences | null,
|
||||
string | null,
|
||||
string | null
|
||||
>;
|
||||
qWeaponPool: ColumnType<MainWeaponId[] | null, string | null, string | null>;
|
||||
plusSkippedForSeasonNth: number | null;
|
||||
}
|
||||
|
||||
@@ -589,6 +639,7 @@ export interface DB {
|
||||
GroupMatch: GroupMatch;
|
||||
GroupMatchMap: GroupMatchMap;
|
||||
GroupMember: GroupMember;
|
||||
PrivateUserNote: PrivateUserNote;
|
||||
LogInLink: LogInLink;
|
||||
MapPoolMap: MapPoolMap;
|
||||
MapResult: MapResult;
|
||||
|
||||
@@ -199,7 +199,6 @@ export interface MapPoolMap {
|
||||
calendarEventId: number | null; // Part of tournament's map pool
|
||||
tournamentTeamId: number | null; // Part of team's map pool
|
||||
tieBreakerCalendarEventId: number | null; // Part of the tournament's tiebreaker pool
|
||||
groupId: number | null; // Part of SendouQ group's map pool
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
}
|
||||
@@ -521,12 +520,6 @@ export interface Group {
|
||||
teamId: number | null;
|
||||
createdAt: number;
|
||||
latestActionAt: number;
|
||||
mapListPreference:
|
||||
| "SZ_ONLY"
|
||||
| "ALL_MODES_ONLY"
|
||||
| "PREFER_SZ"
|
||||
| "PREFER_ALL_MODES"
|
||||
| "NO_PREFERENCE";
|
||||
inviteCode: string;
|
||||
chatCode: string | null;
|
||||
status: "PREPARING" | "ACTIVE" | "INACTIVE";
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { ChatMessage } from "../chat-types";
|
||||
import { MESSAGE_MAX_LENGTH } from "../chat-constants";
|
||||
import { messageTypeToSound, soundEnabled } from "../chat-utils";
|
||||
import { soundPath } from "~/utils/urls";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
|
||||
type ChatUser = Pick<User, "discordName" | "discordId" | "discordAvatar"> & {
|
||||
chatNameColor: string | null;
|
||||
@@ -33,34 +34,6 @@ export interface ChatProps {
|
||||
revalidates?: boolean;
|
||||
}
|
||||
|
||||
const systemMessageText = (msg: ChatMessage) => {
|
||||
const name = () => {
|
||||
if (!msg.context) return "";
|
||||
return msg.context.name;
|
||||
};
|
||||
|
||||
switch (msg.type) {
|
||||
case "SCORE_REPORTED": {
|
||||
return `${name()} reported score`;
|
||||
}
|
||||
case "SCORE_CONFIRMED": {
|
||||
return `${name()} confirmed score. Match is now locked`;
|
||||
}
|
||||
case "CANCEL_REPORTED": {
|
||||
return `${name()} requested canceling the match`;
|
||||
}
|
||||
case "CANCEL_CONFIRMED": {
|
||||
return `${name()} confirmed canceling the match. Match is now locked`;
|
||||
}
|
||||
case "USER_LEFT": {
|
||||
return `${name()} left the group`;
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export function ConnectedChat(props: ChatProps) {
|
||||
const chat = useChat(props);
|
||||
|
||||
@@ -79,6 +52,7 @@ export function Chat({
|
||||
disabled,
|
||||
missingUserName,
|
||||
}: ChatProps & { chat: ReturnType<typeof useChat> }) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const messagesContainerRef = React.useRef<HTMLOListElement>(null);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const {
|
||||
@@ -120,6 +94,34 @@ export function Chat({
|
||||
|
||||
const sendingMessagesDisabled = disabled || !connected;
|
||||
|
||||
const systemMessageText = (msg: ChatMessage) => {
|
||||
const name = () => {
|
||||
if (!msg.context) return "";
|
||||
return msg.context.name;
|
||||
};
|
||||
|
||||
switch (msg.type) {
|
||||
case "SCORE_REPORTED": {
|
||||
return t("common:chat.systemMsg.scoreReported", { name: name() });
|
||||
}
|
||||
case "SCORE_CONFIRMED": {
|
||||
return t("common:chat.systemMsg.scoreConfirmed", { name: name() });
|
||||
}
|
||||
case "CANCEL_REPORTED": {
|
||||
return t("common:chat.systemMsg.cancelReported", { name: name() });
|
||||
}
|
||||
case "CANCEL_CONFIRMED": {
|
||||
return t("common:chat.systemMsg.cancelConfirmed", { name: name() });
|
||||
}
|
||||
case "USER_LEFT": {
|
||||
return t("common:chat.systemMsg.userLeft", { name: name() });
|
||||
}
|
||||
default: {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className={clsx("chat__container", className, { hidden })}>
|
||||
{rooms.length > 1 ? (
|
||||
@@ -181,7 +183,7 @@ export function Chat({
|
||||
<input
|
||||
className="w-full"
|
||||
ref={inputRef}
|
||||
placeholder="Press enter to send"
|
||||
placeholder={t("common:chat.input.placeholder")}
|
||||
disabled={sendingMessagesDisabled}
|
||||
maxLength={MESSAGE_MAX_LENGTH}
|
||||
/>{" "}
|
||||
@@ -190,11 +192,11 @@ export function Chat({
|
||||
<div />
|
||||
) : connected ? (
|
||||
<div className="text-xxs font-semi-bold text-lighter">
|
||||
Connected
|
||||
{t("common:chat.connected")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xxs font-semi-bold text-warning">
|
||||
Disconnected
|
||||
{t("common:chat.disconnected")}
|
||||
</div>
|
||||
)}
|
||||
<SubmitButton
|
||||
@@ -202,7 +204,7 @@ export function Chat({
|
||||
variant="minimal"
|
||||
disabled={sendingMessagesDisabled}
|
||||
>
|
||||
Send
|
||||
{t("common:chat.send")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</form>
|
||||
@@ -276,6 +278,7 @@ function SystemMessage({
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: should contain unseen messages logic, now it's duplicated
|
||||
export function useChat({
|
||||
rooms,
|
||||
onNewMessage,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { jsonArrayFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
|
||||
import {
|
||||
LEADERBOARD_MAX_SIZE,
|
||||
DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
|
||||
} from "./leaderboards-constants";
|
||||
import { ordinalToSp } from "../mmr";
|
||||
@@ -74,7 +74,7 @@ const teamLeaderboardBySeasonQuery = (season: number) =>
|
||||
.where("Skill.matchesCount", ">=", MATCHES_COUNT_NEEDED_FOR_LEADERBOARD)
|
||||
.where("Skill.season", "=", season)
|
||||
.orderBy("Skill.ordinal", "desc")
|
||||
.limit(LEADERBOARD_MAX_SIZE);
|
||||
.limit(DEFAULT_LEADERBOARD_MAX_SIZE);
|
||||
type TeamLeaderboardBySeasonQueryReturnType = InferResult<
|
||||
ReturnType<typeof teamLeaderboardBySeasonQuery>
|
||||
>;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { freshUserSkills } from "~/features/mmr/tiered.server";
|
||||
import { freshUserSkills, userSkills } from "~/features/mmr/tiered.server";
|
||||
import type { UserSPLeaderboardItem } from "../queries/userSPLeaderboard.server";
|
||||
import type { SeasonPopularUsersWeapon } from "../queries/seasonPopularUsersWeapon.server";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
import { weaponCategories } from "~/modules/in-game-lists";
|
||||
import { seasonHasTopTen } from "../leaderboards-utils";
|
||||
import { currentOrPreviousSeason } from "~/features/mmr/season";
|
||||
import { DEFAULT_LEADERBOARD_MAX_SIZE } from "../leaderboards-constants";
|
||||
import { spToOrdinal } from "~/features/mmr/mmr-utils";
|
||||
|
||||
export function addTiers(entries: UserSPLeaderboardItem[], season: number) {
|
||||
const tiers = freshUserSkills(season);
|
||||
@@ -100,3 +102,36 @@ export function addPlacementRank<T>(entries: T[]) {
|
||||
placementRank: index + 1,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function ownEntryPeek({
|
||||
leaderboard,
|
||||
userId,
|
||||
season,
|
||||
}: {
|
||||
leaderboard: UserSPLeaderboardItem[];
|
||||
userId: number;
|
||||
season: number;
|
||||
}) {
|
||||
const found = leaderboard.find(
|
||||
(entry) =>
|
||||
entry.id === userId && entry.placementRank > DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
);
|
||||
|
||||
if (!found) return null;
|
||||
|
||||
const withTier = addTiers([found], season)[0];
|
||||
|
||||
const { intervals } = await userSkills(season);
|
||||
|
||||
return {
|
||||
entry: withTier,
|
||||
nextTier: intervals
|
||||
.slice()
|
||||
.reverse()
|
||||
.find(
|
||||
(tier) =>
|
||||
tier.neededOrdinal &&
|
||||
tier.neededOrdinal > spToOrdinal(withTier.power),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import { mainWeaponIds, weaponCategories } from "~/modules/in-game-lists";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
|
||||
export const MATCHES_COUNT_NEEDED_FOR_LEADERBOARD = 7;
|
||||
export const LEADERBOARD_MAX_SIZE = 500;
|
||||
export const DEFAULT_LEADERBOARD_MAX_SIZE = 500;
|
||||
export const WEAPON_LEADERBOARD_MAX_SIZE = 100;
|
||||
|
||||
export const LEADERBOARD_TYPES = [
|
||||
"USER",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sql } from "~/db/sql";
|
||||
import { LEADERBOARD_MAX_SIZE } from "../leaderboards-constants";
|
||||
import { DEFAULT_LEADERBOARD_MAX_SIZE } from "../leaderboards-constants";
|
||||
import type { User, XRankPlacement } from "~/db/types";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
|
||||
@@ -26,7 +26,7 @@ const getStm = (where = "") =>
|
||||
${where}
|
||||
group by "XRankPlacement"."playerId"
|
||||
order by "power" desc
|
||||
limit ${LEADERBOARD_MAX_SIZE}
|
||||
limit ${DEFAULT_LEADERBOARD_MAX_SIZE}
|
||||
`);
|
||||
|
||||
const allStm = getStm();
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { sql } from "~/db/sql";
|
||||
import {
|
||||
LEADERBOARD_MAX_SIZE,
|
||||
MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
|
||||
} from "../leaderboards-constants";
|
||||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "../leaderboards-constants";
|
||||
import type { PlusTier, User } from "~/db/types";
|
||||
import { ordinalToSp } from "~/features/mmr";
|
||||
|
||||
@@ -36,8 +33,6 @@ const stm = sql.prepare(/* sql */ `
|
||||
and "Skill"."season" = @season
|
||||
order by
|
||||
"Skill"."ordinal" desc
|
||||
limit
|
||||
${LEADERBOARD_MAX_SIZE}
|
||||
`);
|
||||
|
||||
export interface UserSPLeaderboardItem {
|
||||
|
||||
@@ -21,7 +21,11 @@ import styles from "../../top-search/top-search.css";
|
||||
import { userSPLeaderboard } from "../queries/userSPLeaderboard.server";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import React from "react";
|
||||
import { LEADERBOARD_TYPES } from "../leaderboards-constants";
|
||||
import {
|
||||
DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
LEADERBOARD_TYPES,
|
||||
WEAPON_LEADERBOARD_MAX_SIZE,
|
||||
} from "../leaderboards-constants";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { i18next } from "~/modules/i18n";
|
||||
import {
|
||||
@@ -47,6 +51,7 @@ import {
|
||||
addTiers,
|
||||
addWeapons,
|
||||
filterByWeaponCategory,
|
||||
ownEntryPeek,
|
||||
} from "../core/leaderboards.server";
|
||||
import { seasonPopularUsersWeapon } from "../queries/seasonPopularUsersWeapon.server";
|
||||
import { cachified } from "cachified";
|
||||
@@ -56,6 +61,9 @@ import { TopTenPlayer } from "../components/TopTenPlayer";
|
||||
import { seasonHasTopTen } from "../leaderboards-utils";
|
||||
import { USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN } from "~/features/mmr/mmr-constants";
|
||||
import * as LeaderboardRepository from "~/features/leaderboards/LeaderboardRepository.server";
|
||||
import { getUser } from "~/features/auth/core";
|
||||
import type { SkillTierInterval } from "~/features/mmr/tiered.server";
|
||||
import { ordinalToSp } from "~/features/mmr";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["vods"],
|
||||
@@ -89,6 +97,7 @@ const TYPE_SEARCH_PARAM_KEY = "type";
|
||||
const SEASON_SEARCH_PARAM_KEY = "season";
|
||||
|
||||
export const loader = async ({ request }: LoaderArgs) => {
|
||||
const user = await getUser(request);
|
||||
const t = await i18next.getFixedT(request);
|
||||
const unvalidatedType = new URL(request.url).searchParams.get(
|
||||
TYPE_SEARCH_PARAM_KEY,
|
||||
@@ -105,7 +114,7 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
(s) => unvalidatedSeason && s === Number(unvalidatedSeason),
|
||||
) ?? currentOrPreviousSeason(new Date())!.nth;
|
||||
|
||||
const userLeaderboard = type.includes("USER")
|
||||
const fullUserLeaderboard = type.includes("USER")
|
||||
? await cachified({
|
||||
key: `user-leaderboard-season-${season}`,
|
||||
cache,
|
||||
@@ -130,6 +139,11 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
})
|
||||
: null;
|
||||
|
||||
const userLeaderboard = fullUserLeaderboard?.slice(
|
||||
0,
|
||||
DEFAULT_LEADERBOARD_MAX_SIZE,
|
||||
);
|
||||
|
||||
const teamLeaderboard =
|
||||
type === "TEAM"
|
||||
? await cachified({
|
||||
@@ -142,16 +156,26 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
})
|
||||
: null;
|
||||
|
||||
const filteredLeaderboard =
|
||||
userLeaderboard && type !== "USER"
|
||||
? filterByWeaponCategory(
|
||||
userLeaderboard,
|
||||
type.split("-")[1] as (typeof weaponCategories)[number]["name"],
|
||||
)
|
||||
: userLeaderboard;
|
||||
const isWeaponLeaderboard = userLeaderboard && type !== "USER";
|
||||
|
||||
const filteredLeaderboard = isWeaponLeaderboard
|
||||
? filterByWeaponCategory(
|
||||
fullUserLeaderboard!,
|
||||
type.split("-")[1] as (typeof weaponCategories)[number]["name"],
|
||||
).slice(0, WEAPON_LEADERBOARD_MAX_SIZE)
|
||||
: userLeaderboard;
|
||||
|
||||
const showOwnEntryPeek = fullUserLeaderboard && !isWeaponLeaderboard && user;
|
||||
|
||||
return {
|
||||
userLeaderboard: filteredLeaderboard ?? userLeaderboard,
|
||||
ownEntryPeek: showOwnEntryPeek
|
||||
? await ownEntryPeek({
|
||||
leaderboard: fullUserLeaderboard,
|
||||
season,
|
||||
userId: user.id,
|
||||
})
|
||||
: null,
|
||||
teamLeaderboard,
|
||||
xpLeaderboard:
|
||||
type === "XP-ALL"
|
||||
@@ -296,6 +320,13 @@ export default function LeaderboardsPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{data.ownEntryPeek ? (
|
||||
<OwnEntryPeek
|
||||
entry={data.ownEntryPeek.entry}
|
||||
nextTier={data.ownEntryPeek.nextTier}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{data.userLeaderboard ? (
|
||||
<PlayersTable
|
||||
entries={data.userLeaderboard}
|
||||
@@ -323,6 +354,59 @@ export default function LeaderboardsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function OwnEntryPeek({
|
||||
entry,
|
||||
nextTier,
|
||||
}: {
|
||||
entry: NonNullable<SerializeFrom<typeof loader>["userLeaderboard"]>[number];
|
||||
nextTier?: SkillTierInterval;
|
||||
}) {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div>
|
||||
{entry.tier ? (
|
||||
<div className="placements__tier-header">
|
||||
<TierImage tier={entry.tier} width={32} />
|
||||
{entry.tier.name}
|
||||
{entry.tier.isPlus ? "+" : ""}
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<Link
|
||||
to={userSeasonsPage({ user: entry, season: data.season })}
|
||||
className="placements__table__row"
|
||||
>
|
||||
<div className="placements__table__inner-row">
|
||||
<div className="placements__table__rank">{entry.placementRank}</div>
|
||||
<div>
|
||||
<Avatar size="xxs" user={entry} />
|
||||
</div>
|
||||
{typeof entry.weaponSplId === "number" ? (
|
||||
<WeaponImage
|
||||
className="placements__table__weapon"
|
||||
variant="build"
|
||||
weaponSplId={entry.weaponSplId}
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
) : null}
|
||||
<div className="placements__table__name">{entry.discordName}</div>
|
||||
<div className="placements__table__power">{entry.power}</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
{nextTier ? (
|
||||
<div className="text-xs text-lighter ml-auto stack items-end">
|
||||
{nextTier.name}
|
||||
{nextTier.isPlus ? "+" : ""} @ {ordinalToSp(nextTier.neededOrdinal!)}
|
||||
SP
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayersTable({
|
||||
entries,
|
||||
showTiers,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { rate as openskillRate, ordinal } from "openskill";
|
||||
import { rate as openskillRate, ordinal, rating } from "openskill";
|
||||
import type { Rating, Team } from "openskill/dist/types";
|
||||
import invariant from "tiny-invariant";
|
||||
|
||||
@@ -8,6 +8,10 @@ export function ordinalToSp(ordinal: number) {
|
||||
return toTwoDecimals(ordinal * 15 + 1000);
|
||||
}
|
||||
|
||||
export function spToOrdinal(sp: number) {
|
||||
return (sp - 1000) / 15;
|
||||
}
|
||||
|
||||
export function ordinalToRoundedSp(ordinal: number) {
|
||||
return Math.round(ordinalToSp(ordinal));
|
||||
}
|
||||
@@ -76,3 +80,7 @@ export function userIdsToIdentifier(userIds: number[]) {
|
||||
export function identifierToUserIds(identifier: string) {
|
||||
return identifier.split("-").map(Number);
|
||||
}
|
||||
|
||||
export function defaultOrdinal() {
|
||||
return ordinal(rating());
|
||||
}
|
||||
|
||||
@@ -3,8 +3,13 @@ export const SEASONS =
|
||||
? ([
|
||||
{
|
||||
nth: 0,
|
||||
starts: new Date("2020-08-14T15:00:00.000Z"),
|
||||
ends: new Date("2029-08-26T20:59:59.999Z"),
|
||||
starts: new Date("2023-08-14T17:00:00.000Z"),
|
||||
ends: new Date("2023-08-27T20:59:59.999Z"),
|
||||
},
|
||||
{
|
||||
nth: 1,
|
||||
starts: new Date("2023-09-11T17:00:00.000Z"),
|
||||
ends: new Date("2030-11-17T20:59:59.999Z"),
|
||||
},
|
||||
] as const)
|
||||
: ([
|
||||
|
||||
@@ -25,19 +25,21 @@ export interface TieredSkill {
|
||||
export function freshUserSkills(season: number): {
|
||||
userSkills: Record<string, TieredSkill>;
|
||||
intervals: SkillTierInterval[];
|
||||
isAccurateTiers: boolean;
|
||||
} {
|
||||
const points = orderedMMRBySeason({
|
||||
season,
|
||||
type: "user",
|
||||
});
|
||||
|
||||
const tierIntervals = skillTierIntervals(points, "user");
|
||||
const { intervals, isAccurateTiers } = skillTierIntervals(points, "user");
|
||||
|
||||
return {
|
||||
intervals: tierIntervals,
|
||||
intervals,
|
||||
isAccurateTiers,
|
||||
userSkills: Object.fromEntries(
|
||||
points.map((p) => {
|
||||
const { name, isPlus } = tierIntervals.find(
|
||||
const { name, isPlus } = intervals.find(
|
||||
(t) => t.neededOrdinal! <= p.ordinal,
|
||||
) ?? { name: "IRON", isPlus: false };
|
||||
return [
|
||||
@@ -67,7 +69,9 @@ export async function userSkills(season: number) {
|
||||
return cachedSkills;
|
||||
}
|
||||
|
||||
export type SkillTierInterval = ReturnType<typeof skillTierIntervals>[number];
|
||||
export type SkillTierInterval = ReturnType<
|
||||
typeof skillTierIntervals
|
||||
>["intervals"][number];
|
||||
|
||||
function skillTierIntervals(
|
||||
orderedPoints: Array<Pick<Skill, "ordinal" | "matchesCount">>,
|
||||
@@ -112,7 +116,7 @@ function skillTierIntervals(
|
||||
|
||||
if (points.length === 1) {
|
||||
result[0].neededOrdinal = points[0].ordinal;
|
||||
return result;
|
||||
return { intervals: result, isAccurateTiers: hasLeviathan };
|
||||
}
|
||||
|
||||
let previousPercentiles = 0;
|
||||
@@ -137,5 +141,5 @@ function skillTierIntervals(
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return { intervals: result, isAccurateTiers: hasLeviathan };
|
||||
}
|
||||
|
||||
@@ -8,4 +8,4 @@ export type { MonthYear, PlusVoteFromFE } from "./types";
|
||||
|
||||
export { usePlusVoting } from "./usePlusVoting";
|
||||
|
||||
export { isVotingActive } from "./voting-time-new";
|
||||
export { isVotingActive } from "./voting-time";
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { MonthYear } from "./types";
|
||||
import { type RankingSeason, SEASONS } from "~/features/mmr/season";
|
||||
|
||||
export function lastCompletedVoting(now: Date): MonthYear {
|
||||
let match: { startDate: Date; endDate: Date } | null = null;
|
||||
for (const season of SEASONS) {
|
||||
const range = seasonToVotingRange(season);
|
||||
|
||||
if (now.getTime() > range.endDate.getTime()) {
|
||||
match = range;
|
||||
} else if (now.getTime() < range.endDate.getTime()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!match) {
|
||||
throw new Error("No previous voting found.");
|
||||
}
|
||||
|
||||
return rangeToMonthYear(match);
|
||||
}
|
||||
|
||||
export function nextNonCompletedVoting(now: Date) {
|
||||
for (const season of SEASONS) {
|
||||
const range = seasonToVotingRange(season);
|
||||
|
||||
if (now.getTime() < range.endDate.getTime()) {
|
||||
return range;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("No next voting found.");
|
||||
}
|
||||
|
||||
export function rangeToMonthYear(range: { startDate: Date; endDate: Date }) {
|
||||
return {
|
||||
month: range.startDate.getMonth(),
|
||||
year: range.startDate.getFullYear(),
|
||||
};
|
||||
}
|
||||
|
||||
export function seasonToVotingRange(season: RankingSeason) {
|
||||
const { ends: date } = season;
|
||||
|
||||
if (date.getUTCDay() !== 0) {
|
||||
throw new Error("End date is not a Sunday.");
|
||||
}
|
||||
|
||||
const endDate = new Date(date);
|
||||
endDate.setUTCDate(endDate.getUTCDate() - 7);
|
||||
endDate.setUTCHours(18, 0, 0, 0);
|
||||
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setUTCDate(startDate.getUTCDate() - 2);
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
export function isVotingActive() {
|
||||
const now = new Date();
|
||||
|
||||
for (const season of SEASONS) {
|
||||
const { startDate, endDate } = seasonToVotingRange(season);
|
||||
|
||||
if (
|
||||
now.getTime() > startDate.getTime() &&
|
||||
now.getTime() < endDate.getTime()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import type { MonthYear } from "./types";
|
||||
|
||||
export function lastCompletedVoting(now: Date): MonthYear {
|
||||
const thisMonthsRange = monthsVotingRange({
|
||||
month: now.getMonth(),
|
||||
year: now.getFullYear(),
|
||||
});
|
||||
|
||||
if (thisMonthsRange.endDate.getTime() < now.getTime()) {
|
||||
return {
|
||||
month: thisMonthsRange.endDate.getMonth(),
|
||||
year: thisMonthsRange.endDate.getFullYear(),
|
||||
};
|
||||
}
|
||||
|
||||
return previousMonth({
|
||||
month: thisMonthsRange.endDate.getMonth(),
|
||||
year: thisMonthsRange.endDate.getFullYear(),
|
||||
});
|
||||
}
|
||||
|
||||
export function nextNonCompletedVoting(now: Date): MonthYear {
|
||||
return nextMonth(lastCompletedVoting(now));
|
||||
}
|
||||
|
||||
/** Range of first Friday of a month to the following Sunday (this range is when voting is active) */
|
||||
export function monthsVotingRange({ month, year }: MonthYear) {
|
||||
const startDate = new Date(Date.UTC(year, month, 1, 18)); // EU evening, NA day
|
||||
|
||||
while (startDate.getDay() !== 5) {
|
||||
startDate.setDate(startDate.getDate() + 1);
|
||||
}
|
||||
|
||||
const endDate = new Date(startDate.getTime());
|
||||
endDate.setDate(endDate.getDate() + 2);
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
function previousMonth(input: MonthYear): MonthYear {
|
||||
let { month, year } = input;
|
||||
|
||||
month--;
|
||||
if (month < 0) {
|
||||
month = 11;
|
||||
year--;
|
||||
}
|
||||
|
||||
return { month, year };
|
||||
}
|
||||
|
||||
function nextMonth(input: MonthYear): MonthYear {
|
||||
let { month, year } = input;
|
||||
|
||||
month++;
|
||||
if (month === 12) {
|
||||
month = 0;
|
||||
year++;
|
||||
}
|
||||
|
||||
return { month, year };
|
||||
}
|
||||
|
||||
export function isVotingActive() {
|
||||
const now = new Date();
|
||||
const { endDate, startDate } = monthsVotingRange({
|
||||
month: now.getMonth(),
|
||||
year: now.getFullYear(),
|
||||
});
|
||||
|
||||
return (
|
||||
now.getTime() >= startDate.getTime() && now.getTime() <= endDate.getTime()
|
||||
);
|
||||
}
|
||||
@@ -1,25 +1,74 @@
|
||||
import type { MonthYear } from "~/features/top-search/top-search-utils";
|
||||
import {
|
||||
seasonToVotingRange,
|
||||
lastCompletedVoting as lastCompletedVotingNew,
|
||||
} from "./voting-time-new"; // TODO: seasonToVotingRange can be removed as export after the first new voting under the new system
|
||||
import type { MonthYear } from "./types";
|
||||
import { type RankingSeason, SEASONS } from "~/features/mmr/season";
|
||||
|
||||
export {
|
||||
isVotingActive,
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "./voting-time-new";
|
||||
|
||||
// TODO: this can be removed after the first new voting under the new system
|
||||
export function lastCompletedVoting(now: Date): MonthYear {
|
||||
const range = seasonToVotingRange({
|
||||
nth: 1,
|
||||
starts: new Date("2023-09-11T17:00:00.000Z"),
|
||||
ends: new Date("2023-11-19T20:59:59.999Z"),
|
||||
});
|
||||
let match: { startDate: Date; endDate: Date } | null = null;
|
||||
for (const season of SEASONS) {
|
||||
const range = seasonToVotingRange(season);
|
||||
|
||||
// first voting under the new system has not yet concluded
|
||||
const usingOldLogic = range.endDate.getTime() > now.getTime();
|
||||
if (now.getTime() > range.endDate.getTime()) {
|
||||
match = range;
|
||||
} else if (now.getTime() < range.endDate.getTime()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return usingOldLogic ? { month: 9, year: 2023 } : lastCompletedVotingNew(now);
|
||||
if (!match) {
|
||||
throw new Error("No previous voting found.");
|
||||
}
|
||||
|
||||
return rangeToMonthYear(match);
|
||||
}
|
||||
|
||||
export function nextNonCompletedVoting(now: Date) {
|
||||
for (const season of SEASONS) {
|
||||
const range = seasonToVotingRange(season);
|
||||
|
||||
if (now.getTime() < range.endDate.getTime()) {
|
||||
return range;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error("No next voting found.");
|
||||
}
|
||||
|
||||
export function rangeToMonthYear(range: { startDate: Date; endDate: Date }) {
|
||||
return {
|
||||
month: range.startDate.getMonth(),
|
||||
year: range.startDate.getFullYear(),
|
||||
};
|
||||
}
|
||||
|
||||
export function seasonToVotingRange(season: RankingSeason) {
|
||||
const { ends: date } = season;
|
||||
|
||||
if (date.getUTCDay() !== 0) {
|
||||
throw new Error("End date is not a Sunday.");
|
||||
}
|
||||
|
||||
const endDate = new Date(date);
|
||||
endDate.setUTCDate(endDate.getUTCDate() - 7);
|
||||
endDate.setUTCHours(18, 0, 0, 0);
|
||||
|
||||
const startDate = new Date(endDate);
|
||||
startDate.setUTCDate(startDate.getUTCDate() - 2);
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
export function isVotingActive() {
|
||||
const now = new Date();
|
||||
|
||||
for (const season of SEASONS) {
|
||||
const { startDate, endDate } = seasonToVotingRange(season);
|
||||
|
||||
if (
|
||||
now.getTime() > startDate.getTime() &&
|
||||
now.getTime() < endDate.getTime()
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
161
app/features/sendouq-match/QMatchRepository.server.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type { ParsedMemento, Tables } from "~/db/tables";
|
||||
import type { UserSkillDifference } from "~/db/types";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
|
||||
|
||||
export function findById(id: number) {
|
||||
return db
|
||||
.selectFrom("GroupMatch")
|
||||
.select(({ exists, selectFrom, eb }) => [
|
||||
"GroupMatch.id",
|
||||
"GroupMatch.alphaGroupId",
|
||||
"GroupMatch.bravoGroupId",
|
||||
"GroupMatch.createdAt",
|
||||
"GroupMatch.reportedAt",
|
||||
"GroupMatch.reportedByUserId",
|
||||
"GroupMatch.chatCode",
|
||||
"GroupMatch.memento",
|
||||
exists(
|
||||
selectFrom("Skill")
|
||||
.select("Skill.id")
|
||||
.where("Skill.groupMatchId", "=", id),
|
||||
).as("isLocked"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMatchMap")
|
||||
.select([
|
||||
"GroupMatch.id",
|
||||
"GroupMatchMap.mode",
|
||||
"GroupMatchMap.stageId",
|
||||
"GroupMatchMap.source",
|
||||
"GroupMatchMap.winnerGroupId",
|
||||
])
|
||||
.where("GroupMatchMap.matchId", "=", id)
|
||||
.orderBy("GroupMatchMap.index asc"),
|
||||
).as("mapList"),
|
||||
])
|
||||
.where("GroupMatch.id", "=", id)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
export interface GroupForMatch {
|
||||
id: Tables["Group"]["id"];
|
||||
chatCode: Tables["Group"]["chatCode"];
|
||||
tier?: ParsedMemento["groups"][number]["tier"];
|
||||
skillDifference?: ParsedMemento["groups"][number]["skillDifference"];
|
||||
team?: {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
customUrl: string;
|
||||
};
|
||||
members: Array<{
|
||||
id: Tables["GroupMember"]["userId"];
|
||||
discordId: Tables["User"]["discordId"];
|
||||
discordName: Tables["User"]["discordName"];
|
||||
discordAvatar: Tables["User"]["discordAvatar"];
|
||||
role: Tables["GroupMember"]["role"];
|
||||
customUrl: Tables["User"]["customUrl"];
|
||||
inGameName: Tables["User"]["inGameName"];
|
||||
weapons: Array<MainWeaponId>;
|
||||
chatNameColor: string | null;
|
||||
vc: Tables["User"]["vc"];
|
||||
languages: string[];
|
||||
skillDifference?: UserSkillDifference;
|
||||
privateNote: Pick<
|
||||
Tables["PrivateUserNote"],
|
||||
"sentiment" | "text" | "updatedAt"
|
||||
> | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function findGroupById({
|
||||
loggedInUserId,
|
||||
groupId,
|
||||
}: {
|
||||
groupId: number;
|
||||
loggedInUserId?: number;
|
||||
}) {
|
||||
const row = await db
|
||||
.selectFrom("Group")
|
||||
.innerJoin("GroupMatch", (join) =>
|
||||
join.on((eb) =>
|
||||
eb.or([
|
||||
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
|
||||
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select(({ eb }) => [
|
||||
"Group.id",
|
||||
"Group.chatCode",
|
||||
"GroupMatch.memento",
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("AllTeam")
|
||||
.leftJoin(
|
||||
"UserSubmittedImage",
|
||||
"AllTeam.avatarImgId",
|
||||
"UserSubmittedImage.id",
|
||||
)
|
||||
.select([
|
||||
"AllTeam.name",
|
||||
"AllTeam.customUrl",
|
||||
"UserSubmittedImage.url as avatarUrl",
|
||||
])
|
||||
.where("AllTeam.id", "=", eb.ref("Group.teamId")),
|
||||
).as("team"),
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.select((arrayEb) => [
|
||||
...COMMON_USER_FIELDS,
|
||||
"GroupMember.role",
|
||||
"User.inGameName",
|
||||
"User.vc",
|
||||
"User.languages",
|
||||
"User.qWeaponPool as weapons",
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("PrivateUserNote")
|
||||
.select([
|
||||
"PrivateUserNote.sentiment",
|
||||
"PrivateUserNote.text",
|
||||
"PrivateUserNote.updatedAt",
|
||||
])
|
||||
.where("authorId", "=", loggedInUserId ?? -1)
|
||||
.where("targetId", "=", arrayEb.ref("User.id")),
|
||||
).as("privateNote"),
|
||||
sql<
|
||||
string | null
|
||||
>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)`.as(
|
||||
"chatNameColor",
|
||||
),
|
||||
])
|
||||
.where("GroupMember.groupId", "=", groupId)
|
||||
.orderBy("GroupMember.userId asc"),
|
||||
).as("members"),
|
||||
])
|
||||
.where("Group.id", "=", groupId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
chatCode: row.chatCode,
|
||||
tier: row.memento?.groups[row.id]?.tier,
|
||||
skillDifference: row.memento?.groups[row.id]?.skillDifference,
|
||||
team: row.team,
|
||||
members: row.members.map((m) => ({
|
||||
...m,
|
||||
languages: m.languages ? m.languages.split(",") : [],
|
||||
plusTier: row.memento?.users[m.id]?.plusTier,
|
||||
skill: row.memento?.users[m.id]?.skill,
|
||||
skillDifference: row.memento?.users[m.id]?.skillDifference,
|
||||
})),
|
||||
} as GroupForMatch;
|
||||
}
|
||||
135
app/features/sendouq-match/components/AddPrivateNoteDialog.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import * as React from "react";
|
||||
import { Label } from "~/components/Label";
|
||||
import { SENDOUQ } from "~/features/sendouq/q-constants";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { preferenceEmojiUrl } from "~/utils/urls";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { Button } from "~/components/Button";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import type { GroupForMatch } from "../QMatchRepository.server";
|
||||
import type { Tables } from "~/db/tables";
|
||||
|
||||
export function AddPrivateNoteDialog({
|
||||
aboutUser,
|
||||
close,
|
||||
}: {
|
||||
aboutUser?: Pick<
|
||||
GroupForMatch["members"][number],
|
||||
"id" | "discordName" | "privateNote"
|
||||
>;
|
||||
close: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["q", "common"]);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
if (!aboutUser) return null;
|
||||
|
||||
return (
|
||||
<Dialog isOpen>
|
||||
<fetcher.Form method="post" className="stack md">
|
||||
<input type="hidden" name="targetId" value={aboutUser.id} />
|
||||
<div className="stack horizontal items-center justify-between">
|
||||
<h2 className="text-md">
|
||||
{t("q:privateNote.header", { name: aboutUser.discordName })}
|
||||
</h2>
|
||||
<Button
|
||||
variant="minimal-destructive"
|
||||
icon={<CrossIcon />}
|
||||
onClick={close}
|
||||
/>
|
||||
</div>
|
||||
<Textarea initialValue={aboutUser.privateNote?.text} />
|
||||
<Sentiment initialValue={aboutUser.privateNote?.sentiment} />
|
||||
<div className="stack items-center mt-2">
|
||||
<SubmitButton _action="ADD_PRIVATE_USER_NOTE">
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Sentiment({
|
||||
initialValue,
|
||||
}: {
|
||||
initialValue?: Tables["PrivateUserNote"]["sentiment"];
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const [sentiment, setSentiment] = React.useState<
|
||||
Tables["PrivateUserNote"]["sentiment"]
|
||||
>(initialValue ?? "NEUTRAL");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Label>{t("q:privateNote.sentiment.header")}</Label>
|
||||
<input type="hidden" name="sentiment" value={sentiment} />
|
||||
<div className="stack xs my-2">
|
||||
{(["POSITIVE", "NEUTRAL", "NEGATIVE"] as const).map(
|
||||
(sentimentRadio) => {
|
||||
return (
|
||||
<div
|
||||
key={sentimentRadio}
|
||||
className="stack horizontal xs items-center"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id={sentimentRadio}
|
||||
checked={sentimentRadio === sentiment}
|
||||
onChange={() => setSentiment(sentimentRadio)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={sentimentRadio}
|
||||
className="mb-0 stack horizontal xs"
|
||||
>
|
||||
<img
|
||||
src={preferenceEmojiUrl(
|
||||
sentimentRadio === "POSITIVE"
|
||||
? "PREFER"
|
||||
: sentimentRadio === "NEGATIVE"
|
||||
? "AVOID"
|
||||
: undefined,
|
||||
)}
|
||||
alt=""
|
||||
width={18}
|
||||
/>
|
||||
{t(`q:privateNote.sentiment.${sentimentRadio}`)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<FormMessage type="info">{t("q:privateNote.sentiment.info")}</FormMessage>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Textarea({ initialValue }: { initialValue?: string | null }) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const [value, setValue] = React.useState(initialValue ?? "");
|
||||
|
||||
return (
|
||||
<div className="u-edit__bio-container">
|
||||
<Label
|
||||
htmlFor="text"
|
||||
valueLimits={{
|
||||
current: value.length,
|
||||
max: SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH,
|
||||
}}
|
||||
>
|
||||
{t("q:privateNote.comment.header")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="text"
|
||||
name="comment"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
maxLength={SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
64
app/features/sendouq-settings/QSettingsRepository.server.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { db } from "~/db/sql";
|
||||
import type { Tables, UserMapModePreferences } from "~/db/tables";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
|
||||
export async function settingsByUserId(userId: number) {
|
||||
const preferences = await db
|
||||
.selectFrom("User")
|
||||
.select([
|
||||
"User.mapModePreferences",
|
||||
"User.vc",
|
||||
"User.languages",
|
||||
"User.qWeaponPool",
|
||||
])
|
||||
.where("id", "=", userId)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
return {
|
||||
...preferences,
|
||||
languages: preferences.languages?.split(","),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateUserMapModePreferences({
|
||||
userId,
|
||||
mapModePreferences,
|
||||
}: {
|
||||
userId: number;
|
||||
mapModePreferences: UserMapModePreferences;
|
||||
}) {
|
||||
return db
|
||||
.updateTable("User")
|
||||
.set({ mapModePreferences: JSON.stringify(mapModePreferences) })
|
||||
.where("id", "=", userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function updateVoiceChat(args: {
|
||||
userId: number;
|
||||
vc: Tables["User"]["vc"];
|
||||
languages: string[];
|
||||
}) {
|
||||
return db
|
||||
.updateTable("User")
|
||||
.set({
|
||||
vc: args.vc,
|
||||
languages: args.languages.length > 0 ? args.languages.join(",") : null,
|
||||
})
|
||||
.where("User.id", "=", args.userId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function updateSendouQWeaponPool(args: {
|
||||
userId: number;
|
||||
weaponPool: MainWeaponId[];
|
||||
}) {
|
||||
return db
|
||||
.updateTable("User")
|
||||
.set({
|
||||
qWeaponPool:
|
||||
args.weaponPool.length > 0 ? JSON.stringify(args.weaponPool) : null,
|
||||
})
|
||||
.where("User.id", "=", args.userId)
|
||||
.execute();
|
||||
}
|
||||
16
app/features/sendouq-settings/banned-maps.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import { stagesObj as s } from "~/modules/in-game-lists/stage-ids";
|
||||
|
||||
export const COMMON_BANNED_MAPS = [
|
||||
s.HAMMERHEAD_BRIDGE,
|
||||
s.WAHOO_WORLD,
|
||||
s.MINCEMEAT_METALWORKS,
|
||||
s.EELTAIL_ALLEY,
|
||||
];
|
||||
export const BANNED_MAPS: Record<ModeShort, StageId[]> = {
|
||||
TW: [...COMMON_BANNED_MAPS],
|
||||
SZ: [...COMMON_BANNED_MAPS],
|
||||
TC: [...COMMON_BANNED_MAPS, s.BRINEWATER_SPRINGS, s.FLOUNDER_HEIGHTS],
|
||||
RM: [...COMMON_BANNED_MAPS, s.BRINEWATER_SPRINGS, s.UM_AMI_RUINS],
|
||||
CB: [...COMMON_BANNED_MAPS, s.STURGEON_SHIPYARD, s.FLOUNDER_HEIGHTS],
|
||||
};
|
||||
1
app/features/sendouq-settings/q-settings-constants.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SENDOUQ_WEAPON_POOL_MAX_SIZE = 4;
|
||||
45
app/features/sendouq-settings/q-settings-schemas.server.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from "zod";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import {
|
||||
_action,
|
||||
modeShort,
|
||||
noDuplicates,
|
||||
safeJSONParse,
|
||||
stageId,
|
||||
weaponSplId,
|
||||
} from "~/utils/zod";
|
||||
import { SENDOUQ_WEAPON_POOL_MAX_SIZE } from "./q-settings-constants";
|
||||
|
||||
const preference = z.enum(["AVOID", "PREFER"]);
|
||||
export const settingsActionSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("UPDATE_MAP_MODE_PREFERENCES"),
|
||||
mapModePreferences: z.preprocess(
|
||||
safeJSONParse,
|
||||
z.object({
|
||||
modes: z.array(z.object({ mode: modeShort, preference })),
|
||||
maps: z.array(z.object({ stageId, mode: modeShort, preference })),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UPDATE_VC"),
|
||||
vc: z.enum(["YES", "NO", "LISTEN_ONLY"]),
|
||||
languages: z.preprocess(
|
||||
safeJSONParse,
|
||||
z
|
||||
.array(z.string())
|
||||
.refine(noDuplicates)
|
||||
.refine((val) =>
|
||||
val.every((lang) => languagesUnified.some((l) => l.code === lang)),
|
||||
),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("UPDATE_SENDOUQ_WEAPON_POOL"),
|
||||
weaponPool: z.preprocess(
|
||||
safeJSONParse,
|
||||
z.array(weaponSplId).max(SENDOUQ_WEAPON_POOL_MAX_SIZE),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
71
app/features/sendouq-settings/q-settings.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.q__map-mode-radios-container {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 600px) {
|
||||
.q__map-mode-radios-container {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.q-settings__radio {
|
||||
background-color: var(--bg-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
text-transform: uppercase;
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text-lighter);
|
||||
padding: var(--s-1) var(--s-2);
|
||||
border-radius: var(--rounded);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.q-settings__radio:hover .q-settings__radio__emoji {
|
||||
scale: 1.1;
|
||||
}
|
||||
|
||||
.q-settings__radio__emoji {
|
||||
filter: grayscale(100%);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.q-settings__radio__checked {
|
||||
color: var(--text);
|
||||
outline: 2px solid var(--bg-lightest);
|
||||
}
|
||||
|
||||
.q-settings__radio__checked .q-settings__radio__emoji {
|
||||
filter: grayscale(0%);
|
||||
}
|
||||
|
||||
.q-settings__summary {
|
||||
padding: var(--s-3);
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-lighter);
|
||||
font-size: var(--fonts-lg);
|
||||
font-weight: var(--bold);
|
||||
margin-block-end: var(--s-4);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.q-settings__summary > div {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.q-settings__summary svg {
|
||||
width: 24px;
|
||||
color: var(--theme);
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 14px;
|
||||
}
|
||||
|
||||
.q-settings__weapon-pool-select-container {
|
||||
width: 250px;
|
||||
height: 50px;
|
||||
}
|
||||
652
app/features/sendouq-settings/routes/q.settings.tsx
Normal file
@@ -0,0 +1,652 @@
|
||||
import { RadioGroup } from "@headlessui/react";
|
||||
import type { ActionArgs, LinksFunction, LoaderArgs } from "@remix-run/node";
|
||||
import { useFetcher, useLoaderData } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { Button } from "~/components/Button";
|
||||
import { WeaponCombobox } from "~/components/Combobox";
|
||||
import { ModeImage, StageImage, WeaponImage } from "~/components/Image";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { MapIcon } from "~/components/icons/Map";
|
||||
import { MicrophoneFilledIcon } from "~/components/icons/MicrophoneFilled";
|
||||
import { PuzzleIcon } from "~/components/icons/Puzzle";
|
||||
import { SpeakerFilledIcon } from "~/components/icons/SpeakerFilled";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
import type { Preference, Tables, UserMapModePreferences } from "~/db/tables";
|
||||
import { requireUserId } from "~/features/auth/core/user.server";
|
||||
import { soundCodeToLocalStorageKey } from "~/features/chat/chat-utils";
|
||||
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import type { MainWeaponId, ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import { stageIds } from "~/modules/in-game-lists";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { type SendouRouteHandle, parseRequestFormData } from "~/utils/remix";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
SENDOUQ_PAGE,
|
||||
SENDOUQ_SETTINGS_PAGE,
|
||||
navIconUrl,
|
||||
preferenceEmojiUrl,
|
||||
} from "~/utils/urls";
|
||||
import { SENDOUQ_WEAPON_POOL_MAX_SIZE } from "../q-settings-constants";
|
||||
import { settingsActionSchema } from "../q-settings-schemas.server";
|
||||
import styles from "../q-settings.css";
|
||||
import { BANNED_MAPS, COMMON_BANNED_MAPS } from "../banned-maps";
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q"],
|
||||
breadcrumb: () => [
|
||||
{
|
||||
imgPath: navIconUrl("sendouq"),
|
||||
href: SENDOUQ_PAGE,
|
||||
type: "IMAGE",
|
||||
},
|
||||
{
|
||||
imgPath: navIconUrl("settings"),
|
||||
href: SENDOUQ_SETTINGS_PAGE,
|
||||
type: "IMAGE",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const action = async ({ request }: ActionArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
const data = await parseRequestFormData({
|
||||
request,
|
||||
schema: settingsActionSchema,
|
||||
});
|
||||
|
||||
switch (data._action) {
|
||||
case "UPDATE_MAP_MODE_PREFERENCES": {
|
||||
await QSettingsRepository.updateUserMapModePreferences({
|
||||
mapModePreferences: data.mapModePreferences,
|
||||
userId: user.id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "UPDATE_VC": {
|
||||
await QSettingsRepository.updateVoiceChat({
|
||||
userId: user.id,
|
||||
vc: data.vc,
|
||||
languages: data.languages,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "UPDATE_SENDOUQ_WEAPON_POOL": {
|
||||
await QSettingsRepository.updateSendouQWeaponPool({
|
||||
userId: user.id,
|
||||
weaponPool: data.weaponPool,
|
||||
});
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
export const loader = async ({ request }: LoaderArgs) => {
|
||||
const user = await requireUserId(request);
|
||||
|
||||
return {
|
||||
settings: await QSettingsRepository.settingsByUserId(user.id),
|
||||
};
|
||||
};
|
||||
|
||||
export default function SendouQSettingsPage() {
|
||||
return (
|
||||
<Main className="stack sm">
|
||||
<div className="stack">
|
||||
<MapPicker />
|
||||
<WeaponPool />
|
||||
<VoiceChat />
|
||||
<Sounds />
|
||||
</div>
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function MapPicker() {
|
||||
const { t } = useTranslation(["q", "common"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher();
|
||||
const [preferences, setPreferences] = React.useState<UserMapModePreferences>(
|
||||
data.settings.mapModePreferences ?? {
|
||||
maps: [],
|
||||
modes: [],
|
||||
},
|
||||
);
|
||||
|
||||
const handleMapPreferenceChange = ({
|
||||
stageId,
|
||||
mode,
|
||||
preference,
|
||||
}: {
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
preference: Preference & "NEUTRAL";
|
||||
}) => {
|
||||
const newMapPreferences = preferences.maps.filter(
|
||||
(map) => map.stageId !== stageId || map.mode !== mode,
|
||||
);
|
||||
|
||||
if (preference !== "NEUTRAL") {
|
||||
newMapPreferences.push({
|
||||
stageId,
|
||||
mode,
|
||||
preference,
|
||||
});
|
||||
}
|
||||
|
||||
setPreferences({
|
||||
...preferences,
|
||||
maps: newMapPreferences,
|
||||
});
|
||||
};
|
||||
|
||||
const handleModePreferenceChange = ({
|
||||
mode,
|
||||
preference,
|
||||
}: {
|
||||
mode: ModeShort;
|
||||
preference: Preference & "NEUTRAL";
|
||||
}) => {
|
||||
const newModePreferences = preferences.modes.filter(
|
||||
(map) => map.mode !== mode,
|
||||
);
|
||||
|
||||
if (preference !== "NEUTRAL") {
|
||||
newModePreferences.push({
|
||||
mode,
|
||||
preference,
|
||||
});
|
||||
}
|
||||
|
||||
setPreferences({
|
||||
...preferences,
|
||||
modes: newModePreferences,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<details>
|
||||
<summary className="q-settings__summary">
|
||||
<div>
|
||||
<span>{t("q:settings.maps.header")}</span> <MapIcon />
|
||||
</div>
|
||||
</summary>
|
||||
<fetcher.Form method="post" className="mb-4">
|
||||
<input
|
||||
type="hidden"
|
||||
name="mapModePreferences"
|
||||
value={JSON.stringify(preferences)}
|
||||
/>
|
||||
<div className="stack lg">
|
||||
<div className="stack items-center">
|
||||
{modesShort.map((modeShort) => {
|
||||
const preference = preferences.modes.find(
|
||||
(preference) => preference.mode === modeShort,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={modeShort} className="stack horizontal xs my-1">
|
||||
<ModeImage mode={modeShort} width={32} />
|
||||
<PreferenceRadioGroup
|
||||
preference={preference?.preference}
|
||||
onPreferenceChange={(preference) =>
|
||||
handleModePreferenceChange({
|
||||
mode: modeShort,
|
||||
preference,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="stack lg">
|
||||
{stageIds
|
||||
.filter(
|
||||
(stageId) =>
|
||||
!COMMON_BANNED_MAPS.includes(
|
||||
stageId as (typeof COMMON_BANNED_MAPS)[number],
|
||||
),
|
||||
)
|
||||
.map((stageId) => (
|
||||
<MapModeRadios
|
||||
key={stageId}
|
||||
stageId={stageId}
|
||||
preferences={preferences.maps.filter(
|
||||
(map) => map.stageId === stageId,
|
||||
)}
|
||||
onPreferenceChange={handleMapPreferenceChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<SubmitButton
|
||||
_action="UPDATE_MAP_MODE_PREFERENCES"
|
||||
state={fetcher.state}
|
||||
className="mx-auto"
|
||||
size="big"
|
||||
>
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function MapModeRadios({
|
||||
stageId,
|
||||
preferences,
|
||||
onPreferenceChange,
|
||||
}: {
|
||||
stageId: StageId;
|
||||
preferences: UserMapModePreferences["maps"];
|
||||
onPreferenceChange: (args: {
|
||||
stageId: StageId;
|
||||
mode: ModeShort;
|
||||
preference: Preference & "NEUTRAL";
|
||||
}) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="q__map-mode-radios-container">
|
||||
<StageImage stageId={stageId} width={250} className="rounded" />
|
||||
<div className="stack justify-evenly">
|
||||
{modesShort
|
||||
.filter((modeShort) => !BANNED_MAPS[modeShort].includes(stageId))
|
||||
.map((modeShort) => {
|
||||
const preference = preferences.find(
|
||||
(preference) =>
|
||||
preference.mode === modeShort && preference.stageId === stageId,
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={modeShort} className="stack horizontal xs my-1">
|
||||
<ModeImage mode={modeShort} width={24} />
|
||||
<PreferenceRadioGroup
|
||||
preference={preference?.preference}
|
||||
onPreferenceChange={(preference) =>
|
||||
onPreferenceChange({ mode: modeShort, preference, stageId })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreferenceRadioGroup({
|
||||
preference,
|
||||
onPreferenceChange,
|
||||
}: {
|
||||
preference?: Preference;
|
||||
onPreferenceChange: (preference: Preference & "NEUTRAL") => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<RadioGroup
|
||||
value={preference ?? "NEUTRAL"}
|
||||
onChange={(newPreference) =>
|
||||
onPreferenceChange(newPreference as Preference & "NEUTRAL")
|
||||
}
|
||||
className="stack horizontal xs"
|
||||
>
|
||||
<RadioGroup.Option value="AVOID">
|
||||
{({ checked }) => (
|
||||
<span
|
||||
className={clsx("q-settings__radio", {
|
||||
"q-settings__radio__checked": checked,
|
||||
})}
|
||||
>
|
||||
<img
|
||||
src={preferenceEmojiUrl("AVOID")}
|
||||
className="q-settings__radio__emoji"
|
||||
width={18}
|
||||
/>
|
||||
{t("q:settings.maps.avoid")}
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
<RadioGroup.Option value="NEUTRAL">
|
||||
{({ checked }) => (
|
||||
<span
|
||||
className={clsx("q-settings__radio", {
|
||||
"q-settings__radio__checked": checked,
|
||||
})}
|
||||
>
|
||||
<img
|
||||
src={preferenceEmojiUrl()}
|
||||
className="q-settings__radio__emoji"
|
||||
width={18}
|
||||
/>
|
||||
{t("q:settings.maps.neutral")}
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
<RadioGroup.Option value="PREFER">
|
||||
{({ checked }) => (
|
||||
<span
|
||||
className={clsx("q-settings__radio", {
|
||||
"q-settings__radio__checked": checked,
|
||||
})}
|
||||
>
|
||||
<img
|
||||
src={preferenceEmojiUrl("PREFER")}
|
||||
className="q-settings__radio__emoji"
|
||||
width={18}
|
||||
/>
|
||||
{t("q:settings.maps.prefer")}
|
||||
</span>
|
||||
)}
|
||||
</RadioGroup.Option>
|
||||
</RadioGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceChat() {
|
||||
const { t } = useTranslation(["common", "q"]);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<details>
|
||||
<summary className="q-settings__summary">
|
||||
<div>
|
||||
<span>{t("q:settings.voiceChat.header")}</span>{" "}
|
||||
<MicrophoneFilledIcon />
|
||||
</div>
|
||||
</summary>
|
||||
<fetcher.Form method="post" className="mb-4 ml-2-5 stack sm">
|
||||
<VoiceChatAbility />
|
||||
<Languages />
|
||||
<div>
|
||||
<SubmitButton
|
||||
size="big"
|
||||
className="mt-2 mx-auto"
|
||||
_action="UPDATE_VC"
|
||||
state={fetcher.state}
|
||||
>
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function VoiceChatAbility() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const label = (vc: Tables["User"]["vc"]) => {
|
||||
switch (vc) {
|
||||
case "YES":
|
||||
return t("q:settings.voiceChat.canVC.yes");
|
||||
case "NO":
|
||||
return t("q:settings.voiceChat.canVC.no");
|
||||
case "LISTEN_ONLY":
|
||||
return t("q:settings.voiceChat.canVC.listenOnly");
|
||||
default:
|
||||
assertUnreachable(vc);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<label>{t("q:settings.voiceChat.canVC.header")}</label>
|
||||
{(["YES", "NO", "LISTEN_ONLY"] as const).map((option) => {
|
||||
return (
|
||||
<div key={option} className="stack sm horizontal items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="vc"
|
||||
id={option}
|
||||
value={option}
|
||||
required
|
||||
defaultChecked={data.settings.vc === option}
|
||||
/>
|
||||
<label htmlFor={option} className="mb-0 text-main-forced">
|
||||
{label(option)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Languages() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [value, setValue] = React.useState(data.settings.languages ?? []);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<input type="hidden" name="languages" value={JSON.stringify(value)} />
|
||||
<label>{t("q:settings.voiceChat.languages.header")}</label>
|
||||
<select
|
||||
className="w-max"
|
||||
onChange={(e) => {
|
||||
const newLanguages = [...value, e.target.value].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
setValue(newLanguages);
|
||||
}}
|
||||
>
|
||||
<option value="">
|
||||
{t("q:settings.voiceChat.languages.placeholder")}
|
||||
</option>
|
||||
{languagesUnified
|
||||
.filter((lang) => !value.includes(lang.code))
|
||||
.map((option) => {
|
||||
return (
|
||||
<option key={option.code} value={option.code}>
|
||||
{option.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<div className="mt-2">
|
||||
{value.map((code) => {
|
||||
const name = languagesUnified.find((l) => l.code === code)?.name;
|
||||
|
||||
return (
|
||||
<div key={code} className="stack horizontal items-center sm">
|
||||
{name}{" "}
|
||||
<Button
|
||||
icon={<CrossIcon />}
|
||||
variant="minimal-destructive"
|
||||
onClick={() => {
|
||||
const newLanguages = value.filter(
|
||||
(codeInArr) => codeInArr !== code,
|
||||
);
|
||||
setValue(newLanguages);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeaponPool() {
|
||||
const { t } = useTranslation(["common", "q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [weapons, setWeapons] = React.useState(data.settings.qWeaponPool ?? []);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const latestWeapon = weapons[weapons.length - 1];
|
||||
|
||||
return (
|
||||
<details>
|
||||
<summary className="q-settings__summary">
|
||||
<div>
|
||||
<span>{t("q:settings.weaponPool.header")}</span> <PuzzleIcon />
|
||||
</div>
|
||||
</summary>
|
||||
<fetcher.Form method="post" className="mb-4 stack items-center">
|
||||
<input
|
||||
type="hidden"
|
||||
name="weaponPool"
|
||||
value={JSON.stringify(weapons)}
|
||||
/>
|
||||
<div className="q-settings__weapon-pool-select-container">
|
||||
{weapons.length < SENDOUQ_WEAPON_POOL_MAX_SIZE ? (
|
||||
<div>
|
||||
<WeaponCombobox
|
||||
inputName="weapon"
|
||||
id="weapon"
|
||||
onChange={(weapon) => {
|
||||
if (!weapon) return;
|
||||
setWeapons([
|
||||
...weapons,
|
||||
Number(weapon.value) as MainWeaponId,
|
||||
]);
|
||||
}}
|
||||
// empty on selection
|
||||
key={latestWeapon ?? "empty"}
|
||||
weaponIdsToOmit={new Set(weapons)}
|
||||
fullWidth
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-info">
|
||||
{t("q:settings.weaponPool.full")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="stack horizontal sm justify-center">
|
||||
{weapons.map((weapon) => {
|
||||
return (
|
||||
<div key={weapon} className="stack xs">
|
||||
<div>
|
||||
<WeaponImage
|
||||
weaponSplId={weapon}
|
||||
variant="badge"
|
||||
width={38}
|
||||
height={38}
|
||||
/>
|
||||
</div>
|
||||
<div className="stack sm horizontal items-center justify-center">
|
||||
<Button
|
||||
icon={<TrashIcon />}
|
||||
variant="minimal-destructive"
|
||||
aria-label="Delete weapon"
|
||||
onClick={() =>
|
||||
setWeapons(weapons.filter((w) => w !== weapon))
|
||||
}
|
||||
size="tiny"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<SubmitButton
|
||||
size="big"
|
||||
className="mx-auto"
|
||||
_action="UPDATE_SENDOUQ_WEAPON_POOL"
|
||||
state={fetcher.state}
|
||||
>
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function Sounds() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
return (
|
||||
<details>
|
||||
<summary className="q-settings__summary">
|
||||
<div>
|
||||
<span>{t("q:settings.sounds.header")}</span> <SpeakerFilledIcon />
|
||||
</div>
|
||||
</summary>
|
||||
{isMounted && <SoundCheckboxes />}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function SoundCheckboxes() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
const sounds = [
|
||||
{
|
||||
code: "sq_like",
|
||||
name: t("q:settings.sounds.likeReceived"),
|
||||
},
|
||||
{
|
||||
code: "sq_new-group",
|
||||
name: t("q:settings.sounds.groupNewMember"),
|
||||
},
|
||||
{
|
||||
code: "sq_match",
|
||||
name: t("q:settings.sounds.matchStarted"),
|
||||
},
|
||||
];
|
||||
|
||||
// default to true
|
||||
const currentValue = (code: string) =>
|
||||
!localStorage.getItem(soundCodeToLocalStorageKey(code)) ||
|
||||
localStorage.getItem(soundCodeToLocalStorageKey(code)) === "true";
|
||||
|
||||
const [soundValues, setSoundValues] = React.useState(
|
||||
Object.fromEntries(
|
||||
sounds.map((sound) => [sound.code, currentValue(sound.code)]),
|
||||
),
|
||||
);
|
||||
|
||||
// toggle in local storage
|
||||
const toggleSound = (code: string) => {
|
||||
localStorage.setItem(
|
||||
soundCodeToLocalStorageKey(code),
|
||||
String(!currentValue(code)),
|
||||
);
|
||||
setSoundValues((prev) => ({
|
||||
...prev,
|
||||
[code]: !prev[code],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ml-2-5">
|
||||
{sounds.map((sound) => (
|
||||
<div key={sound.code}>
|
||||
<label className="stack horizontal xs items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={soundValues[sound.code]}
|
||||
onChange={() => toggleSound(sound.code)}
|
||||
/>
|
||||
{sound.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
app/features/sendouq/QRepository.server.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { sql } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type {
|
||||
Tables,
|
||||
TablesInsertable,
|
||||
UserMapModePreferences,
|
||||
} from "~/db/tables";
|
||||
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
|
||||
import type { LookingGroupWithInviteCode } from "./q-types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { INVITE_CODE_LENGTH } from "~/constants";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
|
||||
export function mapModePreferencesByGroupId(groupId: number) {
|
||||
return db
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.select(["User.id as userId", "User.mapModePreferences as preferences"])
|
||||
.where("GroupMember.groupId", "=", groupId)
|
||||
.where("User.mapModePreferences", "is not", null)
|
||||
.execute() as Promise<
|
||||
{ userId: number; preferences: UserMapModePreferences }[]
|
||||
>;
|
||||
}
|
||||
|
||||
// groups visible for longer to make development easier
|
||||
const SECONDS_TILL_STALE =
|
||||
process.env.NODE_ENV === "development" ? 1_000_000 : 1_800;
|
||||
|
||||
export async function findLookingGroups({
|
||||
minGroupSize,
|
||||
maxGroupSize,
|
||||
ownGroupId,
|
||||
includeChatCode = false,
|
||||
includeMapModePreferences = false,
|
||||
loggedInUserId,
|
||||
}: {
|
||||
minGroupSize?: number;
|
||||
maxGroupSize?: number;
|
||||
ownGroupId: number;
|
||||
includeChatCode?: boolean;
|
||||
includeMapModePreferences?: boolean;
|
||||
loggedInUserId?: number;
|
||||
}): Promise<LookingGroupWithInviteCode[]> {
|
||||
const rows = await db
|
||||
.selectFrom("Group")
|
||||
.leftJoin("GroupMatch", (join) =>
|
||||
join.on((eb) =>
|
||||
eb.or([
|
||||
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
|
||||
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.select((eb) => [
|
||||
"Group.id",
|
||||
"Group.createdAt",
|
||||
"Group.chatCode",
|
||||
"Group.inviteCode",
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.leftJoin("PlusTier", "PlusTier.userId", "GroupMember.userId")
|
||||
.select((arrayEb) => [
|
||||
...COMMON_USER_FIELDS,
|
||||
"User.qWeaponPool as weapons",
|
||||
"PlusTier.tier as plusTier",
|
||||
"GroupMember.note",
|
||||
"User.languages",
|
||||
"User.vc",
|
||||
jsonObjectFrom(
|
||||
eb
|
||||
.selectFrom("PrivateUserNote")
|
||||
.select([
|
||||
"PrivateUserNote.sentiment",
|
||||
"PrivateUserNote.text",
|
||||
"PrivateUserNote.updatedAt",
|
||||
])
|
||||
.where("authorId", "=", loggedInUserId ?? -1)
|
||||
.where("targetId", "=", arrayEb.ref("User.id")),
|
||||
).as("privateNote"),
|
||||
sql<
|
||||
string | null
|
||||
>`IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)`.as(
|
||||
"chatNameColor",
|
||||
),
|
||||
])
|
||||
.where("GroupMember.groupId", "=", eb.ref("Group.id"))
|
||||
.groupBy("GroupMember.userId"),
|
||||
).as("members"),
|
||||
])
|
||||
.$if(includeMapModePreferences, (qb) =>
|
||||
qb.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("GroupMember")
|
||||
.innerJoin("User", "User.id", "GroupMember.userId")
|
||||
.select("User.mapModePreferences")
|
||||
.where("GroupMember.groupId", "=", eb.ref("Group.id"))
|
||||
.where("User.mapModePreferences", "is not", null),
|
||||
).as("mapModePreferences"),
|
||||
),
|
||||
)
|
||||
.where("Group.status", "=", "ACTIVE")
|
||||
.where("GroupMatch.id", "is", null)
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb(
|
||||
"Group.latestActionAt",
|
||||
">",
|
||||
sql`(unixepoch() - ${SECONDS_TILL_STALE})`,
|
||||
),
|
||||
eb("Group.id", "=", ownGroupId),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
|
||||
// TODO: a bit weird we filter chatCode here but not inviteCode and do some logic about filtering
|
||||
return rows
|
||||
.map((row) => {
|
||||
return {
|
||||
...row,
|
||||
chatCode: includeChatCode ? row.chatCode : undefined,
|
||||
mapModePreferences: row.mapModePreferences?.map(
|
||||
(c) => c.mapModePreferences,
|
||||
) as NonNullable<Tables["User"]["mapModePreferences"]>[],
|
||||
members: row.members.map((member) => {
|
||||
return {
|
||||
...member,
|
||||
languages: member.languages ? member.languages.split(",") : [],
|
||||
} as LookingGroupWithInviteCode["members"][number];
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((group) => {
|
||||
if (group.id === ownGroupId) return true;
|
||||
if (maxGroupSize && group.members.length > maxGroupSize) return false;
|
||||
if (minGroupSize && group.members.length < minGroupSize) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
type CreateGroupArgs = {
|
||||
status: Exclude<Tables["Group"]["status"], "INACTIVE">;
|
||||
userId: number;
|
||||
};
|
||||
export function createGroup(args: CreateGroupArgs) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const createdGroup = await trx
|
||||
.insertInto("Group")
|
||||
.values({
|
||||
inviteCode: nanoid(INVITE_CODE_LENGTH),
|
||||
chatCode: nanoid(INVITE_CODE_LENGTH),
|
||||
status: args.status,
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.insertInto("GroupMember")
|
||||
.values({
|
||||
groupId: createdGroup.id,
|
||||
userId: args.userId,
|
||||
role: "OWNER",
|
||||
})
|
||||
.execute();
|
||||
|
||||
return createdGroup;
|
||||
});
|
||||
}
|
||||
|
||||
type CreateGroupFromPreviousGroupArgs = {
|
||||
previousGroupId: number;
|
||||
members: {
|
||||
id: number;
|
||||
role: Tables["GroupMember"]["role"];
|
||||
}[];
|
||||
};
|
||||
export async function createGroupFromPrevious(
|
||||
args: CreateGroupFromPreviousGroupArgs,
|
||||
) {
|
||||
return db.transaction().execute(async (trx) => {
|
||||
const createdGroup = await trx
|
||||
.insertInto("Group")
|
||||
.columns(["teamId", "chatCode", "inviteCode", "status"])
|
||||
.expression((eb) =>
|
||||
eb
|
||||
.selectFrom("Group")
|
||||
.select((eb) => [
|
||||
"Group.teamId",
|
||||
"Group.chatCode",
|
||||
eb.val(nanoid(INVITE_CODE_LENGTH)).as("inviteCode"),
|
||||
eb.val("PREPARING").as("status"),
|
||||
])
|
||||
.where("Group.id", "=", args.previousGroupId),
|
||||
)
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await trx
|
||||
.insertInto("GroupMember")
|
||||
.values(
|
||||
args.members.map((member) => ({
|
||||
groupId: createdGroup.id,
|
||||
userId: member.id,
|
||||
role: member.role,
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
|
||||
return createdGroup;
|
||||
});
|
||||
}
|
||||
|
||||
export function upsertPrivateUserNote(
|
||||
args: TablesInsertable["PrivateUserNote"],
|
||||
) {
|
||||
return db
|
||||
.insertInto("PrivateUserNote")
|
||||
.values({
|
||||
authorId: args.authorId,
|
||||
targetId: args.targetId,
|
||||
sentiment: args.sentiment,
|
||||
text: args.text,
|
||||
})
|
||||
.onConflict((oc) =>
|
||||
oc.columns(["authorId", "targetId"]).doUpdateSet({
|
||||
sentiment: args.sentiment,
|
||||
text: args.text,
|
||||
updatedAt: dateToDatabaseTimestamp(new Date()),
|
||||
}),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export function deletePrivateUserNote({
|
||||
authorId,
|
||||
targetId,
|
||||
}: {
|
||||
authorId: number;
|
||||
targetId: number;
|
||||
}) {
|
||||
return db
|
||||
.deleteFrom("PrivateUserNote")
|
||||
.where("authorId", "=", authorId)
|
||||
.where("targetId", "=", targetId)
|
||||
.execute();
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Link, useFetcher } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Image, TierImage, WeaponImage } from "~/components/Image";
|
||||
import { Button, LinkButton } from "~/components/Button";
|
||||
import { Image, ModeImage, TierImage, WeaponImage } from "~/components/Image";
|
||||
import { Popover } from "~/components/Popover";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { MicrophoneIcon } from "~/components/icons/Microphone";
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
SENDOUQ_LOOKING_PAGE,
|
||||
TIERS_PAGE,
|
||||
navIconUrl,
|
||||
tierImageUrl,
|
||||
userPage,
|
||||
} from "~/utils/urls";
|
||||
import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants";
|
||||
@@ -26,6 +27,13 @@ import { StarIcon } from "~/components/icons/Star";
|
||||
import { StarFilledIcon } from "~/components/icons/StarFilled";
|
||||
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
|
||||
import * as React from "react";
|
||||
import type { SqlBool } from "kysely";
|
||||
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
|
||||
import { Flipped } from "react-flip-toolkit";
|
||||
import { EditIcon } from "~/components/icons/Edit";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { TrashIcon } from "~/components/icons/Trash";
|
||||
|
||||
export function GroupCard({
|
||||
group,
|
||||
@@ -38,6 +46,8 @@ export function GroupCard({
|
||||
hideWeapons = false,
|
||||
hideNote: _hidenote = false,
|
||||
enableKicking,
|
||||
showAddNote,
|
||||
showNote = false,
|
||||
}: {
|
||||
group: Omit<LookingGroup, "createdAt" | "chatCode">;
|
||||
action?: "LIKE" | "UNLIKE" | "GROUP_UP" | "MATCH_UP";
|
||||
@@ -45,11 +55,15 @@ export function GroupCard({
|
||||
ownGroup?: boolean;
|
||||
isExpired?: boolean;
|
||||
displayOnly?: boolean;
|
||||
hideVc?: boolean;
|
||||
hideWeapons?: boolean;
|
||||
hideVc?: SqlBool;
|
||||
hideWeapons?: SqlBool;
|
||||
hideNote?: boolean;
|
||||
enableKicking?: boolean;
|
||||
showAddNote?: SqlBool;
|
||||
showNote?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const user = useUser();
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const hideNote =
|
||||
@@ -59,86 +73,93 @@ export function GroupCard({
|
||||
_hidenote;
|
||||
|
||||
return (
|
||||
<section
|
||||
className={clsx("q__group", { "q__group__display-only": displayOnly })}
|
||||
>
|
||||
<div
|
||||
className={clsx("stack md", {
|
||||
"horizontal justify-center": !group.members,
|
||||
})}
|
||||
<Flipped flipId={group.id}>
|
||||
<section
|
||||
className={clsx("q__group", { "q__group__display-only": displayOnly })}
|
||||
>
|
||||
{group.members?.map((member) => {
|
||||
return (
|
||||
<GroupMember
|
||||
member={member}
|
||||
showActions={ownGroup && ownRole === "OWNER"}
|
||||
key={member.discordId}
|
||||
displayOnly={displayOnly}
|
||||
hideVc={hideVc}
|
||||
hideWeapons={hideWeapons}
|
||||
hideNote={hideNote}
|
||||
enableKicking={enableKicking}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{!group.members
|
||||
? new Array(FULL_GROUP_SIZE).fill(null).map((_, i) => {
|
||||
{group.members ? (
|
||||
<div className="stack md">
|
||||
{group.members.map((member) => {
|
||||
return (
|
||||
<div key={i} className="q__member-placeholder">
|
||||
?
|
||||
<GroupMember
|
||||
member={member}
|
||||
showActions={ownGroup && ownRole === "OWNER"}
|
||||
key={member.discordId}
|
||||
displayOnly={displayOnly}
|
||||
hideVc={hideVc}
|
||||
hideWeapons={hideWeapons}
|
||||
hideNote={hideNote}
|
||||
enableKicking={enableKicking}
|
||||
showNote={showNote}
|
||||
showAddNote={showAddNote && member.id !== user?.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{group.futureMatchModes ? (
|
||||
<div className="stack horizontal sm justify-center">
|
||||
{group.futureMatchModes.map((mode) => {
|
||||
return (
|
||||
<div key={mode} className="q__group__future-match-mode">
|
||||
<ModeImage mode={mode} />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
</div>
|
||||
{group.tier && !displayOnly ? (
|
||||
<div className="stack xs text-lighter font-bold items-center justify-center text-xs">
|
||||
<TierImage tier={group.tier} width={100} />
|
||||
<div>
|
||||
{group.tier.name}
|
||||
{group.tier.isPlus ? "+" : ""}{" "}
|
||||
{group.isReplay ? (
|
||||
<>
|
||||
/ <span className="text-theme-secondary">REPLAY</span>
|
||||
</>
|
||||
) : null}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{group.tier && displayOnly ? (
|
||||
<div className="q__group__display-group-tier">
|
||||
<TierImage tier={group.tier} width={38} />
|
||||
{group.tier.name}
|
||||
{group.tier.isPlus ? "+" : ""}
|
||||
</div>
|
||||
) : null}
|
||||
{group.skillDifference ? (
|
||||
<GroupSkillDifference skillDifference={group.skillDifference} />
|
||||
) : null}
|
||||
{action &&
|
||||
(ownRole === "OWNER" || ownRole === "MANAGER") &&
|
||||
!isExpired ? (
|
||||
<fetcher.Form className="stack items-center" method="post">
|
||||
<input type="hidden" name="targetGroupId" value={group.id} />
|
||||
<SubmitButton
|
||||
size="tiny"
|
||||
variant={action === "UNLIKE" ? "destructive" : "outlined"}
|
||||
_action={action}
|
||||
state={fetcher.state}
|
||||
>
|
||||
{action === "MATCH_UP"
|
||||
? "Start match"
|
||||
: action === "LIKE" && !group.members
|
||||
? "Challenge"
|
||||
: action === "LIKE"
|
||||
? "Invite"
|
||||
: action === "GROUP_UP"
|
||||
? "Group up"
|
||||
: "Undo"}
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
{group.tier && !displayOnly ? (
|
||||
<div className="stack xs text-lighter font-bold items-center justify-center text-xs">
|
||||
<TierImage tier={group.tier} width={100} />
|
||||
<div>
|
||||
{group.tier.name}
|
||||
{group.tier.isPlus ? "+" : ""}{" "}
|
||||
{group.isReplay ? (
|
||||
<>
|
||||
/{" "}
|
||||
<span className="text-theme-secondary text-uppercase">
|
||||
{t("q:looking.replay")}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{group.tier && displayOnly ? (
|
||||
<div className="q__group__display-group-tier">
|
||||
<TierImage tier={group.tier} width={38} />
|
||||
{group.tier.name}
|
||||
{group.tier.isPlus ? "+" : ""}
|
||||
</div>
|
||||
) : null}
|
||||
{group.skillDifference ? (
|
||||
<GroupSkillDifference skillDifference={group.skillDifference} />
|
||||
) : null}
|
||||
{action &&
|
||||
(ownRole === "OWNER" || ownRole === "MANAGER") &&
|
||||
!isExpired ? (
|
||||
<fetcher.Form className="stack items-center" method="post">
|
||||
<input type="hidden" name="targetGroupId" value={group.id} />
|
||||
<SubmitButton
|
||||
size="tiny"
|
||||
variant={action === "UNLIKE" ? "destructive" : "outlined"}
|
||||
_action={action}
|
||||
state={fetcher.state}
|
||||
>
|
||||
{action === "MATCH_UP"
|
||||
? t("q:looking.groups.actions.startMatch")
|
||||
: action === "LIKE" && !group.members
|
||||
? t("q:looking.groups.actions.challenge")
|
||||
: action === "LIKE"
|
||||
? t("q:looking.groups.actions.invite")
|
||||
: action === "GROUP_UP"
|
||||
? t("q:looking.groups.actions.groupUp")
|
||||
: t("q:looking.groups.actions.undo")}
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
) : null}
|
||||
</section>
|
||||
</Flipped>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -150,37 +171,85 @@ function GroupMember({
|
||||
hideWeapons,
|
||||
hideNote,
|
||||
enableKicking,
|
||||
showAddNote,
|
||||
showNote,
|
||||
}: {
|
||||
member: NonNullable<LookingGroup["members"]>[number];
|
||||
showActions: boolean;
|
||||
displayOnly?: boolean;
|
||||
hideVc?: boolean;
|
||||
hideWeapons?: boolean;
|
||||
hideVc?: SqlBool;
|
||||
hideWeapons?: SqlBool;
|
||||
hideNote?: boolean;
|
||||
enableKicking?: boolean;
|
||||
showAddNote?: SqlBool;
|
||||
showNote?: boolean;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["q", "user"]);
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<div className="stack xxs">
|
||||
<div className="q__group-member">
|
||||
<Link
|
||||
to={userPage(member)}
|
||||
className="text-main-forced stack xs horizontal items-center"
|
||||
target="_blank"
|
||||
>
|
||||
<Avatar user={member} size="xs" />
|
||||
<span className="q__group-member__name">
|
||||
<div className="text-main-forced stack xs horizontal items-center">
|
||||
{showNote && member.privateNote ? (
|
||||
<Popover
|
||||
buttonChildren={
|
||||
<>
|
||||
<Avatar
|
||||
user={member}
|
||||
size="xs"
|
||||
className={clsx(
|
||||
"q__group-member__avatar",
|
||||
`q__group-member__avatar__${member.privateNote.sentiment}`,
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{member.privateNote.text}
|
||||
<div
|
||||
className={clsx(
|
||||
"stack sm horizontal justify-between items-center",
|
||||
{ "mt-2": member.privateNote.text },
|
||||
)}
|
||||
>
|
||||
<div className="text-xxs text-lighter">
|
||||
{databaseTimestampToDate(
|
||||
member.privateNote.updatedAt,
|
||||
).toLocaleString(i18n.language, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
day: "numeric",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
})}
|
||||
</div>
|
||||
<DeletePrivateNoteForm
|
||||
name={member.discordName}
|
||||
targetId={member.id}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
) : (
|
||||
<Avatar user={member} size="xs" />
|
||||
)}
|
||||
<Link
|
||||
to={userPage(member)}
|
||||
className="q__group-member__name"
|
||||
target="_blank"
|
||||
>
|
||||
{member.inGameName ? (
|
||||
<>
|
||||
<span className="text-lighter font-bold text-xxxs">IGN:</span>{" "}
|
||||
<span className="text-lighter font-bold text-xxxs">
|
||||
{t("user:ign.short")}:
|
||||
</span>{" "}
|
||||
{inGameNameWithoutDiscriminator(member.inGameName)}
|
||||
</>
|
||||
) : (
|
||||
member.discordName
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="ml-auto stack horizontal sm items-center">
|
||||
{showActions || displayOnly ? (
|
||||
<MemberRoleManager
|
||||
@@ -205,6 +274,19 @@ function GroupMember({
|
||||
{member.plusTier}
|
||||
</div>
|
||||
) : null}
|
||||
{showAddNote ? (
|
||||
<LinkButton
|
||||
to={`?note=${member.id}`}
|
||||
icon={<EditIcon />}
|
||||
className={clsx("q__group-member__add-note-button", {
|
||||
"q__group-member__add-note-button__edit": member.privateNote,
|
||||
})}
|
||||
>
|
||||
{member.privateNote
|
||||
? t("q:looking.groups.editNote")
|
||||
: t("q:looking.groups.addNote")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
</div>
|
||||
{member.weapons && member.weapons.length > 0 && !hideWeapons ? (
|
||||
<div className="q__group-member__extra-info">
|
||||
@@ -238,6 +320,7 @@ function MemberNote({
|
||||
note?: string | null;
|
||||
editable: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["common", "q"]);
|
||||
const fetcher = useFetcher();
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const [value, setValue] = React.useState(note ?? "");
|
||||
@@ -253,7 +336,7 @@ function MemberNote({
|
||||
stopEditing();
|
||||
}, [stopEditing]);
|
||||
|
||||
const newValueLegal = value.length <= SENDOUQ.NOTE_MAX_LENGTH;
|
||||
const newValueLegal = value.length <= SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH;
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
@@ -271,7 +354,7 @@ function MemberNote({
|
||||
size="miniscule"
|
||||
onClick={stopEditing}
|
||||
>
|
||||
Cancel
|
||||
{t("common:actions.cancel")}
|
||||
</Button>
|
||||
{newValueLegal ? (
|
||||
<SubmitButton
|
||||
@@ -279,11 +362,11 @@ function MemberNote({
|
||||
variant="minimal"
|
||||
size="miniscule"
|
||||
>
|
||||
Save
|
||||
{t("common:actions.save")}
|
||||
</SubmitButton>
|
||||
) : (
|
||||
<span className="text-warning text-xxs font-semi-bold">
|
||||
{value.length}/{SENDOUQ.NOTE_MAX_LENGTH}
|
||||
{value.length}/{SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -302,7 +385,7 @@ function MemberNote({
|
||||
onClick={startEditing}
|
||||
className="mt-2 ml-auto"
|
||||
>
|
||||
Edit note
|
||||
{t("q:looking.groups.editNote")}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -313,11 +396,35 @@ function MemberNote({
|
||||
|
||||
return (
|
||||
<Button variant="minimal" size="miniscule" onClick={startEditing}>
|
||||
Add note
|
||||
{t("q:looking.groups.addNote")}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function DeletePrivateNoteForm({
|
||||
targetId,
|
||||
name,
|
||||
}: {
|
||||
targetId: number;
|
||||
name: string;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("q:privateNote.delete.header", { name })}
|
||||
fields={[
|
||||
["targetId", targetId],
|
||||
["_action", "DELETE_PRIVATE_USER_NOTE"],
|
||||
]}
|
||||
>
|
||||
<SubmitButton variant="minimal-destructive" size="tiny" type="submit">
|
||||
<TrashIcon className="build__icon" />
|
||||
</SubmitButton>
|
||||
</FormWithConfirm>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupSkillDifference({
|
||||
skillDifference,
|
||||
}: {
|
||||
@@ -325,10 +432,13 @@ function GroupSkillDifference({
|
||||
ParsedMemento["groups"][number]["skillDifference"]
|
||||
>;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
if (skillDifference.calculated) {
|
||||
return (
|
||||
<div className="text-center font-semi-bold">
|
||||
Team SP {skillDifference.oldSp} ➜ {skillDifference.newSp}
|
||||
{t("q:looking.teamSP")} {skillDifference.oldSp} ➜{" "}
|
||||
{skillDifference.newSp}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -336,14 +446,14 @@ function GroupSkillDifference({
|
||||
if (skillDifference.newSp) {
|
||||
return (
|
||||
<div className="text-center font-semi-bold">
|
||||
Team SP calculated: {skillDifference.newSp}
|
||||
{t("q:looking.teamSP.calculated")}: {skillDifference.newSp}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center font-semi-bold">
|
||||
Team SP calculating... ({skillDifference.matchesCount}/
|
||||
{t("q:looking.teamSP.calculating")} ({skillDifference.matchesCount}/
|
||||
{skillDifference.matchesCountNeeded})
|
||||
</div>
|
||||
);
|
||||
@@ -356,6 +466,8 @@ function MemberSkillDifference({
|
||||
ParsedMemento["users"][number]["skillDifference"]
|
||||
>;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
if (skillDifference.calculated) {
|
||||
if (skillDifference.spDiff === 0) return null;
|
||||
|
||||
@@ -376,7 +488,7 @@ function MemberSkillDifference({
|
||||
if (skillDifference.matchesCount === skillDifference.matchesCountNeeded) {
|
||||
return (
|
||||
<div className="q__group-member__extra-info">
|
||||
<span className="text-lighter">Calculated:</span>{" "}
|
||||
<span className="text-lighter">{t("q:looking.sp.calculated")}:</span>{" "}
|
||||
{skillDifference.newSp ? <>{skillDifference.newSp}SP</> : null}
|
||||
</div>
|
||||
);
|
||||
@@ -384,7 +496,7 @@ function MemberSkillDifference({
|
||||
|
||||
return (
|
||||
<div className="q__group-member__extra-info">
|
||||
<span className="text-lighter">Calculating...</span> (
|
||||
<span className="text-lighter">{t("q:looking.sp.calculating")}</span> (
|
||||
{skillDifference.matchesCount}/{skillDifference.matchesCountNeeded})
|
||||
</div>
|
||||
);
|
||||
@@ -432,7 +544,7 @@ function MemberRoleManager({
|
||||
_action="GIVE_MANAGER"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Give manager
|
||||
{t("q:looking.groups.actions.giveManager")}
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
{member.role === "MANAGER" ? (
|
||||
@@ -442,7 +554,7 @@ function MemberRoleManager({
|
||||
_action="REMOVE_MANAGER"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Remove manager
|
||||
{t("q:looking.groups.actions.removeManager")}
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
{enableKicking && member.id !== loggedInUser?.id ? (
|
||||
@@ -452,7 +564,7 @@ function MemberRoleManager({
|
||||
_action="KICK_FROM_GROUP"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Kick
|
||||
{t("q:looking.groups.actions.kick")}
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
</fetcher.Form>
|
||||
@@ -462,7 +574,30 @@ function MemberRoleManager({
|
||||
);
|
||||
}
|
||||
|
||||
function TierInfo({ skill }: { skill: TieredSkill }) {
|
||||
function TierInfo({ skill }: { skill: TieredSkill | "CALCULATING" }) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
if (skill === "CALCULATING") {
|
||||
return (
|
||||
<div className="q__group-member__tier">
|
||||
<Popover
|
||||
buttonChildren={
|
||||
<Image
|
||||
path={tierImageUrl("CALCULATING")}
|
||||
alt=""
|
||||
height={32.965}
|
||||
className="q__group-member__tier__placeholder"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t("q:looking.rankCalculating", {
|
||||
count: MATCHES_COUNT_NEEDED_FOR_LEADERBOARD,
|
||||
})}
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="q__group-member__tier">
|
||||
<Popover buttonChildren={<TierImage tier={skill.tier} width={38} />}>
|
||||
@@ -474,7 +609,7 @@ function TierInfo({ skill }: { skill: TieredSkill }) {
|
||||
{skill.tier.isPlus ? "+" : ""}
|
||||
</div>
|
||||
<Link to={TIERS_PAGE} className="text-xxs" target="_blank">
|
||||
All tiers
|
||||
{t("q:looking.allTiers")}
|
||||
</Link>
|
||||
</div>
|
||||
{!skill.approximate ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useFetcher } from "@remix-run/react";
|
||||
import { Button } from "~/components/Button";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { SENDOUQ_LOOKING_PAGE } from "~/utils/urls";
|
||||
|
||||
export function GroupLeaver({
|
||||
@@ -9,6 +10,7 @@ export function GroupLeaver({
|
||||
}: {
|
||||
type: "LEAVE_GROUP" | "LEAVE_Q" | "GO_BACK";
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
if (type === "LEAVE_GROUP") {
|
||||
@@ -20,7 +22,7 @@ export function GroupLeaver({
|
||||
action={SENDOUQ_LOOKING_PAGE}
|
||||
>
|
||||
<Button variant="minimal-destructive" size="tiny">
|
||||
Leave group
|
||||
{t("q:looking.groups.actions.leaveGroup")}
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
);
|
||||
@@ -35,7 +37,9 @@ export function GroupLeaver({
|
||||
size="tiny"
|
||||
state={fetcher.state}
|
||||
>
|
||||
{type === "LEAVE_Q" ? "Leave queue" : "Go back"}
|
||||
{type === "LEAVE_Q"
|
||||
? t("q:looking.groups.actions.leaveQ")
|
||||
: t("q:looking.groups.actions.goBack")}
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import * as React from "react";
|
||||
import { ClipboardIcon } from "~/components/icons/Clipboard";
|
||||
import { PlusIcon } from "~/components/icons/Plus";
|
||||
import { CheckmarkIcon } from "~/components/icons/Checkmark";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
|
||||
export function MemberAdder({
|
||||
inviteCode,
|
||||
@@ -22,6 +23,7 @@ export function MemberAdder({
|
||||
discordName: string;
|
||||
}>;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const [trustedUser, setTrustedUser] = React.useState<number>();
|
||||
const fetcher = useFetcher();
|
||||
const inviteLink = `${SENDOU_INK_BASE_URL}${sendouQInviteLink(inviteCode)}`;
|
||||
@@ -46,7 +48,9 @@ export function MemberAdder({
|
||||
<div className="stack md flex-wrap justify-center">
|
||||
{trustedPlayers.length > 0 ? (
|
||||
<fetcher.Form method="post" action={SENDOUQ_PREPARING_PAGE}>
|
||||
<label htmlFor="players">Quick add</label>
|
||||
<label htmlFor="players">
|
||||
{t("q:looking.groups.adder.quickAdd")}
|
||||
</label>
|
||||
<div className="stack horizontal sm items-center">
|
||||
<select
|
||||
name="id"
|
||||
@@ -58,7 +62,7 @@ export function MemberAdder({
|
||||
}
|
||||
className="q__member-adder__input"
|
||||
>
|
||||
<option value="">Select user</option>
|
||||
<option value="">{t("q:looking.groups.adder.selectUser")}</option>
|
||||
{trustedPlayers.map((player) => {
|
||||
return (
|
||||
<option key={player.id} value={player.id}>
|
||||
@@ -77,7 +81,7 @@ export function MemberAdder({
|
||||
</fetcher.Form>
|
||||
) : null}
|
||||
<div>
|
||||
<label htmlFor="invite">Invite link</label>
|
||||
<label htmlFor="invite">{t("q:looking.groups.adder.inviteLink")}</label>
|
||||
<div className="stack horizontal sm items-center">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { ModeImage } from "~/components/Image";
|
||||
import type { Group } from "~/db/types";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
|
||||
const SZOnly = () => <ModeImage mode="SZ" size={16} />;
|
||||
|
||||
const AllModes = () => (
|
||||
<>
|
||||
<ModeImage mode="SZ" size={16} />
|
||||
<ModeImage mode="TC" size={16} />
|
||||
<ModeImage mode="RM" size={16} />
|
||||
<ModeImage mode="CB" size={16} />
|
||||
</>
|
||||
);
|
||||
|
||||
export function ModePreferenceIcons({
|
||||
preference,
|
||||
}: {
|
||||
preference: Group["mapListPreference"];
|
||||
}) {
|
||||
const comparisonSign = (() => {
|
||||
switch (preference) {
|
||||
case "SZ_ONLY":
|
||||
case "ALL_MODES_ONLY":
|
||||
return null;
|
||||
case "NO_PREFERENCE":
|
||||
return "=";
|
||||
case "PREFER_ALL_MODES":
|
||||
case "PREFER_SZ":
|
||||
return ">";
|
||||
default:
|
||||
assertUnreachable(preference);
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
<>
|
||||
{["SZ_ONLY", "PREFER_SZ"].includes(preference) ? <SZOnly /> : null}
|
||||
{["ALL_MODES_ONLY", "PREFER_ALL_MODES", "NO_PREFERENCE"].includes(
|
||||
preference,
|
||||
) ? (
|
||||
<AllModes />
|
||||
) : null}
|
||||
{comparisonSign ? (
|
||||
<span className="text-main-forced">{comparisonSign}</span>
|
||||
) : null}
|
||||
{["PREFER_SZ"].includes(preference) ? <AllModes /> : null}
|
||||
{["PREFER_ALL_MODES", "NO_PREFERENCE"].includes(preference) ? (
|
||||
<SZOnly />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import type {
|
||||
} from "~/features/mmr/tiered.server";
|
||||
import type { RecentMatchPlayer } from "../queries/findRecentMatchPlayersByUserId.server";
|
||||
import { TIERS } from "~/features/mmr/mmr-constants";
|
||||
import { mapModePreferencesToModeList } from "./match.server";
|
||||
import { modesShort } from "~/modules/in-game-lists";
|
||||
import { defaultOrdinal } from "~/features/mmr/mmr-utils";
|
||||
|
||||
export function divideGroups({
|
||||
groups,
|
||||
@@ -70,31 +73,6 @@ export function divideGroups({
|
||||
};
|
||||
}
|
||||
|
||||
export function filterOutGroupsWithIncompatibleMapListPreference(
|
||||
groups: DividedGroupsUncensored,
|
||||
): DividedGroupsUncensored {
|
||||
if (
|
||||
groups.own.mapListPreference !== "SZ_ONLY" &&
|
||||
groups.own.mapListPreference !== "ALL_MODES_ONLY"
|
||||
) {
|
||||
return groups;
|
||||
}
|
||||
|
||||
return {
|
||||
...groups,
|
||||
neutral: groups.neutral.filter((group) => {
|
||||
if (
|
||||
group.mapListPreference !== "SZ_ONLY" &&
|
||||
group.mapListPreference !== "ALL_MODES_ONLY"
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return group.mapListPreference === groups.own.mapListPreference;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const MIN_PLAYERS_FOR_REPLAY = 3;
|
||||
export function addReplayIndicator({
|
||||
groups,
|
||||
@@ -134,16 +112,46 @@ export function addReplayIndicator({
|
||||
};
|
||||
}
|
||||
|
||||
export function addFutureMatchModes(
|
||||
groups: DividedGroupsUncensored,
|
||||
): DividedGroupsUncensored {
|
||||
const ownModePreferences = groups.own.mapModePreferences?.map((p) => p.modes);
|
||||
if (!ownModePreferences) return groups;
|
||||
|
||||
const futureMatchModes = (group: LookingGroupWithInviteCode) => {
|
||||
const theirModePreferences = group.mapModePreferences?.map((p) => p.modes);
|
||||
if (!theirModePreferences) return;
|
||||
|
||||
return mapModePreferencesToModeList(
|
||||
ownModePreferences,
|
||||
theirModePreferences,
|
||||
).sort((a, b) => modesShort.indexOf(a) - modesShort.indexOf(b));
|
||||
};
|
||||
|
||||
return {
|
||||
own: groups.own,
|
||||
likesReceived: groups.likesReceived.map((g) => ({
|
||||
...g,
|
||||
futureMatchModes: futureMatchModes(g),
|
||||
})),
|
||||
neutral: groups.neutral.map((g) => ({
|
||||
...g,
|
||||
futureMatchModes: futureMatchModes(g),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const censorGroupFully = ({
|
||||
inviteCode: _inviteCode,
|
||||
mapModePreferences: _mapModePreferences,
|
||||
...group
|
||||
}: LookingGroupWithInviteCode): LookingGroup => ({
|
||||
...group,
|
||||
members: undefined,
|
||||
mapListPreference: undefined,
|
||||
});
|
||||
const censorGroupPartly = ({
|
||||
inviteCode: _inviteCode,
|
||||
mapModePreferences: _mapModePreferences,
|
||||
...group
|
||||
}: LookingGroupWithInviteCode): LookingGroup => group;
|
||||
export function censorGroups({
|
||||
@@ -166,7 +174,7 @@ export function censorGroups({
|
||||
};
|
||||
}
|
||||
|
||||
export function sortGroupsBySkill({
|
||||
export function sortGroupsBySkillAndSentiment({
|
||||
groups,
|
||||
userSkills,
|
||||
intervals,
|
||||
@@ -194,6 +202,18 @@ export function sortGroupsBySkill({
|
||||
return Math.abs(ownGroupTierIndex - otherGroupTierIndex);
|
||||
};
|
||||
|
||||
const groupSentiment = (group: LookingGroup) => {
|
||||
if (group.members?.some((m) => m.privateNote?.sentiment === "NEGATIVE")) {
|
||||
return "NEGATIVE";
|
||||
}
|
||||
|
||||
if (group.members?.some((m) => m.privateNote?.sentiment === "POSITIVE")) {
|
||||
return "POSITIVE";
|
||||
}
|
||||
|
||||
return "NEUTRAL";
|
||||
};
|
||||
|
||||
return {
|
||||
...groups,
|
||||
neutral: groups.neutral.sort((a, b) => {
|
||||
@@ -212,6 +232,16 @@ export function sortGroupsBySkill({
|
||||
intervals,
|
||||
})?.name;
|
||||
|
||||
const aSentiment = groupSentiment(a);
|
||||
const bSentiment = groupSentiment(b);
|
||||
|
||||
if (aSentiment !== bSentiment) {
|
||||
if (aSentiment === "NEGATIVE") return 1;
|
||||
if (bSentiment === "NEGATIVE") return -1;
|
||||
if (aSentiment === "POSITIVE") return -1;
|
||||
if (bSentiment === "POSITIVE") return 1;
|
||||
}
|
||||
|
||||
const aTierDiff = tierDiff(aTier);
|
||||
const bTierDiff = tierDiff(bTier);
|
||||
|
||||
@@ -237,10 +267,14 @@ export function addSkillsToGroups({
|
||||
}): DividedGroupsUncensored {
|
||||
const addSkill = (group: LookingGroupWithInviteCode) => ({
|
||||
...group,
|
||||
members: group.members?.map((m) => ({
|
||||
...m,
|
||||
skill: userSkills[String(m.id)],
|
||||
})),
|
||||
members: group.members?.map((m) => {
|
||||
const skill = userSkills[String(m.id)];
|
||||
|
||||
return {
|
||||
...m,
|
||||
skill: !skill || skill.approximate ? ("CALCULATING" as const) : skill,
|
||||
};
|
||||
}),
|
||||
tier:
|
||||
group.members.length === FULL_GROUP_SIZE
|
||||
? resolveGroupSkill({ group, userSkills, intervals })
|
||||
@@ -267,9 +301,10 @@ function resolveGroupSkill({
|
||||
userSkills: Record<string, TieredSkill>;
|
||||
intervals: SkillTierInterval[];
|
||||
}): TieredSkill["tier"] | undefined {
|
||||
const skills = group.members
|
||||
.map((m) => userSkills[String(m.id)])
|
||||
.filter(Boolean);
|
||||
const skills = group.members.map(
|
||||
(m) => userSkills[String(m.id)] ?? { ordinal: defaultOrdinal() },
|
||||
);
|
||||
|
||||
const averageOrdinal =
|
||||
skills.reduce((acc, s) => acc + s.ordinal, 0) / skills.length;
|
||||
|
||||
|
||||
@@ -16,9 +16,6 @@ export function groupAfterMorph({
|
||||
const ourMembers = ourGroup.members ?? [];
|
||||
const theirMembers = theirGroup.members ?? [];
|
||||
|
||||
// if one group is full no mapListPreference is returned and we are not gonna morph anything anymore
|
||||
if (!theirGroup.mapListPreference) return theirGroup;
|
||||
|
||||
if (ourMembers.length > theirMembers.length) {
|
||||
return ourGroup;
|
||||
}
|
||||
|
||||
291
app/features/sendouq/core/match.server.test.ts
Normal file
@@ -0,0 +1,291 @@
|
||||
import { suite } from "uvu";
|
||||
import * as assert from "uvu/assert";
|
||||
import {
|
||||
mapModePreferencesToModeList,
|
||||
mapPoolFromPreferences,
|
||||
} from "./match.server";
|
||||
import * as Test from "~/utils/Test";
|
||||
import { type ModeShort, stageIds } from "~/modules/in-game-lists";
|
||||
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
|
||||
|
||||
const MapModePreferencesToModeList = suite("mapModePreferencesToModeList()");
|
||||
const MapPoolFromPreferences = suite("mapPoolFromPreferences()");
|
||||
|
||||
MapModePreferencesToModeList("returns default list if no preferences", () => {
|
||||
const modeList = mapModePreferencesToModeList([], []);
|
||||
|
||||
assert.ok(Test.arrayContainsSameItems(["SZ", "TC", "RM", "CB"], modeList));
|
||||
});
|
||||
|
||||
MapModePreferencesToModeList(
|
||||
"returns default list if equally disliking everything",
|
||||
() => {
|
||||
const dislikingEverything = [
|
||||
{ mode: "TW", preference: "AVOID" } as const,
|
||||
{ mode: "SZ", preference: "AVOID" } as const,
|
||||
{ mode: "TC", preference: "AVOID" } as const,
|
||||
{ mode: "RM", preference: "AVOID" } as const,
|
||||
{ mode: "CB", preference: "AVOID" } as const,
|
||||
];
|
||||
|
||||
const modeList = mapModePreferencesToModeList(
|
||||
[
|
||||
dislikingEverything,
|
||||
dislikingEverything,
|
||||
dislikingEverything,
|
||||
dislikingEverything,
|
||||
],
|
||||
[
|
||||
dislikingEverything,
|
||||
dislikingEverything,
|
||||
dislikingEverything,
|
||||
dislikingEverything,
|
||||
],
|
||||
);
|
||||
|
||||
assert.ok(Test.arrayContainsSameItems(["SZ", "TC", "RM", "CB"], modeList));
|
||||
},
|
||||
);
|
||||
|
||||
MapModePreferencesToModeList(
|
||||
"if positive about nothing, choose the most liked (-TW)",
|
||||
() => {
|
||||
const modeList = mapModePreferencesToModeList(
|
||||
[[{ mode: "SZ", preference: "AVOID" }]],
|
||||
[],
|
||||
);
|
||||
|
||||
assert.ok(Test.arrayContainsSameItems(["TC", "RM", "CB"], modeList));
|
||||
},
|
||||
);
|
||||
|
||||
MapModePreferencesToModeList(
|
||||
"only turf war possible to get if least bad option",
|
||||
() => {
|
||||
const modeList = mapModePreferencesToModeList(
|
||||
[
|
||||
[
|
||||
{ mode: "SZ", preference: "AVOID" },
|
||||
{ mode: "TC", preference: "AVOID" },
|
||||
{ mode: "RM", preference: "AVOID" },
|
||||
{ mode: "CB", preference: "AVOID" },
|
||||
{ mode: "TW", preference: "AVOID" },
|
||||
],
|
||||
[{ mode: "TW", preference: "PREFER" }],
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
assert.ok(Test.arrayContainsSameItems(["TW"], modeList));
|
||||
},
|
||||
);
|
||||
|
||||
MapModePreferencesToModeList("team votes for their preference", () => {
|
||||
const modeList = mapModePreferencesToModeList(
|
||||
[
|
||||
[
|
||||
{ mode: "SZ", preference: "PREFER" },
|
||||
{ mode: "TC", preference: "PREFER" },
|
||||
],
|
||||
[{ mode: "TC", preference: "PREFER" }],
|
||||
[{ mode: "TC", preference: "AVOID" }],
|
||||
[{ mode: "TC", preference: "PREFER" }],
|
||||
],
|
||||
[
|
||||
[{ mode: "TC", preference: "PREFER" }],
|
||||
[{ mode: "TC", preference: "PREFER" }],
|
||||
[{ mode: "TC", preference: "AVOID" }],
|
||||
[{ mode: "TC", preference: "AVOID" }],
|
||||
],
|
||||
);
|
||||
|
||||
assert.ok(Test.arrayContainsSameItems(["SZ", "TC"], modeList));
|
||||
});
|
||||
|
||||
MapModePreferencesToModeList(
|
||||
"favorite ranked mode sorted first in the array",
|
||||
() => {
|
||||
assert.equal(
|
||||
mapModePreferencesToModeList(
|
||||
[[{ mode: "TC", preference: "PREFER" }]],
|
||||
[],
|
||||
)[0],
|
||||
"TC",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MapModePreferencesToModeList(
|
||||
"includes turf war if more prefer than want to avoid",
|
||||
() => {
|
||||
const modeList = mapModePreferencesToModeList(
|
||||
[[{ mode: "TW", preference: "PREFER" }]],
|
||||
[[{ mode: "SZ", preference: "PREFER" }]],
|
||||
);
|
||||
|
||||
assert.ok(Test.arrayContainsSameItems(["TW", "SZ"], modeList));
|
||||
},
|
||||
);
|
||||
|
||||
MapModePreferencesToModeList("doesn't include turf war if mixed", () => {
|
||||
const modeList = mapModePreferencesToModeList(
|
||||
[[{ mode: "TW", preference: "PREFER" }]],
|
||||
[[{ mode: "TW", preference: "AVOID" }]],
|
||||
);
|
||||
|
||||
assert.ok(Test.arrayContainsSameItems(["SZ", "TC", "RM", "CB"], modeList));
|
||||
});
|
||||
|
||||
const MODES_COUNT = 5;
|
||||
const STAGES_PER_MODE = 7;
|
||||
|
||||
MapPoolFromPreferences("returns maps even if no preferences", () => {
|
||||
const mapPool = mapPoolFromPreferences([]);
|
||||
|
||||
assert.equal(mapPool.stageModePairs.length, STAGES_PER_MODE * MODES_COUNT);
|
||||
});
|
||||
|
||||
MapPoolFromPreferences(
|
||||
"tiebreaker if tied preference is stage id (bigger preferred)",
|
||||
() => {
|
||||
const minIdConsiderBans = (mode: ModeShort) => {
|
||||
const MAX_STAGE_ID = Math.max(...stageIds);
|
||||
|
||||
let id = MAX_STAGE_ID;
|
||||
let stagesToPick = STAGES_PER_MODE;
|
||||
for (const stageId of [...stageIds].reverse()) {
|
||||
if (stagesToPick === 0) break;
|
||||
id--;
|
||||
|
||||
if (BANNED_MAPS[mode].includes(stageId)) continue;
|
||||
|
||||
stagesToPick--;
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
const mapPool = mapPoolFromPreferences([]);
|
||||
|
||||
assert.ok(
|
||||
mapPool.stageModePairs.every(
|
||||
({ stageId, mode }) => stageId >= minIdConsiderBans(mode),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MapPoolFromPreferences("returns maps even if no preferences", () => {
|
||||
const mapPool = mapPoolFromPreferences([]);
|
||||
|
||||
assert.equal(mapPool.stageModePairs.length, STAGES_PER_MODE * MODES_COUNT);
|
||||
});
|
||||
|
||||
MapPoolFromPreferences("preferring map causes it to be included", () => {
|
||||
const mapPool = mapPoolFromPreferences([
|
||||
[{ stageId: 0, preference: "PREFER", mode: "SZ" }],
|
||||
]);
|
||||
|
||||
assert.ok(
|
||||
mapPool.stageModePairs.some(
|
||||
(pair) => pair.stageId === 0 && pair.mode === "SZ",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
MapPoolFromPreferences("maps are voted upon", () => {
|
||||
const mapPool = mapPoolFromPreferences([
|
||||
[{ stageId: 0, preference: "PREFER", mode: "SZ" }],
|
||||
[{ stageId: 0, preference: "AVOID", mode: "SZ" }],
|
||||
[{ stageId: 0, preference: "AVOID", mode: "SZ" }],
|
||||
]);
|
||||
|
||||
assert.not.ok(
|
||||
mapPool.stageModePairs.some(
|
||||
(pair) => pair.stageId === 0 && pair.mode === "SZ",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
MapPoolFromPreferences(
|
||||
"most popular maps are returned even if nothing to be avoided",
|
||||
() => {
|
||||
const commonPreferences = stageIds.map(
|
||||
(stageId) =>
|
||||
({
|
||||
stageId,
|
||||
preference: "PREFER",
|
||||
mode: "SZ",
|
||||
}) as const,
|
||||
);
|
||||
|
||||
const mapPool = mapPoolFromPreferences([
|
||||
commonPreferences,
|
||||
commonPreferences,
|
||||
commonPreferences,
|
||||
commonPreferences.filter((pref) => pref.stageId !== 19),
|
||||
]);
|
||||
|
||||
assert.not.ok(
|
||||
mapPool.stageModePairs.some(
|
||||
(pair) => pair.stageId === 19 && pair.mode === "SZ",
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MapPoolFromPreferences("works across multiple modes", () => {
|
||||
const findFirstLegalMapFromMode = (mode: ModeShort) => {
|
||||
for (const stageId of stageIds) {
|
||||
if (BANNED_MAPS[mode].includes(stageId)) continue;
|
||||
|
||||
return stageId;
|
||||
}
|
||||
|
||||
throw new Error("No legal map found");
|
||||
};
|
||||
|
||||
const mapPool = mapPoolFromPreferences([
|
||||
[
|
||||
{
|
||||
stageId: findFirstLegalMapFromMode("SZ"),
|
||||
preference: "PREFER",
|
||||
mode: "SZ",
|
||||
},
|
||||
{
|
||||
stageId: findFirstLegalMapFromMode("TC"),
|
||||
preference: "PREFER",
|
||||
mode: "TC",
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
stageId: findFirstLegalMapFromMode("RM"),
|
||||
preference: "PREFER",
|
||||
mode: "RM",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
assert.ok(
|
||||
mapPool.stageModePairs.some(
|
||||
(pair) =>
|
||||
pair.stageId === findFirstLegalMapFromMode("SZ") && pair.mode === "SZ",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
mapPool.stageModePairs.some(
|
||||
(pair) =>
|
||||
pair.stageId === findFirstLegalMapFromMode("TC") && pair.mode === "TC",
|
||||
),
|
||||
);
|
||||
assert.ok(
|
||||
mapPool.stageModePairs.some(
|
||||
(pair) =>
|
||||
pair.stageId === findFirstLegalMapFromMode("RM") && pair.mode === "RM",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
MapModePreferencesToModeList.run();
|
||||
MapPoolFromPreferences.run();
|
||||
@@ -1,49 +1,65 @@
|
||||
import type { Group, ParsedMemento } from "~/db/types";
|
||||
import shuffle from "just-shuffle";
|
||||
import type { ParsedMemento, UserMapModePreferences } from "~/db/tables";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { createTournamentMapList } from "~/modules/tournament-map-list-generator";
|
||||
import { currentOrPreviousSeason } from "~/features/mmr/season";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import { modesShort, stageIds } from "~/modules/in-game-lists";
|
||||
import {
|
||||
createTournamentMapList,
|
||||
type TournamentMapListMap,
|
||||
} from "~/modules/tournament-map-list-generator";
|
||||
import { averageArray } from "~/utils/number";
|
||||
import { SENDOUQ_BEST_OF } from "../q-constants";
|
||||
import type { LookingGroup, LookingGroupWithInviteCode } from "../q-types";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { LookingGroupWithInviteCode } from "../q-types";
|
||||
import type { MatchById } from "../queries/findMatchById.server";
|
||||
import { addSkillsToGroups } from "./groups.server";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import { currentOrPreviousSeason } from "~/features/mmr/season";
|
||||
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
|
||||
|
||||
const filterMapPoolToSZ = (mapPool: MapPool) =>
|
||||
new MapPool(mapPool.stageModePairs.filter(({ mode }) => mode === "SZ"));
|
||||
export function matchMapList({
|
||||
ourGroup,
|
||||
theirGroup,
|
||||
ourMapPool,
|
||||
theirMapPool,
|
||||
}: {
|
||||
ourGroup: LookingGroup;
|
||||
theirGroup: LookingGroup;
|
||||
ourMapPool: MapPool;
|
||||
theirMapPool: MapPool;
|
||||
}) {
|
||||
invariant(ourGroup.mapListPreference, "ourGroup.mapListPreference");
|
||||
invariant(theirGroup.mapListPreference, "theirGroup.mapListPreference");
|
||||
|
||||
const type = mapListType([
|
||||
ourGroup.mapListPreference,
|
||||
theirGroup.mapListPreference,
|
||||
]);
|
||||
const filterMapPoolByMode = (mapPool: MapPool, modesIncluded: ModeShort[]) =>
|
||||
new MapPool(
|
||||
mapPool.stageModePairs.filter(({ mode }) => modesIncluded.includes(mode)),
|
||||
);
|
||||
export function matchMapList(
|
||||
groupOne: {
|
||||
preferences: { userId: number; preferences: UserMapModePreferences }[];
|
||||
id: number;
|
||||
},
|
||||
groupTwo: {
|
||||
preferences: { userId: number; preferences: UserMapModePreferences }[];
|
||||
id: number;
|
||||
},
|
||||
) {
|
||||
const modesIncluded = mapModePreferencesToModeList(
|
||||
groupOne.preferences.map(({ preferences }) => preferences.modes),
|
||||
groupTwo.preferences.map(({ preferences }) => preferences.modes),
|
||||
);
|
||||
|
||||
try {
|
||||
return createTournamentMapList({
|
||||
bestOf: SENDOUQ_BEST_OF,
|
||||
seed: String(ourGroup.id),
|
||||
modesIncluded: type === "SZ" ? ["SZ"] : ["SZ", "TC", "RM", "CB"],
|
||||
seed: String(groupOne.id),
|
||||
modesIncluded,
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
followModeOrder: true,
|
||||
teams: [
|
||||
{
|
||||
id: ourGroup.id,
|
||||
maps: type === "SZ" ? filterMapPoolToSZ(ourMapPool) : ourMapPool,
|
||||
id: groupOne.id,
|
||||
maps: filterMapPoolByMode(
|
||||
mapPoolFromPreferences(
|
||||
groupOne.preferences.map(({ preferences }) => preferences.maps),
|
||||
),
|
||||
modesIncluded,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: theirGroup.id,
|
||||
maps: type === "SZ" ? filterMapPoolToSZ(theirMapPool) : theirMapPool,
|
||||
id: groupTwo.id,
|
||||
maps: filterMapPoolByMode(
|
||||
mapPoolFromPreferences(
|
||||
groupTwo.preferences.map(({ preferences }) => preferences.maps),
|
||||
),
|
||||
modesIncluded,
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -53,16 +69,16 @@ export function matchMapList({
|
||||
console.error(e);
|
||||
return createTournamentMapList({
|
||||
bestOf: SENDOUQ_BEST_OF,
|
||||
seed: String(ourGroup.id),
|
||||
modesIncluded: type === "SZ" ? ["SZ"] : ["SZ", "TC", "RM", "CB"],
|
||||
seed: String(groupOne.id),
|
||||
modesIncluded,
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
teams: [
|
||||
{
|
||||
id: ourGroup.id,
|
||||
id: groupOne.id,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
{
|
||||
id: theirGroup.id,
|
||||
id: groupTwo.id,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
@@ -70,28 +86,117 @@ export function matchMapList({
|
||||
}
|
||||
}
|
||||
|
||||
// type score as const object
|
||||
const typeScore = {
|
||||
ALL_MODES_ONLY: -2,
|
||||
PREFER_ALL_MODES: -1,
|
||||
NO_PREFERENCE: 0,
|
||||
PREFER_SZ: 1,
|
||||
SZ_ONLY: 2,
|
||||
} as const;
|
||||
function mapListType(
|
||||
preferences: [Group["mapListPreference"], Group["mapListPreference"]],
|
||||
) {
|
||||
// if neither team has changed the default preference, default to all modes
|
||||
if (preferences.every((p) => p === "NO_PREFERENCE")) {
|
||||
return "ALL_MODES";
|
||||
export function mapModePreferencesToModeList(
|
||||
groupOnePreferences: UserMapModePreferences["modes"][],
|
||||
groupTwoPreferences: UserMapModePreferences["modes"][],
|
||||
): ModeShort[] {
|
||||
const groupOneScores = new Map<ModeShort, number>();
|
||||
const groupTwoScores = new Map<ModeShort, number>();
|
||||
|
||||
for (const [i, groupPrefences] of [
|
||||
groupOnePreferences,
|
||||
groupTwoPreferences,
|
||||
].entries()) {
|
||||
for (const mode of modesShort) {
|
||||
const preferences = groupPrefences
|
||||
.flat()
|
||||
.filter((preference) => preference.mode === mode)
|
||||
.map(({ preference }) => (preference === "AVOID" ? -1 : 1));
|
||||
|
||||
const average = averageArray(preferences.length > 0 ? preferences : [0]);
|
||||
const roundedAverage = Math.round(average);
|
||||
const scoresMap = i === 0 ? groupOneScores : groupTwoScores;
|
||||
|
||||
scoresMap.set(mode, roundedAverage);
|
||||
}
|
||||
}
|
||||
|
||||
const score = typeScore[preferences[0]] + typeScore[preferences[1]];
|
||||
const combinedMap = new Map<ModeShort, number>();
|
||||
for (const mode of modesShort) {
|
||||
const groupOneScore = groupOneScores.get(mode) ?? 0;
|
||||
const groupTwoScore = groupTwoScores.get(mode) ?? 0;
|
||||
const combinedScore = groupOneScore + groupTwoScore;
|
||||
combinedMap.set(mode, combinedScore);
|
||||
}
|
||||
|
||||
if (score < 0) return "ALL_MODES";
|
||||
if (score > 0) return "SZ";
|
||||
const result = shuffle(modesShort).filter((mode) => {
|
||||
const score = combinedMap.get(mode)!;
|
||||
|
||||
return Math.random() < 0.5 ? "ALL_MODES" : "SZ";
|
||||
// if opinion is split, don't include
|
||||
return score > 0;
|
||||
});
|
||||
|
||||
result.sort((a, b) => {
|
||||
const aScore = combinedMap.get(a)!;
|
||||
const bScore = combinedMap.get(b)!;
|
||||
|
||||
if (aScore === bScore) return 0;
|
||||
return aScore > bScore ? -1 : 1;
|
||||
});
|
||||
|
||||
if (result.length === 0) {
|
||||
const bestScore = Math.max(...combinedMap.values());
|
||||
|
||||
const leastWorstModesResult = shuffle(modesShort).filter((mode) => {
|
||||
// turf war never included if not positive
|
||||
if (mode === "TW") return false;
|
||||
|
||||
const score = combinedMap.get(mode)!;
|
||||
|
||||
return score === bestScore;
|
||||
});
|
||||
|
||||
// ok nevermind they are haters but really like turf war for some reason
|
||||
if (leastWorstModesResult.length === 0) return ["TW"];
|
||||
|
||||
return leastWorstModesResult;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const AMOUNT_OF_MAPS_TO_PICK = 7;
|
||||
export function mapPoolFromPreferences(
|
||||
groupPreferences: UserMapModePreferences["maps"][],
|
||||
) {
|
||||
const stageModePairs: { stageId: StageId; mode: ModeShort }[] = [];
|
||||
|
||||
for (const mode of modesShort) {
|
||||
const scores = new Map<StageId, number>();
|
||||
for (const userPreferences of groupPreferences) {
|
||||
for (const preference of userPreferences) {
|
||||
if (preference.mode !== mode) continue;
|
||||
|
||||
const currentScore = scores.get(preference.stageId) ?? 0;
|
||||
|
||||
const delta = preference.preference === "AVOID" ? -1 : 1;
|
||||
|
||||
scores.set(preference.stageId, currentScore + delta);
|
||||
}
|
||||
}
|
||||
|
||||
const stagesWithScore = stageIds.map((stageId) => ({
|
||||
stageId,
|
||||
score: scores.get(stageId) ?? 0,
|
||||
}));
|
||||
stagesWithScore.sort((a, b) => {
|
||||
if (a.score === b.score) return b.stageId - a.stageId;
|
||||
return a.score > b.score ? -1 : 1;
|
||||
});
|
||||
|
||||
const bannedMapsExcluded = stagesWithScore.filter(
|
||||
({ stageId }) => !BANNED_MAPS[mode].includes(stageId),
|
||||
);
|
||||
|
||||
for (const { stageId } of bannedMapsExcluded.slice(
|
||||
0,
|
||||
AMOUNT_OF_MAPS_TO_PICK,
|
||||
)) {
|
||||
stageModePairs.push({ stageId, mode });
|
||||
}
|
||||
}
|
||||
|
||||
return new MapPool(stageModePairs);
|
||||
}
|
||||
|
||||
export function compareMatchToReportedScores({
|
||||
@@ -141,13 +246,27 @@ export function compareMatchToReportedScores({
|
||||
return "SAME";
|
||||
}
|
||||
|
||||
type CreateMatchMementoArgs = {
|
||||
own: {
|
||||
group: LookingGroupWithInviteCode;
|
||||
preferences: { userId: number; preferences: UserMapModePreferences }[];
|
||||
};
|
||||
their: {
|
||||
group: LookingGroupWithInviteCode;
|
||||
preferences: { userId: number; preferences: UserMapModePreferences }[];
|
||||
};
|
||||
mapList: TournamentMapListMap[];
|
||||
};
|
||||
export async function createMatchMemento(
|
||||
ownGroup: LookingGroupWithInviteCode,
|
||||
theirGroup: LookingGroupWithInviteCode,
|
||||
args: CreateMatchMementoArgs,
|
||||
): Promise<ParsedMemento> {
|
||||
const skills = await userSkills(currentOrPreviousSeason(new Date())!.nth);
|
||||
const withTiers = addSkillsToGroups({
|
||||
groups: { neutral: [], likesReceived: [theirGroup], own: ownGroup },
|
||||
groups: {
|
||||
neutral: [],
|
||||
likesReceived: [args.their.group],
|
||||
own: args.own.group,
|
||||
},
|
||||
...skills,
|
||||
});
|
||||
|
||||
@@ -155,14 +274,21 @@ export async function createMatchMemento(
|
||||
const theirWithTier = withTiers.likesReceived[0];
|
||||
|
||||
return {
|
||||
mapPreferences: mapPreferenceMemento(args),
|
||||
modePreferences: modePreferencesMemento(args),
|
||||
users: Object.fromEntries(
|
||||
[...ownGroup.members, ...theirGroup.members].map((member) => [
|
||||
member.id,
|
||||
{
|
||||
plusTier: member.plusTier ?? undefined,
|
||||
skill: skills.userSkills[member.id],
|
||||
},
|
||||
]),
|
||||
[...args.own.group.members, ...args.their.group.members].map((member) => {
|
||||
const skill = skills.userSkills[member.id];
|
||||
|
||||
return [
|
||||
member.id,
|
||||
{
|
||||
plusTier: member.plusTier ?? undefined,
|
||||
skill:
|
||||
!skill || skill.approximate ? ("CALCULATING" as const) : skill,
|
||||
},
|
||||
];
|
||||
}),
|
||||
),
|
||||
groups: Object.fromEntries(
|
||||
[ownWithTier, theirWithTier].map((group) => [
|
||||
@@ -174,3 +300,91 @@ export async function createMatchMemento(
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function mapPreferenceMemento(args: CreateMatchMementoArgs) {
|
||||
const result: NonNullable<ParsedMemento["mapPreferences"]> = [];
|
||||
|
||||
for (const map of args.mapList) {
|
||||
const preferencesOfThisMap: NonNullable<
|
||||
ParsedMemento["mapPreferences"]
|
||||
>[number] = [];
|
||||
if (map.source === args.own.group.id || map.source === "BOTH") {
|
||||
preferencesOfThisMap.push(
|
||||
...opinionsAboutMapFromGroupPreferences({
|
||||
map,
|
||||
groupPreferences: args.own.preferences,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (map.source === args.their.group.id || map.source === "BOTH") {
|
||||
preferencesOfThisMap.push(
|
||||
...opinionsAboutMapFromGroupPreferences({
|
||||
map,
|
||||
groupPreferences: args.their.preferences,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
result.push(preferencesOfThisMap);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function opinionsAboutMapFromGroupPreferences({
|
||||
map,
|
||||
groupPreferences,
|
||||
}: {
|
||||
map: TournamentMapListMap;
|
||||
groupPreferences: CreateMatchMementoArgs["own"]["preferences"];
|
||||
}) {
|
||||
const result: NonNullable<ParsedMemento["mapPreferences"]>[number] = [];
|
||||
|
||||
for (const { preferences, userId } of groupPreferences) {
|
||||
const hasOnlyNeutral = preferences.maps.every((m) => !m.preference);
|
||||
if (hasOnlyNeutral) continue;
|
||||
|
||||
const found = preferences.maps.find(
|
||||
(pref) => pref.stageId === map.stageId && pref.mode === map.mode,
|
||||
);
|
||||
|
||||
result.push({
|
||||
userId,
|
||||
preference: found?.preference,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function modePreferencesMemento(args: CreateMatchMementoArgs) {
|
||||
const result: NonNullable<ParsedMemento["modePreferences"]> = {};
|
||||
|
||||
const modesIncluded: ModeShort[] = [];
|
||||
|
||||
for (const { mode } of args.mapList) {
|
||||
if (!modesIncluded.includes(mode)) modesIncluded.push(mode);
|
||||
}
|
||||
|
||||
for (const mode of modesIncluded) {
|
||||
for (const { preferences, userId } of [
|
||||
...args.own.preferences,
|
||||
...args.their.preferences,
|
||||
]) {
|
||||
const hasOnlyNeutral = preferences.modes.every((m) => !m.preference);
|
||||
if (hasOnlyNeutral) continue;
|
||||
|
||||
const found = preferences.modes.find((pref) => pref.mode === mode);
|
||||
|
||||
if (!result[mode]) result[mode] = [];
|
||||
|
||||
result[mode]!.push({
|
||||
userId,
|
||||
preference: found?.preference,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
import type { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
|
||||
import type { MatchById } from "../queries/findMatchById.server";
|
||||
import type { GroupForMatch } from "../queries/groupForMatch.server";
|
||||
import type { GroupForMatch } from "~/features/sendouq-match/QMatchRepository.server";
|
||||
|
||||
export type ReportedWeaponForMerging = {
|
||||
weaponSplId: MainWeaponId;
|
||||
|
||||
@@ -1,28 +1,11 @@
|
||||
import { TWEET_LENGTH_MAX_LENGTH } from "~/constants";
|
||||
import type { Group } from "~/db/types";
|
||||
import { assertType } from "~/utils/types";
|
||||
|
||||
export const MAP_LIST_PREFERENCE_OPTIONS = [
|
||||
"NO_PREFERENCE",
|
||||
"PREFER_ALL_MODES",
|
||||
"PREFER_SZ",
|
||||
"ALL_MODES_ONLY",
|
||||
"SZ_ONLY",
|
||||
] as const;
|
||||
assertType<
|
||||
Group["mapListPreference"],
|
||||
(typeof MAP_LIST_PREFERENCE_OPTIONS)[number]
|
||||
>();
|
||||
assertType<
|
||||
(typeof MAP_LIST_PREFERENCE_OPTIONS)[number],
|
||||
Group["mapListPreference"]
|
||||
>();
|
||||
|
||||
export const SENDOUQ = {
|
||||
SZ_MAP_COUNT: 6,
|
||||
OTHER_MODE_MAP_COUNT: 3,
|
||||
MAX_STAGE_REPEAT_COUNT: 2,
|
||||
NOTE_MAX_LENGTH: TWEET_LENGTH_MAX_LENGTH / 2,
|
||||
OWN_PUBLIC_NOTE_MAX_LENGTH: TWEET_LENGTH_MAX_LENGTH / 2,
|
||||
PRIVATE_USER_NOTE_MAX_LENGTH: TWEET_LENGTH_MAX_LENGTH,
|
||||
} as const;
|
||||
|
||||
export const FULL_GROUP_SIZE = 4;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import {
|
||||
_action,
|
||||
checkboxValueToBoolean,
|
||||
@@ -7,34 +6,17 @@ import {
|
||||
falsyToNull,
|
||||
id,
|
||||
modeShort,
|
||||
noDuplicates,
|
||||
safeJSONParse,
|
||||
stageId,
|
||||
weaponSplId,
|
||||
} from "~/utils/zod";
|
||||
import { matchEndedAtIndex } from "./core/match";
|
||||
import {
|
||||
MAP_LIST_PREFERENCE_OPTIONS,
|
||||
SENDOUQ,
|
||||
SENDOUQ_BEST_OF,
|
||||
} from "./q-constants";
|
||||
import { SENDOUQ, SENDOUQ_BEST_OF } from "./q-constants";
|
||||
|
||||
export const frontPageSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("JOIN_QUEUE"),
|
||||
mapListPreference: z.enum(MAP_LIST_PREFERENCE_OPTIONS),
|
||||
mapPool: z.string(),
|
||||
direct: z.preprocess(deduplicate, z.literal("true").nullish()),
|
||||
vc: z.enum(["YES", "NO", "LISTEN_ONLY"]),
|
||||
languages: z.preprocess(
|
||||
safeJSONParse,
|
||||
z
|
||||
.array(z.string())
|
||||
.refine(noDuplicates)
|
||||
.refine((val) =>
|
||||
val.every((lang) => languagesUnified.some((l) => l.code === lang)),
|
||||
),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("JOIN_TEAM"),
|
||||
@@ -42,10 +24,6 @@ export const frontPageSchema = z.union([
|
||||
z.object({
|
||||
_action: _action("JOIN_TEAM_WITH_TRUST"),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("SET_INITIAL_SP"),
|
||||
tier: z.enum(["higher", "default", "lower"]),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const preparingSchema = z.union([
|
||||
@@ -97,9 +75,13 @@ export const lookingSchema = z.union([
|
||||
_action: _action("UPDATE_NOTE"),
|
||||
value: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(SENDOUQ.NOTE_MAX_LENGTH).nullable(),
|
||||
z.string().max(SENDOUQ.OWN_PUBLIC_NOTE_MAX_LENGTH).nullable(),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("DELETE_PRIVATE_USER_NOTE"),
|
||||
targetId: id,
|
||||
}),
|
||||
]);
|
||||
|
||||
const winners = z.preprocess(
|
||||
@@ -152,6 +134,15 @@ export const matchSchema = z.union([
|
||||
_action: _action("REPORT_WEAPONS"),
|
||||
weapons,
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("ADD_PRIVATE_USER_NOTE"),
|
||||
comment: z.preprocess(
|
||||
falsyToNull,
|
||||
z.string().max(SENDOUQ.PRIVATE_USER_NOTE_MAX_LENGTH).nullable(),
|
||||
),
|
||||
sentiment: z.enum(["POSITIVE", "NEUTRAL", "NEGATIVE"]),
|
||||
targetId: id,
|
||||
}),
|
||||
]);
|
||||
|
||||
export const weaponUsageSearchParamsSchema = z.object({
|
||||
|
||||
@@ -5,19 +5,21 @@ import type {
|
||||
PlusTier,
|
||||
User,
|
||||
} from "~/db/types";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
import type { MainWeaponId, ModeShort } from "~/modules/in-game-lists";
|
||||
import type { TieredSkill } from "../mmr/tiered.server";
|
||||
import type { GroupForMatch } from "./queries/groupForMatch.server";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { GroupForMatch } from "../sendouq-match/QMatchRepository.server";
|
||||
|
||||
export type LookingGroup = {
|
||||
id: number;
|
||||
mapListPreference?: Group["mapListPreference"];
|
||||
createdAt: Group["createdAt"];
|
||||
tier?: TieredSkill["tier"];
|
||||
isReplay?: boolean;
|
||||
isLiked?: boolean;
|
||||
team?: GroupForMatch["team"];
|
||||
chatCode: Group["chatCode"];
|
||||
chatCode?: Group["chatCode"];
|
||||
mapModePreferences?: Array<NonNullable<Tables["User"]["mapModePreferences"]>>;
|
||||
futureMatchModes?: Array<ModeShort>;
|
||||
skillDifference?: ParsedMemento["groups"][number]["skillDifference"];
|
||||
members?: {
|
||||
id: number;
|
||||
@@ -29,12 +31,16 @@ export type LookingGroup = {
|
||||
role: GroupMember["role"];
|
||||
note?: GroupMember["note"];
|
||||
weapons?: MainWeaponId[];
|
||||
skill?: TieredSkill;
|
||||
skill?: TieredSkill | "CALCULATING";
|
||||
vc?: User["vc"];
|
||||
inGameName?: User["inGameName"];
|
||||
languages?: string[];
|
||||
languages: string[];
|
||||
chatNameColor: string | null;
|
||||
skillDifference?: ParsedMemento["users"][number]["skillDifference"];
|
||||
privateNote: Pick<
|
||||
Tables["PrivateUserNote"],
|
||||
"sentiment" | "text" | "updatedAt"
|
||||
> | null;
|
||||
}[];
|
||||
};
|
||||
|
||||
|
||||
@@ -27,29 +27,27 @@
|
||||
font-size: var(--fonts-xs);
|
||||
}
|
||||
|
||||
.q__header {
|
||||
font-size: var(--fonts-lg);
|
||||
}
|
||||
|
||||
.q__map-preference-label {
|
||||
margin-block-end: 0;
|
||||
font-weight: var(--semi-bold);
|
||||
.q__front-page-link {
|
||||
background-color: var(--bg-lighter);
|
||||
border-radius: var(--rounded-sm);
|
||||
padding: var(--s-2);
|
||||
font-size: var(--fonts-sm);
|
||||
color: var(--text);
|
||||
font-weight: var(--bold);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
color: var(--text-lighter);
|
||||
}
|
||||
|
||||
.q__map-pool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, max-content);
|
||||
gap: var(--s-2-5);
|
||||
font-size: var(--fonts-xs);
|
||||
align-items: center;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.q__map-pool-grid__stage-image {
|
||||
border-radius: 7px;
|
||||
.q__front-page-link:hover {
|
||||
background-color: var(--theme-transparent);
|
||||
}
|
||||
|
||||
.q__front-page-link__sub-text {
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--body);
|
||||
}
|
||||
|
||||
.q__tab-button {
|
||||
@@ -94,7 +92,7 @@
|
||||
}
|
||||
|
||||
.q__chat-container {
|
||||
top: 60px;
|
||||
top: var(--sticky-top);
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
@@ -119,8 +117,24 @@
|
||||
max-width: 94vw;
|
||||
}
|
||||
|
||||
.q__groups-container__right {
|
||||
margin-top: 45px;
|
||||
.q__column-header {
|
||||
text-align: center;
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--semi-bold);
|
||||
text-transform: uppercase;
|
||||
color: var(--theme);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.q__column-header::before,
|
||||
.q__column-header::after {
|
||||
flex: 1;
|
||||
content: "";
|
||||
padding: 2px;
|
||||
background-color: var(--theme-transparent);
|
||||
margin: 5px;
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.q__group {
|
||||
@@ -157,36 +171,78 @@
|
||||
overflow: hidden;
|
||||
max-width: 7.5rem;
|
||||
font-size: var(--fonts-xs);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.q__group-member__avatar {
|
||||
min-width: 36px;
|
||||
}
|
||||
|
||||
.q__group-member__avatar__POSITIVE {
|
||||
outline: 2px solid var(--theme-success-transparent);
|
||||
}
|
||||
|
||||
.q__group-member__avatar__NEUTRAL {
|
||||
outline: 2px solid var(--theme-warning-transparent);
|
||||
}
|
||||
|
||||
.q__group-member__avatar__NEGATIVE {
|
||||
outline: 2px solid var(--theme-error-transparent);
|
||||
}
|
||||
|
||||
.q__group-member__tier {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.q__group-member__tier__placeholder {
|
||||
min-width: 26.58px;
|
||||
}
|
||||
|
||||
.q__group-member__extra-info {
|
||||
font-size: var(--fonts-xs);
|
||||
background-color: var(--bg-darker);
|
||||
border-radius: var(--rounded);
|
||||
padding: var(--s-0-5) var(--s-2);
|
||||
padding: var(--s-0-5) var(--s-1-5);
|
||||
width: max-content;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1-5);
|
||||
gap: var(--s-1);
|
||||
font-weight: var(--semi-bold);
|
||||
}
|
||||
|
||||
.q__group-member__add-note-button {
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0 var(--s-1-5);
|
||||
color: var(--body);
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--body);
|
||||
font-weight: var(--semi-bold);
|
||||
background-color: var(--bg-darker);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.q__group-member__add-note-button__edit > svg {
|
||||
color: var(--theme);
|
||||
}
|
||||
|
||||
.q__group-member__add-note-button > svg {
|
||||
width: 14px;
|
||||
margin-inline-end: var(--s-1);
|
||||
}
|
||||
|
||||
.q__group-member__note-textarea {
|
||||
height: 4rem !important;
|
||||
}
|
||||
|
||||
.q__member-placeholder {
|
||||
.q__group__future-match-mode {
|
||||
border-radius: 100%;
|
||||
background-color: var(--bg-lightest);
|
||||
height: 24px;
|
||||
width: 24px;
|
||||
height: 30px;
|
||||
width: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-weight: var(--semi-bold);
|
||||
padding: var(--s-1-5);
|
||||
}
|
||||
|
||||
.q__group-member-weapons {
|
||||
@@ -243,6 +299,33 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.q-match__stage-popover-button {
|
||||
background-color: transparent;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
padding: 0;
|
||||
border: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
font-weight: var(--body);
|
||||
height: 19.8281px;
|
||||
}
|
||||
|
||||
.q-match__stage-popover-button:focus {
|
||||
outline: none;
|
||||
color: var(--theme);
|
||||
}
|
||||
|
||||
.q-match__mode-popover-button {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.q-match__mode-popover-button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.q-match__join-discord-section {
|
||||
border-left: 4px solid var(--theme);
|
||||
padding-inline-start: var(--s-4);
|
||||
@@ -273,6 +356,13 @@
|
||||
gap: var(--s-8);
|
||||
}
|
||||
|
||||
.q-match__map-list-chat-container {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 2fr;
|
||||
place-items: center;
|
||||
gap: var(--s-4);
|
||||
}
|
||||
|
||||
.q-match__report__user-name-container {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
@@ -291,11 +381,46 @@
|
||||
font-size: var(--fonts-xs);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 640px) {
|
||||
.q-match__teams-container.with-chat {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
.q-match__pool-pass-container {
|
||||
display: flex;
|
||||
gap: var(--s-2);
|
||||
flex-direction: column;
|
||||
max-width: max-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.q-match__sentiment-emoji {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.q-match__chat-container {
|
||||
align-self: flex-start;
|
||||
top: var(--sticky-top);
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.q-match__bottom-mid-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-self: flex-start;
|
||||
top: var(--sticky-top);
|
||||
position: sticky;
|
||||
}
|
||||
|
||||
.q-match__info__header {
|
||||
text-transform: uppercase;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.q-match__info__value {
|
||||
font-size: var(--fonts-xl);
|
||||
font-weight: var(--semi-bold);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 640px) {
|
||||
.q-match__teams-container {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { INVITE_CODE_LENGTH } from "~/constants";
|
||||
import { sql } from "~/db/sql";
|
||||
import type { Group, GroupMember } from "~/db/types";
|
||||
import type { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
|
||||
const createGroupStm = sql.prepare(/* sql */ `
|
||||
insert into "Group"
|
||||
("mapListPreference", "inviteCode", "status", "chatCode")
|
||||
values
|
||||
(@mapListPreference, @inviteCode, @status, @chatCode)
|
||||
returning *
|
||||
`);
|
||||
|
||||
const createGroupMemberStm = sql.prepare(/* sql */ `
|
||||
insert into "GroupMember"
|
||||
("groupId", "userId", "role")
|
||||
values
|
||||
(@groupId, @userId, @role)
|
||||
`);
|
||||
|
||||
const createMapPoolMapStm = sql.prepare(/* sql */ `
|
||||
insert into "MapPoolMap"
|
||||
("stageId", "mode", "groupId")
|
||||
values
|
||||
(@stageId, @mode, @groupId)
|
||||
`);
|
||||
|
||||
type CreateGroupArgs = Pick<Group, "mapListPreference"> & {
|
||||
status: Exclude<Group["status"], "INACTIVE">;
|
||||
userId: number;
|
||||
mapPool: MapPool;
|
||||
};
|
||||
|
||||
const DEFAULT_ROLE: GroupMember["role"] = "OWNER";
|
||||
|
||||
export const createGroup = sql.transaction((args: CreateGroupArgs) => {
|
||||
const group = createGroupStm.get({
|
||||
mapListPreference: args.mapListPreference,
|
||||
inviteCode: nanoid(INVITE_CODE_LENGTH),
|
||||
status: args.status,
|
||||
chatCode: nanoid(INVITE_CODE_LENGTH),
|
||||
}) as Group;
|
||||
|
||||
createGroupMemberStm.run({
|
||||
groupId: group.id,
|
||||
userId: args.userId,
|
||||
role: DEFAULT_ROLE,
|
||||
});
|
||||
|
||||
for (const { stageId, mode } of args.mapPool.stageModePairs) {
|
||||
createMapPoolMapStm.run({
|
||||
stageId,
|
||||
mode,
|
||||
groupId: group.id,
|
||||
});
|
||||
}
|
||||
|
||||
return group;
|
||||
});
|
||||
|
||||
type CreateGroupFromPreviousGroupArgs = {
|
||||
previousGroupId: number;
|
||||
members: {
|
||||
id: number;
|
||||
role: GroupMember["role"];
|
||||
}[];
|
||||
};
|
||||
|
||||
const createGroupFromPreviousGroupStm = sql.prepare(/* sql */ `
|
||||
insert into "Group"
|
||||
("mapListPreference", "teamId", "chatCode", "inviteCode", "status")
|
||||
values
|
||||
(
|
||||
(select "mapListPreference" from "Group" where "id" = @previousGroupId),
|
||||
(select "teamId" from "Group" where "id" = @previousGroupId),
|
||||
(select "chatCode" from "Group" where "id" = @previousGroupId),
|
||||
@inviteCode,
|
||||
@status
|
||||
)
|
||||
returning *
|
||||
`);
|
||||
|
||||
const stealMapPoolStm = sql.prepare(/* sql */ `
|
||||
update "MapPoolMap"
|
||||
set "groupId" = @groupId
|
||||
where "groupId" = @previousGroupId
|
||||
`);
|
||||
|
||||
export const createGroupFromPreviousGroup = sql.transaction(
|
||||
(args: CreateGroupFromPreviousGroupArgs) => {
|
||||
const group = createGroupFromPreviousGroupStm.get({
|
||||
previousGroupId: args.previousGroupId,
|
||||
inviteCode: nanoid(INVITE_CODE_LENGTH),
|
||||
status: "PREPARING",
|
||||
}) as Group;
|
||||
|
||||
for (const member of args.members) {
|
||||
createGroupMemberStm.run({
|
||||
groupId: group.id,
|
||||
userId: member.id,
|
||||
role: member.role,
|
||||
});
|
||||
}
|
||||
|
||||
stealMapPoolStm.run({
|
||||
previousGroupId: args.previousGroupId,
|
||||
groupId: group.id,
|
||||
});
|
||||
|
||||
return group;
|
||||
},
|
||||
);
|
||||
@@ -1,8 +1,9 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { sql } from "~/db/sql";
|
||||
import type { GroupMatch, ParsedMemento } from "~/db/types";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator";
|
||||
import { syncGroupTeamId } from "./syncGroupTeamId.server";
|
||||
import type { ParsedMemento } from "~/db/tables";
|
||||
import type { GroupMatch } from "~/db/types";
|
||||
|
||||
const createMatchStm = sql.prepare(/* sql */ `
|
||||
insert into "GroupMatch" (
|
||||
|
||||
@@ -7,7 +7,6 @@ const stm = sql.prepare(/* sql */ `
|
||||
select
|
||||
"Group"."id",
|
||||
"Group"."createdAt",
|
||||
"Group"."mapListPreference",
|
||||
"Group"."inviteCode",
|
||||
"User"."id" as "userId",
|
||||
"User"."discordId",
|
||||
@@ -32,7 +31,6 @@ const stm = sql.prepare(/* sql */ `
|
||||
)
|
||||
select
|
||||
"q1"."id",
|
||||
"q1"."mapListPreference",
|
||||
"q1"."inviteCode",
|
||||
"q1"."createdAt",
|
||||
json_group_array(
|
||||
@@ -59,7 +57,6 @@ export function findPreparingGroup(
|
||||
return {
|
||||
id: row.id,
|
||||
createdAt: row.createdAt,
|
||||
mapListPreference: row.mapListPreference,
|
||||
chatCode: null,
|
||||
inviteCode: row.inviteCode,
|
||||
members: parseDBJsonArray(row.members).map((member: any) => {
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { sql } from "~/db/sql";
|
||||
import type {
|
||||
Group,
|
||||
GroupMember,
|
||||
ParsedMemento,
|
||||
User,
|
||||
UserSkillDifference,
|
||||
} from "~/db/types";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists";
|
||||
import { parseDBArray } from "~/utils/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
with "GroupMemberWithWeapon" as (
|
||||
select
|
||||
"GroupMember".*,
|
||||
json_group_array("UserWeapon"."weaponSplId") as "weapons"
|
||||
from "GroupMember"
|
||||
left join "UserWeapon" on "UserWeapon"."userId" = "GroupMember"."userId"
|
||||
where
|
||||
"GroupMember"."groupId" = @id
|
||||
and ("UserWeapon"."order" is null or "UserWeapon"."order" <= 3)
|
||||
group by "GroupMember"."userId"
|
||||
)
|
||||
select
|
||||
"Group"."id",
|
||||
"Group"."chatCode",
|
||||
"GroupMatch"."memento",
|
||||
"AllTeam"."name" as "teamName",
|
||||
"AllTeam"."customUrl" as "teamCustomUrl",
|
||||
"UserSubmittedImage"."url" as "teamAvatarUrl",
|
||||
json_group_array(
|
||||
json_object(
|
||||
'id', "GroupMemberWithWeapon"."userId",
|
||||
'discordId', "User"."discordId",
|
||||
'discordName', "User"."discordName",
|
||||
'discordAvatar', "User"."discordAvatar",
|
||||
'role', "GroupMemberWithWeapon"."role",
|
||||
'customUrl', "User"."customUrl",
|
||||
'inGameName', "User"."inGameName",
|
||||
'vc', "User"."vc",
|
||||
'languages', "User"."languages",
|
||||
'weapons', "GroupMemberWithWeapon"."weapons",
|
||||
'chatNameColor', IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null)
|
||||
)
|
||||
) as "members"
|
||||
from
|
||||
"Group"
|
||||
left join "GroupMemberWithWeapon" on "GroupMemberWithWeapon"."groupId" = "Group"."id"
|
||||
left join "User" on "User"."id" = "GroupMemberWithWeapon"."userId"
|
||||
left join "AllTeam" on "AllTeam"."id" = "Group"."teamId"
|
||||
left join "UserSubmittedImage" on "AllTeam"."avatarImgId" = "UserSubmittedImage"."id"
|
||||
left join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id" or "GroupMatch"."bravoGroupId" = "Group"."id"
|
||||
where
|
||||
"Group"."id" = @id
|
||||
group by "Group"."id"
|
||||
order by "GroupMemberWithWeapon"."userId" asc
|
||||
`);
|
||||
|
||||
export interface GroupForMatch {
|
||||
id: Group["id"];
|
||||
chatCode: Group["chatCode"];
|
||||
tier?: ParsedMemento["groups"][number]["tier"];
|
||||
skillDifference?: ParsedMemento["groups"][number]["skillDifference"];
|
||||
team?: {
|
||||
name: string;
|
||||
avatarUrl: string | null;
|
||||
customUrl: string;
|
||||
};
|
||||
members: Array<{
|
||||
id: GroupMember["userId"];
|
||||
discordId: User["discordId"];
|
||||
discordName: User["discordName"];
|
||||
discordAvatar: User["discordAvatar"];
|
||||
role: GroupMember["role"];
|
||||
customUrl: User["customUrl"];
|
||||
inGameName: User["inGameName"];
|
||||
weapons: Array<MainWeaponId>;
|
||||
chatNameColor: string | null;
|
||||
vc: User["vc"];
|
||||
languages: string[];
|
||||
skillDifference?: UserSkillDifference;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function groupForMatch(id: number) {
|
||||
const row = stm.get({ id }) as any;
|
||||
if (!row) return null;
|
||||
|
||||
const memento = row.memento
|
||||
? (JSON.parse(row.memento) as ParsedMemento)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
chatCode: row.chatCode,
|
||||
tier: memento?.groups[row.id]?.tier,
|
||||
skillDifference: memento?.groups[row.id]?.skillDifference,
|
||||
team: row.teamName
|
||||
? {
|
||||
name: row.teamName,
|
||||
avatarUrl: row.teamAvatarUrl,
|
||||
customUrl: row.teamCustomUrl,
|
||||
}
|
||||
: undefined,
|
||||
members: JSON.parse(row.members).map((m: any) => ({
|
||||
...m,
|
||||
weapons: parseDBArray(m.weapons),
|
||||
languages: m.languages ? m.languages.split(",") : [],
|
||||
plusTier: memento?.users[m.id]?.plusTier,
|
||||
skill: memento?.users[m.id]?.skill,
|
||||
skillDifference: memento?.users[m.id]?.skillDifference,
|
||||
})),
|
||||
} as GroupForMatch;
|
||||
}
|
||||
@@ -18,11 +18,6 @@ const deleteGroupStm = sql.prepare(/* sql */ `
|
||||
where "Group"."id" = @groupId
|
||||
`);
|
||||
|
||||
const deleteGroupMapsStm = sql.prepare(/* sql */ `
|
||||
delete from "MapPoolMap"
|
||||
where "groupId" = @groupId
|
||||
`);
|
||||
|
||||
export const leaveGroup = sql.transaction(
|
||||
({
|
||||
groupId,
|
||||
@@ -45,7 +40,6 @@ export const leaveGroup = sql.transaction(
|
||||
deleteGroupMemberStm.run({ groupId, userId });
|
||||
} else {
|
||||
deleteGroupStm.run({ groupId });
|
||||
deleteGroupMapsStm.run({ groupId });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
import { sql } from "~/db/sql";
|
||||
import { parseDBArray, parseDBJsonArray } from "~/utils/sql";
|
||||
import type { LookingGroupWithInviteCode } from "../q-types";
|
||||
|
||||
// groups visible for longer to make development easier
|
||||
const SECONDS_TILL_STALE =
|
||||
process.env.NODE_ENV === "development" ? 1_000_000 : 1_800;
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
with "q1" as (
|
||||
select
|
||||
"Group"."id",
|
||||
"Group"."createdAt",
|
||||
"Group"."mapListPreference",
|
||||
"Group"."inviteCode",
|
||||
"Group"."chatCode",
|
||||
"User"."id" as "userId",
|
||||
"User"."discordId",
|
||||
"User"."discordName",
|
||||
"User"."discordAvatar",
|
||||
"User"."customUrl",
|
||||
"User"."vc",
|
||||
IIF(COALESCE("User"."patronTier", 0) >= 2, "User"."css" ->> 'chat', null) as "chatNameColor",
|
||||
"User"."languages",
|
||||
"PlusTier"."tier" as "plusTier",
|
||||
"GroupMember"."role",
|
||||
"GroupMember"."note",
|
||||
json_group_array("UserWeapon"."weaponSplId") as "weapons"
|
||||
from
|
||||
"Group"
|
||||
left join "GroupMember" on "GroupMember"."groupId" = "Group"."id"
|
||||
left join "User" on "User"."id" = "GroupMember"."userId"
|
||||
left join "PlusTier" on "PlusTier"."userId" = "User"."id"
|
||||
left join "UserWeapon" on "UserWeapon"."userId" = "User"."id"
|
||||
left join "GroupMatch" on "GroupMatch"."alphaGroupId" = "Group"."id"
|
||||
or "GroupMatch"."bravoGroupId" = "Group"."id"
|
||||
where
|
||||
"Group"."status" = 'ACTIVE'
|
||||
-- only groups that were active in the last half an hour as well as own group
|
||||
and ("Group"."latestActionAt" > (unixepoch() - ${SECONDS_TILL_STALE}) or "Group"."id" = @ownGroupId)
|
||||
and "GroupMatch"."id" is null
|
||||
and ("UserWeapon"."order" is null or "UserWeapon"."order" <= 3)
|
||||
group by "User"."id"
|
||||
order by "UserWeapon"."order" asc
|
||||
)
|
||||
select
|
||||
"q1"."id",
|
||||
"q1"."mapListPreference",
|
||||
"q1"."inviteCode",
|
||||
"q1"."createdAt",
|
||||
"q1"."chatCode",
|
||||
json_group_array(
|
||||
json_object(
|
||||
'id', "q1"."userId",
|
||||
'discordId', "q1"."discordId",
|
||||
'discordName', "q1"."discordName",
|
||||
'discordAvatar', "q1"."discordAvatar",
|
||||
'chatNameColor', "q1"."chatNameColor",
|
||||
'customUrl', "q1"."customUrl",
|
||||
'plusTier', "q1"."plusTier",
|
||||
'role', "q1"."role",
|
||||
'note', "q1"."note",
|
||||
'weapons', "q1"."weapons",
|
||||
'vc', "q1"."vc",
|
||||
'languages', "q1"."languages"
|
||||
)
|
||||
) as "members"
|
||||
from "q1"
|
||||
group by "q1"."id"
|
||||
`);
|
||||
|
||||
export function findLookingGroups({
|
||||
minGroupSize,
|
||||
maxGroupSize,
|
||||
ownGroupId,
|
||||
includeChatCode = false,
|
||||
}: {
|
||||
minGroupSize?: number;
|
||||
maxGroupSize?: number;
|
||||
ownGroupId: number;
|
||||
includeChatCode?: boolean;
|
||||
}): LookingGroupWithInviteCode[] {
|
||||
return stm
|
||||
.all({ ownGroupId })
|
||||
.map((row: any) => {
|
||||
return {
|
||||
id: row.id,
|
||||
mapListPreference: row.mapListPreference,
|
||||
inviteCode: row.inviteCode,
|
||||
createdAt: row.createdAt,
|
||||
chatCode: includeChatCode ? row.chatCode : null,
|
||||
members: parseDBJsonArray(row.members).map((member: any) => {
|
||||
const weapons = parseDBArray(member.weapons);
|
||||
|
||||
return {
|
||||
...member,
|
||||
weapons: weapons.length > 0 ? weapons : undefined,
|
||||
languages: member.languages ? member.languages.split(",") : [],
|
||||
};
|
||||
}),
|
||||
};
|
||||
})
|
||||
.filter((group: any) => {
|
||||
if (group.id === ownGroupId) return true;
|
||||
if (maxGroupSize && group.members.length > maxGroupSize) return false;
|
||||
if (minGroupSize && group.members.length < minGroupSize) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { sql } from "~/db/sql";
|
||||
import type { MapPoolMap } from "~/db/types";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select
|
||||
"MapPoolMap"."stageId",
|
||||
"MapPoolMap"."mode"
|
||||
from "MapPoolMap"
|
||||
where "MapPoolMap"."groupId" = @groupId
|
||||
`);
|
||||
|
||||
export function mapPoolByGroupId(groupId: number) {
|
||||
return stm.all({ groupId }) as Array<Pick<MapPoolMap, "stageId" | "mode">>;
|
||||
}
|
||||
@@ -15,11 +15,6 @@ const deleteGroupStm = sql.prepare(/* sql */ `
|
||||
where "Group"."id" = @groupId
|
||||
`);
|
||||
|
||||
const deleteGroupMapsStm = sql.prepare(/* sql */ `
|
||||
delete from "MapPoolMap"
|
||||
where "groupId" = @groupId
|
||||
`);
|
||||
|
||||
const addGroupMemberStm = sql.prepare(/* sql */ `
|
||||
insert into "GroupMember" ("groupId", "userId", "role")
|
||||
values (@groupId, @userId, @role)
|
||||
@@ -46,7 +41,6 @@ export const morphGroups = sql.transaction(
|
||||
.map((row: any) => row.userId) as Array<User["id"]>;
|
||||
|
||||
deleteGroupStm.run({ groupId: otherGroupId });
|
||||
deleteGroupMapsStm.run({ groupId: otherGroupId });
|
||||
|
||||
deleteLikesByGroupId(survivingGroupId);
|
||||
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { sql } from "~/db/sql";
|
||||
import type { User } from "~/db/types";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
update "User"
|
||||
set "vc" = @vc,
|
||||
"languages" = @languages
|
||||
where "id" = @userId
|
||||
`);
|
||||
|
||||
export function updateVCStatus({
|
||||
vc,
|
||||
languages,
|
||||
userId,
|
||||
}: {
|
||||
vc: User["vc"];
|
||||
languages: string[];
|
||||
userId: User["id"];
|
||||
}) {
|
||||
stm.run({
|
||||
vc,
|
||||
languages: languages.join(","),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { sql } from "~/db/sql";
|
||||
|
||||
const stm = sql.prepare(/* sql */ `
|
||||
select
|
||||
1
|
||||
from
|
||||
"Skill"
|
||||
where
|
||||
"Skill"."userId" = @userId
|
||||
and "Skill"."season" = @season
|
||||
limit 1
|
||||
`);
|
||||
|
||||
export function userHasSkill({
|
||||
userId,
|
||||
season,
|
||||
}: {
|
||||
userId: number;
|
||||
season: number;
|
||||
}) {
|
||||
const rows = stm.all({ userId, season });
|
||||
|
||||
return rows.length > 0;
|
||||
}
|
||||
349
app/features/sendouq/routes/q.looking.test.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { suite } from "uvu";
|
||||
import * as Test from "~/utils/Test";
|
||||
import { loader, action as rawLookingAction } from "./q.looking";
|
||||
import { action as rawMatchAction } from "./q.match.$id";
|
||||
import type { lookingSchema, matchSchema } from "../q-schemas.server";
|
||||
import { db } from "~/db/sql";
|
||||
import type { UserMapModePreferences } from "~/db/tables";
|
||||
import type { StageId } from "~/modules/in-game-lists";
|
||||
import invariant from "tiny-invariant";
|
||||
import * as assert from "uvu/assert";
|
||||
import type { SerializeFrom } from "@remix-run/server-runtime";
|
||||
|
||||
const SendouQMatchCreation = suite("SendouQ match creation");
|
||||
const PrivateUserNoteSorting = suite("Private user note sorting");
|
||||
|
||||
const lookingAction = Test.wrappedAction<typeof lookingSchema>({
|
||||
action: rawLookingAction,
|
||||
});
|
||||
|
||||
const createGroup = async (userIds: number[]) => {
|
||||
const group = await db
|
||||
.insertInto("Group")
|
||||
.values({
|
||||
inviteCode: "1234",
|
||||
status: "ACTIVE",
|
||||
})
|
||||
.returning("id")
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
await db
|
||||
.insertInto("GroupMember")
|
||||
.values(
|
||||
userIds.map((userId, i) => ({
|
||||
groupId: group.id,
|
||||
userId,
|
||||
role: i === 0 ? "OWNER" : "REGULAR",
|
||||
})),
|
||||
)
|
||||
.execute();
|
||||
};
|
||||
|
||||
const SZ_ONLY_PREFERENCE: UserMapModePreferences["modes"] = [
|
||||
{ mode: "SZ", preference: "PREFER" },
|
||||
{ mode: "TC", preference: "AVOID" },
|
||||
{ mode: "RM", preference: "AVOID" },
|
||||
{ mode: "CB", preference: "AVOID" },
|
||||
];
|
||||
|
||||
const prepareGroups = async () => {
|
||||
await Test.database.insertUsers(8);
|
||||
await createGroup([1, 2, 3, 4]);
|
||||
await createGroup([5, 6, 7, 8]);
|
||||
await db
|
||||
.insertInto("GroupLike")
|
||||
.values({ likerGroupId: 2, targetGroupId: 1 })
|
||||
.execute();
|
||||
|
||||
await insertMapModePreferences(1, {
|
||||
modes: SZ_ONLY_PREFERENCE,
|
||||
maps: Array.from({ length: 10 }).map((_, i) => ({
|
||||
mode: "SZ",
|
||||
preference: "PREFER",
|
||||
stageId: i as StageId,
|
||||
})),
|
||||
});
|
||||
|
||||
await insertMapModePreferences(5, {
|
||||
modes: SZ_ONLY_PREFERENCE,
|
||||
maps: [
|
||||
{ mode: "SZ", preference: "PREFER", stageId: 11 },
|
||||
{ mode: "SZ", preference: "PREFER", stageId: 12 },
|
||||
{ mode: "SZ", preference: "PREFER", stageId: 13 },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const insertMapModePreferences = (
|
||||
userId: number,
|
||||
preferences: UserMapModePreferences,
|
||||
) => {
|
||||
return db
|
||||
.updateTable("User")
|
||||
.set({
|
||||
mapModePreferences: JSON.stringify(preferences),
|
||||
})
|
||||
.where("User.id", "=", userId)
|
||||
.execute();
|
||||
};
|
||||
|
||||
const createMatch = () =>
|
||||
lookingAction(
|
||||
{
|
||||
_action: "MATCH_UP",
|
||||
targetGroupId: 2,
|
||||
},
|
||||
{ user: "admin" },
|
||||
);
|
||||
|
||||
const findMatch = () =>
|
||||
db
|
||||
.selectFrom("GroupMatch")
|
||||
.selectAll()
|
||||
.where("id", "=", 1)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
SendouQMatchCreation.before.each(async () => {
|
||||
await prepareGroups();
|
||||
});
|
||||
|
||||
SendouQMatchCreation.after.each(() => {
|
||||
Test.database.reset();
|
||||
});
|
||||
|
||||
SendouQMatchCreation(
|
||||
"adds about created map preferences to memento in the correct spot",
|
||||
async () => {
|
||||
await createMatch();
|
||||
|
||||
const match = await findMatch();
|
||||
|
||||
const index = match.memento?.mapPreferences?.findIndex((preference) =>
|
||||
preference.some((p) => p.userId === 1),
|
||||
);
|
||||
invariant(typeof index === "number", "User 1 not found in memento");
|
||||
|
||||
await db
|
||||
.selectFrom("GroupMatchMap")
|
||||
.selectAll()
|
||||
.where("GroupMatchMap.index", "=", index)
|
||||
.where("GroupMatchMap.source", "=", "1")
|
||||
.executeTakeFirstOrThrow();
|
||||
},
|
||||
);
|
||||
|
||||
SendouQMatchCreation(
|
||||
"adds about created map preferences to memento in the correct spot (two preferrers)",
|
||||
async () => {
|
||||
await insertMapModePreferences(2, {
|
||||
modes: SZ_ONLY_PREFERENCE,
|
||||
maps: Array.from({ length: 10 }).map((_, i) => ({
|
||||
mode: "SZ",
|
||||
preference: "PREFER",
|
||||
stageId: i as StageId,
|
||||
})),
|
||||
});
|
||||
|
||||
await createMatch();
|
||||
|
||||
const match = await findMatch();
|
||||
|
||||
const index = match.memento?.mapPreferences?.findIndex(
|
||||
(preference) =>
|
||||
preference.some((p) => p.userId === 1) &&
|
||||
preference.some((p) => p.userId === 2),
|
||||
);
|
||||
invariant(typeof index === "number", "User 1 not found in memento");
|
||||
|
||||
await db
|
||||
.selectFrom("GroupMatchMap")
|
||||
.selectAll()
|
||||
.where("GroupMatchMap.index", "=", index)
|
||||
.where("GroupMatchMap.source", "=", "1")
|
||||
.executeTakeFirstOrThrow();
|
||||
},
|
||||
);
|
||||
|
||||
SendouQMatchCreation("adds neutral preferences", async () => {
|
||||
await insertMapModePreferences(2, {
|
||||
modes: SZ_ONLY_PREFERENCE,
|
||||
maps: Array.from({ length: 18 }).map((_, i) => ({
|
||||
mode: "SZ",
|
||||
preference: i < 10 ? undefined : "AVOID",
|
||||
stageId: i as StageId,
|
||||
})),
|
||||
});
|
||||
|
||||
await createMatch();
|
||||
|
||||
const match = await findMatch();
|
||||
|
||||
const preference = match.memento?.mapPreferences
|
||||
?.flat()
|
||||
.find((p) => p.userId === 2);
|
||||
invariant(preference, "User 2 not found in memento");
|
||||
|
||||
assert.equal(preference.preference, undefined);
|
||||
});
|
||||
|
||||
SendouQMatchCreation(
|
||||
"user missing from preferences if no preferences at all",
|
||||
async () => {
|
||||
await createMatch();
|
||||
|
||||
const match = await findMatch();
|
||||
|
||||
assert.not.ok(
|
||||
match.memento?.mapPreferences?.flat().find((p) => p.userId === 3),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SendouQMatchCreation(
|
||||
"user missing from preferences if only neutral preference",
|
||||
async () => {
|
||||
await insertMapModePreferences(3, {
|
||||
modes: SZ_ONLY_PREFERENCE,
|
||||
maps: Array.from({ length: 10 }).map((_, i) => ({
|
||||
mode: "SZ",
|
||||
stageId: i as StageId,
|
||||
})),
|
||||
});
|
||||
|
||||
await createMatch();
|
||||
|
||||
const match = await findMatch();
|
||||
|
||||
assert.not.ok(
|
||||
match.memento?.mapPreferences?.flat().find((p) => p.userId === 3),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SendouQMatchCreation("adds mode preferences to memento", async () => {
|
||||
await createMatch();
|
||||
|
||||
const match = await findMatch();
|
||||
|
||||
const modePreferences = match.memento?.modePreferences;
|
||||
|
||||
assert.equal(modePreferences?.SZ?.length, 2);
|
||||
});
|
||||
|
||||
SendouQMatchCreation(
|
||||
"adds mode preferences to memento including neutral",
|
||||
async () => {
|
||||
await insertMapModePreferences(2, {
|
||||
modes: [{ mode: "TC", preference: "PREFER" }],
|
||||
maps: [],
|
||||
});
|
||||
|
||||
await createMatch();
|
||||
|
||||
const match = await findMatch();
|
||||
|
||||
const modePreferences = match.memento?.modePreferences;
|
||||
|
||||
assert.equal(modePreferences?.SZ?.length, 3);
|
||||
assert.ok(modePreferences?.SZ?.some((p) => !p.preference));
|
||||
},
|
||||
);
|
||||
|
||||
PrivateUserNoteSorting.before.each(async () => {
|
||||
await Test.database.insertUsers(8);
|
||||
|
||||
await createGroup([1]);
|
||||
await createGroup([2]);
|
||||
await createGroup([3]);
|
||||
await createGroup([4]);
|
||||
await createGroup([5]);
|
||||
await createGroup([6, 7]);
|
||||
await createGroup([8]);
|
||||
|
||||
await db
|
||||
.insertInto("GroupMatch")
|
||||
.values({ alphaGroupId: 2, bravoGroupId: 3 })
|
||||
.execute();
|
||||
});
|
||||
|
||||
PrivateUserNoteSorting.after.each(() => {
|
||||
Test.database.reset();
|
||||
});
|
||||
|
||||
const lookingLoader = Test.wrappedLoader<SerializeFrom<typeof loader>>({
|
||||
loader,
|
||||
});
|
||||
const matchAction = Test.wrappedAction<typeof matchSchema>({
|
||||
action: rawMatchAction,
|
||||
params: { id: "1" },
|
||||
});
|
||||
|
||||
PrivateUserNoteSorting("users with positive note sorted first", async () => {
|
||||
await matchAction(
|
||||
{
|
||||
_action: "ADD_PRIVATE_USER_NOTE",
|
||||
targetId: 5,
|
||||
sentiment: "POSITIVE",
|
||||
comment: "test",
|
||||
},
|
||||
{ user: "admin" },
|
||||
);
|
||||
|
||||
const data = await lookingLoader({ user: "admin" });
|
||||
|
||||
assert.equal(data.groups.neutral[0].members![0].id, 5);
|
||||
});
|
||||
|
||||
PrivateUserNoteSorting("users with negative note sorted last", async () => {
|
||||
await matchAction(
|
||||
{
|
||||
_action: "ADD_PRIVATE_USER_NOTE",
|
||||
targetId: 5,
|
||||
sentiment: "NEGATIVE",
|
||||
comment: "test",
|
||||
},
|
||||
{ user: "admin" },
|
||||
);
|
||||
|
||||
const data = await lookingLoader({ user: "admin" });
|
||||
|
||||
assert.equal(
|
||||
data.groups.neutral[data.groups.neutral.length - 1].members![0].id,
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
PrivateUserNoteSorting(
|
||||
"group with both negative and positive sentiment sorted last",
|
||||
async () => {
|
||||
await matchAction(
|
||||
{
|
||||
_action: "ADD_PRIVATE_USER_NOTE",
|
||||
targetId: 6,
|
||||
sentiment: "POSITIVE",
|
||||
comment: "test",
|
||||
},
|
||||
{ user: "admin" },
|
||||
);
|
||||
await matchAction(
|
||||
{
|
||||
_action: "ADD_PRIVATE_USER_NOTE",
|
||||
targetId: 7,
|
||||
sentiment: "NEGATIVE",
|
||||
comment: "test",
|
||||
},
|
||||
{ user: "admin" },
|
||||
);
|
||||
|
||||
const data = await lookingLoader({ user: "admin" });
|
||||
|
||||
assert.ok(
|
||||
data.groups.neutral[data.groups.neutral.length - 1].members?.some(
|
||||
(m) => m.id === 6,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SendouQMatchCreation.run();
|
||||
PrivateUserNoteSorting.run();
|
||||
@@ -14,7 +14,6 @@ import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import { getUser, requireUser } from "~/features/auth/core/user.server";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import {
|
||||
parseRequestFormData,
|
||||
validate,
|
||||
@@ -24,20 +23,21 @@ import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
SENDOUQ_LOOKING_PAGE,
|
||||
SENDOUQ_PAGE,
|
||||
SENDOUQ_SETTINGS_PAGE,
|
||||
navIconUrl,
|
||||
sendouQMatchPage,
|
||||
} from "~/utils/urls";
|
||||
import { GroupCard } from "../components/GroupCard";
|
||||
import { groupAfterMorph, hasGroupManagerPerms } from "../core/groups";
|
||||
import {
|
||||
addFutureMatchModes,
|
||||
addReplayIndicator,
|
||||
addSkillsToGroups,
|
||||
censorGroups,
|
||||
divideGroups,
|
||||
filterOutGroupsWithIncompatibleMapListPreference,
|
||||
groupExpiryStatus,
|
||||
membersNeededForFull,
|
||||
sortGroupsBySkill,
|
||||
sortGroupsBySkillAndSentiment,
|
||||
} from "../core/groups.server";
|
||||
import { createMatchMemento, matchMapList } from "../core/match.server";
|
||||
import { FULL_GROUP_SIZE } from "../q-constants";
|
||||
@@ -54,8 +54,6 @@ import { groupSize } from "../queries/groupSize.server";
|
||||
import { groupSuccessorOwner } from "../queries/groupSuccessorOwner";
|
||||
import { leaveGroup } from "../queries/leaveGroup.server";
|
||||
import { likeExists } from "../queries/likeExists.server";
|
||||
import { findLookingGroups } from "../queries/lookingGroups.server";
|
||||
import { mapPoolByGroupId } from "../queries/mapPoolByGroupId.server";
|
||||
import { morphGroups } from "../queries/morphGroups.server";
|
||||
import { refreshGroup } from "../queries/refreshGroup.server";
|
||||
import { removeManagerRole } from "../queries/removeManagerRole.server";
|
||||
@@ -75,9 +73,15 @@ import { updateNote } from "../queries/updateNote.server";
|
||||
import { GroupLeaver } from "../components/GroupLeaver";
|
||||
import * as NotificationService from "~/features/chat/NotificationService.server";
|
||||
import { chatCodeByGroupId } from "../queries/chatCodeByGroupId.server";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import { Flipper } from "react-flip-toolkit";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { useUser } from "~/features/auth/core";
|
||||
import { LinkButton } from "~/components/Button";
|
||||
import { Image } from "~/components/Image";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q"],
|
||||
i18n: ["user", "q"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("sendouq"),
|
||||
href: SENDOUQ_LOOKING_PAGE,
|
||||
@@ -154,7 +158,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookingGroups = findLookingGroups({
|
||||
const lookingGroups = await QRepository.findLookingGroups({
|
||||
maxGroupSize: membersNeededForFull(groupSize(currentGroup.id)),
|
||||
ownGroupId: currentGroup.id,
|
||||
includeChatCode: true,
|
||||
@@ -216,7 +220,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lookingGroups = findLookingGroups({
|
||||
const lookingGroups = await QRepository.findLookingGroups({
|
||||
minGroupSize: FULL_GROUP_SIZE,
|
||||
ownGroupId: currentGroup.id,
|
||||
includeChatCode: true,
|
||||
@@ -246,16 +250,30 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
"Their group already has a match",
|
||||
);
|
||||
|
||||
const ourGroupPreferences = await QRepository.mapModePreferencesByGroupId(
|
||||
ourGroup.id,
|
||||
);
|
||||
const theirGroupPreferences =
|
||||
await QRepository.mapModePreferencesByGroupId(theirGroup.id);
|
||||
const mapList = matchMapList(
|
||||
{
|
||||
id: ourGroup.id,
|
||||
preferences: ourGroupPreferences,
|
||||
},
|
||||
{
|
||||
id: theirGroup.id,
|
||||
preferences: theirGroupPreferences,
|
||||
},
|
||||
);
|
||||
const createdMatch = createMatch({
|
||||
alphaGroupId: ourGroup.id,
|
||||
bravoGroupId: theirGroup.id,
|
||||
mapList: matchMapList({
|
||||
ourGroup,
|
||||
theirGroup,
|
||||
ourMapPool: new MapPool(mapPoolByGroupId(ourGroup.id)),
|
||||
theirMapPool: new MapPool(mapPoolByGroupId(theirGroup.id)),
|
||||
mapList,
|
||||
memento: await createMatchMemento({
|
||||
own: { group: ourGroup, preferences: ourGroupPreferences },
|
||||
their: { group: theirGroup, preferences: theirGroupPreferences },
|
||||
mapList,
|
||||
}),
|
||||
memento: await createMatchMemento(ourGroup, theirGroup),
|
||||
});
|
||||
|
||||
if (ourGroup.chatCode && theirGroup.chatCode) {
|
||||
@@ -350,6 +368,14 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
|
||||
break;
|
||||
}
|
||||
case "DELETE_PRIVATE_USER_NOTE": {
|
||||
await QRepository.deletePrivateUserNote({
|
||||
authorId: user.id,
|
||||
targetId: data.targetId,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
@@ -377,12 +403,14 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
const groupIsFull = currentGroupSize === FULL_GROUP_SIZE;
|
||||
|
||||
const dividedGroups = divideGroups({
|
||||
groups: findLookingGroups({
|
||||
groups: await QRepository.findLookingGroups({
|
||||
maxGroupSize: groupIsFull
|
||||
? undefined
|
||||
: membersNeededForFull(currentGroupSize),
|
||||
minGroupSize: groupIsFull ? FULL_GROUP_SIZE : undefined,
|
||||
ownGroupId: currentGroup.id,
|
||||
includeMapModePreferences: groupIsFull,
|
||||
loggedInUserId: user?.id,
|
||||
}),
|
||||
ownGroupId: currentGroup.id,
|
||||
likes: findLikes(currentGroup.id),
|
||||
@@ -399,17 +427,15 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
userSkills: calculatedUserSkills,
|
||||
});
|
||||
|
||||
const compatibleGroups = groupIsFull
|
||||
? filterOutGroupsWithIncompatibleMapListPreference(groupsWithSkills)
|
||||
: groupsWithSkills;
|
||||
const groupsWithFutureMatchModes = addFutureMatchModes(groupsWithSkills);
|
||||
|
||||
const groupsWithReplayIndicator = groupIsFull
|
||||
? addReplayIndicator({
|
||||
groups: compatibleGroups,
|
||||
groups: groupsWithFutureMatchModes,
|
||||
recentMatchPlayers: findRecentMatchPlayersByUserId(user!.id),
|
||||
userId: user!.id,
|
||||
})
|
||||
: compatibleGroups;
|
||||
: groupsWithFutureMatchModes;
|
||||
|
||||
const censoredGroups = censorGroups({
|
||||
groups: groupsWithReplayIndicator,
|
||||
@@ -417,7 +443,7 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
showInviteCode: hasGroupManagerPerms(currentGroup.role) && !groupIsFull,
|
||||
});
|
||||
|
||||
const sortedGroups = sortGroupsBySkill({
|
||||
const sortedGroups = sortGroupsBySkillAndSentiment({
|
||||
groups: censoredGroups,
|
||||
intervals,
|
||||
userSkills: calculatedUserSkills,
|
||||
@@ -436,27 +462,41 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
};
|
||||
|
||||
export default function QLookingPage() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [searchParams] = useSearchParams();
|
||||
useAutoRefresh(data.lastUpdated);
|
||||
|
||||
const wasTryingToJoinAnotherTeam = searchParams.get("joining") === "true";
|
||||
|
||||
const isAlone = data.groups.own.members!.length === 1;
|
||||
const hasWeaponPool = Boolean(
|
||||
data.groups.own.members!.find((m) => m.id === user?.id)?.weapons,
|
||||
);
|
||||
const hasVCStatus =
|
||||
(data.groups.own.members!.find((m) => m.id === user?.id)?.languages ?? [])
|
||||
.length > 0;
|
||||
const showGoToSettingPrompt = isAlone && (!hasWeaponPool || !hasVCStatus);
|
||||
|
||||
return (
|
||||
<Main className="stack md">
|
||||
<InfoText />
|
||||
{wasTryingToJoinAnotherTeam ? (
|
||||
<div className="text-warning text-center">
|
||||
Before joining another group, leave the current one
|
||||
{t("q:looking.joiningGroupError")}
|
||||
</div>
|
||||
) : null}
|
||||
{showGoToSettingPrompt ? (
|
||||
<Alert variation="INFO">{t("q:looking.goToSettingsPrompt")}</Alert>
|
||||
) : null}
|
||||
<Groups />
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoText() {
|
||||
const { i18n } = useTranslation();
|
||||
const { t, i18n } = useTranslation(["q"]);
|
||||
const isMounted = useIsMounted();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher();
|
||||
@@ -467,14 +507,14 @@ function InfoText() {
|
||||
method="post"
|
||||
className="text-xs text-lighter ml-auto text-error stack horizontal sm"
|
||||
>
|
||||
Group hidden due to inactivity. Still looking?{" "}
|
||||
{t("q:looking.inactiveGroup")}{" "}
|
||||
<SubmitButton
|
||||
size="tiny"
|
||||
variant="minimal"
|
||||
_action="REFRESH_GROUP"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Click here
|
||||
{t("q:looking.inactiveGroup.action")}
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
);
|
||||
@@ -486,14 +526,14 @@ function InfoText() {
|
||||
method="post"
|
||||
className="text-xs text-lighter ml-auto text-warning stack horizontal sm"
|
||||
>
|
||||
Group will be marked inactive. Still looking?{" "}
|
||||
{t("q:looking.inactiveGroup.soon")}{" "}
|
||||
<SubmitButton
|
||||
size="tiny"
|
||||
variant="minimal"
|
||||
_action="REFRESH_GROUP"
|
||||
state={fetcher.state}
|
||||
>
|
||||
Click here
|
||||
{t("q:looking.inactiveGroup.action")}
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
);
|
||||
@@ -501,20 +541,30 @@ function InfoText() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("text-xs text-lighter ml-auto", {
|
||||
className={clsx("text-xs text-lighter stack horizontal justify-between", {
|
||||
invisible: !isMounted,
|
||||
})}
|
||||
>
|
||||
<LinkButton
|
||||
to={SENDOUQ_SETTINGS_PAGE}
|
||||
size="tiny"
|
||||
variant="outlined"
|
||||
className="stack horizontal xs"
|
||||
>
|
||||
<Image path={navIconUrl("settings")} alt="" width={18} />
|
||||
{t("q:front.nav.settings.title")}
|
||||
</LinkButton>
|
||||
{isMounted
|
||||
? `Last updated at ${new Date(data.lastUpdated).toLocaleTimeString(
|
||||
i18n.language,
|
||||
)}`
|
||||
? t("q:looking.lastUpdatedAt", {
|
||||
time: new Date(data.lastUpdated).toLocaleTimeString(i18n.language),
|
||||
})
|
||||
: "Placeholder"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Groups() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
@@ -562,26 +612,63 @@ function Groups() {
|
||||
|
||||
const renderChat = data.groups.own.members!.length > 1;
|
||||
|
||||
const invitedGroupsDesktop = (
|
||||
<div className="stack sm">
|
||||
<ColumnHeader>
|
||||
{t(
|
||||
isFullGroup
|
||||
? "q:looking.columns.challenged"
|
||||
: "q:looking.columns.invited",
|
||||
)}
|
||||
</ColumnHeader>
|
||||
{data.groups.neutral
|
||||
.filter((group) => group.isLiked)
|
||||
.map((group) => {
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action="UNLIKE"
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
showNote
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
const chatElement = (
|
||||
<div>
|
||||
{renderChat ? (
|
||||
<Chat
|
||||
rooms={rooms}
|
||||
users={chatUsers}
|
||||
className="w-full q__chat-container"
|
||||
messagesContainerClassName="q__chat-messages-container"
|
||||
onNewMessage={onNewMessage}
|
||||
chat={chat}
|
||||
onMount={onChatMount}
|
||||
onUnmount={onChatUnmount}
|
||||
/>
|
||||
<>
|
||||
<Chat
|
||||
rooms={rooms}
|
||||
users={chatUsers}
|
||||
className="w-full q__chat-container"
|
||||
messagesContainerClassName="q__chat-messages-container"
|
||||
onNewMessage={onNewMessage}
|
||||
chat={chat}
|
||||
onMount={onChatMount}
|
||||
onUnmount={onChatUnmount}
|
||||
/>
|
||||
<div className="mt-4">{invitedGroupsDesktop}</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const ownGroupElement = (
|
||||
<div className="stack md">
|
||||
<GroupCard group={data.groups.own} ownRole={data.role} ownGroup />
|
||||
{!renderChat && (
|
||||
<ColumnHeader>{t("q:looking.columns.myGroup")}</ColumnHeader>
|
||||
)}
|
||||
<GroupCard
|
||||
group={data.groups.own}
|
||||
ownRole={data.role}
|
||||
ownGroup
|
||||
showNote
|
||||
/>
|
||||
{ownGroup.inviteCode ? (
|
||||
<MemberAdder
|
||||
inviteCode={ownGroup.inviteCode}
|
||||
@@ -591,133 +678,172 @@ function Groups() {
|
||||
<GroupLeaver
|
||||
type={ownGroup.members.length === 1 ? "LEAVE_Q" : "LEAVE_GROUP"}
|
||||
/>
|
||||
{!isMobile ? invitedGroupsDesktop : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
const flipKey = `${data.groups.neutral
|
||||
.map((g) => `${g.id}-${g.isLiked}`)
|
||||
.join(":")};${data.groups.likesReceived.map((g) => g.id).join(":")}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("q__groups-container", {
|
||||
"q__groups-container__mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
{!isMobile ? (
|
||||
<div>
|
||||
<Flipper flipKey={flipKey}>
|
||||
<div
|
||||
className={clsx("q__groups-container", {
|
||||
"q__groups-container__mobile": isMobile,
|
||||
})}
|
||||
>
|
||||
{!isMobile ? (
|
||||
<div>
|
||||
<NewTabs
|
||||
disappearing
|
||||
type="divider"
|
||||
tabs={[
|
||||
{
|
||||
label: t("q:looking.columns.myGroup"),
|
||||
number: data.groups.own.members!.length,
|
||||
},
|
||||
{
|
||||
label: t("q:looking.columns.chat"),
|
||||
hidden: !renderChat,
|
||||
number: unseenMessages,
|
||||
},
|
||||
]}
|
||||
content={[
|
||||
{
|
||||
key: "own",
|
||||
element: ownGroupElement,
|
||||
},
|
||||
{
|
||||
key: "chat",
|
||||
element: chatElement,
|
||||
hidden: !data.chatCode,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="q__groups-inner-container">
|
||||
<NewTabs
|
||||
disappearing
|
||||
scrolling={isMobile}
|
||||
tabs={[
|
||||
{
|
||||
label: "Roster",
|
||||
number: data.groups.own.members!.length,
|
||||
label: t("q:looking.columns.groups"),
|
||||
number: data.groups.neutral.length,
|
||||
},
|
||||
{
|
||||
label: "Chat",
|
||||
hidden: !renderChat,
|
||||
label: t(
|
||||
isFullGroup
|
||||
? "q:looking.columns.challenges"
|
||||
: "q:looking.columns.invitations",
|
||||
),
|
||||
number: data.groups.likesReceived.length,
|
||||
hidden: !isMobile,
|
||||
},
|
||||
{
|
||||
label: t("q:looking.columns.myGroup"),
|
||||
number: data.groups.own.members!.length,
|
||||
hidden: !isMobile,
|
||||
},
|
||||
{
|
||||
label: t("q:looking.columns.chat"),
|
||||
hidden: !isMobile || !renderChat,
|
||||
number: unseenMessages,
|
||||
},
|
||||
]}
|
||||
content={[
|
||||
{
|
||||
key: "groups",
|
||||
element: (
|
||||
<div className="stack sm">
|
||||
<ColumnHeader>
|
||||
{t("q:looking.columns.available")}
|
||||
</ColumnHeader>
|
||||
{data.groups.neutral
|
||||
.filter((group) => isMobile || !group.isLiked)
|
||||
.map((group) => {
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={group.isLiked ? "UNLIKE" : "LIKE"}
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
showNote
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "received",
|
||||
hidden: !isMobile,
|
||||
element: (
|
||||
<div className="stack sm">
|
||||
{data.groups.likesReceived.map((group) => {
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
showNote
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "own",
|
||||
hidden: !isMobile,
|
||||
element: ownGroupElement,
|
||||
},
|
||||
{
|
||||
key: "chat",
|
||||
element: chatElement,
|
||||
hidden: !data.chatCode,
|
||||
hidden: !isMobile || !data.chatCode,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="q__groups-inner-container">
|
||||
<NewTabs
|
||||
scrolling={isMobile}
|
||||
tabs={[
|
||||
{
|
||||
label: "Groups",
|
||||
number: data.groups.neutral.length,
|
||||
},
|
||||
{
|
||||
label: isFullGroup ? "Challenges" : "Invitations",
|
||||
number: data.groups.likesReceived.length,
|
||||
hidden: !isMobile,
|
||||
},
|
||||
{
|
||||
label: "Roster",
|
||||
number: data.groups.own.members!.length,
|
||||
hidden: !isMobile,
|
||||
},
|
||||
{
|
||||
label: "Chat",
|
||||
hidden: !isMobile || !renderChat,
|
||||
number: unseenMessages,
|
||||
},
|
||||
]}
|
||||
content={[
|
||||
{
|
||||
key: "groups",
|
||||
element: (
|
||||
<div className="stack sm">
|
||||
{data.groups.neutral.map((group) => {
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={group.isLiked ? "UNLIKE" : "LIKE"}
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "received",
|
||||
hidden: !isMobile,
|
||||
element: (
|
||||
<div className="stack sm">
|
||||
{data.groups.likesReceived.map((group) => {
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "own",
|
||||
hidden: !isMobile,
|
||||
element: ownGroupElement,
|
||||
},
|
||||
{
|
||||
key: "chat",
|
||||
element: chatElement,
|
||||
hidden: !isMobile || !data.chatCode,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{!isMobile ? (
|
||||
<div className="stack sm">
|
||||
<ColumnHeader>
|
||||
{t(
|
||||
isFullGroup
|
||||
? "q:looking.columns.challenges"
|
||||
: "q:looking.columns.invitations",
|
||||
)}
|
||||
</ColumnHeader>
|
||||
{data.groups.likesReceived.map((group) => {
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
showNote
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!isMobile ? (
|
||||
<div className="stack sm q__groups-container__right">
|
||||
{data.groups.likesReceived.map((group) => {
|
||||
return (
|
||||
<GroupCard
|
||||
key={group.id}
|
||||
group={group}
|
||||
action={isFullGroup ? "MATCH_UP" : "GROUP_UP"}
|
||||
ownRole={data.role}
|
||||
isExpired={data.expiryStatus === "EXPIRED"}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Flipper>
|
||||
);
|
||||
}
|
||||
|
||||
function ColumnHeader({ children }: { children: React.ReactNode }) {
|
||||
const { width } = useWindowSize();
|
||||
|
||||
const isMobile = width < 750;
|
||||
|
||||
if (isMobile) return null;
|
||||
|
||||
return <div className="q__column-header">{children}</div>;
|
||||
}
|
||||
|
||||
@@ -3,16 +3,23 @@ import type {
|
||||
LinksFunction,
|
||||
LoaderArgs,
|
||||
SerializeFrom,
|
||||
V2_MetaFunction,
|
||||
} from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import type { FetcherWithComponents } from "@remix-run/react";
|
||||
import { Link, useFetcher, useLoaderData } from "@remix-run/react";
|
||||
import {
|
||||
Link,
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import { Flipped, Flipper } from "react-flip-toolkit";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Button, LinkButton } from "~/components/Button";
|
||||
import { WeaponCombobox } from "~/components/Combobox";
|
||||
import { Divider } from "~/components/Divider";
|
||||
import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
@@ -27,7 +34,7 @@ import { sql } from "~/db/sql";
|
||||
import type { GroupMember, ReportedWeapon } from "~/db/types";
|
||||
import * as NotificationService from "~/features/chat/NotificationService.server";
|
||||
import type { ChatMessage } from "~/features/chat/chat-types";
|
||||
import { ConnectedChat, type ChatProps } from "~/features/chat/components/Chat";
|
||||
import { type ChatProps, Chat, useChat } from "~/features/chat/components/Chat";
|
||||
import { currentSeason } from "~/features/mmr";
|
||||
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
@@ -41,7 +48,7 @@ import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { animate } from "~/utils/flip";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
import { notFoundIfFalsy, parseRequestFormData, validate } from "~/utils/remix";
|
||||
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
|
||||
import { inGameNameWithoutDiscriminator, makeTitle } from "~/utils/strings";
|
||||
import type { Unpacked } from "~/utils/types";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
@@ -50,6 +57,8 @@ import {
|
||||
SENDOUQ_RULES_PAGE,
|
||||
SENDOU_INK_DISCORD_URL,
|
||||
navIconUrl,
|
||||
preferenceEmojiUrl,
|
||||
sendouQMatchPage,
|
||||
teamPage,
|
||||
userSubmittedImage,
|
||||
} from "~/utils/urls";
|
||||
@@ -75,22 +84,49 @@ 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 { createGroupFromPreviousGroup } from "../queries/createGroup.server";
|
||||
import { deleteReporterWeaponsByMatchId } from "../queries/deleteReportedWeaponsByMatchId.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findMatchById } from "../queries/findMatchById.server";
|
||||
import { groupForMatch } from "../queries/groupForMatch.server";
|
||||
import { reportScore } from "../queries/reportScore.server";
|
||||
import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
|
||||
import { setGroupAsInactive } from "../queries/setGroupAsInactive.server";
|
||||
import { useRecentlyReportedWeapons } from "../q-hooks";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import { AddPrivateNoteDialog } from "~/features/sendouq-match/components/AddPrivateNoteDialog";
|
||||
import { safeNumberParse } from "~/utils/number";
|
||||
import { ScaleIcon } from "~/components/icons/Scale";
|
||||
import { DiscordIcon } from "~/components/icons/Discord";
|
||||
import { useWindowSize } from "~/hooks/useWindowSize";
|
||||
import { joinListToNaturalString } from "~/utils/arrays";
|
||||
import { NewTabs } from "~/components/NewTabs";
|
||||
|
||||
export const meta: V2_MetaFunction = (args) => {
|
||||
const data = args.data as SerializeFrom<typeof loader> | null;
|
||||
|
||||
if (!data) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
title: makeTitle(`SendouQ Match #${data.match.id}`),
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content: `${joinListToNaturalString(
|
||||
data.groupAlpha.members.map((m) => m.discordName),
|
||||
)} vs. ${joinListToNaturalString(
|
||||
data.groupBravo.members.map((m) => m.discordName),
|
||||
)}`,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const links: LinksFunction = () => {
|
||||
return [{ rel: "stylesheet", href: styles }];
|
||||
};
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q", "tournament"],
|
||||
i18n: ["q", "tournament", "user"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("sendouq"),
|
||||
href: SENDOUQ_PAGE,
|
||||
@@ -137,11 +173,15 @@ export const action = async ({ request, params }: ActionArgs) => {
|
||||
"Only mods can report scores as admin",
|
||||
);
|
||||
const members = [
|
||||
...groupForMatch(match.alphaGroupId)!.members.map((m) => ({
|
||||
...(await QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
}))!.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.alphaGroupId,
|
||||
})),
|
||||
...groupForMatch(match.bravoGroupId)!.members.map((m) => ({
|
||||
...(await QMatchRepository.findGroupById({
|
||||
groupId: match.bravoGroupId,
|
||||
}))!.members.map((m) => ({
|
||||
...m,
|
||||
groupId: match.bravoGroupId,
|
||||
})),
|
||||
@@ -183,8 +223,12 @@ export const action = async ({ request, params }: ActionArgs) => {
|
||||
compared === "SAME" && !matchIsBeingCanceled
|
||||
? calculateMatchSkills({
|
||||
groupMatchId: match.id,
|
||||
winner: groupForMatch(winnerGroupId)!.members.map((m) => m.id),
|
||||
loser: groupForMatch(loserGroupId)!.members.map((m) => m.id),
|
||||
winner: (await QMatchRepository.findGroupById({
|
||||
groupId: winnerGroupId,
|
||||
}))!.members.map((m) => m.id),
|
||||
loser: (await QMatchRepository.findGroupById({
|
||||
groupId: loserGroupId,
|
||||
}))!.members.map((m) => m.id),
|
||||
winnerGroupId,
|
||||
loserGroupId,
|
||||
})
|
||||
@@ -274,7 +318,9 @@ export const action = async ({ request, params }: ActionArgs) => {
|
||||
const season = currentSeason(new Date());
|
||||
validate(season, "Season is not active");
|
||||
|
||||
const previousGroup = groupForMatch(data.previousGroupId);
|
||||
const previousGroup = await QMatchRepository.findGroupById({
|
||||
groupId: data.previousGroupId,
|
||||
});
|
||||
validate(previousGroup, "Previous group not found");
|
||||
|
||||
for (const member of previousGroup.members) {
|
||||
@@ -288,7 +334,7 @@ export const action = async ({ request, params }: ActionArgs) => {
|
||||
}
|
||||
}
|
||||
|
||||
createGroupFromPreviousGroup({
|
||||
await QRepository.createGroupFromPrevious({
|
||||
previousGroupId: data.previousGroupId,
|
||||
members: previousGroup.members.map((m) => ({ id: m.id, role: m.role })),
|
||||
});
|
||||
@@ -316,6 +362,16 @@ export const action = async ({ request, params }: ActionArgs) => {
|
||||
|
||||
break;
|
||||
}
|
||||
case "ADD_PRIVATE_USER_NOTE": {
|
||||
await QRepository.upsertPrivateUserNote({
|
||||
authorId: user.id,
|
||||
sentiment: data.sentiment,
|
||||
targetId: data.targetId,
|
||||
text: data.comment,
|
||||
});
|
||||
|
||||
throw redirect(sendouQMatchPage(matchId));
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
@@ -327,11 +383,19 @@ export const action = async ({ request, params }: ActionArgs) => {
|
||||
export const loader = async ({ params, request }: LoaderArgs) => {
|
||||
const user = await getUserId(request);
|
||||
const matchId = matchIdFromParams(params);
|
||||
const match = notFoundIfFalsy(findMatchById(matchId));
|
||||
const match = notFoundIfFalsy(await QMatchRepository.findById(matchId));
|
||||
|
||||
const groupAlpha = groupForMatch(match.alphaGroupId);
|
||||
const [groupAlpha, groupBravo] = await Promise.all([
|
||||
QMatchRepository.findGroupById({
|
||||
groupId: match.alphaGroupId,
|
||||
loggedInUserId: user?.id,
|
||||
}),
|
||||
QMatchRepository.findGroupById({
|
||||
groupId: match.bravoGroupId,
|
||||
loggedInUserId: user?.id,
|
||||
}),
|
||||
]);
|
||||
invariant(groupAlpha, "Group alpha not found");
|
||||
const groupBravo = groupForMatch(match.bravoGroupId);
|
||||
invariant(groupBravo, "Group bravo not found");
|
||||
|
||||
const censoredGroupAlpha = { ...groupAlpha, chatCode: undefined };
|
||||
@@ -382,11 +446,11 @@ export const loader = async ({ params, request }: LoaderArgs) => {
|
||||
export default function QMatchPage() {
|
||||
const user = useUser();
|
||||
const isMounted = useIsMounted();
|
||||
const { i18n } = useTranslation();
|
||||
const { t, i18n } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [showWeaponsForm, setShowWeaponsForm] = React.useState(false);
|
||||
const submitScoreFetcher = useFetcher<typeof action>();
|
||||
const cancelScoreFetcher = useFetcher<typeof action>();
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
React.useEffect(() => {
|
||||
setShowWeaponsForm(false);
|
||||
@@ -412,33 +476,18 @@ export default function QMatchPage() {
|
||||
const showScore =
|
||||
data.match.isLocked || (data.match.reportedByUserId && ownGroup);
|
||||
|
||||
const poolCode = () => {
|
||||
const stringId = String(data.match.id);
|
||||
const lastDigit = stringId[stringId.length - 1];
|
||||
|
||||
return `SQ${lastDigit}`;
|
||||
};
|
||||
|
||||
const chatUsers = React.useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
[...data.groupAlpha.members, ...data.groupBravo.members].map((m) => [
|
||||
m.id,
|
||||
m,
|
||||
]),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const chatRooms = React.useMemo(() => {
|
||||
return [
|
||||
data.matchChatCode ? { code: data.matchChatCode, label: "Match" } : null,
|
||||
data.groupChatCode ? { code: data.groupChatCode, label: "Group" } : null,
|
||||
].filter(Boolean) as ChatProps["rooms"];
|
||||
}, [data.matchChatCode, data.groupChatCode]);
|
||||
const addingNoteFor = (
|
||||
data.groupMemberOf === "ALPHA" ? data.groupAlpha : data.groupBravo
|
||||
).members.find((m) => m.id === safeNumberParse(searchParams.get("note")));
|
||||
|
||||
return (
|
||||
<Main className="q-match__container stack lg">
|
||||
<Main className="q-match__container stack xl">
|
||||
<AddPrivateNoteDialog
|
||||
aboutUser={addingNoteFor}
|
||||
close={() => navigate(sendouQMatchPage(data.match.id))}
|
||||
/>
|
||||
<div className="q-match__header">
|
||||
<h2>Match #{data.match.id}</h2>
|
||||
<h2>{t("q:match.header", { number: data.match.id })}</h2>
|
||||
<div
|
||||
className={clsx("text-xs text-lighter", {
|
||||
invisible: !isMounted,
|
||||
@@ -479,14 +528,14 @@ export default function QMatchPage() {
|
||||
) : null}
|
||||
{!showWeaponsForm ? (
|
||||
<>
|
||||
<div
|
||||
className={clsx("q-match__teams-container", {
|
||||
"with-chat": data.matchChatCode || data.groupChatCode,
|
||||
})}
|
||||
>
|
||||
<div className="q-match__teams-container">
|
||||
{[data.groupAlpha, data.groupBravo].map((group, i) => {
|
||||
const side = i === 0 ? "ALPHA" : "BRAVO";
|
||||
|
||||
const matchHasBeenReported = Boolean(data.match.reportedByUserId);
|
||||
const showAddNote =
|
||||
data.groupMemberOf === side && matchHasBeenReported;
|
||||
|
||||
return (
|
||||
<div className="stack sm text-lighter text-xs" key={group.id}>
|
||||
<div className="stack horizontal justify-between items-center">
|
||||
@@ -509,94 +558,19 @@ export default function QMatchPage() {
|
||||
<GroupCard
|
||||
group={group}
|
||||
displayOnly
|
||||
hideVc={data.match.isLocked || data.groupMemberOf !== side}
|
||||
hideWeapons={data.match.isLocked}
|
||||
hideVc={matchHasBeenReported}
|
||||
hideWeapons={matchHasBeenReported}
|
||||
showAddNote={showAddNote}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{chatRooms.length > 0 ? (
|
||||
<ConnectedChat
|
||||
users={chatUsers}
|
||||
rooms={chatRooms}
|
||||
disabled={!data.canPostChatMessages}
|
||||
// we don't want the user to lose the weapons they are reporting
|
||||
// when the match gets suddenly locked
|
||||
revalidates={false}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{!data.match.isLocked && (ownMember || isMod(user)) ? (
|
||||
<div>
|
||||
<div className="stack horizontal justify-between">
|
||||
<Link to={SENDOUQ_RULES_PAGE} className="text-xxs font-bold">
|
||||
Read the rules
|
||||
</Link>
|
||||
{canReportScore && !data.match.isLocked ? (
|
||||
<FormWithConfirm
|
||||
dialogHeading="Cancel match? (Check rules)"
|
||||
fields={[
|
||||
["_action", "REPORT_SCORE"],
|
||||
["winners", "[]"],
|
||||
]}
|
||||
deleteButtonText="Cancel"
|
||||
cancelButtonText="Nevermind"
|
||||
fetcher={cancelScoreFetcher}
|
||||
>
|
||||
<Button
|
||||
className="build__small-text"
|
||||
variant="minimal-destructive"
|
||||
size="tiny"
|
||||
type="submit"
|
||||
disabled={
|
||||
ownTeamReported && !data.match.mapList[0].winnerGroupId
|
||||
}
|
||||
>
|
||||
Cancel match
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="q-match__join-discord-section">
|
||||
If needed, contact your opponent on the <b>#match-meetup</b>{" "}
|
||||
channel of the sendou.ink Discord:{" "}
|
||||
<a
|
||||
href={SENDOU_INK_DISCORD_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{SENDOU_INK_DISCORD_URL}
|
||||
</a>
|
||||
. Alpha team hosts. Password should be{" "}
|
||||
<span className="q-match__join-discord-section__highlighted">
|
||||
{resolveRoomPass(data.match.id)}
|
||||
</span>
|
||||
. Pool code is{" "}
|
||||
<span className="q-match__join-discord-section__highlighted">
|
||||
{poolCode()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{cancelScoreFetcher.data?.error === "cant-cancel" ? (
|
||||
<div className="text-xs text-warning font-semi-bold text-center">
|
||||
Can't cancel since opponent has already reported score for
|
||||
this match. See dispute instructions at the top of the page.
|
||||
</div>
|
||||
) : null}
|
||||
<MapList
|
||||
key={data.match.id}
|
||||
<BottomSection
|
||||
canReportScore={canReportScore}
|
||||
isResubmission={ownTeamReported}
|
||||
fetcher={submitScoreFetcher}
|
||||
ownTeamReported={ownTeamReported}
|
||||
participatingInTheMatch={Boolean(ownMember)}
|
||||
/>
|
||||
{submitScoreFetcher.data?.error === "different" ? (
|
||||
<div className="text-xs text-warning font-semi-bold text-center">
|
||||
You reported different results than your opponent. Double check
|
||||
the above is correct and otherwise see dispute instructions at the
|
||||
top of the page.
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</Main>
|
||||
@@ -611,7 +585,7 @@ function Score({
|
||||
ownTeamReported: boolean;
|
||||
}) {
|
||||
const isMounted = useIsMounted();
|
||||
const { i18n } = useTranslation();
|
||||
const { t, i18n } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const reporter =
|
||||
data.groupAlpha.members.find((m) => m.id === data.match.reportedByUserId) ??
|
||||
@@ -633,13 +607,15 @@ function Score({
|
||||
if (score[0] === 0 && score[1] === 0) {
|
||||
return (
|
||||
<div className="stack items-center line-height-tight">
|
||||
<div className="text-sm font-bold text-warning">Match canceled</div>
|
||||
<div className="text-sm font-bold text-warning">
|
||||
{t("q:match.canceled")}
|
||||
</div>
|
||||
{!data.match.isLocked ? (
|
||||
<div className="text-xs text-lighter stack xs items-center text-center">
|
||||
{!ownTeamReported ? (
|
||||
<DisputePopover />
|
||||
) : (
|
||||
"Pending other team's confirmation"
|
||||
t("q:match.cancelPendingConfirmation")
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
@@ -654,7 +630,7 @@ function Score({
|
||||
<div
|
||||
className={clsx("text-xs text-lighter", { invisible: !isMounted })}
|
||||
>
|
||||
Reported by {reporter?.discordName ?? <b>admin</b>} at{" "}
|
||||
{t("q:match.reportedBy", { name: reporter?.discordName ?? "admin" })}{" "}
|
||||
{isMounted
|
||||
? databaseTimestampToDate(reportedAt).toLocaleString(
|
||||
i18n.language,
|
||||
@@ -670,8 +646,7 @@ function Score({
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-lighter stack xs items-center text-center">
|
||||
SP will be adjusted after both teams report the same results{" "}
|
||||
{!ownTeamReported ? <DisputePopover /> : null}
|
||||
{t("q:match.spInfo")} {!ownTeamReported ? <DisputePopover /> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -679,18 +654,15 @@ function Score({
|
||||
}
|
||||
|
||||
function DisputePopover() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<Popover buttonChildren="Dispute?" containerClassName="text-main-forced">
|
||||
<p>
|
||||
If there is a mistake contact the other team to correct it on their
|
||||
side. Score can be freely rereported till both teams report the same
|
||||
result.
|
||||
</p>
|
||||
<p className="mt-2">
|
||||
If there is a problem talking with the other team, contact a mod on the
|
||||
sendou.ink Discord helpdesk. Provide screenshots that show the correct
|
||||
score.
|
||||
</p>
|
||||
<Popover
|
||||
buttonChildren={t("q:match.dispute.button")}
|
||||
containerClassName="text-main-forced"
|
||||
>
|
||||
<p>{t("q:match.dispute.p1")}</p>
|
||||
<p className="mt-2">{t("q:match.dispute.p2")}</p>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -708,6 +680,7 @@ function AfterMatchActions({
|
||||
showWeaponsForm: boolean;
|
||||
setShowWeaponsForm: (show: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const lookAgainFetcher = useFetcher();
|
||||
|
||||
@@ -736,7 +709,7 @@ function AfterMatchActions({
|
||||
state={lookAgainFetcher.state}
|
||||
_action="LOOK_AGAIN"
|
||||
>
|
||||
Look again with same group
|
||||
{t("q:match.actions.lookAgain")}
|
||||
</SubmitButton>
|
||||
) : null}
|
||||
{showWeaponsFormButton ? (
|
||||
@@ -745,7 +718,9 @@ function AfterMatchActions({
|
||||
onClick={() => setShowWeaponsForm(!showWeaponsForm)}
|
||||
variant={showWeaponsForm ? "destructive" : undefined}
|
||||
>
|
||||
{showWeaponsForm ? "Stop reporting weapons" : "Report used weapons"}
|
||||
{showWeaponsForm
|
||||
? t("q:match.actions.stopReportingWeapons")
|
||||
: t("q:match.actions.reportWeapons")}
|
||||
</Button>
|
||||
) : null}
|
||||
</lookAgainFetcher.Form>
|
||||
@@ -755,6 +730,7 @@ function AfterMatchActions({
|
||||
}
|
||||
|
||||
function ReportWeaponsForm() {
|
||||
const { t } = useTranslation(["q", "user"]);
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const weaponsFetcher = useFetcher();
|
||||
@@ -831,9 +807,9 @@ function ReportWeaponsForm() {
|
||||
value={JSON.stringify(weaponsUsage)}
|
||||
/>
|
||||
<div className="stack horizontal sm justify-between w-max mx-auto">
|
||||
<h3 className="text-md">Who to report?</h3>
|
||||
<h3 className="text-md">{t("q:match.report.whoToReport")}</h3>
|
||||
<label className="stack horizontal xs items-center mb-0">
|
||||
Me
|
||||
{t("q:match.report.whoToReport.me")}
|
||||
<input
|
||||
type="radio"
|
||||
checked={reportingMode === "MYSELF"}
|
||||
@@ -841,7 +817,7 @@ function ReportWeaponsForm() {
|
||||
/>
|
||||
</label>
|
||||
<label className="stack horizontal xs items-center mb-0">
|
||||
My team
|
||||
{t("q:match.report.whoToReport.myTeam")}
|
||||
<input
|
||||
type="radio"
|
||||
checked={reportingMode === "MY_TEAM"}
|
||||
@@ -849,7 +825,7 @@ function ReportWeaponsForm() {
|
||||
/>
|
||||
</label>
|
||||
<label className="stack horizontal xs items-center mb-0">
|
||||
Everyone
|
||||
{t("q:match.report.whoToReport.everyone")}
|
||||
<input
|
||||
type="radio"
|
||||
checked={reportingMode === "ALL"}
|
||||
@@ -880,7 +856,7 @@ function ReportWeaponsForm() {
|
||||
mapIndex: i,
|
||||
})}
|
||||
>
|
||||
Copy weapons from above map
|
||||
{t("q:match.report.copyWeapons")}
|
||||
</Button>
|
||||
) : null}
|
||||
<div className="stack sm">
|
||||
@@ -895,10 +871,14 @@ function ReportWeaponsForm() {
|
||||
return (
|
||||
<React.Fragment key={member.id}>
|
||||
{j === 0 && reportingMode === "ALL" ? (
|
||||
<Divider className="text-sm">Alpha</Divider>
|
||||
<Divider className="text-sm">
|
||||
{t("q:match.sides.alpha")}
|
||||
</Divider>
|
||||
) : null}
|
||||
{j === FULL_GROUP_SIZE && reportingMode === "ALL" ? (
|
||||
<Divider className="text-sm">Bravo</Divider>
|
||||
<Divider className="text-sm">
|
||||
{t("q:match.sides.bravo")}
|
||||
</Divider>
|
||||
) : null}
|
||||
<div
|
||||
key={member.id}
|
||||
@@ -909,7 +889,7 @@ function ReportWeaponsForm() {
|
||||
{member.inGameName ? (
|
||||
<>
|
||||
<span className="text-lighter font-semi-bold">
|
||||
IGN:
|
||||
{t("user:ign.short")}:
|
||||
</span>{" "}
|
||||
{inGameNameWithoutDiscriminator(
|
||||
member.inGameName,
|
||||
@@ -972,17 +952,259 @@ function ReportWeaponsForm() {
|
||||
</div>
|
||||
{weaponsUsage.flat().some((val) => val === null) ? (
|
||||
<div className="text-sm text-center text-warning font-semi-bold">
|
||||
Report all weapons to submit
|
||||
{t("q:match.report.error")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="stack items-center">
|
||||
<SubmitButton _action="REPORT_WEAPONS">Report weapons</SubmitButton>
|
||||
<SubmitButton _action="REPORT_WEAPONS">
|
||||
{t("q:match.report.submit")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
)}
|
||||
</weaponsFetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
function BottomSection({
|
||||
canReportScore,
|
||||
ownTeamReported,
|
||||
participatingInTheMatch,
|
||||
}: {
|
||||
canReportScore: boolean;
|
||||
ownTeamReported: boolean;
|
||||
participatingInTheMatch: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation(["q", "common"]);
|
||||
const { width } = useWindowSize();
|
||||
const isMobile = width < 750;
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const submitScoreFetcher = useFetcher<typeof action>();
|
||||
const cancelFetcher = useFetcher<typeof action>();
|
||||
|
||||
const chatUsers = React.useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
[...data.groupAlpha.members, ...data.groupBravo.members].map((m) => [
|
||||
m.id,
|
||||
m,
|
||||
]),
|
||||
);
|
||||
}, [data]);
|
||||
|
||||
const [_unseenMessages, setUnseenMessages] = React.useState(0);
|
||||
const [chatVisible, setChatVisible] = React.useState(false);
|
||||
|
||||
const onNewMessage = React.useCallback(() => {
|
||||
setUnseenMessages((msg) => msg + 1);
|
||||
}, []);
|
||||
|
||||
const chatRooms = React.useMemo(() => {
|
||||
return [
|
||||
data.matchChatCode ? { code: data.matchChatCode, label: "Match" } : null,
|
||||
data.groupChatCode ? { code: data.groupChatCode, label: "Group" } : null,
|
||||
].filter(Boolean) as ChatProps["rooms"];
|
||||
}, [data.matchChatCode, data.groupChatCode]);
|
||||
|
||||
const chat = useChat({ rooms: chatRooms, onNewMessage });
|
||||
|
||||
const onChatMount = React.useCallback(() => {
|
||||
setChatVisible(true);
|
||||
}, []);
|
||||
|
||||
const onChatUnmount = React.useCallback(() => {
|
||||
setChatVisible(false);
|
||||
setUnseenMessages(0);
|
||||
}, []);
|
||||
|
||||
const unseenMessages = chatVisible ? 0 : _unseenMessages;
|
||||
|
||||
const showMid =
|
||||
!data.match.isLocked && (participatingInTheMatch || isMod(user));
|
||||
|
||||
const poolCode = () => {
|
||||
const stringId = String(data.match.id);
|
||||
const lastDigit = stringId[stringId.length - 1];
|
||||
|
||||
return `SQ${lastDigit}`;
|
||||
};
|
||||
|
||||
if (!isMounted) return null;
|
||||
|
||||
const chatElement = (
|
||||
<Chat
|
||||
onNewMessage={onNewMessage}
|
||||
chat={chat}
|
||||
onMount={onChatMount}
|
||||
onUnmount={onChatUnmount}
|
||||
users={chatUsers}
|
||||
rooms={chatRooms}
|
||||
disabled={!data.canPostChatMessages}
|
||||
// we don't want the user to lose the weapons they are reporting
|
||||
// when the match gets suddenly locked
|
||||
revalidates={false}
|
||||
/>
|
||||
);
|
||||
|
||||
const mapListElement = (
|
||||
<MapList
|
||||
key={data.match.id}
|
||||
canReportScore={canReportScore}
|
||||
isResubmission={ownTeamReported}
|
||||
fetcher={submitScoreFetcher}
|
||||
/>
|
||||
);
|
||||
|
||||
const roomJoiningInfoElement = (
|
||||
<div className="q-match__pool-pass-container">
|
||||
<InfoWithHeader header={t("q:match.pool")} value={poolCode()} />
|
||||
<InfoWithHeader
|
||||
header={t("q:match.password.short")}
|
||||
value={resolveRoomPass(data.match.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const rulesButtonElement = (
|
||||
<LinkButton
|
||||
to={SENDOUQ_RULES_PAGE}
|
||||
variant="outlined"
|
||||
size="tiny"
|
||||
icon={<ScaleIcon />}
|
||||
>
|
||||
{t("q:front.nav.rules.title")}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
const helpdeskButtonElement = (
|
||||
<LinkButton
|
||||
isExternal
|
||||
to={SENDOU_INK_DISCORD_URL}
|
||||
variant="outlined"
|
||||
size="tiny"
|
||||
icon={<DiscordIcon />}
|
||||
>
|
||||
{t("q:match.helpdesk")}
|
||||
</LinkButton>
|
||||
);
|
||||
|
||||
const cancelMatchElement =
|
||||
canReportScore && !data.match.isLocked ? (
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("q:match.cancelMatch.confirm")}
|
||||
fields={[
|
||||
["_action", "REPORT_SCORE"],
|
||||
["winners", "[]"],
|
||||
]}
|
||||
deleteButtonText={t("common:actions.cancel")}
|
||||
cancelButtonText={t("common:actions.nevermind")}
|
||||
fetcher={cancelFetcher}
|
||||
>
|
||||
<Button
|
||||
variant="minimal-destructive"
|
||||
size="tiny"
|
||||
type="submit"
|
||||
disabled={ownTeamReported && !data.match.mapList[0].winnerGroupId}
|
||||
className="build__small-text mt-4"
|
||||
>
|
||||
{t("q:match.cancelMatch")}
|
||||
</Button>
|
||||
</FormWithConfirm>
|
||||
) : null;
|
||||
|
||||
const chatHidden = chatRooms.length === 0;
|
||||
|
||||
if (!showMid && chatHidden) {
|
||||
return mapListElement;
|
||||
}
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<div className="stack lg">
|
||||
<div className="stack horizontal lg items-center justify-center">
|
||||
{roomJoiningInfoElement}
|
||||
<div className="stack md">
|
||||
{rulesButtonElement}
|
||||
{helpdeskButtonElement}
|
||||
{cancelMatchElement}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<NewTabs
|
||||
sticky
|
||||
tabs={[
|
||||
{
|
||||
label: t("q:looking.columns.chat"),
|
||||
number: unseenMessages,
|
||||
hidden: chatHidden,
|
||||
},
|
||||
{
|
||||
label: t("q:match.tabs.reportScore"),
|
||||
},
|
||||
]}
|
||||
disappearing
|
||||
content={[
|
||||
{
|
||||
key: "chat",
|
||||
hidden: chatHidden,
|
||||
element: chatElement,
|
||||
},
|
||||
{
|
||||
key: "report",
|
||||
element: mapListElement,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="q-match__map-list-chat-container">
|
||||
{mapListElement}
|
||||
<div
|
||||
className={clsx("q-match__bottom-mid-section", {
|
||||
invisible: !showMid,
|
||||
})}
|
||||
>
|
||||
<div className="stack md">
|
||||
{roomJoiningInfoElement}
|
||||
{rulesButtonElement}
|
||||
{helpdeskButtonElement}
|
||||
{cancelMatchElement}
|
||||
</div>
|
||||
</div>
|
||||
<div className="q-match__chat-container">
|
||||
{chatRooms.length > 0 ? chatElement : null}
|
||||
</div>
|
||||
</div>
|
||||
{cancelFetcher.data?.error === "cant-cancel" ? (
|
||||
<div className="text-xs text-warning font-semi-bold text-center">
|
||||
{t("q:match.errors.cantCancel")}
|
||||
</div>
|
||||
) : null}
|
||||
{submitScoreFetcher.data?.error === "different" ? (
|
||||
<div className="text-xs text-warning font-semi-bold text-center">
|
||||
{t("q:match.errors.different")}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoWithHeader({ header, value }: { header: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="q-match__info__header">{header}</div>
|
||||
<div className="q-match__info__value">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapList({
|
||||
canReportScore,
|
||||
isResubmission,
|
||||
@@ -992,6 +1214,7 @@ function MapList({
|
||||
isResubmission: boolean;
|
||||
fetcher: FetcherWithComponents<any>;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [adminToggleChecked, setAdminToggleChecked] = React.useState(false);
|
||||
@@ -1081,7 +1304,9 @@ function MapList({
|
||||
<div className="stack md items-center mt-4">
|
||||
<ResultSummary winners={winners} />
|
||||
<SubmitButton _action="REPORT_SCORE" state={fetcher.state}>
|
||||
{isResubmission ? "Submit adjusted scores" : "Submit scores"}
|
||||
{isResubmission
|
||||
? t("q:match.submitScores.adjusted")
|
||||
: t("q:match.submitScores")}
|
||||
</SubmitButton>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -1114,7 +1339,7 @@ function MapListMap({
|
||||
}) {
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["game-misc", "tournament"]);
|
||||
const { t } = useTranslation(["q", "game-misc", "tournament"]);
|
||||
|
||||
const pickInfo = (source: string) => {
|
||||
if (source === "TIEBREAKER") return t("tournament:pickInfo.tiebreaker");
|
||||
@@ -1123,12 +1348,12 @@ function MapListMap({
|
||||
|
||||
if (source === String(data.match.alphaGroupId)) {
|
||||
return t("tournament:pickInfo.team.specific", {
|
||||
team: "Alpha",
|
||||
team: t("q:match.sides.alpha"),
|
||||
});
|
||||
}
|
||||
|
||||
return t("tournament:pickInfo.team.specific", {
|
||||
team: "Bravo",
|
||||
team: t("q:match.sides.bravo"),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1166,13 +1391,16 @@ function MapListMap({
|
||||
if (!winnerId)
|
||||
return (
|
||||
<>
|
||||
• <i>Unplayed</i>
|
||||
• <i>{t("q:match.results.unplayed")}</i>
|
||||
</>
|
||||
);
|
||||
|
||||
const winner = winnerId === data.match.alphaGroupId ? "Alpha" : "Bravo";
|
||||
const winnerSide =
|
||||
winnerId === data.match.alphaGroupId
|
||||
? t("q:match.sides.alpha")
|
||||
: t("q:match.sides.bravo");
|
||||
|
||||
return <>• {winner} won</>;
|
||||
return <>• {t("q:match.won", { side: winnerSide })}</>;
|
||||
};
|
||||
|
||||
const relativeSideText = (side: "ALPHA" | "BRAVO") => {
|
||||
@@ -1181,6 +1409,18 @@ function MapListMap({
|
||||
return data.groupMemberOf === side ? " (us)" : " (them)";
|
||||
};
|
||||
|
||||
const modePreferences = data.match.memento?.modePreferences?.[map.mode];
|
||||
const mapPreferences = data.match.memento?.mapPreferences?.[i];
|
||||
|
||||
const userIdToName = (userId: number) => {
|
||||
const member = [
|
||||
...data.groupAlpha.members,
|
||||
...data.groupBravo.members,
|
||||
].find((m) => m.id === userId);
|
||||
|
||||
return member?.discordName ?? "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={map.stageId} className="stack xs">
|
||||
<Flipped flipId={map.stageId}>
|
||||
@@ -1188,11 +1428,68 @@ function MapListMap({
|
||||
<StageImage stageId={map.stageId} width={64} className="rounded-sm" />
|
||||
<div>
|
||||
<div className="text-sm stack horizontal xs items-center">
|
||||
{i + 1}) <ModeImage mode={map.mode} size={18} />{" "}
|
||||
{i + 1}){" "}
|
||||
{modePreferences ? (
|
||||
<Popover
|
||||
contentClassName="text-main-forced"
|
||||
buttonChildren={<ModeImage mode={map.mode} size={18} />}
|
||||
triggerClassName="q-match__mode-popover-button"
|
||||
>
|
||||
<div className="text-md text-lighter mb-2 line-height-very-tight">
|
||||
{t(`game-misc:MODE_LONG_${map.mode}`)}
|
||||
</div>
|
||||
{modePreferences.map(({ userId, preference }) => {
|
||||
return (
|
||||
<div
|
||||
key={userId}
|
||||
className="stack horizontal items-center xs"
|
||||
>
|
||||
<img
|
||||
src={preferenceEmojiUrl(preference)}
|
||||
className="q-settings__radio__emoji"
|
||||
width={18}
|
||||
/>
|
||||
{userIdToName(userId)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Popover>
|
||||
) : (
|
||||
<ModeImage mode={map.mode} size={18} />
|
||||
)}{" "}
|
||||
{t(`game-misc:STAGE_${map.stageId}`)}
|
||||
</div>
|
||||
<div className="text-lighter text-xs">
|
||||
{pickInfo(map.source)} {winningInfoText(map.winnerGroupId)}
|
||||
{mapPreferences && mapPreferences.length > 0 ? (
|
||||
<Popover
|
||||
triggerClassName="q-match__stage-popover-button"
|
||||
contentClassName="text-main-forced"
|
||||
buttonChildren={<span>{pickInfo(map.source)}</span>}
|
||||
>
|
||||
<div className="text-md text-center text-lighter mb-2 line-height-very-tight">
|
||||
{t(`game-misc:MODE_SHORT_${map.mode}`)}{" "}
|
||||
{t(`game-misc:STAGE_${map.stageId}`)}
|
||||
</div>
|
||||
{mapPreferences.map(({ userId, preference }) => {
|
||||
return (
|
||||
<div
|
||||
key={userId}
|
||||
className="stack horizontal items-center xs"
|
||||
>
|
||||
<img
|
||||
src={preferenceEmojiUrl(preference)}
|
||||
className="q-settings__radio__emoji"
|
||||
width={18}
|
||||
/>
|
||||
{userIdToName(userId)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Popover>
|
||||
) : (
|
||||
pickInfo(map.source)
|
||||
)}{" "}
|
||||
{winningInfoText(map.winnerGroupId)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1231,7 +1528,9 @@ function MapListMap({
|
||||
}}
|
||||
>
|
||||
<div className="q-match__report-section">
|
||||
<label className="mb-0 text-theme-secondary">Winner</label>
|
||||
<label className="mb-0 text-theme-secondary">
|
||||
{t("q:match.report.winnerLabel")}
|
||||
</label>
|
||||
<div className="stack sm horizontal items-center">
|
||||
<div className="stack sm horizontal items-center font-semi-bold">
|
||||
<input
|
||||
@@ -1243,7 +1542,7 @@ function MapListMap({
|
||||
onChange={handleReportScore(i, "ALPHA")}
|
||||
/>
|
||||
<label className="mb-0" htmlFor={`alpha-${i}`}>
|
||||
{`Alpha${relativeSideText("ALPHA")}`}
|
||||
{`${t("q:match.sides.alpha")}${relativeSideText("ALPHA")}`}
|
||||
</label>
|
||||
</div>
|
||||
<div className="stack sm horizontal items-center font-semi-bold">
|
||||
@@ -1256,14 +1555,16 @@ function MapListMap({
|
||||
onChange={handleReportScore(i, "BRAVO")}
|
||||
/>
|
||||
<label className="mb-0" htmlFor={`bravo-${i}`}>
|
||||
{`Bravo${relativeSideText("BRAVO")}`}
|
||||
{`${t("q:match.sides.bravo")}${relativeSideText("BRAVO")}`}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showReportedOwnWeapon && onOwnWeaponSelected ? (
|
||||
<>
|
||||
<label className="mb-0 text-theme-secondary">Your weapon</label>
|
||||
<label className="mb-0 text-theme-secondary">
|
||||
{t("q:match.report.weaponLabel")}
|
||||
</label>
|
||||
<WeaponCombobox
|
||||
inputName="weapon"
|
||||
quickSelectWeaponIds={recentlyReportedWeapons}
|
||||
@@ -1297,6 +1598,7 @@ function MapListMap({
|
||||
}
|
||||
|
||||
function ResultSummary({ winners }: { winners: ("ALPHA" | "BRAVO")[] }) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
@@ -1325,7 +1627,10 @@ function ResultSummary({ winners }: { winners: ("ALPHA" | "BRAVO")[] }) {
|
||||
"text-warning": !userWon,
|
||||
})}
|
||||
>
|
||||
Reporting {score.join("-")} {userWon ? "win" : "loss"}
|
||||
{t("q:match.reporting", {
|
||||
score: score.join("-"),
|
||||
outcome: userWon ? t("q:match.outcome.win") : t("q:match.outcome.loss"),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,17 +29,17 @@ import styles from "../q.css";
|
||||
import { addMember } from "../queries/addMember.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findPreparingGroup } from "../queries/findPreparingGroup.server";
|
||||
import { groupForMatch } from "../queries/groupForMatch.server";
|
||||
import { refreshGroup } from "../queries/refreshGroup.server";
|
||||
import { setGroupAsActive } from "../queries/setGroupAsActive.server";
|
||||
import { trustedPlayersAvailableToPlay } from "../queries/usersInActiveGroup.server";
|
||||
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
|
||||
import { userHasSkill } from "../queries/userHasSkill.server";
|
||||
import { currentSeason } from "~/features/mmr";
|
||||
import { GroupLeaver } from "../components/GroupLeaver";
|
||||
import * as QMatchRepository from "~/features/sendouq-match/QMatchRepository.server";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q"],
|
||||
i18n: ["q", "user"],
|
||||
breadcrumb: () => ({
|
||||
imgPath: navIconUrl("sendouq"),
|
||||
href: SENDOUQ_PREPARING_PAGE,
|
||||
@@ -82,17 +82,15 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
return redirect(SENDOUQ_LOOKING_PAGE);
|
||||
}
|
||||
case "ADD_TRUSTED": {
|
||||
validate(
|
||||
userHasSkill({ userId: data.id, season: season.nth }),
|
||||
"User needs to select their initial SP first",
|
||||
);
|
||||
const available = trustedPlayersAvailableToPlay(user);
|
||||
validate(
|
||||
available.some((u) => u.id === data.id),
|
||||
"Player not available to play",
|
||||
);
|
||||
|
||||
const ownGroupWithMembers = groupForMatch(currentGroup.id);
|
||||
const ownGroupWithMembers = await QMatchRepository.findGroupById({
|
||||
groupId: currentGroup.id,
|
||||
});
|
||||
invariant(ownGroupWithMembers, "No own group found");
|
||||
validate(
|
||||
ownGroupWithMembers.members.length < FULL_GROUP_SIZE,
|
||||
@@ -140,6 +138,7 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
};
|
||||
|
||||
export default function QPreparingPage() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const joinQFetcher = useFetcher();
|
||||
useAutoRefresh(data.lastUpdated);
|
||||
@@ -168,7 +167,7 @@ export default function QPreparingPage() {
|
||||
state={joinQFetcher.state}
|
||||
_action="JOIN_QUEUE"
|
||||
>
|
||||
Join the queue
|
||||
{t("q:preparing.joinQ")}
|
||||
</SubmitButton>
|
||||
</joinQFetcher.Form>
|
||||
<GroupLeaver
|
||||
|
||||
@@ -1,81 +1,65 @@
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { Main } from "~/components/Main";
|
||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
import styles from "../q.css";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import type {
|
||||
LoaderArgs,
|
||||
ActionFunction,
|
||||
LinksFunction,
|
||||
V2_MetaFunction,
|
||||
LoaderArgs,
|
||||
SerializeFrom,
|
||||
V2_MetaFunction,
|
||||
} from "@remix-run/node";
|
||||
import { redirect } from "@remix-run/node";
|
||||
import { Link, useFetcher, useLoaderData } from "@remix-run/react";
|
||||
import clsx from "clsx";
|
||||
import * as React from "react";
|
||||
import invariant from "tiny-invariant";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Button } from "~/components/Button";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { Flag } from "~/components/Flag";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { UserIcon } from "~/components/icons/User";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import { sql } from "~/db/sql";
|
||||
import type { GroupMember } from "~/db/types";
|
||||
import { useUser } from "~/features/auth/core";
|
||||
import { getUserId, requireUserId } from "~/features/auth/core/user.server";
|
||||
import { currentSeason } from "~/features/mmr";
|
||||
import type { RankingSeason } from "~/features/mmr/season";
|
||||
import { nextSeason } from "~/features/mmr/season";
|
||||
import * as QRepository from "~/features/sendouq/QRepository.server";
|
||||
import { giveTrust } from "~/features/tournament/queries/giveTrust.server";
|
||||
import { useAutoRerender } from "~/hooks/useAutoRerender";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import {
|
||||
FULL_GROUP_SIZE,
|
||||
JOIN_CODE_SEARCH_PARAM_KEY,
|
||||
MAP_LIST_PREFERENCE_OPTIONS,
|
||||
SENDOUQ,
|
||||
} from "../q-constants";
|
||||
import { joinListToNaturalString } from "~/utils/arrays";
|
||||
import {
|
||||
parseRequestFormData,
|
||||
validate,
|
||||
type SendouRouteHandle,
|
||||
} from "~/utils/remix";
|
||||
import { Image, ModeImage } from "~/components/Image";
|
||||
import * as React from "react";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import clsx from "clsx";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import {
|
||||
LEADERBOARDS_PAGE,
|
||||
LOG_IN_URL,
|
||||
SENDOUQ_LOOKING_PAGE,
|
||||
SENDOUQ_PAGE,
|
||||
SENDOUQ_PREPARING_PAGE,
|
||||
SENDOUQ_RULES_PAGE,
|
||||
SENDOUQ_SETTINGS_PAGE,
|
||||
SENDOUQ_YOUTUBE_VIDEO,
|
||||
navIconUrl,
|
||||
stageImageUrl,
|
||||
userSeasonsPage,
|
||||
} from "~/utils/urls";
|
||||
import { stageIds } from "~/modules/in-game-lists";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { getUserId, requireUserId } from "~/features/auth/core/user.server";
|
||||
import { FULL_GROUP_SIZE, JOIN_CODE_SEARCH_PARAM_KEY } from "../q-constants";
|
||||
import { frontPageSchema } from "../q-schemas.server";
|
||||
import { RequiredHiddenInput } from "~/components/RequiredHiddenInput";
|
||||
import { createGroup } from "../queries/createGroup.server";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { groupRedirectLocationByCurrentLocation, mapPoolOk } from "../q-utils";
|
||||
import { ModePreferenceIcons } from "../components/ModePrefenceIcons";
|
||||
import { makeTitle } from "~/utils/strings";
|
||||
import { currentSeason } from "~/features/mmr";
|
||||
import type { RankingSeason } from "~/features/mmr/season";
|
||||
import { nextSeason } from "~/features/mmr/season";
|
||||
import { useUser } from "~/features/auth/core";
|
||||
import { Button } from "~/components/Button";
|
||||
import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server";
|
||||
import { Alert } from "~/components/Alert";
|
||||
import { Dialog } from "~/components/Dialog";
|
||||
import { joinListToNaturalString } from "~/utils/arrays";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { groupRedirectLocationByCurrentLocation } from "../q-utils";
|
||||
import styles from "../q.css";
|
||||
import { addMember } from "../queries/addMember.server";
|
||||
import { userHasSkill } from "../queries/userHasSkill.server";
|
||||
import { FormMessage } from "~/components/FormMessage";
|
||||
import { addInitialSkill } from "../queries/addInitialSkill.server";
|
||||
import {
|
||||
DEFAULT_SKILL_HIGH,
|
||||
DEFAULT_SKILL_LOW,
|
||||
DEFAULT_SKILL_MID,
|
||||
} from "~/features/mmr/mmr-constants";
|
||||
import { giveTrust } from "~/features/tournament/queries/giveTrust.server";
|
||||
import type { GroupMember, User } from "~/db/types";
|
||||
import invariant from "tiny-invariant";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import { CrossIcon } from "~/components/icons/Cross";
|
||||
import { updateVCStatus } from "../queries/updateVCStatus.server";
|
||||
import { sql } from "~/db/sql";
|
||||
import { deleteLikesByGroupId } from "../queries/deleteLikesByGroupId.server";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { findCurrentGroupByUserId } from "../queries/findCurrentGroupByUserId.server";
|
||||
import { findGroupByInviteCode } from "../queries/findGroupByInviteCode.server";
|
||||
import { Image } from "~/components/Image";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q"],
|
||||
@@ -114,19 +98,9 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
|
||||
switch (data._action) {
|
||||
case "JOIN_QUEUE": {
|
||||
const mapPool = new MapPool(data.mapPool);
|
||||
validate(mapPoolOk(mapPool), "Invalid map pool");
|
||||
|
||||
updateVCStatus({
|
||||
userId: user.id,
|
||||
languages: data.languages,
|
||||
vc: data.vc,
|
||||
});
|
||||
createGroup({
|
||||
mapListPreference: data.mapListPreference,
|
||||
await QRepository.createGroup({
|
||||
status: data.direct === "true" ? "ACTIVE" : "PREPARING",
|
||||
userId: user.id,
|
||||
mapPool,
|
||||
});
|
||||
|
||||
return redirect(
|
||||
@@ -135,10 +109,6 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
}
|
||||
case "JOIN_TEAM_WITH_TRUST":
|
||||
case "JOIN_TEAM": {
|
||||
validate(
|
||||
userHasSkill({ userId: user.id, season: season.nth }),
|
||||
"Initial SP needs to be set first",
|
||||
);
|
||||
const code = new URL(request.url).searchParams.get(
|
||||
JOIN_CODE_SEARCH_PARAM_KEY,
|
||||
);
|
||||
@@ -172,28 +142,6 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
: SENDOUQ_LOOKING_PAGE,
|
||||
);
|
||||
}
|
||||
case "SET_INITIAL_SP": {
|
||||
validate(
|
||||
!userHasSkill({ userId: user.id, season: season.nth }),
|
||||
"Already set initial SP",
|
||||
);
|
||||
|
||||
const defaultSkill =
|
||||
data.tier === "higher"
|
||||
? DEFAULT_SKILL_HIGH
|
||||
: data.tier === "default"
|
||||
? DEFAULT_SKILL_MID
|
||||
: DEFAULT_SKILL_LOW;
|
||||
|
||||
addInitialSkill({
|
||||
mu: defaultSkill.mu,
|
||||
season: season.nth,
|
||||
sigma: defaultSkill.sigma,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
default: {
|
||||
assertUnreachable(data);
|
||||
}
|
||||
@@ -223,10 +171,6 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
const upcomingSeason = !season ? nextSeason(now) : undefined;
|
||||
|
||||
return {
|
||||
hasSkill:
|
||||
season && user
|
||||
? userHasSkill({ userId: user.id, season: season.nth })
|
||||
: null,
|
||||
season,
|
||||
upcomingSeason,
|
||||
groupInvitedTo,
|
||||
@@ -234,8 +178,8 @@ export const loader = async ({ request }: LoaderArgs) => {
|
||||
};
|
||||
|
||||
export default function QPage() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const [dialogOpen, setDialogOpen] = React.useState(true);
|
||||
const [hasSubmitted, setHasSubmitted] = React.useState(false);
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher();
|
||||
@@ -250,78 +194,58 @@ export default function QPage() {
|
||||
rel="noreferrer"
|
||||
className="text-xs font-bold text-center"
|
||||
>
|
||||
Watch introduction video on YouTube by Chara
|
||||
{t("q:front.watchVideo")}
|
||||
</a>
|
||||
</div>
|
||||
<QLinks />
|
||||
{data.upcomingSeason ? (
|
||||
<UpcomingSeasonInfo season={data.upcomingSeason} />
|
||||
) : null}
|
||||
{data.season ? (
|
||||
<>
|
||||
{data.hasSkill && data.groupInvitedTo === null ? (
|
||||
<Alert variation="WARNING">
|
||||
Invite code doesn't match any active team
|
||||
</Alert>
|
||||
{data.groupInvitedTo === null ? (
|
||||
<Alert variation="WARNING">{t("q:front.inviteCodeWrong")}</Alert>
|
||||
) : null}
|
||||
{data.groupInvitedTo &&
|
||||
data.groupInvitedTo.members.length < FULL_GROUP_SIZE &&
|
||||
data.hasSkill ? (
|
||||
data.groupInvitedTo.members.length < FULL_GROUP_SIZE ? (
|
||||
<JoinTeamDialog
|
||||
open={dialogOpen}
|
||||
close={() => setDialogOpen(false)}
|
||||
members={data.groupInvitedTo.members}
|
||||
/>
|
||||
) : null}
|
||||
{!data.hasSkill && user ? <StartRank /> : null}
|
||||
{user && data.hasSkill ? (
|
||||
{user ? (
|
||||
<>
|
||||
<fetcher.Form className="stack md" method="post">
|
||||
<input type="hidden" name="_action" value="JOIN_QUEUE" />
|
||||
<div>
|
||||
<div className="stack horizontal items-center justify-between">
|
||||
<h2 className="q__header">Join the queue!</h2>
|
||||
<Link to={SENDOUQ_RULES_PAGE} className="text-xs font-bold">
|
||||
Rules
|
||||
</Link>
|
||||
</div>
|
||||
<ActiveSeasonInfo season={data.season} />
|
||||
</div>
|
||||
<VoiceChatAbility />
|
||||
<Languages />
|
||||
<MapPreference />
|
||||
<MapPoolSelector showErrors={hasSubmitted} />
|
||||
<div className="stack md items-center mt-4">
|
||||
<SubmitButton onClick={() => setHasSubmitted(true)}>
|
||||
Add team members
|
||||
<div className="stack horizontal md items-center mt-4 mx-auto">
|
||||
<SubmitButton icon={<UsersIcon />}>
|
||||
{t("q:front.actions.joinWithGroup")}
|
||||
</SubmitButton>
|
||||
<SubmitButton
|
||||
name="direct"
|
||||
value="true"
|
||||
state={fetcher.state}
|
||||
icon={<UserIcon />}
|
||||
variant="outlined"
|
||||
>
|
||||
{t("q:front.actions.joinSolo")}
|
||||
</SubmitButton>
|
||||
<div className="text-lighter text-xs text-center">
|
||||
No team members in mind yet? <br />
|
||||
<SubmitButton
|
||||
variant="minimal"
|
||||
className="text-xs mx-auto"
|
||||
name="direct"
|
||||
value="true"
|
||||
state={fetcher.state}
|
||||
onClick={() => setHasSubmitted(true)}
|
||||
>
|
||||
Join the queue directly.
|
||||
</SubmitButton>
|
||||
</div>
|
||||
</div>
|
||||
<ActiveSeasonInfo season={data.season} />
|
||||
</fetcher.Form>
|
||||
</>
|
||||
) : null}
|
||||
{!user ? (
|
||||
) : (
|
||||
<form
|
||||
className="stack items-center"
|
||||
action={LOG_IN_URL}
|
||||
method="post"
|
||||
>
|
||||
<Button size="big" type="submit">
|
||||
Log in to join SendouQ
|
||||
{t("q:front.actions.logIn")}
|
||||
</Button>
|
||||
</form>
|
||||
) : null}
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</Main>
|
||||
@@ -333,11 +257,11 @@ const countries = [
|
||||
id: 1,
|
||||
countryCode: "US",
|
||||
timeZone: "America/Los_Angeles",
|
||||
city: "Los Angeles",
|
||||
city: "la",
|
||||
},
|
||||
{ id: 2, countryCode: "US", timeZone: "America/New_York", city: "New York" },
|
||||
{ id: 3, countryCode: "FR", timeZone: "Europe/Paris", city: "Paris" },
|
||||
{ id: 4, countryCode: "JP", timeZone: "Asia/Tokyo", city: "Tokyo" },
|
||||
{ id: 2, countryCode: "US", timeZone: "America/New_York", city: "nyc" },
|
||||
{ id: 3, countryCode: "FR", timeZone: "Europe/Paris", city: "paris" },
|
||||
{ id: 4, countryCode: "JP", timeZone: "Asia/Tokyo", city: "tokyo" },
|
||||
] as const;
|
||||
const weekdayFormatter = ({
|
||||
timeZone,
|
||||
@@ -364,7 +288,7 @@ const clockFormatter = ({
|
||||
});
|
||||
function Clocks() {
|
||||
const isMounted = useIsMounted();
|
||||
const { i18n } = useTranslation();
|
||||
const { t, i18n } = useTranslation(["q"]);
|
||||
useAutoRerender();
|
||||
|
||||
return (
|
||||
@@ -372,7 +296,9 @@ function Clocks() {
|
||||
{countries.map((country) => {
|
||||
return (
|
||||
<div key={country.id} className="q__clock">
|
||||
<div className="q__clock-country">{country.city}</div>
|
||||
<div className="q__clock-country">
|
||||
{t(`q:front.cities.${country.city}`)}
|
||||
</div>
|
||||
<Flag countryCode={country.countryCode} />
|
||||
<div className={clsx({ invisible: !isMounted })}>
|
||||
{isMounted
|
||||
@@ -411,6 +337,7 @@ function JoinTeamDialog({
|
||||
role: GroupMember["role"];
|
||||
}[];
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const fetcher = useFetcher();
|
||||
|
||||
const owner = members.find((m) => m.role === "OWNER");
|
||||
@@ -423,28 +350,30 @@ function JoinTeamDialog({
|
||||
closeOnAnyClick={false}
|
||||
className="text-center"
|
||||
>
|
||||
Join the group with{" "}
|
||||
{joinListToNaturalString(members.map((m) => m.discordName))}?
|
||||
{t("q:front.join.header", {
|
||||
members: joinListToNaturalString(members.map((m) => m.discordName)),
|
||||
})}
|
||||
<fetcher.Form
|
||||
className="stack horizontal justify-center sm mt-4 flex-wrap"
|
||||
method="post"
|
||||
>
|
||||
<SubmitButton _action="JOIN_TEAM" state={fetcher.state}>
|
||||
Join
|
||||
{t("q:front.join.joinAction")}
|
||||
</SubmitButton>
|
||||
<SubmitButton
|
||||
_action="JOIN_TEAM_WITH_TRUST"
|
||||
state={fetcher.state}
|
||||
variant="outlined"
|
||||
>
|
||||
Join & trust {owner.discordName}
|
||||
{t("q:front.join.joinWithTrustAction", {
|
||||
inviterName: owner.discordName,
|
||||
})}
|
||||
</SubmitButton>
|
||||
<Button onClick={close} variant="destructive">
|
||||
No thanks
|
||||
{t("q:front.join.refuseAction")}
|
||||
</Button>
|
||||
<FormMessage type="info">
|
||||
Trusting a user allows them to add you to groups without an invite
|
||||
link in the future
|
||||
{t("q:front.join.joinWithTrustAction.explanation")}
|
||||
</FormMessage>
|
||||
</fetcher.Form>
|
||||
</Dialog>
|
||||
@@ -456,13 +385,14 @@ function ActiveSeasonInfo({
|
||||
}: {
|
||||
season: SerializeFrom<RankingSeason>;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation(["q"]);
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
const starts = new Date(season.starts);
|
||||
const ends = new Date(season.ends);
|
||||
|
||||
const dateToString = (date: Date) =>
|
||||
date.toLocaleString("en-US", {
|
||||
date.toLocaleString(i18n.language, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
@@ -471,11 +401,11 @@ function ActiveSeasonInfo({
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx("text-lighter text-xs", {
|
||||
className={clsx("text-lighter text-xs text-center", {
|
||||
invisible: !isMounted,
|
||||
})}
|
||||
>
|
||||
Season {season.nth} open{" "}
|
||||
{t("q:front.seasonOpen", { nth: season.nth })}{" "}
|
||||
{isMounted ? (
|
||||
<b>
|
||||
{dateToString(starts)} - {dateToString(ends)}
|
||||
@@ -485,11 +415,72 @@ function ActiveSeasonInfo({
|
||||
);
|
||||
}
|
||||
|
||||
function QLinks() {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<div className="stack sm">
|
||||
<QLink
|
||||
navIcon="articles"
|
||||
url={SENDOUQ_RULES_PAGE}
|
||||
title={t("q:front.nav.rules.title")}
|
||||
subText={t("q:front.nav.rules.description")}
|
||||
/>
|
||||
{user ? (
|
||||
<QLink
|
||||
navIcon="settings"
|
||||
url={SENDOUQ_SETTINGS_PAGE}
|
||||
title={t("q:front.nav.settings.title")}
|
||||
subText={t("q:front.nav.settings.description")}
|
||||
/>
|
||||
) : null}
|
||||
<QLink
|
||||
navIcon="leaderboards"
|
||||
url={LEADERBOARDS_PAGE}
|
||||
title={t("q:front.nav.leaderboards.title")}
|
||||
subText={t("q:front.nav.leaderboards.description")}
|
||||
/>
|
||||
{user ? (
|
||||
<QLink
|
||||
navIcon="u"
|
||||
url={userSeasonsPage({ user })}
|
||||
title={t("q:front.nav.mySeason.title")}
|
||||
subText={t("q:front.nav.mySeason.description")}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QLink({
|
||||
url,
|
||||
navIcon,
|
||||
title,
|
||||
subText,
|
||||
}: {
|
||||
url: string;
|
||||
navIcon: string;
|
||||
title: string;
|
||||
subText: string;
|
||||
}) {
|
||||
return (
|
||||
<Link to={url} className="q__front-page-link">
|
||||
<Image path={navIconUrl(navIcon)} alt="" width={32} />
|
||||
<div>
|
||||
{title}
|
||||
<div className="q__front-page-link__sub-text">{subText}</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function UpcomingSeasonInfo({
|
||||
season,
|
||||
}: {
|
||||
season: SerializeFrom<RankingSeason>;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const isMounted = useIsMounted();
|
||||
if (!isMounted) return null;
|
||||
|
||||
@@ -504,378 +495,12 @@ function UpcomingSeasonInfo({
|
||||
|
||||
return (
|
||||
<div className="font-semi-bold text-center text-sm">
|
||||
It's off-season!
|
||||
{t("q:front.upcomingSeason.header")}
|
||||
<br />
|
||||
Join Season {season.nth} starting {dateToString(starts)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StartRank() {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
return (
|
||||
<fetcher.Form method="post" className="stack md items-start">
|
||||
<div>
|
||||
<label>Starting rank</label>
|
||||
{["higher", "default", "lower"].map((tier) => {
|
||||
return (
|
||||
<div key={tier} className="stack sm horizontal items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="tier"
|
||||
id={tier}
|
||||
value={tier}
|
||||
defaultChecked={tier === "default"}
|
||||
/>
|
||||
<label htmlFor={tier} className="mb-0 text-capitalize">
|
||||
{tier}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<FormMessage type="info">
|
||||
Decides your starting SP (MMR). "Higher" is recommended for
|
||||
Plus Server level players. "Lower" for Low Ink eligible
|
||||
players. "Default" for everyone else.
|
||||
</FormMessage>
|
||||
<FormMessage type="info" className="font-bold">
|
||||
Setting initial SP is mandatory before you can join SendouQ.
|
||||
</FormMessage>
|
||||
</div>
|
||||
<SubmitButton _action="SET_INITIAL_SP" state={fetcher.state}>
|
||||
Submit
|
||||
</SubmitButton>
|
||||
</fetcher.Form>
|
||||
);
|
||||
}
|
||||
|
||||
const VC_LOCAL_STORAGE_KEY = "q_vc";
|
||||
function VoiceChatAbility() {
|
||||
const [value, setValue] = React.useState<User["vc"]>();
|
||||
|
||||
React.useEffect(() => {
|
||||
const storedValue = localStorage.getItem(VC_LOCAL_STORAGE_KEY);
|
||||
if (storedValue) {
|
||||
setValue(storedValue as User["vc"]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const label = (vc: User["vc"]) => {
|
||||
switch (vc) {
|
||||
case "YES":
|
||||
return "Yes";
|
||||
case "NO":
|
||||
return "No";
|
||||
case "LISTEN_ONLY":
|
||||
return "Listen only";
|
||||
default:
|
||||
assertUnreachable(vc);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<label>Voice chat</label>
|
||||
{(["YES", "NO", "LISTEN_ONLY"] as const).map((option) => {
|
||||
return (
|
||||
<div key={option} className="stack sm horizontal items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="vc"
|
||||
id={option}
|
||||
value={option}
|
||||
checked={value === option}
|
||||
onChange={() => {
|
||||
setValue(option);
|
||||
localStorage.setItem(VC_LOCAL_STORAGE_KEY, option);
|
||||
}}
|
||||
required
|
||||
/>
|
||||
<label
|
||||
htmlFor={option}
|
||||
className="q__map-preference-label text-main-forced"
|
||||
>
|
||||
{label(option)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
{t("q:front.upcomingSeason.date", {
|
||||
nth: season.nth,
|
||||
date: dateToString(starts),
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const LANGUAGES_LOCAL_STORAGE_KEY = "q_lang";
|
||||
function Languages() {
|
||||
const [value, setValue] = React.useState<string[]>([]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const storedValue = localStorage.getItem(LANGUAGES_LOCAL_STORAGE_KEY);
|
||||
if (storedValue) {
|
||||
setValue(JSON.parse(storedValue));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<RequiredHiddenInput
|
||||
isValid={value.length > 0}
|
||||
name="languages"
|
||||
value={JSON.stringify(value)}
|
||||
/>
|
||||
<label>Your languages</label>
|
||||
<select
|
||||
className="w-max"
|
||||
onChange={(e) => {
|
||||
const newLanguages = [...value, e.target.value].sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
setValue(newLanguages);
|
||||
localStorage.setItem(
|
||||
LANGUAGES_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(newLanguages),
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="">Select all that apply</option>
|
||||
{languagesUnified
|
||||
.filter((lang) => !value.includes(lang.code))
|
||||
.map((option) => {
|
||||
return (
|
||||
<option key={option.code} value={option.code}>
|
||||
{option.name}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
<div className="mt-2">
|
||||
{value.map((code) => {
|
||||
const name = languagesUnified.find((l) => l.code === code)?.name;
|
||||
|
||||
return (
|
||||
<div key={code} className="stack horizontal items-center sm">
|
||||
{name}{" "}
|
||||
<Button
|
||||
icon={<CrossIcon />}
|
||||
variant="minimal-destructive"
|
||||
onClick={() => {
|
||||
const newLanguages = value.filter(
|
||||
(codeInArr) => codeInArr !== code,
|
||||
);
|
||||
setValue(newLanguages);
|
||||
localStorage.setItem(
|
||||
LANGUAGES_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(newLanguages),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MAP_PREFERENCE_LOCAL_STORAGE_KEY = "q_mapPreference";
|
||||
function MapPreference() {
|
||||
const [value, setValue] = React.useState<string | null>(null);
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const storedValue = localStorage.getItem(MAP_PREFERENCE_LOCAL_STORAGE_KEY);
|
||||
if (storedValue) {
|
||||
setValue(storedValue);
|
||||
} else {
|
||||
setValue("NO_PREFERENCE");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<label>Maplist preference</label>
|
||||
{MAP_LIST_PREFERENCE_OPTIONS.map((option) => {
|
||||
return (
|
||||
<div key={option} className="stack sm horizontal items-center">
|
||||
<input
|
||||
type="radio"
|
||||
name="mapListPreference"
|
||||
id={option}
|
||||
value={option}
|
||||
checked={value === option}
|
||||
onChange={() => {
|
||||
setValue(option);
|
||||
localStorage.setItem(MAP_PREFERENCE_LOCAL_STORAGE_KEY, option);
|
||||
}}
|
||||
/>
|
||||
<label htmlFor={option} className="q__map-preference-label">
|
||||
<ModePreferenceIcons preference={option} />
|
||||
{t(`q:mapListPreference.${option}`)}
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{value === "SZ_ONLY" || value === "ALL_MODES_ONLY" ? (
|
||||
<div className="text-xs text-lighter mt-2">
|
||||
{t("q:mapListPreference.note", {
|
||||
optionOne:
|
||||
value === "SZ_ONLY"
|
||||
? t("q:mapListPreference.ALL_MODES_ONLY")
|
||||
: t("q:mapListPreference.SZ_ONLY"),
|
||||
optionTwo:
|
||||
value === "SZ_ONLY"
|
||||
? t("q:mapListPreference.PREFER_SZ")
|
||||
: t("q:mapListPreference.PREFER_ALL_MODES"),
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const MAP_POOL_LOCAL_STORAGE_KEY = "q_mapPool";
|
||||
function MapPoolSelector({ showErrors }: { showErrors: boolean }) {
|
||||
const { t } = useTranslation(["game-misc"]);
|
||||
const [mapPool, setMapPool] = React.useState<MapPool>(new MapPool([]));
|
||||
|
||||
React.useEffect(() => {
|
||||
try {
|
||||
const mapPool = localStorage.getItem(MAP_POOL_LOCAL_STORAGE_KEY);
|
||||
if (mapPool) {
|
||||
setMapPool(new MapPool(JSON.parse(mapPool)));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="stack md">
|
||||
<div className="q__map-pool-grid">
|
||||
<RequiredHiddenInput
|
||||
value={mapPool.serialized}
|
||||
isValid={mapPoolOk(mapPool)}
|
||||
name="mapPool"
|
||||
/>
|
||||
<div />
|
||||
<div />
|
||||
{rankedModesShort.map((modeShort) => {
|
||||
return <ModeImage key={modeShort} mode={modeShort} size={22} />;
|
||||
})}
|
||||
<div />
|
||||
{stageIds.map((stageId) => {
|
||||
return (
|
||||
<React.Fragment key={stageId}>
|
||||
<div>
|
||||
<Image
|
||||
alt=""
|
||||
path={stageImageUrl(stageId)}
|
||||
width={32}
|
||||
height={18}
|
||||
className="q__map-pool-grid__stage-image"
|
||||
/>
|
||||
</div>
|
||||
<div>{t(`game-misc:STAGE_${stageId}`)}</div>
|
||||
{rankedModesShort.map((modeShort) => {
|
||||
const id = `${stageId}-${modeShort}`;
|
||||
return (
|
||||
<input
|
||||
key={id}
|
||||
type="checkbox"
|
||||
id={id}
|
||||
checked={mapPool.has({ stageId, mode: modeShort })}
|
||||
onChange={(e) => {
|
||||
const checked = e.target.checked;
|
||||
setMapPool((prev) => {
|
||||
let newMapPool: MapPool;
|
||||
if (checked) {
|
||||
newMapPool = new MapPool([
|
||||
...prev.stageModePairs,
|
||||
{ stageId, mode: modeShort },
|
||||
]);
|
||||
} else {
|
||||
newMapPool = new MapPool([
|
||||
...prev.stageModePairs.filter(
|
||||
(pair) =>
|
||||
pair.stageId !== stageId ||
|
||||
pair.mode !== modeShort,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
localStorage.setItem(
|
||||
MAP_POOL_LOCAL_STORAGE_KEY,
|
||||
JSON.stringify(newMapPool.stageModePairs),
|
||||
);
|
||||
|
||||
return newMapPool;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div
|
||||
className={clsx("text-warning", {
|
||||
invisible:
|
||||
mapPool.stageModePairs.filter((p) => p.stageId === stageId)
|
||||
.length <= SENDOUQ.MAX_STAGE_REPEAT_COUNT,
|
||||
})}
|
||||
>
|
||||
max {SENDOUQ.MAX_STAGE_REPEAT_COUNT}
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
<div />
|
||||
<div />
|
||||
<div
|
||||
className={clsx({
|
||||
"text-warning":
|
||||
mapPool.countMapsByMode("SZ") > SENDOUQ.SZ_MAP_COUNT,
|
||||
"text-success":
|
||||
mapPool.countMapsByMode("SZ") === SENDOUQ.SZ_MAP_COUNT,
|
||||
})}
|
||||
>
|
||||
{mapPool.countMapsByMode("SZ")}/{SENDOUQ.SZ_MAP_COUNT}
|
||||
</div>
|
||||
<div
|
||||
className={clsx({
|
||||
"text-warning":
|
||||
mapPool.countMapsByMode("TC") > SENDOUQ.OTHER_MODE_MAP_COUNT,
|
||||
"text-success":
|
||||
mapPool.countMapsByMode("TC") === SENDOUQ.OTHER_MODE_MAP_COUNT,
|
||||
})}
|
||||
>
|
||||
{mapPool.countMapsByMode("TC")}/{SENDOUQ.OTHER_MODE_MAP_COUNT}
|
||||
</div>
|
||||
<div
|
||||
className={clsx({
|
||||
"text-warning":
|
||||
mapPool.countMapsByMode("RM") > SENDOUQ.OTHER_MODE_MAP_COUNT,
|
||||
"text-success":
|
||||
mapPool.countMapsByMode("RM") === SENDOUQ.OTHER_MODE_MAP_COUNT,
|
||||
})}
|
||||
>
|
||||
{mapPool.countMapsByMode("RM")}/{SENDOUQ.OTHER_MODE_MAP_COUNT}
|
||||
</div>
|
||||
<div
|
||||
className={clsx({
|
||||
"text-warning":
|
||||
mapPool.countMapsByMode("CB") > SENDOUQ.OTHER_MODE_MAP_COUNT,
|
||||
"text-success":
|
||||
mapPool.countMapsByMode("CB") === SENDOUQ.OTHER_MODE_MAP_COUNT,
|
||||
})}
|
||||
>
|
||||
{mapPool.countMapsByMode("CB")}/{SENDOUQ.OTHER_MODE_MAP_COUNT}
|
||||
</div>
|
||||
<div />
|
||||
</div>
|
||||
{showErrors && !mapPoolOk(mapPool) ? (
|
||||
<div className="text-warning text-xs text-center">
|
||||
Map pool is invalid. Check that every mode has exactly the required
|
||||
amount of maps. Also make sure that no map is picked more than twice.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,12 @@ import {
|
||||
} from "~/features/mmr/mmr-constants";
|
||||
import { currentOrPreviousSeason } from "~/features/mmr/season";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import { useTranslation } from "~/hooks/useTranslation";
|
||||
import type { SendouRouteHandle } from "~/utils/remix";
|
||||
|
||||
export const handle: SendouRouteHandle = {
|
||||
i18n: ["q"],
|
||||
};
|
||||
|
||||
export const loader = async () => {
|
||||
const season = currentOrPreviousSeason(new Date());
|
||||
@@ -21,6 +27,7 @@ export const loader = async () => {
|
||||
|
||||
export default function TiersPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["q"]);
|
||||
|
||||
return (
|
||||
<Main halfWidth className="stack md">
|
||||
@@ -39,7 +46,7 @@ export default function TiersPage() {
|
||||
{neededOrdinal ? (
|
||||
<>
|
||||
<div className="text-xs font-semi-bold text-lighter">
|
||||
Current criteria
|
||||
{t("q:tiers.currentCriteria")}
|
||||
</div>
|
||||
<div className="text-sm font-semi-bold text-lighter">
|
||||
{ordinalToSp(neededOrdinal)}SP
|
||||
@@ -50,18 +57,15 @@ export default function TiersPage() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p>{t("q:tiers.info.p1")}</p>
|
||||
<p>
|
||||
For example Leviathan is the top 5% of players. Diamond is the 85th
|
||||
percentile etc.
|
||||
</p>
|
||||
<p>
|
||||
Note: Nobody has Leviathan rank before there are at least{" "}
|
||||
{USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN} players on the leaderboard
|
||||
(or {TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN} for teams)
|
||||
{t("q:tiers.info.p2", {
|
||||
usersMin: USER_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN,
|
||||
teamsMin: TEAM_LEADERBOARD_MIN_ENTRIES_FOR_LEVIATHAN,
|
||||
})}
|
||||
</p>
|
||||
<div>
|
||||
Each rank also has a plus tier (see BRONZE+ as an example below). This
|
||||
means that you are in the top 50% of that rank.
|
||||
{t("q:tiers.info.p3")}
|
||||
<TierImage tier={{ isPlus: true, name: "BRONZE" }} width={32} />
|
||||
</div>
|
||||
</Main>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { parseSearchParams } from "~/utils/remix";
|
||||
import { weaponUsageStats } from "../queries/weaponUsageStats.server";
|
||||
import type { LoaderArgs, SerializeFrom } from "@remix-run/node";
|
||||
import { parseSearchParams } from "~/utils/remix";
|
||||
import { weaponUsageSearchParamsSchema } from "../q-schemas.server";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import { weaponUsageStats } from "../queries/weaponUsageStats.server";
|
||||
|
||||
export type WeaponUsageLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
@@ -14,9 +13,9 @@ export const loader = ({ request }: LoaderArgs) => {
|
||||
|
||||
return {
|
||||
usage: weaponUsageStats({
|
||||
mode: data.modeShort as ModeShort,
|
||||
mode: data.modeShort,
|
||||
season: data.season,
|
||||
stageId: data.stageId as StageId,
|
||||
stageId: data.stageId,
|
||||
userId: data.userId,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Main } from "~/components/Main";
|
||||
import { useIsMounted } from "~/hooks/useIsMounted";
|
||||
import * as React from "react";
|
||||
import { soundCodeToLocalStorageKey } from "~/features/chat/chat-utils";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const isMounted = useIsMounted();
|
||||
|
||||
return (
|
||||
<Main>
|
||||
<h2>Settings</h2>
|
||||
{isMounted ? <Sounds /> : null}
|
||||
</Main>
|
||||
);
|
||||
}
|
||||
|
||||
const sounds = [
|
||||
{
|
||||
code: "sq_like",
|
||||
name: "SendouQ like received",
|
||||
},
|
||||
{
|
||||
code: "sq_new-group",
|
||||
name: "SendouQ group new members",
|
||||
},
|
||||
{
|
||||
code: "sq_match",
|
||||
name: "SendouQ match started",
|
||||
},
|
||||
];
|
||||
|
||||
function Sounds() {
|
||||
// default to true
|
||||
const currentValue = (code: string) =>
|
||||
!localStorage.getItem(soundCodeToLocalStorageKey(code)) ||
|
||||
localStorage.getItem(soundCodeToLocalStorageKey(code)) === "true";
|
||||
|
||||
const [soundValues, setSoundValues] = React.useState(
|
||||
Object.fromEntries(
|
||||
sounds.map((sound) => [sound.code, currentValue(sound.code)]),
|
||||
),
|
||||
);
|
||||
|
||||
// toggle in local storage
|
||||
const toggleSound = (code: string) => {
|
||||
localStorage.setItem(
|
||||
soundCodeToLocalStorageKey(code),
|
||||
String(!currentValue(code)),
|
||||
);
|
||||
setSoundValues((prev) => ({
|
||||
...prev,
|
||||
[code]: !prev[code],
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-lighter">Sounds</h3>
|
||||
{sounds.map((sound) => (
|
||||
<div key={sound.code}>
|
||||
<label className="stack horizontal xs items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={soundValues[sound.code]}
|
||||
onChange={() => toggleSound(sound.code)}
|
||||
/>
|
||||
{sound.name}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -160,7 +160,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
clothesGearSplId: data["CLOTHES[value]"],
|
||||
shoesGearSplId: data["SHOES[value]"],
|
||||
modes: modesShort.filter((mode) => data[mode]),
|
||||
weaponSplIds: data["weapon[value]"] as Array<MainWeaponId>,
|
||||
weaponSplIds: data["weapon[value]"],
|
||||
ownerId: user.id,
|
||||
private: data.private,
|
||||
};
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
currentOrPreviousSeason,
|
||||
seasonObject,
|
||||
} from "~/features/mmr/season";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
|
||||
import { seasonMapWinrateByUserId } from "~/features/sendouq/queries/seasonMapWinrateByUserId.server";
|
||||
import {
|
||||
seasonMatchesByUserId,
|
||||
@@ -52,7 +52,7 @@ import {
|
||||
type ModeShort,
|
||||
type StageId,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { atOrError } from "~/utils/arrays";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { cutToNDecimalPlaces } from "~/utils/number";
|
||||
@@ -83,7 +83,8 @@ export const loader = async ({ params, request }: LoaderArgs) => {
|
||||
await UserRepository.identifierToUserId(identifier),
|
||||
);
|
||||
|
||||
const { tier } = (await userSkills(season)).userSkills[user.id] ?? {
|
||||
const { isAccurateTiers, userSkills } = await _userSkills(season);
|
||||
const { tier } = userSkills[user.id] ?? {
|
||||
approximate: false,
|
||||
ordinal: 0,
|
||||
tier: { isPlus: false, name: "IRON" },
|
||||
@@ -97,6 +98,7 @@ export const loader = async ({ params, request }: LoaderArgs) => {
|
||||
},
|
||||
skills: seasonAllMMRByUserId({ season, userId: user.id }),
|
||||
tier,
|
||||
isAccurateTiers,
|
||||
matches: {
|
||||
value: seasonMatchesByUserId({ season, userId: user.id, page }),
|
||||
currentPage: page,
|
||||
@@ -300,6 +302,18 @@ function Rank({ currentOrdinal }: { currentOrdinal: number }) {
|
||||
{data.tier.name}
|
||||
{data.tier.isPlus ? "+" : ""}
|
||||
</div>
|
||||
<div className="u__season__tentative">
|
||||
Tentative
|
||||
<Popover
|
||||
buttonChildren={<>?</>}
|
||||
contentClassName="u__season__tentative__explanation"
|
||||
>
|
||||
Leaderboard has low amount of entries. Once enough players have
|
||||
finished their calculations the ranking tiers will recalculate. For
|
||||
most players it will mean that their tier goes down. SP always stays
|
||||
the same.
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="text-lg font-bold">{ordinalToSp(currentOrdinal)}SP</div>
|
||||
{!peakAndCurrentSame ? (
|
||||
<div className="text-lighter text-sm">
|
||||
@@ -410,7 +424,7 @@ function Stages({
|
||||
return (
|
||||
<div key={id} className="stack sm">
|
||||
<StageImage stageId={id} height={48} className="rounded" />
|
||||
{rankedModesShort.map((mode) => {
|
||||
{modesShort.map((mode) => {
|
||||
const stats = stages[id]?.[mode];
|
||||
const winPercentage = stats
|
||||
? cutToNDecimalPlaces(
|
||||
|
||||
@@ -528,7 +528,7 @@ function Match({
|
||||
fullWidth
|
||||
key={i}
|
||||
inputName={`player-${i}-weapon`}
|
||||
initialWeaponId={match.weapons[i] as MainWeaponId}
|
||||
initialWeaponId={match.weapons[i]}
|
||||
onChange={(selected) => {
|
||||
if (!selected) return;
|
||||
const weapons = [...match.weapons];
|
||||
@@ -551,7 +551,7 @@ function Match({
|
||||
fullWidth
|
||||
key={i}
|
||||
inputName={`player-${adjustedI}-weapon`}
|
||||
initialWeaponId={match.weapons[adjustedI] as MainWeaponId}
|
||||
initialWeaponId={match.weapons[adjustedI]}
|
||||
onChange={(selected) => {
|
||||
if (!selected) return;
|
||||
const weapons = [...match.weapons];
|
||||
@@ -577,7 +577,7 @@ function Match({
|
||||
fullWidth
|
||||
id={id}
|
||||
inputName={`match-${number}-weapon`}
|
||||
initialWeaponId={match.weapons[0] as MainWeaponId}
|
||||
initialWeaponId={match.weapons[0]}
|
||||
onChange={(selected) =>
|
||||
onChange({
|
||||
...match,
|
||||
|
||||
@@ -21,3 +21,26 @@ export const stageIds = [
|
||||
18, // Crableg Capital
|
||||
19, // Shipshape Cargo Co.
|
||||
] as const;
|
||||
|
||||
export const stagesObj = {
|
||||
SCORCH_GORGE: 0,
|
||||
EELTAIL_ALLEY: 1,
|
||||
HAGGLEFISH_MARKET: 2,
|
||||
UNDERTOW_SPILLWAY: 3,
|
||||
MINCEMEAT_METALWORKS: 4,
|
||||
HAMMERHEAD_BRIDGE: 5,
|
||||
MUSEUM_D_ALFONSINO: 6,
|
||||
MAHI_MAHI_RESORT: 7,
|
||||
INKBLOT_ART_ACADEMY: 8,
|
||||
STURGEON_SHIPYARD: 9,
|
||||
MAKOMART: 10,
|
||||
WAHOO_WORLD: 11,
|
||||
FLOUNDER_HEIGHTS: 12,
|
||||
BRINEWATER_SPRINGS: 13,
|
||||
MANTA_MARIA: 14,
|
||||
UM_AMI_RUINS: 15,
|
||||
HUMPBACK_PUMP_TRACK: 16,
|
||||
BARNACLE_AND_DIME: 17,
|
||||
CRABLEG_CAPITAL: 18,
|
||||
SHIPSHAPE_CARGO_CO: 19,
|
||||
} as const;
|
||||
|
||||
@@ -64,6 +64,7 @@ const generateMaps = ({
|
||||
],
|
||||
tiebreakerMaps = tiebreakerPicks,
|
||||
modesIncluded = [...rankedModesShort],
|
||||
followModeOrder = false,
|
||||
}: Partial<TournamentMaplistInput> = {}) => {
|
||||
return createTournamentMapList({
|
||||
bestOf,
|
||||
@@ -71,6 +72,7 @@ const generateMaps = ({
|
||||
teams,
|
||||
tiebreakerMaps,
|
||||
modesIncluded,
|
||||
followModeOrder,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -91,6 +93,16 @@ TournamentMapListGenerator("Modes are spread evenly", () => {
|
||||
}
|
||||
});
|
||||
|
||||
TournamentMapListGenerator("Follow mode order option", () => {
|
||||
const mapList = generateMaps({ followModeOrder: true });
|
||||
|
||||
assert.equal(mapList[0].mode, "SZ");
|
||||
assert.equal(mapList[1].mode, "TC");
|
||||
assert.equal(mapList[2].mode, "RM");
|
||||
assert.equal(mapList[3].mode, "CB");
|
||||
assert.equal(mapList[4].mode, "SZ");
|
||||
});
|
||||
|
||||
TournamentMapListGenerator("Equal picks", () => {
|
||||
let our = 0;
|
||||
let their = 0;
|
||||
@@ -540,6 +552,50 @@ TournamentMapListGenerator(
|
||||
// }
|
||||
// );
|
||||
|
||||
const threeModesArgs: TournamentMaplistInput = {
|
||||
bestOf: 7,
|
||||
seed: "1002",
|
||||
modesIncluded: ["TC", "TW", "RM"],
|
||||
tiebreakerMaps: new MapPool({
|
||||
TW: [],
|
||||
SZ: [],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
}),
|
||||
teams: [
|
||||
{
|
||||
id: 1002,
|
||||
maps: new MapPool({
|
||||
TW: [9, 7, 6, 5, 3, 2, 0],
|
||||
SZ: [],
|
||||
TC: [9, 8, 7, 4, 1, 6, 2],
|
||||
RM: [9, 7, 6, 5, 3, 1, 0],
|
||||
CB: [],
|
||||
}),
|
||||
},
|
||||
{
|
||||
id: 1001,
|
||||
maps: new MapPool({
|
||||
TW: [8, 7, 5, 2, 9, 4, 3],
|
||||
SZ: [],
|
||||
TC: [7, 6, 5, 3, 2, 0, 9],
|
||||
RM: [9, 8, 6, 5, 3, 2, 7],
|
||||
CB: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
TournamentMapListGenerator(
|
||||
"generates list of modes included length > 1 && < 4",
|
||||
() => {
|
||||
const maps = generateMaps(threeModesArgs);
|
||||
|
||||
assert.equal(maps.length, 7);
|
||||
},
|
||||
);
|
||||
|
||||
const team1SZPicks = new MapPool([
|
||||
{ mode: "SZ", stageId: 4 },
|
||||
{ mode: "SZ", stageId: 5 },
|
||||
|
||||
@@ -241,7 +241,7 @@ export function createTournamentMapList(
|
||||
if (tournamentIsOneModeOnly()) return false;
|
||||
|
||||
// all modes already appeared
|
||||
if (mapList.length >= 4) return false;
|
||||
if (mapList.length >= input.modesIncluded.length) return false;
|
||||
|
||||
if (
|
||||
mapList.some(
|
||||
@@ -257,8 +257,12 @@ export function createTournamentMapList(
|
||||
function isNotFollowingModePattern(stage: StageValidatorInput) {
|
||||
if (tournamentIsOneModeOnly()) return false;
|
||||
|
||||
if (input.followModeOrder) {
|
||||
return isNotFollowingModeOrder(stage);
|
||||
}
|
||||
|
||||
// not all modes appeared yet
|
||||
if (mapList.length < 4) return false;
|
||||
if (mapList.length < input.modesIncluded.length) return false;
|
||||
|
||||
let previousModeShouldBe: ModeShort | undefined;
|
||||
for (let i = 0; i < mapList.length; i++) {
|
||||
@@ -275,6 +279,16 @@ export function createTournamentMapList(
|
||||
return mapList[mapList.length - 1]!.mode !== previousModeShouldBe;
|
||||
}
|
||||
|
||||
function isNotFollowingModeOrder(stage: StageValidatorInput) {
|
||||
let currentIndex = 0;
|
||||
for (let i = 0; i < mapList.length; i++) {
|
||||
currentIndex++;
|
||||
if (currentIndex === input.modesIncluded.length) currentIndex = 0;
|
||||
}
|
||||
|
||||
return stage.mode !== input.modesIncluded[currentIndex];
|
||||
}
|
||||
|
||||
// don't allow making two picks from one team in row
|
||||
function isMakingThingsUnfair(stage: StageValidatorInput) {
|
||||
const score = mapList.reduce((acc, cur) => acc + cur.score, 0);
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface TournamentMaplistInput {
|
||||
];
|
||||
tiebreakerMaps: MapPool;
|
||||
modesIncluded: ModeShort[];
|
||||
followModeOrder?: boolean;
|
||||
}
|
||||
|
||||
export type TournamentMaplistSource = number | (typeof sourceTypes)[number];
|
||||
|
||||
30
app/root.tsx
@@ -14,7 +14,6 @@ import {
|
||||
type ShouldRevalidateFunction,
|
||||
useLoaderData,
|
||||
useMatches,
|
||||
useFetchers,
|
||||
useNavigation,
|
||||
useRouteError,
|
||||
} from "@remix-run/react";
|
||||
@@ -186,33 +185,10 @@ function Document({
|
||||
function useLoadingIndicator() {
|
||||
const transition = useNavigation();
|
||||
|
||||
const fetchers = useFetchers();
|
||||
|
||||
/**
|
||||
* This gets the state of every fetcher active on the app and combine it with
|
||||
* the state of the global transition (Link and Form), then use them to
|
||||
* determine if the app is idle or if it's loading.
|
||||
* Here we consider both loading and submitting as loading.
|
||||
*/
|
||||
const state = React.useMemo<"idle" | "loading">(
|
||||
function getGlobalState() {
|
||||
const states = [
|
||||
transition.state,
|
||||
...fetchers.map((fetcher) => fetcher.state),
|
||||
];
|
||||
if (states.every((state) => state === "idle")) return "idle";
|
||||
return "loading";
|
||||
},
|
||||
[transition.state, fetchers],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
// and when it's something else it means it's either submitting a form or
|
||||
// waiting for the loaders of the next location so we start it
|
||||
if (state === "loading") NProgress.start();
|
||||
// when the state is idle then we can to complete the progress bar
|
||||
if (state === "idle") NProgress.done();
|
||||
}, [state]);
|
||||
if (transition.state === "loading") NProgress.start();
|
||||
if (transition.state === "idle") NProgress.done();
|
||||
}, [transition.state]);
|
||||
}
|
||||
|
||||
// TODO: this should be an array if we can figure out how to make Typescript
|
||||
|
||||
@@ -557,6 +557,52 @@ dialog::backdrop {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.divider-tab__buttons-container {
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab__buttons-container__sticky {
|
||||
position: sticky;
|
||||
top: 47px;
|
||||
z-index: 1;
|
||||
background-color: var(--bg);
|
||||
}
|
||||
|
||||
.divider-tab__buttons-container::before,
|
||||
.divider-tab__buttons-container::after {
|
||||
flex: 1;
|
||||
content: "";
|
||||
padding: 2px;
|
||||
background-color: var(--theme-transparent);
|
||||
margin: 5px;
|
||||
border-radius: var(--rounded);
|
||||
}
|
||||
|
||||
.divider-tab__button {
|
||||
background-color: transparent;
|
||||
font-size: var(--fonts-xxs);
|
||||
font-weight: var(--semi-bold);
|
||||
text-transform: uppercase;
|
||||
color: var(--text-lighter);
|
||||
border: 0;
|
||||
padding: 0;
|
||||
line-height: 17.36px;
|
||||
}
|
||||
|
||||
.divider-tab__button[data-headlessui-state="selected"] {
|
||||
color: var(--theme);
|
||||
}
|
||||
|
||||
.divider-tab__line-guy {
|
||||
background-color: var(--theme-transparent);
|
||||
border-radius: var(--rounded);
|
||||
height: 4px;
|
||||
width: 20.75px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.tab__button:active {
|
||||
transform: initial;
|
||||
}
|
||||
@@ -566,6 +612,7 @@ dialog::backdrop {
|
||||
margin-inline-start: var(--s-2);
|
||||
}
|
||||
|
||||
.divider-tab__button:focus-visible,
|
||||
.tab__button:focus-visible {
|
||||
color: var(--theme) !important;
|
||||
outline: none;
|
||||
@@ -735,7 +782,7 @@ dialog::backdrop {
|
||||
font-size: var(--fonts-sm);
|
||||
font-weight: var(--semi-bold);
|
||||
gap: var(--s-2);
|
||||
line-height: 1.2;
|
||||
line-height: 1.4;
|
||||
margin-inline: auto;
|
||||
padding-block: var(--s-1-5);
|
||||
padding-inline-end: var(--s-4);
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
}
|
||||
|
||||
.u__extra-info {
|
||||
padding: var(--s-1) var(--s-1-5);
|
||||
padding: var(--s-1) var(--s-1);
|
||||
border-radius: var(--rounded);
|
||||
background-color: var(--bg-darker);
|
||||
font-size: var(--fonts-xxs);
|
||||
@@ -417,3 +417,17 @@
|
||||
background-color: var(--bg-lighter);
|
||||
padding: var(--s-1-5);
|
||||
}
|
||||
|
||||
.u__season__tentative {
|
||||
text-transform: uppercase;
|
||||
font-weight: var(--semi-bold);
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xxs);
|
||||
margin-top: -8px;
|
||||
display: flex;
|
||||
gap: var(--s-1);
|
||||
}
|
||||
|
||||
.u__season__tentative__explanation {
|
||||
text-transform: initial;
|
||||
}
|
||||
|
||||
@@ -222,10 +222,18 @@
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.ml-1 {
|
||||
margin-inline-start: var(--s-1);
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-inline-start: var(--s-2);
|
||||
}
|
||||
|
||||
.ml-2-5 {
|
||||
margin-inline-start: var(--s-2-5);
|
||||
}
|
||||
|
||||
.ml-4 {
|
||||
margin-inline-start: var(--s-4);
|
||||
}
|
||||
@@ -250,6 +258,10 @@
|
||||
margin-block: auto;
|
||||
}
|
||||
|
||||
.my-1 {
|
||||
margin-block: var(--s-1);
|
||||
}
|
||||
|
||||
.my-2 {
|
||||
margin-block: var(--s-2);
|
||||
}
|
||||
@@ -314,6 +326,10 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.justify-evenly {
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.justify-self-end {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
@@ -366,6 +382,10 @@
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.line-height-very-tight {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 480px) {
|
||||
.mobile-hidden {
|
||||
display: inherit;
|
||||
|
||||
@@ -89,6 +89,7 @@ html {
|
||||
--label-margin: var(--s-1);
|
||||
--inactive-image-filter: grayscale(100%) brightness(30%);
|
||||
--leaderboard-top-spacing: var(--s-4);
|
||||
--sticky-top: 60px;
|
||||
}
|
||||
|
||||
html.dark {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ActionArgs } from "@remix-run/node";
|
||||
import type { ActionArgs, LoaderArgs } from "@remix-run/node";
|
||||
import type { z } from "zod";
|
||||
import { ADMIN_ID } from "~/constants";
|
||||
import { NZAP_TEST_ID } from "~/db/seed/constants";
|
||||
@@ -6,28 +6,79 @@ import { db, sql } from "~/db/sql";
|
||||
import { SESSION_KEY } from "~/features/auth/core/authenticator.server";
|
||||
import { authSessionStorage } from "~/features/auth/core/session.server";
|
||||
|
||||
export function arrayContainsSameItems<T>(arr1: T[], arr2: T[]) {
|
||||
return (
|
||||
arr1.length === arr2.length && arr1.every((item) => arr2.includes(item))
|
||||
);
|
||||
}
|
||||
|
||||
export function wrappedAction<T extends z.ZodTypeAny>({
|
||||
action,
|
||||
params = {},
|
||||
}: {
|
||||
// TODO: strongly type this
|
||||
action: (args: ActionArgs) => any;
|
||||
params?: ActionArgs["params"];
|
||||
}) {
|
||||
return async (
|
||||
args: z.infer<T>,
|
||||
{ user }: { user?: "admin" | "regular" } = {},
|
||||
) => {
|
||||
const params = new URLSearchParams(args);
|
||||
const body = new URLSearchParams(args);
|
||||
const request = new Request("http://app.com/path", {
|
||||
method: "POST",
|
||||
body: params,
|
||||
body,
|
||||
headers: await authHeader(user),
|
||||
});
|
||||
|
||||
return action({
|
||||
request,
|
||||
context: {},
|
||||
params: {},
|
||||
try {
|
||||
const response = await action({
|
||||
request,
|
||||
context: {},
|
||||
params,
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (thrown) {
|
||||
if (thrown instanceof Response) {
|
||||
// it was a redirect
|
||||
if (thrown.status === 302) return thrown;
|
||||
|
||||
throw new Error(`Response thrown with status code: ${thrown.status}`);
|
||||
}
|
||||
|
||||
throw thrown;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function wrappedLoader<T>({
|
||||
loader,
|
||||
}: {
|
||||
// TODO: strongly type this
|
||||
loader: (args: LoaderArgs) => any;
|
||||
}) {
|
||||
return async ({ user }: { user?: "admin" | "regular" } = {}) => {
|
||||
const request = new Request("http://app.com/path", {
|
||||
method: "GET",
|
||||
headers: await authHeader(user),
|
||||
});
|
||||
|
||||
try {
|
||||
const data = await loader({
|
||||
request,
|
||||
params: {},
|
||||
context: {},
|
||||
});
|
||||
|
||||
return data as T;
|
||||
} catch (thrown) {
|
||||
if (thrown instanceof Response) {
|
||||
throw new Error(`Response thrown with status code: ${thrown.status}`);
|
||||
}
|
||||
|
||||
throw thrown;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ function at<T>(arr: T[], n: number) {
|
||||
return arr[n];
|
||||
}
|
||||
|
||||
// TODO: i18n (at least for SendouQ)
|
||||
export function joinListToNaturalString(arg: string[], lastSeparator = "and") {
|
||||
if (arg.length === 1) return arg[0];
|
||||
|
||||
|
||||
@@ -25,7 +25,13 @@ export function sumArray(arr: number[]) {
|
||||
return arr.reduce((acc, curr) => acc + curr, 0);
|
||||
}
|
||||
|
||||
export function safeNumberParse(value: string) {
|
||||
export function averageArray(arr: number[]) {
|
||||
return sumArray(arr) / arr.length;
|
||||
}
|
||||
|
||||
export function safeNumberParse(value: string | null) {
|
||||
if (value === null) return null;
|
||||
|
||||
const result = Number(value);
|
||||
return Number.isNaN(result) ? null : result;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { serializeBuild } from "~/features/build-analyzer";
|
||||
import type { ArtSouce } from "~/features/art";
|
||||
import { JOIN_CODE_SEARCH_PARAM_KEY } from "~/features/sendouq/q-constants";
|
||||
import type { TierName } from "~/features/mmr/mmr-constants";
|
||||
import type { Preference } from "~/db/tables";
|
||||
|
||||
const staticAssetsUrl = ({
|
||||
folder,
|
||||
@@ -99,6 +100,7 @@ export const SENDOUQ_YOUTUBE_VIDEO =
|
||||
"https://youtu.be/juOIDmkS1dw?si=iMU4LC_bDmp3fiB1";
|
||||
export const SENDOUQ_PAGE = "/q";
|
||||
export const SENDOUQ_RULES_PAGE = "/q/rules";
|
||||
export const SENDOUQ_SETTINGS_PAGE = "/q/settings";
|
||||
export const SENDOUQ_PREPARING_PAGE = "/q/preparing";
|
||||
export const SENDOUQ_LOOKING_PAGE = "/q/looking";
|
||||
export const TIERS_PAGE = "/tiers";
|
||||
@@ -337,8 +339,18 @@ export const stageImageUrl = (stageId: StageId) =>
|
||||
`/static-assets/img/stages/${stageId}`;
|
||||
export const brandImageUrl = (brand: "tentatek" | "takoroka") =>
|
||||
`/static-assets/img/layout/${brand}`;
|
||||
export const tierImageUrl = (tier: TierName) =>
|
||||
export const tierImageUrl = (tier: TierName | "CALCULATING") =>
|
||||
`/static-assets/img/tiers/${tier.toLowerCase()}`;
|
||||
export const preferenceEmojiUrl = (preference?: Preference) => {
|
||||
const emoji =
|
||||
preference === "PREFER"
|
||||
? "grin"
|
||||
: preference === "AVOID"
|
||||
? "unamused"
|
||||
: "no-mouth";
|
||||
|
||||
return `/static-assets/img/emoji/${emoji}.svg`;
|
||||
};
|
||||
export const TIER_PLUS_URL = `/static-assets/img/tiers/plus`;
|
||||
|
||||
export const winnersImageUrl = ({
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import type { ZodType } from "zod";
|
||||
import { z } from "zod";
|
||||
import type { abilitiesShort } from "~/modules/in-game-lists";
|
||||
import {
|
||||
abilities,
|
||||
mainWeaponIds,
|
||||
modesShort,
|
||||
stageIds,
|
||||
} from "~/modules/in-game-lists";
|
||||
import { assertType } from "./types";
|
||||
import { abilities, mainWeaponIds, stageIds } from "~/modules/in-game-lists";
|
||||
import type { Unpacked } from "./types";
|
||||
import { assertType } from "./types";
|
||||
|
||||
export const id = z.coerce.number().int().positive();
|
||||
export const dbBoolean = z.coerce.number().min(0).max(1).int();
|
||||
@@ -66,23 +62,12 @@ assertType<z.infer<typeof ability>, Unpacked<typeof abilitiesShort>>();
|
||||
|
||||
export const weaponSplId = z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.refine((val) =>
|
||||
mainWeaponIds.includes(val as (typeof mainWeaponIds)[number]),
|
||||
),
|
||||
numericEnum(mainWeaponIds),
|
||||
);
|
||||
|
||||
export const modeShort = z
|
||||
.string()
|
||||
.refine((val) => modesShort.includes(val as any));
|
||||
export const modeShort = z.enum(["TW", "SZ", "TC", "RM", "CB"]);
|
||||
|
||||
export const stageId = z.preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.refine((val) => stageIds.includes(val as (typeof stageIds)[number])),
|
||||
);
|
||||
export const stageId = z.preprocess(actualNumber, numericEnum(stageIds));
|
||||
|
||||
export function processMany(
|
||||
...processFuncs: Array<(value: unknown) => unknown>
|
||||
@@ -202,3 +187,18 @@ export function deduplicate(value: unknown) {
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// https://github.com/colinhacks/zod/issues/1118#issuecomment-1235065111
|
||||
export function numericEnum<TValues extends readonly number[]>(
|
||||
values: TValues,
|
||||
) {
|
||||
return z.number().superRefine((val, ctx) => {
|
||||
if (!values.includes(val)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.invalid_enum_value,
|
||||
options: [...values],
|
||||
received: val,
|
||||
});
|
||||
}
|
||||
}) as ZodType<TValues[number]>;
|
||||
}
|
||||
|
||||
BIN
db-test.sqlite3
29
migrations/043-q-new-map-pick.js
Normal file
@@ -0,0 +1,29 @@
|
||||
module.exports.up = function (db) {
|
||||
db.transaction(() => {
|
||||
db.prepare(
|
||||
/* sql */ `alter table "User" add "mapModePreferences" text`,
|
||||
).run();
|
||||
db.prepare(/* sql */ `alter table "User" add "qWeaponPool" text`).run();
|
||||
|
||||
db.prepare(`alter table "Group" drop column "mapListPreference"`).run();
|
||||
|
||||
db.prepare(`drop index map_pool_map_group_id`).run();
|
||||
db.prepare(`delete from "MapPoolMap" where "groupId" is not null`).run();
|
||||
db.prepare(`alter table "MapPoolMap" drop column "groupId"`).run();
|
||||
|
||||
db.prepare(
|
||||
/*sql*/ `
|
||||
create table "PrivateUserNote" (
|
||||
"authorId" integer not null,
|
||||
"targetId" integer not null,
|
||||
"text" text,
|
||||
"sentiment" text not null,
|
||||
"updatedAt" integer default (strftime('%s', 'now')) not null,
|
||||
foreign key ("authorId") references "User"("id") on delete cascade,
|
||||
foreign key ("targetId") references "User"("id") on delete cascade,
|
||||
unique("authorId", "targetId") on conflict rollback
|
||||
) strict
|
||||
`,
|
||||
).run();
|
||||
})();
|
||||
};
|
||||
13
package-lock.json
generated
@@ -81,6 +81,7 @@
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"ignore-styles": "^5.0.1",
|
||||
"ley": "^0.8.1",
|
||||
"mockdate": "^3.0.5",
|
||||
"prettier": "3.0.2",
|
||||
@@ -10473,6 +10474,12 @@
|
||||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-styles": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore-styles/-/ignore-styles-5.0.1.tgz",
|
||||
"integrity": "sha512-gQQmIznCETPLEzfg1UH4Cs2oRq+HBPl8quroEUNXT8oybEG7/0lqI3dGgDSRry6B9HcCXw3PVkFFS0FF3CMddg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.0",
|
||||
"dev": true,
|
||||
@@ -24692,6 +24699,12 @@
|
||||
"integrity": "sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==",
|
||||
"dev": true
|
||||
},
|
||||
"ignore-styles": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore-styles/-/ignore-styles-5.0.1.tgz",
|
||||
"integrity": "sha512-gQQmIznCETPLEzfg1UH4Cs2oRq+HBPl8quroEUNXT8oybEG7/0lqI3dGgDSRry6B9HcCXw3PVkFFS0FF3CMddg==",
|
||||
"dev": true
|
||||
},
|
||||
"import-fresh": {
|
||||
"version": "3.3.0",
|
||||
"dev": true,
|
||||
|
||||
@@ -30,13 +30,16 @@
|
||||
"delete-skill": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/delete-skill.ts",
|
||||
"skip-plus": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/skip-plus.ts",
|
||||
"season-initial-powers": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/season-initial-powers.ts",
|
||||
"map-popularity": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/map-popularity.ts",
|
||||
"transfer-weapon-pools": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/transfer-weapon-pools.ts",
|
||||
"refresh-prod-db": "node --experimental-specifier-resolution=node --loader ts-node/esm -r tsconfig-paths/register scripts/refresh-prod-db.ts && npm run migrate up",
|
||||
"lint:ts": "eslint . --ext .ts,.tsx",
|
||||
"lint:css": "stylelint \"app/styles/**/*.css\"",
|
||||
"prettier:check": "prettier --check . --log-level warn",
|
||||
"prettier:write": "prettier --write . --log-level warn",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test:unit": "cross-env DB_PATH=:memory: NODE_ENV=test BASE_URL=https://example.com uvu -r tsm -r tsconfig-paths/register -i e2e",
|
||||
"test:unit": "cross-env DB_PATH=:memory: NODE_ENV=test BASE_URL=https://example.com SKALOP_SYSTEM_MESSAGE_URL=http://skalop.test SKALOP_TOKEN=test uvu -r tsm -r tsconfig-paths/register -r ignore-styles -i e2e",
|
||||
"test:unit:single": "cross-env DB_PATH=:memory: NODE_ENV=test BASE_URL=https://example.com SKALOP_SYSTEM_MESSAGE_URL=http://skalop.test SKALOP_TOKEN=test node -r tsm -r tsconfig-paths/register -r ignore-styles",
|
||||
"test:e2e": "npx playwright test",
|
||||
"checks": "npm run test:unit && npm run lint:css && npm run lint:ts && npm run prettier:check && npm run typecheck",
|
||||
"cf": "npm run test:unit && npm run check-translation-jsons && npm run lint:css -- --fix && npm run lint:ts -- --fix && npm run prettier:write && npm run typecheck && npm run test:e2e",
|
||||
@@ -116,6 +119,7 @@
|
||||
"eslint": "^8.48.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"ignore-styles": "^5.0.1",
|
||||
"ley": "^0.8.1",
|
||||
"mockdate": "^3.0.5",
|
||||
"prettier": "3.0.2",
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"actions.search": "Search",
|
||||
"actions.setBg": "Set background",
|
||||
"actions.join": "Join",
|
||||
"actions.nevermind": "Nevermind",
|
||||
"actions.upload": "Upload",
|
||||
|
||||
"maps.createMapList": "Create map list",
|
||||
@@ -195,5 +196,15 @@
|
||||
|
||||
"leaderboard.type.USER": "User",
|
||||
"leaderboard.type.TEAM": "Team",
|
||||
"leaderboard.type.XP-ALL": "All"
|
||||
"leaderboard.type.XP-ALL": "All",
|
||||
|
||||
"chat.connected": "Connected",
|
||||
"chat.disconnected": "Disconnected",
|
||||
"chat.send": "Send",
|
||||
"chat.input.placeholder": "Press enter to send",
|
||||
"chat.systemMsg.scoreReported": "{{name}} reported score",
|
||||
"chat.systemMsg.scoreConfirmed": "{{name}} confirmed score. Match is now locked",
|
||||
"chat.systemMsg.cancelReported": "{{name}} requested canceling the match",
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} confirmed canceling the match. Match is now locked",
|
||||
"chat.systemMsg.userLeft": "{{name}} left the group"
|
||||
}
|
||||
|
||||
@@ -10,5 +10,143 @@
|
||||
"roles.REGULAR": "Regular member",
|
||||
"vc.YES": "Can voice chat",
|
||||
"vc.NO": "Can't voice chat",
|
||||
"vc.LISTEN_ONLY": "Can only listen"
|
||||
"vc.LISTEN_ONLY": "Can only listen",
|
||||
"privateNote.header": "Private note about {{name}}",
|
||||
"privateNote.comment.header": "Comment",
|
||||
"privateNote.sentiment.header": "Sentiment",
|
||||
"privateNote.sentiment.info": "Positive or negative sentiment affects their sorting for you in the queue",
|
||||
"privateNote.sentiment.POSITIVE": "Positive",
|
||||
"privateNote.sentiment.NEUTRAL": "Neutral",
|
||||
"privateNote.sentiment.NEGATIVE": "Negative",
|
||||
"privateNote.delete.header": "Delete your note about {{name}}?",
|
||||
|
||||
"front.cities.la": "Los Angeles",
|
||||
"front.cities.nyc": "New York",
|
||||
"front.cities.paris": "Paris",
|
||||
"front.cities.tokyo": "Tokyo",
|
||||
"front.watchVideo": "Watch introduction video on YouTube by Chara",
|
||||
"front.inviteCodeWrong": "Invite code doesn't match any active team",
|
||||
"front.upcomingSeason.header": "It's off-season!",
|
||||
"front.upcomingSeason.date": "Join Season {{nth}} starting {{date}}",
|
||||
"front.nav.rules.title": "Rules",
|
||||
"front.nav.rules.description": "Read these before playing",
|
||||
"front.nav.settings.title": "Settings",
|
||||
"front.nav.settings.description": "Map preferences, weapon pool, voice chat & sounds",
|
||||
"front.nav.leaderboards.title": "Leaderboards",
|
||||
"front.nav.leaderboards.description": "SendouQ solo and team leaderboards",
|
||||
"front.nav.mySeason.title": "My season",
|
||||
"front.nav.mySeason.description": "Match and SP history",
|
||||
"front.actions.logIn": "Log in to join SendouQ",
|
||||
"front.actions.joinWithGroup": "Join with mates",
|
||||
"front.actions.joinSolo": "Join solo",
|
||||
"front.join.header": "Join the group with {{members}}?",
|
||||
"front.join.joinAction": "Join",
|
||||
"front.join.joinWithTrustAction": "Join & trust {{inviterName})",
|
||||
"front.join.joinWithTrustAction.explanation": "Trusting a user allows them to add you to groups without an invite link in the future",
|
||||
"front.join.refuseAction": "No thanks",
|
||||
"front.seasonOpen": "Season {{nth}} open",
|
||||
|
||||
"settings.maps.header": "Stages and modes",
|
||||
"settings.maps.avoid": "Avoid",
|
||||
"settings.maps.prefer": "Prefer",
|
||||
"settings.maps.neutral": "Neutral",
|
||||
"settings.weaponPool.header": "Weapon pool",
|
||||
"settings.weaponPool.full": "Weapon pool is full",
|
||||
"settings.voiceChat.header": "Voice chat",
|
||||
"settings.voiceChat.canVC.header": "Can voice chat?",
|
||||
"settings.voiceChat.canVC.yes": "Yes",
|
||||
"settings.voiceChat.canVC.no": "No",
|
||||
"settings.voiceChat.canVC.listenOnly": "Listen only",
|
||||
"settings.voiceChat.languages.header": "Your languages",
|
||||
"settings.voiceChat.languages.placeholder": "Select all that apply",
|
||||
"settings.sounds.header": "Sounds",
|
||||
"settings.sounds.likeReceived": "Like received",
|
||||
"settings.sounds.groupNewMember": "Group new member",
|
||||
"settings.sounds.matchStarted": "Match started",
|
||||
|
||||
"looking.joiningGroupError": "Before joining another group, leave the current one",
|
||||
"looking.goToSettingsPrompt": "To help group finding set your weapon pool and voice chat status on the settings page",
|
||||
"looking.inactiveGroup.soon": "Group will be marked inactive. Still looking?",
|
||||
"looking.inactiveGroup": "Group hidden due to inactivity. Still looking?",
|
||||
"looking.inactiveGroup.action": "Click here",
|
||||
"looking.lastUpdatedAt": "Last updated at {{time}}",
|
||||
"looking.columns.invited": "Invited",
|
||||
"looking.columns.challenged": "Challenged",
|
||||
"looking.columns.invitations": "Invitations",
|
||||
"looking.columns.challenges": "Challenges",
|
||||
"looking.columns.available": "Available",
|
||||
"looking.columns.myGroup": "My group",
|
||||
"looking.columns.chat": "Chat",
|
||||
"looking.columns.groups": "Groups",
|
||||
"looking.groups.actions.startMatch": "Start match",
|
||||
"looking.groups.actions.challenge": "Challenge",
|
||||
"looking.groups.actions.invite": "Invite",
|
||||
"looking.groups.actions.groupUp": "Group up",
|
||||
"looking.groups.actions.undo": "Undo",
|
||||
"looking.groups.actions.giveManager": "Give manager",
|
||||
"looking.groups.actions.removeManager": "Remove manager",
|
||||
"looking.groups.actions.kick": "Kick",
|
||||
"looking.groups.actions.leaveGroup": "Leave group",
|
||||
"looking.groups.actions.leaveQ": "Leave queue",
|
||||
"looking.groups.actions.goBack": "Go back",
|
||||
"looking.groups.adder.quickAdd": "Quick add",
|
||||
"looking.groups.adder.selectUser": "Select user",
|
||||
"looking.groups.adder.inviteLink": "Invite link",
|
||||
"looking.groups.editNote": "Edit note",
|
||||
"looking.groups.addNote": "Add note",
|
||||
"looking.replay": "Replay",
|
||||
"looking.teamSP": "Team SP",
|
||||
"looking.teamSP.calculated": "Team SP calculated",
|
||||
"looking.teamSP.calculating": "Team SP calculating...",
|
||||
"looking.sp.calculated": "Calculated",
|
||||
"looking.sp.calculating": "Calculating...",
|
||||
"looking.rankCalculating": "Less than {{count}} sets played. Rank is still calculating...",
|
||||
"looking.allTiers": "All tiers",
|
||||
|
||||
"match.header": "Match #{{number}}",
|
||||
"match.spInfo": "SP will be adjusted after both teams report the same result",
|
||||
"match.dispute.button": "Dispute?",
|
||||
"match.dispute.p1": "If there is a mistake contact the other team to correct it on their side. Score can be freely rereported till both teams report the same result.",
|
||||
"match.dispute.p2": "If there is a problem talking with the other team, contact a mod on the sendou.ink Discord helpdesk. Provide screenshots that show the correct score.",
|
||||
"match.actions.lookAgain": "Look again with same group",
|
||||
"match.actions.stopReportingWeapons": "Stop reporting weapons",
|
||||
"match.actions.reportWeapons": "Report used weapons",
|
||||
"match.report.whoToReport": "Who to report?",
|
||||
"match.report.whoToReport.me": "Me",
|
||||
"match.report.whoToReport.myTeam": "My team",
|
||||
"match.report.whoToReport.everyone": "Everyone",
|
||||
"match.report.copyWeapons": "Copy weapons from above map",
|
||||
"match.report.error": "Report all weapons to submit",
|
||||
"match.report.submit": "Report weapons",
|
||||
"match.report.winnerLabel": "Winner",
|
||||
"match.report.weaponLabel": "Weapon",
|
||||
"match.sides.alpha": "Alpha",
|
||||
"match.sides.bravo": "Bravo",
|
||||
"match.helpdesk": "Helpdesk",
|
||||
"match.pool": "Pool",
|
||||
"match.password.short": "Pass",
|
||||
"match.cancelMatch": "Cancel match",
|
||||
"match.canceled": "Match canceled",
|
||||
"match.reportedBy": "Reported by {{name}} at",
|
||||
"match.cancelPendingConfirmation": "Pending other team's confirmation",
|
||||
"match.cancelMatch.confirm": "Cancel match? (requires confirmation from the other group, abuse of the feature will lead to a ban)",
|
||||
"match.tabs.reportScore": "Report score",
|
||||
"match.errors.cantCancel": "Can't cancel since opponent has already reported score for this match. See dispute instructions at the top of the page.",
|
||||
"match.errors.different": "You reported different results than your opponent. Double check the above is correct and otherwise see dispute instructions at the top of the page.",
|
||||
"match.submitScores": "Submit scores",
|
||||
"match.submitScores.adjusted": "Submit adjusted scores",
|
||||
"match.results.unplayed": "Unplayed",
|
||||
"match.results.us": "Us",
|
||||
"match.results.them": "Them",
|
||||
"match.won": "{{side}} won",
|
||||
"match.reporting": "Reporting {{score}} {{outcome}}",
|
||||
"match.outcome.win": "win",
|
||||
"match.outcome.loss": "loss",
|
||||
|
||||
"preparing.joinQ": "Join the queue",
|
||||
|
||||
"tiers.currentCriteria": "Current criteria",
|
||||
"tiers.info.p1": "For example Leviathan is the top 5% of players. Diamond is the 85th percentile etc.",
|
||||
"tiers.info.p2": "Note: Nobody has Leviathan rank before there are at least {{usersMin}} players on the leaderboard (or {{teamsMin}} for teams)",
|
||||
"tiers.info.p3": "Each rank also has a plus tier (see BRONZE+ as an example below). This means that you are in the top 50% of that rank."
|
||||
}
|
||||
|
||||
1
public/static-assets/img/emoji/grin.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"/><path fill="#664500" d="M16 18c-.419 0-.809-.265-.949-.684C14.848 16.717 14.034 15 13 15c-1.062 0-1.888 1.827-2.051 2.316-.175.523-.738.808-1.265.632-.524-.174-.807-.741-.632-1.265C9.177 16.307 10.356 13 13 13s3.823 3.307 3.949 3.684c.175.524-.108 1.091-.632 1.265-.106.034-.213.051-.317.051zm10 0c-.419 0-.809-.265-.948-.684C24.849 16.717 24.033 15 23 15c-1.062 0-1.889 1.827-2.052 2.316-.175.523-.736.808-1.265.632-.523-.174-.807-.741-.632-1.265C19.177 16.307 20.355 13 23 13s3.823 3.307 3.948 3.684c.175.524-.108 1.091-.632 1.265-.105.034-.212.051-.316.051zm-8 4c-3.623 0-6.027-.422-9-1-.679-.131-2 0-2 2 0 4 4.595 9 11 9 6.404 0 11-5 11-9 0-2-1.321-2.132-2-2-2.973.578-5.377 1-9 1z"/><path fill="#FFF" d="M9 23s3 1 9 1 9-1 9-1-1.344 6.75-9 6.75S9 23 9 23z"/><path fill="#664500" d="M18 27.594c-3.596 0-6.272-.372-7.937-.745l-.825-1.871c.823.312 3.889.897 8.763.897 4.954 0 8.037-.616 8.864-.938l-.701 1.842c-1.634.38-4.419.815-8.164.815z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
public/static-assets/img/emoji/no-mouth.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"/><ellipse fill="#664500" cx="11.5" cy="16.5" rx="2.5" ry="3.5"/><ellipse fill="#664500" cx="24.5" cy="16.5" rx="2.5" ry="3.5"/></svg>
|
||||
|
After Width: | Height: | Size: 311 B |
1
public/static-assets/img/emoji/unamused.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#FFCC4D" d="M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"/><path fill="#664500" d="M25.485 27.879C25.44 27.7 24.317 23.5 18 23.5c-6.318 0-7.44 4.2-7.485 4.379-.055.217.043.442.237.554.195.111.439.078.6-.077.019-.019 1.954-1.856 6.648-1.856s6.63 1.837 6.648 1.855c.096.095.224.145.352.145.084 0 .169-.021.246-.064.196-.112.294-.339.239-.557zM29.001 14c-.305 0-.604-.138-.801-.4-2.432-3.244-6.514-.846-6.686-.743-.475.285-1.089.13-1.372-.343-.284-.474-.131-1.088.343-1.372 1.998-1.199 6.514-2.477 9.314 1.257.332.442.242 1.069-.2 1.4-.179.136-.389.201-.598.201zM6.999 14c-.208 0-.419-.065-.599-.2-.442-.331-.531-.958-.2-1.4 2.801-3.734 7.317-2.456 9.314-1.257.474.284.627.898.343 1.372-.284.473-.896.628-1.37.344-.179-.106-4.274-2.475-6.688.742-.195.261-.496.399-.8.399zM29 16c0-.552-.447-1-1-1h-7c-.553 0-1 .448-1 1s.447 1 1 1h5.092c.207.581.756 1 1.408 1 .828 0 1.5-.671 1.5-1.5 0-.11-.014-.217-.036-.321.012-.06.036-.116.036-.179zm-13 0c0-.552-.448-1-1-1H8c-.552 0-1 .448-1 1s.448 1 1 1h5.092c.207.581.756 1 1.408 1 .828 0 1.5-.671 1.5-1.5 0-.11-.014-.217-.036-.321.011-.06.036-.116.036-.179z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
public/static-assets/img/layout/settings.avif
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
public/static-assets/img/layout/settings.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
public/static-assets/img/tiers/calculating.avif
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
public/static-assets/img/tiers/calculating.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
@@ -179,12 +179,12 @@ module.exports = {
|
||||
route("/q/preparing", "features/sendouq/routes/q.preparing.tsx");
|
||||
route("/q/match/:id", "features/sendouq/routes/q.match.$id.tsx");
|
||||
|
||||
route("/q/settings", "features/sendouq-settings/routes/q.settings.tsx");
|
||||
|
||||
route("/weapon-usage", "features/sendouq/routes/weapon-usage.tsx");
|
||||
|
||||
route("/tiers", "features/sendouq/routes/tiers.tsx");
|
||||
|
||||
route("/settings", "features/settings/routes/settings.tsx");
|
||||
|
||||
route("/admin", "features/admin/routes/admin.tsx");
|
||||
|
||||
route("/a", "features/articles/routes/a.tsx");
|
||||
|
||||
108
scripts/map-popularity.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/* eslint-disable no-console */
|
||||
import "dotenv/config";
|
||||
import { db } from "~/db/sql";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists";
|
||||
import { modesShort, stageIds } from "~/modules/in-game-lists";
|
||||
import names from "../public/locales/en/game-misc.json";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { cutToNDecimalPlaces } from "~/utils/number";
|
||||
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
|
||||
|
||||
async function main() {
|
||||
const appearance = await db
|
||||
.selectFrom("MapPoolMap")
|
||||
.select(({ fn }) => [
|
||||
"MapPoolMap.stageId",
|
||||
"MapPoolMap.mode",
|
||||
fn.countAll<number>().as("count"),
|
||||
])
|
||||
.where("MapPoolMap.calendarEventId", "is not", null)
|
||||
.groupBy(["MapPoolMap.stageId", "MapPoolMap.mode"])
|
||||
.execute();
|
||||
|
||||
const usage: Record<
|
||||
ModeShort,
|
||||
{ stageId: StageId; count: number; relativeCount: number }[]
|
||||
> = {
|
||||
TW: [],
|
||||
SZ: [],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
};
|
||||
|
||||
const ageRow = await db
|
||||
.selectFrom("Build")
|
||||
.select((eb) => eb.fn.max("Build.updatedAt").as("age"))
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const dbAgeDate = databaseTimestampToDate(ageRow.age);
|
||||
|
||||
for (const mode of modesShort) {
|
||||
for (const stageId of stageIds) {
|
||||
const count =
|
||||
appearance.find((row) => row.stageId === stageId && row.mode === mode)
|
||||
?.count ?? 0;
|
||||
|
||||
const firstAppear = await db
|
||||
.selectFrom("MapPoolMap")
|
||||
.innerJoin(
|
||||
"CalendarEvent",
|
||||
"MapPoolMap.calendarEventId",
|
||||
"CalendarEvent.id",
|
||||
)
|
||||
.innerJoin(
|
||||
"CalendarEventDate",
|
||||
"CalendarEvent.id",
|
||||
"CalendarEventDate.eventId",
|
||||
)
|
||||
.select((eb) =>
|
||||
eb.fn.min("CalendarEventDate.startTime").as("firstAppear"),
|
||||
)
|
||||
.executeTakeFirst();
|
||||
|
||||
const firstAppearDate = firstAppear
|
||||
? databaseTimestampToDate(firstAppear.firstAppear)
|
||||
: null;
|
||||
|
||||
const datesSinceFirstAppear = firstAppearDate
|
||||
? Math.floor((dbAgeDate.getTime() - firstAppearDate.getTime()) / 864e5)
|
||||
: null;
|
||||
|
||||
usage[mode].push({
|
||||
stageId,
|
||||
count,
|
||||
relativeCount: datesSinceFirstAppear
|
||||
? cutToNDecimalPlaces((count / datesSinceFirstAppear) * 30, 3)
|
||||
: 0,
|
||||
});
|
||||
}
|
||||
|
||||
usage[mode].sort((a, b) => b.relativeCount - a.relativeCount);
|
||||
}
|
||||
|
||||
console.log(`DB Age: ${dbAgeDate.toISOString()}\n`);
|
||||
for (const mode of modesShort) {
|
||||
console.log(mode);
|
||||
let banCount = 0;
|
||||
for (const [i, { stageId, count, relativeCount }] of usage[
|
||||
mode
|
||||
].entries()) {
|
||||
const name = names[`STAGE_${stageId}`];
|
||||
|
||||
const isBanned = BANNED_MAPS[mode].includes(stageId);
|
||||
if (isBanned) banCount++;
|
||||
|
||||
console.log(
|
||||
`${i < 9 ? " " : ""}${i + 1}) ${
|
||||
isBanned ? "❌" : " "
|
||||
} ${name}: ${relativeCount} (${count})`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("Banned maps: " + banCount);
|
||||
console.log();
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
48
scripts/transfer-weapon-pools.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/* eslint-disable no-console */
|
||||
import "dotenv/config";
|
||||
import { db } from "~/db/sql";
|
||||
|
||||
async function main() {
|
||||
const weaponPools = await db
|
||||
.selectFrom("UserWeapon")
|
||||
.select([
|
||||
"UserWeapon.userId",
|
||||
"UserWeapon.weaponSplId",
|
||||
"UserWeapon.userId",
|
||||
])
|
||||
.where("UserWeapon.order", "!=", 5)
|
||||
.orderBy("UserWeapon.order asc")
|
||||
.execute();
|
||||
|
||||
// group by userId
|
||||
const weaponPoolsByUserId = weaponPools.reduce(
|
||||
(acc, weaponPool) => {
|
||||
if (!acc[weaponPool.userId]) {
|
||||
acc[weaponPool.userId] = [];
|
||||
}
|
||||
|
||||
acc[weaponPool.userId].push(weaponPool);
|
||||
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof weaponPools>,
|
||||
);
|
||||
|
||||
for (const [userId, weaponPools] of Object.entries(weaponPoolsByUserId)) {
|
||||
const weaponPoolIds = weaponPools.map(
|
||||
(weaponPool) => weaponPool.weaponSplId,
|
||||
);
|
||||
|
||||
await db
|
||||
.updateTable("User")
|
||||
.set({
|
||||
qWeaponPool: JSON.stringify(weaponPoolIds),
|
||||
})
|
||||
.where("User.id", "=", Number(userId))
|
||||
.execute();
|
||||
}
|
||||
|
||||
console.log("done with the transfer");
|
||||
}
|
||||
|
||||
void main();
|
||||