Migrate SQ match queries to Kysely (#2782)
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

This commit is contained in:
Kalle 2026-02-21 13:48:18 +02:00 committed by GitHub
parent 3a6dc4ace5
commit 7b71abfe53
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 1240 additions and 606 deletions

View File

@ -29,13 +29,10 @@ import {
summarizeMaps,
summarizePlayerResults,
} from "~/features/sendouq-match/core/summarizer.server";
import * as PlayerStatRepository from "~/features/sendouq-match/PlayerStatRepository.server";
import { winnersArrayToWinner } from "~/features/sendouq-match/q-match-utils";
import { addMapResults } from "~/features/sendouq-match/queries/addMapResults.server";
import { addPlayerResults } from "~/features/sendouq-match/queries/addPlayerResults.server";
import { addReportedWeapons } from "~/features/sendouq-match/queries/addReportedWeapons.server";
import { addSkills } from "~/features/sendouq-match/queries/addSkills.server";
import { reportScore } from "~/features/sendouq-match/queries/reportScore.server";
import { setGroupAsInactive } from "~/features/sendouq-match/queries/setGroupAsInactive.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SkillRepository from "~/features/sendouq-match/SkillRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { BANNED_MAPS } from "~/features/sendouq-settings/banned-maps";
import * as QSettingsRepository from "~/features/sendouq-settings/QSettingsRepository.server";
@ -2365,28 +2362,28 @@ async function playedMatches() {
groupId: match.bravoGroupId,
})),
];
sql.transaction(() => {
reportScore({
matchId: match.id,
reportedByUserId:
faker.number.float(1) > 0.5
? groupAlphaMembers[0]
: groupBravoMembers[0],
winners,
});
addSkills({
skills: newSkills,
differences,
groupMatchId: match.id,
oldMatchMemento: { users: {}, groups: {}, pools: [] },
});
setGroupAsInactive(groupAlpha);
setGroupAsInactive(groupBravo);
addMapResults(summarizeMaps({ match: finishedMatch, members, winners }));
addPlayerResults(
summarizePlayerResults({ match: finishedMatch, members, winners }),
);
})();
await SQMatchRepository.updateScore({
matchId: match.id,
reportedByUserId:
faker.number.float(1) > 0.5
? groupAlphaMembers[0]
: groupBravoMembers[0],
winners,
});
await SkillRepository.createMatchSkills({
skills: newSkills,
differences,
groupMatchId: match.id,
oldMatchMemento: { users: {}, groups: {}, pools: [] },
});
await SQGroupRepository.setAsInactive(groupAlpha);
await SQGroupRepository.setAsInactive(groupBravo);
await PlayerStatRepository.upsertMapResults(
summarizeMaps({ match: finishedMatch, members, winners }),
);
await PlayerStatRepository.upsertPlayerResults(
summarizePlayerResults({ match: finishedMatch, members, winners }),
);
// -> add weapons for 90% of matches
if (faker.number.float(1) > 0.9) continue;
@ -2395,7 +2392,7 @@ async function playedMatches() {
finishedMatch.mapList.map((m) => ({ map: m, user: u })),
);
addReportedWeapons(
await ReportedWeaponRepository.createMany(
mapsWithUsers.map((mu) => {
const weapon = () => {
if (faker.number.float(1) < 0.9) return defaultWeapons[mu.user];

View File

@ -0,0 +1,58 @@
import type { Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, Tables } from "~/db/tables";
export function upsertMapResults(
results: Pick<
Tables["MapResult"],
"losses" | "wins" | "userId" | "mode" | "stageId" | "season"
>[],
trx?: Transaction<DB>,
) {
if (results.length === 0) return;
const executor = trx ?? db;
return executor
.insertInto("MapResult")
.values(results)
.onConflict((oc) =>
oc.columns(["userId", "stageId", "mode", "season"]).doUpdateSet((eb) => ({
wins: eb("MapResult.wins", "+", eb.ref("excluded.wins")),
losses: eb("MapResult.losses", "+", eb.ref("excluded.losses")),
})),
)
.execute();
}
export function upsertPlayerResults(
results: Tables["PlayerResult"][],
trx?: Transaction<DB>,
) {
if (results.length === 0) return;
const executor = trx ?? db;
return executor
.insertInto("PlayerResult")
.values(results)
.onConflict((oc) =>
oc
.columns(["ownerUserId", "otherUserId", "type", "season"])
.doUpdateSet((eb) => ({
mapWins: eb("PlayerResult.mapWins", "+", eb.ref("excluded.mapWins")),
mapLosses: eb(
"PlayerResult.mapLosses",
"+",
eb.ref("excluded.mapLosses"),
),
setWins: eb("PlayerResult.setWins", "+", eb.ref("excluded.setWins")),
setLosses: eb(
"PlayerResult.setLosses",
"+",
eb.ref("excluded.setLosses"),
),
})),
)
.execute();
}

View File

@ -0,0 +1,66 @@
import type { NotNull, Transaction } from "kysely";
import { db } from "~/db/sql";
import type { DB, TablesInsertable } from "~/db/tables";
export function createMany(
weapons: TablesInsertable["ReportedWeapon"][],
trx?: Transaction<DB>,
) {
if (weapons.length === 0) return;
return (trx ?? db).insertInto("ReportedWeapon").values(weapons).execute();
}
export async function replaceByMatchId(
matchId: number,
weapons: TablesInsertable["ReportedWeapon"][],
trx?: Transaction<DB>,
) {
const executor = trx ?? db;
const groupMatchMaps = await executor
.selectFrom("GroupMatchMap")
.select("id")
.where("matchId", "=", matchId)
.execute();
if (groupMatchMaps.length > 0) {
await executor
.deleteFrom("ReportedWeapon")
.where(
"groupMatchMapId",
"in",
groupMatchMaps.map((m) => m.id),
)
.execute();
}
if (weapons.length > 0) {
await executor.insertInto("ReportedWeapon").values(weapons).execute();
}
}
export async function findByMatchId(matchId: number) {
const rows = await db
.selectFrom("ReportedWeapon")
.innerJoin(
"GroupMatchMap",
"GroupMatchMap.id",
"ReportedWeapon.groupMatchMapId",
)
.select([
"ReportedWeapon.groupMatchMapId",
"ReportedWeapon.weaponSplId",
"ReportedWeapon.userId",
"GroupMatchMap.index as mapIndex",
])
.where("GroupMatchMap.matchId", "=", matchId)
.orderBy("GroupMatchMap.index", "asc")
.orderBy("ReportedWeapon.userId", "asc")
.$narrowType<{ groupMatchMapId: NotNull }>()
.execute();
if (rows.length === 0) return null;
return rows;
}

View File

@ -0,0 +1,456 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { db } from "~/db/sql";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import { dbInsertUsers, dbReset } from "~/utils/Test";
import * as SQGroupRepository from "../sendouq/SQGroupRepository.server";
import * as SQMatchRepository from "./SQMatchRepository.server";
const { mockSeasonCurrentOrPrevious } = vi.hoisted(() => ({
mockSeasonCurrentOrPrevious: vi.fn(() => ({
nth: 1,
starts: new Date("2023-01-01"),
ends: new Date("2030-12-31"),
})),
}));
vi.mock("~/features/mmr/core/Seasons", () => ({
currentOrPrevious: mockSeasonCurrentOrPrevious,
}));
const createGroup = async (userIds: number[]) => {
const groupResult = await SQGroupRepository.createGroup({
status: "ACTIVE",
userId: userIds[0],
});
for (let i = 1; i < userIds.length; i++) {
await SQGroupRepository.addMember(groupResult.id, {
userId: userIds[i],
role: "REGULAR",
});
}
return groupResult.id;
};
const createMatch = async (alphaGroupId: number, bravoGroupId: number) => {
const match = await db
.insertInto("GroupMatch")
.values({
alphaGroupId,
bravoGroupId,
chatCode: "test-chat-code",
})
.returningAll()
.executeTakeFirstOrThrow();
const mapList: Array<{
mode: ModeShort;
stageId: StageId;
}> = [
{ mode: "SZ", stageId: 1 },
{ mode: "TC", stageId: 2 },
{ mode: "RM", stageId: 3 },
{ mode: "CB", stageId: 4 },
{ mode: "SZ", stageId: 5 },
{ mode: "TC", stageId: 6 },
{ mode: "RM", stageId: 7 },
];
await db
.insertInto("GroupMatchMap")
.values(
mapList.map((map, i) => ({
matchId: match.id,
index: i,
mode: map.mode,
stageId: map.stageId,
source: "TIEBREAKER",
})),
)
.execute();
return match;
};
const fetchMatch = async (matchId: number) => {
return db
.selectFrom("GroupMatch")
.selectAll()
.where("id", "=", matchId)
.executeTakeFirst();
};
const fetchMapResults = async (matchId: number) => {
return db
.selectFrom("GroupMatchMap")
.selectAll()
.where("matchId", "=", matchId)
.orderBy("index", "asc")
.execute();
};
const fetchGroup = async (groupId: number) => {
return db
.selectFrom("Group")
.selectAll()
.where("id", "=", groupId)
.executeTakeFirst();
};
const fetchSkills = async (matchId: number) => {
return db
.selectFrom("Skill")
.selectAll()
.where("groupMatchId", "=", matchId)
.execute();
};
const fetchReportedWeapons = async (matchId: number) => {
return db
.selectFrom("ReportedWeapon")
.innerJoin(
"GroupMatchMap",
"GroupMatchMap.id",
"ReportedWeapon.groupMatchMapId",
)
.selectAll("ReportedWeapon")
.where("GroupMatchMap.matchId", "=", matchId)
.execute();
};
describe("updateScore", () => {
beforeEach(async () => {
await dbInsertUsers(8);
});
afterEach(() => {
dbReset();
});
test("updates match reportedAt and reportedByUserId", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.updateScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
});
const updatedMatch = await fetchMatch(match.id);
expect(updatedMatch?.reportedAt).not.toBeNull();
expect(updatedMatch?.reportedByUserId).toBe(1);
});
test("sets winners correctly for each map", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.updateScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "BRAVO", "ALPHA", "BRAVO"],
});
const maps = await fetchMapResults(match.id);
expect(maps[0].winnerGroupId).toBe(alphaGroupId);
expect(maps[1].winnerGroupId).toBe(bravoGroupId);
expect(maps[2].winnerGroupId).toBe(alphaGroupId);
expect(maps[3].winnerGroupId).toBe(bravoGroupId);
expect(maps[4].winnerGroupId).toBeNull();
});
test("clears previous winners before setting new ones", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.updateScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "ALPHA", "ALPHA"],
});
await SQMatchRepository.updateScore({
matchId: match.id,
reportedByUserId: 5,
winners: ["BRAVO", "BRAVO", "BRAVO", "BRAVO"],
});
const maps = await fetchMapResults(match.id);
for (let i = 0; i < 4; i++) {
expect(maps[i].winnerGroupId).toBe(bravoGroupId);
}
});
});
describe("lockMatchWithoutSkillChange", () => {
beforeEach(async () => {
await dbInsertUsers(8);
});
afterEach(() => {
dbReset();
});
test("inserts dummy skill to lock match", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.lockMatchWithoutSkillChange(match.id);
const skills = await fetchSkills(match.id);
expect(skills).toHaveLength(1);
expect(skills[0].season).toBe(-1);
expect(skills[0].mu).toBe(-1);
expect(skills[0].sigma).toBe(-1);
expect(skills[0].ordinal).toBe(-1);
expect(skills[0].userId).toBeNull();
});
});
describe("adminReport", () => {
beforeEach(async () => {
await dbInsertUsers(8);
});
afterEach(() => {
dbReset();
});
test("sets both groups as inactive", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.adminReport({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
});
const alphaGroup = await fetchGroup(alphaGroupId);
const bravoGroup = await fetchGroup(bravoGroupId);
expect(alphaGroup?.status).toBe("INACTIVE");
expect(bravoGroup?.status).toBe("INACTIVE");
const updatedMatch = await fetchMatch(match.id);
expect(updatedMatch?.reportedAt).not.toBeNull();
});
test("creates skills to lock the match", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.adminReport({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
});
const skills = await fetchSkills(match.id);
expect(skills.length).toBeGreaterThan(0);
});
});
describe("reportScore", () => {
beforeEach(async () => {
await dbInsertUsers(8);
});
afterEach(() => {
dbReset();
});
test("first report sets reporter group as inactive", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
const groupMatchMaps = await db
.selectFrom("GroupMatchMap")
.select(["id", "index"])
.where("matchId", "=", match.id)
.orderBy("index", "asc")
.execute();
const result = await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [
{
groupMatchMapId: groupMatchMaps[0].id,
weaponSplId: 40,
userId: 1,
mapIndex: 0,
},
],
});
expect(result.status).toBe("REPORTED");
expect(result.shouldRefreshCaches).toBe(false);
const alphaGroup = await fetchGroup(alphaGroupId);
expect(alphaGroup?.status).toBe("INACTIVE");
const bravoGroup = await fetchGroup(bravoGroupId);
expect(bravoGroup?.status).toBe("ACTIVE");
const weapons = await fetchReportedWeapons(match.id);
expect(weapons).toHaveLength(1);
});
test("matching second report confirms score and creates skills", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [],
});
const result = await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 5,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [],
});
expect(result.status).toBe("CONFIRMED");
expect(result.shouldRefreshCaches).toBe(true);
const skills = await fetchSkills(match.id);
expect(skills.length).toBeGreaterThan(0);
});
test("different score returns DIFFERENT status", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [],
});
const result = await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 5,
winners: ["BRAVO", "BRAVO", "BRAVO", "BRAVO"],
weapons: [],
});
expect(result.status).toBe("DIFFERENT");
expect(result.shouldRefreshCaches).toBe(false);
});
test("duplicate report returns DUPLICATE status", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [],
});
const result = await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 2,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [],
});
expect(result.status).toBe("DUPLICATE");
expect(result.shouldRefreshCaches).toBe(false);
});
});
describe("cancelMatch", () => {
beforeEach(async () => {
await dbInsertUsers(8);
});
afterEach(() => {
dbReset();
});
test("first cancel report sets group inactive", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
const result = await SQMatchRepository.cancelMatch({
matchId: match.id,
reportedByUserId: 1,
});
expect(result.status).toBe("CANCEL_REPORTED");
expect(result.shouldRefreshCaches).toBe(false);
const alphaGroup = await fetchGroup(alphaGroupId);
expect(alphaGroup?.status).toBe("INACTIVE");
});
test("matching cancel confirms and locks match", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.cancelMatch({
matchId: match.id,
reportedByUserId: 1,
});
const result = await SQMatchRepository.cancelMatch({
matchId: match.id,
reportedByUserId: 5,
});
expect(result.status).toBe("CANCEL_CONFIRMED");
expect(result.shouldRefreshCaches).toBe(true);
const alphaGroup = await fetchGroup(alphaGroupId);
const bravoGroup = await fetchGroup(bravoGroupId);
expect(alphaGroup?.status).toBe("INACTIVE");
expect(bravoGroup?.status).toBe("INACTIVE");
const skills = await fetchSkills(match.id);
expect(skills).toHaveLength(1);
expect(skills[0].season).toBe(-1);
});
test("cant cancel after score reported", async () => {
const alphaGroupId = await createGroup([1, 2, 3, 4]);
const bravoGroupId = await createGroup([5, 6, 7, 8]);
const match = await createMatch(alphaGroupId, bravoGroupId);
await SQMatchRepository.reportScore({
matchId: match.id,
reportedByUserId: 1,
winners: ["ALPHA", "ALPHA", "BRAVO", "ALPHA"],
weapons: [],
});
const result = await SQMatchRepository.cancelMatch({
matchId: match.id,
reportedByUserId: 5,
});
expect(result.status).toBe("CANT_CANCEL");
expect(result.shouldRefreshCaches).toBe(false);
});
});

View File

@ -5,6 +5,7 @@ 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";
@ -18,7 +19,19 @@ import {
} from "~/utils/kysely.server";
import type { Unpacked } from "~/utils/types";
import { FULL_GROUP_SIZE } from "../sendouq/q-constants";
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 { 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
@ -492,3 +505,332 @@ async function validateCreatedMatch(
}
}
}
export async function updateScore(
{
matchId,
reportedByUserId,
winners,
}: {
matchId: number;
reportedByUserId: number;
winners: ("ALPHA" | "BRAVO")[];
},
trx?: Transaction<DB>,
) {
const executor = trx ?? db;
const match = await executor
.updateTable("GroupMatch")
.set({
reportedAt: dateToDatabaseTimestamp(new Date()),
reportedByUserId,
})
.where("id", "=", matchId)
.returningAll()
.executeTakeFirstOrThrow();
await executor
.updateTable("GroupMatchMap")
.set({ winnerGroupId: null })
.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 = match.reportedByUserId
? members.find((m) => m.id === match.reportedByUserId)?.groupId
: undefined;
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,
}: {
matchId: number;
reportedByUserId: number;
}): Promise<CancelMatchResult> {
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 = match.reportedByUserId
? members.find((m) => m.id === match.reportedByUserId)?.groupId
: undefined;
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 };
}
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,
})),
];
}

View File

@ -0,0 +1,139 @@
import type { Transaction } from "kysely";
import { ordinal } from "openskill";
import { db } from "~/db/sql";
import type { DB, ParsedMemento, Tables } from "~/db/tables";
import { identifierToUserIds } from "~/features/mmr/mmr-utils";
import { databaseTimestampNow } from "~/utils/dates";
import type { MementoSkillDifferences } from "./core/skills.server";
export async function createMatchSkills(
{
groupMatchId,
skills,
oldMatchMemento,
differences,
}: {
groupMatchId: number;
skills: Pick<
Tables["Skill"],
"groupMatchId" | "identifier" | "mu" | "season" | "sigma" | "userId"
>[];
oldMatchMemento: ParsedMemento | null;
differences: MementoSkillDifferences;
},
trx?: Transaction<DB>,
) {
const executor = trx ?? db;
const createdAt = databaseTimestampNow();
for (const skill of skills) {
const insertedSkill = await insertSkillWithOrdinal(
{
...skill,
createdAt,
ordinal: ordinal(skill),
},
executor,
);
if (insertedSkill.identifier) {
for (const userId of identifierToUserIds(insertedSkill.identifier)) {
await executor
.insertInto("SkillTeamUser")
.values({
skillId: insertedSkill.id,
userId,
})
.onConflict((oc) => oc.columns(["skillId", "userId"]).doNothing())
.execute();
}
}
}
if (!oldMatchMemento) return;
const newMemento: ParsedMemento = {
...oldMatchMemento,
groups: {},
users: {},
};
for (const [key, value] of Object.entries(oldMatchMemento.users)) {
newMemento.users[key as unknown as number] = {
...value,
skillDifference:
differences.users[key as unknown as number]?.skillDifference,
};
}
for (const [key, value] of Object.entries(oldMatchMemento.groups)) {
newMemento.groups[key as unknown as number] = {
...value,
skillDifference:
differences.groups[key as unknown as number]?.skillDifference,
};
}
await executor
.updateTable("GroupMatch")
.set({ memento: JSON.stringify(newMemento) })
.where("id", "=", groupMatchId)
.execute();
}
async function insertSkillWithOrdinal(
skill: {
groupMatchId: number | null;
identifier: string | null;
mu: number;
season: number;
sigma: number;
userId: number | null;
createdAt: number;
ordinal: number;
},
executor: Transaction<DB> | typeof db,
) {
const isUserSkill = skill.userId !== null;
const isTeamSkill = skill.identifier !== null;
let previousMatchesCount = 0;
if (isUserSkill) {
const previousSkill = await executor
.selectFrom("Skill")
.select(({ fn }) => fn.max("matchesCount").as("maxMatchesCount"))
.where("userId", "=", skill.userId)
.where("season", "=", skill.season)
.executeTakeFirst();
previousMatchesCount = previousSkill?.maxMatchesCount ?? 0;
} else if (isTeamSkill) {
const previousSkill = await executor
.selectFrom("Skill")
.select(({ fn }) => fn.max("matchesCount").as("maxMatchesCount"))
.where("identifier", "=", skill.identifier)
.where("season", "=", skill.season)
.executeTakeFirst();
previousMatchesCount = previousSkill?.maxMatchesCount ?? 0;
}
const insertedSkill = await executor
.insertInto("Skill")
.values({
groupMatchId: skill.groupMatchId,
identifier: skill.identifier,
mu: skill.mu,
season: skill.season,
sigma: skill.sigma,
ordinal: skill.ordinal,
userId: skill.userId,
createdAt: skill.createdAt,
matchesCount: previousMatchesCount + 1,
})
.returningAll()
.executeTakeFirstOrThrow();
return insertedSkill;
}

View File

@ -1,6 +1,5 @@
import type { ActionFunctionArgs } from "react-router";
import { redirect } from "react-router";
import { sql } from "~/db/sql";
import type { ReportedWeapon } from "~/db/tables";
import { requireUser } from "~/features/auth/core/user.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
@ -13,6 +12,7 @@ import {
} from "~/features/sendouq/core/SendouQ.server";
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { refreshStreamsCache } from "~/features/sendouq-streams/core/streams.server";
import invariant from "~/utils/invariant";
@ -25,25 +25,8 @@ import {
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { SENDOUQ_PREPARING_PAGE, sendouQMatchPage } from "~/utils/urls";
import { compareMatchToReportedScores } from "../core/match.server";
import { mergeReportedWeapons } from "../core/reported-weapons.server";
import { calculateMatchSkills } from "../core/skills.server";
import {
summarizeMaps,
summarizePlayerResults,
} from "../core/summarizer.server";
import { matchSchema, qMatchPageParamsSchema } from "../q-match-schemas";
import { winnersArrayToWinner } from "../q-match-utils";
import { addDummySkill } from "../queries/addDummySkill.server";
import { addMapResults } from "../queries/addMapResults.server";
import { addPlayerResults } from "../queries/addPlayerResults.server";
import { addReportedWeapons } from "../queries/addReportedWeapons.server";
import { addSkills } from "../queries/addSkills.server";
import { deleteReporterWeaponsByMatchId } from "../queries/deleteReportedWeaponsByMatchId.server";
import { findMatchById } from "../queries/findMatchById.server";
import { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
import { reportScore } from "../queries/reportScore.server";
import { setGroupAsInactive } from "../queries/setGroupAsInactive.server";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const matchId = parseParams({
@ -58,30 +41,27 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
switch (data._action) {
case "REPORT_SCORE": {
const reportWeapons = () => {
const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? [];
const mergedWeapons = mergeReportedWeapons({
oldWeapons: oldReportedWeapons,
newWeapons: data.weapons as (ReportedWeapon & {
mapIndex: number;
groupMatchMapId: number;
})[],
newReportedMapsCount: data.winners.length,
});
sql.transaction(() => {
deleteReporterWeaponsByMatchId(matchId);
addReportedWeapons(mergedWeapons);
})();
};
const unmappedMatch = notFoundIfFalsy(
await SQMatchRepository.findById(matchId),
);
const match = SendouQ.mapMatch(unmappedMatch, user);
if (match.isLocked) {
reportWeapons();
const oldReportedWeapons =
(await ReportedWeaponRepository.findByMatchId(matchId)) ?? [];
const mergedWeapons = mergeReportedWeapons({
oldWeapons: oldReportedWeapons,
newWeapons: data.weapons,
newReportedMapsCount: data.winners.length,
});
await ReportedWeaponRepository.replaceByMatchId(
matchId,
mergedWeapons.map((w) => ({
groupMatchMapId: w.groupMatchMapId,
userId: w.userId,
weaponSplId: w.weaponSplId,
})),
);
return null;
}
@ -89,6 +69,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
!data.adminReport || user.roles.includes("STAFF"),
"Only mods can report scores as admin",
);
const members = [
...match.groupAlpha.members.map((m) => ({
...m,
@ -99,147 +80,116 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
groupId: match.groupBravo.id,
})),
];
const groupMemberOfId = members.find((m) => m.id === user.id)?.groupId;
invariant(
groupMemberOfId || data.adminReport,
members.some((m) => m.id === user.id) || data.adminReport,
"User is not a member of any group",
);
const winner = winnersArrayToWinner(data.winners);
const winnerGroupId =
winner === "ALPHA" ? match.groupAlpha.id : match.groupBravo.id;
const loserGroupId =
winner === "ALPHA" ? match.groupBravo.id : match.groupAlpha.id;
if (data.adminReport) {
await SQMatchRepository.adminReport({
matchId,
reportedByUserId: user.id,
winners: data.winners,
});
// when admin reports match gets locked right away
const compared = data.adminReport
? "SAME"
: compareMatchToReportedScores({
match,
winners: data.winners,
newReporterGroupId: groupMemberOfId!,
previousReporterGroupId: match.reportedByUserId
? members.find((m) => m.id === match.reportedByUserId)!.groupId
: undefined,
});
// same group reporting same score, probably by mistake
if (compared === "DUPLICATE") {
reportWeapons();
return null;
}
const matchIsBeingCanceled = data.winners.length === 0;
const { newSkills, differences } =
compared === "SAME" && !matchIsBeingCanceled
? 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,
})
: { newSkills: null, differences: null };
const shouldLockMatchWithoutChangingRecords =
compared === "SAME" && matchIsBeingCanceled;
let clearCaches = false;
sql.transaction(() => {
if (
compared === "FIX_PREVIOUS" ||
compared === "FIRST_REPORT" ||
data.adminReport
) {
reportScore({
matchId,
reportedByUserId: user.id,
winners: data.winners,
});
}
// own group gets set inactive
if (groupMemberOfId) setGroupAsInactive(groupMemberOfId);
// skills & map/player results only update after both teams have reported
if (newSkills) {
addMapResults(
summarizeMaps({ match, members, winners: data.winners }),
);
addPlayerResults(
summarizePlayerResults({ match, members, winners: data.winners }),
);
addSkills({
skills: newSkills,
differences,
groupMatchId: match.id,
oldMatchMemento: match.memento,
});
clearCaches = true;
}
if (shouldLockMatchWithoutChangingRecords) {
addDummySkill(match.id);
clearCaches = true;
}
// fix edge case where they 1) report score 2) report weapons 3) report score again, but with different amount of maps played
if (compared === "FIX_PREVIOUS") {
deleteReporterWeaponsByMatchId(matchId);
}
// admin reporting, just set both groups inactive
if (data.adminReport) {
setGroupAsInactive(match.groupAlpha.id);
setGroupAsInactive(match.groupBravo.id);
}
})();
if (clearCaches) {
// this is kind of useless to do when admin reports since skills don't change
// but it's not the most common case so it's ok
try {
refreshUserSkills(Seasons.currentOrPrevious()!.nth);
} catch (error) {
logger.warn("Error refreshing user skills", error);
}
refreshStreamsCache();
await refreshSendouQInstance();
if (match.chatCode) {
ChatSystemMessage.send({
room: match.chatCode,
type: "SCORE_CONFIRMED",
context: { name: user.username },
});
}
break;
}
const matchIsBeingCanceled = data.winners.length === 0;
if (matchIsBeingCanceled) {
const result = await SQMatchRepository.cancelMatch({
matchId,
reportedByUserId: user.id,
});
if (result.shouldRefreshCaches) {
try {
refreshUserSkills(Seasons.currentOrPrevious()!.nth);
} catch (error) {
logger.warn("Error refreshing user skills", error);
}
refreshStreamsCache();
}
if (result.status === "CANT_CANCEL") {
return { error: "cant-cancel" as const };
}
if (result.status === "DUPLICATE") {
break;
}
await refreshSendouQInstance();
if (match.chatCode) {
const type: NonNullable<ChatMessage["type"]> =
result.status === "CANCEL_CONFIRMED"
? "CANCEL_CONFIRMED"
: "CANCEL_REPORTED";
ChatSystemMessage.send({
room: match.chatCode,
type,
context: { name: user.username },
});
}
break;
}
const result = await SQMatchRepository.reportScore({
matchId,
reportedByUserId: user.id,
winners: data.winners,
weapons: data.weapons as (ReportedWeapon & {
mapIndex: number;
groupMatchMapId: number;
})[],
});
if (result.shouldRefreshCaches) {
try {
refreshUserSkills(Seasons.currentOrPrevious()!.nth);
} catch (error) {
logger.warn("Error refreshing user skills", error);
}
refreshStreamsCache();
}
if (compared === "DIFFERENT") {
return {
error: matchIsBeingCanceled
? ("cant-cancel" as const)
: ("different" as const),
};
if (result.status === "DIFFERENT") {
return { error: "different" as const };
}
// in a different transaction but it's okay
reportWeapons();
if (result.status !== "DUPLICATE") {
await refreshSendouQInstance();
}
await refreshSendouQInstance();
if (match.chatCode) {
const type = (): NonNullable<ChatMessage["type"]> => {
if (compared === "SAME") {
return matchIsBeingCanceled
? "CANCEL_CONFIRMED"
: "SCORE_CONFIRMED";
}
return matchIsBeingCanceled ? "CANCEL_REPORTED" : "SCORE_REPORTED";
};
if (match.chatCode && result.status !== "DUPLICATE") {
const type: NonNullable<ChatMessage["type"]> =
result.status === "CONFIRMED" ? "SCORE_CONFIRMED" : "SCORE_REPORTED";
ChatSystemMessage.send({
room: match.chatCode,
type: type(),
context: {
name: user.username,
},
type,
context: { name: user.username },
});
}
@ -282,10 +232,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
throw redirect(SENDOUQ_PREPARING_PAGE);
}
case "REPORT_WEAPONS": {
const match = notFoundIfFalsy(findMatchById(matchId));
const match = notFoundIfFalsy(await SQMatchRepository.findById(matchId));
errorToastIfFalsy(match.reportedAt, "Match has not been reported yet");
const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? [];
const oldReportedWeapons =
(await ReportedWeaponRepository.findByMatchId(matchId)) ?? [];
const mergedWeapons = mergeReportedWeapons({
oldWeapons: oldReportedWeapons,
@ -295,10 +246,14 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
})[],
});
sql.transaction(() => {
deleteReporterWeaponsByMatchId(matchId);
addReportedWeapons(mergedWeapons);
})();
await ReportedWeaponRepository.replaceByMatchId(
matchId,
mergedWeapons.map((w) => ({
groupMatchMapId: w.groupMatchMapId,
userId: w.userId,
weaponSplId: w.weaponSplId,
})),
);
break;
}

View File

@ -303,7 +303,10 @@ export function compareMatchToReportedScores({
newReporterGroupId,
previousReporterGroupId,
}: {
match: SQMatch;
match: Pick<SQMatch, "reportedByUserId" | "mapList"> & {
groupAlpha: { id: number };
groupBravo: { id: number };
};
winners: ("ALPHA" | "BRAVO")[];
newReporterGroupId: number;
previousReporterGroupId?: number;

View File

@ -1,7 +1,7 @@
import type { SQMatchGroup } from "~/features/sendouq/core/SendouQ.server";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import type { MatchById } from "../queries/findMatchById.server";
import type { reportedWeaponsByMatchId } from "../queries/reportedWeaponsByMatchId.server";
import type * as ReportedWeaponRepository from "../ReportedWeaponRepository.server";
import type * as SQMatchRepository from "../SQMatchRepository.server";
export type ReportedWeaponForMerging = {
weaponSplId?: MainWeaponId;
@ -65,8 +65,12 @@ export function reportedWeaponsToArrayOfArrays({
groupAlpha,
groupBravo,
}: {
reportedWeapons: ReturnType<typeof reportedWeaponsByMatchId>;
mapList: MatchById["mapList"];
reportedWeapons: Awaited<
ReturnType<typeof ReportedWeaponRepository.findByMatchId>
>;
mapList: NonNullable<
Awaited<ReturnType<typeof SQMatchRepository.findById>>
>["mapList"];
groupAlpha: SQMatchGroup;
groupBravo: SQMatchGroup;
}) {

View File

@ -1,15 +1,21 @@
import type { Tables } from "~/db/tables";
import * as Seasons from "~/features/mmr/core/Seasons";
import type { SQMatch } from "~/features/sendouq/core/SendouQ.server";
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
import invariant from "~/utils/invariant";
import { winnersArrayToWinner } from "../q-match-utils";
type MatchForSummarizing = {
mapList: Array<{ mode: ModeShort; stageId: StageId }>;
groupAlpha: { id: number };
groupBravo: { id: number };
};
export function summarizeMaps({
match,
winners,
members,
}: {
match: SQMatch;
match: MatchForSummarizing;
winners: ("ALPHA" | "BRAVO")[];
members: { id: number; groupId: number }[];
}) {
@ -59,7 +65,7 @@ export function summarizePlayerResults({
winners,
members,
}: {
match: SQMatch;
match: MatchForSummarizing;
winners: ("ALPHA" | "BRAVO")[];
members: { id: number; groupId: number }[];
}) {

View File

@ -3,7 +3,7 @@ import { getUser } from "~/features/auth/core/user.server";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import * as PrivateUserNoteRepository from "~/features/sendouq/PrivateUserNoteRepository.server";
import { reportedWeaponsToArrayOfArrays } from "~/features/sendouq-match/core/reported-weapons.server";
import { reportedWeaponsByMatchId } from "~/features/sendouq-match/queries/reportedWeaponsByMatchId.server";
import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { notFoundIfFalsy, parseParams } from "~/utils/remix.server";
import { qMatchPageParamsSchema } from "../q-match-schemas";
@ -29,7 +29,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
const match = SendouQ.mapMatch(matchUnmapped, user, privateNotes);
const rawReportedWeapons = match.reportedAt
? reportedWeaponsByMatchId(matchId)
? await ReportedWeaponRepository.findByMatchId(matchId)
: null;
return {

View File

@ -39,7 +39,7 @@ const weapons = z.preprocess(
groupMatchMapId: id,
}),
)
.nullish()
.optional()
.default([]),
);
export const matchSchema = z.union([

View File

@ -1,20 +0,0 @@
import { sql } from "~/db/sql";
const stm = sql.prepare(/* sql */ `
insert into "Skill" ("groupMatchId", "identifier", "mu", "season", "sigma", "ordinal", "userId", "matchesCount")
values (
@groupMatchId,
null,
-1,
-1,
-1,
-1,
null,
0
)
`);
/** Adds a placeholder skill that makes the match locked */
export function addDummySkill(groupMatchId: number) {
stm.run({ groupMatchId });
}

View File

@ -1,34 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
const addMapResultDeltaStm = sql.prepare(/* sql */ `
insert into "MapResult" (
"mode",
"stageId",
"userId",
"wins",
"losses",
"season"
) values (
@mode,
@stageId,
@userId,
@wins,
@losses,
@season
) on conflict ("userId", "stageId", "mode", "season") do
update
set
"wins" = "wins" + @wins,
"losses" = "losses" + @losses
`);
export function addMapResults(
results: Array<
Pick<Tables["MapResult"], "losses" | "wins" | "userId" | "mode" | "stageId">
>,
) {
for (const result of results) {
addMapResultDeltaStm.run(result);
}
}

View File

@ -1,45 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
const addPlayerResultDeltaStm = sql.prepare(/* sql */ `
insert into "PlayerResult" (
"ownerUserId",
"otherUserId",
"mapWins",
"mapLosses",
"setWins",
"setLosses",
"type",
"season"
) values (
@ownerUserId,
@otherUserId,
@mapWins,
@mapLosses,
@setWins,
@setLosses,
@type,
@season
) on conflict ("ownerUserId", "otherUserId", "type", "season") do
update
set
"mapWins" = "mapWins" + @mapWins,
"mapLosses" = "mapLosses" + @mapLosses,
"setWins" = "setWins" + @setWins,
"setLosses" = "setLosses" + @setLosses
`);
export function addPlayerResults(results: Array<Tables["PlayerResult"]>) {
for (const result of results) {
addPlayerResultDeltaStm.run({
ownerUserId: result.ownerUserId,
otherUserId: result.otherUserId,
mapWins: result.mapWins,
mapLosses: result.mapLosses,
setWins: result.setWins,
setLosses: result.setLosses,
type: result.type,
season: result.season,
});
}
}

View File

@ -1,20 +0,0 @@
import { sql } from "~/db/sql";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
const insertStm = sql.prepare(/* sql */ `
insert into "ReportedWeapon"
("groupMatchMapId", "weaponSplId", "userId")
values (@groupMatchMapId, @weaponSplId, @userId)
`);
export const addReportedWeapons = (
args: {
groupMatchMapId: number;
weaponSplId: MainWeaponId;
userId: number;
}[],
) => {
for (const { groupMatchMapId, userId, weaponSplId } of args) {
insertStm.run({ groupMatchMapId, userId, weaponSplId });
}
};

View File

@ -1,110 +0,0 @@
import { ordinal } from "openskill";
import { sql } from "~/db/sql";
import type { ParsedMemento, Tables } from "~/db/tables";
import { identifierToUserIds } from "~/features/mmr/mmr-utils";
import { databaseTimestampNow } from "~/utils/dates";
import type { MementoSkillDifferences } from "../core/skills.server";
const getStm = (type: "user" | "team") =>
sql.prepare(/* sql */ `
insert into "Skill" ("groupMatchId", "identifier", "mu", "season", "sigma", "ordinal", "userId", "createdAt", "matchesCount")
values (
@groupMatchId,
@identifier,
@mu,
@season,
@sigma,
@ordinal,
@userId,
@createdAt,
1 + coalesce((
select max("matchesCount") from "Skill"
where
${type === "user" ? /* sql */ `"userId" = @userId` : ""}
${type === "team" ? /* sql */ `"identifier" = @identifier` : ""}
and "season" = @season
group by ${
type === "user" ? /* sql */ `"userId"` : /* sql */ `"identifier"`
}
), 0)
) returning *
`);
const addSkillTeamUserStm = sql.prepare(/* sql */ `
insert into "SkillTeamUser" (
"skillId",
"userId"
) values (
@skillId,
@userId
) on conflict("skillId", "userId") do nothing
`);
const userStm = getStm("user");
const teamStm = getStm("team");
const updateMatchMementoStm = sql.prepare(/* sql */ `
update "GroupMatch"
set "memento" = @memento
where "id" = @id
`);
export function addSkills({
groupMatchId,
skills,
oldMatchMemento,
differences,
}: {
groupMatchId: number;
skills: Pick<
Tables["Skill"],
"groupMatchId" | "identifier" | "mu" | "season" | "sigma" | "userId"
>[];
oldMatchMemento: ParsedMemento | null;
differences: MementoSkillDifferences;
}) {
for (const skill of skills) {
const stm = skill.userId ? userStm : teamStm;
const insertedSkill = stm.get({
...skill,
createdAt: databaseTimestampNow(),
ordinal: ordinal(skill),
}) as Tables["Skill"];
if (insertedSkill.identifier) {
for (const userId of identifierToUserIds(insertedSkill.identifier)) {
addSkillTeamUserStm.run({
skillId: insertedSkill.id,
userId,
});
}
}
}
if (!oldMatchMemento) return;
const newMemento: ParsedMemento = {
...oldMatchMemento,
groups: {},
users: {},
};
for (const [key, value] of Object.entries(oldMatchMemento.users)) {
newMemento.users[key as any] = {
...value,
skillDifference: differences.users[key as any]?.skillDifference,
};
}
for (const [key, value] of Object.entries(oldMatchMemento.groups)) {
newMemento.groups[key as any] = {
...value,
skillDifference: differences.groups[key as any]?.skillDifference,
};
}
updateMatchMementoStm.run({
id: groupMatchId,
memento: JSON.stringify(newMemento),
});
}

View File

@ -1,21 +0,0 @@
import { sql } from "~/db/sql";
const deleteStm = sql.prepare(/* sql */ `
delete from "ReportedWeapon"
where "groupMatchMapId" = @groupMatchMapId
`);
const getGroupMatchMapsStm = sql.prepare(/* sql */ `
select "id" from "GroupMatchMap"
where "matchId" = @matchId
`);
export const deleteReporterWeaponsByMatchId = (matchId: number) => {
const groupMatchMaps = getGroupMatchMapsStm.all({ matchId }) as Array<{
id: number;
}>;
for (const { id } of groupMatchMaps) {
deleteStm.run({ groupMatchMapId: id });
}
};

View File

@ -1,60 +0,0 @@
import { sql } from "~/db/sql";
import type { ParsedMemento, Tables } from "~/db/tables";
import { parseDBJsonArray } from "~/utils/sql";
const stm = sql.prepare(/* sql */ `
select
"GroupMatch"."id",
"GroupMatch"."alphaGroupId",
"GroupMatch"."bravoGroupId",
"GroupMatch"."createdAt",
"GroupMatch"."reportedAt",
"GroupMatch"."reportedByUserId",
"GroupMatch"."chatCode",
"GroupMatch"."memento",
(select exists (select 1 from "Skill" where "Skill"."groupMatchId" = @id)) as "isLocked",
json_group_array(
json_object(
'id', "GroupMatchMap"."id",
'mode', "GroupMatchMap"."mode",
'stageId', "GroupMatchMap"."stageId",
'source', "GroupMatchMap"."source",
'winnerGroupId', "GroupMatchMap"."winnerGroupId"
)
) as "mapList"
from "GroupMatch"
left join "GroupMatchMap" on "GroupMatchMap"."matchId" = "GroupMatch"."id"
where "GroupMatch"."id" = @id
group by "GroupMatch"."id"
order by "GroupMatchMap"."index" asc
`);
export interface MatchById {
id: Tables["GroupMatch"]["id"];
alphaGroupId: Tables["GroupMatch"]["alphaGroupId"];
bravoGroupId: Tables["GroupMatch"]["bravoGroupId"];
createdAt: Tables["GroupMatch"]["createdAt"];
reportedAt: Tables["GroupMatch"]["reportedAt"];
reportedByUserId: Tables["GroupMatch"]["reportedByUserId"];
chatCode: Tables["GroupMatch"]["chatCode"];
isLocked: boolean;
memento: ParsedMemento;
mapList: Array<
Pick<
Tables["GroupMatchMap"],
"id" | "mode" | "stageId" | "source" | "winnerGroupId"
>
>;
}
export function findMatchById(id: number) {
const row = stm.get({ id }) as any;
if (!row) return null;
return {
...row,
mapList: parseDBJsonArray(row.mapList),
isLocked: Boolean(row.isLocked),
memento: row.memento ? JSON.parse(row.memento) : null,
} as MatchById;
}

View File

@ -1,52 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
import { dateToDatabaseTimestamp } from "~/utils/dates";
const updateMatchStm = sql.prepare(/* sql */ `
update "GroupMatch"
set "reportedAt" = @reportedAt,
"reportedByUserId" = @reportedByUserId
where "id" = @matchId
returning *
`);
const clearMatchMapWinnersStm = sql.prepare(/* sql */ `
update "GroupMatchMap"
set "winnerGroupId" = null
where "matchId" = @matchId
`);
const updateMatchMapStm = sql.prepare(/* sql */ `
update "GroupMatchMap"
set "winnerGroupId" = @winnerGroupId
where "matchId" = @matchId and "index" = @index
`);
export const reportScore = ({
reportedByUserId,
winners,
matchId,
}: {
reportedByUserId: number;
winners: ("ALPHA" | "BRAVO")[];
matchId: number;
}) => {
const updatedMatch = updateMatchStm.get({
reportedAt: dateToDatabaseTimestamp(new Date()),
reportedByUserId,
matchId,
}) as Tables["GroupMatch"];
clearMatchMapWinnersStm.run({ matchId });
for (const [index, winner] of winners.entries()) {
updateMatchMapStm.run({
winnerGroupId:
winner === "ALPHA"
? updatedMatch.alphaGroupId
: updatedMatch.bravoGroupId,
matchId,
index,
});
}
};

View File

@ -1,27 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
const stm = sql.prepare(/* sql */ `
select
"ReportedWeapon"."groupMatchMapId",
"ReportedWeapon"."weaponSplId",
"ReportedWeapon"."userId",
"GroupMatchMap"."index" as "mapIndex"
from
"ReportedWeapon"
left join "GroupMatchMap" on "GroupMatchMap"."id" = "ReportedWeapon"."groupMatchMapId"
where "GroupMatchMap"."matchId" = @matchId
`);
export function reportedWeaponsByMatchId(matchId: number) {
const rows = stm.all({ matchId }) as Array<
Tables["ReportedWeapon"] & {
mapIndex: Tables["GroupMatchMap"]["index"];
groupMatchMapId: number;
}
>;
if (rows.length === 0) return null;
return rows;
}

View File

@ -1,11 +0,0 @@
import { sql } from "~/db/sql";
const groupToInactiveStm = sql.prepare(/* sql */ `
update "Group"
set "status" = 'INACTIVE'
where "id" = @groupId
`);
export function setGroupAsInactive(groupId: number) {
groupToInactiveStm.run({ groupId });
}

View File

@ -695,3 +695,11 @@ export function setPreparingGroupAsActive(groupId: number) {
.where("status", "=", "PREPARING")
.execute();
}
export function setAsInactive(groupId: number, trx?: Transaction<DB>) {
return (trx ?? db)
.updateTable("Group")
.set({ status: "INACTIVE" })
.where("id", "=", groupId)
.execute();
}

View File

@ -1,7 +1,7 @@
import "dotenv/config";
import { db } from "~/db/sql";
import * as Seasons from "~/features/mmr/core/Seasons";
import { addDummySkill } from "~/features/sendouq-match/queries/addDummySkill.server";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { logger } from "~/utils/logger";
@ -36,7 +36,7 @@ async function main() {
.execute();
for (const match of allMatches) {
addDummySkill(match.id);
await SQMatchRepository.lockMatchWithoutSkillChange(match.id);
}
logger.info(`All done with nuking the season (${allMatches.length} matches)`);