From 6224806254e37b03f5ef31b701db4e9d2902033d Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 3 May 2026 16:44:49 +0300 Subject: [PATCH] SendouQ tweaks --- AGENTS.md | 1 + .../match-page/MatchRosterTab.module.css | 4 +- app/components/match-page/MatchRosterTab.tsx | 5 +- .../match-page/useMatchWeaponReport.ts | 59 ++++++++++ app/components/match-page/utils.test.ts | 30 ++++- app/components/match-page/utils.ts | 52 +++++++-- app/features/chat/components/Chat.tsx | 8 +- .../scrims/components/ScrimMatchTabs.tsx | 2 +- ...GroupMatchContinueVoteRepository.server.ts | 6 +- .../actions/q.match.$id.server.ts | 76 +++++++------ .../components/SendouQMatchActionTab.tsx | 107 ++++-------------- .../components/SendouQMatchBanner.tsx | 10 +- .../components/SendouQMatchTabs.tsx | 2 +- app/features/sendouq-match/core/RejoinVote.ts | 2 +- .../sendouq-match/core/SendouQMatch.ts | 5 + .../sendouq-match/core/match-timeline.test.ts | 18 --- .../sendouq-match/core/match-timeline.ts | 17 +-- .../sendouq-match/routes/q.match.$id.tsx | 24 ++++ .../components/TournamentMatchActionTab.tsx | 69 ++++------- .../components/TournamentMatchAdminTab.tsx | 9 +- .../components/TournamentMatchTabs.tsx | 3 +- .../tournament-match-utils.test.ts | 27 ----- .../tournament-match-utils.ts | 49 +------- app/features/vods/routes/vods.new.tsx | 2 +- .../useRecentlyReportedWeapons.ts} | 0 25 files changed, 282 insertions(+), 305 deletions(-) create mode 100644 app/components/match-page/useMatchWeaponReport.ts rename app/{features/sendouq/q-hooks.ts => hooks/useRecentlyReportedWeapons.ts} (100%) diff --git a/AGENTS.md b/AGENTS.md index 161dc7354..e6dfd8792 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ - always use named exports - Remeda is the utility library of choice - date-fns should be used for date related logic +- do not use `forEach`, prefer `for...of` ## React diff --git a/app/components/match-page/MatchRosterTab.module.css b/app/components/match-page/MatchRosterTab.module.css index 142a0efbe..a193f5a88 100644 --- a/app/components/match-page/MatchRosterTab.module.css +++ b/app/components/match-page/MatchRosterTab.module.css @@ -25,7 +25,7 @@ .rosterMembers { position: relative; - padding-left: 34px; + padding-inline-start: 34px; list-style: none; display: flex; flex-direction: column; @@ -35,7 +35,7 @@ &::before { content: ""; position: absolute; - left: 21px; + inset-inline-start: 21px; top: -8px; bottom: 0; width: 3px; diff --git a/app/components/match-page/MatchRosterTab.tsx b/app/components/match-page/MatchRosterTab.tsx index 956f55fb9..2eda072b9 100644 --- a/app/components/match-page/MatchRosterTab.tsx +++ b/app/components/match-page/MatchRosterTab.tsx @@ -110,12 +110,13 @@ function TeamRoster({ onSubbedOutChange?: (teamId: number, subbedOut: number[]) => void; isSubmitting?: boolean; }) { - const { t } = useTranslation(["common"]); + const { t } = useTranslation(["common", "q"]); const [isEditing, setIsEditing] = useState(defaultIsEditing); const [selectedMemberIds, setSelectedMemberIds] = useState([]); const dotClassName = side === "alpha" ? styles.teamOneDot : styles.teamTwoDot; - const label = side === "alpha" ? "Alpha" : "Bravo"; + const label = + side === "alpha" ? t("q:match.sides.alpha") : t("q:match.sides.bravo"); const subbedOutSet = new Set(team.subbedOut); const activeMembers = team.members.filter( diff --git a/app/components/match-page/useMatchWeaponReport.ts b/app/components/match-page/useMatchWeaponReport.ts new file mode 100644 index 000000000..fa394cebe --- /dev/null +++ b/app/components/match-page/useMatchWeaponReport.ts @@ -0,0 +1,59 @@ +import { useFetcher } from "react-router"; +import { useRecentlyReportedWeapons } from "~/hooks/useRecentlyReportedWeapons"; +import type { + MainWeaponId, + ModeShort, + StageId, +} from "~/modules/in-game-lists/types"; +import type { WeaponReporterProps } from "./WeaponReporter"; + +/** + * Wires the `` component to the standard + * `REPORT_WEAPON` / `UNDO_WEAPON_REPORT` fetcher actions and to the + * locally persisted recently-reported weapons list. + * + * `maps` is the play order of maps the viewer can report a weapon for and + * `pastReported` is the weapons the viewer has already reported, in order. + */ +export function useMatchWeaponReport({ + maps, + pastReported, +}: { + maps: { stageId: StageId; mode: ModeShort }[]; + pastReported: MainWeaponId[]; +}): WeaponReporterProps { + const weaponFetcher = useFetcher(); + const { recentlyReportedWeapons, addRecentlyReportedWeapon } = + useRecentlyReportedWeapons(); + + return { + maps, + pastReported, + quickSelectWeaponIds: recentlyReportedWeapons, + isSubmitting: weaponFetcher.state !== "idle", + onSubmit: (weaponSplId) => { + addRecentlyReportedWeapon(weaponSplId); + const mapIndex = pastReported.length; + if (!maps[mapIndex]) return; + weaponFetcher.submit( + { + _action: "REPORT_WEAPON", + weaponSplId: String(weaponSplId), + mapIndex: String(mapIndex), + }, + { method: "post" }, + ); + }, + onUndo: () => { + const mapIndex = pastReported.length - 1; + if (mapIndex < 0) return; + weaponFetcher.submit( + { + _action: "UNDO_WEAPON_REPORT", + mapIndex: String(mapIndex), + }, + { method: "post" }, + ); + }, + }; +} diff --git a/app/components/match-page/utils.test.ts b/app/components/match-page/utils.test.ts index f1df3306f..20e8c2b51 100644 --- a/app/components/match-page/utils.test.ts +++ b/app/components/match-page/utils.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, test } from "vitest"; import type { CommonUser } from "~/utils/kysely.server"; -import { inferSubstitutions } from "./utils"; +import { inferSubstitutions, resolveRoomPass } from "./utils"; function user(id: number): CommonUser { return { @@ -113,3 +113,29 @@ describe("inferSubstitutions", () => { ]); }); }); + +describe("resolveRoomPass", () => { + test("returns a 4-digit password", () => { + const pass = resolveRoomPass(12345); + + expect(pass).toMatch(/^\d{4}$/); + }); + + test("returns deterministic password for a given numeric seed", () => { + const pass1 = resolveRoomPass(12345); + const pass2 = resolveRoomPass(12345); + expect(pass1).toBe(pass2); + }); + + test("returns deterministic password for a given string seed", () => { + const pass1 = resolveRoomPass("test-seed"); + const pass2 = resolveRoomPass("test-seed"); + expect(pass1).toBe(pass2); + }); + + test("returns different passwords for different seeds", () => { + const pass1 = resolveRoomPass(1); + const pass2 = resolveRoomPass(2); + expect(pass1).not.toBe(pass2); + }); +}); diff --git a/app/components/match-page/utils.ts b/app/components/match-page/utils.ts index 34eeb0bbf..22531e3d5 100644 --- a/app/components/match-page/utils.ts +++ b/app/components/match-page/utils.ts @@ -1,4 +1,6 @@ +import * as R from "remeda"; import type { CommonUser } from "~/utils/kysely.server"; +import { seededRandom } from "~/utils/random"; type MatchSide = "ALPHA" | "BRAVO"; @@ -32,16 +34,50 @@ export function inferSubstitutions( const out = previousRosters[side].filter((u) => !currIds.has(u.id)); const inn = currentRosters[side].filter((u) => !prevIds.has(u.id)); - for (let i = 0; i < Math.max(out.length, inn.length); i++) { - if (out[i] && inn[i]) { - result.push({ - side: side === "alpha" ? "ALPHA" : "BRAVO", - playerOut: out[i], - playerIn: inn[i], - }); - } + for (const [playerOut, playerIn] of R.zip(out, inn)) { + result.push({ + side: side === "alpha" ? "ALPHA" : "BRAVO", + playerOut, + playerIn, + }); } } return result; } + +const NUM_MAP = { + "1": ["1", "2", "4"], + "2": ["2", "1", "3", "5"], + "3": ["3", "2", "6"], + "4": ["4", "1", "5", "7"], + "5": ["5", "2", "4", "6", "8"], + "6": ["6", "3", "5", "9"], + "7": ["7", "4", "8"], + "8": ["8", "7", "5", "9", "0"], + "9": ["9", "6", "8"], + "0": ["0", "8"], +}; + +/** + * Generates a deterministic 4-digit Splatoon private battle room password based on the provided seed. + * + * Given the same seed, this function will always return the same password. + */ +export function resolveRoomPass(seed: number | string) { + let pass = "5"; + for (let i = 0; i < 3; i++) { + const { seededShuffle } = seededRandom(`${seed}-${i}`); + + const key = pass[i] as keyof typeof NUM_MAP; + const opts = NUM_MAP[key]; + const next = seededShuffle(opts)[0]; + pass += next; + } + + // prevent 5555 since many use it as a default pass + // making it a bit more common guess + if (pass === "5555") return "5800"; + + return pass; +} diff --git a/app/features/chat/components/Chat.tsx b/app/features/chat/components/Chat.tsx index a246529a8..8d73bc3f9 100644 --- a/app/features/chat/components/Chat.tsx +++ b/app/features/chat/components/Chat.tsx @@ -16,6 +16,8 @@ import { useChatAutoScroll } from "../chat-hooks"; import type { ChatMessage, ChatProps, ChatUser } from "../chat-types"; import styles from "./Chat.module.css"; +const ROOM_LINK_PATTERN = new RegExp(SPLATNET_ROOM_LINK_PATTERN.source, "g"); + export interface ChatAdapter { messages: ChatMessage[]; send: (contents: string) => void; @@ -310,16 +312,14 @@ function SystemMessage({ } function MessageContents({ text }: { text: string }) { - const pattern = new RegExp(SPLATNET_ROOM_LINK_PATTERN.source, "g"); - const matches = [...text.matchAll(pattern)]; + const matches = [...text.matchAll(ROOM_LINK_PATTERN)]; if (matches.length === 0) return <>{text}; const parts: React.ReactNode[] = []; let lastIndex = 0; - for (let i = 0; i < matches.length; i++) { - const match = matches[i]; + for (const [i, match] of matches.entries()) { if (match.index > lastIndex) { parts.push(text.slice(lastIndex, match.index)); } diff --git a/app/features/scrims/components/ScrimMatchTabs.tsx b/app/features/scrims/components/ScrimMatchTabs.tsx index c5627b917..80cc4dedf 100644 --- a/app/features/scrims/components/ScrimMatchTabs.tsx +++ b/app/features/scrims/components/ScrimMatchTabs.tsx @@ -4,12 +4,12 @@ import { useLoaderData } from "react-router"; import { MatchJoinTab } from "~/components/match-page/MatchJoinTab"; import { MatchRosterTab } from "~/components/match-page/MatchRosterTab"; import { MatchTabs, TAB_KEYS } from "~/components/match-page/MatchTabs"; +import { resolveRoomPass } from "~/components/match-page/utils"; import { useUser } from "~/features/auth/core/user"; import { resolveActiveRoomLink, useConfirmRoom, } from "~/features/chat/room-link-utils"; -import { resolveRoomPass } from "~/features/tournament-match/tournament-match-utils"; import { dateToDatabaseTimestamp } from "~/utils/dates"; import { teamPage } from "~/utils/urls"; import * as Scrim from "../core/Scrim"; diff --git a/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.ts b/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.ts index c5566c615..1d4df60b7 100644 --- a/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.ts +++ b/app/features/sendouq-match/GroupMatchContinueVoteRepository.server.ts @@ -2,10 +2,12 @@ import type { Transaction } from "kysely"; import { db } from "~/db/sql"; import type { DB, DBBoolean } from "~/db/tables"; -export async function findForGroups(groupIds: number[]) { +export async function findForGroups(groupIds: number[], trx?: Transaction) { if (groupIds.length === 0) return []; - const rows = await db + const executor = trx ?? db; + + const rows = await executor .selectFrom("GroupMatchContinueVote") .select([ "GroupMatchContinueVote.groupId", 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 121735f95..b4a95b5c8 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -1,5 +1,6 @@ import type { ActionFunctionArgs } from "react-router"; import { redirect } from "react-router"; +import { db } from "~/db/sql"; import { requireUser } from "~/features/auth/core/user.server"; import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import * as Seasons from "~/features/mmr/core/Seasons"; @@ -120,15 +121,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { "This group must use the continue vote", ); + const requester = previousGroup.members.find((m) => m.id === user.id); + errorToastIfFalsy( + requester?.role === "OWNER", + "You are not the owner of the group", + ); + for (const member of previousGroup.members) { const currentGroup = SendouQ.findOwnGroup(member.id); errorToastIfFalsy(!currentGroup, "Member is already in a group"); - if (member.id === user.id) { - errorToastIfFalsy( - member.role === "OWNER", - "You are not the owner of the group", - ); - } } await SQGroupRepository.createGroupFromPrevious({ @@ -166,39 +167,50 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { "This group uses the trusted rematch flow", ); - if ( - !RejoinVote.canCastVote( - await GroupMatchContinueVoteRepository.findForGroups([ - viewerGroup.id, - ]), - user.id, - ) - ) { - return null; - } + const votingResult = await db.transaction().execute(async (trx) => { + const existingVotes = + await GroupMatchContinueVoteRepository.findForGroups( + [viewerGroup.id], + trx, + ); - await GroupMatchContinueVoteRepository.cast({ - groupId: viewerGroup.id, - userId: user.id, - isContinuing: data.isContinuing, + if (!RejoinVote.canCastVote(existingVotes, user.id)) { + return null; + } + + await GroupMatchContinueVoteRepository.cast( + { + groupId: viewerGroup.id, + userId: user.id, + isContinuing: data.isContinuing, + }, + trx, + ); + + return RejoinVote.result( + await GroupMatchContinueVoteRepository.findForGroups( + [viewerGroup.id], + trx, + ), + ); }); - const votingResult = RejoinVote.result( - await GroupMatchContinueVoteRepository.findForGroups([ - viewerGroup.id, - ]), - ); - - if (votingResult.type === "RESOLVED") { + if (votingResult?.type === "RESOLVED") { const survivors = viewerGroup.members .filter((m) => votingResult.continuingUserIds.includes(m.id)) .map((m) => ({ id: m.id, role: m.role })); - await SQGroupRepository.createGroupFromPrevious({ - previousGroupId: viewerGroup.id, - members: survivors, - status: "ACTIVE", - }); + try { + await SQGroupRepository.createGroupFromPrevious({ + previousGroupId: viewerGroup.id, + members: survivors, + status: "ACTIVE", + }); + } catch (error) { + // a concurrent voter may have already created the successor + // group; the in-memory queue still needs to be refreshed below + if (!(error instanceof SendouQError)) throw error; + } await refreshSendouQInstance(); } diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx index b3cbd3ef7..25ea07c6a 100644 --- a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx +++ b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx @@ -8,9 +8,9 @@ import { FormWithConfirm } from "~/components/FormWithConfirm"; import { MatchActionTab } from "~/components/match-page/MatchActionTab"; import { TAB_KEYS } from "~/components/match-page/MatchTabs"; import { MatchTimeline } from "~/components/match-page/MatchTimeline"; +import { useMatchWeaponReport } from "~/components/match-page/useMatchWeaponReport"; import { WeaponReporter } from "~/components/match-page/WeaponReporter"; import { useUser } from "~/features/auth/core/user"; -import { useRecentlyReportedWeapons } from "~/features/sendouq/q-hooks"; import type { MainWeaponId, ModeShort, @@ -18,7 +18,6 @@ import type { } from "~/modules/in-game-lists/types"; import { resolveGroupNames, - resolveMatchScore, resolveTimelineMaps, resolveTimelineTeams, } from "../core/match-timeline"; @@ -184,7 +183,8 @@ function RequeueTab({ const { t } = useTranslation(["q"]); const user = useUser(); - const score = resolveMatchScore(data.match); + const { alphaWins, bravoWins } = SendouQMatch.score(data.match); + const score = { alpha: alphaWins, bravo: bravoWins }; const teams = resolveTimelineTeams(data.match, t); const maps = resolveTimelineMaps(data.match, data.reportedWeapons); @@ -256,14 +256,9 @@ function WeaponReportSection({ data: SendouQMatchLoaderData; viewerUserId: number; }) { - const weaponFetcher = useFetcher(); - const { recentlyReportedWeapons, addRecentlyReportedWeapon } = - useRecentlyReportedWeapons(); - const completedMaps = data.match.mapList.filter( (m) => m.winnerGroupId !== null, ); - if (completedMaps.length === 0) return null; const pastReported: MainWeaponId[] = data.reportedWeapons ? data.reportedWeapons @@ -271,38 +266,14 @@ function WeaponReportSection({ .map((w) => w.weaponSplId) : []; - return ( - ({ stageId: m.stageId, mode: m.mode }))} - pastReported={pastReported} - quickSelectWeaponIds={recentlyReportedWeapons} - isSubmitting={weaponFetcher.state !== "idle"} - onSubmit={(weaponSplId) => { - addRecentlyReportedWeapon(weaponSplId); - const mapIndex = pastReported.length; - if (!completedMaps[mapIndex]) return; - weaponFetcher.submit( - { - _action: "REPORT_WEAPON", - weaponSplId: String(weaponSplId), - mapIndex: String(mapIndex), - }, - { method: "post" }, - ); - }} - onUndo={() => { - const mapIndex = pastReported.length - 1; - if (mapIndex < 0) return; - weaponFetcher.submit( - { - _action: "UNDO_WEAPON_REPORT", - mapIndex: String(mapIndex), - }, - { method: "post" }, - ); - }} - /> - ); + const weaponReport = useMatchWeaponReport({ + maps: completedMaps.map((m) => ({ stageId: m.stageId, mode: m.mode })), + pastReported, + }); + + if (completedMaps.length === 0) return null; + + return ; } function ScoreConfirmerSection({ data }: { data: SendouQMatchLoaderData }) { @@ -386,9 +357,6 @@ function InProgressTab({ const fetcher = useFetcher(); const undoFetcher = useFetcher(); const cancelFetcher = useFetcher(); - const weaponFetcher = useFetcher(); - const { recentlyReportedWeapons, addRecentlyReportedWeapon } = - useRecentlyReportedWeapons(); const isStaffOnly = ownTeamId == null; @@ -422,15 +390,16 @@ function InProgressTab({ const scoreIsNotZero = alphaScore > 0 || bravoScore > 0; - const weaponReportMaps = data.match.mapList - .slice(0, reportedCount + 1) - .map((m) => ({ stageId: m.stageId, mode: m.mode })); - - const weaponPastReported: MainWeaponId[] = data.reportedWeapons - ? data.reportedWeapons - .filter((w) => w.userId === user.id) - .map((w) => w.weaponSplId) - : []; + const weaponReport = useMatchWeaponReport({ + maps: data.match.mapList + .slice(0, reportedCount + 1) + .map((m) => ({ stageId: m.stageId, mode: m.mode })), + pastReported: data.reportedWeapons + ? data.reportedWeapons + .filter((w) => w.userId === user.id) + .map((w) => w.weaponSplId) + : [], + }); const groupNames = resolveGroupNames(data.match, t); @@ -457,39 +426,7 @@ function InProgressTab({ { method: "post" }, ); }} - weaponReport={ - isStaffOnly - ? undefined - : { - maps: weaponReportMaps, - pastReported: weaponPastReported, - quickSelectWeaponIds: recentlyReportedWeapons, - isSubmitting: weaponFetcher.state !== "idle", - onSubmit: (weaponSplId) => { - addRecentlyReportedWeapon(weaponSplId); - const mapIndex = weaponPastReported.length; - weaponFetcher.submit( - { - _action: "REPORT_WEAPON", - weaponSplId: String(weaponSplId), - mapIndex: String(mapIndex), - }, - { method: "post" }, - ); - }, - onUndo: () => { - const mapIndex = weaponPastReported.length - 1; - if (mapIndex < 0) return; - weaponFetcher.submit( - { - _action: "UNDO_WEAPON_REPORT", - mapIndex: String(mapIndex), - }, - { method: "post" }, - ); - }, - } - } + weaponReport={isStaffOnly ? undefined : weaponReport} actionButtons={ <> {isStaffOnly ? ( diff --git a/app/features/sendouq-match/components/SendouQMatchBanner.tsx b/app/features/sendouq-match/components/SendouQMatchBanner.tsx index 456190b16..7f17064e4 100644 --- a/app/features/sendouq-match/components/SendouQMatchBanner.tsx +++ b/app/features/sendouq-match/components/SendouQMatchBanner.tsx @@ -118,11 +118,7 @@ function SendouQMatchBannerTopRow({ }) { const now = useAutoRerender("ten seconds"); - const countScore = (groupId: number) => - data.match.mapList.reduce( - (acc, map) => acc + (map.winnerGroupId === groupId ? 1 : 0), - 0, - ); + const { alphaWins, bravoWins } = SendouQMatch.score(data.match); const startedAt = databaseTimestampToDate(data.match.createdAt); @@ -138,8 +134,8 @@ function SendouQMatchBannerTopRow({ return ( v.isContinuing === false).map((v) => v.userId); + return votes.filter((v) => !v.isContinuing).map((v) => v.userId); } diff --git a/app/features/sendouq-match/core/SendouQMatch.ts b/app/features/sendouq-match/core/SendouQMatch.ts index 4c3e43b07..ba3c8f939 100644 --- a/app/features/sendouq-match/core/SendouQMatch.ts +++ b/app/features/sendouq-match/core/SendouQMatch.ts @@ -1,5 +1,10 @@ import { SENDOUQ_BEST_OF } from "~/features/sendouq/q-constants"; +/** + * Calculates the current map win counts for each group in a SendouQ match and + * indicates whether the match has been decided (i.e. one group has reached the + * required number of map wins for the configured best-of). + */ export function score(match: { mapList: Array<{ winnerGroupId: number | null }>; groupAlpha: { id: number }; diff --git a/app/features/sendouq-match/core/match-timeline.test.ts b/app/features/sendouq-match/core/match-timeline.test.ts index f3d13fff9..c8a4e6971 100644 --- a/app/features/sendouq-match/core/match-timeline.test.ts +++ b/app/features/sendouq-match/core/match-timeline.test.ts @@ -3,7 +3,6 @@ import { describe, expect, test } from "vitest"; import type { SendouQMatchLoaderData } from "../loaders/q.match.$id.server"; import { resolveGroupNames, - resolveMatchScore, resolveTimelineMaps, resolveTimelineSpChanges, resolveTimelineTeams, @@ -227,20 +226,3 @@ describe("resolveTimelineSpChanges()", () => { expect(result?.alpha.skillDifference).toEqual({ calculated: true }); }); }); - -describe("resolveMatchScore()", () => { - test("counts wins per side and ignores unreported maps", () => { - const result = resolveMatchScore( - matchWith({ - mapList: [ - { winnerGroupId: ALPHA_ID }, - { winnerGroupId: ALPHA_ID }, - { winnerGroupId: BRAVO_ID }, - { winnerGroupId: null }, - ], - }), - ); - - expect(result).toEqual({ alpha: 2, bravo: 1 }); - }); -}); diff --git a/app/features/sendouq-match/core/match-timeline.ts b/app/features/sendouq-match/core/match-timeline.ts index 930a3e67f..cd51da73d 100644 --- a/app/features/sendouq-match/core/match-timeline.ts +++ b/app/features/sendouq-match/core/match-timeline.ts @@ -46,13 +46,13 @@ export function resolveTimelineMaps( const w = reportedWeapons?.find( (rw) => rw.mapIndex === mapIndex && rw.userId === member.id, ); - return w ? w.weaponSplId : null; + return w?.weaponSplId ?? null; }); const bravoWeapons = match.groupBravo.members.map((member) => { const w = reportedWeapons?.find( (rw) => rw.mapIndex === mapIndex && rw.userId === member.id, ); - return w ? w.weaponSplId : null; + return w?.weaponSplId ?? null; }); const hasAnyWeapon = @@ -62,7 +62,9 @@ export function resolveTimelineMaps( return { stageId: map.stageId, mode: map.mode, - timestamp: databaseTimestampToJavascriptTimestamp(map.reportedAt!), + timestamp: databaseTimestampToJavascriptTimestamp( + map.reportedAt ?? match.createdAt, + ), winner: map.winnerGroupId === match.groupAlpha.id ? ("ALPHA" as const) @@ -120,12 +122,3 @@ export function resolveTimelineSpChanges( }, }; } - -export function resolveMatchScore(match: MatchData) { - return { - alpha: match.mapList.filter((m) => m.winnerGroupId === match.groupAlpha.id) - .length, - bravo: match.mapList.filter((m) => m.winnerGroupId === match.groupBravo.id) - .length, - }; -} diff --git a/app/features/sendouq-match/routes/q.match.$id.tsx b/app/features/sendouq-match/routes/q.match.$id.tsx index e57f4d5f5..ae854a5b6 100644 --- a/app/features/sendouq-match/routes/q.match.$id.tsx +++ b/app/features/sendouq-match/routes/q.match.$id.tsx @@ -1,7 +1,10 @@ +import type { MetaFunction } from "react-router"; import { useLoaderData } from "react-router"; import { Main } from "~/components/Main"; import { MatchPage } from "~/components/match-page/MatchPage"; +import { metaTags, type SerializeFrom } from "~/utils/remix"; import type { SendouRouteHandle } from "~/utils/remix.server"; +import { navIconUrl, SENDOUQ_PAGE } from "~/utils/urls"; import { action } from "../actions/q.match.$id.server"; import { SendouQMatchBanner } from "../components/SendouQMatchBanner"; import { SendouQMatchHeader } from "../components/SendouQMatchHeader"; @@ -10,8 +13,29 @@ import { loader } from "../loaders/q.match.$id.server"; export { action, loader }; +export const meta: MetaFunction = (args) => { + const data = args.data as SerializeFrom | null; + + if (!data) return []; + + return metaTags({ + title: `SendouQ - Match #${data.match.id}`, + description: `${new Intl.ListFormat("en-US").format( + data.match.groupAlpha.members.map((m) => m.username), + )} vs. ${new Intl.ListFormat("en-US").format( + data.match.groupBravo.members.map((m) => m.username), + )}`, + location: args.location, + }); +}; + export const handle: SendouRouteHandle = { i18n: ["q"], + breadcrumb: () => ({ + imgPath: navIconUrl("sendouq"), + href: SENDOUQ_PAGE, + type: "IMAGE", + }), }; export default function SendouQMatchPage() { diff --git a/app/features/tournament-match/components/TournamentMatchActionTab.tsx b/app/features/tournament-match/components/TournamentMatchActionTab.tsx index cc4f3b825..cbad95256 100644 --- a/app/features/tournament-match/components/TournamentMatchActionTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchActionTab.tsx @@ -6,12 +6,9 @@ import { SendouButton } from "~/components/elements/Button"; import { SendouTabPanel } from "~/components/elements/Tabs"; import { MatchActionTab } from "~/components/match-page/MatchActionTab"; import { TAB_KEYS } from "~/components/match-page/MatchTabs"; -import { - WeaponReporter, - type WeaponReporterProps, -} from "~/components/match-page/WeaponReporter"; +import { useMatchWeaponReport } from "~/components/match-page/useMatchWeaponReport"; +import { WeaponReporter } from "~/components/match-page/WeaponReporter"; import { useUser } from "~/features/auth/core/user"; -import { useRecentlyReportedWeapons } from "~/features/sendouq/q-hooks"; import { useTournament } from "~/features/tournament/routes/to.$id"; import type { MainWeaponId } from "~/modules/in-game-lists/types"; import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates"; @@ -159,16 +156,7 @@ function useTournamentWeaponReport({ }: { data: TournamentMatchLoaderData; viewerUserId: number | undefined; -}): WeaponReporterProps | null { - const weaponFetcher = useFetcher(); - const { recentlyReportedWeapons, addRecentlyReportedWeapon } = - useRecentlyReportedWeapons(); - - if (viewerUserId === undefined) return null; - - const isParticipant = data.match.players.some((p) => p.id === viewerUserId); - if (!isParticipant) return null; - +}) { const playOrderMaps = (data.mapList ?? []).filter( (m) => !m.bannedByTournamentTeamId, ); @@ -177,43 +165,26 @@ function useTournamentWeaponReport({ .slice(0, reportedCount + 1) .map((m) => ({ stageId: m.stageId, mode: m.mode })); - if (weaponReportMaps.length === 0) return null; + const pastReported: MainWeaponId[] = + data.reportedWeapons && viewerUserId !== undefined + ? data.reportedWeapons + .filter((w) => w.userId === viewerUserId) + .map((w) => w.weaponSplId) + : []; - const pastReported: MainWeaponId[] = data.reportedWeapons - ? data.reportedWeapons - .filter((w) => w.userId === viewerUserId) - .map((w) => w.weaponSplId) - : []; - - return { + const weaponReport = useMatchWeaponReport({ maps: weaponReportMaps, pastReported, - quickSelectWeaponIds: recentlyReportedWeapons, - isSubmitting: weaponFetcher.state !== "idle", - onSubmit: (weaponSplId) => { - addRecentlyReportedWeapon(weaponSplId); - const mapIndex = pastReported.length; - weaponFetcher.submit( - { - _action: "REPORT_WEAPON", - weaponSplId: String(weaponSplId), - mapIndex: String(mapIndex), - }, - { method: "post" }, - ); - }, - onUndo: () => { - const mapIndex = pastReported.length - 1; - if (mapIndex < 0) return; - weaponFetcher.submit( - { - _action: "UNDO_WEAPON_REPORT", - mapIndex: String(mapIndex), - }, - { method: "post" }, - ); - }, - }; + }); + + if (viewerUserId === undefined) return null; + + const isParticipant = data.match.players.some((p) => p.id === viewerUserId); + if (!isParticipant) return null; + + if (weaponReportMaps.length === 0) return null; + + return weaponReport; } function buildSetEndingData({ diff --git a/app/features/tournament-match/components/TournamentMatchAdminTab.tsx b/app/features/tournament-match/components/TournamentMatchAdminTab.tsx index de6f9d957..3318d1452 100644 --- a/app/features/tournament-match/components/TournamentMatchAdminTab.tsx +++ b/app/features/tournament-match/components/TournamentMatchAdminTab.tsx @@ -465,7 +465,9 @@ function EditReportedScoreForm({ index: number; }) { const { t } = useTranslation(["common", "q"]); - const initialRosters = React.useMemo<[number[], number[]]>(() => { + const [checkedPlayers, setCheckedPlayers] = React.useState< + [number[], number[]] + >(() => { return [ result.participants .filter((p) => p.tournamentTeamId === teams[0].id) @@ -474,10 +476,7 @@ function EditReportedScoreForm({ .filter((p) => p.tournamentTeamId === teams[1].id) .map((p) => p.userId), ]; - }, [result, teams]); - - const [checkedPlayers, setCheckedPlayers] = - React.useState<[number[], number[]]>(initialRosters); + }); const [isKO, setIsKO] = React.useState( result.opponentOnePoints === 100 || result.opponentTwoPoints === 100, ); diff --git a/app/features/tournament-match/components/TournamentMatchTabs.tsx b/app/features/tournament-match/components/TournamentMatchTabs.tsx index e32598015..884bdb837 100644 --- a/app/features/tournament-match/components/TournamentMatchTabs.tsx +++ b/app/features/tournament-match/components/TournamentMatchTabs.tsx @@ -8,6 +8,7 @@ import type { TimelineMap, TimelinePickBanEvent, } from "~/components/match-page/MatchTimeline"; +import { resolveRoomPass } from "~/components/match-page/utils"; import { useUser } from "~/features/auth/core/user"; import { resolveActiveRoomLink, @@ -23,7 +24,7 @@ import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates"; import { tournamentTeamPage } from "~/utils/urls"; import type { TournamentMatchLoaderData } from "../loaders/to.$id.matches.$mid.server"; import { type MatchPageTeam, useMatch } from "../match-page-context"; -import { resolveHostingTeam, resolveRoomPass } from "../tournament-match-utils"; +import { resolveHostingTeam } from "../tournament-match-utils"; import { TournamentMatchActionPickBanTab } from "./TournamentMatchActionPickBanTab"; import { TournamentMatchActionTab } from "./TournamentMatchActionTab"; import { TournamentMatchAdminTab } from "./TournamentMatchAdminTab"; diff --git a/app/features/tournament-match/tournament-match-utils.test.ts b/app/features/tournament-match/tournament-match-utils.test.ts index 4c497b9e7..a80e0d5e4 100644 --- a/app/features/tournament-match/tournament-match-utils.test.ts +++ b/app/features/tournament-match/tournament-match-utils.test.ts @@ -2,7 +2,6 @@ import { describe, expect, test } from "vitest"; import { mapCountPlayedInSetWithCertainty, matchEndedEarly, - resolveRoomPass, } from "./tournament-match-utils"; const mapCountParamsToResult: { @@ -31,32 +30,6 @@ describe("mapCountPlayedInSetWithCertainty()", () => { } }); -describe("resolveRoomPass", () => { - test("returns a 4-digit password", () => { - const pass = resolveRoomPass(12345); - - expect(pass).toMatch(/^\d{4}$/); - }); - - test("returns deterministic password for a given numeric seed", () => { - const pass1 = resolveRoomPass(12345); - const pass2 = resolveRoomPass(12345); - expect(pass1).toBe(pass2); - }); - - test("returns deterministic password for a given string seed", () => { - const pass1 = resolveRoomPass("test-seed"); - const pass2 = resolveRoomPass("test-seed"); - expect(pass1).toBe(pass2); - }); - - test("returns different passwords for different seeds", () => { - const pass1 = resolveRoomPass(1); - const pass2 = resolveRoomPass(2); - expect(pass1).not.toBe(pass2); - }); -}); - describe("matchEndedEarly", () => { test("returns false when no winner", () => { expect( diff --git a/app/features/tournament-match/tournament-match-utils.ts b/app/features/tournament-match/tournament-match-utils.ts index 5abd5688a..7a9e9df17 100644 --- a/app/features/tournament-match/tournament-match-utils.ts +++ b/app/features/tournament-match/tournament-match-utils.ts @@ -6,46 +6,10 @@ import type { TournamentDataTeam } from "~/features/tournament-bracket/core/Tour import type { ModeShort, StageId } from "~/modules/in-game-lists/types"; import type { TournamentMaplistSource } from "~/modules/tournament-map-list-generator/types"; import { logger } from "~/utils/logger"; -import { seededRandom } from "~/utils/random"; export const tournamentMatchWebsocketRoom = (matchId: number) => `match__${matchId}`; -const NUM_MAP = { - "1": ["1", "2", "4"], - "2": ["2", "1", "3", "5"], - "3": ["3", "2", "6"], - "4": ["4", "1", "5", "7"], - "5": ["5", "2", "4", "6", "8"], - "6": ["6", "3", "5", "9"], - "7": ["7", "4", "8"], - "8": ["8", "7", "5", "9", "0"], - "9": ["9", "6", "8"], - "0": ["0", "8"], -}; -/** - * Generates a deterministic 4-digit Splatoon private battle room password based on the provided seed. - * - * Given the same seed, this function will always return the same password. - */ -export function resolveRoomPass(seed: number | string) { - let pass = "5"; - for (let i = 0; i < 3; i++) { - const { seededShuffle } = seededRandom(`${seed}-${i}`); - - const key = pass[i] as keyof typeof NUM_MAP; - const opts = NUM_MAP[key]; - const next = seededShuffle(opts)[0]; - pass += next; - } - - // prevent 5555 since many use it as a default pass - // making it a bit more common guess - if (pass === "5555") return "5800"; - - return pass; -} - export function resolveHostingTeam( teams: [TournamentDataTeam, TournamentDataTeam], ) { @@ -69,7 +33,7 @@ export function mapCountPlayedInSetWithCertainty({ scores: [number, number]; }) { const maxScore = Math.max(...scores); - const scoreSum = scores.reduce((acc, curr) => acc + curr, 0); + const scoreSum = R.sum(scores); return scoreSum + (Math.ceil(bestOf / 2) - maxScore); } @@ -133,18 +97,13 @@ export function isSetOverByResults({ count: number; countType: TournamentRoundMaps["type"]; }) { - const winCounts = new Map(); - - for (const result of results) { - const count = winCounts.get(result.winnerTeamId) ?? 0; - winCounts.set(result.winnerTeamId, count + 1); - } + const winCounts = R.countBy(results, (r) => r.winnerTeamId); if (countType === "PLAY_ALL") { - return R.sum(Array.from(winCounts.values())) === count; + return R.sum(Object.values(winCounts)) === count; } - const maxWins = Math.max(...Array.from(winCounts.values())); + const maxWins = Math.max(...Object.values(winCounts)); // best of return maxWins >= Math.ceil(count / 2); diff --git a/app/features/vods/routes/vods.new.tsx b/app/features/vods/routes/vods.new.tsx index 18a41e081..4a6757f7a 100644 --- a/app/features/vods/routes/vods.new.tsx +++ b/app/features/vods/routes/vods.new.tsx @@ -8,12 +8,12 @@ import { Label } from "~/components/Label"; import { Main } from "~/components/Main"; import { WeaponSelect } from "~/components/WeaponSelect"; import { YouTubeEmbed } from "~/components/YouTubeEmbed"; -import { useRecentlyReportedWeapons } from "~/features/sendouq/q-hooks"; import type { ArrayItemRenderContext, CustomFieldRenderProps } from "~/form"; import { FormFieldWrapper } from "~/form/fields/FormFieldWrapper"; import type { WeaponPoolItem } from "~/form/fields/WeaponPoolFormField"; import type { FormRenderProps } from "~/form/SendouForm"; import { SendouForm, useFormFieldContext } from "~/form/SendouForm"; +import { useRecentlyReportedWeapons } from "~/hooks/useRecentlyReportedWeapons"; import type { MainWeaponId, StageId } from "~/modules/in-game-lists/types"; import { useHasRole } from "~/modules/permissions/hooks"; import type { SendouRouteHandle } from "~/utils/remix.server"; diff --git a/app/features/sendouq/q-hooks.ts b/app/hooks/useRecentlyReportedWeapons.ts similarity index 100% rename from app/features/sendouq/q-hooks.ts rename to app/hooks/useRecentlyReportedWeapons.ts