;
-
-const AllModes = () => (
- <>
-
-
-
-
- >
-);
-
-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) ?
: null}
- {["ALL_MODES_ONLY", "PREFER_ALL_MODES", "NO_PREFERENCE"].includes(
- preference,
- ) ? (
-
- ) : null}
- {comparisonSign ? (
-
{comparisonSign}
- ) : null}
- {["PREFER_SZ"].includes(preference) ?
: null}
- {["PREFER_ALL_MODES", "NO_PREFERENCE"].includes(preference) ? (
-
- ) : null}
- >
- );
-}
diff --git a/app/features/sendouq/core/groups.server.ts b/app/features/sendouq/core/groups.server.ts
index d03f61d9f..02dba54db 100644
--- a/app/features/sendouq/core/groups.server.ts
+++ b/app/features/sendouq/core/groups.server.ts
@@ -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
;
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;
diff --git a/app/features/sendouq/core/groups.ts b/app/features/sendouq/core/groups.ts
index 43fe7bfe6..da32e5d38 100644
--- a/app/features/sendouq/core/groups.ts
+++ b/app/features/sendouq/core/groups.ts
@@ -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;
}
diff --git a/app/features/sendouq/core/match.server.test.ts b/app/features/sendouq/core/match.server.test.ts
new file mode 100644
index 000000000..7b5ab4e66
--- /dev/null
+++ b/app/features/sendouq/core/match.server.test.ts
@@ -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();
diff --git a/app/features/sendouq/core/match.server.ts b/app/features/sendouq/core/match.server.ts
index 05be68a83..f476aacf7 100644
--- a/app/features/sendouq/core/match.server.ts
+++ b/app/features/sendouq/core/match.server.ts
@@ -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();
+ const groupTwoScores = new Map();
+
+ 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();
+ 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();
+ 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 {
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 = [];
+
+ 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[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 = {};
+
+ 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;
+}
diff --git a/app/features/sendouq/core/reported-weapons.server.ts b/app/features/sendouq/core/reported-weapons.server.ts
index c39fae36a..6befcb91d 100644
--- a/app/features/sendouq/core/reported-weapons.server.ts
+++ b/app/features/sendouq/core/reported-weapons.server.ts
@@ -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;
diff --git a/app/features/sendouq/q-constants.ts b/app/features/sendouq/q-constants.ts
index c89b284e6..92adec010 100644
--- a/app/features/sendouq/q-constants.ts
+++ b/app/features/sendouq/q-constants.ts
@@ -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;
diff --git a/app/features/sendouq/q-schemas.server.ts b/app/features/sendouq/q-schemas.server.ts
index f9d7ee2e0..2322d0acf 100644
--- a/app/features/sendouq/q-schemas.server.ts
+++ b/app/features/sendouq/q-schemas.server.ts
@@ -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({
diff --git a/app/features/sendouq/q-types.ts b/app/features/sendouq/q-types.ts
index 0b8c3c923..ab4d95695 100644
--- a/app/features/sendouq/q-types.ts
+++ b/app/features/sendouq/q-types.ts
@@ -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>;
+ futureMatchModes?: Array;
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;
}[];
};
diff --git a/app/features/sendouq/q.css b/app/features/sendouq/q.css
index 6b31b794a..25b9e6020 100644
--- a/app/features/sendouq/q.css
+++ b/app/features/sendouq/q.css
@@ -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;
}
diff --git a/app/features/sendouq/queries/createGroup.server.ts b/app/features/sendouq/queries/createGroup.server.ts
deleted file mode 100644
index af532d91d..000000000
--- a/app/features/sendouq/queries/createGroup.server.ts
+++ /dev/null
@@ -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 & {
- status: Exclude;
- 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;
- },
-);
diff --git a/app/features/sendouq/queries/createMatch.server.ts b/app/features/sendouq/queries/createMatch.server.ts
index c36eb1765..256b1eeb4 100644
--- a/app/features/sendouq/queries/createMatch.server.ts
+++ b/app/features/sendouq/queries/createMatch.server.ts
@@ -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" (
diff --git a/app/features/sendouq/queries/findPreparingGroup.server.ts b/app/features/sendouq/queries/findPreparingGroup.server.ts
index 76059fef8..40c96e3a8 100644
--- a/app/features/sendouq/queries/findPreparingGroup.server.ts
+++ b/app/features/sendouq/queries/findPreparingGroup.server.ts
@@ -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) => {
diff --git a/app/features/sendouq/queries/groupForMatch.server.ts b/app/features/sendouq/queries/groupForMatch.server.ts
deleted file mode 100644
index dfae836a3..000000000
--- a/app/features/sendouq/queries/groupForMatch.server.ts
+++ /dev/null
@@ -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;
- 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;
-}
diff --git a/app/features/sendouq/queries/leaveGroup.server.ts b/app/features/sendouq/queries/leaveGroup.server.ts
index e34bc3686..8135086fb 100644
--- a/app/features/sendouq/queries/leaveGroup.server.ts
+++ b/app/features/sendouq/queries/leaveGroup.server.ts
@@ -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 });
}
},
);
diff --git a/app/features/sendouq/queries/lookingGroups.server.ts b/app/features/sendouq/queries/lookingGroups.server.ts
deleted file mode 100644
index b4b93406c..000000000
--- a/app/features/sendouq/queries/lookingGroups.server.ts
+++ /dev/null
@@ -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;
- });
-}
diff --git a/app/features/sendouq/queries/mapPoolByGroupId.server.ts b/app/features/sendouq/queries/mapPoolByGroupId.server.ts
deleted file mode 100644
index a107c5761..000000000
--- a/app/features/sendouq/queries/mapPoolByGroupId.server.ts
+++ /dev/null
@@ -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>;
-}
diff --git a/app/features/sendouq/queries/morphGroups.server.ts b/app/features/sendouq/queries/morphGroups.server.ts
index 0c2a108cf..a66dd444a 100644
--- a/app/features/sendouq/queries/morphGroups.server.ts
+++ b/app/features/sendouq/queries/morphGroups.server.ts
@@ -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;
deleteGroupStm.run({ groupId: otherGroupId });
- deleteGroupMapsStm.run({ groupId: otherGroupId });
deleteLikesByGroupId(survivingGroupId);
diff --git a/app/features/sendouq/queries/updateVCStatus.server.ts b/app/features/sendouq/queries/updateVCStatus.server.ts
deleted file mode 100644
index f9157e0a4..000000000
--- a/app/features/sendouq/queries/updateVCStatus.server.ts
+++ /dev/null
@@ -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,
- });
-}
diff --git a/app/features/sendouq/queries/userHasSkill.server.ts b/app/features/sendouq/queries/userHasSkill.server.ts
deleted file mode 100644
index 5350a7966..000000000
--- a/app/features/sendouq/queries/userHasSkill.server.ts
+++ /dev/null
@@ -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;
-}
diff --git a/app/features/sendouq/routes/q.looking.test.ts b/app/features/sendouq/routes/q.looking.test.ts
new file mode 100644
index 000000000..ab96a1318
--- /dev/null
+++ b/app/features/sendouq/routes/q.looking.test.ts
@@ -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({
+ 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>({
+ loader,
+});
+const matchAction = Test.wrappedAction({
+ 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();
diff --git a/app/features/sendouq/routes/q.looking.tsx b/app/features/sendouq/routes/q.looking.tsx
index 564848851..9ecee43b3 100644
--- a/app/features/sendouq/routes/q.looking.tsx
+++ b/app/features/sendouq/routes/q.looking.tsx
@@ -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();
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 (
{wasTryingToJoinAnotherTeam ? (
- Before joining another group, leave the current one
+ {t("q:looking.joiningGroupError")}
) : null}
+ {showGoToSettingPrompt ? (
+ {t("q:looking.goToSettingsPrompt")}
+ ) : null}
);
}
function InfoText() {
- const { i18n } = useTranslation();
+ const { t, i18n } = useTranslation(["q"]);
const isMounted = useIsMounted();
const data = useLoaderData();
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")}{" "}
- Click here
+ {t("q:looking.inactiveGroup.action")}
);
@@ -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")}{" "}
- Click here
+ {t("q:looking.inactiveGroup.action")}
);
@@ -501,20 +541,30 @@ function InfoText() {
return (
+
+
+ {t("q:front.nav.settings.title")}
+
{isMounted
- ? `Last updated at ${new Date(data.lastUpdated).toLocaleTimeString(
- i18n.language,
- )}`
+ ? t("q:looking.lastUpdatedAt", {
+ time: new Date(data.lastUpdated).toLocaleTimeString(i18n.language),
+ })
: "Placeholder"}
);
}
function Groups() {
+ const { t } = useTranslation(["q"]);
const data = useLoaderData();
const isMounted = useIsMounted();
@@ -562,26 +612,63 @@ function Groups() {
const renderChat = data.groups.own.members!.length > 1;
+ const invitedGroupsDesktop = (
+
+
+ {t(
+ isFullGroup
+ ? "q:looking.columns.challenged"
+ : "q:looking.columns.invited",
+ )}
+
+ {data.groups.neutral
+ .filter((group) => group.isLiked)
+ .map((group) => {
+ return (
+
+ );
+ })}
+
+ );
+
const chatElement = (
{renderChat ? (
-
+ <>
+
+
{invitedGroupsDesktop}
+ >
) : null}
);
const ownGroupElement = (
-
+ {!renderChat && (
+ {t("q:looking.columns.myGroup")}
+ )}
+
{ownGroup.inviteCode ? (
+ {!isMobile ? invitedGroupsDesktop : null}
);
+ const flipKey = `${data.groups.neutral
+ .map((g) => `${g.id}-${g.isLiked}`)
+ .join(":")};${data.groups.likesReceived.map((g) => g.id).join(":")}`;
+
return (
-
- {!isMobile ? (
-
+
+
+ {!isMobile ? (
+
+
+
+ ) : null}
+
+
+ {t("q:looking.columns.available")}
+
+ {data.groups.neutral
+ .filter((group) => isMobile || !group.isLiked)
+ .map((group) => {
+ return (
+
+ );
+ })}
+
+ ),
+ },
+ {
+ key: "received",
+ hidden: !isMobile,
+ element: (
+
+ {data.groups.likesReceived.map((group) => {
+ return (
+
+ );
+ })}
+
+ ),
+ },
{
key: "own",
+ hidden: !isMobile,
element: ownGroupElement,
},
{
key: "chat",
element: chatElement,
- hidden: !data.chatCode,
+ hidden: !isMobile || !data.chatCode,
},
]}
/>
- ) : null}
-
-
- {data.groups.neutral.map((group) => {
- return (
-
- );
- })}
-
- ),
- },
- {
- key: "received",
- hidden: !isMobile,
- element: (
-
- {data.groups.likesReceived.map((group) => {
- return (
-
- );
- })}
-
- ),
- },
- {
- key: "own",
- hidden: !isMobile,
- element: ownGroupElement,
- },
- {
- key: "chat",
- element: chatElement,
- hidden: !isMobile || !data.chatCode,
- },
- ]}
- />
+ {!isMobile ? (
+
+
+ {t(
+ isFullGroup
+ ? "q:looking.columns.challenges"
+ : "q:looking.columns.invitations",
+ )}
+
+ {data.groups.likesReceived.map((group) => {
+ return (
+
+ );
+ })}
+
+ ) : null}
- {!isMobile ? (
-
- {data.groups.likesReceived.map((group) => {
- return (
-
- );
- })}
-
- ) : null}
-
+
);
}
+
+function ColumnHeader({ children }: { children: React.ReactNode }) {
+ const { width } = useWindowSize();
+
+ const isMobile = width < 750;
+
+ if (isMobile) return null;
+
+ return {children}
;
+}
diff --git a/app/features/sendouq/routes/q.match.$id.tsx b/app/features/sendouq/routes/q.match.$id.tsx
index 806af686c..e32c0263d 100644
--- a/app/features/sendouq/routes/q.match.$id.tsx
+++ b/app/features/sendouq/routes/q.match.$id.tsx
@@ -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 | 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();
const [showWeaponsForm, setShowWeaponsForm] = React.useState(false);
- const submitScoreFetcher = useFetcher();
- const cancelScoreFetcher = useFetcher();
+ 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 (
-
+
+ navigate(sendouQMatchPage(data.match.id))}
+ />
-
Match #{data.match.id}
+
{t("q:match.header", { number: data.match.id })}
-
+
{[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 (
@@ -509,94 +558,19 @@ export default function QMatchPage() {
);
})}
- {chatRooms.length > 0 ? (
-
- ) : null}
- {!data.match.isLocked && (ownMember || isMod(user)) ? (
-
-
-
- Read the rules
-
- {canReportScore && !data.match.isLocked ? (
-
-
-
- ) : null}
-
-
- If needed, contact your opponent on the
#match-meetup{" "}
- channel of the sendou.ink Discord:{" "}
-
- {SENDOU_INK_DISCORD_URL}
-
- . Alpha team hosts. Password should be{" "}
-
- {resolveRoomPass(data.match.id)}
-
- . Pool code is{" "}
-
- {poolCode()}
-
-
-
- ) : null}
- {cancelScoreFetcher.data?.error === "cant-cancel" ? (
-
- Can't cancel since opponent has already reported score for
- this match. See dispute instructions at the top of the page.
-
- ) : null}
-
- {submitScoreFetcher.data?.error === "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.
-
- ) : null}
>
) : null}
@@ -611,7 +585,7 @@ function Score({
ownTeamReported: boolean;
}) {
const isMounted = useIsMounted();
- const { i18n } = useTranslation();
+ const { t, i18n } = useTranslation(["q"]);
const data = useLoaderData
();
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 (
-
Match canceled
+
+ {t("q:match.canceled")}
+
{!data.match.isLocked ? (
{!ownTeamReported ? (
) : (
- "Pending other team's confirmation"
+ t("q:match.cancelPendingConfirmation")
)}
) : null}
@@ -654,7 +630,7 @@ function Score({
- Reported by {reporter?.discordName ?? admin} at{" "}
+ {t("q:match.reportedBy", { name: reporter?.discordName ?? "admin" })}{" "}
{isMounted
? databaseTimestampToDate(reportedAt).toLocaleString(
i18n.language,
@@ -670,8 +646,7 @@ function Score({
) : (
- SP will be adjusted after both teams report the same results{" "}
- {!ownTeamReported ? : null}
+ {t("q:match.spInfo")} {!ownTeamReported ? : null}
)}
@@ -679,18 +654,15 @@ function Score({
}
function DisputePopover() {
+ const { t } = useTranslation(["q"]);
+
return (
-
-
- 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.
-
-
- 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.
-
+
+ {t("q:match.dispute.p1")}
+ {t("q:match.dispute.p2")}
);
}
@@ -708,6 +680,7 @@ function AfterMatchActions({
showWeaponsForm: boolean;
setShowWeaponsForm: (show: boolean) => void;
}) {
+ const { t } = useTranslation(["q"]);
const data = useLoaderData();
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")}
) : 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")}
) : null}
@@ -755,6 +730,7 @@ function AfterMatchActions({
}
function ReportWeaponsForm() {
+ const { t } = useTranslation(["q", "user"]);
const user = useUser();
const data = useLoaderData();
const weaponsFetcher = useFetcher();
@@ -831,9 +807,9 @@ function ReportWeaponsForm() {
value={JSON.stringify(weaponsUsage)}
/>
-
Who to report?
+
{t("q:match.report.whoToReport")}