mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-15 23:41:53 -05:00
1425 lines
37 KiB
TypeScript
1425 lines
37 KiB
TypeScript
import { add } from "date-fns";
|
|
import type { ExpressionBuilder, NotNull, Transaction } from "kysely";
|
|
import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite";
|
|
import * as R from "remeda";
|
|
import { db } from "~/db/sql";
|
|
import type { DB, ParsedMemento } from "~/db/tables";
|
|
import * as Seasons from "~/features/mmr/core/Seasons";
|
|
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
|
import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types";
|
|
import { mostPopularArrayElement } from "~/utils/arrays";
|
|
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
|
import { shortNanoid } from "~/utils/id";
|
|
import invariant from "~/utils/invariant";
|
|
import {
|
|
COMMON_USER_FIELDS,
|
|
concatUserSubmittedImagePrefix,
|
|
tournamentLogoWithDefault,
|
|
} from "~/utils/kysely.server";
|
|
import { errorIsSqliteUniqueConstraintFailure } from "~/utils/sql";
|
|
import type { Unpacked } from "~/utils/types";
|
|
import { FULL_GROUP_SIZE } from "../sendouq/q-constants";
|
|
import { SendouQError } from "../sendouq/q-utils.server";
|
|
import * as SQGroupRepository from "../sendouq/SQGroupRepository.server";
|
|
import { MATCHES_PER_SEASONS_PAGE } from "../user-page/user-page-constants";
|
|
import { compareMatchToReportedScores } from "./core/match.server";
|
|
import { mergeReportedWeapons } from "./core/reported-weapons.server";
|
|
import * as SendouQMatch from "./core/SendouQMatch";
|
|
import { calculateMatchSkills } from "./core/skills.server";
|
|
import {
|
|
summarizeMaps,
|
|
summarizePlayerResults,
|
|
} from "./core/summarizer.server";
|
|
import * as PlayerStatRepository from "./PlayerStatRepository.server";
|
|
import { winnersArrayToWinner } from "./q-match-utils";
|
|
import * as ReportedWeaponRepository from "./ReportedWeaponRepository.server";
|
|
import * as SkillRepository from "./SkillRepository.server";
|
|
|
|
export async function findById(id: number) {
|
|
const result = await db
|
|
.selectFrom("GroupMatch")
|
|
.select(({ exists, selectFrom, eb }) => [
|
|
"GroupMatch.id",
|
|
"GroupMatch.createdAt",
|
|
"GroupMatch.confirmedAt",
|
|
"GroupMatch.confirmedByUserId",
|
|
"GroupMatch.chatCode",
|
|
"GroupMatch.memento",
|
|
"GroupMatch.cancelRequestedByUserId",
|
|
"GroupMatch.cancelAcceptedByUserId",
|
|
|
|
exists(
|
|
selectFrom("Skill")
|
|
.select("Skill.id")
|
|
.where("Skill.groupMatchId", "=", id),
|
|
).as("isLocked"),
|
|
exists(
|
|
selectFrom("Skill")
|
|
.select("Skill.id")
|
|
.where("Skill.groupMatchId", "=", id)
|
|
.where("Skill.season", "=", -1),
|
|
).as("isCanceled"),
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("GroupMatchMap")
|
|
.select([
|
|
"GroupMatchMap.id",
|
|
"GroupMatchMap.mode",
|
|
"GroupMatchMap.stageId",
|
|
"GroupMatchMap.source",
|
|
"GroupMatchMap.winnerGroupId",
|
|
"GroupMatchMap.reportedAt",
|
|
"GroupMatchMap.reportedByUserId",
|
|
])
|
|
.where("GroupMatchMap.matchId", "=", id)
|
|
.orderBy("GroupMatchMap.index", "asc"),
|
|
).as("mapList"),
|
|
groupWithTeamAndMembers(eb, "GroupMatch.alphaGroupId").as("groupAlpha"),
|
|
groupWithTeamAndMembers(eb, "GroupMatch.bravoGroupId").as("groupBravo"),
|
|
])
|
|
.where("GroupMatch.id", "=", id)
|
|
.$narrowType<{
|
|
groupAlpha: NotNull;
|
|
groupBravo: NotNull;
|
|
}>()
|
|
.executeTakeFirst();
|
|
|
|
if (!result) return null;
|
|
|
|
invariant(result.groupAlpha, `Group alpha not found for match ${id}`);
|
|
invariant(result.groupBravo, `Group bravo not found for match ${id}`);
|
|
|
|
return result;
|
|
}
|
|
|
|
function groupWithTeamAndMembers(
|
|
eb: ExpressionBuilder<DB, "GroupMatch">,
|
|
groupIdRef: "GroupMatch.alphaGroupId" | "GroupMatch.bravoGroupId",
|
|
) {
|
|
return jsonObjectFrom(
|
|
eb
|
|
.selectFrom("Group")
|
|
.select(({ eb }) => [
|
|
"Group.id",
|
|
"Group.chatCode",
|
|
"Group.matchmade",
|
|
jsonObjectFrom(
|
|
eb
|
|
.selectFrom("AllTeam")
|
|
.leftJoin(
|
|
"UserSubmittedImage",
|
|
"AllTeam.avatarImgId",
|
|
"UserSubmittedImage.id",
|
|
)
|
|
.select((eb) => [
|
|
"AllTeam.id",
|
|
"AllTeam.name",
|
|
"AllTeam.customUrl",
|
|
concatUserSubmittedImagePrefix(
|
|
eb.ref("UserSubmittedImage.url"),
|
|
).as("avatarUrl"),
|
|
])
|
|
.where("AllTeam.id", "=", eb.ref("Group.teamId")),
|
|
).as("team"),
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("GroupMember")
|
|
.innerJoin("User", "User.id", "GroupMember.userId")
|
|
.leftJoin("PlusTier", "User.id", "PlusTier.userId")
|
|
.leftJoin("GroupMatchContinueVote", (join) =>
|
|
join
|
|
.onRef(
|
|
"GroupMember.userId",
|
|
"=",
|
|
"GroupMatchContinueVote.userId",
|
|
)
|
|
.onRef(
|
|
"GroupMember.groupId",
|
|
"=",
|
|
"GroupMatchContinueVote.groupId",
|
|
),
|
|
)
|
|
.select((arrayEb) => [
|
|
...COMMON_USER_FIELDS,
|
|
"GroupMember.role",
|
|
"GroupMember.note",
|
|
"User.inGameName",
|
|
"User.pronouns",
|
|
"User.vc",
|
|
"User.languages",
|
|
"User.noScreen",
|
|
"User.qWeaponPool as weapons",
|
|
"User.mapModePreferences",
|
|
"PlusTier.tier as plusTier",
|
|
"GroupMatchContinueVote.isContinuing",
|
|
arrayEb
|
|
.selectFrom("UserFriendCode")
|
|
.select("UserFriendCode.friendCode")
|
|
.whereRef("UserFriendCode.userId", "=", "User.id")
|
|
.orderBy("UserFriendCode.createdAt", "desc")
|
|
.limit(1)
|
|
.as("friendCode"),
|
|
])
|
|
.whereRef("GroupMember.groupId", "=", groupIdRef)
|
|
.orderBy("GroupMember.userId", "asc"),
|
|
).as("members"),
|
|
])
|
|
.where("Group.id", "=", eb.ref(groupIdRef)),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Retrieves the pages count of results for a specific user and season. Counting both SendouQ matches and ranked tournaments.
|
|
*/
|
|
export async function seasonResultPagesByUserId({
|
|
userId,
|
|
season,
|
|
}: {
|
|
userId: number;
|
|
season: number;
|
|
}): Promise<number> {
|
|
const row = await db
|
|
.selectFrom("Skill")
|
|
.select(({ fn }) => [fn.countAll().as("count")])
|
|
.where("userId", "=", userId)
|
|
.where("season", "=", season)
|
|
.where(({ or, eb }) =>
|
|
or([
|
|
eb("groupMatchId", "is not", null),
|
|
eb("tournamentId", "is not", null),
|
|
]),
|
|
)
|
|
.executeTakeFirstOrThrow();
|
|
|
|
return Math.ceil((row.count as number) / MATCHES_PER_SEASONS_PAGE);
|
|
}
|
|
|
|
const tournamentResultsSubQuery = (
|
|
eb: ExpressionBuilder<DB, "Skill">,
|
|
userId: number,
|
|
) =>
|
|
eb
|
|
.selectFrom("TournamentResult")
|
|
.innerJoin(
|
|
"CalendarEvent",
|
|
"TournamentResult.tournamentId",
|
|
"CalendarEvent.tournamentId",
|
|
)
|
|
.innerJoin(
|
|
"CalendarEventDate",
|
|
"CalendarEvent.id",
|
|
"CalendarEventDate.eventId",
|
|
)
|
|
.select((eb) => [
|
|
"TournamentResult.spDiff",
|
|
"TournamentResult.setResults",
|
|
"TournamentResult.tournamentId",
|
|
"TournamentResult.tournamentTeamId",
|
|
"CalendarEventDate.startTime as tournamentStartTime",
|
|
"CalendarEvent.name as tournamentName",
|
|
tournamentLogoWithDefault(eb).as("logoUrl"),
|
|
])
|
|
.whereRef("TournamentResult.tournamentId", "=", "Skill.tournamentId")
|
|
.where("TournamentResult.userId", "=", userId);
|
|
|
|
const groupMatchResultsSubQuery = (eb: ExpressionBuilder<DB, "Skill">) => {
|
|
const groupMembersSubQuery = (
|
|
eb: ExpressionBuilder<DB, "GroupMatch">,
|
|
side: "alpha" | "bravo",
|
|
) =>
|
|
jsonArrayFrom(
|
|
eb
|
|
.selectFrom("GroupMember")
|
|
.innerJoin("User", "GroupMember.userId", "User.id")
|
|
.select([...COMMON_USER_FIELDS])
|
|
.whereRef(
|
|
"GroupMember.groupId",
|
|
"=",
|
|
side === "alpha"
|
|
? "GroupMatch.alphaGroupId"
|
|
: "GroupMatch.bravoGroupId",
|
|
),
|
|
);
|
|
|
|
return eb
|
|
.selectFrom("GroupMatch")
|
|
.select((innerEb) => [
|
|
"GroupMatch.id",
|
|
"GroupMatch.memento",
|
|
"GroupMatch.createdAt",
|
|
"GroupMatch.alphaGroupId",
|
|
"GroupMatch.bravoGroupId",
|
|
groupMembersSubQuery(innerEb, "alpha").as("groupAlphaMembers"),
|
|
groupMembersSubQuery(innerEb, "bravo").as("groupBravoMembers"),
|
|
jsonArrayFrom(
|
|
innerEb
|
|
.selectFrom("GroupMatchMap")
|
|
.select((innerEb2) => [
|
|
"GroupMatchMap.winnerGroupId",
|
|
jsonArrayFrom(
|
|
innerEb2
|
|
.selectFrom("ReportedWeapon")
|
|
.select(["ReportedWeapon.userId", "ReportedWeapon.weaponSplId"])
|
|
.whereRef(
|
|
"ReportedWeapon.groupMatchMapId",
|
|
"=",
|
|
"GroupMatchMap.id",
|
|
),
|
|
).as("weapons"),
|
|
])
|
|
.whereRef("GroupMatchMap.matchId", "=", "GroupMatch.id"),
|
|
).as("maps"),
|
|
])
|
|
.whereRef("Skill.groupMatchId", "=", "GroupMatch.id");
|
|
};
|
|
|
|
export type SeasonGroupMatch = Extract<
|
|
Unpacked<Unpacked<ReturnType<typeof seasonResultsByUserId>>>,
|
|
{ type: "GROUP_MATCH" }
|
|
>["groupMatch"];
|
|
|
|
export type SeasonTournamentResult = Extract<
|
|
Unpacked<Unpacked<ReturnType<typeof seasonResultsByUserId>>>,
|
|
{ type: "TOURNAMENT_RESULT" }
|
|
>["tournamentResult"];
|
|
|
|
/**
|
|
* Retrieves results of given user, competitive season & page. Both SendouQ matches and ranked tournaments.
|
|
*/
|
|
export async function seasonResultsByUserId({
|
|
userId,
|
|
season,
|
|
page = 1,
|
|
}: {
|
|
userId: number;
|
|
season: number;
|
|
page: number;
|
|
}) {
|
|
const rows = await db
|
|
.selectFrom("Skill")
|
|
.select((eb) => [
|
|
"Skill.id",
|
|
"Skill.createdAt",
|
|
jsonObjectFrom(tournamentResultsSubQuery(eb, userId)).as(
|
|
"tournamentResult",
|
|
),
|
|
jsonObjectFrom(groupMatchResultsSubQuery(eb)).as("groupMatch"),
|
|
])
|
|
.where("userId", "=", userId)
|
|
.where("season", "=", season)
|
|
.where(({ or, eb }) =>
|
|
or([
|
|
eb("groupMatchId", "is not", null),
|
|
eb("tournamentId", "is not", null),
|
|
]),
|
|
)
|
|
.limit(MATCHES_PER_SEASONS_PAGE)
|
|
.offset(MATCHES_PER_SEASONS_PAGE * (page - 1))
|
|
.orderBy("Skill.id", "desc")
|
|
.execute();
|
|
|
|
return rows
|
|
.map((row) => {
|
|
if (row.groupMatch) {
|
|
const skillDiff =
|
|
row.groupMatch?.memento?.users[userId]?.skillDifference;
|
|
|
|
const chooseMostPopularWeapon = (userId: number) => {
|
|
const weaponSplIds = row
|
|
.groupMatch!.maps.flatMap((map) => map.weapons)
|
|
.filter((w) => w.userId === userId)
|
|
.map((w) => w.weaponSplId);
|
|
|
|
return mostPopularArrayElement(weaponSplIds);
|
|
};
|
|
|
|
return {
|
|
type: "GROUP_MATCH" as const,
|
|
...R.omit(row, ["groupMatch", "tournamentResult"]),
|
|
// older skills don't have createdAt, so we use groupMatch's createdAt as fallback
|
|
createdAt: row.createdAt ?? row.groupMatch.createdAt,
|
|
groupMatch: {
|
|
...R.omit(row.groupMatch, ["createdAt", "memento", "maps"]),
|
|
// note there is no corresponding "censoring logic" for tournament result
|
|
// because for those the sp diff is not inserted in the first place
|
|
// if it should not be shown to the user
|
|
spDiff: skillDiff?.calculated ? skillDiff.spDiff : null,
|
|
groupAlphaMembers: row.groupMatch.groupAlphaMembers.map((m) => ({
|
|
...m,
|
|
weaponSplId: chooseMostPopularWeapon(m.id),
|
|
})),
|
|
groupBravoMembers: row.groupMatch.groupBravoMembers.map((m) => ({
|
|
...m,
|
|
weaponSplId: chooseMostPopularWeapon(m.id),
|
|
})),
|
|
score: row.groupMatch.maps.reduce(
|
|
(acc, cur) => [
|
|
acc[0] +
|
|
(cur.winnerGroupId === row.groupMatch!.alphaGroupId ? 1 : 0),
|
|
acc[1] +
|
|
(cur.winnerGroupId === row.groupMatch!.bravoGroupId ? 1 : 0),
|
|
],
|
|
[0, 0],
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
if (row.tournamentResult) {
|
|
return {
|
|
type: "TOURNAMENT_RESULT" as const,
|
|
...R.omit(row, ["groupMatch", "tournamentResult"]),
|
|
// older skills don't have createdAt, so we use tournament's start time as a fallback
|
|
createdAt: row.createdAt ?? row.tournamentResult.tournamentStartTime,
|
|
tournamentResult: row.tournamentResult,
|
|
};
|
|
}
|
|
|
|
// Skills from dropped teams without tournament results - skip these
|
|
return null;
|
|
})
|
|
.filter((result) => result !== null);
|
|
}
|
|
|
|
export async function seasonCanceledMatchesByUserId({
|
|
userId,
|
|
season,
|
|
}: {
|
|
userId: number;
|
|
season: number;
|
|
}) {
|
|
const { starts, ends } = Seasons.nthToDateRange(season);
|
|
|
|
return db
|
|
.selectFrom("GroupMember")
|
|
.innerJoin("Group", "GroupMember.groupId", "Group.id")
|
|
.innerJoin("GroupMatch", (join) =>
|
|
join.on((eb) =>
|
|
eb.or([
|
|
eb("GroupMatch.alphaGroupId", "=", eb.ref("Group.id")),
|
|
eb("GroupMatch.bravoGroupId", "=", eb.ref("Group.id")),
|
|
]),
|
|
),
|
|
)
|
|
.innerJoin("Skill", (join) =>
|
|
join
|
|
.onRef("GroupMatch.id", "=", "Skill.groupMatchId")
|
|
// dummy skills used to close match when it's canceled have season -1
|
|
.on("Skill.season", "=", -1),
|
|
)
|
|
.select(["GroupMatch.id", "GroupMatch.createdAt"])
|
|
.where("GroupMember.userId", "=", userId)
|
|
.where("GroupMatch.createdAt", ">=", dateToDatabaseTimestamp(starts))
|
|
.where(
|
|
"GroupMatch.createdAt",
|
|
"<=",
|
|
dateToDatabaseTimestamp(add(ends, { days: 1 })),
|
|
)
|
|
.orderBy("GroupMatch.createdAt", "desc")
|
|
.execute();
|
|
}
|
|
|
|
export function create({
|
|
alphaGroupId,
|
|
bravoGroupId,
|
|
mapList,
|
|
memento,
|
|
}: {
|
|
alphaGroupId: number;
|
|
bravoGroupId: number;
|
|
mapList: TournamentMapListMap[];
|
|
memento: ParsedMemento;
|
|
}) {
|
|
return db.transaction().execute(async (trx) => {
|
|
const match = await trx
|
|
.insertInto("GroupMatch")
|
|
.values({
|
|
alphaGroupId,
|
|
bravoGroupId,
|
|
chatCode: shortNanoid(),
|
|
memento: JSON.stringify(memento),
|
|
})
|
|
.returningAll()
|
|
.executeTakeFirstOrThrow()
|
|
.catch((error) => {
|
|
// race: another manager matched one of the two groups first, tripping the
|
|
// unique constraint on GroupMatch.alphaGroupId / bravoGroupId
|
|
if (errorIsSqliteUniqueConstraintFailure(error)) {
|
|
throw new SendouQError("Group is already in a match");
|
|
}
|
|
throw error;
|
|
});
|
|
|
|
await trx
|
|
.insertInto("GroupMatchMap")
|
|
.values(
|
|
mapList.map((map, i) => ({
|
|
matchId: match.id,
|
|
index: i,
|
|
mode: map.mode,
|
|
stageId: map.stageId,
|
|
source: String(map.source),
|
|
})),
|
|
)
|
|
.execute();
|
|
|
|
await syncGroupTeamId(alphaGroupId, trx);
|
|
await syncGroupTeamId(bravoGroupId, trx);
|
|
|
|
await validateCreatedMatch(trx, alphaGroupId, bravoGroupId);
|
|
|
|
return match;
|
|
});
|
|
}
|
|
|
|
async function syncGroupTeamId(groupId: number, trx: Transaction<DB>) {
|
|
const members = await trx
|
|
.selectFrom("GroupMember")
|
|
.leftJoin(
|
|
"TeamMemberWithSecondary",
|
|
"TeamMemberWithSecondary.userId",
|
|
"GroupMember.userId",
|
|
)
|
|
.select(["TeamMemberWithSecondary.teamId"])
|
|
.where("GroupMember.groupId", "=", groupId)
|
|
.execute();
|
|
|
|
const teamIds = members.map((m) => m.teamId).filter((id) => id !== null);
|
|
|
|
const counts = new Map<number, number>();
|
|
|
|
for (const teamId of teamIds) {
|
|
const newCount = (counts.get(teamId) ?? 0) + 1;
|
|
if (newCount === 4) {
|
|
await trx
|
|
.updateTable("Group")
|
|
.set({ teamId })
|
|
.where("id", "=", groupId)
|
|
.execute();
|
|
return;
|
|
}
|
|
|
|
counts.set(teamId, newCount);
|
|
}
|
|
|
|
await trx
|
|
.updateTable("Group")
|
|
.set({ teamId: null })
|
|
.where("id", "=", groupId)
|
|
.execute();
|
|
}
|
|
|
|
async function validateCreatedMatch(
|
|
trx: Transaction<DB>,
|
|
alphaGroupId: number,
|
|
bravoGroupId: number,
|
|
) {
|
|
for (const groupId of [alphaGroupId, bravoGroupId]) {
|
|
const members = await trx
|
|
.selectFrom("GroupMember")
|
|
.select("GroupMember.userId")
|
|
.where("GroupMember.groupId", "=", groupId)
|
|
.execute();
|
|
|
|
if (members.length !== FULL_GROUP_SIZE) {
|
|
throw new Error(`Group ${groupId} does not have full group members`);
|
|
}
|
|
|
|
const matches = await trx
|
|
.selectFrom("GroupMatch")
|
|
.select("GroupMatch.id")
|
|
.where((eb) =>
|
|
eb.or([
|
|
eb("GroupMatch.alphaGroupId", "=", groupId),
|
|
eb("GroupMatch.bravoGroupId", "=", groupId),
|
|
]),
|
|
)
|
|
.execute();
|
|
|
|
if (matches.length !== 1) {
|
|
throw new Error(`Group ${groupId} is already in a match`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export async function updateScore(
|
|
{
|
|
matchId,
|
|
reportedByUserId,
|
|
winners,
|
|
}: {
|
|
matchId: number;
|
|
reportedByUserId: number;
|
|
winners: ("ALPHA" | "BRAVO")[];
|
|
},
|
|
trx?: Transaction<DB>,
|
|
) {
|
|
const executor = trx ?? db;
|
|
const reportedAt = dateToDatabaseTimestamp(new Date());
|
|
|
|
const match = await executor
|
|
.selectFrom("GroupMatch")
|
|
.select(["alphaGroupId", "bravoGroupId"])
|
|
.where("id", "=", matchId)
|
|
.executeTakeFirstOrThrow();
|
|
|
|
await executor
|
|
.updateTable("GroupMatchMap")
|
|
.set({ winnerGroupId: null, reportedAt, reportedByUserId })
|
|
.where("matchId", "=", matchId)
|
|
.execute();
|
|
|
|
for (const [index, winner] of winners.entries()) {
|
|
await executor
|
|
.updateTable("GroupMatchMap")
|
|
.set({
|
|
winnerGroupId:
|
|
winner === "ALPHA" ? match.alphaGroupId : match.bravoGroupId,
|
|
})
|
|
.where("matchId", "=", matchId)
|
|
.where("index", "=", index)
|
|
.execute();
|
|
}
|
|
}
|
|
|
|
export function lockMatchWithoutSkillChange(
|
|
groupMatchId: number,
|
|
trx?: Transaction<DB>,
|
|
) {
|
|
return (trx ?? db)
|
|
.insertInto("Skill")
|
|
.values({
|
|
groupMatchId,
|
|
identifier: null,
|
|
mu: -1,
|
|
season: -1,
|
|
sigma: -1,
|
|
ordinal: -1,
|
|
userId: null,
|
|
matchesCount: 0,
|
|
})
|
|
.execute();
|
|
}
|
|
|
|
export type ReportScoreResult =
|
|
| { status: "REPORTED"; shouldRefreshCaches: false }
|
|
| { status: "CONFIRMED"; shouldRefreshCaches: true }
|
|
| { status: "DIFFERENT"; shouldRefreshCaches: false }
|
|
| { status: "DUPLICATE"; shouldRefreshCaches: false };
|
|
|
|
export type CancelMatchResult =
|
|
| { status: "CANCEL_REPORTED"; shouldRefreshCaches: false }
|
|
| { status: "CANCEL_CONFIRMED"; shouldRefreshCaches: true }
|
|
| { status: "CANT_CANCEL"; shouldRefreshCaches: false }
|
|
| { status: "DUPLICATE"; shouldRefreshCaches: false };
|
|
|
|
type WeaponInput = {
|
|
groupMatchMapId: number;
|
|
weaponSplId: MainWeaponId;
|
|
userId: number;
|
|
mapIndex: number;
|
|
};
|
|
|
|
export async function adminReport({
|
|
matchId,
|
|
reportedByUserId,
|
|
winners,
|
|
}: {
|
|
matchId: number;
|
|
reportedByUserId: number;
|
|
winners: ("ALPHA" | "BRAVO")[];
|
|
}): Promise<void> {
|
|
const match = await findById(matchId);
|
|
invariant(match, "Match not found");
|
|
|
|
const members = buildMembers(match);
|
|
const winner = winnersArrayToWinner(winners);
|
|
const winnerGroupId =
|
|
winner === "ALPHA" ? match.groupAlpha.id : match.groupBravo.id;
|
|
const loserGroupId =
|
|
winner === "ALPHA" ? match.groupBravo.id : match.groupAlpha.id;
|
|
|
|
const { newSkills, differences } = calculateMatchSkills({
|
|
groupMatchId: match.id,
|
|
winner: (match.groupAlpha.id === winnerGroupId
|
|
? match.groupAlpha
|
|
: match.groupBravo
|
|
).members.map((m) => m.id),
|
|
loser: (match.groupAlpha.id === loserGroupId
|
|
? match.groupAlpha
|
|
: match.groupBravo
|
|
).members.map((m) => m.id),
|
|
winnerGroupId,
|
|
loserGroupId,
|
|
});
|
|
|
|
await db.transaction().execute(async (trx) => {
|
|
await updateScore({ matchId, reportedByUserId, winners }, trx);
|
|
await SQGroupRepository.setAsInactive(match.groupAlpha.id, trx);
|
|
await SQGroupRepository.setAsInactive(match.groupBravo.id, trx);
|
|
await PlayerStatRepository.upsertMapResults(
|
|
summarizeMaps({ match, members, winners }),
|
|
trx,
|
|
);
|
|
await PlayerStatRepository.upsertPlayerResults(
|
|
summarizePlayerResults({ match, members, winners }),
|
|
trx,
|
|
);
|
|
await SkillRepository.createMatchSkills(
|
|
{
|
|
skills: newSkills,
|
|
differences,
|
|
groupMatchId: match.id,
|
|
oldMatchMemento: match.memento,
|
|
},
|
|
trx,
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function reportScore({
|
|
matchId,
|
|
reportedByUserId,
|
|
winners,
|
|
weapons,
|
|
}: {
|
|
matchId: number;
|
|
reportedByUserId: number;
|
|
winners: ("ALPHA" | "BRAVO")[];
|
|
weapons: WeaponInput[];
|
|
}): Promise<ReportScoreResult> {
|
|
const match = await findById(matchId);
|
|
invariant(match, "Match not found");
|
|
|
|
const members = buildMembers(match);
|
|
const reporterGroupId = members.find(
|
|
(m) => m.id === reportedByUserId,
|
|
)?.groupId;
|
|
invariant(reporterGroupId, "Reporter is not a member of any group");
|
|
|
|
const previousReporterGroupId = lastReporterGroupId(match, members);
|
|
|
|
const compared = compareMatchToReportedScores({
|
|
match,
|
|
winners,
|
|
newReporterGroupId: reporterGroupId,
|
|
previousReporterGroupId,
|
|
});
|
|
|
|
const oldReportedWeapons =
|
|
(await ReportedWeaponRepository.findByMatchId(matchId)) ?? [];
|
|
const mergedWeapons = mergeReportedWeapons({
|
|
oldWeapons: oldReportedWeapons,
|
|
newWeapons: weapons,
|
|
newReportedMapsCount: winners.length,
|
|
});
|
|
const weaponsForDb = mergedWeapons.map((w) => ({
|
|
groupMatchMapId: w.groupMatchMapId,
|
|
userId: w.userId,
|
|
weaponSplId: w.weaponSplId,
|
|
}));
|
|
|
|
if (compared === "DUPLICATE") {
|
|
await ReportedWeaponRepository.replaceByMatchId(matchId, weaponsForDb);
|
|
return { status: "DUPLICATE", shouldRefreshCaches: false };
|
|
}
|
|
|
|
if (compared === "DIFFERENT") {
|
|
await SQGroupRepository.setAsInactive(reporterGroupId);
|
|
return { status: "DIFFERENT", shouldRefreshCaches: false };
|
|
}
|
|
|
|
if (compared === "FIRST_REPORT") {
|
|
await db.transaction().execute(async (trx) => {
|
|
await updateScore({ matchId, reportedByUserId, winners }, trx);
|
|
await SQGroupRepository.setAsInactive(reporterGroupId, trx);
|
|
if (weaponsForDb.length > 0) {
|
|
await ReportedWeaponRepository.createMany(weaponsForDb, trx);
|
|
}
|
|
});
|
|
return { status: "REPORTED", shouldRefreshCaches: false };
|
|
}
|
|
|
|
if (compared === "FIX_PREVIOUS") {
|
|
await db.transaction().execute(async (trx) => {
|
|
await updateScore({ matchId, reportedByUserId, winners }, trx);
|
|
await ReportedWeaponRepository.replaceByMatchId(
|
|
matchId,
|
|
weaponsForDb,
|
|
trx,
|
|
);
|
|
});
|
|
return { status: "REPORTED", shouldRefreshCaches: false };
|
|
}
|
|
|
|
const winner = winnersArrayToWinner(winners);
|
|
const winnerGroupId =
|
|
winner === "ALPHA" ? match.groupAlpha.id : match.groupBravo.id;
|
|
const loserGroupId =
|
|
winner === "ALPHA" ? match.groupBravo.id : match.groupAlpha.id;
|
|
|
|
const { newSkills, differences } = calculateMatchSkills({
|
|
groupMatchId: match.id,
|
|
winner: (match.groupAlpha.id === winnerGroupId
|
|
? match.groupAlpha
|
|
: match.groupBravo
|
|
).members.map((m) => m.id),
|
|
loser: (match.groupAlpha.id === loserGroupId
|
|
? match.groupAlpha
|
|
: match.groupBravo
|
|
).members.map((m) => m.id),
|
|
winnerGroupId,
|
|
loserGroupId,
|
|
});
|
|
|
|
await db.transaction().execute(async (trx) => {
|
|
await SQGroupRepository.setAsInactive(reporterGroupId, trx);
|
|
await PlayerStatRepository.upsertMapResults(
|
|
summarizeMaps({ match, members, winners }),
|
|
trx,
|
|
);
|
|
await PlayerStatRepository.upsertPlayerResults(
|
|
summarizePlayerResults({ match, members, winners }),
|
|
trx,
|
|
);
|
|
await SkillRepository.createMatchSkills(
|
|
{
|
|
skills: newSkills,
|
|
differences,
|
|
groupMatchId: match.id,
|
|
oldMatchMemento: match.memento,
|
|
},
|
|
trx,
|
|
);
|
|
await ReportedWeaponRepository.replaceByMatchId(matchId, weaponsForDb, trx);
|
|
});
|
|
|
|
return { status: "CONFIRMED", shouldRefreshCaches: true };
|
|
}
|
|
|
|
export async function cancelMatch({
|
|
matchId,
|
|
reportedByUserId,
|
|
isAdminReport,
|
|
}: {
|
|
matchId: number;
|
|
reportedByUserId: number;
|
|
isAdminReport?: boolean;
|
|
}): Promise<CancelMatchResult> {
|
|
const match = await findById(matchId);
|
|
invariant(match, "Match not found");
|
|
|
|
if (isAdminReport) {
|
|
await db.transaction().execute(async (trx) => {
|
|
await updateScore({ matchId, reportedByUserId, winners: [] }, trx);
|
|
await SQGroupRepository.setAsInactive(match.groupAlpha.id, trx);
|
|
await SQGroupRepository.setAsInactive(match.groupBravo.id, trx);
|
|
await lockMatchWithoutSkillChange(match.id, trx);
|
|
});
|
|
return { status: "CANCEL_CONFIRMED", shouldRefreshCaches: true };
|
|
}
|
|
|
|
const members = buildMembers(match);
|
|
const reporterGroupId = members.find(
|
|
(m) => m.id === reportedByUserId,
|
|
)?.groupId;
|
|
invariant(reporterGroupId, "Reporter is not a member of any group");
|
|
|
|
const previousReporterGroupId = lastReporterGroupId(match, members);
|
|
|
|
const compared = compareMatchToReportedScores({
|
|
match,
|
|
winners: [],
|
|
newReporterGroupId: reporterGroupId,
|
|
previousReporterGroupId,
|
|
});
|
|
|
|
if (compared === "DUPLICATE") {
|
|
return { status: "DUPLICATE", shouldRefreshCaches: false };
|
|
}
|
|
|
|
if (compared === "DIFFERENT") {
|
|
await SQGroupRepository.setAsInactive(reporterGroupId);
|
|
return { status: "CANT_CANCEL", shouldRefreshCaches: false };
|
|
}
|
|
|
|
if (compared === "FIRST_REPORT" || compared === "FIX_PREVIOUS") {
|
|
await db.transaction().execute(async (trx) => {
|
|
await updateScore({ matchId, reportedByUserId, winners: [] }, trx);
|
|
await SQGroupRepository.setAsInactive(reporterGroupId, trx);
|
|
if (compared === "FIX_PREVIOUS") {
|
|
await ReportedWeaponRepository.replaceByMatchId(matchId, [], trx);
|
|
}
|
|
});
|
|
return { status: "CANCEL_REPORTED", shouldRefreshCaches: false };
|
|
}
|
|
|
|
await db.transaction().execute(async (trx) => {
|
|
await SQGroupRepository.setAsInactive(reporterGroupId, trx);
|
|
await lockMatchWithoutSkillChange(match.id, trx);
|
|
});
|
|
return { status: "CANCEL_CONFIRMED", shouldRefreshCaches: true };
|
|
}
|
|
|
|
export type RequestCancelResult =
|
|
| { status: "REQUESTED" }
|
|
| { status: "ALREADY_LOCKED" }
|
|
| { status: "ALREADY_REQUESTED" };
|
|
|
|
export async function requestCancelMatch({
|
|
matchId,
|
|
requestedByUserId,
|
|
}: {
|
|
matchId: number;
|
|
requestedByUserId: number;
|
|
}): Promise<RequestCancelResult> {
|
|
const match = await findById(matchId);
|
|
invariant(match, "Match not found");
|
|
|
|
if (match.isLocked) {
|
|
return { status: "ALREADY_LOCKED" };
|
|
}
|
|
|
|
if (match.cancelRequestedByUserId) {
|
|
return { status: "ALREADY_REQUESTED" };
|
|
}
|
|
|
|
await db
|
|
.updateTable("GroupMatch")
|
|
.set({ cancelRequestedByUserId: requestedByUserId })
|
|
.where("id", "=", matchId)
|
|
.execute();
|
|
|
|
return { status: "REQUESTED" };
|
|
}
|
|
|
|
export type AcceptCancelResult =
|
|
| { status: "ACCEPTED" }
|
|
| { status: "ALREADY_LOCKED" }
|
|
| { status: "NO_CANCEL_REQUEST" }
|
|
| { status: "NOT_ALLOWED" };
|
|
|
|
export async function acceptCancelMatch({
|
|
matchId,
|
|
acceptedByUserId,
|
|
}: {
|
|
matchId: number;
|
|
acceptedByUserId: number;
|
|
}): Promise<AcceptCancelResult> {
|
|
const match = await findById(matchId);
|
|
invariant(match, "Match not found");
|
|
|
|
if (match.isLocked) {
|
|
return { status: "ALREADY_LOCKED" };
|
|
}
|
|
|
|
if (!match.cancelRequestedByUserId) {
|
|
return { status: "NO_CANCEL_REQUEST" };
|
|
}
|
|
|
|
const members = buildMembers(match);
|
|
const requesterGroupId = members.find(
|
|
(m) => m.id === match.cancelRequestedByUserId,
|
|
)?.groupId;
|
|
invariant(requesterGroupId, "Requester is not a member of any group");
|
|
|
|
const accepterGroupId = members.find(
|
|
(m) => m.id === acceptedByUserId,
|
|
)?.groupId;
|
|
invariant(accepterGroupId, "Accepter is not a member of any group");
|
|
|
|
if (accepterGroupId === requesterGroupId) {
|
|
return { status: "NOT_ALLOWED" };
|
|
}
|
|
|
|
await db.transaction().execute(async (trx) => {
|
|
await SQGroupRepository.setAsInactive(requesterGroupId, trx);
|
|
await SQGroupRepository.setAsInactive(accepterGroupId, trx);
|
|
await lockMatchWithoutSkillChange(match.id, trx);
|
|
await trx
|
|
.updateTable("GroupMatch")
|
|
.set({ cancelAcceptedByUserId: acceptedByUserId })
|
|
.where("id", "=", matchId)
|
|
.execute();
|
|
});
|
|
|
|
return { status: "ACCEPTED" };
|
|
}
|
|
|
|
export type RefuseCancelResult =
|
|
| { status: "REFUSED" }
|
|
| { status: "ALREADY_LOCKED" }
|
|
| { status: "NO_CANCEL_REQUEST" }
|
|
| { status: "NOT_ALLOWED" };
|
|
|
|
export async function refuseCancelMatch({
|
|
matchId,
|
|
refusedByUserId,
|
|
}: {
|
|
matchId: number;
|
|
refusedByUserId: number;
|
|
}): Promise<RefuseCancelResult> {
|
|
const match = await findById(matchId);
|
|
invariant(match, "Match not found");
|
|
|
|
if (match.isLocked) {
|
|
return { status: "ALREADY_LOCKED" };
|
|
}
|
|
|
|
if (!match.cancelRequestedByUserId) {
|
|
return { status: "NO_CANCEL_REQUEST" };
|
|
}
|
|
|
|
const members = buildMembers(match);
|
|
const requesterGroupId = members.find(
|
|
(m) => m.id === match.cancelRequestedByUserId,
|
|
)?.groupId;
|
|
const refuserGroupId = members.find((m) => m.id === refusedByUserId)?.groupId;
|
|
invariant(refuserGroupId, "Refuser is not a member of any group");
|
|
|
|
if (refuserGroupId === requesterGroupId) {
|
|
return { status: "NOT_ALLOWED" };
|
|
}
|
|
|
|
await db
|
|
.updateTable("GroupMatch")
|
|
.set({ cancelRequestedByUserId: null })
|
|
.where("id", "=", matchId)
|
|
.execute();
|
|
|
|
return { status: "REFUSED" };
|
|
}
|
|
|
|
export type ReportMapWinnerResult =
|
|
| { status: "MAP_REPORTED" }
|
|
| { status: "MATCH_REPORTED" }
|
|
| { status: "MATCH_FINALIZED" }
|
|
| { status: "ALREADY_LOCKED" }
|
|
| { status: "INVALID_WINNER" }
|
|
| { status: "SCORE_DISAGREEMENT" }
|
|
| { status: "STALE" };
|
|
|
|
export async function reportMapWinner({
|
|
matchId,
|
|
winnerId,
|
|
reportedByUserId,
|
|
reportedCount,
|
|
isStaffReport,
|
|
}: {
|
|
matchId: number;
|
|
winnerId: number;
|
|
reportedByUserId: number;
|
|
reportedCount: number;
|
|
isStaffReport?: boolean;
|
|
}): Promise<ReportMapWinnerResult> {
|
|
const match = await findById(matchId);
|
|
invariant(match, "Match not found");
|
|
|
|
if (match.isLocked) {
|
|
return { status: "ALREADY_LOCKED" };
|
|
}
|
|
|
|
if (winnerId !== match.groupAlpha.id && winnerId !== match.groupBravo.id) {
|
|
return { status: "INVALID_WINNER" };
|
|
}
|
|
|
|
const {
|
|
mapsToWin,
|
|
alphaWins: existingAlphaWins,
|
|
bravoWins: existingBravoWins,
|
|
isDecisive: scoreAlreadyDecisive,
|
|
} = SendouQMatch.score(match);
|
|
|
|
// Confirmation flow: score is already decisive (first team reported the set-ending map)
|
|
if (scoreAlreadyDecisive) {
|
|
// Staff sees the Undo view in awaiting state and cannot reach this path via the UI
|
|
if (isStaffReport) return { status: "STALE" };
|
|
return handleMatchConfirmation({
|
|
match,
|
|
winnerId,
|
|
reportedByUserId,
|
|
existingAlphaWins,
|
|
mapsToWin,
|
|
});
|
|
}
|
|
|
|
const actualReportedCount = match.mapList.filter(
|
|
(m) => m.winnerGroupId !== null,
|
|
).length;
|
|
if (actualReportedCount !== reportedCount) {
|
|
return { status: "STALE" };
|
|
}
|
|
|
|
const currentMap = match.mapList.find((m) => m.winnerGroupId === null);
|
|
invariant(currentMap, "No unreported map found");
|
|
|
|
const alphaWins =
|
|
existingAlphaWins + (winnerId === match.groupAlpha.id ? 1 : 0);
|
|
const bravoWins =
|
|
existingBravoWins + (winnerId === match.groupBravo.id ? 1 : 0);
|
|
const matchIsOver = alphaWins >= mapsToWin || bravoWins >= mapsToWin;
|
|
|
|
// Non-final map: report and continue
|
|
if (!matchIsOver) {
|
|
await db
|
|
.updateTable("GroupMatchMap")
|
|
.set({
|
|
winnerGroupId: winnerId,
|
|
reportedAt: dateToDatabaseTimestamp(new Date()),
|
|
reportedByUserId,
|
|
})
|
|
.where("id", "=", currentMap.id)
|
|
.execute();
|
|
return { status: "MAP_REPORTED" };
|
|
}
|
|
|
|
// Set-ending map reported by staff: auto-finalize (no awaiting confirmation)
|
|
if (isStaffReport) {
|
|
return handleStaffFinalization({
|
|
match,
|
|
currentMap,
|
|
winnerId,
|
|
reportedByUserId,
|
|
});
|
|
}
|
|
|
|
// Set-ending map: first report, await confirmation from other team
|
|
const members = buildMembers(match);
|
|
const reporterGroupId = members.find(
|
|
(m) => m.id === reportedByUserId,
|
|
)?.groupId;
|
|
invariant(reporterGroupId, "Reporter is not a member of any group");
|
|
|
|
await db.transaction().execute(async (trx) => {
|
|
await trx
|
|
.updateTable("GroupMatchMap")
|
|
.set({
|
|
winnerGroupId: winnerId,
|
|
reportedAt: dateToDatabaseTimestamp(new Date()),
|
|
reportedByUserId,
|
|
})
|
|
.where("id", "=", currentMap.id)
|
|
.execute();
|
|
await SQGroupRepository.setAsInactive(reporterGroupId, trx);
|
|
});
|
|
|
|
return { status: "MATCH_REPORTED" };
|
|
}
|
|
|
|
async function handleMatchConfirmation({
|
|
match,
|
|
winnerId,
|
|
reportedByUserId,
|
|
existingAlphaWins,
|
|
mapsToWin,
|
|
}: {
|
|
match: NonNullable<Awaited<ReturnType<typeof findById>>>;
|
|
winnerId: number;
|
|
reportedByUserId: number;
|
|
existingAlphaWins: number;
|
|
mapsToWin: number;
|
|
}): Promise<ReportMapWinnerResult> {
|
|
const members = buildMembers(match);
|
|
const reporterGroupId = members.find(
|
|
(m) => m.id === reportedByUserId,
|
|
)?.groupId;
|
|
invariant(reporterGroupId, "Reporter is not a member of any group");
|
|
|
|
// Find the deciding map (last map with a winner)
|
|
const decidingMap = match.mapList
|
|
.toReversed()
|
|
.find((m) => m.winnerGroupId !== null);
|
|
invariant(decidingMap, "No deciding map found");
|
|
|
|
const originalReporterGroupId = decidingMap.reportedByUserId
|
|
? members.find((m) => m.id === decidingMap.reportedByUserId)?.groupId
|
|
: undefined;
|
|
|
|
// Same team re-reporting
|
|
if (reporterGroupId === originalReporterGroupId) {
|
|
return { status: "STALE" };
|
|
}
|
|
|
|
// Other team reports a different winner for the deciding map
|
|
if (winnerId !== decidingMap.winnerGroupId) {
|
|
await SQGroupRepository.setAsInactive(reporterGroupId);
|
|
return { status: "SCORE_DISAGREEMENT" };
|
|
}
|
|
|
|
// Other team confirms the score — finalize
|
|
const winnerGroupId =
|
|
existingAlphaWins >= mapsToWin ? match.groupAlpha.id : match.groupBravo.id;
|
|
const loserGroupId =
|
|
existingAlphaWins >= mapsToWin ? match.groupBravo.id : match.groupAlpha.id;
|
|
|
|
const winners: ("ALPHA" | "BRAVO")[] = match.mapList
|
|
.filter((m) => m.winnerGroupId !== null)
|
|
.map((m) => (m.winnerGroupId === match.groupAlpha.id ? "ALPHA" : "BRAVO"));
|
|
|
|
await finalizeMatch({
|
|
match,
|
|
members,
|
|
winners,
|
|
winnerGroupId,
|
|
loserGroupId,
|
|
confirmedByUserId: reportedByUserId,
|
|
preFinalize: (trx) => SQGroupRepository.setAsInactive(reporterGroupId, trx),
|
|
});
|
|
|
|
return { status: "MATCH_FINALIZED" };
|
|
}
|
|
|
|
async function handleStaffFinalization({
|
|
match,
|
|
currentMap,
|
|
winnerId,
|
|
reportedByUserId,
|
|
}: {
|
|
match: NonNullable<Awaited<ReturnType<typeof findById>>>;
|
|
currentMap: NonNullable<
|
|
Awaited<ReturnType<typeof findById>>
|
|
>["mapList"][number];
|
|
winnerId: number;
|
|
reportedByUserId: number;
|
|
}): Promise<ReportMapWinnerResult> {
|
|
const winnerGroupId = winnerId;
|
|
const loserGroupId =
|
|
winnerId === match.groupAlpha.id
|
|
? match.groupBravo.id
|
|
: match.groupAlpha.id;
|
|
|
|
const members = buildMembers(match);
|
|
|
|
const winners: ("ALPHA" | "BRAVO")[] = [
|
|
...match.mapList
|
|
.filter((m) => m.winnerGroupId !== null)
|
|
.map((m) =>
|
|
m.winnerGroupId === match.groupAlpha.id
|
|
? ("ALPHA" as const)
|
|
: ("BRAVO" as const),
|
|
),
|
|
winnerId === match.groupAlpha.id ? "ALPHA" : "BRAVO",
|
|
];
|
|
|
|
await finalizeMatch({
|
|
match,
|
|
members,
|
|
winners,
|
|
winnerGroupId,
|
|
loserGroupId,
|
|
confirmedByUserId: reportedByUserId,
|
|
preFinalize: async (trx) => {
|
|
await trx
|
|
.updateTable("GroupMatchMap")
|
|
.set({
|
|
winnerGroupId,
|
|
reportedAt: dateToDatabaseTimestamp(new Date()),
|
|
reportedByUserId,
|
|
})
|
|
.where("id", "=", currentMap.id)
|
|
.execute();
|
|
await SQGroupRepository.setAsInactive(match.groupAlpha.id, trx);
|
|
await SQGroupRepository.setAsInactive(match.groupBravo.id, trx);
|
|
},
|
|
});
|
|
|
|
return { status: "MATCH_FINALIZED" };
|
|
}
|
|
|
|
async function finalizeMatch({
|
|
match,
|
|
members,
|
|
winners,
|
|
winnerGroupId,
|
|
loserGroupId,
|
|
confirmedByUserId,
|
|
preFinalize,
|
|
}: {
|
|
match: NonNullable<Awaited<ReturnType<typeof findById>>>;
|
|
members: ReturnType<typeof buildMembers>;
|
|
winners: ("ALPHA" | "BRAVO")[];
|
|
winnerGroupId: number;
|
|
loserGroupId: number;
|
|
confirmedByUserId: number;
|
|
preFinalize?: (trx: Transaction<DB>) => Promise<unknown>;
|
|
}) {
|
|
const { newSkills, differences } = calculateMatchSkills({
|
|
groupMatchId: match.id,
|
|
winner: (match.groupAlpha.id === winnerGroupId
|
|
? match.groupAlpha
|
|
: match.groupBravo
|
|
).members.map((m) => m.id),
|
|
loser: (match.groupAlpha.id === loserGroupId
|
|
? match.groupAlpha
|
|
: match.groupBravo
|
|
).members.map((m) => m.id),
|
|
winnerGroupId,
|
|
loserGroupId,
|
|
});
|
|
|
|
await db.transaction().execute(async (trx) => {
|
|
if (preFinalize) await preFinalize(trx);
|
|
await trx
|
|
.updateTable("GroupMatch")
|
|
.set({
|
|
confirmedAt: dateToDatabaseTimestamp(new Date()),
|
|
confirmedByUserId,
|
|
})
|
|
.where("id", "=", match.id)
|
|
.execute();
|
|
await PlayerStatRepository.upsertMapResults(
|
|
summarizeMaps({ match, members, winners }),
|
|
trx,
|
|
);
|
|
await PlayerStatRepository.upsertPlayerResults(
|
|
summarizePlayerResults({ match, members, winners }),
|
|
trx,
|
|
);
|
|
await SkillRepository.createMatchSkills(
|
|
{
|
|
skills: newSkills,
|
|
differences,
|
|
groupMatchId: match.id,
|
|
oldMatchMemento: match.memento,
|
|
},
|
|
trx,
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function undoMatchReport({
|
|
matchId,
|
|
requestedByUserId,
|
|
isStaff,
|
|
}: {
|
|
matchId: number;
|
|
requestedByUserId: number;
|
|
isStaff?: boolean;
|
|
}): Promise<{ status: "SUCCESS" | "NOT_ALLOWED" | "ALREADY_LOCKED" }> {
|
|
const match = await findById(matchId);
|
|
invariant(match, "Match not found");
|
|
|
|
if (match.isLocked) {
|
|
return { status: "ALREADY_LOCKED" };
|
|
}
|
|
|
|
if (!SendouQMatch.score(match).isDecisive) {
|
|
return { status: "NOT_ALLOWED" };
|
|
}
|
|
|
|
const decidingMap = [...match.mapList]
|
|
.reverse()
|
|
.find((m) => m.winnerGroupId !== null);
|
|
invariant(decidingMap, "No deciding map found");
|
|
|
|
if (!decidingMap.reportedByUserId) {
|
|
return { status: "NOT_ALLOWED" };
|
|
}
|
|
|
|
const members = buildMembers(match);
|
|
const requesterGroupId = members.find(
|
|
(m) => m.id === requestedByUserId,
|
|
)?.groupId;
|
|
const reporterGroupId = members.find(
|
|
(m) => m.id === decidingMap.reportedByUserId,
|
|
)?.groupId;
|
|
|
|
if (!isStaff && requesterGroupId !== reporterGroupId) {
|
|
return { status: "NOT_ALLOWED" };
|
|
}
|
|
|
|
await db.transaction().execute(async (trx) => {
|
|
await trx
|
|
.updateTable("GroupMatchMap")
|
|
.set({ winnerGroupId: null, reportedAt: null, reportedByUserId: null })
|
|
.where("id", "=", decidingMap.id)
|
|
.execute();
|
|
|
|
await trx
|
|
.deleteFrom("GroupMatchContinueVote")
|
|
.where("GroupMatchContinueVote.groupId", "in", [
|
|
match.groupAlpha.id,
|
|
match.groupBravo.id,
|
|
])
|
|
.execute();
|
|
});
|
|
|
|
return { status: "SUCCESS" };
|
|
}
|
|
|
|
export async function undoMapReport({
|
|
matchId,
|
|
mapIndex,
|
|
}: {
|
|
matchId: number;
|
|
mapIndex: number;
|
|
}): Promise<{ status: "SUCCESS" | "NOT_ALLOWED" | "ALREADY_LOCKED" }> {
|
|
const match = await findById(matchId);
|
|
invariant(match, "Match not found");
|
|
|
|
if (match.isLocked) {
|
|
return { status: "ALREADY_LOCKED" };
|
|
}
|
|
|
|
if (SendouQMatch.score(match).isDecisive) {
|
|
return { status: "NOT_ALLOWED" };
|
|
}
|
|
|
|
const targetMap = match.mapList[mapIndex];
|
|
if (!targetMap || targetMap.winnerGroupId === null) {
|
|
return { status: "NOT_ALLOWED" };
|
|
}
|
|
|
|
const hasLaterReport = match.mapList
|
|
.slice(mapIndex + 1)
|
|
.some((m) => m.winnerGroupId !== null);
|
|
if (hasLaterReport) {
|
|
return { status: "NOT_ALLOWED" };
|
|
}
|
|
|
|
await db.transaction().execute(async (trx) => {
|
|
await trx
|
|
.updateTable("GroupMatchMap")
|
|
.set({ winnerGroupId: null })
|
|
.where("id", "=", targetMap.id)
|
|
.execute();
|
|
|
|
await trx
|
|
.deleteFrom("GroupMatchContinueVote")
|
|
.where("GroupMatchContinueVote.groupId", "in", [
|
|
match.groupAlpha.id,
|
|
match.groupBravo.id,
|
|
])
|
|
.execute();
|
|
});
|
|
|
|
return { status: "SUCCESS" };
|
|
}
|
|
|
|
function buildMembers(
|
|
match: NonNullable<Awaited<ReturnType<typeof findById>>>,
|
|
) {
|
|
return [
|
|
...match.groupAlpha.members.map((m) => ({
|
|
...m,
|
|
groupId: match.groupAlpha.id,
|
|
})),
|
|
...match.groupBravo.members.map((m) => ({
|
|
...m,
|
|
groupId: match.groupBravo.id,
|
|
})),
|
|
];
|
|
}
|
|
|
|
function lastReporterGroupId(
|
|
match: NonNullable<Awaited<ReturnType<typeof findById>>>,
|
|
members: ReturnType<typeof buildMembers>,
|
|
) {
|
|
const lastReportedMap = match.mapList
|
|
.toReversed()
|
|
.find((m) => m.reportedByUserId !== null);
|
|
if (!lastReportedMap?.reportedByUserId) return undefined;
|
|
return members.find((m) => m.id === lastReportedMap.reportedByUserId)
|
|
?.groupId;
|
|
}
|