diff --git a/app/features/sendouq-match/ReportedWeaponRepository.server.test.ts b/app/features/sendouq-match/ReportedWeaponRepository.server.test.ts new file mode 100644 index 000000000..c62f4da1f --- /dev/null +++ b/app/features/sendouq-match/ReportedWeaponRepository.server.test.ts @@ -0,0 +1,100 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; +import * as SQReportedWeaponFactory from "~/db/seed/factories/SQReportedWeaponFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as Seasons from "~/features/mmr/core/Seasons"; +import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; +import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; +import { invariant } from "~/utils/invariant"; +import * as ReportedWeaponRepository from "./ReportedWeaponRepository.server"; +import * as SQMatchRepository from "./SQMatchRepository.server"; + +vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ + send: vi.fn(), + notifyStatusChanged: vi.fn(), + notifyNotificationsChanged: vi.fn(), + notifyRoomsChangedByRoomIds: vi.fn(), +})); + +const SEASON = Seasons.currentOrPrevious()!.nth; +const WEAPON_SPL_ID = 40; + +const users = UserFactory.pool(); +const playerId = () => users.id(1); +const enemyId = () => users.id(FULL_GROUP_SIZE + 1); + +describe("ReportedWeaponRepository.findAllWeaponUsageStats", () => { + beforeEach(async () => { + await users.create(FULL_GROUP_SIZE * 2); + }); + + test("counts a won map of a match in progress", async () => { + const { map } = await playFirstMapWithWeapon(); + + expect(await usageStatsOn(map)).toEqual([ + expect.objectContaining({ + type: "SELF", + weaponSplId: WEAPON_SPL_ID, + count: 1, + wins: 1, + losses: 0, + }), + ]); + }); + + test("ignores maps of a match both teams agreed to cancel", async () => { + const { matchId, map } = await playFirstMapWithWeapon(); + + await SQMatchRepository.requestCancelMatch({ + matchId, + requestedByUserId: playerId(), + reason: "Disconnect", + nominatedUserIds: [], + }); + const acceptance = await SQMatchRepository.acceptCancelMatch({ + matchId, + acceptedByUserId: enemyId(), + reason: "Disconnect", + nominatedUserIds: [], + }); + expect(acceptance.status).toBe("ACCEPTED"); + + expect(await usageStatsOn(map)).toEqual([]); + }); +}); + +async function playFirstMapWithWeapon() { + const match = await SQMatchFactory.create({ + alphaUserIds: users.ids().slice(0, FULL_GROUP_SIZE), + bravoUserIds: users.ids().slice(FULL_GROUP_SIZE), + }); + + const report = await SQMatchRepository.reportMapWinner({ + matchId: match.id, + winnerId: match.alphaGroup.id, + reportedByUserId: playerId(), + reportedCount: 0, + }); + expect(report.status).toBe("MAP_REPORTED"); + + await SQReportedWeaponFactory.createMany(1, () => ({ + groupMatchId: match.id, + mapIndex: 0, + userId: playerId(), + weaponSplId: WEAPON_SPL_ID, + })); + + const fetched = await SQMatchRepository.findById(match.id); + invariant(fetched, "Match not found"); + + return { matchId: match.id, map: fetched.mapList[0] }; +} + +function usageStatsOn(map: { mode: ModeShort; stageId: StageId }) { + return ReportedWeaponRepository.findAllWeaponUsageStats({ + userId: playerId(), + mode: map.mode, + stageId: map.stageId, + season: SEASON, + }); +} diff --git a/app/features/sendouq-match/ReportedWeaponRepository.server.ts b/app/features/sendouq-match/ReportedWeaponRepository.server.ts index cb44f8dbd..48c879306 100644 --- a/app/features/sendouq-match/ReportedWeaponRepository.server.ts +++ b/app/features/sendouq-match/ReportedWeaponRepository.server.ts @@ -49,6 +49,13 @@ export async function replaceByMatchId( await executor.insertInto("ReportedWeapon").values(weapons).execute(); } +export async function deleteByMatchId(matchId: number, trx?: Transaction) { + await (trx ?? db) + .deleteFrom("ReportedWeapon") + .where("groupMatchId", "=", matchId) + .execute(); +} + export async function deleteOwnByMapIndex({ matchId, mapIndex, diff --git a/app/features/sendouq-match/SQMatchRepository.server.test.ts b/app/features/sendouq-match/SQMatchRepository.server.test.ts index 4e2495fc4..a1905061d 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.test.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.test.ts @@ -4,6 +4,7 @@ import { backdate } from "~/db/seed/core/backdate"; import * as SplatoonFaker from "~/db/seed/core/SplatoonFaker"; import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory"; import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; +import * as SQReportedWeaponFactory from "~/db/seed/factories/SQReportedWeaponFactory"; import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; @@ -20,6 +21,7 @@ import { dateToYYYYMMDD } from "~/utils/dates"; import { invariant } from "~/utils/invariant"; import { withUserId } from "~/utils/Test"; import * as GroupMatchContinueVoteRepository from "./GroupMatchContinueVoteRepository.server"; +import * as ReportedWeaponRepository from "./ReportedWeaponRepository.server"; import * as SQMatchRepository from "./SQMatchRepository.server"; const setupMatch = async ({ @@ -201,6 +203,19 @@ describe("lockMatchWithoutSkillChange", () => { expect((await fetchChatRoom(match.chatRoomId!)).inactive).toBe(1); }); + + test("deletes the match's reported weapons", async () => { + const { match, alphaMembers } = await setupMatch(); + await SQReportedWeaponFactory.createMany(1, () => ({ + groupMatchId: match.id, + mapIndex: 0, + userId: alphaMembers[0].id, + })); + + await SQMatchRepository.lockMatchWithoutSkillChange(match); + + expect(await ReportedWeaponRepository.findByMatchId(match.id)).toBeNull(); + }); }); describe("cancelMatch", () => { diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 5444ae6e8..9fb404a08 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -1178,6 +1178,7 @@ export async function lockMatchWithoutSkillChange( }) .execute(); await ChatRepository.updateRoomsInactive([match.chatRoomId], true, trx); + await ReportedWeaponRepository.deleteByMatchId(match.id, trx); } export type CancelMatchResult = diff --git a/app/features/sendouq-match/actions/q.match.$id.server.test.ts b/app/features/sendouq-match/actions/q.match.$id.server.test.ts new file mode 100644 index 000000000..b3c7b9702 --- /dev/null +++ b/app/features/sendouq-match/actions/q.match.$id.server.test.ts @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; +import type { reportWeaponSchema } from "~/components/match-page/match-page-schemas"; +import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; +import { assertResponseErrored, wrappedAction } from "~/utils/Test"; +import * as ReportedWeaponRepository from "../ReportedWeaponRepository.server"; +import * as SQMatchRepository from "../SQMatchRepository.server"; +import { action as rawMatchAction } from "./q.match.$id.server"; + +vi.mock("~/features/chat/ChatSystemMessage.server", () => ({ + send: vi.fn(), + notifyStatusChanged: vi.fn(), + notifyNotificationsChanged: vi.fn(), + notifyRoomsChangedByRoomIds: vi.fn(), +})); + +const WEAPON_SPL_ID = 40; + +const matchAction = wrappedAction({ + action: rawMatchAction, +}); + +const users = UserFactory.pool(); +const playerId = () => users.id(1); +const enemyId = () => users.id(FULL_GROUP_SIZE + 1); + +describe("REPORT_WEAPON", () => { + let matchId: number; + + beforeEach(async () => { + await users.create(FULL_GROUP_SIZE * 2); + + const match = await SQMatchFactory.create({ + alphaUserIds: users.ids().slice(0, FULL_GROUP_SIZE), + bravoUserIds: users.ids().slice(FULL_GROUP_SIZE), + }); + matchId = match.id; + }); + + const reportWeapon = () => + matchAction( + { _action: "REPORT_WEAPON", mapIndex: 0, weaponSplId: WEAPON_SPL_ID }, + { user: playerId(), params: { id: String(matchId) } }, + ); + + test("saves the weapon of a match in progress", async () => { + await reportWeapon(); + + expect(await ReportedWeaponRepository.findByMatchId(matchId)).toEqual([ + expect.objectContaining({ + mapIndex: 0, + userId: playerId(), + weaponSplId: WEAPON_SPL_ID, + }), + ]); + }); + + test("refuses a weapon report for a canceled match", async () => { + await SQMatchRepository.requestCancelMatch({ + matchId, + requestedByUserId: playerId(), + reason: "Disconnect", + nominatedUserIds: [], + }); + const acceptance = await SQMatchRepository.acceptCancelMatch({ + matchId, + acceptedByUserId: enemyId(), + reason: "Disconnect", + nominatedUserIds: [], + }); + expect(acceptance.status).toBe("ACCEPTED"); + + assertResponseErrored(await reportWeapon(), "Match was canceled"); + expect(await ReportedWeaponRepository.findByMatchId(matchId)).toBeNull(); + }); +}); 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 89987c8fd..fa178ebc5 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -285,6 +285,8 @@ export const action = defineAction( break; } case "REPORT_WEAPON": { + errorToastIfFalsy(!match.isCanceled, "Match was canceled"); + await ReportedWeaponRepository.upsertOwn({ groupMatchId: matchId, mapIndex: body.mapIndex, diff --git a/changelog/2026-10-16-weapon-usage-canceled-matches.md b/changelog/2026-10-16-weapon-usage-canceled-matches.md new file mode 100644 index 000000000..2499bfed7 --- /dev/null +++ b/changelog/2026-10-16-weapon-usage-canceled-matches.md @@ -0,0 +1,5 @@ +--- +navItem: sendouq +type: bug +--- +Fixed weapons reported in canceled SendouQ matches counting towards weapon stats, such as season stats and weapon usage diff --git a/migrations/20261016163419-delete-canceled-match-weapons.ts b/migrations/20261016163419-delete-canceled-match-weapons.ts new file mode 100644 index 000000000..3e5207d7e --- /dev/null +++ b/migrations/20261016163419-delete-canceled-match-weapons.ts @@ -0,0 +1,19 @@ +import type { Kysely } from "kysely"; + +/** Canceling a SendouQ match now deletes its reported weapons, this clears the ones canceled before that. */ +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx + .deleteFrom("ReportedWeapon") + .where( + "groupMatchId", + "in", + trx + .selectFrom("Skill") + .select("Skill.groupMatchId") + .where("Skill.season", "=", -1) + .where("Skill.groupMatchId", "is not", null), + ) + .execute(); + }); +}