enhanced mod menu + simplify scenario select
This commit is contained in:
14 files changed
+1799
-460
No files matched your search
@@ -116,7 +116,7 @@ export class MockGameServerProvider implements GameServerProvider {
|
||||
bindPort: 2001,
|
||||
game: {
|
||||
name: 'Mock Reforger Server',
|
||||
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
|
||||
scenarioId: '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf',
|
||||
maxPlayers: 16,
|
||||
crossPlatform: true,
|
||||
gameProperties: {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mergeMissions, parseMissionList, scenariosFromWorkshopMod } from './missions-catalog.js';
|
||||
import {
|
||||
hasScenarioTag,
|
||||
mergeMissions,
|
||||
parseMissionList,
|
||||
scenariosFromWorkshopMod,
|
||||
} from './missions-catalog.js';
|
||||
|
||||
// Verbatim shape from a real console.log (server runs with -listScenarios).
|
||||
const LOG = [
|
||||
@@ -41,6 +46,12 @@ describe('parseMissionList', () => {
|
||||
});
|
||||
|
||||
describe('workshop scenario helpers', () => {
|
||||
it('recognizes scenario tag variants from the workshop', () => {
|
||||
expect(hasScenarioTag(['SCENARIOS_MP'])).toBe(true);
|
||||
expect(hasScenarioTag(['scenario sp'])).toBe(true);
|
||||
expect(hasScenarioTag(['WEAPONS'])).toBe(false);
|
||||
});
|
||||
|
||||
it('converts mod scenarios into mission entries', () => {
|
||||
const missions = scenariosFromWorkshopMod({
|
||||
id: 'ABC',
|
||||
|
||||
@@ -4,6 +4,14 @@ import type { LogPathResolver } from './ingestion/log-path-resolver.js';
|
||||
|
||||
const CATALOG_TTL_MS = 10 * 60 * 1000;
|
||||
const CATALOG_MAX_BYTES = 2 * 1024 * 1024;
|
||||
export const DEFAULT_SCENARIO_ID = '{FDE33AFE2ED7875B}Missions/23_Campaign_Montignac.conf';
|
||||
export const DEFAULT_MISSION: MissionInfo = {
|
||||
scenarioId: DEFAULT_SCENARIO_ID,
|
||||
name: 'Campaign - Montignac',
|
||||
source: 'official',
|
||||
};
|
||||
|
||||
const SCENARIO_TAGS = new Set(['scenario', 'scenario mp', 'scenario sp']);
|
||||
|
||||
/**
|
||||
* Scenario listing printed at boot when the server runs with -listScenarios
|
||||
@@ -45,6 +53,19 @@ export function scenariosFromWorkshopMod(mod: WorkshopModDetail): MissionInfo[]
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeTag(tag: string): string {
|
||||
return tag
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/^scenarios\b/, 'scenario');
|
||||
}
|
||||
|
||||
export function hasScenarioTag(tags: string[]): boolean {
|
||||
return tags.some((tag) => SCENARIO_TAGS.has(normalizeTag(tag)));
|
||||
}
|
||||
|
||||
export function mergeMissions(...groups: MissionInfo[][]): MissionInfo[] {
|
||||
const merged: MissionInfo[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
@@ -50,6 +50,8 @@ export class ResourceHistoryService {
|
||||
cpuLimitPercent: null,
|
||||
memoryBytes: 0,
|
||||
memoryLimitBytes: null,
|
||||
diskBytes: 0,
|
||||
diskLimitBytes: null,
|
||||
networkRxRate: 0,
|
||||
networkTxRate: 0,
|
||||
rxTotal: -1,
|
||||
@@ -80,6 +82,8 @@ export class ResourceHistoryService {
|
||||
cpuLimitPercent: resources.cpuLimitPercent,
|
||||
memoryBytes: resources.memoryBytes,
|
||||
memoryLimitBytes: resources.memoryLimitBytes,
|
||||
diskBytes: resources.diskBytes,
|
||||
diskLimitBytes: resources.diskLimitBytes,
|
||||
networkRxRate: Math.round(networkRxRate),
|
||||
networkTxRate: Math.round(networkTxRate),
|
||||
rxTotal: resources.networkRxBytes,
|
||||
|
||||
@@ -2,6 +2,8 @@ import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import type {
|
||||
LogIngestionHealth,
|
||||
MissionInfo,
|
||||
ModDependencyIssue,
|
||||
ServerResources,
|
||||
ServerStatus,
|
||||
ServerSummary,
|
||||
@@ -17,7 +19,13 @@ import type { GameServerProvider } from '../pterodactyl/types.js';
|
||||
import type { LogPathResolver } from '../reforger-logs/ingestion/log-path-resolver.js';
|
||||
import type { IngestionScheduler, ScheduledServer } from '../reforger-logs/ingestion/scheduler.js';
|
||||
import type { MissionCatalog } from '../reforger-logs/missions-catalog.js';
|
||||
import { mergeMissions, scenariosFromWorkshopMod } from '../reforger-logs/missions-catalog.js';
|
||||
import {
|
||||
DEFAULT_MISSION,
|
||||
DEFAULT_SCENARIO_ID,
|
||||
hasScenarioTag,
|
||||
mergeMissions,
|
||||
scenariosFromWorkshopMod,
|
||||
} from '../reforger-logs/missions-catalog.js';
|
||||
import type { ServerRecord, ServerService } from './server-service.js';
|
||||
import type { WorkshopClient } from '../workshop/workshop-client.js';
|
||||
|
||||
@@ -46,7 +54,8 @@ const performanceBodySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(200)
|
||||
.regex(/^\{[0-9A-Fa-f]{16}\}\S+\.conf$/, 'Invalid scenario id.')
|
||||
// Allow spaces in the path portion (some modded scenario IDs contain them).
|
||||
.regex(/^\{[0-9A-Fa-f]{16}\}[^\0\r\n]+\.conf$/, 'Invalid scenario id.')
|
||||
.nullable(),
|
||||
maxPlayers: z.number().int().min(1).max(128).nullable(),
|
||||
serverMaxViewDistance: z.number().int().min(500).max(10000).nullable(),
|
||||
@@ -290,31 +299,121 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
|
||||
router.get('/:slug/missions', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
const logMissions = deps.missions ? (await deps.missions.list()).missions : [];
|
||||
const modMissions = [];
|
||||
|
||||
// Resolve the installed mod list from config.json.
|
||||
// Prefer the mods service (already owns that parse); fall back to configSync.
|
||||
let installedModIds: string[] = [];
|
||||
if (deps.mods) {
|
||||
const installed = await deps.mods.getMods(server);
|
||||
const modsData = await deps.mods.getMods(server);
|
||||
installedModIds = modsData.mods.map((m) => m.modId);
|
||||
} else if (deps.configSync) {
|
||||
const config = await deps.configSync.getLiveConfig(server).catch(() => null);
|
||||
installedModIds = (config?.mods ?? []).map((m) => m.modId);
|
||||
}
|
||||
|
||||
// Workshop API -> scenarios from installed scenario-tagged mods.
|
||||
const modMissions: MissionInfo[] = [];
|
||||
let scenarioLookupComplete = true;
|
||||
if (installedModIds.length > 0) {
|
||||
const details = await Promise.allSettled(
|
||||
installed.mods.map((mod) => deps.workshop.getMod(mod.modId)),
|
||||
installedModIds.map((modId) => deps.workshop.getMod(modId)),
|
||||
);
|
||||
scenarioLookupComplete = details.every((result) => result.status === 'fulfilled');
|
||||
for (const result of details) {
|
||||
if (result.status === 'fulfilled') {
|
||||
modMissions.push(...scenariosFromWorkshopMod(result.value));
|
||||
if (result.status !== 'fulfilled') continue;
|
||||
const mod = result.value;
|
||||
if (hasScenarioTag(mod.tags)) {
|
||||
modMissions.push(...scenariosFromWorkshopMod(mod));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!deps.missions && !deps.mods) {
|
||||
throw ApiError.notConfigured('Missions require logs or config/mod access.');
|
||||
}
|
||||
|
||||
res.json({
|
||||
missions: mergeMissions(logMissions, modMissions),
|
||||
fetchedAt: new Date().toISOString(),
|
||||
missions: mergeMissions([DEFAULT_MISSION], modMissions),
|
||||
fetchedAt: scenarioLookupComplete ? new Date().toISOString() : null,
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/:slug/logs/stream',
|
||||
requireCapability('ops.health.view', 'Live console stream is restricted.'),
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
if (!deps.resolveLogPath) {
|
||||
throw ApiError.notConfigured('Log streaming requires a configured game server backend.');
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.flushHeaders();
|
||||
|
||||
const controller = new AbortController();
|
||||
req.on('close', () => controller.abort());
|
||||
|
||||
// Poll the log file every 2 seconds and push only new lines via SSE.
|
||||
// Tracks total file size to derive the new-content offset on each poll,
|
||||
// so we never re-send lines and handle log rotation gracefully.
|
||||
let lastTotalBytes = 0;
|
||||
|
||||
const sleep = (ms: number) =>
|
||||
new Promise<void>((resolve) => {
|
||||
const t = setTimeout(resolve, ms);
|
||||
controller.signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
while (!controller.signal.aborted) {
|
||||
try {
|
||||
const logPath = await deps.resolveLogPath();
|
||||
if (logPath) {
|
||||
const file = await provider.downloadTextFile(providerId(server), logPath, 512 * 1024);
|
||||
const totalBytes =
|
||||
file.totalSizeBytes ?? file.contentStartOffset + file.content.length;
|
||||
|
||||
let newContent: string;
|
||||
if (lastTotalBytes === 0 || totalBytes < lastTotalBytes) {
|
||||
// First poll or log rotated — send all available content.
|
||||
newContent = file.content;
|
||||
} else {
|
||||
const skip = Math.max(0, lastTotalBytes - file.contentStartOffset);
|
||||
newContent = file.content.slice(skip);
|
||||
}
|
||||
lastTotalBytes = totalBytes;
|
||||
|
||||
if (newContent) {
|
||||
for (const line of newContent.split('\n')) {
|
||||
if (controller.signal.aborted) break;
|
||||
if (line) res.write(`data: ${JSON.stringify(line)}\n\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Provider unreachable or no log yet — keep the connection alive.
|
||||
}
|
||||
await sleep(2000);
|
||||
}
|
||||
|
||||
res.end();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:slug/logs/raw',
|
||||
requireCapability('ops.health.view', 'Raw logs are restricted.'),
|
||||
@@ -506,6 +605,77 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:slug/mods/check', async (req, res, next) => {
|
||||
try {
|
||||
const server = await loadServer(req.params.slug);
|
||||
if (!deps.mods) {
|
||||
throw ApiError.notConfigured('Mod management requires a configured game server backend.');
|
||||
}
|
||||
const { mods } = await deps.mods.getMods(server);
|
||||
const installedIds = new Set(mods.map((m) => m.modId.toUpperCase()));
|
||||
|
||||
// Fetch workshop details for all installed mods in parallel.
|
||||
const details = await Promise.allSettled(mods.map((mod) => deps.workshop.getMod(mod.modId)));
|
||||
|
||||
const modsWithMissingVersions: string[] = [];
|
||||
const modsWithMissingDeps: ModDependencyIssue[] = [];
|
||||
|
||||
for (let i = 0; i < mods.length; i++) {
|
||||
const mod = mods[i]!;
|
||||
if (!mod.version) modsWithMissingVersions.push(mod.modId);
|
||||
const result = details[i]!;
|
||||
if (result.status === 'fulfilled') {
|
||||
const missing = result.value.dependencies.filter(
|
||||
(dep) => dep.id && !installedIds.has(dep.id.toUpperCase()),
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
modsWithMissingDeps.push({
|
||||
modId: mod.modId,
|
||||
modName: mod.name ?? result.value.name ?? null,
|
||||
missing,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Detect orphaned mission: configured scenarioId no longer available.
|
||||
let orphanedMission: { scenarioId: string; name: string | null } | null = null;
|
||||
const configData = deps.configSync
|
||||
? await deps.configSync.getLiveConfig(server).catch(() => null)
|
||||
: null;
|
||||
const scenarioId = configData?.scenarioId ?? null;
|
||||
|
||||
if (scenarioId) {
|
||||
const knownScenarioIds = new Set([DEFAULT_SCENARIO_ID]);
|
||||
const scenarioLookupComplete = details.every((result) => result.status === 'fulfilled');
|
||||
for (const result of details) {
|
||||
if (result.status === 'fulfilled') {
|
||||
const mod = result.value;
|
||||
if (!hasScenarioTag(mod.tags)) continue;
|
||||
for (const s of mod.scenarios) {
|
||||
knownScenarioIds.add(s.scenarioId);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (scenarioLookupComplete && !knownScenarioIds.has(scenarioId)) {
|
||||
orphanedMission = {
|
||||
scenarioId,
|
||||
name: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
modsWithMissingVersions,
|
||||
modsWithMissingDeps,
|
||||
orphanedMission,
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/:slug/mods',
|
||||
syncRateLimit,
|
||||
|
||||
@@ -59,8 +59,8 @@ describe('normalizeImageUrl', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkshopClient image enrichment', () => {
|
||||
it('warms list images from the detail endpoint in the background and caches them', async () => {
|
||||
describe('WorkshopClient preview cache', () => {
|
||||
it('does not fan out detail requests during search', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string | URL) => {
|
||||
const path = String(url);
|
||||
if (path.includes('/v1/mod/')) {
|
||||
@@ -76,26 +76,22 @@ describe('WorkshopClient image enrichment', () => {
|
||||
|
||||
const first = await client.search('', 1);
|
||||
expect(first.mods[0]!.imageUrl).toBeNull();
|
||||
await vi.waitFor(() => {
|
||||
const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
|
||||
expect(detailCalls).toHaveLength(1);
|
||||
});
|
||||
expect(first.mods[0]!.version).toBeNull();
|
||||
const detailCalls = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
|
||||
expect(detailCalls).toHaveLength(1);
|
||||
expect(detailCalls).toHaveLength(0);
|
||||
|
||||
// Second search hits the cache — no extra detail request.
|
||||
await client.getMod('AAAAAAAAAAAAAAA1');
|
||||
|
||||
// Second search can use the cached detail without another detail request.
|
||||
const second = await client.search('', 1);
|
||||
expect(second.mods[0]!.imageUrl).toBe(REAL_IMAGE);
|
||||
expect(second.mods[0]!.version).toBe('1.2.0');
|
||||
const detailCallsAfter = fetchImpl.mock.calls.filter((c) => String(c[0]).includes('/v1/mod/'));
|
||||
expect(detailCallsAfter).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('leaves the image empty when the detail fetch fails', async () => {
|
||||
it('leaves the image empty when there is no cached detail', async () => {
|
||||
const fetchImpl = vi.fn(async (url: string | URL) => {
|
||||
const path = String(url);
|
||||
if (path.includes('/v1/mod/')) {
|
||||
return new Response('nope', { status: 500 });
|
||||
}
|
||||
return new Response(JSON.stringify(listResponse()), { status: 200 });
|
||||
});
|
||||
const client = new WorkshopClient({
|
||||
@@ -105,4 +101,31 @@ describe('WorkshopClient image enrichment', () => {
|
||||
const result = await client.search('', 1);
|
||||
expect(result.mods[0]!.imageUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts scenario IDs from malformed scenario metadata', async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
const detail = detailResponse('AAAAAAAAAAAAAAA1');
|
||||
detail.mod.scenarios = [
|
||||
{
|
||||
name: '[OG] Udachne',
|
||||
description: '',
|
||||
scenarioID: '',
|
||||
gamemode: 'Scenario ID{39AB5D9094E502AA}Missions/OG_Conflict.conf',
|
||||
playerCount: 0,
|
||||
imageURL: '',
|
||||
},
|
||||
];
|
||||
return new Response(JSON.stringify(detail), { status: 200 });
|
||||
});
|
||||
const client = new WorkshopClient({
|
||||
baseUrl: 'https://workshop.test',
|
||||
fetchImpl: fetchImpl as unknown as typeof fetch,
|
||||
});
|
||||
|
||||
const mod = await client.getMod('AAAAAAAAAAAAAAA1');
|
||||
expect(mod.scenarios[0]).toMatchObject({
|
||||
scenarioId: '{39AB5D9094E502AA}Missions/OG_Conflict.conf',
|
||||
gamemode: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,9 @@ const modPreviewSchema = z.object({
|
||||
size: z.string().catch(''),
|
||||
rating: z.string().catch(''),
|
||||
ID: z.string(),
|
||||
version: z.string().nullish(),
|
||||
summary: z.string().nullish(),
|
||||
tags: z.array(z.string()).catch([]),
|
||||
});
|
||||
|
||||
const searchResponseSchema = z.object({
|
||||
@@ -61,7 +64,7 @@ const modDetailSchema = z.object({
|
||||
z.object({
|
||||
name: z.string(),
|
||||
description: z.string().catch(''),
|
||||
scenarioID: z.string(),
|
||||
scenarioID: z.string().catch(''),
|
||||
gamemode: z.string().catch(''),
|
||||
playerCount: z.number().catch(0),
|
||||
imageURL: z.string().catch(''),
|
||||
@@ -79,6 +82,23 @@ function extractModId(apiModUrl: string): string | null {
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
const SCENARIO_ID_PATTERN = /(\{[0-9a-fA-F]{16}\}[^\s,;)]*?\.conf)/;
|
||||
const SCENARIO_ID_WITH_LABEL_PATTERN =
|
||||
/scenario\s*id\s*:?\s*\{[0-9a-fA-F]{16}\}[^\s,;)]*?\.conf/i;
|
||||
|
||||
function extractScenarioId(...values: Array<string | null | undefined>): string {
|
||||
for (const value of values) {
|
||||
const match = value?.match(SCENARIO_ID_PATTERN);
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function cleanScenarioText(value: string | null | undefined): string | null {
|
||||
const cleaned = value?.replace(SCENARIO_ID_WITH_LABEL_PATTERN, '').trim();
|
||||
return cleaned || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upstream image URLs need repair: list endpoints return dead
|
||||
* via.placeholder.com stubs, and detail endpoints sometimes concatenate two
|
||||
@@ -101,18 +121,29 @@ function toPreview(mod: z.infer<typeof modPreviewSchema>): WorkshopModPreview {
|
||||
size: mod.size || null,
|
||||
rating: mod.rating || null,
|
||||
workshopUrl: mod.originalModURL || null,
|
||||
version: mod.version ?? null,
|
||||
summary: mod.summary ?? null,
|
||||
tags: mod.tags,
|
||||
};
|
||||
}
|
||||
|
||||
const IMAGE_CACHE_TTL_MS = 60 * 60 * 1000; // matches upstream's 1 h detail cache
|
||||
const IMAGE_FETCH_CONCURRENCY = 5;
|
||||
const PREVIEW_CACHE_TTL_MS = 60 * 60 * 1000; // matches upstream's 1 h detail cache
|
||||
|
||||
export class WorkshopClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly fetchImpl: typeof fetch;
|
||||
private readonly timeoutMs: number;
|
||||
/** modId → real image URL (or null when the mod has none). */
|
||||
private imageCache = new Map<string, { url: string | null; expiresAt: number }>();
|
||||
/** modId -> detail fields used to make browse cards useful. */
|
||||
private previewCache = new Map<
|
||||
string,
|
||||
{
|
||||
imageUrl: string | null;
|
||||
version: string | null;
|
||||
summary: string | null;
|
||||
tags: string[];
|
||||
expiresAt: number;
|
||||
}
|
||||
>();
|
||||
|
||||
constructor(options: { baseUrl: string; fetchImpl?: typeof fetch; timeoutMs?: number }) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/$/, '');
|
||||
@@ -175,67 +206,22 @@ export class WorkshopClient {
|
||||
throw ApiError.upstream('Workshop API returned an unexpected response shape.');
|
||||
}
|
||||
const mods = parsed.data.data.map(toPreview);
|
||||
this.applyCachedImages(mods);
|
||||
void this.enrichImages(mods).catch(() => undefined);
|
||||
this.applyCachedPreviews(mods);
|
||||
return {
|
||||
mods,
|
||||
meta: parsed.data.meta,
|
||||
};
|
||||
}
|
||||
|
||||
private applyCachedImages(mods: WorkshopModPreview[]): void {
|
||||
private applyCachedPreviews(mods: WorkshopModPreview[]): void {
|
||||
const now = Date.now();
|
||||
for (const mod of mods) {
|
||||
if (mod.imageUrl) continue;
|
||||
const cached = this.imageCache.get(mod.id);
|
||||
const cached = this.previewCache.get(mod.id);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
mod.imageUrl = cached.url;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List responses carry no usable images, so fill them in from the detail
|
||||
* endpoint (which does). This runs as a background cache warmer from search:
|
||||
* first-load results are fast, later visits pick up cached images.
|
||||
*/
|
||||
private async enrichImages(mods: WorkshopModPreview[]): Promise<void> {
|
||||
const now = Date.now();
|
||||
const pending: WorkshopModPreview[] = [];
|
||||
for (const mod of mods) {
|
||||
if (mod.imageUrl) continue;
|
||||
const cached = this.imageCache.get(mod.id);
|
||||
if (cached && cached.expiresAt > now) {
|
||||
mod.imageUrl = cached.url;
|
||||
} else {
|
||||
pending.push(mod);
|
||||
}
|
||||
}
|
||||
if (pending.length === 0) return;
|
||||
|
||||
const queue = [...pending];
|
||||
const worker = async () => {
|
||||
for (;;) {
|
||||
const mod = queue.shift();
|
||||
if (!mod) return;
|
||||
try {
|
||||
const detail = await this.getMod(mod.id);
|
||||
mod.imageUrl = detail.imageUrl;
|
||||
} catch {
|
||||
mod.imageUrl = null;
|
||||
}
|
||||
this.imageCache.set(mod.id, {
|
||||
url: mod.imageUrl,
|
||||
expiresAt: Date.now() + IMAGE_CACHE_TTL_MS,
|
||||
});
|
||||
}
|
||||
};
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(IMAGE_FETCH_CONCURRENCY, queue.length) }, () => worker()),
|
||||
);
|
||||
if (this.imageCache.size > 5_000) {
|
||||
for (const [key, value] of this.imageCache) {
|
||||
if (value.expiresAt <= now) this.imageCache.delete(key);
|
||||
mod.imageUrl = mod.imageUrl ?? cached.imageUrl;
|
||||
mod.version = mod.version ?? cached.version;
|
||||
mod.summary = mod.summary ?? cached.summary;
|
||||
mod.tags = mod.tags.length > 0 ? mod.tags : cached.tags;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -247,7 +233,7 @@ export class WorkshopClient {
|
||||
throw ApiError.upstream('Workshop API returned an unexpected response shape.');
|
||||
}
|
||||
const mod = parsed.data.mod;
|
||||
return {
|
||||
const detail = {
|
||||
id: mod.id,
|
||||
name: mod.name,
|
||||
author: mod.author,
|
||||
@@ -272,11 +258,30 @@ export class WorkshopClient {
|
||||
scenarios: mod.scenarios.map((scenario) => ({
|
||||
name: scenario.name,
|
||||
description: scenario.description || null,
|
||||
scenarioId: scenario.scenarioID,
|
||||
gamemode: scenario.gamemode || null,
|
||||
scenarioId: extractScenarioId(
|
||||
scenario.scenarioID,
|
||||
scenario.gamemode,
|
||||
scenario.description,
|
||||
scenario.name,
|
||||
),
|
||||
gamemode: cleanScenarioText(scenario.gamemode),
|
||||
playerCount: scenario.playerCount || null,
|
||||
imageUrl: normalizeImageUrl(scenario.imageURL),
|
||||
})),
|
||||
};
|
||||
this.previewCache.set(detail.id, {
|
||||
imageUrl: detail.imageUrl,
|
||||
version: detail.version,
|
||||
summary: detail.summary ?? detail.description,
|
||||
tags: detail.tags,
|
||||
expiresAt: Date.now() + PREVIEW_CACHE_TTL_MS,
|
||||
});
|
||||
if (this.previewCache.size > 5_000) {
|
||||
const now = Date.now();
|
||||
for (const [key, value] of this.previewCache) {
|
||||
if (value.expiresAt <= now) this.previewCache.delete(key);
|
||||
}
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user