mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-28 14:20:46 -05:00
Adjust which lobbies get uploaded
This commit is contained in:
@@ -2,6 +2,10 @@ import { subDays } from "date-fns";
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import type { ScannerMatch } from "~/features/scanner/core/scanner-match";
|
||||
import {
|
||||
isLinkableLobby,
|
||||
isUploadedLobby,
|
||||
} from "~/features/scanner/scanner-types";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { parseBody } from "~/utils/remix.server";
|
||||
@@ -23,14 +27,15 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
|
||||
const povUserId = user.id;
|
||||
|
||||
const indexedMatches = data.matches
|
||||
.map((match, requestIndex) => ({
|
||||
match: withoutDisprovenCast(match),
|
||||
requestIndex,
|
||||
}))
|
||||
.filter(({ match }) => match.lobby === null || match.lobby === "PRIVATE");
|
||||
const requestMatches = data.matches.map(withoutDisprovenCast);
|
||||
const indexedMatches = requestMatches
|
||||
.map((match, requestIndex) => ({ match, requestIndex }))
|
||||
.filter(({ match }) => isLinkableLobby(match.lobby));
|
||||
const matches = indexedMatches.map(({ match }) => match);
|
||||
if (matches.length === 0) {
|
||||
const storeOnlyMatches = requestMatches.filter(
|
||||
(match) => isUploadedLobby(match.lobby) && !isLinkableLobby(match.lobby),
|
||||
);
|
||||
if (matches.length === 0 && storeOnlyMatches.length === 0) {
|
||||
return {
|
||||
storedMatchesCount: 0,
|
||||
mergedMatchesCount: 0,
|
||||
@@ -40,19 +45,31 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
} satisfies IngestResponse;
|
||||
}
|
||||
|
||||
const resolved = await resolveIngestContext({
|
||||
matches,
|
||||
const storedOnly = await ScannerIngestRepository.addOrMergeMatches({
|
||||
povUserId,
|
||||
casterUserId: user.id,
|
||||
submitterUserId: user.id,
|
||||
matches: storeOnlyMatches,
|
||||
context: null,
|
||||
});
|
||||
|
||||
const { insertedCount, mergedCount, effectiveMatches } =
|
||||
await ScannerIngestRepository.addOrMergeMatches({
|
||||
povUserId,
|
||||
submitterUserId: user.id,
|
||||
matches,
|
||||
context: resolved?.context ?? null,
|
||||
});
|
||||
const resolved =
|
||||
matches.length > 0
|
||||
? await resolveIngestContext({
|
||||
matches,
|
||||
povUserId,
|
||||
casterUserId: user.id,
|
||||
})
|
||||
: null;
|
||||
|
||||
const linkable = await ScannerIngestRepository.addOrMergeMatches({
|
||||
povUserId,
|
||||
submitterUserId: user.id,
|
||||
matches,
|
||||
context: resolved?.context ?? null,
|
||||
});
|
||||
const { effectiveMatches } = linkable;
|
||||
const insertedCount = storedOnly.insertedCount + linkable.insertedCount;
|
||||
const mergedCount = storedOnly.mergedCount + linkable.mergedCount;
|
||||
|
||||
let linkedGamesCount = 0;
|
||||
let linkedMatches: IngestResponse["linkedMatches"] = [];
|
||||
|
||||
@@ -113,6 +113,15 @@ describe("isSameMatch", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("differing lobbies contradict identity", () => {
|
||||
expect(
|
||||
Matches.isSameMatch(
|
||||
scannerMatch({ lobby: "PRIVATE" }),
|
||||
scannerMatch({ lobby: "X" }),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("a null mode does not contradict a read one", () => {
|
||||
expect(
|
||||
Matches.isSameMatch(
|
||||
|
||||
@@ -65,10 +65,11 @@ export function canonicalMatch(match: ScannerMatch): ScannerMatch {
|
||||
|
||||
/**
|
||||
* Whether two (possibly partial) matches describe the same game; callers pre-scope to the same
|
||||
* tournament + POV user. Contradicting mode/stage/replay-code/play-time rules identity out; then a
|
||||
* tournament + POV user. Contradicting lobby/mode/stage/replay-code/play-time rules identity out; then a
|
||||
* matching replay code, close play times, or an aligning roster (names, else weapons) rules it in.
|
||||
*/
|
||||
export function isSameMatch(a: ScannerMatch, b: ScannerMatch): boolean {
|
||||
if (a.lobby !== null && b.lobby !== null && a.lobby !== b.lobby) return false;
|
||||
if (a.mode !== null && b.mode !== null && a.mode !== b.mode) return false;
|
||||
if (a.stage !== null && b.stage !== null && a.stage !== b.stage) return false;
|
||||
|
||||
|
||||
@@ -3,7 +3,10 @@ import type {
|
||||
ScannerMatchObjective,
|
||||
ScannerMatchPlayerStatus,
|
||||
} from "~/features/scanner/core/scanner-match";
|
||||
import type { ScannerLobby } from "~/features/scanner/scanner-types";
|
||||
import {
|
||||
isLinkableLobby,
|
||||
type ScannerLobby,
|
||||
} from "~/features/scanner/scanner-types";
|
||||
import type {
|
||||
AbilityWithUnknown,
|
||||
MainWeaponId,
|
||||
@@ -13,9 +16,6 @@ import type {
|
||||
import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates";
|
||||
import * as Matches from "./Matches";
|
||||
|
||||
/** Lobby header value scoreboards of tournament/SendouQ games are expected to have. */
|
||||
const TOURNAMENT_LOBBY = "PRIVATE";
|
||||
|
||||
/** Of 8 player rows, how many must share name and position for a match to count as a re-detection (tolerates a couple of OCR misreads). */
|
||||
const MIN_LINKED_DUPLICATE_NAME_MATCHES = 6;
|
||||
|
||||
@@ -158,7 +158,7 @@ export function matchedGames({
|
||||
return view ? { ...view, matchIndex } : null;
|
||||
})
|
||||
.filter((view): view is IndexedView => view !== null)
|
||||
.filter((view) => !view.lobby || view.lobby === TOURNAMENT_LOBBY)
|
||||
.filter((view) => isLinkableLobby(view.lobby))
|
||||
.sort((a, b) => a.order - b.order),
|
||||
);
|
||||
const orderedGames = games.toSorted(
|
||||
|
||||
@@ -22,12 +22,13 @@ import {
|
||||
} from "./harness";
|
||||
|
||||
describe("gating & request filtering", () => {
|
||||
test("G2 non-private lobby: only X-battle matches in the request → skipped entirely", async () => {
|
||||
test("G2 other lobbies: anarchy and casual matches are skipped entirely", async () => {
|
||||
const w = await sendouqWorld();
|
||||
await w.conclude();
|
||||
|
||||
const res = await ingest(w.povUser, [
|
||||
w.scanned(w.maps[0]!, { lobby: "X" }),
|
||||
w.scanned(w.maps[0]!, { lobby: "SERIES" }),
|
||||
w.scanned(w.maps[1]!, { lobby: "REGULAR" }),
|
||||
]);
|
||||
|
||||
expect(res).toEqual({
|
||||
@@ -40,22 +41,47 @@ describe("gating & request filtering", () => {
|
||||
expect(await fetchIngestedMatches()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("G3 mixed request keeps indices: only the private match is stored and linked", async () => {
|
||||
test("G3 X battle: stored without a context hint and never linked", async () => {
|
||||
const w = await sendouqWorld();
|
||||
await w.conclude();
|
||||
|
||||
const res = await ingest(w.povUser, [
|
||||
w.scanned(w.maps[0]!, { lobby: "X" }),
|
||||
]);
|
||||
|
||||
expect(res).toEqual({
|
||||
storedMatchesCount: 1,
|
||||
mergedMatchesCount: 0,
|
||||
linkedGamesCount: 0,
|
||||
linkedMatches: [],
|
||||
contextResolved: false,
|
||||
});
|
||||
const rows = await fetchIngestedMatches();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.data.lobby).toBe("X");
|
||||
expect(rows[0]!.groupMatchIdHint).toBeNull();
|
||||
expect(await fetchLinks()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("G4 mixed request keeps indices: X battle stored, private match stored and linked", async () => {
|
||||
const w = await sendouqWorld();
|
||||
await w.conclude();
|
||||
|
||||
const res = await ingest(w.povUser, [
|
||||
w.scanned(w.maps[1]!, { lobby: "X" }),
|
||||
w.scanned(w.maps[0]!, { lobby: "REGULAR" }),
|
||||
w.scanned(w.maps[0]!),
|
||||
]);
|
||||
|
||||
expect(res.storedMatchesCount).toBe(1);
|
||||
expect(res.storedMatchesCount).toBe(2);
|
||||
expect(res.linkedMatches).toEqual([
|
||||
{ matchIndex: 1, link: { type: "sendouq", groupMatchId: w.match.id } },
|
||||
{ matchIndex: 2, link: { type: "sendouq", groupMatchId: w.match.id } },
|
||||
]);
|
||||
const rows = await fetchIngestedMatches();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]!.data.lobby).toBe("PRIVATE");
|
||||
expect(new Set(rows.map((row) => row.data.lobby))).toEqual(
|
||||
new Set(["PRIVATE", "X"]),
|
||||
);
|
||||
expect(await fetchLinks()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -186,7 +186,7 @@ function viewOf(state: UploadState): UploadView {
|
||||
detail:
|
||||
state.reason === "disconnect"
|
||||
? "A disconnect ended this game before it was decided, so there is no result to upload."
|
||||
: `Only Private Battle games are uploaded. This one was ${lobbyLabel(state.lobby) ?? "not a private battle"}.`,
|
||||
: `Only Private Battle and X Battle games are uploaded. This one was ${lobbyLabel(state.lobby) ?? "neither"}.`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,11 @@ const LOBBY_LABELS: Record<ScannerLobby, string> = {
|
||||
SERIES: "Anarchy Battle (Series)",
|
||||
OPEN: "Anarchy Battle (Open)",
|
||||
PRIVATE: "Private Battle",
|
||||
REGULAR: "Regular Battle",
|
||||
CHALLENGE: "Challenge",
|
||||
SPLATFEST_OPEN: "Splatfest Battle (Open)",
|
||||
SPLATFEST_PRO: "Splatfest Battle (Pro)",
|
||||
TRICOLOR: "Tricolor Battle",
|
||||
};
|
||||
|
||||
export function lobbyLabel(lobby: ScannerLobby | null): string | null {
|
||||
|
||||
@@ -60,6 +60,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "私人比赛",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "一般比赛",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "活动比赛",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "祭典比赛(开放)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "祭典比赛(挑战)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "三色夺宝比赛",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -209,6 +229,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "Privater Kampf",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "Standardkampf",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "Event-Kampf",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "Splatfestkampf (Offen)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "Splatfestkampf (Profi)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "Dreifarb-Match",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -367,6 +407,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "Private Battle",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "Regular Battle",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "Challenge",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "Splatfest Battle (Open)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "Splatfest Battle (Pro)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "Tricolor Battle",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -516,6 +576,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "Combate privado",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "Combate amistoso",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "Combate evento",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "Combate temático (informal)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "Combate temático (formal)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "Pugna tricolor",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -674,6 +754,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "Match privé",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "Match classique",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "Match Challenge",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "Festimatch (ouvert)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "Festimatch (défi)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "Match tricolore",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -823,6 +923,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "Partita privata",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "Partita amichevole",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "Partita evento",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "Partita tematica (Aperta)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "Partita tematica (Sfida)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "Scontro tricolore",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -972,6 +1092,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "Privégevecht",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "Standaardgevecht",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "Eventgevecht",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "Splatfest-gevecht (open)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "Splatfest-gevecht (pro)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "Driekleurengevecht",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -1142,6 +1282,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "Частный бой",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "Бой салаг",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "Бой-событие",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "Сплатфест-бой (открытый)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "Сплатфест-бой (профи)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "Трехцветная стычка",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -1296,6 +1456,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "プライベートマッチ",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "レギュラーマッチ",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "イベントマッチ",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "フェスマッチ(オープン)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "フェスマッチ(チャレンジ)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "トリカラマッチ",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -1445,6 +1625,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "프라이빗 매치",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "레귤러 매치",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "이벤트 매치",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "페스티벌 매치(오픈)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "페스티벌 매치(챌린지)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "트리컬러 매치",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -1594,6 +1794,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "私人比賽",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "一般比賽",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "活動比賽",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "祭典比賽(開放)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "祭典比賽(挑戰)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "三色奪寶比賽",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -1743,6 +1963,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "Private Battle",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "Regular Battle",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "Challenge",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "Splatfest Battle (Open)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "Splatfest Battle (Pro)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "Tricolor Battle",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -1892,6 +2132,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "Combate privado",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "Combate amistoso",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "Combate especial",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "Combate temático (informal)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "Combate temático (formal)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "Combate tricolor",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
@@ -2041,6 +2301,26 @@ export const LANGUAGE_ENTRIES: readonly LanguageEntries[] = [
|
||||
text: "Match privé",
|
||||
lobby: "PRIVATE",
|
||||
},
|
||||
{
|
||||
text: "Match classique",
|
||||
lobby: "REGULAR",
|
||||
},
|
||||
{
|
||||
text: "Match épreuve",
|
||||
lobby: "CHALLENGE",
|
||||
},
|
||||
{
|
||||
text: "Festimatch (ouvert)",
|
||||
lobby: "SPLATFEST_OPEN",
|
||||
},
|
||||
{
|
||||
text: "Festimatch (défi)",
|
||||
lobby: "SPLATFEST_PRO",
|
||||
},
|
||||
{
|
||||
text: "Match tricolore",
|
||||
lobby: "TRICOLOR",
|
||||
},
|
||||
],
|
||||
modes: [
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
ModeShort,
|
||||
StageId,
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { isUploadedLobby } from "../scanner-types";
|
||||
import {
|
||||
type GearMains,
|
||||
harvestAbilities,
|
||||
@@ -82,9 +83,6 @@ import {
|
||||
} from "./slot-row-assignment";
|
||||
import { editDistance, matchKey } from "./text";
|
||||
|
||||
/** The lobby header value private battles (tournament games) carry. */
|
||||
const TOURNAMENT_LOBBY = "PRIVATE";
|
||||
|
||||
/** How far back a scoreboard with no MapStart claims deaths: matches run well under 8 min. */
|
||||
const FALLBACK_WINDOW_SECONDS = 480;
|
||||
|
||||
@@ -312,14 +310,14 @@ function attachOwnResults<E extends DetectedEvent>(
|
||||
|
||||
/** Why a built match is held back from /ingest; absent = it is sent. */
|
||||
export type IngestSkipReason =
|
||||
/** not a tournament (Private Battle) game */
|
||||
/** not a Private Battle or X Battle game */
|
||||
| "lobby"
|
||||
/** a disconnect ended it before it could be decided */
|
||||
| "disconnect";
|
||||
|
||||
/**
|
||||
* Which built matches are not worth sending to /ingest, and why: non-tournament
|
||||
* lobbies (unread lobbies get the benefit of the doubt), and games a disconnect
|
||||
* Which built matches are not worth sending to /ingest, and why: lobbies other
|
||||
* than Private and X Battle (unread lobbies get the benefit of the doubt), and games a disconnect
|
||||
* cut short — counter reads show the game couldn't have ended on its own
|
||||
* (`endedEarly`), or the same map/mode was replayed right after with a score.
|
||||
* Replay evidence only arrives after the fact, so a live scan may already have
|
||||
@@ -331,7 +329,7 @@ export function ingestSkipReasons<E extends DetectedEvent>(
|
||||
const reasons = new Map<BuiltMatch<E>, IngestSkipReason>();
|
||||
for (const [index, candidate] of built.entries()) {
|
||||
const { match } = candidate;
|
||||
if (match.lobby !== null && match.lobby !== TOURNAMENT_LOBBY) {
|
||||
if (!isUploadedLobby(match.lobby)) {
|
||||
reasons.set(candidate, "lobby");
|
||||
} else if (endedEarly(match) || wasReplayed(built, index)) {
|
||||
reasons.set(candidate, "disconnect");
|
||||
|
||||
@@ -10,10 +10,36 @@ import type {
|
||||
} from "~/modules/in-game-lists/types";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
|
||||
/** The scoreboard header's lobby tag. PRIVATE marks tournament games. */
|
||||
export const SCANNER_LOBBIES = ["X", "SERIES", "OPEN", "PRIVATE"] as const;
|
||||
/**
|
||||
* The scoreboard header's lobby tag. PRIVATE marks tournament games. The casual
|
||||
* lobbies are in the closed set so their headers snap to themselves instead of
|
||||
* reading as unknown (or as a lookalike ranked lobby).
|
||||
*/
|
||||
export const SCANNER_LOBBIES = [
|
||||
"X",
|
||||
"SERIES",
|
||||
"OPEN",
|
||||
"PRIVATE",
|
||||
"REGULAR",
|
||||
"CHALLENGE",
|
||||
"SPLATFEST_OPEN",
|
||||
"SPLATFEST_PRO",
|
||||
"TRICOLOR",
|
||||
] as const;
|
||||
export type ScannerLobby = (typeof SCANNER_LOBBIES)[number];
|
||||
|
||||
const UPLOADED_LOBBIES: ReadonlySet<ScannerLobby> = new Set(["PRIVATE", "X"]);
|
||||
|
||||
/** Whether /ingest stores games of this lobby; an unread lobby gets the benefit of the doubt. */
|
||||
export function isUploadedLobby(lobby: ScannerLobby | null): boolean {
|
||||
return lobby === null || UPLOADED_LOBBIES.has(lobby);
|
||||
}
|
||||
|
||||
/** Whether games of this lobby link to reported tournament/SendouQ games (private battles); an unread lobby gets the benefit of the doubt. */
|
||||
export function isLinkableLobby(lobby: ScannerLobby | null): boolean {
|
||||
return lobby === null || lobby === "PRIVATE";
|
||||
}
|
||||
|
||||
const MAIN_WEAPON_ID_SET: ReadonlySet<number> = new Set(mainWeaponIds);
|
||||
const ABILITY_SET: ReadonlySet<string> = new Set(abilities.map((a) => a.name));
|
||||
|
||||
|
||||
@@ -752,18 +752,24 @@ test("the fallback window does not reach past the previous scoreboard", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("non-private lobbies are recorded and skipped on ingest", () => {
|
||||
test("lobbies other than private and X battle are recorded and skipped on ingest", () => {
|
||||
const built = buildScannerMatches([
|
||||
mapStart(0),
|
||||
scoreboard(300, { lobby: "X" }),
|
||||
scoreboard(300, { lobby: "REGULAR" }),
|
||||
mapStart(400),
|
||||
scoreboard(700),
|
||||
scoreboard(700, { lobby: "SERIES" }),
|
||||
mapStart(800),
|
||||
scoreboard(1100, { lobby: "X" }),
|
||||
mapStart(1200),
|
||||
scoreboard(1500),
|
||||
]);
|
||||
const skipped = ingestSkipReasons(built);
|
||||
assert.equal(built.length, 2);
|
||||
assert.equal(built[0]!.match.lobby, "X");
|
||||
assert.equal(built.length, 4);
|
||||
assert.equal(built[0]!.match.lobby, "REGULAR");
|
||||
assert.equal(skipped.get(built[0]!), "lobby");
|
||||
assert.equal(skipped.get(built[1]!), undefined);
|
||||
assert.equal(skipped.get(built[1]!), "lobby");
|
||||
assert.equal(skipped.get(built[2]!), undefined);
|
||||
assert.equal(skipped.get(built[3]!), undefined);
|
||||
});
|
||||
|
||||
test("a scoreless match whose counters had no time to run out is a disconnect", () => {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* Sources per language:
|
||||
* CommonMsg/VS/VSRuleName modes (+ _2L two-line intro-splash wraps, "Muschel-\nchaos")
|
||||
* CommonMsg/VS/VSStageName stages (keyed via the USen values)
|
||||
* CommonMsg/MatchMode lobby tags (XMatch / Private)
|
||||
* CommonMsg/MatchMode lobby tags (XMatch / Private / casual lobbies)
|
||||
* LayoutMsg/Lobby_MenuMode_00 the intro splash's "MODE" label
|
||||
* LayoutMsg/Mng_Result_00 replay-browser VICTORY / DEFEAT tags
|
||||
* LayoutMsg/VS_Beaten_00 (999) death-burst message; the weapon placeholder sits on
|
||||
@@ -64,6 +64,11 @@ const LOBBY_KEYS: Record<string, ScannerLobby> = {
|
||||
Bankara: "SERIES",
|
||||
BankaraOpen: "OPEN",
|
||||
Private: "PRIVATE",
|
||||
Regular: "REGULAR",
|
||||
League: "CHALLENGE",
|
||||
FestRegular: "SPLATFEST_OPEN",
|
||||
FestChallenge: "SPLATFEST_PRO",
|
||||
FestTriColor: "TRICOLOR",
|
||||
};
|
||||
|
||||
/** the English lobby tags as the game shows them, for USen validation */
|
||||
@@ -72,6 +77,11 @@ const LOBBY_ENGLISH: Record<ScannerLobby, string> = {
|
||||
SERIES: "Anarchy Battle (Series)",
|
||||
OPEN: "Anarchy Battle (Open)",
|
||||
PRIVATE: "Private Battle",
|
||||
REGULAR: "Regular Battle",
|
||||
CHALLENGE: "Challenge",
|
||||
SPLATFEST_OPEN: "Splatfest Battle (Open)",
|
||||
SPLATFEST_PRO: "Splatfest Battle (Pro)",
|
||||
TRICOLOR: "Tricolor Battle",
|
||||
};
|
||||
|
||||
type LangDump = Record<string, Record<string, string>>;
|
||||
|
||||
Reference in New Issue
Block a user