This commit is contained in:
SowinskiBraeden committed 2026-07-14 14:19:17 -07:00
commit 9f6a8b85f5
23 files changed
+1981 -454

No files matched your search

@@ -21,6 +21,7 @@ const FIELD_LOCATIONS: Record<
disableThirdPerson: ['gameProperties', 'disableThirdPerson'],
fastValidation: ['gameProperties', 'fastValidation'],
battlEye: ['gameProperties', 'battlEye'],
disableAI: ['operating', 'disableAI'],
aiLimit: ['operating', 'aiLimit'],
playerSaveTime: ['operating', 'playerSaveTime'],
slotReservationTimeout: ['operating', 'slotReservationTimeout'],
@@ -11,7 +11,7 @@ const REAL_SHAPE = {
a2s: { address: '0.0.0.0', port: 17777 },
rcon: { address: '127.0.0.1', port: 19999, password: 'hunter2', permission: 'admin' },
game: {
name: 'DazzledCorp Training Grounds',
name: 'DZR Training Grounds',
password: '',
passwordAdmin: 'secret',
admins: ['76561198000000000'],
@@ -34,7 +34,12 @@ const REAL_SHAPE = {
{ modId: '5AAF0CCE3F001FB5' },
],
},
operating: { lobbyPlayerSynchronise: true, aiLimit: -1, playerSaveTime: 120 },
operating: {
lobbyPlayerSynchronise: true,
disableAI: false,
aiLimit: -1,
playerSaveTime: 120
},
};
describe('parseReforgerConfigJson', () => {
@@ -44,6 +49,7 @@ describe('parseReforgerConfigJson', () => {
serverName: 'DazzledCorp Training Grounds',
maxPlayers: 16,
scenarioId: '{ECC61978EDCC2B5A}Missions/23_Campaign.conf',
disableAI: false,
aiLimit: -1,
serverMaxViewDistance: 2500,
networkViewDistance: 1000,
@@ -66,6 +72,7 @@ describe('parseReforgerConfigJson', () => {
const config = parseReforgerConfigJson('{"game":{"name":"Bare"}}');
expect(config.serverName).toBe('Bare');
expect(config.maxPlayers).toBe(0);
expect(config.disableAI).toBe(false);
expect(config.aiLimit).toBe(-1);
expect(config.mods).toEqual([]);
});
@@ -52,6 +52,7 @@ export function mapReforgerConfig(raw: unknown): ReforgerServerConfig {
serverName: str(game.name, 'Unnamed server'),
maxPlayers: num(game.maxPlayers, 0),
scenarioId: str(game.scenarioId),
disableAI: bool(operating.disableAI, false),
// -1 means "no limit" in Reforger's operating.aiLimit.
aiLimit: num(operating.aiLimit, -1),
serverMaxViewDistance: num(gameProperties.serverMaxViewDistance, 0),
@@ -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: {
@@ -126,7 +126,7 @@ export class MockGameServerProvider implements GameServerProvider {
},
mods: [{ modId: '591AF5BDA9F7CE8B', name: 'Mock Sample Mod', version: '1.0.2' }],
},
operating: { aiLimit: 40 },
operating: { disableAI: false, aiLimit: 40 },
},
null,
2,
@@ -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,
+199 -14
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(),
@@ -55,6 +64,7 @@ const performanceBodySchema = z
disableThirdPerson: z.boolean().nullable(),
fastValidation: z.boolean().nullable(),
battlEye: z.boolean().nullable(),
disableAI: z.boolean().nullable(),
aiLimit: z.number().int().min(-1).max(1000).nullable(),
playerSaveTime: z.number().int().min(1).max(3600).nullable(),
slotReservationTimeout: z.number().int().min(5).max(300).nullable(),
@@ -290,31 +300,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 +606,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,
@@ -526,7 +697,21 @@ export function createServerRouter(deps: ServerRouterDeps): Router {
throw ApiError.validation('Duplicate mod ids in the list.');
}
const result = await deps.mods.setMods(server, body.data.mods);
// Reforger requires a version in config.json for each mod to load.
// Fetch it from the Workshop for any mod the caller didn't supply one for.
const enrichedMods = await Promise.all(
body.data.mods.map(async (mod) => {
if (mod.version) return mod;
try {
const detail = await deps.workshop.getMod(mod.modId);
return { ...mod, ...(detail.version ? { version: detail.version } : {}) };
} catch {
return mod;
}
}),
);
const result = await deps.mods.setMods(server, enrichedMods);
const user = req.user!;
await service.recordActivity({
serverId: server.id,
@@ -59,7 +59,7 @@ describe('normalizeImageUrl', () => {
});
});
describe('WorkshopClient image enrichment', () => {
describe('WorkshopClient preview cache', () => {
it('identifies panel traffic to the upstream API', async () => {
const fetchImpl = vi.fn(async () => {
return new Response(JSON.stringify({ status: 'ok' }), { status: 200 });
@@ -82,7 +82,7 @@ describe('WorkshopClient image enrichment', () => {
);
});
it('warms list images from the detail endpoint in the background and caches them', async () => {
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/')) {
@@ -98,26 +98,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({
@@ -127,4 +123,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,19 +121,30 @@ 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 CLIENT_IDENTITY = 'reforger.dzr.tools';
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(/\/$/, '');
@@ -180,67 +211,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;
}
}
}
@@ -252,7 +238,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,
@@ -277,11 +263,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;
}
}
+1
View File
@@ -343,6 +343,7 @@ describe('performance config by role', () => {
disableThirdPerson: null,
fastValidation: null,
battlEye: null,
disableAI: null,
aiLimit: null,
playerSaveTime: null,
slotReservationTimeout: null,
+1
View File
@@ -69,6 +69,7 @@ describe('ServerModsService', () => {
expect(parsed.bindPort).toBe(2001);
expect(parsed.game.name).toBe('Mock Reforger Server');
expect(parsed.game.maxPlayers).toBe(16);
expect(parsed.operating.disableAI).toBe(false);
expect(parsed.operating.aiLimit).toBe(40);
});
+3 -1
View File
@@ -37,6 +37,7 @@ describe('PerformanceSettingsService', () => {
// Present in the mock config.json:
expect(settings.maxPlayers).toBe(16);
expect(settings.serverMaxViewDistance).toBe(2500);
expect(settings.disableAI).toBe(false);
expect(settings.aiLimit).toBe(40);
expect(settings.disableThirdPerson).toBe(false);
// Absent keys:
@@ -50,10 +51,11 @@ describe('PerformanceSettingsService', () => {
...settings,
maxPlayers: 32,
playerSaveTime: 180, // new key
disableAI: null,
aiLimit: null, // remove key → game default
});
expect(result.changedFields.sort()).toEqual(['aiLimit', 'maxPlayers', 'playerSaveTime']);
expect(result.changedFields.sort()).toEqual(['aiLimit', 'disableAI', 'maxPlayers', 'playerSaveTime']);
expect(result.requiresRestart).toBe(true);
const written = JSON.parse(provider.writtenFiles.get('/config.json')!);