Fix canceled SendouQ match weapons counting towards weapon stats

This commit is contained in:
Kalle
2026-10-16 19:45:19 +03:00
parent 8fc1cd4dcb
commit b202f7dbb8
8 changed files with 226 additions and 0 deletions

View File

@@ -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,
});
}

View File

@@ -49,6 +49,13 @@ export async function replaceByMatchId(
await executor.insertInto("ReportedWeapon").values(weapons).execute();
}
export async function deleteByMatchId(matchId: number, trx?: Transaction<DB>) {
await (trx ?? db)
.deleteFrom("ReportedWeapon")
.where("groupMatchId", "=", matchId)
.execute();
}
export async function deleteOwnByMapIndex({
matchId,
mapIndex,

View File

@@ -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", () => {

View File

@@ -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 =

View File

@@ -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<typeof reportWeaponSchema>({
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();
});
});

View File

@@ -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,

View File

@@ -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

View File

@@ -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<any>): Promise<void> {
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();
});
}