From 4ef2176974314f5b53a3e6c86f4ad2bebd9041f3 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 1 Oct 2026 21:07:19 +0300 Subject: [PATCH] Handle assets load failing + more refined SQ match linking --- .../ScannerIngestRepository.server.ts | 55 +++++++ .../scanner-ingest/core/Scoreboards.ts | 2 +- .../scanner-ingest/core/relink.server.ts | 67 +++++++++ .../tests/ingest-scenarios.test.ts | 92 ++++++++++++ app/features/scanner/README.md | 23 ++- .../scanner/components/live-session.ts | 10 ++ app/features/scanner/core/resources.ts | 38 +++-- app/features/scanner/node/resources.ts | 12 +- .../scanner/worker/analyzer.worker.ts | 12 +- app/features/scanner/worker/client.ts | 7 + app/features/scanner/worker/protocol.ts | 6 +- app/features/scanner/worker/resources.ts | 139 +++++++++++------- .../sendouq-match/SQMatchRepository.server.ts | 9 ++ .../actions/q.match.$id.server.ts | 3 + .../actions/to.$id.matches.$mid.server.ts | 7 + 15 files changed, 411 insertions(+), 71 deletions(-) create mode 100644 app/features/scanner-ingest/core/relink.server.ts diff --git a/app/features/scanner-ingest/ScannerIngestRepository.server.ts b/app/features/scanner-ingest/ScannerIngestRepository.server.ts index 4cfc7ac63..f70e2dffc 100644 --- a/app/features/scanner-ingest/ScannerIngestRepository.server.ts +++ b/app/features/scanner-ingest/ScannerIngestRepository.server.ts @@ -495,6 +495,61 @@ export async function addLinks({ }); } +/** Stored matches hinted to the context and played since `playedSince` that no game links yet, chronological. */ +export function findUnlinkedMatchesByHint({ + context, + playedSince, +}: { + context: IngestContext; + /** database timestamp (seconds) */ + playedSince: number; +}) { + return db + .selectFrom("IngestedMatch") + .select([ + "IngestedMatch.id", + "IngestedMatch.data", + "IngestedMatch.povUserId", + ]) + .where( + context.type === "tournament" + ? "IngestedMatch.tournamentIdHint" + : "IngestedMatch.groupMatchIdHint", + "=", + context.type === "tournament" + ? context.tournamentId + : context.groupMatchId, + ) + .where("IngestedMatch.playedAt", ">=", playedSince) + .where((eb) => + eb.not( + eb.exists( + eb + .selectFrom("IngestedMatchLink") + .select("IngestedMatchLink.id") + .whereRef( + "IngestedMatchLink.ingestedMatchId", + "=", + "IngestedMatch.id", + ), + ), + ), + ) + .orderBy("IngestedMatch.playedAt", "asc") + .execute(); +} + +/** Unlinks every ingested match from a SendouQ map, as when its report is undone. */ +export async function deleteLinksByGroupMatchMapId( + groupMatchMapId: number, + trx?: Transaction, +) { + await (trx ?? db) + .deleteFrom("IngestedMatchLink") + .where("IngestedMatchLink.groupMatchMapId", "=", groupMatchMapId) + .execute(); +} + async function addOrMergeMatch( trx: Transaction, { diff --git a/app/features/scanner-ingest/core/Scoreboards.ts b/app/features/scanner-ingest/core/Scoreboards.ts index 65ad2f086..89231c5ed 100644 --- a/app/features/scanner-ingest/core/Scoreboards.ts +++ b/app/features/scanner-ingest/core/Scoreboards.ts @@ -31,7 +31,7 @@ const MIN_RESOLVED_SCOREBOARDS = 2; * plays of one map. Outside every candidate the scan stays unlinked (re-sendable) rather than * silently putting strangers on a match page. */ -const PLAYED_AT_TOLERANCE_MS = 30 * 60 * 1000; +export const PLAYED_AT_TOLERANCE_MS = 30 * 60 * 1000; /** The match context an ingest request was resolved to belong to. */ export type IngestContext = diff --git a/app/features/scanner-ingest/core/relink.server.ts b/app/features/scanner-ingest/core/relink.server.ts new file mode 100644 index 000000000..2f8264c74 --- /dev/null +++ b/app/features/scanner-ingest/core/relink.server.ts @@ -0,0 +1,67 @@ +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import { logger } from "~/utils/logger"; +import * as ScannerIngestRepository from "../ScannerIngestRepository.server"; +import * as Scoreboards from "./Scoreboards"; + +type ReportedMatch = + | { type: "tournament"; tournamentId: number; tournamentMatchId: number } + | { type: "sendouq"; groupMatchId: number }; + +/** + * Links stored matches to a game reported just now. Senders stop resending once sendou.ink has + * stored a match, so a scan that beat the report (or lost its link to a corrected one) would + * otherwise never link. Only matches hinted to the reported match's context and played recently + * enough to be its game are considered, and only that match's games are candidates. Failures are + * logged, never thrown: the report itself already went through. + */ +export async function linkStoredMatches(reported: ReportedMatch) { + try { + const stored = await ScannerIngestRepository.findUnlinkedMatchesByHint({ + context: + reported.type === "tournament" + ? { type: "tournament", tournamentId: reported.tournamentId } + : { type: "sendouq", groupMatchId: reported.groupMatchId }, + playedSince: dateToDatabaseTimestamp( + new Date(Date.now() - Scoreboards.PLAYED_AT_TOLERANCE_MS), + ), + }); + + // sides are pinned by the sender's seat, so each sender's scans match on their own + const byPovUserId = new Map(); + for (const match of stored) { + byPovUserId.set(match.povUserId, [ + ...(byPovUserId.get(match.povUserId) ?? []), + match, + ]); + } + + for (const [povUserId, matches] of byPovUserId) { + // reloaded per sender: an earlier sender's links mark games as taken + const games = + reported.type === "tournament" + ? await ScannerIngestRepository.gamesInTournamentMatch( + reported.tournamentMatchId, + ) + : await ScannerIngestRepository.gamesInGroupMatch( + reported.groupMatchId, + ); + const matched = Scoreboards.matchedGames({ + matches: matches.map((match) => match.data), + games, + povUserId, + }); + if (matched.length === 0) continue; + + await ScannerIngestRepository.addLinks({ + links: matched.map(({ matchIndex, game }) => ({ + ingestedMatchId: matches[matchIndex]!.id, + match: matches[matchIndex]!.data, + game, + })), + povUserId, + }); + } + } catch (error) { + logger.error("Linking stored scanner matches failed", error); + } +} diff --git a/app/features/scanner-ingest/tests/ingest-scenarios.test.ts b/app/features/scanner-ingest/tests/ingest-scenarios.test.ts index 9765e66b2..802aa55e3 100644 --- a/app/features/scanner-ingest/tests/ingest-scenarios.test.ts +++ b/app/features/scanner-ingest/tests/ingest-scenarios.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "vitest"; +import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server"; +import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server"; import { databaseTimestampToJavascriptTimestamp } from "~/utils/dates"; +import { linkStoredMatches } from "../core/relink.server"; import { ALPHA_NAMES, anotherSendouqMatch, @@ -456,6 +459,62 @@ describe("SendouQ flow", () => { const page = await qMatchPage(w.match.id); expect(page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([0, 1]); }); + + test("Q14 send before report: the report links the stored read without a resend", async () => { + const w = await sendouqWorld(); + const res = await ingest(w.povUser, [w.scanned(w.maps[0]!)]); + expect(res.linkedGamesCount).toBe(0); + + await SQMatchRepository.reportMapWinner({ + matchId: w.match.id, + winnerId: w.match.alphaGroup.id, + reportedByUserId: w.povUser.id, + reportedCount: 0, + }); + await linkStoredMatches({ type: "sendouq", groupMatchId: w.match.id }); + + const page = await qMatchPage(w.match.id); + expect(page.ingestedScoreboards.map((sb) => sb.mapIndex)).toEqual([0]); + expect(page.reportedWeapons).toEqual([ + { + groupMatchId: w.match.id, + mapIndex: 0, + userId: w.povUser.id, + weaponSplId: WEAPONS[0], + }, + ]); + }); + + test("Q15 corrected map report: the undo unlinks the read, which relinks only to a report it agrees with", async () => { + const w = await sendouqWorld(); + const reportFirstMap = async (winnerId: number) => { + await SQMatchRepository.reportMapWinner({ + matchId: w.match.id, + winnerId, + reportedByUserId: w.povUser.id, + reportedCount: 0, + }); + await linkStoredMatches({ type: "sendouq", groupMatchId: w.match.id }); + }; + const undoFirstMap = () => + SQMatchRepository.undoMapReport({ matchId: w.match.id, mapIndex: 0 }); + + await reportFirstMap(w.match.alphaGroup.id); + await ingest(w.povUser, [w.scanned(w.maps[0]!)]); + expect(await fetchLinks()).toHaveLength(1); + + await undoFirstMap(); + expect(await fetchLinks()).toHaveLength(0); + + await reportFirstMap(w.match.bravoGroup.id); + expect(await fetchLinks()).toHaveLength(0); + expect((await qMatchPage(w.match.id)).ingestedScoreboards).toHaveLength(0); + + await undoFirstMap(); + await reportFirstMap(w.match.alphaGroup.id); + expect(await fetchLinks()).toHaveLength(1); + expect((await qMatchPage(w.match.id)).ingestedScoreboards).toHaveLength(1); + }); }); describe("tournament flow", () => { @@ -625,6 +684,39 @@ describe("tournament flow", () => { expect(res.linkedGamesCount).toBe(2); expect(await fetchReportedWeapons()).toHaveLength(0); }); + + test("T8 undone and re-reported game: the read relinks to the new result without a resend", async () => { + const w = await tournamentWorld(); + const finalMatch = w.matches.at(-1)!; + const [game1] = await w.games(finalMatch.id); + await ingest(w.povUser, [w.scanned(game1!)]); + const [result] = await TournamentMatchRepository.findResultsByMatchId( + finalMatch.id, + ); + + await TournamentMatchRepository.deleteResultById(result!.id); + expect(await fetchLinks()).toHaveLength(0); + + const reReported = await TournamentMatchRepository.insertResult({ + matchId: finalMatch.id, + mode: result!.mode, + stageId: result!.stageId, + reporterId: w.author.id, + winnerTeamId: result!.winnerTeamId, + number: 1, + source: result!.source, + }); + await linkStoredMatches({ + type: "tournament", + tournamentId: w.tournamentId, + tournamentMatchId: finalMatch.id, + }); + + const links = await fetchLinks(); + expect(links.map((link) => link.tournamentMatchGameResultId)).toEqual([ + reReported.id, + ]); + }); }); describe("response contract & idempotency", () => { diff --git a/app/features/scanner/README.md b/app/features/scanner/README.md index 46ba22d65..f9cecb1f0 100644 --- a/app/features/scanner/README.md +++ b/app/features/scanner/README.md @@ -72,7 +72,12 @@ opens it, for anyone, through the same handoff Inspect uses. - **Upload** is on by default when logged in (settings toggle, persisted). Live: a scoreboard closes its match and sends it, a 15 s tick retries unlinked matches on a backoff (`sendou-ingest.ts`) and flushes closed - matches whose send was skipped; Stop sends what is left. Per-event send + matches whose send was skipped; Stop sends what is left. Server-side, a + game report (tournament or SendouQ) links the stored, still unlinked + matches hinted to its context (`scanner-ingest/core/relink.server.ts`), + and a SendouQ report undo drops the map's links (a tournament undo deletes + the result, cascading them), so a correction relinks without a resend. + Per-event send statuses back the cards' upload status button (`UploadStatus.tsx`) and its Retry/Upload. VoD scans never upload: their reads carry no wall clock, and without one a stranger's game can't be told apart from the uploader's @@ -577,12 +582,22 @@ in the **sendou-ink/assets repo** under `assets/img/**` (`.avif`; ids from Scanner-specific sets — glyph atlases and the planner signature atlas — live in the same repo under `assets/scanner/v1/**` (override the local path with `SCANNER_ASSETS_DIR`). These are the only assets that mutate at a fixed URL — -nothing sets Cache-Control on the Space, and each atlas's `.png` and `.json` -cache independently, so a regen that moves glyph boxes must bump the version -segment. Otherwise a client can pair a fresh image with a stale meta and +the CDN serves them with `max-age=3600` and each atlas's `.png` and `.json` +cache independently. The worker fetches them `no-cache` (an ETag +revalidation), which keeps the browser cache from pairing a fresh image with +a stale meta, but the CDN edge can still do so for an hour, so a regen that +moves glyph boxes must bump the version segment. Otherwise a client can silently read garbage (old dirs are deleted from the Space by the sync's `--delete-removed`). +Loading never fails init over one asset: every fetch is retried once, an +icon that still fails only drops its template (a weapon added to +`in-game-lists` before the assets push), and an atlas that fails reads as +null. The worker's `ready` names the missing atlases; a live capture refuses +to start with any (its games would upload unread — an unread lobby passes +`ingestSkipReasons`), VoD/screenshot scans carry on. Node loading +(`node/resources.ts`) stays strict and throws on a missing icon. + - Browser/worker: everything from `Config.staticAssetsUrl` — icons at `img/**`, atlases at `scanner/v1/**` (base URL rides the worker init message; the DO Space needs CORS for GET from sendou.ink + localhost). diff --git a/app/features/scanner/components/live-session.ts b/app/features/scanner/components/live-session.ts index 707b9f990..2b1d7a582 100644 --- a/app/features/scanner/components/live-session.ts +++ b/app/features/scanner/components/live-session.ts @@ -91,6 +91,13 @@ const AUDIO_SILENCE_MS = 5_000; const CAPTURE_LOCK = "scanner:capture"; const SOURCE_ENDED_ERROR = "The capture source was disconnected — check the capture card or OBS Virtual Camera and start the capture again"; +/** + * Without its atlases the worker reads no names, digits, lobbies or stages; + * such games would upload as unread lobbies (which pass the lobby filter), so + * the capture doesn't start at all rather than store them. + */ +const MISSING_ATLASES_ERROR = + "Some of the scanner's game data could not be downloaded — check the connection and start the capture again"; export type LiveStatus = "idle" | "starting" | "running" | "error"; /** `unsupported`: no WebCodecs/track processor; `failed`: the encoder refused this stream */ @@ -263,6 +270,9 @@ export async function startCapture({ client = starting; try { await starting.whenReady(); + if (starting.missingAtlases.length > 0) { + throw new Error(MISSING_ATLASES_ERROR); + } } catch (error) { starting.dispose(); if (client === starting) client = null; diff --git a/app/features/scanner/core/resources.ts b/app/features/scanner/core/resources.ts index 73c361692..39ac615d4 100644 --- a/app/features/scanner/core/resources.ts +++ b/app/features/scanner/core/resources.ts @@ -38,7 +38,7 @@ import type { GlyphSet } from "./glyphs"; import type { FrameData } from "./image"; export interface ResourceIO { - /** decoded RGBA of the shared game icon img//.avif */ + /** decoded RGBA of the shared game icon img//.avif; rejects when it can't be read */ readIcon(dir: string, id: string): Promise; /** glyph atlas by name as a (possibly lazy) getter; () => null when absent */ loadAtlas(name: string): Promise<() => GlyphSet | null>; @@ -77,17 +77,34 @@ function lazy(build: () => T): () => T { }; } -/** Requires loadOpenCV() to have resolved. */ +interface AssembledResources { + resources: ScoreboardResources; + /** `/` of the icons that could not be read; their templates are left out */ + missingIcons: string[]; +} + +/** + * Requires loadOpenCV() to have resolved. One unreadable icon (a weapon added + * to in-game-lists before the assets repo has its image) only drops that + * template, the caller decides whether that is fatal. + */ export async function assembleScoreboardResources( io: ResourceIO, -): Promise { - const icons = (dir: string, ids: readonly (number | string)[]) => - Promise.all( - ids.map(async (id) => ({ - id: String(id), - image: await io.readIcon(dir, String(id)), - })), +): Promise { + const missingIcons: string[] = []; + const icons = async (dir: string, ids: readonly (number | string)[]) => { + const read = await Promise.allSettled( + ids.map((id) => io.readIcon(dir, String(id))), ); + return read.flatMap((result, i) => { + const id = String(ids[i]); + if (result.status === "rejected") { + missingIcons.push(`${dir}/${id}`); + return []; + } + return [{ id, image: result.value }]; + }); + }; const [ weaponIcons, @@ -153,7 +170,7 @@ export async function assembleScoreboardResources( prepareMinimapAbilityTemplates(abilityIcons), ); - return { + const resources: ScoreboardResources = { get weapons() { return weapons(); }, @@ -230,4 +247,5 @@ export async function assembleScoreboardResources( return atlas.killFeedGlyphs(); }, }; + return { resources, missingIcons }; } diff --git a/app/features/scanner/node/resources.ts b/app/features/scanner/node/resources.ts index 9c2911bab..49d664e10 100644 --- a/app/features/scanner/node/resources.ts +++ b/app/features/scanner/node/resources.ts @@ -45,11 +45,17 @@ async function loadPlannerStagesLazy(): Promise<() => PlannerStage[] | null> { }; } -/** Requires loadOpenCV() to have resolved. */ -export function loadScoreboardResources(): Promise { - return assembleScoreboardResources({ +/** Requires loadOpenCV() to have resolved. Throws on a missing icon: tests and atlas builds need the whole set. */ +export async function loadScoreboardResources(): Promise { + const { resources, missingIcons } = await assembleScoreboardResources({ readIcon: (dir, id) => readImage(join(GAME_IMG_DIR, dir, `${id}.avif`)), loadAtlas: loadAtlasLazy, loadPlannerStages: loadPlannerStagesLazy, }); + if (missingIcons.length > 0) { + throw new Error( + `missing game icons under ${GAME_IMG_DIR}: ${missingIcons.join(", ")}`, + ); + } + return resources; } diff --git a/app/features/scanner/worker/analyzer.worker.ts b/app/features/scanner/worker/analyzer.worker.ts index 1479b0d0e..4f0fcd604 100644 --- a/app/features/scanner/worker/analyzer.worker.ts +++ b/app/features/scanner/worker/analyzer.worker.ts @@ -132,7 +132,15 @@ async function init({ ); } } - const resources = await fetchScoreboardResources(assetsBaseUrl); + const { resources, missingAtlases, missingIcons } = + await fetchScoreboardResources(assetsBaseUrl); + if (missingAtlases.length > 0 || missingIcons.length > 0) { + // biome-ignore lint/suspicious/noConsole: the scan goes on with what loaded, so say what didn't + console.warn("scanner: assets failed to load", { + missingAtlases, + missingIcons, + }); + } detectors = createAllDetectors(resources); scheduler = new DetectorScheduler(detectors, { suppressSteadyFrames, @@ -142,7 +150,7 @@ async function init({ collectTelemetry = collect; attachFrames = attach; telemetry = freshTelemetry(); - post({ kind: "ready" }); + post({ kind: "ready", missingAtlases }); } catch (error) { post({ kind: "error", message: `init failed: ${String(error)}` }); } diff --git a/app/features/scanner/worker/client.ts b/app/features/scanner/worker/client.ts index 10837fe2d..d55c9d2c1 100644 --- a/app/features/scanner/worker/client.ts +++ b/app/features/scanner/worker/client.ts @@ -45,6 +45,7 @@ interface QueuedFrame { export class AnalyzerClient { readonly #worker: Worker; #ready = false; + #missingAtlases: readonly string[] = []; #busy = false; readonly #onResult: ResultHandler; readonly #onError: ErrorHandler; @@ -98,6 +99,7 @@ export class AnalyzerClient { const msg = e.data; if (msg.kind === "ready") { this.#ready = true; + this.#missingAtlases = msg.missingAtlases; resolveReady(); } else if (msg.kind === "result") { this.#onResult(msg); @@ -139,6 +141,11 @@ export class AnalyzerClient { return this.#readyPromise; } + /** Atlases the worker could not load (known once ready): it scans on, but its reads lack what they cover. */ + get missingAtlases(): readonly string[] { + return this.#missingAtlases; + } + get busy(): boolean { return this.#busy || !this.#ready; } diff --git a/app/features/scanner/worker/protocol.ts b/app/features/scanner/worker/protocol.ts index e2ec44dd7..5a9236168 100644 --- a/app/features/scanner/worker/protocol.ts +++ b/app/features/scanner/worker/protocol.ts @@ -52,7 +52,11 @@ export type WorkerRequest = | AbortChunkRequest; export type WorkerResponse = - | { kind: "ready" } + | { + kind: "ready"; + /** glyph/planner atlases that failed to load: names, digits, lobbies, stages… read as null, so results must not be uploaded */ + missingAtlases: string[]; + } | { kind: "result"; detector: string; diff --git a/app/features/scanner/worker/resources.ts b/app/features/scanner/worker/resources.ts index 586a94e46..fec147281 100644 --- a/app/features/scanner/worker/resources.ts +++ b/app/features/scanner/worker/resources.ts @@ -23,9 +23,62 @@ import { assembleScoreboardResources } from "../core/resources"; */ const ATLAS_PATH = "scanner/v1"; -async function fetchImage(url: string): Promise { - const res = await fetch(url); +/** A failed fetch is tried once more after this, riding out a dropped connection or a CDN hiccup. */ +const RETRY_DELAY_MS = 1_000; + +/** + * Atlases mutate at a fixed URL and each one's .png and .json cache on their + * own, so they are revalidated on every load rather than risking a fresh + * image paired with a stale meta. + */ +const ATLAS_FETCH: RequestInit = { cache: "no-cache" }; + +interface FetchedResources { + resources: ScoreboardResources; + /** atlases (and the planner set) that failed to load: their reads come back null */ + missingAtlases: string[]; + /** `/` of game icons that failed to load: their templates are left out */ + missingIcons: string[]; +} + +/** Requires loadOpenCV() to have resolved. */ +export async function fetchScoreboardResources( + base: string, +): Promise { + const missingAtlases: string[] = []; + const recordMissing = (name: string, load: Promise<(() => T) | null>) => + load.then((getter) => { + if (getter) return getter; + missingAtlases.push(name); + return () => null; + }); + const atlasBase = `${base}/${ATLAS_PATH}`; + const { resources, missingIcons } = await assembleScoreboardResources({ + readIcon: (dir, id) => fetchImage(`${base}/img/${dir}/${id}.avif`), + loadAtlas: (name) => recordMissing(name, fetchAtlas(atlasBase, name)), + loadPlannerStages: () => + recordMissing("planner", fetchPlannerStages(atlasBase)), + }); + return { resources, missingAtlases, missingIcons }; +} + +async function fetchOk(url: string, init?: RequestInit): Promise { + try { + return await fetchOnce(url, init); + } catch { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + return fetchOnce(url, init); + } +} + +async function fetchOnce(url: string, init?: RequestInit): Promise { + const res = await fetch(url, init); if (!res.ok) throw new Error(`fetch ${url}: ${res.status}`); + return res; +} + +async function fetchImage(url: string, init?: RequestInit): Promise { + const res = await fetchOk(url, init); const bitmap = await createImageBitmap(await res.blob()); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const ctx = canvas.getContext("2d")!; @@ -35,53 +88,39 @@ async function fetchImage(url: string): Promise { return { width: data.width, height: data.height, data: data.data }; } -function makeFetchAtlas(base: string) { - return async function fetchAtlas( - name: string, - ): Promise<() => GlyphSet | null> { - try { - const [meta, image] = await Promise.all([ - fetch(`${base}/glyphs/${name}.json`).then((r) => { - if (!r.ok) throw new Error(String(r.status)); - return r.json() as Promise; - }), - fetchImage(`${base}/glyphs/${name}.png`), - ]); - const set = loadGlyphSet(image, meta); - return () => set; - } catch { - return () => null; - } - }; -} - -function makeFetchPlannerStages(base: string) { - return async function fetchPlannerStages(): Promise< - () => PlannerStage[] | null - > { - try { - const [manifest, atlas] = await Promise.all([ - fetch(`${base}/planner/manifest.json`).then((r) => { - if (!r.ok) throw new Error(String(r.status)); - return r.json() as Promise; - }), - fetchImage(`${base}/planner/signatures.png`), - ]); - const stages = loadPlannerStages(atlas, manifest); - return () => stages; - } catch { - return () => null; - } - }; -} - -/** Requires loadOpenCV() to have resolved. */ -export function fetchScoreboardResources( +/** Null when the atlas could not be loaded. */ +async function fetchAtlas( base: string, -): Promise { - return assembleScoreboardResources({ - readIcon: (dir, id) => fetchImage(`${base}/img/${dir}/${id}.avif`), - loadAtlas: makeFetchAtlas(`${base}/${ATLAS_PATH}`), - loadPlannerStages: makeFetchPlannerStages(`${base}/${ATLAS_PATH}`), - }); + name: string, +): Promise<(() => GlyphSet) | null> { + try { + const [meta, image] = await Promise.all([ + fetchOk(`${base}/glyphs/${name}.json`, ATLAS_FETCH).then( + (res) => res.json() as Promise, + ), + fetchImage(`${base}/glyphs/${name}.png`, ATLAS_FETCH), + ]); + const set = loadGlyphSet(image, meta); + return () => set; + } catch { + return null; + } +} + +/** Null when the planner signatures could not be loaded. */ +async function fetchPlannerStages( + base: string, +): Promise<(() => PlannerStage[]) | null> { + try { + const [manifest, atlas] = await Promise.all([ + fetchOk(`${base}/planner/manifest.json`, ATLAS_FETCH).then( + (res) => res.json() as Promise, + ), + fetchImage(`${base}/planner/signatures.png`, ATLAS_FETCH), + ]); + const stages = loadPlannerStages(atlas, manifest); + return () => stages; + } catch { + return null; + } } diff --git a/app/features/sendouq-match/SQMatchRepository.server.ts b/app/features/sendouq-match/SQMatchRepository.server.ts index a6db496c8..5a0d62b99 100644 --- a/app/features/sendouq-match/SQMatchRepository.server.ts +++ b/app/features/sendouq-match/SQMatchRepository.server.ts @@ -20,6 +20,7 @@ import { } from "~/features/mmr/mmr-constants"; import { identifierToUserIds } from "~/features/mmr/mmr-utils"; import type { TieredSkill } from "~/features/mmr/tiered.server"; +import * as ScannerIngestRepository from "~/features/scanner-ingest/ScannerIngestRepository.server"; import { serializeMaplistSource } from "~/modules/tournament-map-list-generator/source"; import type { TournamentMapListMap } from "~/modules/tournament-map-list-generator/types"; import { mostPopularArrayElement } from "~/utils/arrays"; @@ -1803,6 +1804,10 @@ export async function undoMatchReport({ { matchId, mapIndex: decidingMapIndex }, trx, ); + await ScannerIngestRepository.deleteLinksByGroupMatchMapId( + decidingMap.id, + trx, + ); await trx .deleteFrom("GroupMatchContinueVote") @@ -1854,6 +1859,10 @@ export async function undoMapReport({ .execute(); await ReportedWeaponRepository.deleteByMapIndex({ matchId, mapIndex }, trx); + await ScannerIngestRepository.deleteLinksByGroupMatchMapId( + targetMap.id, + trx, + ); await trx .deleteFrom("GroupMatchContinueVote") 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 289c1d6d2..c4aa4dd58 100644 --- a/app/features/sendouq-match/actions/q.match.$id.server.ts +++ b/app/features/sendouq-match/actions/q.match.$id.server.ts @@ -6,6 +6,7 @@ import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import { chatRoomChannel } from "~/features/events/events-types"; import * as Seasons from "~/features/mmr/core/Seasons"; import { refreshUserSkills } from "~/features/mmr/tiered.server"; +import { linkStoredMatches } from "~/features/scanner-ingest/core/relink.server"; import { refreshSendouQInstance, SendouQ, @@ -90,6 +91,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { ); } + await linkStoredMatches({ type: "sendouq", groupMatchId: matchId }); + if (result.status === "MATCH_FINALIZED") { try { await refreshUserSkills(Seasons.currentOrPrevious()!.nth); diff --git a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts index 56cb38a88..2cba70216 100644 --- a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts @@ -5,6 +5,7 @@ import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server"; import type { PersistedSystemMessageType } from "~/features/chat/chat-types"; import { notify } from "~/features/notifications/core/notify.server"; import { resolveNotifications } from "~/features/notifications/core/resolve.server"; +import { linkStoredMatches } from "~/features/scanner-ingest/core/relink.server"; import * as ReportedWeaponRepository from "~/features/sendouq-match/ReportedWeaponRepository.server"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; @@ -120,6 +121,12 @@ export const action: ActionFunction = async ({ params, request }) => { // the game was already reported, let their page refresh to pick it up if (!reported) return null; + await linkStoredMatches({ + type: "tournament", + tournamentId, + tournamentMatchId: match.id, + }); + endedDroppedMatchIds = reported.endedMatchIds; setIsOver = reported.setOver;