From 7ef8bc6dba797517153fac4ee093dbc8661f63e0 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 8 Nov 2026 16:27:36 +0200 Subject: [PATCH] Keep the tournament link of badge wins when a badge's owners are edited --- .../badges/BadgeRepository.server.test.ts | 71 +++++++++++++++++++ app/features/badges/BadgeRepository.server.ts | 64 +++++++++++++---- 2 files changed, 122 insertions(+), 13 deletions(-) diff --git a/app/features/badges/BadgeRepository.server.test.ts b/app/features/badges/BadgeRepository.server.test.ts index 2c7bcc652..ec23db10a 100644 --- a/app/features/badges/BadgeRepository.server.test.ts +++ b/app/features/badges/BadgeRepository.server.test.ts @@ -1,7 +1,10 @@ +import * as R from "remeda"; import { beforeEach, describe, expect, test } from "vitest"; import * as BadgeFactory from "~/db/seed/factories/BadgeFactory"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; import * as UserFactory from "~/db/seed/factories/UserFactory"; import * as XRankPlacementFactory from "~/db/seed/factories/XRankPlacementFactory"; +import * as FeedRepository from "~/features/feed/FeedRepository.server"; import * as BadgeRepository from "./BadgeRepository.server"; import { SPLATOON_3_XP_BADGE_VALUES } from "./badges-constants"; @@ -73,8 +76,76 @@ describe("replaceOwners", () => { const updated = await BadgeRepository.findById(badge.id); expect(updated?.owners).toHaveLength(0); }); + + describe("with a badge won at a tournament", () => { + const users = UserFactory.pool(); + const winnerId = () => users.id(1); + const loserId = () => users.id(2); + const addedByManagerId = () => users.id(3); + let badgeId: number; + + beforeEach(async () => { + await users.create(3); + badgeId = (await BadgeFactory.create()).id; + await TournamentFactory.createPlayed( + { authorId: winnerId(), minMembersPerTeam: 1, badges: [badgeId] }, + { teamRosters: [[winnerId()], [loserId()]], playedOut: "all" }, + ); + }); + + test("keeps the win tied to the tournament when another owner is added", async () => { + await BadgeRepository.replaceOwners({ + badgeId, + ownerIds: [winnerId(), addedByManagerId()], + }); + + expect(await tournamentWinnerIds()).toEqual([winnerId()]); + expect(await ownerCounts(badgeId)).toEqual([ + { id: winnerId(), count: 1 }, + { id: addedByManagerId(), count: 1 }, + ]); + }); + + test("grants extra copies to a winner on top of their tournament win", async () => { + await BadgeRepository.replaceOwners({ + badgeId, + ownerIds: [winnerId(), winnerId(), winnerId()], + }); + + expect(await tournamentWinnerIds()).toEqual([winnerId()]); + expect(await ownerCounts(badgeId)).toEqual([ + { id: winnerId(), count: 3 }, + ]); + }); + + test("removes the win when the winner is removed from the owners", async () => { + await BadgeRepository.replaceOwners({ + badgeId, + ownerIds: [addedByManagerId()], + }); + + expect(await tournamentWinnerIds()).toEqual([]); + expect(await ownerCounts(badgeId)).toEqual([ + { id: addedByManagerId(), count: 1 }, + ]); + }); + }); }); +async function tournamentWinnerIds() { + const owners = + await FeedRepository.findAllTournamentBadgeOwnersStartedAfter(0); + return owners.map((owner) => owner.userId); +} + +async function ownerCounts(badgeId: number) { + const badge = await BadgeRepository.findById(badgeId); + return R.sortBy( + badge!.owners.map(({ id, count }) => ({ id, count })), + (owner) => owner.id, + ); +} + /** Gives the user a linked X Rank player whose one placement is worth `power`. */ const givePeakXp = (userId: number, power: number) => XRankPlacementFactory.create( diff --git a/app/features/badges/BadgeRepository.server.ts b/app/features/badges/BadgeRepository.server.ts index 17783f389..165040f61 100644 --- a/app/features/badges/BadgeRepository.server.ts +++ b/app/features/badges/BadgeRepository.server.ts @@ -196,6 +196,12 @@ export function replaceManagers({ }); } +/** + * Sets who owns the badge and how many times, one `ownerIds` entry per copy. Copies won at a + * tournament keep their tournament link: a user's tournament wins count first towards their new + * total and the rest is a manual grant. Lowering a total below the user's wins removes the most + * recent ones. + */ export function replaceOwners({ badgeId, ownerIds, @@ -204,26 +210,58 @@ export function replaceOwners({ ownerIds: number[]; }) { return db.transaction().execute(async (trx) => { - await trx - .deleteFrom("TournamentBadgeOwner") + const newCounts = new Map(); + for (const userId of ownerIds) { + newCounts.set(userId, (newCounts.get(userId) ?? 0) + 1); + } + + const tournamentWins = await trx + .selectFrom("TournamentBadgeOwner") + .select(["userId", "tournamentId"]) .where("badgeId", "=", badgeId) + .where("tournamentId", "is not", null) + .$narrowType<{ tournamentId: NotNull }>() + .orderBy("tournamentId", "asc") .execute(); - const counts = new Map(); - for (const userId of ownerIds) { - counts.set(userId, (counts.get(userId) ?? 0) + 1); + const keptWinCounts = new Map(); + const removedWins: typeof tournamentWins = []; + for (const win of tournamentWins) { + const keptCount = keptWinCounts.get(win.userId) ?? 0; + if (keptCount < (newCounts.get(win.userId) ?? 0)) { + keptWinCounts.set(win.userId, keptCount + 1); + } else { + removedWins.push(win); + } } await trx - .insertInto("TournamentBadgeOwner") - .values( - Array.from(counts, ([userId, count]) => ({ - badgeId, - userId, - count, - })), - ) + .deleteFrom("TournamentBadgeOwner") + .where("badgeId", "=", badgeId) + .where("tournamentId", "is", null) .execute(); + + for (const win of removedWins) { + await trx + .deleteFrom("TournamentBadgeOwner") + .where("badgeId", "=", badgeId) + .where("userId", "=", win.userId) + .where("tournamentId", "=", win.tournamentId) + .execute(); + } + + const manualGrants = Array.from(newCounts, ([userId, count]) => ({ + badgeId, + userId, + count: count - (keptWinCounts.get(userId) ?? 0), + })).filter((grant) => grant.count > 0); + + if (manualGrants.length > 0) { + await trx + .insertInto("TournamentBadgeOwner") + .values(manualGrants) + .execute(); + } }); }