Show map win rates in match profile map preferences (#3469)

This commit is contained in:
Kalle
2026-10-24 17:23:05 +03:00
committed by GitHub
parent a09c3419b7
commit fafcff16a9
24 changed files with 1365 additions and 286 deletions

View File

@@ -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 (
<div
@@ -27,6 +29,7 @@ export function SendouChipRadioGroup({
data-orientation={orientation}
data-wrap={wrap}
role="radiogroup"
aria-label={ariaLabel}
>
{children}
</div>

View File

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

View File

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

View File

@@ -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<MapResult, "wins" | "losses">;
/** 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,
};
}

View File

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

View File

@@ -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<number>("wins").as("wins"),
fn.sum<number>("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,

View File

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

View File

@@ -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<ModeShort>(
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<ModeShort>(
modesShort[0],
const pickableModes = modesShort.filter(
(mode) => modePreference(mode) !== "AVOID",
);
const activeMode = pickableModes.includes(selectedMode)
? selectedMode
: pickableModes[0];
return (
<div className="stack lg">
<div className="stack items-center">
{modesShort.map((modeShort) => {
const preference = value.modes.find(
(candidate) => candidate.mode === modeShort,
);
return (
<div key={modeShort} className="stack horizontal xs my-1">
<ModeImage mode={modeShort} width={32} />
<PreferenceRadioGroup
preference={preference?.preference}
onPreferenceChange={(newPreference) =>
handleModePreferenceChange({
mode: modeShort,
preference: newPreference,
})
}
aria-label={`Select preference towards ${modeShort}`}
/>
</div>
);
})}
</div>
<div className="stack md">
{mapResults ? (
<p className="text-xs text-lighter">
{t("settings:matchProfile.maps.winRatesExplanation")}
</p>
) : null}
<section className={styles.section}>
<h3 className={styles.heading}>
{t("settings:matchProfile.maps.modes")}
</h3>
{modesShort.map((mode) => (
<ModePreferenceRow
key={mode}
mode={mode}
preference={modePreference(mode) ?? "NEUTRAL"}
onPreferenceChange={(preference) =>
handleModePreferenceChange(mode, preference)
}
mapResults={mapResults}
/>
))}
</section>
{activeMode ? (
<ModeMapPoolPicker
<MapPoolSection
mode={activeMode}
modeTabs={pickableModes}
onModeChange={setSelectedMode}
amountToPick={AMOUNT_OF_MAPS_IN_POOL_PER_MODE}
allowedStages={SENDOUQ_MAP_POOL.parsed[activeMode]}
unavailableLabel={t("common:maps.banned")}
pool={value.pool.find((p) => p.mode === activeMode)?.stages ?? []}
onChange={(stages) => handlePoolChange(activeMode, stages)}
poolOf={poolOf}
onPoolChange={(stages) => handlePoolChange(activeMode, stages)}
mapResults={mapResults}
/>
) : null}
</div>
);
}
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 (
<div
className={clsx(styles.modeRow, {
[styles.modeRowWithoutStats]: !modeRecord,
})}
data-testid={`mode-preference-row-${mode}`}
>
<ModeImage mode={mode} size={32} />
<div className={styles.modeName}>
<span>{t(`game-misc:MODE_LONG_${mode}`)}</span>
{modeRecord ? (
<span className={styles.record}>
{modeRecord.wins}–{modeRecord.losses}
</span>
) : null}
</div>
{modeRecord ? (
<WinRate winLoss={modeRecord} className={styles.modeWinRate} />
) : null}
<SendouChipRadioGroup
aria-label={`Select preference towards ${mode}`}
className={styles.preferenceChips}
>
{PREFERENCE_OPTIONS.map((option) => (
<SendouChipRadio
key={option}
name={`mode-preference-${mode}`}
value={option}
checked={preference === option}
onChange={() => onPreferenceChange(option)}
>
{t(PREFERENCE_LABEL_KEYS[option])}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
</div>
);
}
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<MapWinRates.SortOrder>("DEFAULT");
const [wigglingStageId, setWigglingStageId] = React.useState<StageId | null>(
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 (
<section className={styles.section}>
<div className={styles.poolHeader}>
<h3 className={styles.heading}>{t("common:maps.mapPool")}</h3>
{mapResults ? (
<div className={styles.sort}>
<span className="text-xs text-lighter" aria-hidden>
{t("settings:matchProfile.maps.sort")}
</span>
<SendouChipRadioGroup
aria-label={t("settings:matchProfile.maps.sort")}
>
{SORT_ORDERS.map((order) => (
<SendouChipRadio
key={order}
name="map-pool-sort"
value={order}
checked={sortOrder === order}
onChange={() => setSortOrder(order)}
>
{t(`settings:matchProfile.maps.sortOrder.${order}`)}
</SendouChipRadio>
))}
</SendouChipRadioGroup>
</div>
) : null}
</div>
<div className={styles.modeTabs}>
{modeTabs.map((tabMode) => {
const active = tabMode === mode;
return (
<button
key={tabMode}
type="button"
className={clsx(styles.modeTab, {
[styles.modeTabActive]: active,
})}
onClick={() => onModeChange(tabMode)}
aria-pressed={active}
data-testid={`map-pool-mode-tab-${tabMode}`}
>
<ModeImage mode={tabMode} size={24} />
<span className={styles.modeTabCount}>
{poolOf(tabMode).length}/{AMOUNT_OF_MAPS_IN_POOL_PER_MODE}
</span>
</button>
);
})}
</div>
<div className={styles.slots}>
<div className={styles.slotList}>
{Array.from(
{ length: AMOUNT_OF_MAPS_IN_POOL_PER_MODE },
(_, index) => {
const stageId = pool.at(index);
if (stageId === undefined) {
return (
<span key={index} className={styles.emptySlot}>
{index + 1}
</span>
);
}
return (
<button
key={index}
type="button"
className={styles.filledSlot}
onClick={() => handleStageClick(stageId)}
aria-label={`Remove ${t(`game-misc:STAGE_${stageId}`)}`}
>
<img src={`${stageImageUrl(stageId)}.avif`} alt="" />
</button>
);
},
)}
</div>
<span className="text-xs text-lighter">
{poolIsFull
? t("settings:matchProfile.maps.poolFull")
: t("settings:matchProfile.maps.pickUpTo", {
count: AMOUNT_OF_MAPS_IN_POOL_PER_MODE - pool.length,
})}
</span>
</div>
<div className={styles.mapGrid}>
{sortedStages.map((stageId, index) => (
<MapCard
key={stageId}
mode={mode}
stageId={stageId}
rank={mapResults && sortOrder !== "DEFAULT" ? index + 1 : undefined}
picked={pool.includes(stageId)}
locked={poolIsFull && !pool.includes(stageId)}
wiggle={wigglingStageId === stageId}
onWiggleEnd={() => setWigglingStageId(null)}
onClick={() => handleStageClick(stageId)}
mapResults={mapResults}
/>
))}
</div>
{BANNED_MAPS[mode].length > 0 ? (
<details className={styles.banned}>
<summary>
<ChevronRight className={styles.bannedChevron} />
{t("settings:matchProfile.maps.bannedCount", {
count: BANNED_MAPS[mode].length,
})}
</summary>
<div className={styles.bannedList}>
{BANNED_MAPS[mode]
.toSorted((a, b) => a - b)
.map((stageId) => (
<span key={stageId} className={styles.bannedMap}>
<img src={`${stageImageUrl(stageId)}.avif`} alt="" />
{t(`game-misc:STAGE_${stageId}`)}
</span>
))}
</div>
</details>
) : null}
</section>
);
}
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 (
<button
type="button"
className={clsx(styles.mapCard, {
[styles.mapCardPicked]: picked,
[styles.mapCardLocked]: locked,
[styles.mapCardWiggle]: wiggle,
})}
onClick={onClick}
onAnimationEnd={wiggle ? onWiggleEnd : undefined}
aria-pressed={picked}
data-testid={`map-pool-${mode}-${stageId}`}
>
<span className={styles.mapCardImage}>
<img src={`${stageImageUrl(stageId)}.avif`} alt="" loading="lazy" />
{picked ? (
<span className={styles.mapCardCheck}>
<Check />
</span>
) : null}
{rank && hasMaps ? (
<span className={styles.mapCardRank}>#{rank}</span>
) : null}
</span>
<span className={styles.mapCardBody}>
<span className={styles.mapCardName}>
{t(`game-misc:STAGE_${stageId}`)}
</span>
{stageRecord ? (
<>
<span className={styles.mapCardRecord}>
<WinRatePercentage winLoss={stageRecord} />
<span className={styles.record}>
{hasMaps
? `${stageRecord.wins}–${stageRecord.losses}`
: t("settings:matchProfile.maps.noMaps")}
</span>
</span>
<WinRateBar winLoss={stageRecord} />
</>
) : null}
</span>
</button>
);
}
function WinRate({
winLoss,
className,
}: {
winLoss: MapWinRates.WinLoss;
className?: string;
}) {
return (
<span className={clsx(styles.winRate, className)}>
<WinRatePercentage winLoss={winLoss} />
<WinRateBar winLoss={winLoss} />
</span>
);
}
function WinRatePercentage({ winLoss }: { winLoss: MapWinRates.WinLoss }) {
const winRate = MapWinRates.rate(winLoss);
return (
<span
className={clsx(styles.percentage, {
[styles.percentageWinning]:
winRate !== null &&
winRate >= 0.5 &&
!MapWinRates.isLowSample(winLoss),
[styles.percentageLosing]:
winRate !== null &&
winRate < 0.5 &&
!MapWinRates.isLowSample(winLoss),
})}
>
{winRate === null ? "—" : `${Math.round(winRate * 100)}%`}
</span>
);
}
function WinRateBar({ winLoss }: { winLoss: MapWinRates.WinLoss }) {
const winRate = MapWinRates.rate(winLoss);
const segment = winRate === null ? null : MapWinRates.barSegment(winRate);
return (
<span className={styles.bar} aria-hidden>
{segment ? (
<span
className={clsx(styles.barFill, {
[styles.barFillLosing]: winRate !== null && winRate < 0.5,
})}
style={{
left: `${segment.startPercent}%`,
width: `${segment.widthPercent}%`,
}}
/>
) : null}
<span className={styles.barCenter} />
</span>
);
}

View File

@@ -50,6 +50,7 @@ export function MatchProfileTab() {
noScreen: Boolean(matchProfile.noScreen),
}}
revalidateRoot
fullWidth
>
{({ FormField }) => (
<>
@@ -61,6 +62,7 @@ export function MatchProfileTab() {
<MapModePreferencesField
value={props.value as UserMapModePreferences}
onChange={props.onChange}
mapResults={data.mapResults ?? undefined}
/>
)}
</FormField>

View File

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

View File

@@ -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<StageId | null>(
@@ -73,40 +66,15 @@ export function ModeMapPoolPicker({
})}
</div>
<Divider className={styles.divider}>
{modeTabs && onModeChange ? (
<div className={styles.modeTabs}>
{modeTabs.map((tabMode) => {
const active = tabMode === mode;
return (
<button
key={tabMode}
type="button"
className={clsx(styles.modeTab, {
[styles.modeTabActive]: active,
})}
onClick={() => onModeChange(tabMode)}
aria-pressed={active}
data-testid={`map-pool-mode-tab-${tabMode}`}
>
<ModeImage mode={tabMode} size={24} />
</button>
);
})}
</div>
) : (
<ModeImage mode={mode} size={32} />
)}
<ModeImage mode={mode} size={32} />
</Divider>
<div className="stack sm horizontal flex-wrap justify-center mt-1">
{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({
<button
className={clsx(styles.mapButton, {
[styles.mapButtonWiggle]: wiggle,
[styles.mapButtonGreyedOut]: selected || unavailable,
[styles.mapButtonGreyedOut]: selected,
})}
style={{ "--map-image-url": `url("${stageImageUrl(stageId)}.avif")` }}
onClick={onClick}
onAnimationEnd={wiggle ? onWiggleEnd : undefined}
disabled={disabled || unavailable}
disabled={disabled}
type="button"
aria-label={t(`game-misc:STAGE_${stageId}`)}
data-testid={testId}
@@ -191,11 +153,6 @@ function MapButton({
data-testid={`${testId}-picked`}
/>
) : null}
{unavailable ? (
<div className={clsx(styles.mapButtonText, "text-error")}>
{unavailableLabel}
</div>
) : null}
<div className={styles.mapButtonLabel}>
{shortStageName(t(`game-misc:STAGE_${stageId}`))}
</div>

View File

@@ -1,35 +0,0 @@
.radio {
background-color: var(--color-bg-high);
font-size: var(--font-xs);
text-transform: uppercase;
font-weight: var(--weight-semi);
color: var(--color-text-high);
padding: var(--s-1) var(--s-2);
border-radius: var(--radius-field);
display: flex;
align-items: center;
gap: var(--s-1);
cursor: pointer;
}
.emoji {
filter: grayscale(100%);
transition: all 0.2s;
}
.checked {
color: var(--color-text);
outline: 2px solid var(--color-bg-higher);
& .emoji {
filter: grayscale(0%);
}
}
.focusVisible {
outline: var(--focus-ring);
}
.radio:hover .emoji {
scale: 1.1;
}

View File

@@ -1,84 +0,0 @@
import clsx from "clsx";
import { useTranslation } from "react-i18next";
import { SendouRadio, SendouRadioGroup } from "~/components/elements/Radio";
import type { Preference } from "~/db/tables-json";
import { preferenceEmojiUrl } from "~/utils/urls";
import styles from "./PreferenceRadioGroup.module.css";
export function PreferenceRadioGroup({
preference,
onPreferenceChange,
"aria-label": ariaLabel,
}: {
preference?: Preference;
onPreferenceChange: (preference: Preference & "NEUTRAL") => void;
"aria-label": string;
}) {
const { t } = useTranslation(["settings"]);
return (
<SendouRadioGroup
value={preference ?? "NEUTRAL"}
onChange={(newPreference) =>
onPreferenceChange(newPreference as Preference & "NEUTRAL")
}
className="stack horizontal xs"
aria-label={ariaLabel}
>
<SendouRadio value="AVOID" aria-label="Avoid the mode">
{({ isSelected, isFocusVisible }) => (
<span
className={clsx(styles.radio, {
[styles.checked]: isSelected,
[styles.focusVisible]: isFocusVisible,
})}
>
<img
src={preferenceEmojiUrl("AVOID")}
className={styles.emoji}
width={18}
alt="Avoid emoji"
/>
{t("settings:matchProfile.maps.avoid")}
</span>
)}
</SendouRadio>
<SendouRadio value="NEUTRAL" aria-label="Neutral towards the mode">
{({ isSelected, isFocusVisible }) => (
<span
className={clsx(styles.radio, {
[styles.checked]: isSelected,
[styles.focusVisible]: isFocusVisible,
})}
>
<img
src={preferenceEmojiUrl()}
className={styles.emoji}
width={18}
alt="Neutral emoji"
/>
{t("settings:matchProfile.maps.neutral")}
</span>
)}
</SendouRadio>
<SendouRadio value="PREFER" aria-label="Prefer the mode">
{({ isSelected, isFocusVisible }) => (
<span
className={clsx(styles.radio, {
[styles.checked]: isSelected,
[styles.focusVisible]: isFocusVisible,
})}
>
<img
src={preferenceEmojiUrl("PREFER")}
className={styles.emoji}
width={18}
alt="Prefer emoji"
/>
{t("settings:matchProfile.maps.prefer")}
</span>
)}
</SendouRadio>
</SendouRadioGroup>
);
}

View File

@@ -1,19 +1,32 @@
import type { LoaderFunctionArgs } from "react-router";
import { getUser } from "~/features/auth/core/user.server";
import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server";
import * as Seasons from "~/features/mmr/core/Seasons";
import * as PlayerStatRepository from "~/features/sendouq-match/PlayerStatRepository.server";
import * as TranslationCatalog from "~/features/translate/core/TranslationCatalog.server";
const WIN_RATE_SEASONS_COUNT = 4;
export const loader = async (_args: LoaderFunctionArgs) => {
const user = getUser();
const translationProgress = TranslationCatalog.progressByLanguage();
if (!user) {
return { matchProfile: null, translationProgress };
return { matchProfile: null, mapResults: null, translationProgress };
}
const matchProfile = await MatchProfileRepository.findSettingsByUserId(
user.id,
);
const mapResults =
await PlayerStatRepository.findMapResultsByUserIdAndSeasons({
userId: user.id,
seasons: winRateSeasons(),
});
return { matchProfile, translationProgress };
return { matchProfile, mapResults, translationProgress };
};
function winRateSeasons() {
return Seasons.allStarted().slice(0, WIN_RATE_SEASONS_COUNT);
}

View File

@@ -0,0 +1,9 @@
---
navItem: [settings, t]
type: feature
---
Map & mode preferences redesign
- Your win rate on every mode and map (last 4 seasons) is shown while picking your preferences
- Sort maps by your best or worst win rate
- Picked maps are previewed in the pool slots, and banned maps are tucked away

View File

@@ -58,6 +58,7 @@ export async function loadFactories(parallelIndex: number) {
LFGPostFactory: await import("~/db/seed/factories/LFGPostFactory"),
LiveStreamFactory: await import("~/db/seed/factories/LiveStreamFactory"),
LogInLinkFactory: await import("~/db/seed/factories/LogInLinkFactory"),
MapResultFactory: await import("~/db/seed/factories/MapResultFactory"),
NotificationFactory: await import(
"~/db/seed/factories/NotificationFactory"
),

View File

@@ -1,9 +1,8 @@
import type { Page } from "@playwright/test";
import type { ModeShort } from "~/modules/in-game-lists/types";
export const SELECTED_MAP_CLASS = /mapButtonGreyedOut/;
type PreferenceLabel = "Avoid" | "Neutral" | "Prefer";
type SortOrder = "DEFAULT" | "BEST" | "WORST";
/** The map/mode preferences field shared by the settings match profile tab and the team edit page. */
export class MapModePreferencesField {
@@ -14,18 +13,17 @@ export class MapModePreferencesField {
}
async setModePreference(mode: ModeShort, preference: PreferenceLabel) {
// the selected-state icon overlays the radio and intercepts clicks
await this.preferenceRadio(mode, preference).click({ force: true });
await this.page
.locator(
`label[for="chip-radio-mode-preference-${mode}-${preference.toUpperCase()}"]`,
)
.click();
}
preferenceRadio(mode: ModeShort, preference: PreferenceLabel) {
const name =
preference === "Neutral"
? "Neutral towards the mode"
: `${preference} the mode`;
return this.page
.getByRole("radiogroup", { name: `Select preference towards ${mode}` })
.getByRole("radio", { name });
.getByRole("radio", { name: preference });
}
async selectModeTab(mode: ModeShort) {
@@ -35,4 +33,31 @@ export class MapModePreferencesField {
mapButton(mode: ModeShort, stageId: number) {
return this.page.getByTestId(`map-pool-${mode}-${stageId}`);
}
/** The map cards of a mode in the order they are shown. */
mapCards(mode: ModeShort) {
return this.page.locator(`[data-testid^="map-pool-${mode}-"]`);
}
modeRow(mode: ModeShort) {
return this.page.getByTestId(`mode-preference-row-${mode}`);
}
async sortMaps(order: SortOrder) {
await this.page
.locator(`label[for="chip-radio-map-pool-sort-${order}"]`)
.click();
}
poolSlot(stageName: string) {
return this.page.getByRole("button", { name: `Remove ${stageName}` });
}
bannedMapsToggle() {
return this.page.getByText(/maps? banned in this mode/);
}
bannedMap(stageName: string) {
return this.page.locator("details").getByText(stageName);
}
}

View File

@@ -1,11 +1,11 @@
import { ADMIN_ID } from "~/features/admin/admin-constants";
import * as Seasons from "~/features/mmr/core/Seasons";
import type { BuildAbilitiesTuple } from "~/modules/in-game-lists/types";
import type { Factories } from "./helpers/factories";
import { expect, impersonate, isNotVisible, test } from "./helpers/playwright";
import { WeaponBuildsPage } from "./pages/builds/weapon-builds-page";
import { CalendarPage } from "./pages/calendar/calendar-page";
import { FaqPage } from "./pages/info/faq-page";
import { SELECTED_MAP_CLASS } from "./pages/settings/map-mode-preferences-field";
import { MatchProfilePage } from "./pages/settings/match-profile-page";
import { SettingsPage } from "./pages/settings/settings-page";
import { TournamentBracketsPage } from "./pages/tournament/tournament-brackets-page";
@@ -154,6 +154,59 @@ test.describe("Settings", () => {
});
test.describe("Match profile map preferences", () => {
test("shows win rates, sorts maps by them and edits the pool from the slots", async ({
page,
factories,
}) => {
const season = Seasons.allStarted()[0];
await factories.MapResultFactory.create({
userId: ADMIN_ID,
season,
mode: "TC",
stageId: 1,
wins: 1,
losses: 4,
});
await factories.MapResultFactory.create({
userId: ADMIN_ID,
season,
mode: "TC",
stageId: 2,
wins: 8,
losses: 2,
});
await impersonate(page);
const matchProfile = new MatchProfilePage(page);
await matchProfile.goto();
await expect(matchProfile.maps.modeRow("TC")).toContainText("60%");
await matchProfile.maps.selectModeTab("TC");
await expect(matchProfile.maps.mapCards("TC").first()).toHaveAttribute(
"data-testid",
"map-pool-TC-1",
);
await matchProfile.maps.sortMaps("BEST");
await expect(matchProfile.maps.mapCards("TC").first()).toHaveAttribute(
"data-testid",
"map-pool-TC-2",
);
await expect(matchProfile.maps.mapCards("TC").first()).toContainText("#1");
await matchProfile.maps.mapButton("TC", 2).click();
await matchProfile.maps.poolSlot("Hagglefish Market").click();
await expect(matchProfile.maps.mapButton("TC", 2)).toHaveAttribute(
"aria-pressed",
"false",
);
await expect(matchProfile.maps.bannedMap("Wahoo World")).not.toBeVisible();
await matchProfile.maps.bannedMapsToggle().click();
await expect(matchProfile.maps.bannedMap("Wahoo World")).toBeVisible();
});
test("retains map selection when toggling a mode prefer -> avoid -> prefer", async ({
page,
}) => {
@@ -165,8 +218,9 @@ test.describe("Match profile map preferences", () => {
await matchProfile.maps.setModePreference("SZ", "Prefer");
await matchProfile.maps.selectModeTab("SZ");
await matchProfile.maps.mapButton("SZ", 1).click();
await expect(matchProfile.maps.mapButton("SZ", 1)).toHaveClass(
SELECTED_MAP_CLASS,
await expect(matchProfile.maps.mapButton("SZ", 1)).toHaveAttribute(
"aria-pressed",
"true",
);
// avoiding hides the picker, but the selection should be remembered
@@ -175,8 +229,9 @@ test.describe("Match profile map preferences", () => {
await matchProfile.maps.setModePreference("SZ", "Prefer");
await matchProfile.maps.selectModeTab("SZ");
await expect(matchProfile.maps.mapButton("SZ", 1)).toHaveClass(
SELECTED_MAP_CLASS,
await expect(matchProfile.maps.mapButton("SZ", 1)).toHaveAttribute(
"aria-pressed",
"true",
);
});

View File

@@ -19,7 +19,6 @@ import {
} from "./helpers/playwright";
import { createFormHelpers } from "./helpers/playwright-form";
import { AnythingAdder } from "./pages/layout/anything-adder";
import { SELECTED_MAP_CLASS } from "./pages/settings/map-mode-preferences-field";
import { JoinTeamPage } from "./pages/team/join-team-page";
import { NewTeamPage } from "./pages/team/new-team-page";
import { TeamEditPage } from "./pages/team/team-edit-page";
@@ -105,8 +104,9 @@ test.describe("Team page", () => {
await expect(teamEdit.maps.preferenceRadio("SZ", "Prefer")).toBeChecked();
await expect(teamEdit.maps.preferenceRadio("TW", "Avoid")).toBeChecked();
await teamEdit.maps.selectModeTab("SZ");
await expect(teamEdit.maps.mapButton("SZ", 1)).toHaveClass(
SELECTED_MAP_CLASS,
await expect(teamEdit.maps.mapButton("SZ", 1)).toHaveAttribute(
"aria-pressed",
"true",
);
await teamEdit.removeMapPreferences();
@@ -116,8 +116,9 @@ test.describe("Team page", () => {
await expect(teamEdit.maps.preferenceRadio("SZ", "Neutral")).toBeChecked();
await expect(teamEdit.maps.preferenceRadio("TW", "Neutral")).toBeChecked();
await teamEdit.maps.selectModeTab("SZ");
await expect(teamEdit.maps.mapButton("SZ", 1)).not.toHaveClass(
SELECTED_MAP_CLASS,
await expect(teamEdit.maps.mapButton("SZ", 1)).toHaveAttribute(
"aria-pressed",
"false",
);
await teamEdit.goto(customUrl);

View File

@@ -238,7 +238,6 @@
"maps.validation.STAGE_REPEAT_IN_SAME_MODE": "Can't pick the same map twice in the same mode",
"maps.validation.TOO_MUCH_STAGE_REPEAT": "Can't pick stage more than {{maxStageRepeat}} times",
"maps.validation.NOT_IN_POOL": "Can't pick stages outside of the map pool",
"maps.banned": "Banned",
"maps.validation.VALID": "Map pool ok!",
"results": "Results",
"forms.name": "Name",

View File

@@ -13,6 +13,18 @@
"matchProfile.maps.avoid": "Avoid",
"matchProfile.maps.prefer": "Prefer",
"matchProfile.maps.neutral": "Neutral",
"matchProfile.maps.modes": "Modes",
"matchProfile.maps.winRatesExplanation": "Win rates are from your ranked maps (SendouQ and ranked tournaments) in the last 4 seasons.",
"matchProfile.maps.sort": "Sort",
"matchProfile.maps.sortOrder.DEFAULT": "Default",
"matchProfile.maps.sortOrder.BEST": "Best first",
"matchProfile.maps.sortOrder.WORST": "Worst first",
"matchProfile.maps.pickUpTo_one": "Pick up to {{count}} more map",
"matchProfile.maps.pickUpTo_other": "Pick up to {{count}} more maps",
"matchProfile.maps.poolFull": "Pool full. Remove a map to swap it out.",
"matchProfile.maps.bannedCount_one": "{{count}} map banned in this mode",
"matchProfile.maps.bannedCount_other": "{{count}} maps banned in this mode",
"matchProfile.maps.noMaps": "No maps",
"sounds.likeReceived": "Group invitation received",
"sounds.readyCheckStarted": "SendouQ ready check started",
"sounds.matchStarted": "SendouQ match started",

View File

@@ -225,7 +225,6 @@
"maps.validation.STAGE_REPEAT_IN_SAME_MODE": "No se puede elegir el mismo escenario dos veces en el mismo modo",
"maps.validation.TOO_MUCH_STAGE_REPEAT": "No se puede elegir un escenario más de {{maxStageRepeat}} veces",
"maps.validation.NOT_IN_POOL": "No se pueden elegir escenarios fuera del grupo de mapas",
"maps.banned": "Baneado",
"maps.validation.VALID": "¡Grupo de mapas correcto!",
"results": "Resultados",
"forms.name": "Nombre",

View File

@@ -176,7 +176,6 @@
"maps.tournamentMaplist": "Créer une liste de stages de tournoi (maps.iplabs.ink)",
"maps.validation.STAGE_REPEAT_IN_SAME_MODE": "Impossible de choisir le même stage deux fois dans le même mode",
"maps.validation.TOO_MUCH_STAGE_REPEAT": "Impossible de choisir le même stage plus de {{maxStageRepeat}} fois",
"maps.banned": "Bannie",
"maps.validation.VALID": "Liste de stages valide !",
"results": "Résultats",
"forms.name": "Nom",

View File

@@ -888,6 +888,12 @@ export function buildCases(fx: Fixtures): {
add("PlayerStatRepository.findSeasonStagesByUserId", fx.sq, (sq) =>
PlayerStatRepository.findSeasonStagesByUserId(sq),
);
add("PlayerStatRepository.findMapResultsByUserIdAndSeasons", fx.sq, (sq) =>
PlayerStatRepository.findMapResultsByUserIdAndSeasons({
userId: sq.userId,
seasons: [sq.season, sq.season - 1, sq.season - 2, sq.season - 3],
}),
);
add("PlayerStatRepository.findSeasonMatesEnemiesByUserId", fx.sq, (sq) =>
PlayerStatRepository.findSeasonMatesEnemiesByUserId({
...sq,