sendou.ink/app/features/sendouq-match/core/match.server.ts
Kalle 7b71abfe53
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Tests and checks on push / run-checks-and-tests (push) Waiting to run
Updates translation progress / update-translation-progress-issue (push) Waiting to run
Migrate SQ match queries to Kysely (#2782)
2026-02-21 13:48:18 +02:00

446 lines
12 KiB
TypeScript

import * as R from "remeda";
import type { ParsedMemento, UserMapModePreferences } from "~/db/tables";
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 type {
SQMatch,
SQUncensoredGroup,
} from "~/features/sendouq/core/SendouQ.server";
import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants";
import {
BANNED_MAPS,
SENDOUQ_MAP_POOL,
} from "~/features/sendouq-settings/banned-maps";
import { modesShort } from "~/modules/in-game-lists/modes";
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";
type WeightsMap = Map<string, number>;
async function calculateMapWeights(
groupOnePreferences: UserMapModePreferences[],
groupTwoPreferences: UserMapModePreferences[],
modesIncluded: readonly 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: readonly 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;
},
groupTwo: {
preferences: { userId: number; preferences: UserMapModePreferences }[];
id: number;
},
modesIncluded: readonly ModeShort[],
): Promise<TournamentMapListMap[]> {
const weights = await calculateMapWeights(
groupOne.preferences.map((p) => p.preferences),
groupTwo.preferences.map((p) => p.preferences),
modesIncluded,
);
logger.info(
`[matchMapList] Generated map weights: ${JSON.stringify(
Array.from(weights.entries()),
)}`,
);
const generator = MapList.generate({
mapPool: new MapPool(
SENDOUQ_MAP_POOL.stageModePairs.filter((pair) =>
modesIncluded.includes(pair.mode),
),
),
initialWeights: weights,
skipEnsureMinimumCandidates: true,
});
generator.next();
const maps = generator.next({ amount: SENDOUQ_BEST_OF }).value;
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),
),
);
if (groupOnePrefers && groupTwoPrefers) {
return "BOTH";
}
if (groupOnePrefers) {
return groupOne.id;
}
if (groupTwoPrefers) {
return groupTwo.id;
}
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 result;
}
export function mapModePreferencesToModeList(
groupOnePreferences: UserMapModePreferences["modes"][],
groupTwoPreferences: UserMapModePreferences["modes"][],
): ModeShort[] {
const groupOneScores = new Map<ModeShort, number>();
const groupTwoScores = new Map<ModeShort, number>();
for (const [i, groupPrefences] of [
groupOnePreferences,
groupTwoPreferences,
].entries()) {
for (const mode of modesShort) {
const preferences = groupPrefences
.flat()
.filter((preference) => preference.mode === mode)
.map(({ preference }) => (preference === "AVOID" ? -1 : 1));
const average = averageArray(preferences.length > 0 ? preferences : [0]);
const roundedAverage = Math.round(average);
const scoresMap = i === 0 ? groupOneScores : groupTwoScores;
scoresMap.set(mode, roundedAverage);
}
}
const combinedMap = new Map<ModeShort, number>();
for (const mode of modesShort) {
const groupOneScore = groupOneScores.get(mode) ?? 0;
const groupTwoScore = groupTwoScores.get(mode) ?? 0;
const combinedScore = groupOneScore + groupTwoScore;
combinedMap.set(mode, combinedScore);
}
const result = R.shuffle(modesShort).filter((mode) => {
const score = combinedMap.get(mode)!;
// if opinion is split, don't include
return score > 0;
});
result.sort((a, b) => {
const aScore = combinedMap.get(a)!;
const bScore = combinedMap.get(b)!;
if (aScore === bScore) return 0;
return aScore > bScore ? -1 : 1;
});
if (result.length === 0) {
const bestScore = Math.max(...combinedMap.values());
const leastWorstModesResult = R.shuffle(modesShort).filter((mode) => {
// turf war never included if not positive
if (mode === "TW") return false;
const score = combinedMap.get(mode)!;
return score === bestScore;
});
// ok nevermind they are haters but really like turf war for some reason
if (leastWorstModesResult.length === 0) return ["TW"];
return leastWorstModesResult;
}
return result;
}
export function compareMatchToReportedScores({
match,
winners,
newReporterGroupId,
previousReporterGroupId,
}: {
match: Pick<SQMatch, "reportedByUserId" | "mapList"> & {
groupAlpha: { id: number };
groupBravo: { id: number };
};
winners: ("ALPHA" | "BRAVO")[];
newReporterGroupId: number;
previousReporterGroupId?: number;
}) {
// match has not been reported before
if (!match.reportedByUserId) return "FIRST_REPORT";
const sameGroupReporting = newReporterGroupId === previousReporterGroupId;
const differentConstant = sameGroupReporting ? "FIX_PREVIOUS" : "DIFFERENT";
if (
previousReporterGroupId &&
match.mapList.filter((m) => m.winnerGroupId).length !== winners.length
) {
return differentConstant;
}
for (const [
i,
{ winnerGroupId: previousWinnerGroupId },
] of match.mapList.entries()) {
const newWinner = winners[i] ?? null;
if (!newWinner && !previousWinnerGroupId) continue;
if (!newWinner && previousWinnerGroupId) return differentConstant;
if (newWinner && !previousWinnerGroupId) return differentConstant;
const previousWinner =
previousWinnerGroupId === match.groupAlpha.id ? "ALPHA" : "BRAVO";
if (previousWinner !== newWinner) return differentConstant;
}
// same group reporting the same exact score
if (sameGroupReporting) return "DUPLICATE";
return "SAME";
}
type CreateMatchMementoArgs = {
own: {
group: SQUncensoredGroup;
preferences: { userId: number; preferences: UserMapModePreferences }[];
};
their: {
group: SQUncensoredGroup;
preferences: { userId: number; preferences: UserMapModePreferences }[];
};
mapList: TournamentMapListMap[];
};
export function createMatchMemento(
args: CreateMatchMementoArgs,
): Omit<ParsedMemento, "mapPreferences"> {
const skills = userSkills(Seasons.currentOrPrevious()!.nth);
const ownWithTier = args.own.group;
const theirWithTier = args.their.group;
return {
modePreferences: modePreferencesMemento(args),
pools: poolsMemento(args),
users: Object.fromEntries(
[...args.own.group.members, ...args.their.group.members].map((member) => {
const skill = skills.userSkills[member.id];
return [
member.id,
{
plusTier: member.plusTier ?? undefined,
skill:
!skill || skill.approximate ? ("CALCULATING" as const) : skill,
},
];
}),
),
groups: Object.fromEntries(
[ownWithTier, theirWithTier].map((group) => [
group!.id,
{
tier: group!.tier!,
},
]),
),
};
}
function modePreferencesMemento(args: CreateMatchMementoArgs) {
const result: NonNullable<ParsedMemento["modePreferences"]> = {};
const modesIncluded: ModeShort[] = [];
for (const { mode } of args.mapList) {
if (!modesIncluded.includes(mode)) modesIncluded.push(mode);
}
for (const mode of modesIncluded) {
for (const { preferences, userId } of [
...args.own.preferences,
...args.their.preferences,
]) {
const hasOnlyNeutral = preferences.modes.every((m) => !m.preference);
if (hasOnlyNeutral) continue;
const found = preferences.modes.find((pref) => pref.mode === mode);
if (!result[mode]) result[mode] = [];
result[mode].push({
userId,
preference: found?.preference,
});
}
}
return result;
}
function poolsMemento(args: CreateMatchMementoArgs): ParsedMemento["pools"] {
return [...args.own.preferences, ...args.their.preferences].flatMap((p) => {
const avoidedModes = p.preferences.modes
.filter((m) => m.preference === "AVOID")
.map((m) => m.mode);
const pool = p.preferences.pool.filter(
(pool) => !avoidedModes.includes(pool.mode),
);
if (pool.length === 0) return [];
return {
userId: p.userId,
pool,
};
});
}