overhaul
This commit is contained in:
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,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user