This commit is contained in:
SowinskiBraeden committed 2026-09-05 13:10:36 -07:00
1 parent 3ecced51c8
commit 3adb45886a
66 files changed
+9227 -3679

No files matched your search

@@ -1,83 +1,59 @@
import type {
ConfigPatchOp,
PerformanceSettings,
PerformanceSettingsPatch,
PerformanceSettingsResponse,
} from '@reforger-panel/shared';
import type { Logger } from '../../lib/logger.js';
import type { ServerRecord } from '../servers/server-service.js';
import { asRecord, type ConfigFileGateway } from './config-file-gateway.js';
import type { ConfigSyncService } from './config-sync.js';
import type { ConfigEditorService } from './config-editor-service.js';
import type { ConfigFileGateway } from './config-file-gateway.js';
import { readAtPath } from './config-tree.js';
/** Where each performance field lives inside config.json. */
const FIELD_LOCATIONS: Record<
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string]
> = {
scenarioId: ['game', 'scenarioId'],
maxPlayers: ['game', 'maxPlayers'],
serverMaxViewDistance: ['gameProperties', 'serverMaxViewDistance'],
networkViewDistance: ['gameProperties', 'networkViewDistance'],
serverMinGrassDistance: ['gameProperties', 'serverMinGrassDistance'],
disableThirdPerson: ['gameProperties', 'disableThirdPerson'],
fastValidation: ['gameProperties', 'fastValidation'],
battlEye: ['gameProperties', 'battlEye'],
disableAI: ['operating', 'disableAI'],
aiLimit: ['operating', 'aiLimit'],
playerSaveTime: ['operating', 'playerSaveTime'],
slotReservationTimeout: ['operating', 'slotReservationTimeout'],
lobbyPlayerSynchronise: ['operating', 'lobbyPlayerSynchronise'],
/** Where each curated performance field lives inside config.json. */
export const PERFORMANCE_FIELD_PATHS: Record<keyof PerformanceSettings, string> = {
scenarioId: 'game.scenarioId',
maxPlayers: 'game.maxPlayers',
serverMaxViewDistance: 'game.gameProperties.serverMaxViewDistance',
networkViewDistance: 'game.gameProperties.networkViewDistance',
serverMinGrassDistance: 'game.gameProperties.serverMinGrassDistance',
disableThirdPerson: 'game.gameProperties.disableThirdPerson',
fastValidation: 'game.gameProperties.fastValidation',
battlEye: 'game.gameProperties.battlEye',
disableAI: 'operating.disableAI',
aiLimit: 'operating.aiLimit',
playerSaveTime: 'operating.playerSaveTime',
slotReservationTimeout: 'operating.slotReservationTimeout',
lobbyPlayerSynchronise: 'operating.lobbyPlayerSynchronise',
};
function sectionFor(
root: Record<string, unknown>,
section: 'game' | 'gameProperties' | 'operating',
createMissing: boolean,
): Record<string, unknown> | null {
const game = asRecord(root.game)!;
if (section === 'game') return game;
if (section === 'gameProperties') {
let props = asRecord(game.gameProperties);
if (!props && createMissing) {
props = {};
game.gameProperties = props;
}
return props;
}
let operating = asRecord(root.operating);
if (!operating && createMissing) {
operating = {};
root.operating = operating;
}
return operating;
}
const PERFORMANCE_FIELDS = Object.keys(PERFORMANCE_FIELD_PATHS) as (keyof PerformanceSettings)[];
export function readPerformanceSettings(root: Record<string, unknown>): PerformanceSettings {
const result = {} as Record<keyof PerformanceSettings, number | boolean | string | null>;
for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string],
][]) {
const container = sectionFor(root, section, false);
const value = container?.[key];
const validType =
field === 'scenarioId'
? typeof value === 'string'
: typeof value === 'number' || typeof value === 'boolean';
result[field] = validType ? (value as number | boolean | string) : null;
const result = {} as Record<keyof PerformanceSettings, string | number | boolean | null>;
for (const field of PERFORMANCE_FIELDS) {
const value = readAtPath(root, PERFORMANCE_FIELD_PATHS[field]);
const expected = field === 'scenarioId' ? 'string' : ['number', 'boolean'];
const matches =
typeof expected === 'string' ? typeof value === expected : expected.includes(typeof value);
result[field] = matches ? value : null;
}
return result as PerformanceSettings;
}
/**
* Edits the performance-related keys of the live config.json. A `null` value
* removes the key from the file entirely so the game's own default applies —
* network/identity fields (bind address, ports, passwords, rcon…) are never
* touched by this service.
* The curated view of config.json: typed, range-validated fields for the
* settings the panel understands well.
*
* It now shares the patch engine with the general key editor, so a save only
* ever touches the fields the caller explicitly included. Previously the form
* posted all thirteen values on every submit, which meant a form loaded before
* somebody else's change silently reverted it.
*/
export class PerformanceSettingsService {
constructor(
private readonly gateway: ConfigFileGateway,
private readonly configSync: ConfigSyncService,
private readonly editor: ConfigEditorService,
private readonly logger: Logger,
) {}
@@ -86,59 +62,48 @@ export class PerformanceSettingsService {
}
async get(server: ServerRecord): Promise<PerformanceSettingsResponse> {
const { root } = await this.gateway.download(this.providerId(server));
return { settings: readPerformanceSettings(root), fetchedAt: new Date().toISOString() };
const document = await this.gateway.download(this.providerId(server));
return {
settings: readPerformanceSettings(document.root),
revision: document.revision,
fetchedAt: new Date().toISOString(),
};
}
async update(
server: ServerRecord,
patch: PerformanceSettingsPatch,
options: { expectedRevision?: string; writeStartupVars?: boolean } = {},
): Promise<PerformanceSettingsResponse & { changedFields: string[]; requiresRestart: true }> {
const providerId = this.providerId(server);
const { raw, root } = await this.gateway.download(providerId);
const before = readPerformanceSettings(root);
const before = await this.get(server);
// Only fields the caller actually sent, and only where the value differs.
const ops: ConfigPatchOp[] = [];
const changedFields: string[] = [];
for (const [field, [section, key]] of Object.entries(FIELD_LOCATIONS) as [
keyof PerformanceSettings,
['game' | 'gameProperties' | 'operating', string],
][]) {
if (!(field in patch)) continue; // untouched fields stay as-is
const next = patch[field] as number | boolean | string | null;
if (before[field] === next) continue;
for (const field of PERFORMANCE_FIELDS) {
if (!(field in patch)) continue;
const next = patch[field] ?? null;
if (before.settings[field] === next) continue;
ops.push({ path: PERFORMANCE_FIELD_PATHS[field], value: next });
changedFields.push(field);
if (next === null) {
const container = sectionFor(root, section, false);
if (container) delete container[key];
} else {
const container = sectionFor(root, section, true)!;
container[key] = next;
}
}
if (changedFields.length > 0) {
await this.gateway.write(providerId, root, raw, (readBack) => {
const after = readPerformanceSettings(readBack);
for (const field of changedFields) {
if (
after[field as keyof PerformanceSettings] !== patch[field as keyof PerformanceSettings]
) {
throw new Error('Config write verification failed. Check config.json.bak.');
}
}
});
await this.configSync.sync(server).catch((error) => {
this.logger.warn(
{ serverId: server.id, err: String(error) },
'post-write config sync failed',
);
});
this.logger.info({ serverId: server.id, changedFields }, 'performance settings updated');
if (ops.length === 0) {
return { ...before, changedFields: [], requiresRestart: true };
}
const result = await this.editor.patch(server, ops, {
expectedRevision: options.expectedRevision ?? before.revision,
writeStartupVars: options.writeStartupVars ?? true,
});
this.logger.info({ serverId: server.id, changedFields }, 'performance settings updated');
const after = await this.get(server);
return {
settings: { ...before, ...patch } as PerformanceSettings,
fetchedAt: new Date().toISOString(),
settings: after.settings,
revision: result.revision,
fetchedAt: result.fetchedAt,
changedFields,
requiresRestart: true,
};