mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-05-09 04:02:40 -05:00
SendouQ tweaks
This commit is contained in:
parent
7bba3f25ad
commit
6224806254
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
59
app/components/match-page/useMatchWeaponReport.ts
Normal file
59
app/components/match-page/useMatchWeaponReport.ts
Normal 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" },
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user