mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
SendouQ map list generation via MapList.generate (#2617)
This commit is contained in:
parent
84b4c1d67f
commit
9bd8d84f20
|
|
@ -57,14 +57,14 @@ import {
|
|||
shoesGearIds,
|
||||
} from "~/modules/in-game-lists/gear-ids";
|
||||
import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import { stagesObj as s, stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import type {
|
||||
AbilityType,
|
||||
MainWeaponId,
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
|
||||
import { nullFilledArray } from "~/utils/arrays";
|
||||
import {
|
||||
|
|
@ -91,6 +91,57 @@ import {
|
|||
} from "./constants";
|
||||
import placements from "./placements.json";
|
||||
|
||||
const SENDOUQ_DEFAULT_MAPS: Record<
|
||||
ModeShort,
|
||||
[StageId, StageId, StageId, StageId, StageId, StageId, StageId]
|
||||
> = {
|
||||
TW: [
|
||||
s.EELTAIL_ALLEY,
|
||||
s.HAGGLEFISH_MARKET,
|
||||
s.UNDERTOW_SPILLWAY,
|
||||
s.WAHOO_WORLD,
|
||||
s.UM_AMI_RUINS,
|
||||
s.HUMPBACK_PUMP_TRACK,
|
||||
s.ROBO_ROM_EN,
|
||||
],
|
||||
SZ: [
|
||||
s.HAGGLEFISH_MARKET,
|
||||
s.MAHI_MAHI_RESORT,
|
||||
s.INKBLOT_ART_ACADEMY,
|
||||
s.MAKOMART,
|
||||
s.HUMPBACK_PUMP_TRACK,
|
||||
s.CRABLEG_CAPITAL,
|
||||
s.ROBO_ROM_EN,
|
||||
],
|
||||
TC: [
|
||||
s.ROBO_ROM_EN,
|
||||
s.EELTAIL_ALLEY,
|
||||
s.UNDERTOW_SPILLWAY,
|
||||
s.MUSEUM_D_ALFONSINO,
|
||||
s.MAKOMART,
|
||||
s.MANTA_MARIA,
|
||||
s.SHIPSHAPE_CARGO_CO,
|
||||
],
|
||||
RM: [
|
||||
s.SCORCH_GORGE,
|
||||
s.HAGGLEFISH_MARKET,
|
||||
s.UNDERTOW_SPILLWAY,
|
||||
s.MUSEUM_D_ALFONSINO,
|
||||
s.FLOUNDER_HEIGHTS,
|
||||
s.CRABLEG_CAPITAL,
|
||||
s.MINCEMEAT_METALWORKS,
|
||||
],
|
||||
CB: [
|
||||
s.SCORCH_GORGE,
|
||||
s.INKBLOT_ART_ACADEMY,
|
||||
s.BRINEWATER_SPRINGS,
|
||||
s.MANTA_MARIA,
|
||||
s.HUMPBACK_PUMP_TRACK,
|
||||
s.UM_AMI_RUINS,
|
||||
s.ROBO_ROM_EN,
|
||||
],
|
||||
};
|
||||
|
||||
const calendarEventWithToToolsRegOpen = () =>
|
||||
calendarEventWithToTools("PICNIC", true);
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ describe("MapList.generate()", () => {
|
|||
const maps = gen.next({ amount: 3 }).value;
|
||||
|
||||
expect(maps).toHaveLength(
|
||||
new Set(maps.map((m) => `${m.mode}-${m.stageId}`)).size,
|
||||
new Set(maps.map((m) => MapList.modeStageKey(m.mode, m.stageId))).size,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -522,3 +522,85 @@ describe("MapList.parsePattern()", () => {
|
|||
).toBeInstanceOf(Err);
|
||||
});
|
||||
});
|
||||
|
||||
describe("MapList.generate() with initialWeights", () => {
|
||||
it("accepts initialWeights parameter without errors", () => {
|
||||
const mapPool = new MapPool({
|
||||
SZ: [1, 2, 3],
|
||||
TC: [4, 5],
|
||||
RM: [],
|
||||
CB: [],
|
||||
TW: [],
|
||||
});
|
||||
|
||||
const initialWeights = new Map<string, number>();
|
||||
initialWeights.set("SZ-1", 100);
|
||||
initialWeights.set("TC-44", -10);
|
||||
|
||||
const gen = MapList.generate({ mapPool, initialWeights });
|
||||
gen.next();
|
||||
|
||||
const maps = gen.next({ amount: 3 }).value;
|
||||
|
||||
expect(maps).toHaveLength(3);
|
||||
expect(maps.every((m) => mapPool.has(m))).toBe(true);
|
||||
});
|
||||
|
||||
it("handles empty initialWeights", () => {
|
||||
const mapPool = new MapPool({
|
||||
SZ: [1, 2],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
TW: [],
|
||||
});
|
||||
|
||||
const gen = MapList.generate({ mapPool, initialWeights: new Map() });
|
||||
gen.next();
|
||||
|
||||
const maps = gen.next({ amount: 2 }).value;
|
||||
|
||||
expect(maps).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("handles undefined initialWeights", () => {
|
||||
const mapPool = new MapPool({
|
||||
SZ: [1, 2],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
TW: [],
|
||||
});
|
||||
|
||||
const gen = MapList.generate({ mapPool });
|
||||
gen.next();
|
||||
|
||||
const maps = gen.next({ amount: 2 }).value;
|
||||
|
||||
expect(maps).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("initialWeights affect stage selection", () => {
|
||||
const mapPool = new MapPool({
|
||||
SZ: [1, 2, 3, 4, 5],
|
||||
TC: [],
|
||||
RM: [],
|
||||
CB: [],
|
||||
TW: [],
|
||||
});
|
||||
|
||||
const initialWeights = new Map<string, number>();
|
||||
initialWeights.set("SZ-1", 1);
|
||||
initialWeights.set("SZ-2", -1);
|
||||
initialWeights.set("SZ-3", -1);
|
||||
initialWeights.set("SZ-4", -1);
|
||||
initialWeights.set("SZ-5", -1);
|
||||
|
||||
const gen = MapList.generate({ mapPool, initialWeights });
|
||||
gen.next();
|
||||
|
||||
const maps = gen.next({ amount: 3 }).value;
|
||||
|
||||
expect(maps[0].stageId).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -25,6 +25,15 @@ interface MaplistPattern {
|
|||
pattern: Array<"ANY" | ModeShort>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a unique key for a stage-mode combination.
|
||||
* @example
|
||||
* stageModeKey("SZ", 1); // "1-SZ"
|
||||
*/
|
||||
export function modeStageKey(mode: ModeShort, stageId: StageId): string {
|
||||
return `${mode}-${stageId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates map lists that avoid repeating stages and optionally allows providing mode pattern.
|
||||
*
|
||||
|
|
@ -39,6 +48,8 @@ export function* generate(args: {
|
|||
mapPool: MapPool;
|
||||
/** Should the function bias in favor of maps not played? E.g. maps 4 & 5 in a Bo5 format (not every team plays them). Should be true if generating for tournament with best of format. */
|
||||
considerGuaranteed?: boolean;
|
||||
/** Initial weights for specific stage-mode combinations. Key format: `${mode}-${stageId}` (generate via `MapList.stageModeKey`). Negative weights can be used to deprioritize certain maps. */
|
||||
initialWeights?: Map<string, number>;
|
||||
}): Generator<Array<ModeWithStage>, Array<ModeWithStage>, GenerateNext> {
|
||||
if (args.mapPool.isEmpty()) {
|
||||
while (true) yield [];
|
||||
|
|
@ -49,6 +60,7 @@ export function* generate(args: {
|
|||
const { stageWeights, stageModeWeights } = initializeWeights(
|
||||
modes,
|
||||
args.mapPool.parsed,
|
||||
args.initialWeights,
|
||||
);
|
||||
const orderedModes = modeOrders(modes);
|
||||
let currentOrderIndex = 0;
|
||||
|
|
@ -107,7 +119,7 @@ export function* generate(args: {
|
|||
const stageModeWeightPenalty = args.mapPool.modes.length > 1 ? -10 : 0;
|
||||
|
||||
stageWeights.set(stageId, stageWeightPenalty);
|
||||
stageModeWeights.set(`${stageId}-${mode}`, stageModeWeightPenalty);
|
||||
stageModeWeights.set(modeStageKey(mode, stageId), stageModeWeightPenalty);
|
||||
}
|
||||
|
||||
currentOrderIndex++;
|
||||
|
|
@ -119,7 +131,11 @@ export function* generate(args: {
|
|||
}
|
||||
}
|
||||
|
||||
function initializeWeights(modes: ModeShort[], mapPool: ReadonlyMapPoolObject) {
|
||||
function initializeWeights(
|
||||
modes: ModeShort[],
|
||||
mapPool: ReadonlyMapPoolObject,
|
||||
initialWeights?: Map<string, number>,
|
||||
) {
|
||||
const stageWeights = new Map<StageId, number>();
|
||||
const stageModeWeights = new Map<string, number>();
|
||||
|
||||
|
|
@ -127,7 +143,9 @@ function initializeWeights(modes: ModeShort[], mapPool: ReadonlyMapPoolObject) {
|
|||
const stageIds = mapPool[mode];
|
||||
for (const stageId of stageIds) {
|
||||
stageWeights.set(stageId, 0);
|
||||
stageModeWeights.set(`${stageId}-${mode}`, 0);
|
||||
const key = modeStageKey(mode, stageId);
|
||||
const initialWeight = initialWeights?.get(key) ?? 0;
|
||||
stageModeWeights.set(key, initialWeight);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -172,7 +190,8 @@ function selectStageWeighted({
|
|||
const getCandidates = () =>
|
||||
possibleStages.filter((stageId) => {
|
||||
const stageWeight = stageWeights.get(stageId) ?? 0;
|
||||
const stageModeWeight = stageModeWeights.get(`${stageId}-${mode}`) ?? 0;
|
||||
const stageModeWeight =
|
||||
stageModeWeights.get(modeStageKey(mode, stageId)) ?? 0;
|
||||
return stageWeight >= 0 && stageModeWeight >= 0;
|
||||
});
|
||||
|
||||
|
|
@ -190,7 +209,8 @@ function selectStageWeighted({
|
|||
|
||||
return weightedRandomSelect(candidates, (stageId) => {
|
||||
const stageWeight = stageWeights.get(stageId) ?? 0;
|
||||
const stageModeWeight = stageModeWeights.get(`${stageId}-${mode}`) ?? 0;
|
||||
const stageModeWeight =
|
||||
stageModeWeights.get(modeStageKey(mode, stageId)) ?? 0;
|
||||
return stageWeight + stageModeWeight + 1;
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { describe, expect, test } from "vitest";
|
||||
import type { UserMapModePreferences } from "~/db/tables";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import type { StageId } from "~/modules/in-game-lists/types";
|
||||
import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants";
|
||||
import { nullFilledArray } from "~/utils/arrays";
|
||||
import * as Test from "~/utils/Test";
|
||||
import { mapLottery, mapModePreferencesToModeList } from "./match.server";
|
||||
import {
|
||||
mapModePreferencesToModeList,
|
||||
normalizeAndCombineWeights,
|
||||
} from "./match.server";
|
||||
|
||||
describe("mapModePreferencesToModeList()", () => {
|
||||
test("returns default list if no preferences", () => {
|
||||
|
|
@ -126,74 +124,97 @@ describe("mapModePreferencesToModeList()", () => {
|
|||
});
|
||||
});
|
||||
|
||||
const MODES_COUNT = 4;
|
||||
const STAGES_PER_MODE = 7;
|
||||
describe("normalizeAndCombineWeights()", () => {
|
||||
test("normalizes and combines weights when both teams have weights", () => {
|
||||
const teamOneWeights = new Map([
|
||||
["map1-SZ", 100],
|
||||
["map2-TC", 50],
|
||||
]);
|
||||
const teamTwoWeights = new Map([
|
||||
["map1-SZ", 200],
|
||||
["map2-TC", 100],
|
||||
]);
|
||||
|
||||
describe("mapLottery()", () => {
|
||||
test("returns maps even if no preferences", () => {
|
||||
const mapPool = mapLottery([], rankedModesShort);
|
||||
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
|
||||
|
||||
expect(mapPool.stageModePairs.length).toBe(STAGES_PER_MODE * MODES_COUNT);
|
||||
expect(result.get("map1-SZ")).toBe(400);
|
||||
expect(result.get("map2-TC")).toBe(200);
|
||||
});
|
||||
|
||||
test("returns some maps from the map pools", () => {
|
||||
const memberOnePool: UserMapModePreferences["pool"] = rankedModesShort.map(
|
||||
(mode) => ({
|
||||
mode,
|
||||
stages: nullFilledArray(7).map((_, i) => (i + 1) as StageId),
|
||||
}),
|
||||
);
|
||||
const memberTwoPool: UserMapModePreferences["pool"] = rankedModesShort.map(
|
||||
(mode) => ({
|
||||
mode,
|
||||
stages: nullFilledArray(7).map((_, i) => (i + 10) as StageId),
|
||||
}),
|
||||
);
|
||||
test("normalizes correctly when team totals differ", () => {
|
||||
const teamOneWeights = new Map([["map1-SZ", 100]]);
|
||||
const teamTwoWeights = new Map([["map1-SZ", 50]]);
|
||||
|
||||
const pool = mapLottery(
|
||||
[
|
||||
{ modes: [], pool: memberOnePool },
|
||||
{ modes: [], pool: memberTwoPool },
|
||||
],
|
||||
rankedModesShort,
|
||||
);
|
||||
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
|
||||
|
||||
expect(pool.stageModePairs.some((p) => p.stageId <= 7)).toBe(true);
|
||||
expect(pool.stageModePairs.some((p) => p.stageId > 10)).toBe(true);
|
||||
expect(result.get("map1-SZ")).toBe(100);
|
||||
});
|
||||
|
||||
test("includes modes that were given and nothing else", () => {
|
||||
const memberOnePool: UserMapModePreferences["pool"] = rankedModesShort.map(
|
||||
(mode) => ({
|
||||
mode,
|
||||
stages: nullFilledArray(7).map((_, i) => (i + 1) as StageId),
|
||||
}),
|
||||
);
|
||||
test("includes all keys from both teams", () => {
|
||||
const teamOneWeights = new Map([
|
||||
["map1-SZ", 100],
|
||||
["map2-TC", 50],
|
||||
]);
|
||||
const teamTwoWeights = new Map([
|
||||
["map1-SZ", 100],
|
||||
["map3-RM", 50],
|
||||
]);
|
||||
|
||||
const pool = mapLottery([{ modes: [], pool: memberOnePool }], ["SZ", "TC"]);
|
||||
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
|
||||
|
||||
expect(
|
||||
pool.stageModePairs.every((p) => p.mode === "SZ" || p.mode === "TC"),
|
||||
).toBe(true);
|
||||
expect(result.has("map1-SZ")).toBe(true);
|
||||
expect(result.has("map2-TC")).toBe(true);
|
||||
expect(result.has("map3-RM")).toBe(true);
|
||||
expect(result.size).toBe(3);
|
||||
});
|
||||
|
||||
test("excludes map preferences if mode is avoided", () => {
|
||||
const memberOnePool: UserMapModePreferences["pool"] = [
|
||||
{
|
||||
mode: "SZ",
|
||||
stages: nullFilledArray(7).map((_, i) => (i + 1) as StageId),
|
||||
},
|
||||
];
|
||||
test("handles team one having zero weights", () => {
|
||||
const teamOneWeights = new Map<string, number>();
|
||||
const teamTwoWeights = new Map([["map1-SZ", 100]]);
|
||||
|
||||
const pool = mapLottery(
|
||||
[{ modes: [{ preference: "AVOID", mode: "SZ" }], pool: memberOnePool }],
|
||||
["SZ"],
|
||||
);
|
||||
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
|
||||
|
||||
expect(
|
||||
pool.stageModePairs.every((p) =>
|
||||
SENDOUQ_DEFAULT_MAPS.SZ.some((stageId) => stageId === p.stageId),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(result.get("map1-SZ")).toBe(100);
|
||||
});
|
||||
|
||||
test("handles team two having zero weights", () => {
|
||||
const teamOneWeights = new Map([["map1-SZ", 100]]);
|
||||
const teamTwoWeights = new Map<string, number>();
|
||||
|
||||
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
|
||||
|
||||
expect(result.get("map1-SZ")).toBe(100);
|
||||
});
|
||||
|
||||
test("handles both teams having zero weights", () => {
|
||||
const teamOneWeights = new Map<string, number>();
|
||||
const teamTwoWeights = new Map<string, number>();
|
||||
|
||||
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
test("handles keys present in one team but not the other", () => {
|
||||
const teamOneWeights = new Map([["map1-SZ", 100]]);
|
||||
const teamTwoWeights = new Map([["map2-TC", 200]]);
|
||||
|
||||
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
|
||||
|
||||
expect(result.get("map1-SZ")).toBe(200);
|
||||
expect(result.get("map2-TC")).toBe(200);
|
||||
});
|
||||
|
||||
test("normalizes team one weight proportionally to team two total", () => {
|
||||
const teamOneWeights = new Map([["map1-SZ", 40]]);
|
||||
const teamTwoWeights = new Map([
|
||||
["map1-SZ", 60],
|
||||
["map2-TC", 40],
|
||||
]);
|
||||
|
||||
const result = normalizeAndCombineWeights(teamOneWeights, teamTwoWeights);
|
||||
|
||||
expect(result.get("map1-SZ")).toBe(160);
|
||||
expect(result.get("map2-TC")).toBe(40);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,26 +1,161 @@
|
|||
import * as R from "remeda";
|
||||
import type { ParsedMemento, UserMapModePreferences } from "~/db/tables";
|
||||
import {
|
||||
type DbMapPoolList,
|
||||
MapPool,
|
||||
} from "~/features/map-list-generator/core/map-pool";
|
||||
import * as MapList from "~/features/map-list-generator/core/MapList";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { userSkills } from "~/features/mmr/tiered.server";
|
||||
import { getDefaultMapWeights } from "~/features/sendouq/core/default-maps.server";
|
||||
import { addSkillsToGroups } from "~/features/sendouq/core/groups.server";
|
||||
import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants";
|
||||
import type { LookingGroupWithInviteCode } from "~/features/sendouq/q-types";
|
||||
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
|
||||
import {
|
||||
BANNED_MAPS,
|
||||
SENDOUQ_MAP_POOL,
|
||||
} from "~/features/sendouq-settings/banned-maps";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { generateBalancedMapList } from "~/modules/tournament-map-list-generator/balanced-map-list";
|
||||
import { SENDOUQ_DEFAULT_MAPS } from "~/modules/tournament-map-list-generator/constants";
|
||||
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { ModeShort, ModeWithStage } from "~/modules/in-game-lists/types";
|
||||
import type {
|
||||
TournamentMapListMap,
|
||||
TournamentMaplistSource,
|
||||
} from "~/modules/tournament-map-list-generator/types";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { averageArray } from "~/utils/number";
|
||||
import type { MatchById } from "../queries/findMatchById.server";
|
||||
|
||||
export function matchMapList(
|
||||
type WeightsMap = Map<string, number>;
|
||||
|
||||
async function calculateMapWeights(
|
||||
groupOnePreferences: UserMapModePreferences[],
|
||||
groupTwoPreferences: UserMapModePreferences[],
|
||||
modesIncluded: ModeShort[],
|
||||
): Promise<WeightsMap> {
|
||||
const teamOneVotes: WeightsMap = new Map();
|
||||
const teamTwoVotes: WeightsMap = new Map();
|
||||
|
||||
countVotesForTeam(modesIncluded, groupOnePreferences, teamOneVotes);
|
||||
countVotesForTeam(modesIncluded, groupTwoPreferences, teamTwoVotes);
|
||||
|
||||
const applyWeightFormula = (voteCount: number) =>
|
||||
// 1, 4 or 9 (cap)
|
||||
Math.min(voteCount * voteCount, 9);
|
||||
|
||||
const teamOneWeights: WeightsMap = new Map();
|
||||
const teamTwoWeights: WeightsMap = new Map();
|
||||
|
||||
for (const [key, votes] of teamOneVotes) {
|
||||
teamOneWeights.set(key, applyWeightFormula(votes));
|
||||
}
|
||||
for (const [key, votes] of teamTwoVotes) {
|
||||
teamTwoWeights.set(key, applyWeightFormula(votes));
|
||||
}
|
||||
|
||||
const combinedWeights = normalizeAndCombineWeights(
|
||||
teamOneWeights,
|
||||
teamTwoWeights,
|
||||
);
|
||||
|
||||
return applyDefaultWeights(combinedWeights);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes and combines map weights from two teams.
|
||||
*
|
||||
* When both teams have weights, team one's weights are normalized to match
|
||||
* team two's total before combining. This ensures fair weighting when teams
|
||||
* have different numbers of preferences.
|
||||
*
|
||||
* @returns Combined weights map with all keys from both teams
|
||||
*/
|
||||
export function normalizeAndCombineWeights(
|
||||
teamOneWeights: Map<string, number>,
|
||||
teamTwoWeights: Map<string, number>,
|
||||
): Map<string, number> {
|
||||
const teamOneTotal = Array.from(teamOneWeights.values()).reduce(
|
||||
(sum, w) => sum + w,
|
||||
0,
|
||||
);
|
||||
const teamTwoTotal = Array.from(teamTwoWeights.values()).reduce(
|
||||
(sum, w) => sum + w,
|
||||
0,
|
||||
);
|
||||
|
||||
const combinedWeights = new Map<string, number>();
|
||||
const allKeys = new Set([...teamOneWeights.keys(), ...teamTwoWeights.keys()]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
const teamOneWeight = teamOneWeights.get(key) ?? 0;
|
||||
const teamTwoWeight = teamTwoWeights.get(key) ?? 0;
|
||||
|
||||
if (teamOneTotal > 0 && teamTwoTotal > 0) {
|
||||
const normalizedTeamOne = (teamOneWeight / teamOneTotal) * teamTwoTotal;
|
||||
combinedWeights.set(key, normalizedTeamOne + teamTwoWeight);
|
||||
} else {
|
||||
combinedWeights.set(key, teamOneWeight + teamTwoWeight);
|
||||
}
|
||||
}
|
||||
|
||||
return combinedWeights;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies default map weights to combined weights for any maps not already weighted.
|
||||
*
|
||||
* Fetches global default weights and adds them to the combined weights map for any
|
||||
* map-mode combinations that don't already have weights. This ensures the pool always
|
||||
* has a baseline selection of maps.
|
||||
*
|
||||
* @returns Combined weights with defaults applied
|
||||
*/
|
||||
async function applyDefaultWeights(
|
||||
combinedWeights: WeightsMap,
|
||||
): Promise<WeightsMap> {
|
||||
let defaultWeights: WeightsMap;
|
||||
try {
|
||||
defaultWeights = await getDefaultMapWeights();
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[calculateMapWeights] Failed to get default map weights: ${err}`,
|
||||
);
|
||||
defaultWeights = new Map();
|
||||
}
|
||||
|
||||
for (const [key, weight] of defaultWeights) {
|
||||
if (!combinedWeights.has(key)) {
|
||||
combinedWeights.set(key, weight);
|
||||
}
|
||||
}
|
||||
|
||||
return combinedWeights;
|
||||
}
|
||||
|
||||
function countVotesForTeam(
|
||||
modesIncluded: ModeShort[],
|
||||
preferences: UserMapModePreferences[],
|
||||
votesMap: WeightsMap,
|
||||
) {
|
||||
for (const preference of preferences) {
|
||||
for (const poolEntry of preference.pool) {
|
||||
if (!modesIncluded.includes(poolEntry.mode)) continue;
|
||||
|
||||
const avoidedMode = preference.modes.find(
|
||||
(m) => m.mode === poolEntry.mode && m.preference === "AVOID",
|
||||
);
|
||||
if (avoidedMode) continue;
|
||||
|
||||
for (const stageId of poolEntry.stages) {
|
||||
if (BANNED_MAPS[poolEntry.mode].includes(stageId)) continue;
|
||||
|
||||
votesMap.set(
|
||||
MapList.modeStageKey(poolEntry.mode, stageId),
|
||||
(votesMap.get(MapList.modeStageKey(poolEntry.mode, stageId)) ?? 0) +
|
||||
1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function matchMapList(
|
||||
groupOne: {
|
||||
preferences: { userId: number; preferences: UserMapModePreferences }[];
|
||||
id: number;
|
||||
|
|
@ -31,7 +166,7 @@ export function matchMapList(
|
|||
id: number;
|
||||
ignoreModePreferences?: boolean;
|
||||
},
|
||||
) {
|
||||
): Promise<TournamentMapListMap[]> {
|
||||
const modesIncluded = mapModePreferencesToModeList(
|
||||
groupOne.ignoreModePreferences
|
||||
? []
|
||||
|
|
@ -41,107 +176,64 @@ export function matchMapList(
|
|||
: groupTwo.preferences.map(({ preferences }) => preferences.modes),
|
||||
);
|
||||
|
||||
try {
|
||||
return generateBalancedMapList({
|
||||
count: SENDOUQ_BEST_OF,
|
||||
seed: String(groupOne.id),
|
||||
modesIncluded,
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
followModeOrder: true,
|
||||
teams: [
|
||||
{
|
||||
id: groupOne.id,
|
||||
maps: mapLottery(
|
||||
groupOne.preferences.map((p) => p.preferences),
|
||||
modesIncluded,
|
||||
),
|
||||
},
|
||||
{
|
||||
id: groupTwo.id,
|
||||
maps: mapLottery(
|
||||
groupTwo.preferences.map((p) => p.preferences),
|
||||
modesIncluded,
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
// in rare cases, the map list generator can fail
|
||||
// in that case, just return a map list from our default set of maps
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
return generateBalancedMapList({
|
||||
count: SENDOUQ_BEST_OF,
|
||||
seed: String(groupOne.id),
|
||||
modesIncluded,
|
||||
tiebreakerMaps: new MapPool([]),
|
||||
teams: [
|
||||
{
|
||||
id: groupOne.id,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
{
|
||||
id: groupTwo.id,
|
||||
maps: new MapPool([]),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
const weights = await calculateMapWeights(
|
||||
groupOne.preferences.map((p) => p.preferences),
|
||||
groupTwo.preferences.map((p) => p.preferences),
|
||||
modesIncluded,
|
||||
);
|
||||
|
||||
const MAPS_PER_MODE = 7;
|
||||
logger.info(
|
||||
`[matchMapList] Generated map weights: ${JSON.stringify(
|
||||
Array.from(weights.entries()),
|
||||
)}`,
|
||||
);
|
||||
|
||||
export function mapLottery(
|
||||
preferences: UserMapModePreferences[],
|
||||
modes: ModeShort[],
|
||||
) {
|
||||
invariant(modes.length > 0, "mapLottery: no modes");
|
||||
const generator = MapList.generate({
|
||||
mapPool: new MapPool(
|
||||
SENDOUQ_MAP_POOL.stageModePairs.filter((pair) =>
|
||||
modesIncluded.includes(pair.mode),
|
||||
),
|
||||
),
|
||||
initialWeights: weights,
|
||||
});
|
||||
generator.next();
|
||||
|
||||
const mapPoolList: DbMapPoolList = [];
|
||||
const maps = generator.next({ amount: SENDOUQ_BEST_OF }).value;
|
||||
|
||||
for (const mode of modes) {
|
||||
const stageIdsFromPools = R.shuffle(
|
||||
preferences.flatMap((preference) => {
|
||||
// if they disliked the mode don't include their maps
|
||||
// they are just saved in the DB so they can be restored later
|
||||
if (
|
||||
preference.modes.find((mp) => mp.mode === mode)?.preference ===
|
||||
"AVOID"
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return preference.pool.find((pool) => pool.mode === mode)?.stages ?? [];
|
||||
}),
|
||||
const resolveSource = (map: ModeWithStage): TournamentMaplistSource => {
|
||||
const groupOnePrefers = groupOne.preferences.some((p) =>
|
||||
p.preferences.pool.some(
|
||||
(pool) => pool.mode === map.mode && pool.stages.includes(map.stageId),
|
||||
),
|
||||
);
|
||||
const groupTwoPrefers = groupTwo.preferences.some((p) =>
|
||||
p.preferences.pool.some(
|
||||
(pool) => pool.mode === map.mode && pool.stages.includes(map.stageId),
|
||||
),
|
||||
);
|
||||
|
||||
const modeStageIdsForMatch: StageId[] = [];
|
||||
for (const stageId of stageIdsFromPools) {
|
||||
if (modeStageIdsForMatch.length === MAPS_PER_MODE) break;
|
||||
if (
|
||||
modeStageIdsForMatch.includes(stageId) ||
|
||||
BANNED_MAPS[mode].includes(stageId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
modeStageIdsForMatch.push(stageId);
|
||||
if (groupOnePrefers && groupTwoPrefers) {
|
||||
return "BOTH";
|
||||
}
|
||||
if (groupOnePrefers) {
|
||||
return groupOne.id;
|
||||
}
|
||||
if (groupTwoPrefers) {
|
||||
return groupTwo.id;
|
||||
}
|
||||
|
||||
if (modeStageIdsForMatch.length === MAPS_PER_MODE) {
|
||||
for (const stageId of modeStageIdsForMatch) {
|
||||
mapPoolList.push({ mode, stageId });
|
||||
}
|
||||
// this should only happen if they made no map picks at all yet
|
||||
// as when everyone avoids a mode it can't appear
|
||||
// and if they select mode as neutral/prefer you need to pick 7 maps
|
||||
} else {
|
||||
mapPoolList.push(
|
||||
...SENDOUQ_DEFAULT_MAPS[mode].map((stageId) => ({ mode, stageId })),
|
||||
);
|
||||
}
|
||||
return "DEFAULT";
|
||||
};
|
||||
|
||||
const result = maps.map((map) => ({ ...map, source: resolveSource(map) }));
|
||||
|
||||
if (result.some((m) => m.source === "DEFAULT")) {
|
||||
logger.info(
|
||||
`[matchMapList] Some maps were selected from DEFAULT source. groupOne: ${JSON.stringify(groupOne)}, groupTwo: ${JSON.stringify(groupTwo)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return new MapPool(mapPoolList);
|
||||
return result;
|
||||
}
|
||||
|
||||
export function mapModePreferencesToModeList(
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { ArchiveBoxIcon } from "~/components/icons/ArchiveBox";
|
|||
import { DiscordIcon } from "~/components/icons/Discord";
|
||||
import { RefreshArrowsIcon } from "~/components/icons/RefreshArrows";
|
||||
import { ScaleIcon } from "~/components/icons/Scale";
|
||||
import { UsersIcon } from "~/components/icons/Users";
|
||||
import { Main } from "~/components/Main";
|
||||
import { SubmitButton } from "~/components/SubmitButton";
|
||||
import { WeaponSelect } from "~/components/WeaponSelect";
|
||||
|
|
@ -1294,18 +1295,24 @@ function MapListMapPickInfo({
|
|||
|
||||
const pickInfo = (source: string) => {
|
||||
if (source === "TIEBREAKER") return t("tournament:pickInfo.tiebreaker");
|
||||
if (source === "BOTH") return t("tournament:pickInfo.both");
|
||||
if (source === "DEFAULT") return t("tournament:pickInfo.default");
|
||||
|
||||
if (source === String(data.match.alphaGroupId)) {
|
||||
return t("tournament:pickInfo.team.specific", {
|
||||
team: t("q:match.sides.alpha"),
|
||||
});
|
||||
}
|
||||
const poolMemberIds = sourcePoolMemberIds();
|
||||
const playerCount =
|
||||
poolMemberIds.length > 0
|
||||
? poolMemberIds.length
|
||||
: (mapPreferences?.length ?? 0);
|
||||
|
||||
return t("tournament:pickInfo.team.specific", {
|
||||
team: t("q:match.sides.bravo"),
|
||||
});
|
||||
return (
|
||||
<div className="stack horizontal xs items-center">
|
||||
<UsersIcon className="w-4" />
|
||||
<span>
|
||||
{t("tournament:pickInfo.votes", {
|
||||
count: playerCount,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const userIdToUser = (userId: number) => {
|
||||
|
|
@ -1348,6 +1355,8 @@ function MapListMapPickInfo({
|
|||
// legacy preference system (season 2)
|
||||
if (mapPreferences && mapPreferences.length > 0) return true;
|
||||
|
||||
if (map.source === "DEFAULT") return true;
|
||||
|
||||
return sourcePoolMemberIds().length > 0;
|
||||
};
|
||||
|
||||
|
|
@ -1365,7 +1374,11 @@ function MapListMapPickInfo({
|
|||
{t(`game-misc:MODE_SHORT_${map.mode}`)}{" "}
|
||||
{t(`game-misc:STAGE_${map.stageId}`)}
|
||||
</div>
|
||||
{sourcePoolMemberIds().length > 0 ? (
|
||||
{map.source === "DEFAULT" ? (
|
||||
<div className="text-sm text-center text-lighter">
|
||||
{t("tournament:pickInfo.default.explanation")}
|
||||
</div>
|
||||
) : sourcePoolMemberIds().length > 0 ? (
|
||||
<div className="stack sm">
|
||||
{sourcePoolMemberIds().map((userId) => {
|
||||
const user = userIdToUser(userId);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { stagesObj as s } from "~/modules/in-game-lists/stage-ids";
|
||||
import { stagesObj as s, stageIds } from "~/modules/in-game-lists/stage-ids";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import { MapPool } from "../map-list-generator/core/map-pool";
|
||||
|
||||
export const BANNED_MAPS: Record<ModeShort, StageId[]> = {
|
||||
TW: [],
|
||||
|
|
@ -44,3 +45,11 @@ export const BANNED_MAPS: Record<ModeShort, StageId[]> = {
|
|||
s.LEMURIA_HUB,
|
||||
],
|
||||
};
|
||||
|
||||
export const SENDOUQ_MAP_POOL = new MapPool({
|
||||
TW: stageIds.filter((stageId) => !BANNED_MAPS.TW.includes(stageId)),
|
||||
SZ: stageIds.filter((stageId) => !BANNED_MAPS.SZ.includes(stageId)),
|
||||
TC: stageIds.filter((stageId) => !BANNED_MAPS.TC.includes(stageId)),
|
||||
RM: stageIds.filter((stageId) => !BANNED_MAPS.RM.includes(stageId)),
|
||||
CB: stageIds.filter((stageId) => !BANNED_MAPS.CB.includes(stageId)),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { z } from "zod/v4";
|
||||
import { languagesUnified } from "~/modules/i18n/config";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import {
|
||||
_action,
|
||||
checkboxValueToBoolean,
|
||||
|
|
@ -27,27 +26,19 @@ export const settingsActionSchema = z.union([
|
|||
modes: z.array(z.object({ mode: modeShort, preference })),
|
||||
pool: z.array(
|
||||
z.object({
|
||||
stages: z.array(stageId).length(AMOUNT_OF_MAPS_IN_POOL_PER_MODE),
|
||||
stages: z.array(stageId).max(AMOUNT_OF_MAPS_IN_POOL_PER_MODE),
|
||||
mode: modeShort,
|
||||
}),
|
||||
),
|
||||
})
|
||||
.refine((val) =>
|
||||
val.pool.every((pool) => {
|
||||
const mp = val.modes.find((m) => m.mode === pool.mode);
|
||||
return mp?.preference !== "AVOID";
|
||||
}, "Can't have map pool for a mode that was avoided"),
|
||||
)
|
||||
.refine((val) => {
|
||||
for (const mode of modesShort) {
|
||||
const mp = val.modes.find((m) => m.mode === mode);
|
||||
if (mp?.preference === "AVOID") continue;
|
||||
|
||||
if (!val.pool.some((p) => p.mode === mode)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, "Pool has to be picked for each mode that wasn't avoided"),
|
||||
.refine(
|
||||
(val) =>
|
||||
val.pool.every((pool) => {
|
||||
const mp = val.modes.find((m) => m.mode === pool.mode);
|
||||
return mp?.preference !== "AVOID";
|
||||
}),
|
||||
"Can't have map pool for a mode that was avoided",
|
||||
),
|
||||
),
|
||||
}),
|
||||
z.object({
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ function MapPicker() {
|
|||
if (mp?.preference === "AVOID") continue;
|
||||
|
||||
const pool = preferences.pool.find((p) => p.mode === mode);
|
||||
if (!pool || pool.stages.length !== AMOUNT_OF_MAPS_IN_POOL_PER_MODE) {
|
||||
if (pool && pool.stages.length > AMOUNT_OF_MAPS_IN_POOL_PER_MODE) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { sub } from "date-fns";
|
||||
import { sql } from "kysely";
|
||||
import { type NotNull, sql } from "kysely";
|
||||
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
||||
import { db } from "~/db/sql";
|
||||
import type {
|
||||
|
|
@ -21,9 +21,8 @@ export function mapModePreferencesByGroupId(groupId: number) {
|
|||
.select(["User.id as userId", "User.mapModePreferences as preferences"])
|
||||
.where("GroupMember.groupId", "=", groupId)
|
||||
.where("User.mapModePreferences", "is not", null)
|
||||
.execute() as Promise<
|
||||
{ userId: number; preferences: UserMapModePreferences }[]
|
||||
>;
|
||||
.$narrowType<{ preferences: NotNull }>()
|
||||
.execute();
|
||||
}
|
||||
|
||||
// groups visible for longer to make development easier
|
||||
|
|
@ -395,3 +394,16 @@ export async function setOldGroupsAsInactive() {
|
|||
.executeTakeFirst();
|
||||
});
|
||||
}
|
||||
|
||||
export async function mapModePreferencesBySeasonNth(seasonNth: number) {
|
||||
return db
|
||||
.selectFrom("Skill")
|
||||
.innerJoin("User", "User.id", "Skill.userId")
|
||||
.select("User.mapModePreferences")
|
||||
.where("Skill.season", "=", seasonNth)
|
||||
.where("Skill.userId", "is not", null)
|
||||
.where("User.mapModePreferences", "is not", null)
|
||||
.groupBy("Skill.userId")
|
||||
.$narrowType<{ mapModePreferences: UserMapModePreferences }>()
|
||||
.execute();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ export const action: ActionFunction = async ({ request }) => {
|
|||
);
|
||||
const theirGroupPreferences =
|
||||
await QRepository.mapModePreferencesByGroupId(theirGroup.id);
|
||||
const mapList = matchMapList(
|
||||
const mapList = await matchMapList(
|
||||
{
|
||||
id: ourGroup.id,
|
||||
preferences: ourGroupPreferences,
|
||||
|
|
|
|||
|
|
@ -160,22 +160,36 @@ export function GroupCard({
|
|||
</div>
|
||||
) : null}
|
||||
{group.tierRange?.range ? (
|
||||
<div className="stack sm items-center">
|
||||
<div className="q__group__tier-diff-text">
|
||||
±{group.tierRange.diff}
|
||||
</div>
|
||||
<div className="stack items-center">
|
||||
<div className="stack sm horizontal items-center text-sm font-bold">
|
||||
<TierImage tier={group.tierRange.range[0]} width={38} />
|
||||
{t("q:looking.range.or")}
|
||||
<TierImage tier={group.tierRange.range[1]} width={38} />
|
||||
</div>
|
||||
{group.isReplay ? (
|
||||
<div className="text-theme-secondary text-uppercase text-xs font-bold">
|
||||
{t("q:looking.replay")}
|
||||
<div className="stack md items-center">
|
||||
<div className="stack sm horizontal items-center justify-center">
|
||||
<div className="stack xs items-center">
|
||||
<TierImage tier={group.tierRange.range[0]} width={80} />
|
||||
<div className="text-lighter text-sm font-bold">
|
||||
(-{group.tierRange.diff})
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<SendouPopover
|
||||
popoverClassName="text-main-forced"
|
||||
trigger={
|
||||
<SendouButton className="q__group__or-popover-button">
|
||||
{t("q:looking.range.or")}
|
||||
</SendouButton>
|
||||
}
|
||||
>
|
||||
{t("q:looking.range.or.explanation")}
|
||||
</SendouPopover>
|
||||
<div className="stack xs items-center">
|
||||
<TierImage tier={group.tierRange.range[1]} width={80} />
|
||||
<div className="text-lighter text-sm font-bold">
|
||||
(+{group.tierRange.diff})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{group.isReplay ? (
|
||||
<div className="text-theme-secondary text-uppercase text-xs font-bold">
|
||||
{t("q:looking.replay")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{group.skillDifference ? (
|
||||
|
|
|
|||
436
app/features/sendouq/core/default-maps.server.test.ts
Normal file
436
app/features/sendouq/core/default-maps.server.test.ts
Normal file
|
|
@ -0,0 +1,436 @@
|
|||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { db } from "~/db/sql";
|
||||
import type { UserMapModePreferences } from "~/db/tables";
|
||||
import type { StageId } from "~/modules/in-game-lists/types";
|
||||
import { dbInsertUsers, dbReset } from "~/utils/Test";
|
||||
import {
|
||||
clearCacheForTesting,
|
||||
getDefaultMapWeights,
|
||||
} from "./default-maps.server";
|
||||
|
||||
const { mockSeasonCurrent, mockSeasonPrevious } = vi.hoisted(() => ({
|
||||
mockSeasonCurrent: vi.fn(),
|
||||
mockSeasonPrevious: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("~/features/mmr/core/Seasons", () => ({
|
||||
current: mockSeasonCurrent,
|
||||
previous: mockSeasonPrevious,
|
||||
}));
|
||||
|
||||
describe("getDefaultMapWeights()", () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
clearCacheForTesting();
|
||||
await dbInsertUsers(10);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
dbReset();
|
||||
});
|
||||
|
||||
test("returns empty map when no season is found", async () => {
|
||||
mockSeasonCurrent.mockReturnValue(null);
|
||||
mockSeasonPrevious.mockReturnValue(null);
|
||||
|
||||
const result = await getDefaultMapWeights();
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
test("uses previous season when current season is less than 7 days old", async () => {
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
mockSeasonCurrent.mockReturnValue({
|
||||
nth: 2,
|
||||
starts: yesterday,
|
||||
ends: new Date("2030-12-31"),
|
||||
});
|
||||
mockSeasonPrevious.mockReturnValue({
|
||||
nth: 1,
|
||||
starts: new Date("2023-01-01"),
|
||||
ends: new Date("2023-12-31"),
|
||||
});
|
||||
|
||||
await insertUserMapModePreferencesForSeason({
|
||||
seasonNth: 1,
|
||||
userPreferences: [
|
||||
{
|
||||
userId: 1,
|
||||
mapModePreferences: {
|
||||
modes: [],
|
||||
pool: [{ mode: "SZ", stages: [0] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getDefaultMapWeights();
|
||||
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("uses current season when it is 7 or more days old", async () => {
|
||||
const eightDaysAgo = new Date();
|
||||
eightDaysAgo.setDate(eightDaysAgo.getDate() - 8);
|
||||
|
||||
mockSeasonCurrent.mockReturnValue({
|
||||
nth: 2,
|
||||
starts: eightDaysAgo,
|
||||
ends: new Date("2030-12-31"),
|
||||
});
|
||||
mockSeasonPrevious.mockReturnValue({
|
||||
nth: 1,
|
||||
starts: new Date("2023-01-01"),
|
||||
ends: new Date("2023-12-31"),
|
||||
});
|
||||
|
||||
await insertUserMapModePreferencesForSeason({
|
||||
seasonNth: 2,
|
||||
userPreferences: [
|
||||
{
|
||||
userId: 1,
|
||||
mapModePreferences: {
|
||||
modes: [],
|
||||
pool: [{ mode: "SZ", stages: [0] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getDefaultMapWeights();
|
||||
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("uses previous season when current season does not exist", async () => {
|
||||
mockSeasonCurrent.mockReturnValue(null);
|
||||
mockSeasonPrevious.mockReturnValue({
|
||||
nth: 1,
|
||||
starts: new Date("2023-01-01"),
|
||||
ends: new Date("2023-12-31"),
|
||||
});
|
||||
|
||||
await insertUserMapModePreferencesForSeason({
|
||||
seasonNth: 1,
|
||||
userPreferences: [
|
||||
{
|
||||
userId: 1,
|
||||
mapModePreferences: {
|
||||
modes: [],
|
||||
pool: [{ mode: "SZ", stages: [0] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getDefaultMapWeights();
|
||||
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("returns weights for top 7 maps per mode", async () => {
|
||||
mockSeasonCurrent.mockReturnValue({
|
||||
nth: 1,
|
||||
starts: new Date("2023-01-01"),
|
||||
ends: new Date("2023-12-31"),
|
||||
});
|
||||
|
||||
await insertUserMapModePreferencesForSeason({
|
||||
seasonNth: 1,
|
||||
userPreferences: [
|
||||
{
|
||||
userId: 1,
|
||||
mapModePreferences: {
|
||||
modes: [],
|
||||
pool: [
|
||||
{ mode: "SZ", stages: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] },
|
||||
{ mode: "TC", stages: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10] },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: 2,
|
||||
mapModePreferences: {
|
||||
modes: [],
|
||||
pool: [
|
||||
{ mode: "SZ", stages: [0, 1, 2, 3, 4, 5, 6] },
|
||||
{ mode: "TC", stages: [1, 2, 3, 4, 5, 6, 7] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getDefaultMapWeights();
|
||||
|
||||
const szMaps = Array.from(result.keys()).filter((key) =>
|
||||
key.startsWith("SZ-"),
|
||||
);
|
||||
const tcMaps = Array.from(result.keys()).filter((key) =>
|
||||
key.startsWith("TC-"),
|
||||
);
|
||||
|
||||
expect(szMaps.length).toBe(7);
|
||||
expect(tcMaps.length).toBe(7);
|
||||
});
|
||||
|
||||
test("assigns weight of -1 to all selected maps", async () => {
|
||||
mockSeasonCurrent.mockReturnValue({
|
||||
nth: 1,
|
||||
starts: new Date("2023-01-01"),
|
||||
ends: new Date("2023-12-31"),
|
||||
});
|
||||
|
||||
await insertUserMapModePreferencesForSeason({
|
||||
seasonNth: 1,
|
||||
userPreferences: [
|
||||
{
|
||||
userId: 1,
|
||||
mapModePreferences: {
|
||||
modes: [],
|
||||
pool: [{ mode: "SZ", stages: [0, 1, 2] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getDefaultMapWeights();
|
||||
|
||||
for (const weight of result.values()) {
|
||||
expect(weight).toBe(-1);
|
||||
}
|
||||
});
|
||||
|
||||
test("does not count preferences for avoided modes", async () => {
|
||||
mockSeasonCurrent.mockReturnValue({
|
||||
nth: 1,
|
||||
starts: new Date("2023-01-01"),
|
||||
ends: new Date("2023-12-31"),
|
||||
});
|
||||
|
||||
await insertUserMapModePreferencesForSeason({
|
||||
seasonNth: 1,
|
||||
userPreferences: [
|
||||
{
|
||||
userId: 1,
|
||||
mapModePreferences: {
|
||||
modes: [{ mode: "SZ", preference: "AVOID" }],
|
||||
pool: [
|
||||
{ mode: "SZ", stages: [0, 1, 2] },
|
||||
{ mode: "TC", stages: [0, 1, 2] },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: 2,
|
||||
mapModePreferences: {
|
||||
modes: [{ mode: "SZ", preference: "AVOID" }],
|
||||
pool: [
|
||||
{ mode: "SZ", stages: [0, 1, 2] },
|
||||
{ mode: "TC", stages: [0, 1, 2] },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: 3,
|
||||
mapModePreferences: {
|
||||
modes: [],
|
||||
pool: [
|
||||
{ mode: "SZ", stages: [3, 4, 5] },
|
||||
{ mode: "TC", stages: [0, 1, 2] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getDefaultMapWeights();
|
||||
|
||||
const szMaps = Array.from(result.keys()).filter((key) =>
|
||||
key.startsWith("SZ-"),
|
||||
);
|
||||
const tcMaps = Array.from(result.keys()).filter((key) =>
|
||||
key.startsWith("TC-"),
|
||||
);
|
||||
|
||||
expect(tcMaps.length).toBe(3);
|
||||
expect(szMaps.length).toBe(3);
|
||||
});
|
||||
|
||||
test("handles users with no pool preferences", async () => {
|
||||
mockSeasonCurrent.mockReturnValue({
|
||||
nth: 1,
|
||||
starts: new Date("2023-01-01"),
|
||||
ends: new Date("2023-12-31"),
|
||||
});
|
||||
|
||||
await insertUserMapModePreferencesForSeason({
|
||||
seasonNth: 1,
|
||||
userPreferences: [
|
||||
{
|
||||
userId: 1,
|
||||
mapModePreferences: {
|
||||
modes: [],
|
||||
pool: undefined as any,
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: 2,
|
||||
mapModePreferences: {
|
||||
modes: [],
|
||||
pool: [{ mode: "SZ", stages: [0, 1, 2] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getDefaultMapWeights();
|
||||
|
||||
expect(result.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("selects most popular maps across users", async () => {
|
||||
mockSeasonCurrent.mockReturnValue({
|
||||
nth: 1,
|
||||
starts: new Date("2023-01-01"),
|
||||
ends: new Date("2023-12-31"),
|
||||
});
|
||||
|
||||
const FROM_ZERO_TO_SIX = Array.from({ length: 7 }, (_, i) => i as StageId);
|
||||
|
||||
await insertUserMapModePreferencesForSeason({
|
||||
seasonNth: 1,
|
||||
userPreferences: [
|
||||
{
|
||||
userId: 1,
|
||||
mapModePreferences: {
|
||||
modes: [
|
||||
{
|
||||
mode: "SZ",
|
||||
preference: "PREFER",
|
||||
},
|
||||
],
|
||||
pool: [{ mode: "SZ", stages: [1, 2, 3, 4, 5, 6, 7] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: 2,
|
||||
mapModePreferences: {
|
||||
modes: [
|
||||
{
|
||||
mode: "SZ",
|
||||
preference: "PREFER",
|
||||
},
|
||||
],
|
||||
pool: [{ mode: "SZ", stages: FROM_ZERO_TO_SIX }],
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: 3,
|
||||
mapModePreferences: {
|
||||
modes: [
|
||||
{
|
||||
mode: "SZ",
|
||||
preference: "PREFER",
|
||||
},
|
||||
],
|
||||
pool: [{ mode: "SZ", stages: FROM_ZERO_TO_SIX }],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getDefaultMapWeights();
|
||||
|
||||
expect(result.has("SZ-0")).toBe(true);
|
||||
expect(result.has("SZ-7")).toBe(false);
|
||||
});
|
||||
|
||||
test("handles empty preferences array", async () => {
|
||||
mockSeasonCurrent.mockReturnValue({
|
||||
nth: 1,
|
||||
starts: new Date("2023-01-01"),
|
||||
ends: new Date("2023-12-31"),
|
||||
});
|
||||
|
||||
const result = await getDefaultMapWeights();
|
||||
|
||||
expect(result).toBeInstanceOf(Map);
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
test("processes all ranked modes (SZ, TC, RM, CB)", async () => {
|
||||
mockSeasonCurrent.mockReturnValue({
|
||||
nth: 1,
|
||||
starts: new Date("2023-01-01"),
|
||||
ends: new Date("2023-12-31"),
|
||||
});
|
||||
|
||||
await insertUserMapModePreferencesForSeason({
|
||||
seasonNth: 1,
|
||||
userPreferences: [
|
||||
{
|
||||
userId: 1,
|
||||
mapModePreferences: {
|
||||
modes: [],
|
||||
pool: [
|
||||
{ mode: "SZ", stages: [0, 1, 2, 3, 4, 5, 6, 7] },
|
||||
{ mode: "TC", stages: [0, 1, 2, 3, 4, 5, 6, 7] },
|
||||
{ mode: "RM", stages: [0, 1, 2, 3, 4, 5, 6, 7] },
|
||||
{ mode: "CB", stages: [0, 1, 2, 3, 4, 5, 6, 7] },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = await getDefaultMapWeights();
|
||||
|
||||
const modes = new Set<string>();
|
||||
for (const key of result.keys()) {
|
||||
const mode = key.split("-")[0];
|
||||
modes.add(mode);
|
||||
}
|
||||
|
||||
expect(modes.has("SZ")).toBe(true);
|
||||
expect(modes.has("TC")).toBe(true);
|
||||
expect(modes.has("RM")).toBe(true);
|
||||
expect(modes.has("CB")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// using db directly here instead of repositories as inserting skills would be too much of a hassle
|
||||
async function insertUserMapModePreferencesForSeason({
|
||||
seasonNth,
|
||||
userPreferences,
|
||||
}: {
|
||||
seasonNth: number;
|
||||
userPreferences: Array<{
|
||||
userId: number;
|
||||
mapModePreferences: UserMapModePreferences;
|
||||
}>;
|
||||
}) {
|
||||
for (const { userId, mapModePreferences } of userPreferences) {
|
||||
await db
|
||||
.updateTable("User")
|
||||
.set({ mapModePreferences: JSON.stringify(mapModePreferences) })
|
||||
.where("id", "=", userId)
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.insertInto("Skill")
|
||||
.values({
|
||||
userId,
|
||||
season: seasonNth,
|
||||
mu: 25,
|
||||
sigma: 8.333,
|
||||
ordinal: 0,
|
||||
matchesCount: 10,
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
98
app/features/sendouq/core/default-maps.server.ts
Normal file
98
app/features/sendouq/core/default-maps.server.ts
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { differenceInDays } from "date-fns";
|
||||
import * as MapList from "~/features/map-list-generator/core/MapList";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { logger } from "~/utils/logger";
|
||||
import * as QRepository from "../QRepository.server";
|
||||
|
||||
let cachedDefaults: Map<string, number> | null = null;
|
||||
|
||||
const ONE_WEEK_IN_DAYS = 7;
|
||||
const DEFAULT_MAP_WEIGHT = -1;
|
||||
const TOP_MAPS_PER_MODE = 7;
|
||||
|
||||
export function clearCacheForTesting() {
|
||||
cachedDefaults = null;
|
||||
}
|
||||
|
||||
export async function getDefaultMapWeights(): Promise<Map<string, number>> {
|
||||
const season = resolveSeasonForDefaults();
|
||||
if (!season) {
|
||||
logger.warn(
|
||||
"[getDefaultMapWeights] No season found for default map weights",
|
||||
);
|
||||
return new Map();
|
||||
}
|
||||
|
||||
if (cachedDefaults) {
|
||||
return cachedDefaults;
|
||||
}
|
||||
|
||||
const weights = await calculateSeasonDefaultMaps(season.nth);
|
||||
logger.info(
|
||||
`[getDefaultMapWeights] Calculated default map weights: ${JSON.stringify(Object.fromEntries(weights))}`,
|
||||
);
|
||||
cachedDefaults = weights;
|
||||
|
||||
return weights;
|
||||
}
|
||||
|
||||
function resolveSeasonForDefaults() {
|
||||
const currentSeason = Seasons.current();
|
||||
if (!currentSeason) {
|
||||
return Seasons.previous();
|
||||
}
|
||||
|
||||
const daysSinceSeasonStart = differenceInDays(
|
||||
new Date(),
|
||||
currentSeason.starts,
|
||||
);
|
||||
if (daysSinceSeasonStart < ONE_WEEK_IN_DAYS) {
|
||||
return Seasons.previous();
|
||||
}
|
||||
|
||||
return currentSeason;
|
||||
}
|
||||
|
||||
async function calculateSeasonDefaultMaps(
|
||||
seasonNth: number,
|
||||
): Promise<Map<string, number>> {
|
||||
const activeUsersWithPreferences =
|
||||
await QRepository.mapModePreferencesBySeasonNth(seasonNth);
|
||||
|
||||
const mapModeCounts = new Map<string, number>();
|
||||
|
||||
for (const row of activeUsersWithPreferences) {
|
||||
const preferences = row.mapModePreferences;
|
||||
if (!preferences?.pool) continue;
|
||||
|
||||
for (const poolEntry of preferences.pool) {
|
||||
const avoidedMode = preferences.modes.find(
|
||||
(m) => m.mode === poolEntry.mode && m.preference === "AVOID",
|
||||
);
|
||||
if (avoidedMode) continue;
|
||||
|
||||
for (const stageId of poolEntry.stages) {
|
||||
mapModeCounts.set(
|
||||
MapList.modeStageKey(poolEntry.mode, stageId),
|
||||
(mapModeCounts.get(MapList.modeStageKey(poolEntry.mode, stageId)) ??
|
||||
0) + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const weights = new Map<string, number>();
|
||||
for (const mode of modesShort) {
|
||||
const mapsForMode = Array.from(mapModeCounts.entries())
|
||||
.filter(([key]) => key.includes(mode))
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, TOP_MAPS_PER_MODE);
|
||||
|
||||
for (const [key] of mapsForMode) {
|
||||
weights.set(key, DEFAULT_MAP_WEIGHT);
|
||||
}
|
||||
}
|
||||
|
||||
return weights;
|
||||
}
|
||||
|
|
@ -316,8 +316,16 @@
|
|||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
.q__group__tier-diff-text {
|
||||
font-size: 3rem;
|
||||
.q__group__or-popover-button {
|
||||
background-color: transparent;
|
||||
color: var(--text-lighter);
|
||||
font-size: var(--fonts-xs);
|
||||
padding: 0;
|
||||
border: none;
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dotted;
|
||||
font-weight: var(--bold);
|
||||
height: 19.8281px;
|
||||
}
|
||||
|
||||
.q__member-adder__input {
|
||||
|
|
|
|||
|
|
@ -1,75 +1,4 @@
|
|||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { modesShort } from "../in-game-lists/modes";
|
||||
import { stagesObj as s } from "../in-game-lists/stage-ids";
|
||||
import type { ModeShort, StageId } from "../in-game-lists/types";
|
||||
|
||||
export const SENDOUQ_DEFAULT_MAPS: Record<
|
||||
ModeShort,
|
||||
[StageId, StageId, StageId, StageId, StageId, StageId, StageId]
|
||||
> = {
|
||||
TW: [
|
||||
s.EELTAIL_ALLEY,
|
||||
s.HAGGLEFISH_MARKET,
|
||||
s.UNDERTOW_SPILLWAY,
|
||||
s.WAHOO_WORLD,
|
||||
s.UM_AMI_RUINS,
|
||||
s.HUMPBACK_PUMP_TRACK,
|
||||
s.ROBO_ROM_EN,
|
||||
],
|
||||
SZ: [
|
||||
s.HAGGLEFISH_MARKET,
|
||||
s.MAHI_MAHI_RESORT,
|
||||
s.INKBLOT_ART_ACADEMY,
|
||||
s.MAKOMART,
|
||||
s.HUMPBACK_PUMP_TRACK,
|
||||
s.CRABLEG_CAPITAL,
|
||||
s.ROBO_ROM_EN,
|
||||
],
|
||||
TC: [
|
||||
s.ROBO_ROM_EN,
|
||||
s.EELTAIL_ALLEY,
|
||||
s.UNDERTOW_SPILLWAY,
|
||||
s.MUSEUM_D_ALFONSINO,
|
||||
s.MAKOMART,
|
||||
s.MANTA_MARIA,
|
||||
s.SHIPSHAPE_CARGO_CO,
|
||||
],
|
||||
RM: [
|
||||
s.SCORCH_GORGE,
|
||||
s.HAGGLEFISH_MARKET,
|
||||
s.UNDERTOW_SPILLWAY,
|
||||
s.MUSEUM_D_ALFONSINO,
|
||||
s.FLOUNDER_HEIGHTS,
|
||||
s.CRABLEG_CAPITAL,
|
||||
s.MINCEMEAT_METALWORKS,
|
||||
],
|
||||
CB: [
|
||||
s.SCORCH_GORGE,
|
||||
s.INKBLOT_ART_ACADEMY,
|
||||
s.BRINEWATER_SPRINGS,
|
||||
s.MANTA_MARIA,
|
||||
s.HUMPBACK_PUMP_TRACK,
|
||||
s.UM_AMI_RUINS,
|
||||
s.ROBO_ROM_EN,
|
||||
],
|
||||
};
|
||||
|
||||
for (const mode of modesShort) {
|
||||
invariant(
|
||||
SENDOUQ_DEFAULT_MAPS[mode].length ===
|
||||
new Set(SENDOUQ_DEFAULT_MAPS[mode]).size,
|
||||
"Duplicate maps in SENDOUQ_DEFAULT_MAPS",
|
||||
);
|
||||
|
||||
invariant(
|
||||
BANNED_MAPS[mode].every(
|
||||
(stageId) => !SENDOUQ_DEFAULT_MAPS[mode].includes(stageId),
|
||||
),
|
||||
`Banned maps in the default map pool of ${mode}`,
|
||||
);
|
||||
}
|
||||
|
||||
export const sourceTypes = [
|
||||
"DEFAULT",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "",
|
||||
"looking.joinQPrompt": "",
|
||||
"looking.range.or": "",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "",
|
||||
"match.spInfo": "",
|
||||
"match.dispute.button": "",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@
|
|||
"pickInfo.team.specific": "{{team}} valgte",
|
||||
"pickInfo.tiebreaker": "Tiebreak",
|
||||
"pickInfo.both": "begge hold valgte",
|
||||
"pickInfo.default": "Turneringsspecifik standardbane valgt",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.votes_one": "",
|
||||
"pickInfo.votes_other": "",
|
||||
"pickInfo.counterpick": "Modvalg",
|
||||
"generator.error": "Ændringer, som du har lavet, blev ikke gemt, da turneringen nu er begyndt. ",
|
||||
"teams.mapsPickedStatus": "Status for banevalg",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "",
|
||||
"looking.joinQPrompt": "",
|
||||
"looking.range.or": "",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "",
|
||||
"match.spInfo": "",
|
||||
"match.dispute.button": "",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@
|
|||
"pickInfo.team.specific": "Von {{team}} ausgewählt",
|
||||
"pickInfo.tiebreaker": "Tiebreaker",
|
||||
"pickInfo.both": "Von beiden ausgewählt",
|
||||
"pickInfo.default": "Standardmäßige Arena",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.votes_one": "",
|
||||
"pickInfo.votes_other": "",
|
||||
"pickInfo.counterpick": "Counterpick",
|
||||
"generator.error": "Die Änderungen wurden nicht gespeichert, weil das Turnier bereits begonnen hat",
|
||||
"teams.mapsPickedStatus": "Status ausgewählter Arenen",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "All tiers",
|
||||
"looking.joinQPrompt": "Join the queue to find a group",
|
||||
"looking.range.or": "or",
|
||||
"looking.range.or.explanation": "You will be matched with a group whose average rank is one of these. Exact is not shown to avoid behavior where groups only play against tiers lower than themselves.",
|
||||
"match.header": "Match #{{number}}",
|
||||
"match.spInfo": "SP will be adjusted after both teams report the same result",
|
||||
"match.dispute.button": "Dispute?",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,10 @@
|
|||
"pickInfo.team.specific": "{{team}} picked",
|
||||
"pickInfo.tiebreaker": "Tiebreaker",
|
||||
"pickInfo.both": "Both picked",
|
||||
"pickInfo.default": "Default map",
|
||||
"pickInfo.default": "Community's choice",
|
||||
"pickInfo.default.explanation": "A suitable map was not available from the participants' pools. This map was selected from all popular maps instead.",
|
||||
"pickInfo.votes_one": "{{count}} vote",
|
||||
"pickInfo.votes_other": "{{count}} votes",
|
||||
"pickInfo.counterpick": "Counterpick",
|
||||
"generator.error": "Changes you made weren't saved since the tournament started",
|
||||
"teams.mapsPickedStatus": "Maps picked status",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "Todos los niveles",
|
||||
"looking.joinQPrompt": "Únete a la fila para encontrar un grupo",
|
||||
"looking.range.or": "o",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "Partido #{{number}}",
|
||||
"match.spInfo": "Fuerza Sendou será ajustada despues de que ambos equipos informen el mismo resultado",
|
||||
"match.dispute.button": "¿Disputar?",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@
|
|||
"pickInfo.team.specific": "{{team}} eligió",
|
||||
"pickInfo.tiebreaker": "Desempate",
|
||||
"pickInfo.both": "Ambos eligieron",
|
||||
"pickInfo.default": "Mapa por defacto",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.votes_one": "",
|
||||
"pickInfo.votes_many": "",
|
||||
"pickInfo.votes_other": "",
|
||||
"pickInfo.counterpick": "Contraselección",
|
||||
"generator.error": "Cambios no fueron guardados ya que el torneo comenzó",
|
||||
"teams.mapsPickedStatus": "Estado de mapas elegidos",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "Todos los niveles",
|
||||
"looking.joinQPrompt": "Únete a la fila para encontrar un grupo",
|
||||
"looking.range.or": "o",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "Partido #{{number}}",
|
||||
"match.spInfo": "Fuerza Sendou será ajustada despues de que ambos equipos informen el mismo resultado",
|
||||
"match.dispute.button": "¿Disputar?",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@
|
|||
"pickInfo.team.specific": "{{team}} eligió",
|
||||
"pickInfo.tiebreaker": "Desempate",
|
||||
"pickInfo.both": "Ambos eligieron",
|
||||
"pickInfo.default": "Mapa por defacto",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.votes_one": "",
|
||||
"pickInfo.votes_many": "",
|
||||
"pickInfo.votes_other": "",
|
||||
"pickInfo.counterpick": "Contraselección",
|
||||
"generator.error": "Cambios no fueron guardados ya que el torneo comenzó",
|
||||
"teams.mapsPickedStatus": "Estado de escenarios elegidos",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "",
|
||||
"looking.joinQPrompt": "",
|
||||
"looking.range.or": "",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "",
|
||||
"match.spInfo": "",
|
||||
"match.dispute.button": "",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@
|
|||
"pickInfo.team.specific": "{{team}} a fait son choix.",
|
||||
"pickInfo.tiebreaker": "Tiebreaker",
|
||||
"pickInfo.both": "Les deux ont choisi",
|
||||
"pickInfo.default": "Stages par défaut",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.votes_one": "",
|
||||
"pickInfo.votes_many": "",
|
||||
"pickInfo.votes_other": "",
|
||||
"pickInfo.counterpick": "",
|
||||
"generator.error": "Les modifications apportées n'ont pas été enregistrées depuis le début du tournoi",
|
||||
"teams.mapsPickedStatus": "Statut des stages sélectionnés",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "Tout les ranks",
|
||||
"looking.joinQPrompt": "Rejoindre la queue pour trouvez un groupe",
|
||||
"looking.range.or": "ou",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "Match #{{number}}",
|
||||
"match.spInfo": "Les SP après que les deux teams est reportées le même résultat",
|
||||
"match.dispute.button": "Dispute?",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@
|
|||
"pickInfo.team.specific": "{{team}} a fait son choix.",
|
||||
"pickInfo.tiebreaker": "Manche décisive",
|
||||
"pickInfo.both": "Les deux ont choisi",
|
||||
"pickInfo.default": "Stages par défaut",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.votes_one": "",
|
||||
"pickInfo.votes_many": "",
|
||||
"pickInfo.votes_other": "",
|
||||
"pickInfo.counterpick": "Counterpick",
|
||||
"generator.error": "Les modifications apportées n'ont pas été enregistrées depuis le début du tournoi",
|
||||
"teams.mapsPickedStatus": "Statut des stages sélectionnés",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "",
|
||||
"looking.joinQPrompt": "",
|
||||
"looking.range.or": "",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "",
|
||||
"match.spInfo": "",
|
||||
"match.dispute.button": "",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@
|
|||
"pickInfo.team.specific": "{{team}} נבחר",
|
||||
"pickInfo.tiebreaker": "שובר שיוויון",
|
||||
"pickInfo.both": "שניהם בחרו",
|
||||
"pickInfo.default": "מפת ברירת מחדל",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.votes_one": "",
|
||||
"pickInfo.votes_two": "",
|
||||
"pickInfo.votes_other": "",
|
||||
"pickInfo.counterpick": "",
|
||||
"generator.error": "שינויים שביצעת לא נשמרו מאז תחילת הטורניר",
|
||||
"teams.mapsPickedStatus": "סטטוס בחירת המפות",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "Tutti i tier",
|
||||
"looking.joinQPrompt": "Unisciti alla coda o trova un gruppo",
|
||||
"looking.range.or": "o",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "Match #{{number}}",
|
||||
"match.spInfo": "Gli SP verranno sistemati una volta che entrambi i team riporteranno lo stesso punteggio",
|
||||
"match.dispute.button": "Disputa?",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@
|
|||
"pickInfo.team.specific": "{{team}} ha scelto",
|
||||
"pickInfo.tiebreaker": "Spareggio",
|
||||
"pickInfo.both": "Scelto da entrambi",
|
||||
"pickInfo.default": "Scenario di default",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.votes_one": "",
|
||||
"pickInfo.votes_many": "",
|
||||
"pickInfo.votes_other": "",
|
||||
"pickInfo.counterpick": "Counterpick",
|
||||
"generator.error": "I cambiamenti che hai fatto non sono stati salvati visto che il torneo è già cominciato.",
|
||||
"teams.mapsPickedStatus": "Stato scenari selezionati",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "全てのティア",
|
||||
"looking.joinQPrompt": "グループを見つけるには列に入ってください",
|
||||
"looking.range.or": "か",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "マッチ #{{number}}番",
|
||||
"match.spInfo": "SP は両チームが同じ結果を報告してからアップデートされます。",
|
||||
"match.dispute.button": "異議を唱えますか?",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@
|
|||
"pickInfo.team.specific": "{{team}} picked",
|
||||
"pickInfo.tiebreaker": "タイブレイカー",
|
||||
"pickInfo.both": "両者選択",
|
||||
"pickInfo.default": "デフォルトマップ",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.counterpick": "順番に選ぶ",
|
||||
"generator.error": "トーナメントが開始済みなので変更は保存されませんでした。",
|
||||
"teams.mapsPickedStatus": "マップの選択状況",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "",
|
||||
"looking.joinQPrompt": "",
|
||||
"looking.range.or": "",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "",
|
||||
"match.spInfo": "",
|
||||
"match.dispute.button": "",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@
|
|||
"pickInfo.tiebreaker": "",
|
||||
"pickInfo.both": "",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.counterpick": "",
|
||||
"generator.error": "",
|
||||
"teams.mapsPickedStatus": "",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "",
|
||||
"looking.joinQPrompt": "",
|
||||
"looking.range.or": "",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "",
|
||||
"match.spInfo": "",
|
||||
"match.dispute.button": "",
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@
|
|||
"pickInfo.tiebreaker": "",
|
||||
"pickInfo.both": "",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.votes_one": "",
|
||||
"pickInfo.votes_other": "",
|
||||
"pickInfo.counterpick": "",
|
||||
"generator.error": "",
|
||||
"teams.mapsPickedStatus": "",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "",
|
||||
"looking.joinQPrompt": "",
|
||||
"looking.range.or": "",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "",
|
||||
"match.spInfo": "",
|
||||
"match.dispute.button": "",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,12 @@
|
|||
"pickInfo.team.specific": "",
|
||||
"pickInfo.tiebreaker": "Tiebreaker",
|
||||
"pickInfo.both": "Oba wybrane",
|
||||
"pickInfo.default": "Domyślna mapa",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.votes_one": "",
|
||||
"pickInfo.votes_few": "",
|
||||
"pickInfo.votes_many": "",
|
||||
"pickInfo.votes_other": "",
|
||||
"pickInfo.counterpick": "",
|
||||
"generator.error": "Zmiany nie zostały zapisane z powodu rozpoczęcia turnieju",
|
||||
"teams.mapsPickedStatus": "Status wybranych map",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "Todas as tiers",
|
||||
"looking.joinQPrompt": "Entre na fila para achar um grupo",
|
||||
"looking.range.or": "",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "Partida #{{number}}",
|
||||
"match.spInfo": "SP será ajustado depois que ambos os times declarem o mesmo resultado",
|
||||
"match.dispute.button": "Disputar?",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,11 @@
|
|||
"pickInfo.team.specific": "{{team}} escolheu",
|
||||
"pickInfo.tiebreaker": "Desempate",
|
||||
"pickInfo.both": "Ambos escolheram",
|
||||
"pickInfo.default": "Mapa padrão",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.votes_one": "",
|
||||
"pickInfo.votes_many": "",
|
||||
"pickInfo.votes_other": "",
|
||||
"pickInfo.counterpick": "Counterpick",
|
||||
"generator.error": "Mudanças que você fez não foram salvas uma vez que o torneio já começou",
|
||||
"teams.mapsPickedStatus": "Status dos mapas escolhidos",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "Все ранги",
|
||||
"looking.joinQPrompt": "Присоединитесь к очереди, чтобы найти группу",
|
||||
"looking.range.or": "или",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "Матч #{{number}}",
|
||||
"match.spInfo": "SP будет изменено после того, как обе команды сообщат одинаковый счёт.",
|
||||
"match.dispute.button": "Оспорить?",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,12 @@
|
|||
"pickInfo.team.specific": "",
|
||||
"pickInfo.tiebreaker": "Тай-брейк",
|
||||
"pickInfo.both": "Выбрано обеими",
|
||||
"pickInfo.default": "Карта по умолчанию",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.votes_one": "",
|
||||
"pickInfo.votes_few": "",
|
||||
"pickInfo.votes_many": "",
|
||||
"pickInfo.votes_other": "",
|
||||
"pickInfo.counterpick": "Контрвыбор",
|
||||
"generator.error": "Ваши изменения не были сохранены так как турнир начался",
|
||||
"teams.mapsPickedStatus": "Статус выбранных карт",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@
|
|||
"looking.allTiers": "所有段位",
|
||||
"looking.joinQPrompt": "参加匹配以寻找小队",
|
||||
"looking.range.or": "或",
|
||||
"looking.range.or.explanation": "",
|
||||
"match.header": "对战 #{{number}}",
|
||||
"match.spInfo": "SP会在双方提交相同的比分后改变",
|
||||
"match.dispute.button": "分歧?",
|
||||
|
|
|
|||
|
|
@ -47,7 +47,8 @@
|
|||
"pickInfo.team.specific": "{{team}} 选择",
|
||||
"pickInfo.tiebreaker": "决胜局",
|
||||
"pickInfo.both": "双方都选择",
|
||||
"pickInfo.default": "默认地图",
|
||||
"pickInfo.default": "",
|
||||
"pickInfo.default.explanation": "",
|
||||
"pickInfo.counterpick": "反选",
|
||||
"generator.error": "由于比赛已经开始,您所更改的内容无法保存",
|
||||
"teams.mapsPickedStatus": "地图的选择情况",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user