diff --git a/app/components/match-page/MatchActionTab.tsx b/app/components/match-page/MatchActionTab.tsx index a060ca14f..725e0c384 100644 --- a/app/components/match-page/MatchActionTab.tsx +++ b/app/components/match-page/MatchActionTab.tsx @@ -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")} )} diff --git a/app/features/chat/chat-types.ts b/app/features/chat/chat-types.ts index 42a7eed8d..5c172a1fe 100644 --- a/app/features/chat/chat-types.ts +++ b/app/features/chat/chat-types.ts @@ -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" diff --git a/app/features/chat/components/Chat.tsx b/app/features/chat/components/Chat.tsx index 4b5610675..3a9624580 100644 --- a/app/features/chat/components/Chat.tsx +++ b/app/features/chat/components/Chat.tsx @@ -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 }); } diff --git a/app/features/sendouq-match/SQMatchRepository.server.test.ts b/app/features/sendouq-match/SQMatchRepository.server.test.ts index 9d045cc75..94011fbfe 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.test.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.test.ts @@ -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 diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index 487242339..a6db496c8 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -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 { 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>>; @@ -1428,6 +1431,7 @@ async function handleMatchConfirmation({ reportedByUserId: number; existingAlphaWins: number; mapsToWin: number; + confirmingReportedAt?: number; isStaffReport?: boolean; }): Promise { 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; diff --git a/app/features/sendouq-match/actions/q.match.$id.server.ts b/app/features/sendouq-match/actions/q.match.$id.server.ts index 78fe46dc5..289c1d6d2 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -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"); diff --git a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx index 85b9ff00f..c75d3be28 100644 --- a/app/features/sendouq-match/components/SendouQMatchActionTab.tsx +++ b/app/features/sendouq-match/components/SendouQMatchActionTab.tsx @@ -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({ ) : null} {isStaffOnly && awaitingConfirmation ? ( - + ) : null} ) : ( @@ -287,7 +293,9 @@ function RequeueTab({ {showTimeline ? ( ) : null} - {isOnConfirmerTeam ? : null} + {isOnConfirmerTeam ? ( + + ) : null} {isOnReporterTeam ? : null} {data.match.isCanceled ? null : ( @@ -329,10 +337,22 @@ function WeaponReportSection({ return ; } -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(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 (
- { - if (!decidingMap?.winnerGroupId) return; - submit("REPORT_SCORE", { - winnerId: decidingMap.winnerGroupId, - reportedCount, - }); - }} - > - {t("q:match.confirmScore")} - + {reporterSide ? ( +

+ {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), + })} +

+ ) : null}

- {t("q:match.confirmScore.wrongHint")} + {t("q:match.confirmScore.check")}

+ 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()} + + {viewerSide === null ? null : hasDisputed ? ( +

+ {t("q:match.confirmScore.wrongSent")} +

+ ) : ( + { + setHasDisputed(true); + disputeScore.submit("DISPUTE_SCORE"); + }} + > + {t("q:match.confirmScore.wrong")} + + )}
); } @@ -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 }); }} > diff --git a/app/features/sendouq-match/q-match-schemas.ts b/app/features/sendouq-match/q-match-schemas.ts index 02da1221a..6a256f116 100644 --- a/app/features/sendouq-match/q-match-schemas.ts +++ b/app/features/sendouq-match/q-match-schemas.ts @@ -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"), diff --git a/app/hooks/useCooldown.ts b/app/hooks/useCooldown.ts new file mode 100644 index 000000000..f895eb0f0 --- /dev/null +++ b/app/hooks/useCooldown.ts @@ -0,0 +1,74 @@ +import * as React from "react"; + +interface CooldownStore { + subscribe: (listener: () => void) => () => void; + getSnapshot: () => number; + getServerSnapshot: () => number; +} + +const cooldownStores = new Map(); + +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 | 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, + ); +} diff --git a/changelog/2026-09-19-sendouq-score-confirm-safeguards.md b/changelog/2026-09-19-sendouq-score-confirm-safeguards.md new file mode 100644 index 000000000..d70c72874 --- /dev/null +++ b/changelog/2026-09-19-sendouq-score-confirm-safeguards.md @@ -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 diff --git a/e2e/pages/sendouq/sendouq-match-page.ts b/e2e/pages/sendouq/sendouq-match-page.ts index 6ca9879e4..0aa6de3be 100644 --- a/e2e/pages/sendouq/sendouq-match-page.ts +++ b/e2e/pages/sendouq/sendouq-match-page.ts @@ -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(); + } }); } diff --git a/locales/da/common.json b/locales/da/common.json index d4354348e..3d8b3c0b3 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/da/q.json b/locales/da/q.json index 1bc8e69bd..481743321 100644 --- a/locales/da/q.json +++ b/locales/da/q.json @@ -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": "", diff --git a/locales/de/common.json b/locales/de/common.json index 1116adb24..5df14944c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/de/q.json b/locales/de/q.json index 83e5e53ce..6f046c1b6 100644 --- a/locales/de/q.json +++ b/locales/de/q.json @@ -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": "", diff --git a/locales/en/common.json b/locales/en/common.json index 5e679c93d..bba61137b 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/en/q.json b/locales/en/q.json index daa9deecf..c1874c883 100644 --- a/locales/en/q.json +++ b/locales/en/q.json @@ -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", diff --git a/locales/es-ES/common.json b/locales/es-ES/common.json index 35da9b70d..87c7eece4 100644 --- a/locales/es-ES/common.json +++ b/locales/es-ES/common.json @@ -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", diff --git a/locales/es-ES/q.json b/locales/es-ES/q.json index af253f7b3..13a1b1fe7 100644 --- a/locales/es-ES/q.json +++ b/locales/es-ES/q.json @@ -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", diff --git a/locales/es-US/common.json b/locales/es-US/common.json index 04eb29ff2..262f242ff 100644 --- a/locales/es-US/common.json +++ b/locales/es-US/common.json @@ -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", diff --git a/locales/es-US/q.json b/locales/es-US/q.json index 582994c50..1a64442a5 100644 --- a/locales/es-US/q.json +++ b/locales/es-US/q.json @@ -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", diff --git a/locales/fr-CA/common.json b/locales/fr-CA/common.json index 52e97f395..38f6150a8 100644 --- a/locales/fr-CA/common.json +++ b/locales/fr-CA/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/fr-CA/q.json b/locales/fr-CA/q.json index 18161ad62..34ec52db6 100644 --- a/locales/fr-CA/q.json +++ b/locales/fr-CA/q.json @@ -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": "", diff --git a/locales/fr-EU/common.json b/locales/fr-EU/common.json index e1c3b7572..1e25a3a3f 100644 --- a/locales/fr-EU/common.json +++ b/locales/fr-EU/common.json @@ -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": "", diff --git a/locales/fr-EU/q.json b/locales/fr-EU/q.json index 88ee0d00a..66ce5aca3 100644 --- a/locales/fr-EU/q.json +++ b/locales/fr-EU/q.json @@ -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": "", diff --git a/locales/he/common.json b/locales/he/common.json index 2ad12ecaa..39eb42d74 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/he/q.json b/locales/he/q.json index 91780a985..3f368d8d7 100644 --- a/locales/he/q.json +++ b/locales/he/q.json @@ -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": "", diff --git a/locales/it/common.json b/locales/it/common.json index a2bd0d022..ae47f54af 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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": "", diff --git a/locales/it/q.json b/locales/it/q.json index 792f5c767..3da53aa93 100644 --- a/locales/it/q.json +++ b/locales/it/q.json @@ -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": "", diff --git a/locales/ja/common.json b/locales/ja/common.json index 75c3d533e..609ed2329 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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}} が試合キャンセルを却下しました。", diff --git a/locales/ja/q.json b/locales/ja/q.json index b0a650625..cf86f25b4 100644 --- a/locales/ja/q.json +++ b/locales/ja/q.json @@ -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": "", diff --git a/locales/ko/common.json b/locales/ko/common.json index 6ef939890..60514d1c6 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/ko/q.json b/locales/ko/q.json index 83e5e53ce..6f046c1b6 100644 --- a/locales/ko/q.json +++ b/locales/ko/q.json @@ -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": "", diff --git a/locales/nl/common.json b/locales/nl/common.json index 39dd94e97..432f974ff 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/nl/q.json b/locales/nl/q.json index 83e5e53ce..6f046c1b6 100644 --- a/locales/nl/q.json +++ b/locales/nl/q.json @@ -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": "", diff --git a/locales/pl/common.json b/locales/pl/common.json index 791ffbd45..cae63d164 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -368,6 +368,7 @@ "chat.send": "", "chat.systemMsg.scoreReported": "", "chat.systemMsg.scoreConfirmed": "", + "chat.systemMsg.scoreDisputed": "", "chat.systemMsg.cancelReported": "", "chat.systemMsg.cancelConfirmed": "", "chat.systemMsg.cancelRefused": "", diff --git a/locales/pl/q.json b/locales/pl/q.json index 83e5e53ce..6f046c1b6 100644 --- a/locales/pl/q.json +++ b/locales/pl/q.json @@ -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": "", diff --git a/locales/pt-BR/common.json b/locales/pt-BR/common.json index b1adb8cf9..820f33c3e 100644 --- a/locales/pt-BR/common.json +++ b/locales/pt-BR/common.json @@ -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": "", diff --git a/locales/pt-BR/q.json b/locales/pt-BR/q.json index d3df5d6b8..061c13cef 100644 --- a/locales/pt-BR/q.json +++ b/locales/pt-BR/q.json @@ -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": "", diff --git a/locales/ru/common.json b/locales/ru/common.json index 9c46cde6b..c3925a5c0 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "", diff --git a/locales/ru/q.json b/locales/ru/q.json index fdf39f4b2..865aba72d 100644 --- a/locales/ru/q.json +++ b/locales/ru/q.json @@ -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": "", diff --git a/locales/zh/common.json b/locales/zh/common.json index 5f5eaddd6..93a2aec72 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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}} 拒绝取消对局", diff --git a/locales/zh/q.json b/locales/zh/q.json index 040412d4d..f4a459753 100644 --- a/locales/zh/q.json +++ b/locales/zh/q.json @@ -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": "是,继续排队",