mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-29 05:57:34 -05:00
Show map win rates in match profile map preferences (#3469)
This commit is contained in:
@@ -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>
|
||||
|
||||
23
app/db/seed/factories/MapResultFactory.ts
Normal file
23
app/db/seed/factories/MapResultFactory.ts
Normal 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;
|
||||
},
|
||||
});
|
||||
99
app/features/match-profile/core/MapWinRates.test.ts
Normal file
99
app/features/match-profile/core/MapWinRates.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
113
app/features/match-profile/core/MapWinRates.ts
Normal file
113
app/features/match-profile/core/MapWinRates.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
9
changelog/2026-10-24-map-preferences-win-rates.md
Normal file
9
changelog/2026-10-24-map-preferences-win-rates.md
Normal 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
|
||||
@@ -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"
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user