mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 13:47:56 -05:00
SQ Match confirmation changes (#3401)
This commit is contained in:
@@ -4,6 +4,7 @@ import type * as React from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useWebHaptics } from "web-haptics/react";
|
||||
import { useCooldown } from "~/hooks/useCooldown";
|
||||
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import type { CommonUser } from "~/utils/kysely.server";
|
||||
@@ -43,6 +44,7 @@ interface MatchActionTabProps {
|
||||
onSubmit?: (data: { winnerId: number; ko?: boolean }) => void;
|
||||
isSubmitting?: boolean;
|
||||
setEnding?: SetEndingData;
|
||||
submitCooldownUntil?: number | null;
|
||||
actionButtons?: React.ReactNode;
|
||||
secondaryAction?: React.ReactNode;
|
||||
}
|
||||
@@ -56,6 +58,7 @@ export function MatchActionTab({
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
setEnding,
|
||||
submitCooldownUntil = null,
|
||||
actionButtons,
|
||||
secondaryAction,
|
||||
}: MatchActionTabProps) {
|
||||
@@ -64,8 +67,9 @@ export function MatchActionTab({
|
||||
const [isKo, setIsKo] = useState(false);
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
const { trigger } = useWebHaptics();
|
||||
const cooldownSecondsLeft = useCooldown(submitCooldownUntil);
|
||||
|
||||
const canSubmit = winnerId !== null;
|
||||
const canSubmit = winnerId !== null && cooldownSecondsLeft === 0;
|
||||
|
||||
const isOnTeam =
|
||||
ownTeamId != null &&
|
||||
@@ -177,7 +181,9 @@ export function MatchActionTab({
|
||||
className={styles.submit}
|
||||
testId="report-score-button"
|
||||
>
|
||||
{t("common:actions.submit")}
|
||||
{cooldownSecondsLeft > 0
|
||||
? `${t("common:actions.submit")} (${cooldownSecondsLeft})`
|
||||
: t("common:actions.submit")}
|
||||
</SendouButton>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,6 +16,7 @@ export type SystemMessageType =
|
||||
| "LIKE_RECEIVED"
|
||||
| "SCORE_REPORTED"
|
||||
| "SCORE_CONFIRMED"
|
||||
| "SCORE_DISPUTED"
|
||||
| "CANCEL_REPORTED"
|
||||
| "CANCEL_CONFIRMED"
|
||||
| "CANCEL_REFUSED"
|
||||
@@ -29,6 +30,7 @@ export type PersistedSystemMessageType = Extract<
|
||||
SystemMessageType,
|
||||
| "SCORE_REPORTED"
|
||||
| "SCORE_CONFIRMED"
|
||||
| "SCORE_DISPUTED"
|
||||
| "CANCEL_REPORTED"
|
||||
| "CANCEL_CONFIRMED"
|
||||
| "CANCEL_REFUSED"
|
||||
|
||||
@@ -68,6 +68,9 @@ export function Chat({
|
||||
case "SCORE_CONFIRMED": {
|
||||
return t("common:chat.systemMsg.scoreConfirmed", { name });
|
||||
}
|
||||
case "SCORE_DISPUTED": {
|
||||
return t("common:chat.systemMsg.scoreDisputed", { name });
|
||||
}
|
||||
case "CANCEL_REPORTED": {
|
||||
return t("common:chat.systemMsg.cancelReported", { name });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { add, sub } from "date-fns";
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import * as SplatoonFaker from "~/db/seed/core/SplatoonFaker";
|
||||
import * as SQGroupFactory from "~/db/seed/factories/SQGroupFactory";
|
||||
import * as SQMatchFactory from "~/db/seed/factories/SQMatchFactory";
|
||||
@@ -680,6 +680,86 @@ describe("finalizeMatch", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("reportMapWinner confirmation", () => {
|
||||
test("rejects as stale a confirmation of a set-ending report undone and re-reported since", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
try {
|
||||
const setup = await setupMatch();
|
||||
|
||||
const reportSweep = async () => {
|
||||
let reportedCount = 0;
|
||||
let result = await SQMatchRepository.reportMapWinner({
|
||||
matchId: setup.match.id,
|
||||
winnerId: setup.alphaGroupId,
|
||||
reportedByUserId: setup.alphaMembers[0].id,
|
||||
reportedCount,
|
||||
});
|
||||
while (result.status === "MAP_REPORTED") {
|
||||
reportedCount++;
|
||||
result = await SQMatchRepository.reportMapWinner({
|
||||
matchId: setup.match.id,
|
||||
winnerId: setup.alphaGroupId,
|
||||
reportedByUserId: setup.alphaMembers[0].id,
|
||||
reportedCount,
|
||||
});
|
||||
}
|
||||
expect(result.status).toBe("MATCH_REPORTED");
|
||||
|
||||
return reportedCount + 1;
|
||||
};
|
||||
|
||||
const decidingMapReportedAt = async () => {
|
||||
const maps = await fetchMapResults(setup.match.id);
|
||||
const decidingMap = maps.findLast((m) => m.winnerGroupId !== null);
|
||||
invariant(decidingMap?.reportedAt, "No deciding map reported");
|
||||
|
||||
return decidingMap.reportedAt;
|
||||
};
|
||||
|
||||
const reportedCount = await reportSweep();
|
||||
const originalReportedAt = await decidingMapReportedAt();
|
||||
|
||||
vi.setSystemTime(add(new Date(), { seconds: 5 }));
|
||||
await SQMatchRepository.undoMatchReport({
|
||||
matchId: setup.match.id,
|
||||
requestedByUserId: setup.alphaMembers[0].id,
|
||||
isStaff: false,
|
||||
});
|
||||
await SQMatchRepository.reportMapWinner({
|
||||
matchId: setup.match.id,
|
||||
winnerId: setup.alphaGroupId,
|
||||
reportedByUserId: setup.alphaMembers[0].id,
|
||||
reportedCount: reportedCount - 1,
|
||||
});
|
||||
const replacementReportedAt = await decidingMapReportedAt();
|
||||
expect(replacementReportedAt).not.toBe(originalReportedAt);
|
||||
|
||||
const staleConfirmation = await SQMatchRepository.reportMapWinner({
|
||||
matchId: setup.match.id,
|
||||
winnerId: setup.alphaGroupId,
|
||||
reportedByUserId: setup.bravoMembers[0].id,
|
||||
reportedCount,
|
||||
confirmingReportedAt: originalReportedAt,
|
||||
});
|
||||
expect(staleConfirmation.status).toBe("STALE");
|
||||
expect(
|
||||
(await SQMatchRepository.findById(setup.match.id))?.isLocked,
|
||||
).toBeFalsy();
|
||||
|
||||
const confirmation = await SQMatchRepository.reportMapWinner({
|
||||
matchId: setup.match.id,
|
||||
winnerId: setup.alphaGroupId,
|
||||
reportedByUserId: setup.bravoMembers[0].id,
|
||||
reportedCount,
|
||||
confirmingReportedAt: replacementReportedAt,
|
||||
});
|
||||
expect(confirmation.status).toBe("MATCH_FINALIZED");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("undoMatchReport", () => {
|
||||
// intended: the group stays INACTIVE after an undo so it can't re-enter the
|
||||
// queue while the disagreement is unresolved; the teams keep playing and
|
||||
|
||||
@@ -1314,12 +1314,14 @@ export async function reportMapWinner({
|
||||
winnerId,
|
||||
reportedByUserId,
|
||||
reportedCount,
|
||||
confirmingReportedAt,
|
||||
isStaffReport,
|
||||
}: {
|
||||
matchId: number;
|
||||
winnerId: number;
|
||||
reportedByUserId: number;
|
||||
reportedCount: number;
|
||||
confirmingReportedAt?: number;
|
||||
isStaffReport?: boolean;
|
||||
}): Promise<ReportMapWinnerResult> {
|
||||
const match = await findById(matchId);
|
||||
@@ -1340,7 +1342,6 @@ export async function reportMapWinner({
|
||||
isDecisive: scoreAlreadyDecisive,
|
||||
} = SendouQMatch.score(match);
|
||||
|
||||
// Confirmation flow: score is already decisive (first team reported the set-ending map)
|
||||
if (scoreAlreadyDecisive) {
|
||||
return handleMatchConfirmation({
|
||||
match,
|
||||
@@ -1348,6 +1349,7 @@ export async function reportMapWinner({
|
||||
reportedByUserId,
|
||||
existingAlphaWins,
|
||||
mapsToWin,
|
||||
confirmingReportedAt,
|
||||
isStaffReport,
|
||||
});
|
||||
}
|
||||
@@ -1421,6 +1423,7 @@ async function handleMatchConfirmation({
|
||||
reportedByUserId,
|
||||
existingAlphaWins,
|
||||
mapsToWin,
|
||||
confirmingReportedAt,
|
||||
isStaffReport,
|
||||
}: {
|
||||
match: NonNullable<Awaited<ReturnType<typeof findById>>>;
|
||||
@@ -1428,6 +1431,7 @@ async function handleMatchConfirmation({
|
||||
reportedByUserId: number;
|
||||
existingAlphaWins: number;
|
||||
mapsToWin: number;
|
||||
confirmingReportedAt?: number;
|
||||
isStaffReport?: boolean;
|
||||
}): Promise<ReportMapWinnerResult> {
|
||||
const members = buildMembers(match);
|
||||
@@ -1438,6 +1442,13 @@ async function handleMatchConfirmation({
|
||||
.find((m) => m.winnerGroupId !== null);
|
||||
invariant(decidingMap, "No deciding map found");
|
||||
|
||||
if (
|
||||
typeof confirmingReportedAt === "number" &&
|
||||
confirmingReportedAt !== decidingMap.reportedAt
|
||||
) {
|
||||
return { status: "STALE" };
|
||||
}
|
||||
|
||||
const originalReporterGroupId = decidingMap.reportedByUserId
|
||||
? members.find((m) => m.id === decidingMap.reportedByUserId)?.groupId
|
||||
: undefined;
|
||||
|
||||
@@ -71,6 +71,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
winnerId: data.winnerId,
|
||||
reportedByUserId: user.id,
|
||||
reportedCount: data.reportedCount,
|
||||
confirmingReportedAt: data.confirmingReportedAt,
|
||||
isStaffReport,
|
||||
});
|
||||
|
||||
@@ -118,6 +119,41 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
|
||||
break;
|
||||
}
|
||||
case "DISPUTE_SCORE": {
|
||||
errorToastIfFalsy(!match.isLocked, "Match is already locked");
|
||||
errorToastIfFalsy(
|
||||
SendouQMatch.score(match).isDecisive,
|
||||
"No reported score to dispute",
|
||||
);
|
||||
|
||||
const decidingMap = match.mapList
|
||||
.toReversed()
|
||||
.find((m) => m.winnerGroupId !== null);
|
||||
const reporterSide = SendouQMatch.resolveGroupMemberOf({
|
||||
groupAlpha: match.groupAlpha,
|
||||
groupBravo: match.groupBravo,
|
||||
userId: decidingMap?.reportedByUserId,
|
||||
});
|
||||
const disputerSide = SendouQMatch.resolveGroupMemberOf({
|
||||
groupAlpha: match.groupAlpha,
|
||||
groupBravo: match.groupBravo,
|
||||
userId: user.id,
|
||||
});
|
||||
errorToastIfFalsy(
|
||||
disputerSide !== null && disputerSide !== reporterSide,
|
||||
"Only the team asked to confirm can dispute the score",
|
||||
);
|
||||
|
||||
if (match.chatRoomId) {
|
||||
ChatSystemMessage.sendPersisted({
|
||||
roomId: match.chatRoomId,
|
||||
type: "SCORE_DISPUTED",
|
||||
authorUserId: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "LOOK_AGAIN": {
|
||||
const season = Seasons.current();
|
||||
errorToastIfFalsy(season, "Season is not active");
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { Ban, Check, Undo2, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFetcher } from "react-router";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
@@ -16,6 +17,7 @@ import { FormField } from "~/form/FormField";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import type { FormObjectSchema } from "~/form/types";
|
||||
import { useActionSubmit } from "~/hooks/useActionSubmit";
|
||||
import { useCooldown } from "~/hooks/useCooldown";
|
||||
import type { ModeShort, StageId } from "~/modules/in-game-lists/types";
|
||||
import {
|
||||
resolveGroupNames,
|
||||
@@ -36,6 +38,10 @@ import {
|
||||
} from "./RejoinSections";
|
||||
import styles from "./SendouQMatchActionTab.module.css";
|
||||
|
||||
const CONFIRM_COOLDOWN_MS = 5_000;
|
||||
const CONFIRM_LOSS_ARMED_MS = 5_000;
|
||||
const MAP_CHANGED_COOLDOWN_MS = 10_000;
|
||||
|
||||
export function SendouQMatchActionTab({
|
||||
data,
|
||||
currentMap,
|
||||
@@ -259,7 +265,7 @@ function RequeueTab({
|
||||
<MatchTimeline compact teams={teams} score={score} maps={maps} />
|
||||
) : null}
|
||||
{isStaffOnly && awaitingConfirmation ? (
|
||||
<ScoreConfirmerSection data={data} />
|
||||
<ScoreConfirmerSection data={data} viewerSide={null} />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
@@ -287,7 +293,9 @@ function RequeueTab({
|
||||
{showTimeline ? (
|
||||
<MatchTimeline compact teams={teams} score={score} maps={maps} />
|
||||
) : null}
|
||||
{isOnConfirmerTeam ? <ScoreConfirmerSection data={data} /> : null}
|
||||
{isOnConfirmerTeam ? (
|
||||
<ScoreConfirmerSection data={data} viewerSide={viewerSide} />
|
||||
) : null}
|
||||
{isOnReporterTeam ? <ReporterUndoSection /> : null}
|
||||
{data.match.isCanceled ? null : (
|
||||
<WeaponReportSection data={data} viewerUserId={user.id} />
|
||||
@@ -329,10 +337,22 @@ function WeaponReportSection({
|
||||
return <WeaponReporter {...weaponReport} />;
|
||||
}
|
||||
|
||||
function ScoreConfirmerSection({ data }: { data: SendouQMatchLoaderData }) {
|
||||
function ScoreConfirmerSection({
|
||||
data,
|
||||
viewerSide,
|
||||
}: {
|
||||
data: SendouQMatchLoaderData;
|
||||
/** `null` for staff confirming on a team's behalf, who get a neutral button. */
|
||||
viewerSide: "ALPHA" | "BRAVO" | null;
|
||||
}) {
|
||||
const { t } = useTranslation(["q"]);
|
||||
const { submit, state } = useActionSubmit(matchSchema);
|
||||
const confirmFetcherPending = state !== "idle";
|
||||
const confirmScore = useActionSubmit(matchSchema);
|
||||
const disputeScore = useActionSubmit(matchSchema);
|
||||
const [cooldownUntil] = useState(() => Date.now() + CONFIRM_COOLDOWN_MS);
|
||||
const cooldownSecondsLeft = useCooldown(cooldownUntil);
|
||||
const [lossArmedUntil, setLossArmedUntil] = useState<number | null>(null);
|
||||
const isLossArmed = useCooldown(lossArmedUntil) > 0;
|
||||
const [hasDisputed, setHasDisputed] = useState(false);
|
||||
|
||||
const decidingMap = [...data.match.mapList]
|
||||
.reverse()
|
||||
@@ -341,24 +361,103 @@ function ScoreConfirmerSection({ data }: { data: SendouQMatchLoaderData }) {
|
||||
(m) => m.winnerGroupId !== null,
|
||||
).length;
|
||||
|
||||
const { alphaWins, bravoWins } = SendouQMatch.score(data.match);
|
||||
const winnerSide =
|
||||
decidingMap?.winnerGroupId === data.match.groupAlpha.id ? "ALPHA" : "BRAVO";
|
||||
const reporterSide = SendouQMatch.resolveGroupMemberOf({
|
||||
groupAlpha: data.match.groupAlpha,
|
||||
groupBravo: data.match.groupBravo,
|
||||
userId: decidingMap?.reportedByUserId,
|
||||
});
|
||||
const groupNames = resolveGroupNames(data.match, t);
|
||||
const scoreFor = (side: "ALPHA" | "BRAVO") =>
|
||||
side === "ALPHA"
|
||||
? `${alphaWins}-${bravoWins}`
|
||||
: `${bravoWins}-${alphaWins}`;
|
||||
|
||||
const outcome =
|
||||
viewerSide === null ? null : viewerSide === winnerSide ? "win" : "loss";
|
||||
const ownScore = viewerSide === null ? null : scoreFor(viewerSide);
|
||||
|
||||
const buttonLabel = () => {
|
||||
if (outcome === "win") {
|
||||
return t("q:match.confirmScore.win", { score: ownScore });
|
||||
}
|
||||
if (outcome === "loss") {
|
||||
return isLossArmed
|
||||
? t("q:match.confirmScore.lossAgain")
|
||||
: t("q:match.confirmScore.loss", { score: ownScore });
|
||||
}
|
||||
return t("q:match.confirmScore");
|
||||
};
|
||||
|
||||
const submitConfirmation = () => {
|
||||
if (!decidingMap?.winnerGroupId) return;
|
||||
confirmScore.submit("REPORT_SCORE", {
|
||||
winnerId: decidingMap.winnerGroupId,
|
||||
reportedCount,
|
||||
confirmingReportedAt: decidingMap.reportedAt ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="stack md items-center">
|
||||
<SendouButton
|
||||
variant="primary"
|
||||
isPending={confirmFetcherPending}
|
||||
onClick={() => {
|
||||
if (!decidingMap?.winnerGroupId) return;
|
||||
submit("REPORT_SCORE", {
|
||||
winnerId: decidingMap.winnerGroupId,
|
||||
reportedCount,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("q:match.confirmScore")}
|
||||
</SendouButton>
|
||||
{reporterSide ? (
|
||||
<p className="text-sm text-center">
|
||||
{reporterSide === winnerSide
|
||||
? t("q:match.confirmScore.reportedWin", {
|
||||
team: groupNames[reporterSide === "ALPHA" ? "alpha" : "bravo"],
|
||||
score: scoreFor(reporterSide),
|
||||
})
|
||||
: t("q:match.confirmScore.reportedLoss", {
|
||||
team: groupNames[reporterSide === "ALPHA" ? "alpha" : "bravo"],
|
||||
score: scoreFor(reporterSide),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-lighter text-xs text-center">
|
||||
{t("q:match.confirmScore.wrongHint")}
|
||||
{t("q:match.confirmScore.check")}
|
||||
</p>
|
||||
<SendouButton
|
||||
variant={
|
||||
outcome === "win"
|
||||
? "success"
|
||||
: outcome === "loss"
|
||||
? "destructive"
|
||||
: "primary"
|
||||
}
|
||||
isDisabled={cooldownSecondsLeft > 0}
|
||||
isPending={confirmScore.state !== "idle"}
|
||||
onClick={() => {
|
||||
if (outcome === "loss" && !isLossArmed) {
|
||||
setLossArmedUntil(Date.now() + CONFIRM_LOSS_ARMED_MS);
|
||||
return;
|
||||
}
|
||||
submitConfirmation();
|
||||
}}
|
||||
testId="confirm-score-button"
|
||||
>
|
||||
{cooldownSecondsLeft > 0
|
||||
? `${buttonLabel()} (${cooldownSecondsLeft})`
|
||||
: buttonLabel()}
|
||||
</SendouButton>
|
||||
{viewerSide === null ? null : hasDisputed ? (
|
||||
<p className="text-lighter text-xs text-center">
|
||||
{t("q:match.confirmScore.wrongSent")}
|
||||
</p>
|
||||
) : (
|
||||
<SendouButton
|
||||
variant="minimal-destructive"
|
||||
size="small"
|
||||
isPending={disputeScore.state !== "idle"}
|
||||
onClick={() => {
|
||||
setHasDisputed(true);
|
||||
disputeScore.submit("DISPUTE_SCORE");
|
||||
}}
|
||||
>
|
||||
{t("q:match.confirmScore.wrong")}
|
||||
</SendouButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -404,6 +503,26 @@ function InProgressTab({
|
||||
const undoReport = useActionSubmit(matchSchema);
|
||||
const cancelFetcher = useFetcher();
|
||||
|
||||
// the reported count this viewer's own last report or undo would move the match
|
||||
// to; a change to any other count came from someone else
|
||||
const [expectedReportedCount, setExpectedReportedCount] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [mapChange, setMapChange] = useState({
|
||||
reportedCount,
|
||||
cooldownUntil: null as number | null,
|
||||
});
|
||||
if (mapChange.reportedCount !== reportedCount) {
|
||||
const changedByOthers = expectedReportedCount !== reportedCount;
|
||||
setMapChange({
|
||||
reportedCount,
|
||||
cooldownUntil: changedByOthers
|
||||
? Date.now() + MAP_CHANGED_COOLDOWN_MS
|
||||
: null,
|
||||
});
|
||||
setExpectedReportedCount(null);
|
||||
}
|
||||
|
||||
const isStaffOnly = ownTeamId == null;
|
||||
|
||||
const {
|
||||
@@ -462,7 +581,9 @@ function InProgressTab({
|
||||
withKo={false}
|
||||
isSubmitting={reportScore.state !== "idle"}
|
||||
setEnding={setEnding}
|
||||
submitCooldownUntil={mapChange.cooldownUntil}
|
||||
onSubmit={({ winnerId }) => {
|
||||
setExpectedReportedCount(reportedCount + 1);
|
||||
reportScore.submit("REPORT_SCORE", { winnerId, reportedCount });
|
||||
}}
|
||||
secondaryAction={
|
||||
@@ -513,6 +634,7 @@ function InProgressTab({
|
||||
(m) => m.winnerGroupId !== null,
|
||||
);
|
||||
if (mapIndex < 0) return;
|
||||
setExpectedReportedCount(reportedCount - 1);
|
||||
undoReport.submit("UNDO_MAP_REPORT", { mapIndex });
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -35,6 +35,12 @@ export const matchSchema = v.union([
|
||||
_action: _action("REPORT_SCORE"),
|
||||
winnerId: id,
|
||||
reportedCount: v.pipe(coerceNumber(), v.integer(), v.minValue(0)),
|
||||
confirmingReportedAt: v.optional(
|
||||
v.pipe(coerceNumber(), v.integer(), v.minValue(0)),
|
||||
),
|
||||
}),
|
||||
v.object({
|
||||
_action: _action("DISPUTE_SCORE"),
|
||||
}),
|
||||
v.object({
|
||||
_action: _action("LOOK_AGAIN"),
|
||||
|
||||
74
app/hooks/useCooldown.ts
Normal file
74
app/hooks/useCooldown.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import * as React from "react";
|
||||
|
||||
interface CooldownStore {
|
||||
subscribe: (listener: () => void) => () => void;
|
||||
getSnapshot: () => number;
|
||||
getServerSnapshot: () => number;
|
||||
}
|
||||
|
||||
const cooldownStores = new Map<number, CooldownStore>();
|
||||
|
||||
function secondsLeftAt(until: number, now: number) {
|
||||
return Math.max(0, Math.ceil((until - now) / 1000));
|
||||
}
|
||||
|
||||
function getCooldownStore(until: number): CooldownStore {
|
||||
const existing = cooldownStores.get(until);
|
||||
if (existing) return existing;
|
||||
|
||||
const initialSecondsLeft = secondsLeftAt(until, Date.now());
|
||||
let secondsLeft = initialSecondsLeft;
|
||||
const listeners = new Set<() => void>();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const scheduleNextTick = () => {
|
||||
const now = Date.now();
|
||||
if (now >= until) return;
|
||||
|
||||
const msIntoSecond = ((until - now) % 1000) + 1;
|
||||
timeout = setTimeout(() => {
|
||||
secondsLeft = secondsLeftAt(until, Date.now());
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
}
|
||||
scheduleNextTick();
|
||||
}, msIntoSecond);
|
||||
};
|
||||
|
||||
const store: CooldownStore = {
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
if (listeners.size === 1) {
|
||||
secondsLeft = secondsLeftAt(until, Date.now());
|
||||
scheduleNextTick();
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
clearTimeout(timeout);
|
||||
cooldownStores.delete(until);
|
||||
}
|
||||
};
|
||||
},
|
||||
getSnapshot: () => secondsLeft,
|
||||
getServerSnapshot: () => initialSecondsLeft,
|
||||
};
|
||||
cooldownStores.set(until, store);
|
||||
return store;
|
||||
}
|
||||
|
||||
const noopStore: CooldownStore = {
|
||||
subscribe: () => () => {},
|
||||
getSnapshot: () => 0,
|
||||
getServerSnapshot: () => 0,
|
||||
};
|
||||
|
||||
export function useCooldown(until: number | null): number {
|
||||
const store = until === null ? noopStore : getCooldownStore(until);
|
||||
|
||||
return React.useSyncExternalStore(
|
||||
store.subscribe,
|
||||
store.getSnapshot,
|
||||
store.getServerSnapshot,
|
||||
);
|
||||
}
|
||||
10
changelog/2026-09-19-sendouq-score-confirm-safeguards.md
Normal file
10
changelog/2026-09-19-sendouq-score-confirm-safeguards.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
navItem: sendouq
|
||||
type: feature
|
||||
---
|
||||
SendouQ score reporting is harder to get wrong by accident
|
||||
|
||||
- The confirm button is now disabled for a few seconds before it can be pressed
|
||||
- Confirming a set now shows the score and the outcome for your team, and reporting a loss requires a double tap
|
||||
- Added a "Score is wrong" button which tells the other team in the match chat to undo their report
|
||||
- When someone else reports a map result while you have the "Action" tab open, the submit button is now disabled for ten seconds so you can't accidentally report the next map
|
||||
@@ -43,7 +43,7 @@ export class SendouQMatchPage {
|
||||
undoReportButton: page.getByRole("button", { name: "Undo report" }),
|
||||
reportWeaponsButton: page.getByTestId("expand-secondary-action-button"),
|
||||
undoWeaponButton: page.getByRole("button", { name: "Undo weapon" }),
|
||||
confirmScoreButton: page.getByRole("button", { name: "Confirm score" }),
|
||||
confirmScoreButton: page.getByTestId("confirm-score-button"),
|
||||
requestCancelButton: page.getByRole("button", { name: "Request cancel" }),
|
||||
cancelPendingText: page.getByText("Pending other team's confirmation"),
|
||||
cancelPrompt: page.getByText("Accept canceling the set?"),
|
||||
@@ -152,8 +152,19 @@ export class SendouQMatchPage {
|
||||
}
|
||||
|
||||
async confirmScore() {
|
||||
await expect(this.locators.confirmScoreButton).toBeEnabled({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await this.locators.confirmScoreButton.click();
|
||||
|
||||
const armedLossButton = this.locators.confirmScoreButton.filter({
|
||||
hasText: "Tap again",
|
||||
});
|
||||
|
||||
await waitForPOSTResponse(this.page, async () => {
|
||||
await this.locators.confirmScoreButton.click();
|
||||
if (await armedLossButton.isVisible()) {
|
||||
await armedLossButton.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "",
|
||||
"chat.systemMsg.scoreReported": "",
|
||||
"chat.systemMsg.scoreConfirmed": "",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "",
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "",
|
||||
"match.weapon.undoWeapon": "",
|
||||
"match.confirmScore": "",
|
||||
"match.confirmScore.wrongHint": "",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "",
|
||||
"match.rematch.resolved": "",
|
||||
"match.rematch.vote.yes": "",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "",
|
||||
"chat.systemMsg.scoreReported": "",
|
||||
"chat.systemMsg.scoreConfirmed": "",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "",
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "",
|
||||
"match.weapon.undoWeapon": "",
|
||||
"match.confirmScore": "",
|
||||
"match.confirmScore.wrongHint": "",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "",
|
||||
"match.rematch.resolved": "",
|
||||
"match.rematch.vote.yes": "",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "Send",
|
||||
"chat.systemMsg.scoreReported": "{{name}} reported score",
|
||||
"chat.systemMsg.scoreConfirmed": "{{name}} confirmed score. Match is now locked",
|
||||
"chat.systemMsg.scoreDisputed": "{{name}} says the reported score is wrong.",
|
||||
"chat.systemMsg.cancelReported": "{{name}} requested canceling the match",
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} confirmed canceling the match. Match is now locked",
|
||||
"chat.systemMsg.cancelRefused": "{{name}} refused canceling the match",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "Your weapon",
|
||||
"match.weapon.undoWeapon": "Undo weapon",
|
||||
"match.confirmScore": "Confirm score",
|
||||
"match.confirmScore.wrongHint": "Wrong score? Ask the other team to undo their report and adjust it.",
|
||||
"match.confirmScore.win": "Confirm {{score}} win",
|
||||
"match.confirmScore.loss": "Confirm {{score}} loss",
|
||||
"match.confirmScore.lossAgain": "Tap again to confirm loss",
|
||||
"match.confirmScore.reportedWin": "{{team}} reported a {{score}} win",
|
||||
"match.confirmScore.reportedLoss": "{{team}} reported a {{score}} loss",
|
||||
"match.confirmScore.check": "Check the score above. Confirming can't be undone.",
|
||||
"match.confirmScore.wrong": "Score is wrong",
|
||||
"match.confirmScore.wrongSent": "The other team was informed in the match chat.",
|
||||
"match.rematch.prompt": "Continue queueing with the group of {{count}}?",
|
||||
"match.rematch.resolved": "New group of {{count}} formed",
|
||||
"match.rematch.vote.yes": "Yes, continue",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "Enviar",
|
||||
"chat.systemMsg.scoreReported": "{{name}} ha reportado el resultado",
|
||||
"chat.systemMsg.scoreConfirmed": "{{name}} ha confirmado el resultado. La partida está cerrada",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "{{name}} pidió cancelar la partida",
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar la partida. La partida está cerrada",
|
||||
"chat.systemMsg.cancelRefused": "{{name}} ha rechazado cancelar la partida",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "Tu arma",
|
||||
"match.weapon.undoWeapon": "Deshacer arma",
|
||||
"match.confirmScore": "Confirmar resultado",
|
||||
"match.confirmScore.wrongHint": "¿Resultado incorrecto? Pide al otro equipo que deshaga su reporte y lo ajuste.",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "¿Continuar en la cola con el grupo de {{count}}?",
|
||||
"match.rematch.resolved": "Nuevo grupo de {{count}} formado",
|
||||
"match.rematch.vote.yes": "Sí, continuar",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "Enviar",
|
||||
"chat.systemMsg.scoreReported": "{{name}} reportó el puntaje",
|
||||
"chat.systemMsg.scoreConfirmed": "{{name}} confirmó el puntaje. La partida está cerrada",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "{{name}} pidió cancelar la partida",
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} confirmó cancelar la partida. La partida está cerrada",
|
||||
"chat.systemMsg.cancelRefused": "{{name}} ha rechazado cancelar la partida",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "Tu arma",
|
||||
"match.weapon.undoWeapon": "Deshacer arma",
|
||||
"match.confirmScore": "Confirmar resultado",
|
||||
"match.confirmScore.wrongHint": "¿Resultado incorrecto? Pide al otro equipo que deshaga su reporte y lo ajuste.",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "¿Continuar en la cola con el grupo de {{count}}?",
|
||||
"match.rematch.resolved": "Nuevo grupo de {{count}} formado",
|
||||
"match.rematch.vote.yes": "Sí, continuar",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "",
|
||||
"chat.systemMsg.scoreReported": "",
|
||||
"chat.systemMsg.scoreConfirmed": "",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "",
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "",
|
||||
"match.weapon.undoWeapon": "",
|
||||
"match.confirmScore": "",
|
||||
"match.confirmScore.wrongHint": "",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "",
|
||||
"match.rematch.resolved": "",
|
||||
"match.rematch.vote.yes": "",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "Envoyer",
|
||||
"chat.systemMsg.scoreReported": "{{name}} a reporté le score",
|
||||
"chat.systemMsg.scoreConfirmed": "{{name}} a confirmé le score. Le match est maintenant vérrouillé",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "{{name}} a demandé d'annuler le match",
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} a confirmé l'anunulation du match. Le match est maintenant vérrouillé",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "",
|
||||
"match.weapon.undoWeapon": "",
|
||||
"match.confirmScore": "",
|
||||
"match.confirmScore.wrongHint": "",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "",
|
||||
"match.rematch.resolved": "",
|
||||
"match.rematch.vote.yes": "",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "",
|
||||
"chat.systemMsg.scoreReported": "",
|
||||
"chat.systemMsg.scoreConfirmed": "",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "",
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "",
|
||||
"match.weapon.undoWeapon": "",
|
||||
"match.confirmScore": "",
|
||||
"match.confirmScore.wrongHint": "",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "",
|
||||
"match.rematch.resolved": "",
|
||||
"match.rematch.vote.yes": "",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "Invia",
|
||||
"chat.systemMsg.scoreReported": "{{name}} ha riportato il punteggio",
|
||||
"chat.systemMsg.scoreConfirmed": "{{name}} ha confermato il punteggio. Il match è ora bloccato",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "{{name}} ha richiesto la cancellazione del match",
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} ha confermato la cancellazione del match. Il match è ora bloccato",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "",
|
||||
"match.weapon.undoWeapon": "",
|
||||
"match.confirmScore": "",
|
||||
"match.confirmScore.wrongHint": "",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "",
|
||||
"match.rematch.resolved": "",
|
||||
"match.rematch.vote.yes": "",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "送る",
|
||||
"chat.systemMsg.scoreReported": "{{name}}がスコアを報告しました",
|
||||
"chat.systemMsg.scoreConfirmed": "{{name}}がスコアを確認しました。試合がロックされました",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "{{name}} が試合のキャンセルを申請しました",
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} が試合キャンセルを承認しました。試合がロックされました",
|
||||
"chat.systemMsg.cancelRefused": "{{name}} が試合キャンセルを却下しました。",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "",
|
||||
"match.weapon.undoWeapon": "",
|
||||
"match.confirmScore": "",
|
||||
"match.confirmScore.wrongHint": "",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "",
|
||||
"match.rematch.resolved": "",
|
||||
"match.rematch.vote.yes": "",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "",
|
||||
"chat.systemMsg.scoreReported": "",
|
||||
"chat.systemMsg.scoreConfirmed": "",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "",
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "",
|
||||
"match.weapon.undoWeapon": "",
|
||||
"match.confirmScore": "",
|
||||
"match.confirmScore.wrongHint": "",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "",
|
||||
"match.rematch.resolved": "",
|
||||
"match.rematch.vote.yes": "",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "",
|
||||
"chat.systemMsg.scoreReported": "",
|
||||
"chat.systemMsg.scoreConfirmed": "",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "",
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "",
|
||||
"match.weapon.undoWeapon": "",
|
||||
"match.confirmScore": "",
|
||||
"match.confirmScore.wrongHint": "",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "",
|
||||
"match.rematch.resolved": "",
|
||||
"match.rematch.vote.yes": "",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "",
|
||||
"chat.systemMsg.scoreReported": "",
|
||||
"chat.systemMsg.scoreConfirmed": "",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "",
|
||||
"chat.systemMsg.cancelConfirmed": "",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "",
|
||||
"match.weapon.undoWeapon": "",
|
||||
"match.confirmScore": "",
|
||||
"match.confirmScore.wrongHint": "",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "",
|
||||
"match.rematch.resolved": "",
|
||||
"match.rematch.vote.yes": "",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "Enviar",
|
||||
"chat.systemMsg.scoreReported": "{{name}} declarou a pontuação",
|
||||
"chat.systemMsg.scoreConfirmed": "{{name}} confirmou a pontuação. A partida foi trancada",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "{{name}} solicitou o cancelamento da partida",
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} confirmou o cancelamento da partida. A partida foi trancada",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "",
|
||||
"match.weapon.undoWeapon": "",
|
||||
"match.confirmScore": "",
|
||||
"match.confirmScore.wrongHint": "",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "",
|
||||
"match.rematch.resolved": "",
|
||||
"match.rematch.vote.yes": "",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "Отправить",
|
||||
"chat.systemMsg.scoreReported": "{{name}} сообщил результаты",
|
||||
"chat.systemMsg.scoreConfirmed": "{{name}} подтвердил счёт. Матч теперь закрыт",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "{{name}} запросил отмену матча",
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} подтвердил отмену матча. Матч теперь закрыт",
|
||||
"chat.systemMsg.cancelRefused": "",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "",
|
||||
"match.weapon.undoWeapon": "",
|
||||
"match.confirmScore": "",
|
||||
"match.confirmScore.wrongHint": "",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "",
|
||||
"match.rematch.resolved": "",
|
||||
"match.rematch.vote.yes": "",
|
||||
|
||||
@@ -368,6 +368,7 @@
|
||||
"chat.send": "发送",
|
||||
"chat.systemMsg.scoreReported": "{{name}} 汇报比分",
|
||||
"chat.systemMsg.scoreConfirmed": "{{name}} 确认了比分,本次对局已锁定",
|
||||
"chat.systemMsg.scoreDisputed": "",
|
||||
"chat.systemMsg.cancelReported": "{{name}} 申请取消对局",
|
||||
"chat.systemMsg.cancelConfirmed": "{{name}} 确认取消对局,本次对局已锁定",
|
||||
"chat.systemMsg.cancelRefused": "{{name}} 拒绝取消对局",
|
||||
|
||||
@@ -151,7 +151,14 @@
|
||||
"match.weapon.yourWeapon": "您的武器",
|
||||
"match.weapon.undoWeapon": "撤销武器选择",
|
||||
"match.confirmScore": "确认比分",
|
||||
"match.confirmScore.wrongHint": "比分有误?请联系对方队伍撤销上报并进行修改。",
|
||||
"match.confirmScore.win": "",
|
||||
"match.confirmScore.loss": "",
|
||||
"match.confirmScore.lossAgain": "",
|
||||
"match.confirmScore.reportedWin": "",
|
||||
"match.confirmScore.reportedLoss": "",
|
||||
"match.confirmScore.check": "",
|
||||
"match.confirmScore.wrong": "",
|
||||
"match.confirmScore.wrongSent": "",
|
||||
"match.rematch.prompt": "要与这个由 {{count}} 人组成的小组继续排队吗?",
|
||||
"match.rematch.resolved": "已组成全新的 {{count}} 人小组",
|
||||
"match.rematch.vote.yes": "是,继续排队",
|
||||
|
||||
Reference in New Issue
Block a user