enhanced mod menu + simplify scenario select

This commit is contained in:
SowinskiBraeden committed 2026-07-08 11:43:48 -07:00
1 parent 5f22548ba6
commit 0d61329e7c
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,
+183 -13
View File
@@ -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;
}
}