Add schema level sanity checks

This commit is contained in:
Kalle
2026-09-28 21:13:01 +03:00
parent 0a40f7c824
commit 3be15888a9
5 changed files with 167 additions and 23 deletions

View File

@@ -949,7 +949,7 @@ async function groupRosters(groupIds: number[]) {
return result;
}
/** Winner-first player names of each game's earliest linked ingest, keyed by the link target column's value. */
/** Winner-first player names of each game's earliest linked ingest with recognizable names, keyed by the link target column's value. */
async function linkedPlayerNamesByTarget(
column: "tournamentMatchGameResultId" | "groupMatchMapId",
targetIds: number[],
@@ -972,7 +972,7 @@ async function linkedPlayerNamesByTarget(
for (const row of rows) {
if (row.targetId === null || result.has(row.targetId)) continue;
const names = Scoreboards.winnerFirstPlayerNames(row.data);
const names = Scoreboards.recognizablePlayerNames(row.data);
if (names) result.set(row.targetId, names);
}

View File

@@ -277,7 +277,7 @@ describe("matchedGames", () => {
games: [
testGame({
matchGameResultId: 11,
linkedPlayerNames: ["", "", "", "", "l1", "l2", "l3", "l4"],
linkedPlayerNames: ["", "", "w3", "w4", "l1", "l2", "l3", "l4"],
}),
testGame({ matchGameResultId: 12, playedAt: 2000 }),
],
@@ -785,9 +785,9 @@ describe("deriveScoreboardData", () => {
});
});
describe("winnerFirstPlayerNames", () => {
describe("recognizablePlayerNames", () => {
test("returns names winner-first with unread names empty", () => {
const names = Scoreboards.winnerFirstPlayerNames(
const names = Scoreboards.recognizablePlayerNames(
swapSides(
testMatch({ names: ["w1", "", "w3", "w4", "l1", "l2", "l3", "l4"] }),
),
@@ -798,7 +798,15 @@ describe("winnerFirstPlayerNames", () => {
test("returns null for a match without a linkable scoreboard", () => {
expect(
Scoreboards.winnerFirstPlayerNames({ ...testMatch(), winner: null }),
Scoreboards.recognizablePlayerNames({ ...testMatch(), winner: null }),
).toBe(null);
});
test("returns null for a read with too few names to recognize a re-detection", () => {
expect(
Scoreboards.recognizablePlayerNames(
testMatch({ names: ["w1", "", "", "w4", "l1", "", "l3", "l4"] }),
),
).toBe(null);
});
});

View File

@@ -64,7 +64,7 @@ export interface IngestableGame {
loserInGameNames: string[];
/** timestamp of the game's report: the chronological key and what a scan's play time is measured against */
playedAt: number;
/** winner-first row-order names of an already linked ingest of the game, null when none; lets matching skip taken games yet recognize re-detections */
/** winner-first row-order names of the game's earliest linked ingest that read enough names to recognize a re-detection, null when none; lets matching skip taken games yet recognize re-detections */
linkedPlayerNames: string[] | null;
}
@@ -257,10 +257,22 @@ export function deriveScoreboardData({
};
}
/** A match's players winner-first in row order (unread names as ""), or null without such a view — a game's `linkedPlayerNames`. */
export function winnerFirstPlayerNames(match: ScannerMatch): string[] | null {
/**
* A linked match's players winner-first in row order (unread names as ""), the game's
* `linkedPlayerNames`. Null when it can't form a view or read too few names to ever recognize
* a re-detection: such a read must not lock the game against every other POV's scan.
*/
export function recognizablePlayerNames(match: ScannerMatch): string[] | null {
const view = winnerFirstView(match);
return view ? view.players.map((player) => player.name.trim()) : null;
if (!view) return null;
const names = view.players.map((player) => player.name.trim());
const readNamesCount = names.filter((name) =>
Matches.normalizeInGameName(name),
).length;
if (readNamesCount < MIN_LINKED_DUPLICATE_NAME_MATCHES) return null;
return names;
}
/** Winner-first row view of a match (unread names as ""). Null when it can't link: unknown winner or a team not fully seen. */

View File

@@ -83,6 +83,61 @@ describe("gating & request filtering", () => {
);
expect(await fetchLinks()).toHaveLength(1);
});
test("G5 implausible values: stats, scores and names the game can't show are stored as unread", async () => {
const w = await sendouqWorld();
await w.conclude();
const scan = w.scanned(w.maps[0]!);
const [winners, losers] = scan.teams;
const res = await ingest(w.povUser, [
{
...scan,
matchScores: [99999, -5],
teams: [
{
players: [
{
...winners.players[0]!,
name: "x".repeat(100),
ka: 1e12,
d: -3,
s: 1.5,
paint: 1.5e300,
},
...winners.players.slice(1),
],
},
losers,
],
},
]);
expect(res.linkedGamesCount).toBe(1);
const scoreboard = (await qMatchPage(w.match.id)).ingestedScoreboards[0]!;
expect(scoreboard.data.scores).toEqual([null, null]);
expect(scoreboard.data.players[0]).toMatchObject({
name: "",
ka: null,
d: null,
s: null,
paint: null,
});
});
test("G6 a play time far in the future: stored without one and never linked", async () => {
const w = await sendouqWorld();
await w.conclude();
const res = await ingest(w.povUser, [
w.scanned(w.maps[0]!, { playedAt: 1e300 }),
]);
expect(res.storedMatchesCount).toBe(1);
expect(res.linkedGamesCount).toBe(0);
const rows = await fetchIngestedMatches();
expect(rows[0]!.data.playedAt).toBeNull();
});
});
describe("SendouQ flow", () => {
@@ -269,6 +324,38 @@ describe("SendouQ flow", () => {
]);
});
test("Q12 a nameless first link: the opponent's named read still joins the game", async () => {
const w = await sendouqWorld();
await w.conclude();
const scan = w.scanned(w.maps[0]!);
await ingest(w.povUser, [
{
...scan,
teams: [
{
players: scan.teams[0].players.map((p) => ({ ...p, name: null })),
},
{
players: scan.teams[1].players.map((p) => ({ ...p, name: null })),
},
],
},
]);
const res = await ingest(w.bravoUsers[0]!, [
w.scanned(w.maps[0]!, { seenFrom: "loser" }),
]);
expect(res.linkedGamesCount).toBe(1);
const scoreboard = (await qMatchPage(w.match.id)).ingestedScoreboards[0]!;
expect(scoreboard.data.players.map((p) => p.name)).toEqual([
...ALPHA_NAMES,
...BRAVO_NAMES,
]);
expect(scoreboard.data.players[0]!.userId).toBe(w.povUser.id);
expect(scoreboard.data.players[4]!.userId).toBe(w.bravoUsers[0]!.id);
});
test("Q11 POV read misflagged as cast: the sender's seat still resolves and links their match", async () => {
const w = await sendouqWorld();
await w.conclude();

View File

@@ -23,6 +23,12 @@ import { SCANNER_LOBBIES } from "./scanner-types";
const detectionText = v.pipe(v.string(), v.maxLength(500));
/** longer than any in-game name or replay code the game displays */
const MAX_PLAUSIBLE_TEXT_LENGTH = 32;
/** how far ahead of the server's clock a sender's clock may run */
const PLAYED_AT_MAX_CLOCK_SKEW_MS = 24 * 60 * 60 * 1000;
const scannerLobbySchema = v.picklist(SCANNER_LOBBIES);
export const modeShortSchema = v.picklist(modesShort);
export const stageIdSchema = v.picklist(stageIds);
@@ -36,12 +42,12 @@ const scannerAbilitySchema = v.union([
]);
const scannerMatchPlayerSchema = v.object({
name: v.nullable(detectionText),
name: plausibleText(),
weaponId: v.nullable(mainWeaponIdSchema),
paint: v.nullable(v.number()),
ka: v.nullable(v.number()),
d: v.nullable(v.number()),
s: v.nullable(v.number()),
paint: plausibleCount(9999),
ka: plausibleCount(99),
d: plausibleCount(99),
s: plausibleCount(99),
/** [head, clothes, shoes] ability rows; a row may hold its main alone */
abilities: v.optional(
v.pipe(
@@ -63,8 +69,8 @@ const MAX_OBJECTIVE_SAMPLES = 1000;
const scannerMatchObjectiveSampleSchema = v.object({
t: v.pipe(v.number(), v.integer(), v.minValue(0)),
time: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))),
score: v.tuple([v.nullable(v.number()), v.nullable(v.number())]),
penalty: v.tuple([v.nullable(v.number()), v.nullable(v.number())]),
score: v.tuple([plausibleCount(100), plausibleCount(100)]),
penalty: v.tuple([plausibleCount(999), plausibleCount(999)]),
control: v.nullable(teamIndexSchema),
position: v.optional(
v.nullable(v.pipe(v.number(), v.minValue(-100), v.maxValue(100))),
@@ -106,21 +112,28 @@ const MAX_KILLS = 200;
const scannerMatchKillSchema = v.object({
t: v.pipe(v.number(), v.integer(), v.minValue(0)),
time: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))),
name: v.nullable(detectionText),
name: plausibleText(),
});
export const scannerMatchSchema = v.object({
startsAt: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))),
endsAt: v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0))),
/** wall-clock ms the game was played */
playedAt: v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))),
playedAt: v.nullable(
v.pipe(
v.number(),
v.integer(),
v.gtValue(0),
v.transform((playedAt) =>
playedAt <= Date.now() + PLAYED_AT_MAX_CLOCK_SKEW_MS ? playedAt : null,
),
),
),
lobby: v.nullable(scannerLobbySchema),
mode: v.nullable(modeShortSchema),
stage: v.nullable(stageIdSchema),
matchScores: v.nullable(
v.tuple([v.nullable(v.number()), v.nullable(v.number())]),
),
replayCode: v.nullable(detectionText),
matchScores: v.nullable(v.tuple([plausibleCount(100), plausibleCount(100)])),
replayCode: plausibleText(),
cast: v.boolean(),
objective: v.nullable(scannerMatchObjectiveSchema),
playerStatus: v.nullable(scannerMatchPlayerStatusSchema),
@@ -137,6 +150,30 @@ export const scannerMatchSchema = v.object({
),
});
/** An OCR'd text no game screen could show reads as unread instead of failing the whole upload. */
function plausibleText() {
return v.nullable(
v.pipe(
detectionText,
v.transform((text) =>
text.length <= MAX_PLAUSIBLE_TEXT_LENGTH ? text : null,
),
),
);
}
/** A number the game can't display (a misread or a forged value) reads as unread instead of failing the whole upload. */
function plausibleCount(max: number) {
return v.nullable(
v.pipe(
v.number(),
v.transform((value) =>
Number.isInteger(value) && value >= 0 && value <= max ? value : null,
),
),
);
}
type MutuallyAssignable<A, B> = [A] extends [B]
? [B] extends [A]
? true