mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-28 14:20:46 -05:00
Add schema level sanity checks
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user