Handle assets load failing + more refined SQ match linking

This commit is contained in:
Kalle
2026-10-01 21:07:19 +03:00
parent 650feede25
commit 4ef2176974
15 changed files with 411 additions and 71 deletions

View File

@@ -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<DB>,
) {
await (trx ?? db)
.deleteFrom("IngestedMatchLink")
.where("IngestedMatchLink.groupMatchMapId", "=", groupMatchMapId)
.execute();
}
async function addOrMergeMatch(
trx: Transaction<DB>,
{

View File

@@ -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 =

View File

@@ -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<number | null, typeof stored>();
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);
}
}

View File

@@ -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", () => {

View File

@@ -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).

View File

@@ -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;

View File

@@ -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/<dir>/<id>.avif */
/** decoded RGBA of the shared game icon img/<dir>/<id>.avif; rejects when it can't be read */
readIcon(dir: string, id: string): Promise<FrameData>;
/** glyph atlas by name as a (possibly lazy) getter; () => null when absent */
loadAtlas(name: string): Promise<() => GlyphSet | null>;
@@ -77,17 +77,34 @@ function lazy<T>(build: () => T): () => T {
};
}
/** Requires loadOpenCV() to have resolved. */
interface AssembledResources {
resources: ScoreboardResources;
/** `<dir>/<id>` 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<ScoreboardResources> {
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<AssembledResources> {
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 };
}

View File

@@ -45,11 +45,17 @@ async function loadPlannerStagesLazy(): Promise<() => PlannerStage[] | null> {
};
}
/** Requires loadOpenCV() to have resolved. */
export function loadScoreboardResources(): Promise<ScoreboardResources> {
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<ScoreboardResources> {
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;
}

View File

@@ -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)}` });
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -23,9 +23,62 @@ import { assembleScoreboardResources } from "../core/resources";
*/
const ATLAS_PATH = "scanner/v1";
async function fetchImage(url: string): Promise<FrameData> {
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[];
/** `<dir>/<id>` 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<FetchedResources> {
const missingAtlases: string[] = [];
const recordMissing = <T>(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<Response> {
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<Response> {
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<FrameData> {
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<FrameData> {
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<AtlasMeta>;
}),
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<PlannerManifest>;
}),
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<ScoreboardResources> {
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<AtlasMeta>,
),
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<PlannerManifest>,
),
fetchImage(`${base}/planner/signatures.png`, ATLAS_FETCH),
]);
const stages = loadPlannerStages(atlas, manifest);
return () => stages;
} catch {
return null;
}
}

View File

@@ -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")

View File

@@ -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);

View File

@@ -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;