SQ Match confirmation changes (#3401)

This commit is contained in:
hfcRed
2026-09-19 11:45:16 -04:00
committed by GitHub
parent 7a35b176b1
commit 171661becb
43 changed files with 530 additions and 41 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

@@ -368,6 +368,7 @@
"chat.send": "",
"chat.systemMsg.scoreReported": "",
"chat.systemMsg.scoreConfirmed": "",
"chat.systemMsg.scoreDisputed": "",
"chat.systemMsg.cancelReported": "",
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.cancelRefused": "",

View File

@@ -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": "",

View File

@@ -368,6 +368,7 @@
"chat.send": "",
"chat.systemMsg.scoreReported": "",
"chat.systemMsg.scoreConfirmed": "",
"chat.systemMsg.scoreDisputed": "",
"chat.systemMsg.cancelReported": "",
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.cancelRefused": "",

View File

@@ -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": "",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -368,6 +368,7 @@
"chat.send": "",
"chat.systemMsg.scoreReported": "",
"chat.systemMsg.scoreConfirmed": "",
"chat.systemMsg.scoreDisputed": "",
"chat.systemMsg.cancelReported": "",
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.cancelRefused": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -368,6 +368,7 @@
"chat.send": "",
"chat.systemMsg.scoreReported": "",
"chat.systemMsg.scoreConfirmed": "",
"chat.systemMsg.scoreDisputed": "",
"chat.systemMsg.cancelReported": "",
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.cancelRefused": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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}} が試合キャンセルを却下しました。",

View File

@@ -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": "",

View File

@@ -368,6 +368,7 @@
"chat.send": "",
"chat.systemMsg.scoreReported": "",
"chat.systemMsg.scoreConfirmed": "",
"chat.systemMsg.scoreDisputed": "",
"chat.systemMsg.cancelReported": "",
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.cancelRefused": "",

View File

@@ -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": "",

View File

@@ -368,6 +368,7 @@
"chat.send": "",
"chat.systemMsg.scoreReported": "",
"chat.systemMsg.scoreConfirmed": "",
"chat.systemMsg.scoreDisputed": "",
"chat.systemMsg.cancelReported": "",
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.cancelRefused": "",

View File

@@ -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": "",

View File

@@ -368,6 +368,7 @@
"chat.send": "",
"chat.systemMsg.scoreReported": "",
"chat.systemMsg.scoreConfirmed": "",
"chat.systemMsg.scoreDisputed": "",
"chat.systemMsg.cancelReported": "",
"chat.systemMsg.cancelConfirmed": "",
"chat.systemMsg.cancelRefused": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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": "",

View File

@@ -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}} 拒绝取消对局",

View File

@@ -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": "是,继续排队",