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
This commit is contained in:
Kalle
2023-11-30 20:57:06 +02:00
committed by GitHub
parent 18bf6dd7bb
commit 7dec8c572e
98 changed files with 4985 additions and 2080 deletions

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
: ([

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

@@ -0,0 +1 @@
export const SENDOUQ_WEAPON_POOL_MAX_SIZE = 4;

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -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&apos;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&apos;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). &quot;Higher&quot; is recommended for
Plus Server level players. &quot;Lower&quot; for Low Ink eligible
players. &quot;Default&quot; 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ export interface TournamentMaplistInput {
];
tiebreakerMaps: MapPool;
modesIncluded: ModeShort[];
followModeOrder?: boolean;
}
export type TournamentMaplistSource = number | (typeof sourceTypes)[number];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

View 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
View File

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

View File

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

View File

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

View File

@@ -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."
}

View 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

View 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

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

View File

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

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