mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-02 08:07:40 -05:00
Handle assets load failing + more refined SQ match linking
This commit is contained in:
@@ -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>,
|
||||
{
|
||||
|
||||
@@ -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 =
|
||||
|
||||
67
app/features/scanner-ingest/core/relink.server.ts
Normal file
67
app/features/scanner-ingest/core/relink.server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)}` });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user