diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index e5eb26ad5..259f6a317 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -178,7 +178,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [ adminUserWeaponPool, adminUserWidgets, userProfiles, - userMapModePreferences, + variation === "TEAM_MAP_PREFS" ? undefined : userMapModePreferences, userQWeaponPool, seedingSkills, lastMonthsVoting, @@ -230,7 +230,7 @@ const basicSeeds = (variation?: SeedVariation | null) => [ arts, commissionsOpen, playedMatches, - variation === "NO_SQ_GROUPS" ? undefined : groups, + variation === "NO_SQ_GROUPS" ? undefined : () => groups(variation), friendCodes, lfgPosts, variation === "NO_SCRIMS" ? undefined : scrimPosts, @@ -2099,6 +2099,20 @@ const detailedTeam = (seedVariation?: SeedVariation | null) => () => { ) .run(); } + + const teamPreferences: UserMapModePreferences = { + modes: modesShort.map((mode) => ({ mode, preference: "PREFER" as const })), + pool: modesShort.map((mode) => ({ + mode, + stages: [...SENDOUQ_DEFAULT_MAPS[mode]], + })), + }; + + sql + .prepare( + /*sql*/ `update "AllTeam" set "mapModePreferences" = ? where "id" = 1`, + ) + .run(JSON.stringify(teamPreferences)); }; function otherTeams() { @@ -2456,7 +2470,11 @@ function commissionsOpen() { } const SENDOU_IN_FULL_GROUP = true; -async function groups() { +async function groups(variation?: SeedVariation | null) { + if (variation === "TEAM_MAP_PREFS") { + return teamMapPrefsGroups(); + } + const users = userIdsInAscendingOrderById() .slice(0, 100) .filter((id) => id !== ADMIN_ID && id !== NZAP_TEST_ID); @@ -2498,6 +2516,65 @@ async function groups() { } } +async function teamMapPrefsGroups() { + const arMemberIds = ( + sql + .prepare( + /*sql*/ `select "userId" from "AllTeamMember" where "teamId" = 1 and "leftAt" is null and "userId" != ?`, + ) + .all(ADMIN_ID) as any[] + ).map((row: any) => row.userId as number); + + const arMemberSet = new Set(arMemberIds); + const users = userIdsInAscendingOrderById() + .slice(0, 100) + .filter( + (id) => id !== ADMIN_ID && id !== NZAP_TEST_ID && !arMemberSet.has(id), + ); + + const nzapGroup = await SQGroupRepository.createGroup({ + status: "ACTIVE", + userId: NZAP_TEST_ID, + }); + for (let j = 0; j < 3; j++) { + sql + .prepare( + /* sql */ ` + insert into "GroupMember" ("groupId", "userId", "role") + values (@groupId, @userId, @role) + `, + ) + .run({ + groupId: nzapGroup.id, + userId: users.pop()!, + role: "REGULAR", + }); + } + + const adminGroup = await SQGroupRepository.createGroup({ + status: "ACTIVE", + userId: ADMIN_ID, + }); + for (const memberId of arMemberIds) { + sql + .prepare( + /* sql */ ` + insert into "GroupMember" ("groupId", "userId", "role") + values (@groupId, @userId, @role) + `, + ) + .run({ + groupId: adminGroup.id, + userId: memberId, + role: "REGULAR", + }); + } + + sql + .prepare(/*sql*/ `update "Group" set "teamId" = 1 where "id" = ?`) + .run(adminGroup.id); +} + const randomMapList = ( groupAlpha: number, groupBravo: number, @@ -3111,7 +3188,7 @@ async function friendships(variation?: SeedVariation | null) { .run({ userOneId, userTwoId }); } - if (variation === "NO_SQ_GROUPS") return; + if (variation === "NO_SQ_GROUPS" || variation === "TEAM_MAP_PREFS") return; for (const friendId of SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS) { const group = await SQGroupRepository.createGroup({ diff --git a/app/db/tables.ts b/app/db/tables.ts index 29bf91380..0eac82e42 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -86,6 +86,7 @@ export interface Team { inviteCode: string; name: string; bsky: string | null; + mapModePreferences: JSONColumnTypeNullable; /** Team's tag, typically used in-game in front of users' names to indicate they are a member of the team. */ tag: string | null; } @@ -289,7 +290,11 @@ export type ParsedMemento = { >; /** mapPreferences of season 2 */ mapPreferences?: Array<{ userId: number; preference?: Preference }[]>; - pools: Array<{ userId: number; pool: UserMapModePreferences["pool"] }>; + pools: Array<{ + userId: number; + pool: UserMapModePreferences["pool"]; + teamName?: string; + }>; }; export interface GroupMatch { diff --git a/app/features/api-private/constants.ts b/app/features/api-private/constants.ts index 6f6982bba..d4611d366 100644 --- a/app/features/api-private/constants.ts +++ b/app/features/api-private/constants.ts @@ -6,5 +6,6 @@ export const SEED_VARIATIONS = [ "NZAP_IN_TEAM", "NO_SCRIMS", "NO_SQ_GROUPS", + "TEAM_MAP_PREFS", "FINALIZED_BRACKET", ] as const; diff --git a/app/features/sendouq-match/core/match.server.ts b/app/features/sendouq-match/core/match.server.ts index da0c3af9e..5fc125ab8 100644 --- a/app/features/sendouq-match/core/match.server.ts +++ b/app/features/sendouq-match/core/match.server.ts @@ -347,14 +347,19 @@ export function compareMatchToReportedScores({ return "SAME"; } +type GroupPreference = { + userId: number; + preferences: UserMapModePreferences; + teamName?: string | null; +}; type CreateMatchMementoArgs = { own: { group: SQUncensoredGroup; - preferences: { userId: number; preferences: UserMapModePreferences }[]; + preferences: GroupPreference[]; }; their: { group: SQUncensoredGroup; - preferences: { userId: number; preferences: UserMapModePreferences }[]; + preferences: GroupPreference[]; }; mapList: TournamentMapListMap[]; }; @@ -440,6 +445,7 @@ function poolsMemento(args: CreateMatchMementoArgs): ParsedMemento["pools"] { return { userId: p.userId, pool, + ...(p.teamName ? { teamName: p.teamName } : {}), }; }); } diff --git a/app/features/sendouq-match/routes/q.match.$id.tsx b/app/features/sendouq-match/routes/q.match.$id.tsx index e81eee9a1..1baeaa16a 100644 --- a/app/features/sendouq-match/routes/q.match.$id.tsx +++ b/app/features/sendouq-match/routes/q.match.$id.tsx @@ -1293,17 +1293,49 @@ function MapListMapPickInfo({ return result; }; - const mapPreferences = data.match.memento?.mapPreferences?.[i]; - const showPopover = () => { - // legacy preference system (season 2) - if (mapPreferences && mapPreferences.length > 0) return true; + const sourceTeams = () => { + if (!data.match.memento?.pools) return []; - if (map.source === "DEFAULT") return true; + const pickerGroups = [data.match.groupAlpha, data.match.groupBravo].filter( + (g) => map.source === "BOTH" || String(g.id) === map.source, + ); - return sourcePoolMemberIds().length > 0; + const teams: Array<{ name: string; avatarUrl: string | null }> = []; + for (const pickerGroup of pickerGroups) { + for (const poolEntry of data.match.memento.pools) { + if (!poolEntry.teamName) continue; + if (!pickerGroup.members.some((m) => m.id === poolEntry.userId)) { + continue; + } + + const modePool = poolEntry.pool.find((p) => p.mode === map.mode); + if ( + modePool?.stages.includes(map.stageId) && + !teams.some((t) => t.name === poolEntry.teamName) + ) { + teams.push({ + name: poolEntry.teamName, + avatarUrl: + pickerGroup.team?.name === poolEntry.teamName + ? pickerGroup.team.avatarUrl + : null, + }); + } + } + } + + return teams; }; - if (showPopover()) { + const mapPreferences = data.match.memento?.mapPreferences?.[i]; + const teams = sourceTeams(); + const poolMemberIds = sourcePoolMemberIds(); + const showPopover = + (mapPreferences && mapPreferences.length > 0) || + map.source === "DEFAULT" || + poolMemberIds.length > 0; + + if (showPopover) { return ( {t("tournament:pickInfo.default.explanation")} - ) : sourcePoolMemberIds().length > 0 ? ( + ) : teams.length > 0 ? (
- {sourcePoolMemberIds().map((userId) => { + {teams.map((team) => ( +
+ + {team.name} +
+ ))} +
+ ) : poolMemberIds.length > 0 ? ( +
+ {poolMemberIds.map((userId) => { const user = userIdToUser(userId); return (
!mapModePreferences.pool.some((mp) => mp.mode === mode), - ); - const currentPreferences = ( await db .selectFrom("User") @@ -44,22 +40,55 @@ export async function updateUserMapModePreferences({ .executeTakeFirstOrThrow() ).mapModePreferences; - for (const mode of modesExcluded) { - const previousModePreference = currentPreferences?.pool.filter( - (mp) => mp.mode === mode, - ); - if (previousModePreference && previousModePreference.length > 0) { - mapModePreferences.pool.push(...previousModePreference); - } - } + const mergedPool = mergeExcludedModePreferences( + mapModePreferences.pool, + currentPreferences?.pool, + ); return db .updateTable("User") - .set({ mapModePreferences: JSON.stringify(mapModePreferences) }) + .set({ + mapModePreferences: JSON.stringify({ + ...mapModePreferences, + pool: mergedPool, + }), + }) .where("id", "=", userId) .execute(); } +export async function updateTeamMapModePreferences({ + teamId, + mapModePreferences, +}: { + teamId: number; + mapModePreferences: UserMapModePreferences; +}) { + const currentPreferences = ( + await db + .selectFrom("AllTeam") + .select("mapModePreferences") + .where("id", "=", teamId) + .executeTakeFirstOrThrow() + ).mapModePreferences; + + const mergedPool = mergeExcludedModePreferences( + mapModePreferences.pool, + currentPreferences?.pool, + ); + + return db + .updateTable("AllTeam") + .set({ + mapModePreferences: JSON.stringify({ + ...mapModePreferences, + pool: mergedPool, + }), + }) + .where("id", "=", teamId) + .execute(); +} + export function updateVoiceChat(args: { userId: number; vc: Tables["User"]["vc"]; @@ -112,11 +141,21 @@ export function updateNoScreen({ .execute(); } -export function currentTeamByUserId(userId: number) { - return db - .selectFrom("TeamMember") - .innerJoin("Team", "Team.id", "TeamMember.teamId") - .select(["Team.name"]) - .where("TeamMember.userId", "=", userId) - .executeTakeFirst(); +/** + * Preserves existing preferences for modes not included in the new submission. + * So if they later want to play this mode again, the system remembers their maps. + */ +function mergeExcludedModePreferences( + newPool: UserMapModePreferences["pool"], + currentPool: UserMapModePreferences["pool"] | undefined, +) { + const modesExcluded = modesShort.filter( + (mode) => !newPool.some((mp) => mp.mode === mode), + ); + + const preservedPreferences = modesExcluded.flatMap( + (mode) => currentPool?.filter((mp) => mp.mode === mode) ?? [], + ); + + return [...newPool, ...preservedPreferences]; } diff --git a/app/features/sendouq-settings/actions/q.settings.server.ts b/app/features/sendouq-settings/actions/q.settings.server.ts index e95fa977b..72cfcf3fe 100644 --- a/app/features/sendouq-settings/actions/q.settings.server.ts +++ b/app/features/sendouq-settings/actions/q.settings.server.ts @@ -1,5 +1,6 @@ import { requireUser } from "~/features/auth/core/user.server"; import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server"; +import * as TeamRepository from "~/features/team/TeamRepository.server"; import { parseRequestPayload } from "~/utils/remix.server"; import { assertUnreachable } from "~/utils/types"; import { settingsActionSchema } from "../q-settings-schemas.server"; @@ -13,10 +14,25 @@ export const action = async ({ request }: { request: Request }) => { switch (data._action) { case "UPDATE_MAP_MODE_PREFERENCES": { - await QSettingsRepository.updateUserMapModePreferences({ - mapModePreferences: data.mapModePreferences, - userId: user.id, - }); + if (typeof data.teamId === "number") { + const allTeams = await TeamRepository.findAllMemberOfByUserId(user.id); + const canManage = allTeams.some( + (t) => t.id === data.teamId && (t.isOwner || t.isManager), + ); + if (!canManage) { + throw new Response(null, { status: 403 }); + } + + await QSettingsRepository.updateTeamMapModePreferences({ + mapModePreferences: data.mapModePreferences, + teamId: data.teamId, + }); + } else { + await QSettingsRepository.updateUserMapModePreferences({ + mapModePreferences: data.mapModePreferences, + userId: user.id, + }); + } break; } case "UPDATE_VC": { diff --git a/app/features/sendouq-settings/loaders/q.settings.server.ts b/app/features/sendouq-settings/loaders/q.settings.server.ts index 63dd13988..57e9806d6 100644 --- a/app/features/sendouq-settings/loaders/q.settings.server.ts +++ b/app/features/sendouq-settings/loaders/q.settings.server.ts @@ -1,11 +1,15 @@ import { requireUser } from "~/features/auth/core/user.server"; import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server"; +import * as TeamRepository from "~/features/team/TeamRepository.server"; export const loader = async () => { const user = requireUser(); + const allTeams = await TeamRepository.findAllMemberOfByUserId(user.id); + const manageableTeams = allTeams.filter((t) => t.isOwner || t.isManager); + return { settings: await QSettingsRepository.settingsByUserId(user.id), - team: await QSettingsRepository.currentTeamByUserId(user.id), + manageableTeams, }; }; diff --git a/app/features/sendouq-settings/q-settings-schemas.server.ts b/app/features/sendouq-settings/q-settings-schemas.server.ts index 5a63bb932..24528aace 100644 --- a/app/features/sendouq-settings/q-settings-schemas.server.ts +++ b/app/features/sendouq-settings/q-settings-schemas.server.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { _action, modeShort, safeJSONParse, stageId } from "~/utils/zod"; +import { _action, id, modeShort, safeJSONParse, stageId } from "~/utils/zod"; import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "./q-settings-constants"; import { updateVoiceChatSchema, @@ -10,6 +10,10 @@ const preference = z.enum(["AVOID", "PREFER"]).optional(); export const settingsActionSchema = z.union([ z.object({ _action: _action("UPDATE_MAP_MODE_PREFERENCES"), + teamId: z.preprocess( + (val) => (val === "" || val === undefined ? undefined : Number(val)), + id.optional(), + ), mapModePreferences: z.preprocess( safeJSONParse, z diff --git a/app/features/sendouq-settings/routes/q.settings.tsx b/app/features/sendouq-settings/routes/q.settings.tsx index 5a62a5f18..10b2d39f5 100644 --- a/app/features/sendouq-settings/routes/q.settings.tsx +++ b/app/features/sendouq-settings/routes/q.settings.tsx @@ -5,10 +5,13 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import type { MetaFunction } from "react-router"; import { useFetcher, useLoaderData } from "react-router"; +import { Avatar } from "~/components/Avatar"; +import { SendouSelect, SendouSelectItem } from "~/components/elements/Select"; import { ModeImage } from "~/components/Image"; import { Main } from "~/components/Main"; import { SubmitButton } from "~/components/SubmitButton"; import type { Preference, UserMapModePreferences } from "~/db/tables"; +import { useUser } from "~/features/auth/core/user"; import { soundCodeToLocalStorageKey, soundVolume, @@ -18,6 +21,7 @@ import { SendouForm } from "~/form/SendouForm"; import { useHydrated } from "~/hooks/useHydrated"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort } from "~/modules/in-game-lists/types"; +import type { SerializeFrom } from "~/utils/remix"; import { metaTags } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; import { @@ -79,29 +83,52 @@ export default function SendouQSettingsPage() { ); } +const PERSONAL_KEY = "personal"; + +type ManageableTeam = SerializeFrom["manageableTeams"][number]; + +function preferencesFromRaw( + raw: UserMapModePreferences | null, +): UserMapModePreferences { + if (!raw) return { pool: [], modes: [] }; + + return { + modes: raw.modes, + pool: raw.pool.map((p) => ({ + mode: p.mode, + stages: p.stages.filter((s) => !BANNED_MAPS[p.mode].includes(s)), + })), + }; +} + function MapPicker() { const { t } = useTranslation(["q", "common"]); const data = useLoaderData(); const fetcher = useFetcher(); - const [preferences, setPreferences] = React.useState( - () => { - if (!data.settings.mapModePreferences) { - return { - pool: [], - modes: [], - }; - } + const hasTeams = data.manageableTeams.length > 0; - return { - modes: data.settings.mapModePreferences.modes, - pool: data.settings.mapModePreferences.pool.map((p) => ({ - mode: p.mode, - stages: p.stages.filter((s) => !BANNED_MAPS[p.mode].includes(s)), - })), - }; - }, + const [selectedKey, setSelectedKey] = useState(PERSONAL_KEY); + + const selectedTeamId = + selectedKey === PERSONAL_KEY ? undefined : Number(selectedKey); + + const preferencesForSelection = (key: string) => { + if (key === PERSONAL_KEY) { + return preferencesFromRaw(data.settings.mapModePreferences); + } + const team = data.manageableTeams.find((t) => String(t.id) === key); + return preferencesFromRaw(team?.mapModePreferences ?? null); + }; + + const [preferences, setPreferences] = useState(() => + preferencesForSelection(PERSONAL_KEY), ); + const handleSelectionChange = (key: string) => { + setSelectedKey(key); + setPreferences(preferencesForSelection(key)); + }; + const handleModePreferenceChange = ({ mode, preference, @@ -142,6 +169,14 @@ function MapPicker() { return true; }; + const selectItems = [ + { id: PERSONAL_KEY, name: t("q:settings.maps.personal") }, + ...data.manageableTeams.map((team) => ({ + id: String(team.id), + name: team.name, + })), + ]; + return (
@@ -164,7 +199,34 @@ function MapPicker() { }), })} /> + {selectedTeamId ? ( + + ) : null}
+ {hasTeams ? ( +
+ handleSelectionChange(String(key))} + aria-label={t("q:settings.maps.preferencesFor")} + items={selectItems} + bottomText={t("q:settings.maps.teamExplanation")} + > + {(item) => ( + + + + )} + +
+ ) : null}
{modesShort.map((modeShort) => { const preference = preferences.modes.find( @@ -241,6 +303,35 @@ function MapPicker() { ); } +function MapPickerSelectOption({ + item, + teams, +}: { + item: { id: string; name: string }; + teams: ManageableTeam[]; +}) { + const user = useUser(); + + if (item.id === PERSONAL_KEY) { + return ( +
+ + {item.name} +
+ ); + } + + const team = teams.find((t) => String(t.id) === item.id); + if (!team) return item.name; + + return ( +
+ + {team.name} +
+ ); +} + function VoiceChat() { const { t } = useTranslation(["q"]); const data = useLoaderData(); diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index 46ba00f26..be13d9129 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -12,7 +12,31 @@ import { userIsBanned } from "../ban/core/banned.server"; import { FULL_GROUP_SIZE } from "./q-constants"; import { SendouQError } from "./q-utils.server"; -export function mapModePreferencesByGroupId(groupId: number) { +export async function mapModePreferencesByGroupId(groupId: number) { + const group = await db + .selectFrom("Group") + .leftJoin("AllTeam", "AllTeam.id", "Group.teamId") + .select([ + "AllTeam.mapModePreferences as teamMapModePreferences", + "AllTeam.name as teamName", + ]) + .where("Group.id", "=", groupId) + .executeTakeFirst(); + + if (group?.teamMapModePreferences) { + const members = await db + .selectFrom("GroupMember") + .select("GroupMember.userId") + .where("GroupMember.groupId", "=", groupId) + .execute(); + + return members.map((m) => ({ + userId: m.userId, + preferences: group.teamMapModePreferences as UserMapModePreferences, + teamName: group.teamName, + })); + } + return db .selectFrom("GroupMember") .innerJoin("User", "User.id", "GroupMember.userId") diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index b662a7b0f..f7af186b5 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -69,7 +69,10 @@ export function findAllMemberOfByUserId(userId: number) { "Team.id", "Team.customUrl", "Team.name", + "Team.mapModePreferences", "TeamMemberWithSecondary.role", + "TeamMemberWithSecondary.isOwner", + "TeamMemberWithSecondary.isManager", concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as( "logoUrl", ), diff --git a/db-test.sqlite3 b/db-test.sqlite3 index a401409cf..ede8ef700 100644 Binary files a/db-test.sqlite3 and b/db-test.sqlite3 differ diff --git a/e2e/seeds/db-seed-DEFAULT.sqlite3 b/e2e/seeds/db-seed-DEFAULT.sqlite3 index 43373e50a..19177cbd2 100644 Binary files a/e2e/seeds/db-seed-DEFAULT.sqlite3 and b/e2e/seeds/db-seed-DEFAULT.sqlite3 differ diff --git a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 index 4e9862157..7c0b74dde 100644 Binary files a/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 and b/e2e/seeds/db-seed-FINALIZED_BRACKET.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 index 919f74410..914da1ed3 100644 Binary files a/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 and b/e2e/seeds/db-seed-NO_SCRIMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 index 2791bc9d6..7eb6b597a 100644 Binary files a/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 and b/e2e/seeds/db-seed-NO_SQ_GROUPS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 index 1182df1bf..bddcdf76b 100644 Binary files a/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 and b/e2e/seeds/db-seed-NO_TOURNAMENT_TEAMS.sqlite3 differ diff --git a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 index 269b5d99c..d329fb104 100644 Binary files a/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 and b/e2e/seeds/db-seed-NZAP_IN_TEAM.sqlite3 differ diff --git a/e2e/seeds/db-seed-REG_OPEN.sqlite3 b/e2e/seeds/db-seed-REG_OPEN.sqlite3 index 521795527..22c0c7a00 100644 Binary files a/e2e/seeds/db-seed-REG_OPEN.sqlite3 and b/e2e/seeds/db-seed-REG_OPEN.sqlite3 differ diff --git a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 index a437e578f..5b0b81d7d 100644 Binary files a/e2e/seeds/db-seed-SMALL_SOS.sqlite3 and b/e2e/seeds/db-seed-SMALL_SOS.sqlite3 differ diff --git a/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 new file mode 100644 index 000000000..980b352b7 Binary files /dev/null and b/e2e/seeds/db-seed-TEAM_MAP_PREFS.sqlite3 differ diff --git a/e2e/sendouq.spec.ts b/e2e/sendouq.spec.ts index c6ba44d2c..19c88c015 100644 --- a/e2e/sendouq.spec.ts +++ b/e2e/sendouq.spec.ts @@ -249,4 +249,39 @@ test.describe("SendouQ", () => { combinedGroup.getByTestId("sendouq-group-card-member"), ).toHaveCount(2); }); + + test("Team map preferences shown on match page", async ({ page }) => { + await seed(page, "TEAM_MAP_PREFS"); + await impersonate(page, ADMIN_ID); + + await navigate({ page, url: SENDOUQ_LOOKING_PAGE }); + + const groupCards = page.getByTestId("sendouq-group-card"); + const count = await groupCards.count(); + for (let i = 1; i < count; i++) { + const groupCard = groupCards.nth(i); + const challengeButton = groupCard + .locator('button[type="submit"]') + .first(); + await challengeButton.click(); + } + + await impersonate(page, NZAP_TEST_ID); + await navigate({ page, url: SENDOUQ_LOOKING_PAGE }); + + const acceptButton = page + .getByRole("button", { name: "Start match" }) + .first(); + await expect(acceptButton).toBeVisible(); + await acceptButton.click(); + + await expect(page).toHaveURL(/\/q\/match\/\d+/); + + const popoverButton = page.getByRole("button", { name: /votes/ }).first(); + await expect(popoverButton).toBeVisible(); + await popoverButton.click(); + + const popover = page.getByRole("dialog"); + await expect(popover.getByText("Alliance Rogue")).toBeVisible(); + }); }); diff --git a/locales/da/q.json b/locales/da/q.json index c52a417a6..ff80aa05d 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -51,6 +51,9 @@ "front.preview": "", "front.preview.explanation": "", "settings.maps.header": "", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "", "settings.maps.prefer": "", "settings.maps.neutral": "", diff --git a/locales/da/tournament.json b/locales/da/tournament.json index 34d252c8f..b18afafad 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -54,6 +54,7 @@ "pickInfo.default.explanation": "", "pickInfo.votes_one": "", "pickInfo.votes_other": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "Modvalg", "pickInfo.roll": "", "generator.error": "Ændringer, som du har lavet, blev ikke gemt, da turneringen nu er begyndt. ", diff --git a/locales/de/q.json b/locales/de/q.json index 88f39bd8e..3355040aa 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -51,6 +51,9 @@ "front.preview": "", "front.preview.explanation": "", "settings.maps.header": "", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "", "settings.maps.prefer": "", "settings.maps.neutral": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 31900e6b6..63a7c0bca 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -54,6 +54,7 @@ "pickInfo.default.explanation": "", "pickInfo.votes_one": "", "pickInfo.votes_other": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "Counterpick", "pickInfo.roll": "", "generator.error": "Die Änderungen wurden nicht gespeichert, weil das Turnier bereits begonnen hat", diff --git a/locales/en/q.json b/locales/en/q.json index b38ca8878..76316120d 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -51,6 +51,9 @@ "front.preview": "Preview groups in the queue without joining", "front.preview.explanation": "This feature is only available to Supporter tier (or above) patrons of sendou.ink", "settings.maps.header": "Stages and modes", + "settings.maps.personal": "Personal", + "settings.maps.preferencesFor": "Preferences for", + "settings.maps.teamExplanation": "Team map list is used for all members when queueing as a team", "settings.maps.avoid": "Avoid", "settings.maps.prefer": "Prefer", "settings.maps.neutral": "Neutral", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index 2d1fc2b5f..a0edf91ee 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -54,6 +54,7 @@ "pickInfo.default.explanation": "A suitable map was not available from the participants' pools. This map was selected from all popular maps instead.", "pickInfo.votes_one": "{{count}} vote", "pickInfo.votes_other": "{{count}} votes", + "pickInfo.teamMapList": "{{teamName}} map list", "pickInfo.counterpick": "Counterpick", "pickInfo.roll": "Random", "generator.error": "Changes you made weren't saved since the tournament started", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index e3daaac86..a74997146 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -51,6 +51,9 @@ "front.preview": "Ver avance de grupos en fila sin unirte", "front.preview.explanation": "Esta función solo es disponible para Patrons a nivel Supporter (o más alto) de sendou.ink", "settings.maps.header": "Mapas y modos", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "Evitar", "settings.maps.prefer": "Preferir", "settings.maps.neutral": "Neutral", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 96e35e5cd..72710251f 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -55,6 +55,7 @@ "pickInfo.votes_one": "{{count}} voto", "pickInfo.votes_many": "{{count}} votos", "pickInfo.votes_other": "{{count}} votos", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "Contraselección", "pickInfo.roll": "", "generator.error": "Los cambios no fueron guardados ya que el torneo comenzó", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index 83eb8b78d..c101ef4c3 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -51,6 +51,9 @@ "front.preview": "Ver avance de grupos en fila sin unirte", "front.preview.explanation": "Esta función solo es disponible para Patrons a nivel Supporter (o más alto) de sendou.ink", "settings.maps.header": "Escenarios y Estilos", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "Evitar", "settings.maps.prefer": "Preferir", "settings.maps.neutral": "Neutral", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 338d39274..3ba0494c6 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -55,6 +55,7 @@ "pickInfo.votes_one": "", "pickInfo.votes_many": "", "pickInfo.votes_other": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "Contraselección", "pickInfo.roll": "", "generator.error": "Cambios no fueron guardados ya que el torneo comenzó", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index d76636324..f3deee494 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -51,6 +51,9 @@ "front.preview": "", "front.preview.explanation": "", "settings.maps.header": "", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "", "settings.maps.prefer": "", "settings.maps.neutral": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index ca9736fdc..12440c68d 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -55,6 +55,7 @@ "pickInfo.votes_one": "", "pickInfo.votes_many": "", "pickInfo.votes_other": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "", "pickInfo.roll": "", "generator.error": "Les modifications apportées n'ont pas été enregistrées depuis le début du tournoi", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index 40a3d0334..ae751f9d9 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -51,6 +51,9 @@ "front.preview": "Regarder qui est dans la queue sans la rejoindre", "front.preview.explanation": "Cette fonctionnalité n'est disponible que pour les utilisateurs de niveau Supporter (ou supérieur) sur le patreon de sendou.ink.", "settings.maps.header": "Stages et modes", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "Détester", "settings.maps.prefer": "Préférer", "settings.maps.neutral": "Neutre", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index 65209bad2..5fdc55e35 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -55,6 +55,7 @@ "pickInfo.votes_one": "", "pickInfo.votes_many": "", "pickInfo.votes_other": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "Counterpick", "pickInfo.roll": "", "generator.error": "Les modifications apportées n'ont pas été enregistrées depuis le début du tournoi", diff --git a/locales/he/q.json b/locales/he/q.json index dfc88b378..51b86abed 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -51,6 +51,9 @@ "front.preview": "", "front.preview.explanation": "", "settings.maps.header": "", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "", "settings.maps.prefer": "", "settings.maps.neutral": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index 930ca0994..d89da162a 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -55,6 +55,7 @@ "pickInfo.votes_one": "", "pickInfo.votes_two": "", "pickInfo.votes_other": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "", "pickInfo.roll": "", "generator.error": "שינויים שביצעת לא נשמרו מאז תחילת הטורניר", diff --git a/locales/it/q.json b/locales/it/q.json index d8094cadc..885ff5a71 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -51,6 +51,9 @@ "front.preview": "Visualizza gruppi nella coda prima di unirti", "front.preview.explanation": "Questa funzione è disponibile solo per iscritti al Patreon di sendou.ink di tier Supporter o più", "settings.maps.header": "Mappe e modalità", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "Evita", "settings.maps.prefer": "Preferisci", "settings.maps.neutral": "Neutrale", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index a1c250bb4..6d46c16a7 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -55,6 +55,7 @@ "pickInfo.votes_one": "", "pickInfo.votes_many": "", "pickInfo.votes_other": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "Counterpick", "pickInfo.roll": "", "generator.error": "I cambiamenti che hai fatto non sono stati salvati visto che il torneo è già cominciato.", diff --git a/locales/ja/q.json b/locales/ja/q.json index 4318c54a8..6cd595f88 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -51,6 +51,9 @@ "front.preview": "ジョインせず列にいるグループを見る", "front.preview.explanation": "この機能はサポーター(あるいはそれ以上)の支援者のみ使えます。", "settings.maps.header": "ステージ、モード", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "避ける", "settings.maps.prefer": "好み", "settings.maps.neutral": "普通", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 764ff8f13..dd0b47e6e 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -52,6 +52,7 @@ "pickInfo.both": "両者選択", "pickInfo.default": "", "pickInfo.default.explanation": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "順番に選ぶ", "pickInfo.roll": "", "generator.error": "トーナメントが開始済みなので変更は保存されませんでした。", diff --git a/locales/ko/q.json b/locales/ko/q.json index 88f39bd8e..3355040aa 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -51,6 +51,9 @@ "front.preview": "", "front.preview.explanation": "", "settings.maps.header": "", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "", "settings.maps.prefer": "", "settings.maps.neutral": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 3e31f7bf3..9e311c0ff 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -52,6 +52,7 @@ "pickInfo.both": "", "pickInfo.default": "", "pickInfo.default.explanation": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "", "pickInfo.roll": "", "generator.error": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 88f39bd8e..3355040aa 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -51,6 +51,9 @@ "front.preview": "", "front.preview.explanation": "", "settings.maps.header": "", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "", "settings.maps.prefer": "", "settings.maps.neutral": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index c7e4c53eb..1894e4dab 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -54,6 +54,7 @@ "pickInfo.default.explanation": "", "pickInfo.votes_one": "", "pickInfo.votes_other": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "", "pickInfo.roll": "", "generator.error": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 88f39bd8e..3355040aa 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -51,6 +51,9 @@ "front.preview": "", "front.preview.explanation": "", "settings.maps.header": "", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "", "settings.maps.prefer": "", "settings.maps.neutral": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index ab2fbf4a3..2228823ad 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -56,6 +56,7 @@ "pickInfo.votes_few": "", "pickInfo.votes_many": "", "pickInfo.votes_other": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "", "pickInfo.roll": "", "generator.error": "Zmiany nie zostały zapisane z powodu rozpoczęcia turnieju", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index 9dd927d04..dc50c38ab 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -51,6 +51,9 @@ "front.preview": "Pré-visualizar grupos na fila sem entrar", "front.preview.explanation": "Essa função está disponível apenas para patronos (ou patronesses) da tier Supporter (ou acima) do sendou.ink", "settings.maps.header": "Mapas e modos", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "Evitar", "settings.maps.prefer": "Prefere", "settings.maps.neutral": "Neutro", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 775231cc2..9d449ffbb 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -55,6 +55,7 @@ "pickInfo.votes_one": "", "pickInfo.votes_many": "", "pickInfo.votes_other": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "Counterpick", "pickInfo.roll": "", "generator.error": "Mudanças que você fez não foram salvas uma vez que o torneio já começou", diff --git a/locales/ru/q.json b/locales/ru/q.json index 0699ac4f0..65fbf5144 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -51,6 +51,9 @@ "front.preview": "Предпросмотр груп в очереди без присоединения", "front.preview.explanation": "Данная функция доступна только меценатам sendou.imk уровня Supporter (или выше)", "settings.maps.header": "Арены и режимы", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "Избегаю", "settings.maps.prefer": "Предпочитаю", "settings.maps.neutral": "Нейтрально", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 9a1e117e1..0a3df698c 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -56,6 +56,7 @@ "pickInfo.votes_few": "", "pickInfo.votes_many": "", "pickInfo.votes_other": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "Контрвыбор", "pickInfo.roll": "", "generator.error": "Ваши изменения не были сохранены так как турнир начался", diff --git a/locales/zh/q.json b/locales/zh/q.json index 325ec4f4a..0b694df57 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -51,6 +51,9 @@ "front.preview": "预览当前匹配中的队伍", "front.preview.explanation": "该功能仅对sendou.ink的patron支持者开放", "settings.maps.header": "地图和模式", + "settings.maps.personal": "", + "settings.maps.preferencesFor": "", + "settings.maps.teamExplanation": "", "settings.maps.avoid": "避开", "settings.maps.prefer": "偏好", "settings.maps.neutral": "中立", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 53c721180..3fb65d3c5 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -52,6 +52,7 @@ "pickInfo.both": "双方都选择", "pickInfo.default": "", "pickInfo.default.explanation": "", + "pickInfo.teamMapList": "", "pickInfo.counterpick": "反选", "pickInfo.roll": "", "generator.error": "由于比赛已经开始,您所更改的内容无法保存", diff --git a/migrations/128-team-map-mode-preferences.js b/migrations/128-team-map-mode-preferences.js new file mode 100644 index 000000000..8152599b5 --- /dev/null +++ b/migrations/128-team-map-mode-preferences.js @@ -0,0 +1,5 @@ +export function up(db) { + db.prepare( + /* sql */ `alter table "AllTeam" add column "mapModePreferences" text`, + ).run(); +}