SendouQ tweaks

This commit is contained in:
Kalle 2026-05-03 16:44:49 +03:00
parent 7bba3f25ad
commit 6224806254
25 changed files with 282 additions and 305 deletions

View File

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

View File

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

View File

@ -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<number[]>([]);
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(

View File

@ -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 `<WeaponReporter />` 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" },
);
},
};
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<DB>) {
if (groupIds.length === 0) return [];
const rows = await db
const executor = trx ?? db;
const rows = await executor
.selectFrom("GroupMatchContinueVote")
.select([
"GroupMatchContinueVote.groupId",

View File

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

View File

@ -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 (
<WeaponReporter
maps={completedMaps.map((m) => ({ 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 <WeaponReporter {...weaponReport} />;
}
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 ? (

View File

@ -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 (
<MatchBannerTopRow
score={{
alpha: countScore(data.match.groupAlpha.id),
bravo: countScore(data.match.groupBravo.id),
alpha: alphaWins,
bravo: bravoWins,
isFinal: Boolean(data.match.isLocked),
count: SENDOUQ_BEST_OF,
bestOf: true,

View File

@ -4,13 +4,13 @@ import { MatchJoinTab } from "~/components/match-page/MatchJoinTab";
import { MatchResultTab } from "~/components/match-page/MatchResultTab";
import { MatchRosterTab } from "~/components/match-page/MatchRosterTab";
import { MatchTabs } 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 { ACTION_TAB_AFTER_LOCKED_SECONDS } from "~/features/sendouq/q-constants";
import { resolveRoomPass } from "~/features/tournament-match/tournament-match-utils";
import { useHasRole } from "~/modules/permissions/hooks";
import { databaseTimestampNow } from "~/utils/dates";
import { safeNumberParse } from "~/utils/number";

View File

@ -92,5 +92,5 @@ export function currentUserIds(
}
function droppedUserIds(votes: RejoinVote[]): number[] {
return votes.filter((v) => v.isContinuing === false).map((v) => v.userId);
return votes.filter((v) => !v.isContinuing).map((v) => v.userId);
}

View File

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

View File

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

View File

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

View File

@ -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<typeof loader> | 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() {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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