diff --git a/app/features/match-profile/MatchProfileRepository.server.test.ts b/app/features/match-profile/MatchProfileRepository.server.test.ts new file mode 100644 index 000000000..b071d39e1 --- /dev/null +++ b/app/features/match-profile/MatchProfileRepository.server.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { db } from "~/db/sql"; +import type { UserMapModePreferences } from "~/db/tables"; +import { dbInsertUsers, dbReset, withUserId } from "~/utils/Test"; +import * as MatchProfileRepository from "./MatchProfileRepository.server"; + +const USER_ID = 1; + +const PREFERENCES: UserMapModePreferences = { + modes: [{ mode: "SZ", preference: "PREFER" }], + pool: [{ mode: "SZ", stages: [1, 2, 3, 4] }], +}; + +const OTHER_PREFERENCES: UserMapModePreferences = { + modes: [{ mode: "SZ", preference: "PREFER" }], + pool: [{ mode: "SZ", stages: [5, 6, 7, 8] }], +}; + +const updateProfile = ( + args: Partial< + Parameters[0] + > = {}, +) => + withUserId(USER_ID, () => + MatchProfileRepository.updateOwnMatchProfile({ + mapModePreferences: PREFERENCES, + vc: "NO", + languages: [], + weaponPool: [], + noScreen: 0, + ...args, + }), + ); + +describe("updateOwnMatchProfile", () => { + beforeEach(async () => { + await dbInsertUsers(1); + await db + .updateTable("User") + .set({ mapModePreferences: JSON.stringify(PREFERENCES), noScreen: 0 }) + .where("id", "=", USER_ID) + .execute(); + }); + + afterEach(() => { + dbReset(); + }); + + test("reports no change when nothing matchmaking-relevant changed", async () => { + const result = await updateProfile({ vc: "YES", languages: ["en"] }); + + expect(result.mapModePreferencesChanged).toBe(false); + expect(result.noScreenChanged).toBe(false); + }); + + test("detects a noScreen change", async () => { + const result = await updateProfile({ noScreen: 1 }); + + expect(result.noScreenChanged).toBe(true); + expect(result.mapModePreferencesChanged).toBe(false); + }); + + test("detects a map/mode preferences change", async () => { + const result = await updateProfile({ + mapModePreferences: OTHER_PREFERENCES, + }); + + expect(result.mapModePreferencesChanged).toBe(true); + expect(result.noScreenChanged).toBe(false); + }); +}); diff --git a/app/features/match-profile/MatchProfileRepository.server.ts b/app/features/match-profile/MatchProfileRepository.server.ts index e2007439c..89382366f 100644 --- a/app/features/match-profile/MatchProfileRepository.server.ts +++ b/app/features/match-profile/MatchProfileRepository.server.ts @@ -1,3 +1,4 @@ +import * as R from "remeda"; import { db } from "~/db/sql"; import type { Tables, UserMapModePreferences } from "~/db/tables"; import { actorId } from "~/features/auth/core/user.server"; @@ -56,20 +57,29 @@ export async function updateOwnMatchProfile({ noScreen: number; }) { const userId = actorId(); - const currentPreferences = ( - await db - .selectFrom("User") - .select("mapModePreferences") - .where("id", "=", userId) - .executeTakeFirstOrThrow() - ).mapModePreferences; + const current = await db + .selectFrom("User") + .select(["mapModePreferences", "noScreen"]) + .where("id", "=", userId) + .executeTakeFirstOrThrow(); const mergedPool = mergeExcludedModePreferences( mapModePreferences.pool, - currentPreferences?.pool, + current.mapModePreferences?.pool, ); - return db.transaction().execute(async (trx) => { + const newMapModePreferences: UserMapModePreferences = { + ...mapModePreferences, + pool: mergedPool, + }; + + const mapModePreferencesChanged = !R.isDeepEqual( + newMapModePreferences, + current.mapModePreferences, + ); + const noScreenChanged = current.noScreen !== noScreen; + + await db.transaction().execute(async (trx) => { await trx .deleteFrom("UserWeaponPool") .where("userId", "=", userId) @@ -92,10 +102,7 @@ export async function updateOwnMatchProfile({ await trx .updateTable("User") .set({ - mapModePreferences: JSON.stringify({ - ...mapModePreferences, - pool: mergedPool, - }), + mapModePreferences: JSON.stringify(newMapModePreferences), vc, languages: languages.length > 0 ? languages.join(",") : null, noScreen, @@ -103,6 +110,8 @@ export async function updateOwnMatchProfile({ .where("id", "=", userId) .execute(); }); + + return { mapModePreferencesChanged, noScreenChanged }; } /** diff --git a/app/features/sendouq/SQGroupRepository.server.ts b/app/features/sendouq/SQGroupRepository.server.ts index e96912998..725af15ff 100644 --- a/app/features/sendouq/SQGroupRepository.server.ts +++ b/app/features/sendouq/SQGroupRepository.server.ts @@ -675,6 +675,11 @@ export function deleteLike({ }); } +/** Deletes every like where the given group is the liker or the target. */ +export function deleteAllLikesByGroupId(groupId: number) { + return db.transaction().execute((trx) => deleteLikesByGroupId(groupId, trx)); +} + export function leaveGroup(userId: number) { return db.transaction().execute(async (trx) => { const userGroup = await trx diff --git a/app/features/sendouq/core/likes.server.ts b/app/features/sendouq/core/likes.server.ts new file mode 100644 index 000000000..298d77b89 --- /dev/null +++ b/app/features/sendouq/core/likes.server.ts @@ -0,0 +1,44 @@ +import * as R from "remeda"; +import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; +import * as SQGroupRepository from "~/features/sendouq/SQGroupRepository.server"; +import { + FULL_GROUP_SIZE, + SENDOUQ_LOOKING_ROOM, + sqGroupWebsocketRoom, +} from "../q-constants"; +import { refreshSendouQInstance, SendouQ } from "./SendouQ.server"; + +/** + * Cancels every pending challenge (both given and received) involving the user's + * active and full SendouQ group. Only full groups are affected: partial groups + * merge (rather than start a match) when a request is accepted, so their members' + * preferences are not yet locked in. + */ +export async function cancelActiveGroupLikes(userId: number) { + const ownGroup = SendouQ.findOwnGroup(userId); + if (!ownGroup) return; + if (ownGroup.status !== "ACTIVE" || ownGroup.matchId) return; + if (ownGroup.members.length !== FULL_GROUP_SIZE) return; + + const likes = await SQGroupRepository.allLikesByGroupId(ownGroup.id); + const affectedGroupIds = R.unique([ + ...likes.given.map((like) => like.groupId), + ...likes.received.map((like) => like.groupId), + ]); + if (affectedGroupIds.length === 0) return; + + await SQGroupRepository.deleteAllLikesByGroupId(ownGroup.id); + + await refreshSendouQInstance(); + + ChatSystemMessage.send([ + ...[...affectedGroupIds, ownGroup.id].map((groupId) => ({ + room: sqGroupWebsocketRoom(groupId), + revalidateOnly: true, + })), + { + room: SENDOUQ_LOOKING_ROOM, + revalidateOnly: true, + }, + ]); +} diff --git a/app/features/settings/actions/settings.server.ts b/app/features/settings/actions/settings.server.ts index 0ebd013d2..dfea0a142 100644 --- a/app/features/settings/actions/settings.server.ts +++ b/app/features/settings/actions/settings.server.ts @@ -1,6 +1,7 @@ import type { ActionFunctionArgs } from "react-router"; import { requireUser } from "~/features/auth/core/user.server"; import * as MatchProfileRepository from "~/features/match-profile/MatchProfileRepository.server"; +import { cancelActiveGroupLikes } from "~/features/sendouq/core/likes.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; import { isSupporter } from "~/modules/permissions/utils"; import { clampThemeToGamut } from "~/utils/oklch-gamut"; @@ -59,13 +60,20 @@ export const action = async ({ request }: ActionFunctionArgs) => { break; } case "UPDATE_MATCH_PROFILE": { - await MatchProfileRepository.updateOwnMatchProfile({ - mapModePreferences: data.mapModePreferences, - vc: data.vc, - languages: data.languages, - weaponPool: data.weaponPool, - noScreen: Number(data.noScreen), - }); + const { mapModePreferencesChanged, noScreenChanged } = + await MatchProfileRepository.updateOwnMatchProfile({ + mapModePreferences: data.mapModePreferences, + vc: data.vc, + languages: data.languages, + weaponPool: data.weaponPool, + noScreen: Number(data.noScreen), + }); + + // Challenges are made based on the modes/preferences shown at that + // moment, so changing them must undo pending requests to/from the group. + if (mapModePreferencesChanged || noScreenChanged) { + await cancelActiveGroupLikes(user.id); + } break; } default: { diff --git a/e2e/sendouq.spec.ts b/e2e/sendouq.spec.ts index 3df01308d..951260f10 100644 --- a/e2e/sendouq.spec.ts +++ b/e2e/sendouq.spec.ts @@ -4,6 +4,7 @@ import { SENDOUQ_LOOKING_PAGE, SENDOUQ_PAGE, SENDOUQ_PREPARING_PAGE, + SETTINGS_PAGE, sendouQInviteLink, } from "~/utils/urls"; import { @@ -13,6 +14,7 @@ import { seed, submit, test, + waitForPOSTResponse, } from "./helpers/playwright"; test.describe("SendouQ", () => { @@ -140,4 +142,32 @@ test.describe("SendouQ", () => { combinedGroup.getByTestId("sendouq-group-card-member"), ).toHaveCount(2); }); + + test("Changing match preferences cancels pending requests", async ({ + page, + }) => { + await seed(page); + + // Sendou (ADMIN) is in a full group. Challenge another full group. + await impersonate(page, ADMIN_ID); + await navigate({ page, url: SENDOUQ_LOOKING_PAGE }); + await waitForPOSTResponse(page, () => + page.getByRole("button", { name: "Challenge" }).first().click(), + ); + + // The challenge is now pending and can be undone + await expect(page.getByRole("button", { name: "Undo" })).toHaveCount(1); + + // Changing a matchmaking preference (noScreen) last second must undo the + // pending request so it can't be matched on terms the challenger never saw + await navigate({ page, url: `${SETTINGS_PAGE}?tab=match-profile` }); + await page + .getByRole("switch", { name: /Avoid Splattercolor Screen/i }) + .click({ force: true }); + await submit(page); + + // The pending challenge has been undone + await navigate({ page, url: SENDOUQ_LOOKING_PAGE }); + await expect(page.getByRole("button", { name: "Undo" })).toHaveCount(0); + }); });