From 7b71abfe532258272f4296bf8d2d55d84cce87dc Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sat, 21 Feb 2026 13:48:18 +0200 Subject: [PATCH] Migrate SQ match queries to Kysely (#2782) --- app/db/seed/index.ts | 55 +-- .../PlayerStatRepository.server.ts | 58 +++ .../ReportedWeaponRepository.server.ts | 66 +++ .../SQMatchRepository.server.test.ts | 456 ++++++++++++++++++ .../sendouq-match/SQMatchRepository.server.ts | 342 +++++++++++++ .../sendouq-match/SkillRepository.server.ts | 139 ++++++ .../actions/q.match.$id.server.ts | 283 +++++------ .../sendouq-match/core/match.server.ts | 5 +- .../core/reported-weapons.server.ts | 12 +- .../sendouq-match/core/summarizer.server.ts | 12 +- .../loaders/q.match.$id.server.ts | 4 +- app/features/sendouq-match/q-match-schemas.ts | 2 +- .../queries/addDummySkill.server.ts | 20 - .../queries/addMapResults.server.ts | 34 -- .../queries/addPlayerResults.server.ts | 45 -- .../queries/addReportedWeapons.server.ts | 20 - .../sendouq-match/queries/addSkills.server.ts | 110 ----- .../deleteReportedWeaponsByMatchId.server.ts | 21 - .../queries/findMatchById.server.ts | 60 --- .../queries/reportScore.server.ts | 52 -- .../reportedWeaponsByMatchId.server.ts | 27 -- .../queries/setGroupAsInactive.server.ts | 11 - .../sendouq/SQGroupRepository.server.ts | 8 + scripts/nuke-season-data.ts | 4 +- 24 files changed, 1240 insertions(+), 606 deletions(-) create mode 100644 app/features/sendouq-match/PlayerStatRepository.server.ts create mode 100644 app/features/sendouq-match/ReportedWeaponRepository.server.ts create mode 100644 app/features/sendouq-match/SQMatchRepository.server.test.ts create mode 100644 app/features/sendouq-match/SkillRepository.server.ts delete mode 100644 app/features/sendouq-match/queries/addDummySkill.server.ts delete mode 100644 app/features/sendouq-match/queries/addMapResults.server.ts delete mode 100644 app/features/sendouq-match/queries/addPlayerResults.server.ts delete mode 100644 app/features/sendouq-match/queries/addReportedWeapons.server.ts delete mode 100644 app/features/sendouq-match/queries/addSkills.server.ts delete mode 100644 app/features/sendouq-match/queries/deleteReportedWeaponsByMatchId.server.ts delete mode 100644 app/features/sendouq-match/queries/findMatchById.server.ts delete mode 100644 app/features/sendouq-match/queries/reportScore.server.ts delete mode 100644 app/features/sendouq-match/queries/reportedWeaponsByMatchId.server.ts delete mode 100644 app/features/sendouq-match/queries/setGroupAsInactive.server.ts diff --git a/app/db/seed/index.ts b/app/db/seed/index.ts index a43cc2e32..b689cf192 100644 --- a/app/db/seed/index.ts +++ b/app/db/seed/index.ts @@ -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]; diff --git a/app/features/sendouq-match/PlayerStatRepository.server.ts b/app/features/sendouq-match/PlayerStatRepository.server.ts new file mode 100644 index 000000000..c35badaae --- /dev/null +++ b/app/features/sendouq-match/PlayerStatRepository.server.ts @@ -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, +) { + 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, +) { + 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(); +} diff --git a/app/features/sendouq-match/ReportedWeaponRepository.server.ts b/app/features/sendouq-match/ReportedWeaponRepository.server.ts new file mode 100644 index 000000000..4e886255d --- /dev/null +++ b/app/features/sendouq-match/ReportedWeaponRepository.server.ts @@ -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, +) { + if (weapons.length === 0) return; + + return (trx ?? db).insertInto("ReportedWeapon").values(weapons).execute(); +} + +export async function replaceByMatchId( + matchId: number, + weapons: TablesInsertable["ReportedWeapon"][], + trx?: Transaction, +) { + 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; +} diff --git a/app/features/sendouq-match/SQMatchRepository.server.test.ts b/app/features/sendouq-match/SQMatchRepository.server.test.ts new file mode 100644 index 000000000..6df02cf38 --- /dev/null +++ b/app/features/sendouq-match/SQMatchRepository.server.test.ts @@ -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); + }); +}); diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index e411e5023..76f20ac9b 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -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, +) { + 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, +) { + 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 { + 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 { + 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 { + 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>>, +) { + return [ + ...match.groupAlpha.members.map((m) => ({ + ...m, + groupId: match.groupAlpha.id, + })), + ...match.groupBravo.members.map((m) => ({ + ...m, + groupId: match.groupBravo.id, + })), + ]; +} diff --git a/app/features/sendouq-match/SkillRepository.server.ts b/app/features/sendouq-match/SkillRepository.server.ts new file mode 100644 index 000000000..25f9b3f94 --- /dev/null +++ b/app/features/sendouq-match/SkillRepository.server.ts @@ -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, +) { + 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 | 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; +} diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index 19d1af21c..e1e7960c2 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -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 = + 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 => { - if (compared === "SAME") { - return matchIsBeingCanceled - ? "CANCEL_CONFIRMED" - : "SCORE_CONFIRMED"; - } - - return matchIsBeingCanceled ? "CANCEL_REPORTED" : "SCORE_REPORTED"; - }; + if (match.chatCode && result.status !== "DUPLICATE") { + const type: NonNullable = + 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; } diff --git a/app/features/sendouq-match/core/match.server.ts b/app/features/sendouq-match/core/match.server.ts index ec05cc237..da0c3af9e 100644 --- a/app/features/sendouq-match/core/match.server.ts +++ b/app/features/sendouq-match/core/match.server.ts @@ -303,7 +303,10 @@ export function compareMatchToReportedScores({ newReporterGroupId, previousReporterGroupId, }: { - match: SQMatch; + match: Pick & { + groupAlpha: { id: number }; + groupBravo: { id: number }; + }; winners: ("ALPHA" | "BRAVO")[]; newReporterGroupId: number; previousReporterGroupId?: number; diff --git a/app/features/sendouq-match/core/reported-weapons.server.ts b/app/features/sendouq-match/core/reported-weapons.server.ts index 187f0baf4..9e5d53e44 100644 --- a/app/features/sendouq-match/core/reported-weapons.server.ts +++ b/app/features/sendouq-match/core/reported-weapons.server.ts @@ -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; - mapList: MatchById["mapList"]; + reportedWeapons: Awaited< + ReturnType + >; + mapList: NonNullable< + Awaited> + >["mapList"]; groupAlpha: SQMatchGroup; groupBravo: SQMatchGroup; }) { diff --git a/app/features/sendouq-match/core/summarizer.server.ts b/app/features/sendouq-match/core/summarizer.server.ts index 5cc65ca5a..46f94eb9f 100644 --- a/app/features/sendouq-match/core/summarizer.server.ts +++ b/app/features/sendouq-match/core/summarizer.server.ts @@ -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 }[]; }) { diff --git a/app/features/sendouq-match/loaders/q.match.$id.server.ts b/app/features/sendouq-match/loaders/q.match.$id.server.ts index 4de65c2bb..0d2ab3705 100644 --- a/app/features/sendouq-match/loaders/q.match.$id.server.ts +++ b/app/features/sendouq-match/loaders/q.match.$id.server.ts @@ -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 { diff --git a/app/features/sendouq-match/q-match-schemas.ts b/app/features/sendouq-match/q-match-schemas.ts index b6f830f40..f28b77bb5 100644 --- a/app/features/sendouq-match/q-match-schemas.ts +++ b/app/features/sendouq-match/q-match-schemas.ts @@ -39,7 +39,7 @@ const weapons = z.preprocess( groupMatchMapId: id, }), ) - .nullish() + .optional() .default([]), ); export const matchSchema = z.union([ diff --git a/app/features/sendouq-match/queries/addDummySkill.server.ts b/app/features/sendouq-match/queries/addDummySkill.server.ts deleted file mode 100644 index 10a144361..000000000 --- a/app/features/sendouq-match/queries/addDummySkill.server.ts +++ /dev/null @@ -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 }); -} diff --git a/app/features/sendouq-match/queries/addMapResults.server.ts b/app/features/sendouq-match/queries/addMapResults.server.ts deleted file mode 100644 index 91fee9148..000000000 --- a/app/features/sendouq-match/queries/addMapResults.server.ts +++ /dev/null @@ -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 - >, -) { - for (const result of results) { - addMapResultDeltaStm.run(result); - } -} diff --git a/app/features/sendouq-match/queries/addPlayerResults.server.ts b/app/features/sendouq-match/queries/addPlayerResults.server.ts deleted file mode 100644 index b845b6581..000000000 --- a/app/features/sendouq-match/queries/addPlayerResults.server.ts +++ /dev/null @@ -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) { - 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, - }); - } -} diff --git a/app/features/sendouq-match/queries/addReportedWeapons.server.ts b/app/features/sendouq-match/queries/addReportedWeapons.server.ts deleted file mode 100644 index b6e82226a..000000000 --- a/app/features/sendouq-match/queries/addReportedWeapons.server.ts +++ /dev/null @@ -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 }); - } -}; diff --git a/app/features/sendouq-match/queries/addSkills.server.ts b/app/features/sendouq-match/queries/addSkills.server.ts deleted file mode 100644 index ce6aece4b..000000000 --- a/app/features/sendouq-match/queries/addSkills.server.ts +++ /dev/null @@ -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), - }); -} diff --git a/app/features/sendouq-match/queries/deleteReportedWeaponsByMatchId.server.ts b/app/features/sendouq-match/queries/deleteReportedWeaponsByMatchId.server.ts deleted file mode 100644 index 14e114106..000000000 --- a/app/features/sendouq-match/queries/deleteReportedWeaponsByMatchId.server.ts +++ /dev/null @@ -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 }); - } -}; diff --git a/app/features/sendouq-match/queries/findMatchById.server.ts b/app/features/sendouq-match/queries/findMatchById.server.ts deleted file mode 100644 index c74062069..000000000 --- a/app/features/sendouq-match/queries/findMatchById.server.ts +++ /dev/null @@ -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; -} diff --git a/app/features/sendouq-match/queries/reportScore.server.ts b/app/features/sendouq-match/queries/reportScore.server.ts deleted file mode 100644 index 8f4c7c338..000000000 --- a/app/features/sendouq-match/queries/reportScore.server.ts +++ /dev/null @@ -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, - }); - } -}; diff --git a/app/features/sendouq-match/queries/reportedWeaponsByMatchId.server.ts b/app/features/sendouq-match/queries/reportedWeaponsByMatchId.server.ts deleted file mode 100644 index 0f25a493f..000000000 --- a/app/features/sendouq-match/queries/reportedWeaponsByMatchId.server.ts +++ /dev/null @@ -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; -} diff --git a/app/features/sendouq-match/queries/setGroupAsInactive.server.ts b/app/features/sendouq-match/queries/setGroupAsInactive.server.ts deleted file mode 100644 index 4c48f5426..000000000 --- a/app/features/sendouq-match/queries/setGroupAsInactive.server.ts +++ /dev/null @@ -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 }); -} diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index 19f1beede..9677f0852 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -695,3 +695,11 @@ export function setPreparingGroupAsActive(groupId: number) { .where("status", "=", "PREPARING") .execute(); } + +export function setAsInactive(groupId: number, trx?: Transaction) { + return (trx ?? db) + .updateTable("Group") + .set({ status: "INACTIVE" }) + .where("id", "=", groupId) + .execute(); +} diff --git a/scripts/nuke-season-data.ts b/scripts/nuke-season-data.ts index aefb0546c..a5324e2bd 100644 --- a/scripts/nuke-season-data.ts +++ b/scripts/nuke-season-data.ts @@ -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)`);