diff --git a/app/components/elements/ChipRadio.tsx b/app/components/elements/ChipRadio.tsx index e2b21bdfc..169811fd9 100644 --- a/app/components/elements/ChipRadio.tsx +++ b/app/components/elements/ChipRadio.tsx @@ -5,6 +5,7 @@ interface SendouChipRadioGroupProps { orientation?: "horizontal" | "vertical"; wrap?: boolean; className?: string; + "aria-label"?: string; } interface SendouChipRadioProps { @@ -20,6 +21,7 @@ export function SendouChipRadioGroup({ orientation = "horizontal", wrap = false, className, + "aria-label": ariaLabel, }: SendouChipRadioGroupProps) { return (
{children}
diff --git a/app/db/seed/factories/MapResultFactory.ts b/app/db/seed/factories/MapResultFactory.ts new file mode 100644 index 000000000..bfe8472ad --- /dev/null +++ b/app/db/seed/factories/MapResultFactory.ts @@ -0,0 +1,23 @@ +import * as PlayerStatRepository from "~/features/sendouq-match/PlayerStatRepository.server"; +import type { StageId } from "~/modules/in-game-lists/types"; +import { defineFactory } from "../core/defineFactory"; + +type InsertArgs = Parameters< + typeof PlayerStatRepository.upsertMapResults +>[0][number]; + +/** A user's map record for one mode and stage of a season. Rows for the same mode, stage and season add up, as reported results do. */ +export const { create } = defineFactory({ + defaults: () => ({ + season: 1, + mode: "SZ" as const, + stageId: 1 as StageId, + wins: 1, + losses: 0, + }), + insert: async (args: InsertArgs) => { + await PlayerStatRepository.upsertMapResults([args]); + + return args; + }, +}); diff --git a/app/features/match-profile/core/MapWinRates.test.ts b/app/features/match-profile/core/MapWinRates.test.ts new file mode 100644 index 000000000..0ac5941ff --- /dev/null +++ b/app/features/match-profile/core/MapWinRates.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "vitest"; +import type { StageId } from "~/modules/in-game-lists/types"; +import * as MapWinRates from "./MapWinRates"; + +const results: MapWinRates.MapResult[] = [ + { mode: "SZ", stageId: 1, wins: 6, losses: 4 }, + { mode: "SZ", stageId: 2, wins: 2, losses: 8 }, + { mode: "SZ", stageId: 3, wins: 3, losses: 2 }, + { mode: "SZ", stageId: 4, wins: 12, losses: 8 }, + { mode: "TC", stageId: 1, wins: 10, losses: 0 }, +]; + +describe("MapWinRates.record", () => { + test("sums every stage of a mode", () => { + expect(MapWinRates.record(results, { mode: "SZ" })).toEqual({ + wins: 23, + losses: 22, + }); + }); + + test("picks one stage of a mode", () => { + expect(MapWinRates.record(results, { mode: "TC", stageId: 1 })).toEqual({ + wins: 10, + losses: 0, + }); + }); + + test("is empty for a stage without maps", () => { + expect(MapWinRates.record(results, { mode: "RM", stageId: 1 })).toEqual({ + wins: 0, + losses: 0, + }); + }); +}); + +describe("MapWinRates.rate", () => { + test.each([ + { why: "no maps", wins: 0, losses: 0, expected: null }, + { why: "all wins", wins: 3, losses: 0, expected: 1 }, + { why: "even", wins: 2, losses: 2, expected: 0.5 }, + ])("$why", ({ wins, losses, expected }) => { + expect(MapWinRates.rate({ wins, losses })).toBe(expected); + }); +}); + +describe("MapWinRates.isLowSample", () => { + test.each([ + { why: "no maps", wins: 0, losses: 0, expected: true }, + { why: "just below the threshold", wins: 4, losses: 0, expected: true }, + { why: "at the threshold", wins: 0, losses: 5, expected: false }, + { why: "wins and losses summed", wins: 3, losses: 2, expected: false }, + ])("$why", ({ wins, losses, expected }) => { + expect(MapWinRates.isLowSample({ wins, losses })).toBe(expected); + }); +}); + +describe("MapWinRates.sortStages", () => { + const stageIds: StageId[] = [0, 1, 2, 3, 4]; + + test.each([ + { order: "DEFAULT", expected: [0, 1, 2, 3, 4] }, + { order: "BEST", expected: [4, 1, 3, 2, 0] }, + { order: "WORST", expected: [2, 4, 1, 3, 0] }, + ] as const)("$order", ({ order, expected }) => { + expect( + MapWinRates.sortStages({ stageIds, results, mode: "SZ", order }), + ).toEqual(expected); + }); +}); + +describe("MapWinRates.barSegment", () => { + test.each([ + { + why: "even", + winRate: 0.5, + expected: { startPercent: 50, widthPercent: 0 }, + }, + { + why: "winning", + winRate: 0.6, + expected: { startPercent: 50, widthPercent: 20 }, + }, + { + why: "losing", + winRate: 0.4, + expected: { startPercent: 30, widthPercent: 20 }, + }, + { + why: "capped", + winRate: 0.9, + expected: { startPercent: 50, widthPercent: 50 }, + }, + ])("$why", ({ winRate, expected }) => { + const segment = MapWinRates.barSegment(winRate); + + expect(segment.startPercent).toBeCloseTo(expected.startPercent); + expect(segment.widthPercent).toBeCloseTo(expected.widthPercent); + }); +}); diff --git a/app/features/match-profile/core/MapWinRates.ts b/app/features/match-profile/core/MapWinRates.ts new file mode 100644 index 000000000..3392aa920 --- /dev/null +++ b/app/features/match-profile/core/MapWinRates.ts @@ -0,0 +1,113 @@ +/** + * A player's SendouQ win rates per mode and stage, shown next to the map pool preferences of their + * match profile so they can pick maps they do well on. Records are summed from the per-stage map + * results of recent seasons, small samples are flagged as tentative and each win rate is drawn as a + * bar growing from an even 50%. + */ + +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; + +/** Below this many maps a win rate is shown as tentative. */ +const LOW_SAMPLE_MAPS_COUNT = 5; +/** How much the distance from an even win rate is stretched on the bar, so typical differences stay visible. */ +const BAR_STRETCH = 2; + +/** Maps won and lost by a player on one stage of a mode. */ +export type MapResult = { + mode: ModeShort; + stageId: StageId; + wins: number; + losses: number; +}; + +/** A record of maps won and lost. */ +export type WinLoss = Pick; + +/** How stages are ordered in the picker: as listed, best win rate first or worst first. */ +export type SortOrder = "DEFAULT" | "BEST" | "WORST"; + +/** Sums the records of a mode, or of one stage in it when `stageId` is given. */ +export function record( + results: MapResult[], + { mode, stageId }: { mode: ModeShort; stageId?: StageId }, +): WinLoss { + const total = { wins: 0, losses: 0 }; + + for (const result of results) { + if (result.mode !== mode) continue; + if (stageId !== undefined && result.stageId !== stageId) continue; + + total.wins += result.wins; + total.losses += result.losses; + } + + return total; +} + +/** Share of maps won between 0 and 1, `null` when none were played. */ +export function rate({ wins, losses }: WinLoss): number | null { + const total = wins + losses; + if (total === 0) return null; + + return wins / total; +} + +/** Whether too few maps were played for the win rate to mean much. */ +export function isLowSample({ wins, losses }: WinLoss) { + return wins + losses < LOW_SAMPLE_MAPS_COUNT; +} + +/** + * Orders stages of a mode by win rate. `DEFAULT` keeps the given order; otherwise ties go to the stage + * with more maps played and stages without maps come last, in their given order. + */ +export function sortStages({ + stageIds, + results, + mode, + order, +}: { + stageIds: readonly StageId[]; + results: MapResult[]; + mode: ModeShort; + order: SortOrder; +}): StageId[] { + if (order === "DEFAULT") return [...stageIds]; + + const direction = order === "BEST" ? -1 : 1; + const withRecords = stageIds.map((stageId) => { + const stageRecord = record(results, { mode, stageId }); + return { + stageId, + rate: rate(stageRecord), + total: stageRecord.wins + stageRecord.losses, + }; + }); + + return withRecords + .toSorted((a, b) => { + if (a.rate === null || b.rate === null) { + return Number(a.rate === null) - Number(b.rate === null); + } + if (a.rate !== b.rate) return direction * (a.rate - b.rate); + + return b.total - a.total; + }) + .map(({ stageId }) => stageId); +} + +/** + * Where the bar of a win rate goes on a track centered on 50%, both in percent of the track. It grows right + * of the center for winning records and left for losing ones; 75% and 25% (or beyond) fill their half. + */ +export function barSegment(winRate: number) { + const widthPercent = Math.min( + 50, + Math.abs(winRate - 0.5) * 100 * BAR_STRETCH, + ); + + return { + startPercent: winRate >= 0.5 ? 50 : 50 - widthPercent, + widthPercent, + }; +} diff --git a/app/features/sendouq-match/PlayerStatRepository.server.test.ts b/app/features/sendouq-match/PlayerStatRepository.server.test.ts index ab02e8755..f4988660a 100644 --- a/app/features/sendouq-match/PlayerStatRepository.server.test.ts +++ b/app/features/sendouq-match/PlayerStatRepository.server.test.ts @@ -1,5 +1,6 @@ import { add } from "date-fns"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import * as MapResultFactory from "~/db/seed/factories/MapResultFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as Seasons from "~/features/mmr/core/Seasons"; @@ -75,3 +76,45 @@ describe("PlayerStatRepository.findSeasonSetParticipantsByUserId", () => { expect(participants).toEqual([]); }); }); + +describe("PlayerStatRepository.findMapResultsByUserIdAndSeasons", () => { + beforeEach(async () => { + await users.create(2); + }); + + test("sums a stage's records over the given seasons", async () => { + await MapResultFactory.create({ + userId: playerId(), + season: 1, + mode: "SZ", + stageId: 3, + wins: 2, + losses: 1, + }); + await MapResultFactory.create({ + userId: playerId(), + season: 2, + mode: "SZ", + stageId: 3, + wins: 4, + losses: 3, + }); + + const results = await PlayerStatRepository.findMapResultsByUserIdAndSeasons( + { userId: playerId(), seasons: [1, 2] }, + ); + + expect(results).toEqual([{ mode: "SZ", stageId: 3, wins: 6, losses: 4 }]); + }); + + test("leaves out other seasons and other users", async () => { + await MapResultFactory.create({ userId: playerId(), season: 0 }); + await MapResultFactory.create({ userId: users.id(2), season: 1 }); + + const results = await PlayerStatRepository.findMapResultsByUserIdAndSeasons( + { userId: playerId(), seasons: [1, 2] }, + ); + + expect(results).toEqual([]); + }); +}); diff --git a/app/features/sendouq-match/PlayerStatRepository.server.ts b/app/features/sendouq-match/PlayerStatRepository.server.ts index 5233f407c..80745f0d0 100644 --- a/app/features/sendouq-match/PlayerStatRepository.server.ts +++ b/app/features/sendouq-match/PlayerStatRepository.server.ts @@ -121,6 +121,28 @@ export async function findSeasonStagesByUserId({ }, {}); } +/** Per-mode per-stage win/loss records of a user, summed over the given seasons. */ +export function findMapResultsByUserIdAndSeasons({ + userId, + seasons, +}: { + userId: number; + seasons: number[]; +}) { + return db + .selectFrom("MapResult") + .select(({ fn }) => [ + "mode", + "stageId", + fn.sum("wins").as("wins"), + fn.sum("losses").as("losses"), + ]) + .where("userId", "=", userId) + .where("season", "in", seasons) + .groupBy(["mode", "stageId"]) + .execute(); +} + /** Mates or enemies for a user in a given season, ordered by most sets played together. */ export async function findSeasonMatesEnemiesByUserId({ userId, diff --git a/app/features/settings/components/MapModePreferencesField.module.css b/app/features/settings/components/MapModePreferencesField.module.css new file mode 100644 index 000000000..93a910682 --- /dev/null +++ b/app/features/settings/components/MapModePreferencesField.module.css @@ -0,0 +1,485 @@ +.section { + container-type: inline-size; + display: flex; + flex-direction: column; + gap: var(--s-3); + padding: var(--s-4); + background-color: var(--color-bg-high); + border: var(--border-style); + border-radius: var(--radius-box); +} + +.heading { + margin: 0; + font-size: var(--font-sm); + font-weight: var(--weight-bold); +} + +.record { + font-size: var(--font-2xs); + color: var(--color-text-high); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.modeRow { + display: grid; + grid-template-columns: auto minmax(0, 1fr) 11rem auto; + grid-template-areas: "icon name winRate chips"; + align-items: center; + gap: var(--s-1) var(--s-3); + + & > :first-child { + grid-area: icon; + } + + @container (width < 36rem) { + grid-template-columns: auto minmax(0, 1fr) 8rem; + grid-template-areas: + "icon name winRate" + "chips chips chips"; + } +} + +.modeRowWithoutStats { + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: "icon name chips"; + + @container (width < 36rem) { + grid-template-columns: auto minmax(0, 1fr); + grid-template-areas: + "icon name" + "chips chips"; + } +} + +.modeName { + grid-area: name; + display: flex; + flex-direction: column; + min-width: 0; + font-size: var(--font-sm); + font-weight: var(--weight-bold); +} + +.modeWinRate { + grid-area: winRate; +} + +.preferenceChips { + grid-area: chips; + + @container (width < 36rem) { + width: auto; + + & label { + flex: 1 1 0; + } + } +} + +.poolHeader { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--s-2); +} + +.sort { + display: flex; + align-items: center; + gap: var(--s-2); +} + +.modeTabs { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); +} + +.modeTab { + display: flex; + align-items: center; + gap: var(--s-2); + height: var(--field-size); + padding: 0 var(--s-3) 0 var(--s-2); + border: var(--border-style); + border-radius: var(--radius-full); + background-color: transparent; + color: var(--color-text-high); + font-size: var(--font-xs); + font-weight: var(--weight-bold); + font-variant-numeric: tabular-nums; + + & img { + filter: grayscale(100%); + opacity: 0.5; + transition: + filter 0.2s, + opacity 0.2s; + } + + &:hover img { + opacity: 0.8; + } + + &:focus-visible { + outline: var(--focus-ring); + } +} + +.modeTabActive { + border-color: var(--color-fg-accent); + background-color: var(--color-bg-accent); + color: var(--color-text); + + & img { + filter: none; + opacity: 1; + } +} + +.modeTabCount { + line-height: 1; +} + +.slots { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--s-2) var(--s-3); + padding: var(--s-2-5) var(--s-3); + background-color: var(--color-bg-higher); + border-radius: var(--radius-field); +} + +.slotList { + display: grid; + grid-template-columns: repeat(7, minmax(0, 52px)); + gap: var(--s-1-5); + flex: 1 1 100%; +} + +.emptySlot, +.filledSlot { + aspect-ratio: 16 / 9; + border-radius: 6px; +} + +.emptySlot { + display: grid; + place-items: center; + box-sizing: border-box; + border: 2px dashed var(--color-border-high); + background-color: var(--color-bg); + font-size: var(--font-3xs); + font-weight: var(--weight-bold); + color: var(--color-text-high); +} + +.filledSlot { + padding: 0; + border: none; + overflow: hidden; + background-color: var(--color-bg); + + & img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + &:focus-visible { + outline: var(--focus-ring); + outline-offset: 1px; + } +} + +.mapGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr)); + gap: var(--s-3); + + @container (width < 30rem) { + grid-template-columns: minmax(0, 1fr); + gap: var(--s-1-5); + } +} + +.mapCard { + display: flex; + flex-direction: column; + gap: var(--s-1-5); + min-width: 0; + padding: 0 0 var(--s-2-5); + border: 2px solid transparent; + border-radius: var(--radius-box); + overflow: hidden; + background-color: var(--color-bg-higher); + color: var(--color-text); + text-align: start; + transition: opacity 0.2s; + + &:active { + transform: none; + } + + &:focus-visible { + outline: var(--focus-ring); + } + + @container (width < 30rem) { + flex-direction: row; + align-items: center; + gap: var(--s-2-5); + padding: var(--s-1-5) var(--s-2-5) var(--s-1-5) var(--s-1-5); + border-radius: var(--radius-field); + } +} + +.mapCardPicked { + border-color: var(--color-fg-accent); +} + +.mapCardLocked { + opacity: 0.5; + cursor: not-allowed; +} + +@keyframes wiggle { + 0% { + transform: rotate(0deg); + } + + 25% { + transform: rotate(3deg); + } + + 75% { + transform: rotate(-3deg); + } + + 100% { + transform: rotate(0deg); + } +} + +.mapCardWiggle { + animation: wiggle 0.25s; +} + +.mapCardImage { + position: relative; + display: block; + width: 100%; + aspect-ratio: 16 / 9; + + & img { + display: block; + width: 100%; + height: 100%; + object-fit: cover; + } + + @container (width < 30rem) { + flex-shrink: 0; + width: 72px; + border-radius: 6px; + overflow: hidden; + } +} + +.mapCardCheck { + position: absolute; + top: var(--s-1-5); + right: var(--s-1-5); + display: grid; + place-items: center; + width: 24px; + height: 24px; + border-radius: var(--radius-full); + background-color: var(--color-fill-accent); + color: var(--color-fg-on-accent); + + & svg { + width: 16px; + height: 16px; + stroke-width: 3; + } + + @container (width < 30rem) { + top: 2px; + right: 2px; + width: 18px; + height: 18px; + + & svg { + width: 12px; + height: 12px; + } + } +} + +.mapCardRank { + position: absolute; + top: var(--s-1-5); + left: var(--s-1-5); + padding: 0 var(--s-1-5); + border-radius: var(--radius-full); + background-color: var(--color-bg-overlay); + color: var(--color-text-on-dark); + font-size: var(--font-2xs); + font-weight: var(--weight-bold); + font-variant-numeric: tabular-nums; + + @container (width < 30rem) { + top: auto; + bottom: 2px; + left: 2px; + font-size: var(--font-3xs); + } +} + +.mapCardBody { + display: flex; + flex-direction: column; + gap: var(--s-1); + min-width: 0; + padding: 0 var(--s-2-5); + + @container (width < 30rem) { + flex-grow: 1; + padding: 0; + } +} + +.mapCardName { + font-size: var(--font-xs); + font-weight: var(--weight-bold); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.mapCardRecord { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--s-1-5); +} + +.winRate { + display: flex; + align-items: center; + gap: var(--s-2); + + & .percentage { + min-width: 2.5rem; + text-align: end; + } + + & .bar { + flex-grow: 1; + } +} + +.percentage { + font-size: var(--font-sm); + font-weight: var(--weight-bold); + font-variant-numeric: tabular-nums; + color: var(--color-text-high); +} + +.percentageWinning { + color: var(--color-fg-accent); +} + +.percentageLosing { + color: var(--color-fg-second); +} + +.bar { + position: relative; + display: block; + height: 4px; + border-radius: var(--radius-full); + background-color: var(--color-bg); +} + +.barFill { + position: absolute; + top: 0; + bottom: 0; + border-radius: var(--radius-full); + background-color: var(--color-fg-accent); +} + +.barFillLosing { + background-color: var(--color-fg-second); +} + +.barCenter { + position: absolute; + top: -2px; + bottom: -2px; + left: 50%; + width: 2px; + margin-left: -1px; + background-color: var(--color-border-high); +} + +.banned { + & summary { + display: inline-flex; + align-items: center; + gap: var(--s-1-5); + font-size: var(--font-xs); + font-weight: var(--weight-bold); + color: var(--color-text-high); + cursor: pointer; + list-style: none; + + &::-webkit-details-marker { + display: none; + } + } + + &[open] .bannedChevron { + transform: rotate(90deg); + } +} + +.bannedChevron { + width: 16px; + height: 16px; + transition: transform 0.2s; +} + +.bannedList { + display: flex; + flex-wrap: wrap; + gap: var(--s-2); + margin-top: var(--s-2); +} + +.bannedMap { + display: flex; + align-items: center; + gap: var(--s-2); + padding: var(--s-1) var(--s-2-5) var(--s-1) var(--s-1); + border-radius: var(--radius-field); + background-color: var(--color-bg-higher); + font-size: var(--font-2xs); + color: var(--color-text-high); + + & img { + width: 40px; + height: 22px; + object-fit: cover; + border-radius: 4px; + filter: grayscale(100%); + opacity: 0.6; + } +} diff --git a/app/features/settings/components/MapModePreferencesField.tsx b/app/features/settings/components/MapModePreferencesField.tsx index 42d1685db..82b03cc5b 100644 --- a/app/features/settings/components/MapModePreferencesField.tsx +++ b/app/features/settings/components/MapModePreferencesField.tsx @@ -1,16 +1,33 @@ +import clsx from "clsx"; +import { Check, ChevronRight } from "lucide-react"; import * as React from "react"; import { useTranslation } from "react-i18next"; +import { + SendouChipRadio, + SendouChipRadioGroup, +} from "~/components/elements/ChipRadio"; import { ModeImage } from "~/components/Image"; import type { Preference, UserMapModePreferences } from "~/db/tables-json"; import { BANNED_MAPS, SENDOUQ_MAP_POOL, } from "~/features/match-profile/banned-maps"; +import * as MapWinRates from "~/features/match-profile/core/MapWinRates"; import { AMOUNT_OF_MAPS_IN_POOL_PER_MODE } from "~/features/match-profile/match-profile-constants"; import { modesShort } from "~/modules/in-game-lists/modes"; import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; -import { ModeMapPoolPicker } from "./ModeMapPoolPicker"; -import { PreferenceRadioGroup } from "./PreferenceRadioGroup"; +import { stageImageUrl } from "~/utils/urls"; +import styles from "./MapModePreferencesField.module.css"; + +const PREFERENCE_OPTIONS = ["AVOID", "NEUTRAL", "PREFER"] as const; +const PREFERENCE_LABEL_KEYS = { + AVOID: "settings:matchProfile.maps.avoid", + NEUTRAL: "settings:matchProfile.maps.neutral", + PREFER: "settings:matchProfile.maps.prefer", +} as const; +const SORT_ORDERS: MapWinRates.SortOrder[] = ["DEFAULT", "BEST", "WORST"]; + +type PreferenceOption = (typeof PREFERENCE_OPTIONS)[number]; export function preferencesFromRaw( raw: UserMapModePreferences | null | undefined, @@ -26,22 +43,26 @@ export function preferencesFromRaw( }; } +/** Mode preferences and the map pool of each mode. With `mapResults` the win rate of every mode and map is shown alongside. */ export function MapModePreferencesField({ value, onChange, + mapResults, }: { value: UserMapModePreferences; onChange: (value: UserMapModePreferences) => void; + mapResults?: MapWinRates.MapResult[]; }) { - const { t } = useTranslation(["common"]); - const handleModePreferenceChange = ({ - mode, - preference, - }: { - mode: ModeShort; - preference: Preference & "NEUTRAL"; - }) => { - const newModePreferences = value.modes.filter((map) => map.mode !== mode); + const { t } = useTranslation(["common", "settings"]); + const [selectedMode, setSelectedMode] = React.useState( + modesShort[0], + ); + + const handleModePreferenceChange = ( + mode: ModeShort, + preference: PreferenceOption, + ) => { + const newModePreferences = value.modes.filter((mp) => mp.mode !== mode); if (preference !== "NEUTRAL") { newModePreferences.push({ mode, preference }); } @@ -54,56 +75,414 @@ export function MapModePreferencesField({ onChange({ ...value, pool: filtered }); }; - const pickableModes = modesShort.filter((mode) => { - const mp = value.modes.find((p) => p.mode === mode); - return mp?.preference !== "AVOID"; - }); + const modePreference = (mode: ModeShort): Preference | undefined => + value.modes.find((mp) => mp.mode === mode)?.preference; + const poolOf = (mode: ModeShort) => + value.pool.find((p) => p.mode === mode)?.stages ?? []; - const [selectedMode, setSelectedMode] = React.useState( - modesShort[0], + const pickableModes = modesShort.filter( + (mode) => modePreference(mode) !== "AVOID", ); const activeMode = pickableModes.includes(selectedMode) ? selectedMode : pickableModes[0]; return ( -
-
- {modesShort.map((modeShort) => { - const preference = value.modes.find( - (candidate) => candidate.mode === modeShort, - ); - - return ( -
- - - handleModePreferenceChange({ - mode: modeShort, - preference: newPreference, - }) - } - aria-label={`Select preference towards ${modeShort}`} - /> -
- ); - })} -
- +
+ {mapResults ? ( +

+ {t("settings:matchProfile.maps.winRatesExplanation")} +

+ ) : null} +
+

+ {t("settings:matchProfile.maps.modes")} +

+ {modesShort.map((mode) => ( + + handleModePreferenceChange(mode, preference) + } + mapResults={mapResults} + /> + ))} +
{activeMode ? ( - p.mode === activeMode)?.stages ?? []} - onChange={(stages) => handlePoolChange(activeMode, stages)} + poolOf={poolOf} + onPoolChange={(stages) => handlePoolChange(activeMode, stages)} + mapResults={mapResults} /> ) : null}
); } + +function ModePreferenceRow({ + mode, + preference, + onPreferenceChange, + mapResults, +}: { + mode: ModeShort; + preference: PreferenceOption; + onPreferenceChange: (preference: PreferenceOption) => void; + mapResults?: MapWinRates.MapResult[]; +}) { + const { t } = useTranslation(["settings", "game-misc"]); + const modeRecord = mapResults + ? MapWinRates.record(mapResults, { mode }) + : null; + + return ( +
+ +
+ {t(`game-misc:MODE_LONG_${mode}`)} + {modeRecord ? ( + + {modeRecord.wins}–{modeRecord.losses} + + ) : null} +
+ {modeRecord ? ( + + ) : null} + + {PREFERENCE_OPTIONS.map((option) => ( + onPreferenceChange(option)} + > + {t(PREFERENCE_LABEL_KEYS[option])} + + ))} + +
+ ); +} + +function MapPoolSection({ + mode, + modeTabs, + onModeChange, + poolOf, + onPoolChange, + mapResults, +}: { + mode: ModeShort; + modeTabs: ModeShort[]; + onModeChange: (mode: ModeShort) => void; + poolOf: (mode: ModeShort) => StageId[]; + onPoolChange: (stages: StageId[]) => void; + mapResults?: MapWinRates.MapResult[]; +}) { + const { t } = useTranslation(["common", "settings", "game-misc"]); + const [sortOrder, setSortOrder] = + React.useState("DEFAULT"); + const [wigglingStageId, setWigglingStageId] = React.useState( + null, + ); + + const pool = poolOf(mode); + const poolIsFull = pool.length >= AMOUNT_OF_MAPS_IN_POOL_PER_MODE; + const allowedStages = SENDOUQ_MAP_POOL.parsed[mode]; + const sortedStages = MapWinRates.sortStages({ + stageIds: allowedStages, + results: mapResults ?? [], + mode, + order: mapResults ? sortOrder : "DEFAULT", + }); + + const handleStageClick = (stageId: StageId) => { + if (pool.includes(stageId)) { + onPoolChange(pool.filter((s) => s !== stageId)); + return; + } + + if (poolIsFull) { + setWigglingStageId(stageId); + return; + } + + onPoolChange([...pool, stageId].sort((a, b) => a - b)); + }; + + return ( +
+
+

{t("common:maps.mapPool")}

+ {mapResults ? ( +
+ + {t("settings:matchProfile.maps.sort")} + + + {SORT_ORDERS.map((order) => ( + setSortOrder(order)} + > + {t(`settings:matchProfile.maps.sortOrder.${order}`)} + + ))} + +
+ ) : null} +
+ +
+ {modeTabs.map((tabMode) => { + const active = tabMode === mode; + + return ( + + ); + })} +
+ +
+
+ {Array.from( + { length: AMOUNT_OF_MAPS_IN_POOL_PER_MODE }, + (_, index) => { + const stageId = pool.at(index); + + if (stageId === undefined) { + return ( + + {index + 1} + + ); + } + + return ( + + ); + }, + )} +
+ + {poolIsFull + ? t("settings:matchProfile.maps.poolFull") + : t("settings:matchProfile.maps.pickUpTo", { + count: AMOUNT_OF_MAPS_IN_POOL_PER_MODE - pool.length, + })} + +
+ +
+ {sortedStages.map((stageId, index) => ( + setWigglingStageId(null)} + onClick={() => handleStageClick(stageId)} + mapResults={mapResults} + /> + ))} +
+ + {BANNED_MAPS[mode].length > 0 ? ( +
+ + + {t("settings:matchProfile.maps.bannedCount", { + count: BANNED_MAPS[mode].length, + })} + +
+ {BANNED_MAPS[mode] + .toSorted((a, b) => a - b) + .map((stageId) => ( + + + {t(`game-misc:STAGE_${stageId}`)} + + ))} +
+
+ ) : null} +
+ ); +} + +function MapCard({ + mode, + stageId, + rank, + picked, + locked, + wiggle, + onWiggleEnd, + onClick, + mapResults, +}: { + mode: ModeShort; + stageId: StageId; + rank?: number; + picked: boolean; + locked: boolean; + wiggle: boolean; + onWiggleEnd: () => void; + onClick: () => void; + mapResults?: MapWinRates.MapResult[]; +}) { + const { t } = useTranslation(["settings", "game-misc"]); + const stageRecord = mapResults + ? MapWinRates.record(mapResults, { mode, stageId }) + : null; + const hasMaps = stageRecord ? MapWinRates.rate(stageRecord) !== null : false; + + return ( + + ); +} + +function WinRate({ + winLoss, + className, +}: { + winLoss: MapWinRates.WinLoss; + className?: string; +}) { + return ( + + + + + ); +} + +function WinRatePercentage({ winLoss }: { winLoss: MapWinRates.WinLoss }) { + const winRate = MapWinRates.rate(winLoss); + + return ( + = 0.5 && + !MapWinRates.isLowSample(winLoss), + [styles.percentageLosing]: + winRate !== null && + winRate < 0.5 && + !MapWinRates.isLowSample(winLoss), + })} + > + {winRate === null ? "—" : `${Math.round(winRate * 100)}%`} + + ); +} + +function WinRateBar({ winLoss }: { winLoss: MapWinRates.WinLoss }) { + const winRate = MapWinRates.rate(winLoss); + const segment = winRate === null ? null : MapWinRates.barSegment(winRate); + + return ( + + {segment ? ( + + ) : null} + + + ); +} diff --git a/app/features/settings/components/MatchProfileTab.tsx b/app/features/settings/components/MatchProfileTab.tsx index f8f2cd2e2..bb82b53fe 100644 --- a/app/features/settings/components/MatchProfileTab.tsx +++ b/app/features/settings/components/MatchProfileTab.tsx @@ -50,6 +50,7 @@ export function MatchProfileTab() { noScreen: Boolean(matchProfile.noScreen), }} revalidateRoot + fullWidth > {({ FormField }) => ( <> @@ -61,6 +62,7 @@ export function MatchProfileTab() { )} diff --git a/app/features/settings/components/ModeMapPoolPicker.module.css b/app/features/settings/components/ModeMapPoolPicker.module.css index 435d0db55..2d0a49845 100644 --- a/app/features/settings/components/ModeMapPoolPicker.module.css +++ b/app/features/settings/components/ModeMapPoolPicker.module.css @@ -79,14 +79,6 @@ cursor: pointer; } -.mapButtonText { - position: absolute; - top: 14px; - text-transform: uppercase; - font-weight: var(--weight-bold); - cursor: not-allowed; -} - .mapButtonLabel { font-size: var(--font-2xs); color: var(--color-text-high); @@ -110,42 +102,3 @@ border-bottom: 2px dotted var(--color-bg-higher); } } - -.modeTabs { - display: flex; - gap: var(--s-1); -} - -.modeTab { - display: grid; - place-items: center; - padding: var(--s-1-5); - border: var(--border-style); - border-radius: var(--radius-full); - background-color: transparent; - - & img { - filter: grayscale(100%); - opacity: 0.5; - transition: - filter 0.2s, - opacity 0.2s; - } - - &:hover img { - opacity: 0.8; - } - - &:focus-visible { - outline: var(--focus-ring); - } -} - -.modeTabActive { - background-color: var(--color-bg-higher); - - & img { - filter: none; - opacity: 1; - } -} diff --git a/app/features/settings/components/ModeMapPoolPicker.tsx b/app/features/settings/components/ModeMapPoolPicker.tsx index 7e88eea13..3ff00756c 100644 --- a/app/features/settings/components/ModeMapPoolPicker.tsx +++ b/app/features/settings/components/ModeMapPoolPicker.tsx @@ -15,22 +15,15 @@ export function ModeMapPoolPicker({ amountToPick, pool, allowedStages, - unavailableLabel, onChange, - modeTabs, - onModeChange, disabled, }: { mode: ModeShort; amountToPick: number; pool: StageId[]; - /** Stages that can be picked. The rest are shown greyed out with `unavailableLabel`, or not at all without one. */ + /** Stages that can be picked, the rest are not shown. */ allowedStages: readonly StageId[]; - unavailableLabel?: string; onChange: (stages: StageId[]) => void; - /** When provided, the divider becomes a tab switcher between these modes. */ - modeTabs?: ModeShort[]; - onModeChange?: (mode: ModeShort) => void; disabled?: boolean; }) { const [wigglingStageId, setWigglingStageId] = React.useState( @@ -73,40 +66,15 @@ export function ModeMapPoolPicker({ })}
- {modeTabs && onModeChange ? ( -
- {modeTabs.map((tabMode) => { - const active = tabMode === mode; - - return ( - - ); - })} -
- ) : ( - - )} +
{stageIds.map((stageId) => { - const unavailable = !allowedStages.includes(stageId); - if (unavailable && !unavailableLabel) return null; + if (!allowedStages.includes(stageId)) return null; const selected = stages.includes(stageId); const onClick = () => { if (disabled) return; - if (unavailable) return; if (selected) return handlePickedStageClick(stageId); handleUnpickedStageClick(stageId); @@ -118,8 +86,6 @@ export function ModeMapPoolPicker({ stageId={stageId} onClick={onClick} selected={selected} - unavailable={unavailable} - unavailableLabel={unavailableLabel} wiggle={wigglingStageId === stageId} onWiggleEnd={() => setWigglingStageId(null)} disabled={disabled} @@ -148,8 +114,6 @@ function MapButton({ stageId, onClick, selected, - unavailable, - unavailableLabel, wiggle, onWiggleEnd, disabled, @@ -158,8 +122,6 @@ function MapButton({ stageId: StageId; onClick: () => void; selected?: boolean; - unavailable?: boolean; - unavailableLabel?: string; wiggle?: boolean; onWiggleEnd?: () => void; disabled?: boolean; @@ -174,12 +136,12 @@ function MapButton({