diff --git a/src/lib/config-workbench.ts b/src/lib/config-workbench.ts new file mode 100644 index 0000000..bc0da8c --- /dev/null +++ b/src/lib/config-workbench.ts @@ -0,0 +1,66 @@ +export interface ConfigSource { + id: number | null; + name: string; + version: number | null; + kind: 'baseline' | 'config' | 'map'; + draft?: boolean; +} + +export interface ConfigAssignment { + value: string; + source: ConfigSource; + line: number; +} + +export interface ConfigSetting { + key: string; + value: string; + source: ConfigSource; + history: ConfigAssignment[]; +} + +export interface ConfigInspection { + text: string; + settings: ConfigSetting[]; + sources: ConfigSource[]; + warnings: string[]; + reason?: string; +} + +export interface ConfigServer { name: string; game: string | null; map: string | null; } + +/** The same lowercase `set` grammar that the overlay composer accepts. */ +export function configLines(text: string) { + return text.split(/\r?\n/).flatMap((line, index) => { + const match = /^(\s*set\s+)(\S+)(\s+)(.*)$/.exec(line); + return match ? [{ index, key: match[2], value: match[4], prefix: match[1] + match[2] + match[3] }] : []; + }); +} + +export function changeConfigValue(text: string, index: number, value: string): string { + // Keep every separator, comment and unrelated line byte-for-byte intact. + const parts = text.split(/(\r?\n)/); + const entry = configLines(text).find(line => line.index === index); + if (entry) parts[index * 2] = entry.prefix + value.replace(/[\r\n]/g, ''); + return parts.join(''); +} + +export function configWarnings(text: string, overlay: boolean): string[] { + const warnings: string[] = []; + const seen = new Map(); + for (const entry of configLines(text)) { + const key = entry.key.toLowerCase(); + if (seen.has(key)) warnings.push(`Line ${entry.index + 1}: ${entry.key} is also assigned on line ${seen.get(key)}.`); + seen.set(key, entry.index + 1); + if (!entry.value.trim() || entry.value.trim().startsWith('//')) warnings.push(`Line ${entry.index + 1}: ${entry.key} has no value.`); + } + if (overlay) text.split(/\r?\n/).forEach((line, index) => { + if (line.trim() && !/^\s*\/\//.test(line) && !/^\s*set\s+\S+\s+/.test(line)) { + warnings.push(`Line ${index + 1} is ignored by overlays. Use set KEY VALUE.`); + } + }); + if (/^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/im.test(text)) { + warnings.push('For recognized maps, the server’s selected map overrides sv_maprotation.'); + } + return warnings; +} diff --git a/src/lib/server/fleet/config-inspect.ts b/src/lib/server/fleet/config-inspect.ts new file mode 100644 index 0000000..bf1f39c --- /dev/null +++ b/src/lib/server/fleet/config-inspect.ts @@ -0,0 +1,62 @@ +import { composeServerCfg, parseCfg } from './config-compose.js'; +import { selectedZombieMap, mapRotation } from './map-aliases.js'; +import { configWarnings, type ConfigAssignment, type ConfigInspection, type ConfigSource } from '../../config-workbench.js'; + +export interface ConfigLayer { source: ConfigSource; text: string; } + +/** Trace file composition, including assignments left in place by the composer. + * This is an intended-file preview, not a query of live runtime dvars. */ +export function inspectConfig(baseline: ConfigLayer, overlays: ConfigLayer[], game: string | null, map: string | null, mapSlug?: string | null): ConfigInspection { + const sorted = [...overlays].sort((a, b) => a.source.id! - b.source.id!); + const parsed = parseCfg(baseline.text); + type TracedLine = { line: string; history: ConfigAssignment[] }; + function assignment(line: string, source: ConfigSource, index: number): ConfigAssignment { + const match = /^\s*(?:(?:set|seta)\s+)(\S+)\s+(.*)$/i.exec(line) + ?? /^\s*(sv_maprotation)\s+(.*)$/i.exec(line); + return { value: match?.[2] ?? '', source, line: index + 1 }; + } + const lines: TracedLine[] = parsed.order.map((entry, index) => ({ + line: entry.line, history: [assignment(entry.line, baseline.source, index)] + })); + const replacements = new Map(); + const warnings = configWarnings(baseline.text, false).map(w => `${baseline.source.name}: ${w}`); + for (const overlay of sorted) { + warnings.push(...configWarnings(overlay.text, true).map(w => `${overlay.source.name}: ${w}`)); + overlay.text.split(/\r?\n/).forEach((line, index) => { + const match = /^\s*set\s+(\S+)\s+/.exec(line); + if (!match) return; + const previous = replacements.get(match[1]); + replacements.set(match[1], { line, history: [...(previous?.history ?? []), assignment(line, overlay.source, index)] }); + }); + } + for (const [key, replacement] of replacements) { + const index = parsed.entries.get(key); + if (index === undefined) lines.push(replacement); + else lines[index] = { line: replacement.line, history: [...lines[index].history, ...replacement.history] }; + } + const sources = [baseline.source, ...sorted.map(o => o.source)]; + const selected = selectedZombieMap(game, map, mapSlug); + if (selected) { + const source: ConfigSource = { id: null, name: 'Server map selection', version: null, kind: 'map' }; + sources.push(source); + const rotation = /^\s*(?:(?:set|seta)\s+)?sv_maprotation\s+/i; + const history = lines.filter(entry => rotation.test(entry.line)).flatMap(entry => entry.history); + for (let i = lines.length - 1; i >= 0; i--) if (rotation.test(lines[i].line)) lines.splice(i, 1); + const line = `set sv_maprotation "${mapRotation(selected)}"`; + lines.push({ line, history: [...history, assignment(line, source, 0)] }); + } + const byKey = new Map(); + for (const entry of lines) { + const match = /^\s*(?:set|seta)\s+(\S+)\s+/i.exec(entry.line) ?? /^\s*(sv_maprotation)\s+/i.exec(entry.line); + if (!match) continue; + const normalized = match[1].toLowerCase(); + const existing = byKey.get(normalized); + byKey.set(normalized, { key: match[1], history: [...(existing?.history ?? []), ...entry.history] }); + } + return { + text: composeServerCfg(baseline.text, sorted.map(o => ({ id: o.source.id!, text: o.text })), game, map, mapSlug), + sources, warnings, + settings: [...byKey.values()].map(({ key, history }) => ({ key, history, value: history.at(-1)!.value, source: history.at(-1)!.source })) + .sort((a, b) => a.key.localeCompare(b.key)) + }; +} diff --git a/src/lib/ui/components/ArtifactDetail.svelte b/src/lib/ui/components/ArtifactDetail.svelte index 81abb0d..36326ca 100644 --- a/src/lib/ui/components/ArtifactDetail.svelte +++ b/src/lib/ui/components/ArtifactDetail.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import { artifactVersionLabel } from '$lib/artifact-version.js'; import PluginGithubImport from './PluginGithubImport.svelte'; + import ConfigEditor from './ConfigEditor.svelte'; import type { PluginSource } from '$lib/plugin-github.js'; import { goto } from '$app/navigation'; import Card from './primitives/Card.svelte'; @@ -329,6 +330,11 @@ + {#if a.kind === 'config' || a.kind === 'baseline'} + load({ seedForm: false })} /> + {/if} +

Version history

diff --git a/src/lib/ui/components/ConfigEditor.svelte b/src/lib/ui/components/ConfigEditor.svelte new file mode 100644 index 0000000..d71c2aa --- /dev/null +++ b/src/lib/ui/components/ConfigEditor.svelte @@ -0,0 +1,140 @@ + + + +
+
+
Edit · preview · version

Configuration editor

+ {#if dirty}Unsaved draft{/if} +
+
+ +
+ {#if recovered && !loading}
A previous draft is available in this browser.
{/if} + {#if success}
{success} Open betas →
{/if} + {#if error}{/if} + {#if loading}

Loading config contents…

+ {:else if loadedVersion} + + +
{runningBetaId !== null ? 'Finish the active beta to save another version.' : 'A new version must pass through a beta before promotion.'} + + +
+ {/if} +
+ {#if loadedVersion && !loading}{/if} +
+ + diff --git a/src/lib/ui/components/ConfigInspector.svelte b/src/lib/ui/components/ConfigInspector.svelte new file mode 100644 index 0000000..9adc5b6 --- /dev/null +++ b/src/lib/ui/components/ConfigInspector.svelte @@ -0,0 +1,144 @@ + + +
+
{artifactId ? 'Draft preview' : 'Follow every value to its source'}

{artifactId ? 'On this server' : 'Effective configuration'}

+ +
+

{artifactId ? 'Your draft composed with the other promoted configs. Saving creates an untested version.' : 'The intended file from promoted configs and the selected map. Live files and active betas may differ.'}

+ + {#if error} + {:else if loading}

Composing configuration…

+ {:else if !eligible.length}

{artifactId ? 'No registered servers match this scope.' : 'Register a server to inspect its configuration.'}

+ {:else if inspection?.reason}

{inspection.reason}

+ {:else if inspection} +
+ {#each inspection.sources as source, index} + {#if index}{/if} + {#if source.id}{label(source)} + {:else}{source.name}{/if} + {/each} +
+

Overlays apply in creation order; later overlays win for the same key. The selected map takes precedence over map rotation.

+
+ {#if raw} + {:else} +
+
+ {#each settings as setting (setting.key)} +
{setting.key}{setting.value}
{label(setting.source)} {#if setting.history.length > 1}{setting.history.length - 1} overridden{/if}
+
    {#each setting.history as entry, index}
  1. {label(entry.source)}{entry.source.kind === 'map' ? 'Automatic' : `Line ${entry.line}`} · {index === setting.history.length - 1 ? 'Final value' : 'Overridden'}
    {entry.value}
  2. {/each}
+
+ {:else}

No matching settings.

{/each} +
+

Shows set / seta assignments and map rotation. Other commands remain visible in the composed file.

+ {/if} + {#if inspection.warnings.length}
{inspection.warnings.length} configuration {inspection.warnings.length === 1 ? 'note' : 'notes'}
    {#each inspection.warnings as warning}
  • {warning}
  • {/each}
{/if} + {/if} +
+ + diff --git a/src/lib/ui/components/ConfigLibrary.svelte b/src/lib/ui/components/ConfigLibrary.svelte new file mode 100644 index 0000000..e69204d --- /dev/null +++ b/src/lib/ui/components/ConfigLibrary.svelte @@ -0,0 +1,215 @@ + + +
+ + {#snippet actions()}{/snippet} + +
+
{baselines.length}Game baselinesThe starting point for your servers
+
{overlays.length}Scoped overlaysChanges for a game or map
+
0}>{pending}Awaiting promotionLatest versions outside the promoted set
+
+ + {#if error}{/if} + {#if view === 'inspect'} + + {:else} +
+ + + + + {#if query || game || scope || status}{/if} +
+ {#if loading}
Loading your config library…
+ {:else if !artifacts.length && !error} +
Start with a foundation

Your fleet, configured your way.

Add a baseline for each game, then layer on small, focused changes for a game or map. Every change gets its own version.

+ {:else if !filtered.length && !error} + {:else} + {#if filteredBaselines.length} +

Baselines {filteredBaselines.length}

One foundation per game

+ +
+ {/if} + {#if filteredOverlays.length} +

Overlays {filteredOverlays.length}

Small changes with a clear scope

+ +
+ {/if} + {/if} + {#if artifacts.length}

Choose a config to edit and preview a draft, or inspect a server to trace its promoted settings.

{/if} + {/if} +
+ +{#snippet versionBadges(artifact: Artifact)} + {#if artifact.currentVersion}v{artifact.currentVersion.version} promoted{:else}Never promoted{/if} + {#if artifact.latestVersion && artifact.latestVersion.id !== artifact.current_version_id}v{artifact.latestVersion.version} {artifact.latestVersion.state.replace('-', ' ')}{/if} + {#if artifact.runningBetaId}β{artifact.runningBetaId} active{/if} +{/snippet} + + { if (saving) event.preventDefault(); }}> +
+
Create version 1

New config

+
+
+ {#if kind === 'config'}{#if scopeType === 'map'}{/if}{/if} +
+ {#if baselineExists}

This game already has a baseline. Open it from the library to save a new version.

{/if} + {#if !gameOptions.length}

Register a server first to make its game and map available here.

{/if} + + + + {#if createError}{/if} +

Saved as untested. Test in a beta before promotion.

+ +
+ + diff --git a/src/lib/ui/components/ConfigTextEditor.svelte b/src/lib/ui/components/ConfigTextEditor.svelte new file mode 100644 index 0000000..ea7cb4e --- /dev/null +++ b/src/lib/ui/components/ConfigTextEditor.svelte @@ -0,0 +1,60 @@ + + +
+
+
+ + +
+ {text.split(/\r?\n/).length} lines +
+ {#if mode === 'raw'} + + {:else} +
+ +

Edit values and inline comments. Other commands and formatting stay in the raw file.

+ {#each visible as entry (entry.index)} + + {:else}

{entries.length ? 'No matching settings.' : 'Add set KEY VALUE lines in the raw editor to get started.'}

{/each} +
+ {/if} +
+{#if warnings.length} +
{warnings.length} configuration {warnings.length === 1 ? 'note' : 'notes'} +
    {#each warnings as warning}
  • {warning}
  • {/each}
+
+{/if} + + diff --git a/src/routes/api/configs/[id]/+server.ts b/src/routes/api/configs/[id]/+server.ts new file mode 100644 index 0000000..9e9043c --- /dev/null +++ b/src/routes/api/configs/[id]/+server.ts @@ -0,0 +1,13 @@ +import { json } from '@sveltejs/kit'; +import { getScript, getVersion, getLatestVersion } from '$lib/server/fleet/scripts.js'; + +export async function GET({ params, url }: { params: { id: string }; url: URL }) { + const id = Number(params.id); + if (!Number.isSafeInteger(id) || id <= 0) return json({ error: 'Invalid config ID.' }, { status: 400 }); + const script = getScript(id); + if (!script || !['config', 'baseline'].includes(script.kind)) return json({ error: 'Config not found.' }, { status: 404 }); + const requested = url.searchParams.get('version'); + const version = requested === null ? getLatestVersion(id) : getVersion(Number(requested)); + if (!version || version.script_id !== id) return json({ error: 'Config version not found.' }, { status: 404 }); + return json({ text: version.content.toString('utf8'), versionId: version.id, version: version.version }, { headers: { 'Cache-Control': 'no-store' } }); +} diff --git a/src/routes/api/configs/inspect/+server.ts b/src/routes/api/configs/inspect/+server.ts new file mode 100644 index 0000000..7f54bbe --- /dev/null +++ b/src/routes/api/configs/inspect/+server.ts @@ -0,0 +1,57 @@ +import { json } from '@sveltejs/kit'; +import { getServer, listServers } from '$lib/server/fleet/servers.js'; +import { getBaselineForScope, getScript, getVersion, listPromotedOverlaysForScope, type ScriptRow } from '$lib/server/fleet/scripts.js'; +import { inspectConfig, type ConfigLayer } from '$lib/server/fleet/config-inspect.js'; +import type { ConfigInspection } from '$lib/config-workbench.js'; + +function inspect(serverName: string, draft?: { artifactId: number; text: string }): ConfigInspection { + const server = getServer(serverName); + if (!server || server.destroyed_at) throw new Error('Server not found.'); + const artifact = draft ? getScript(draft.artifactId) : undefined; + if (draft && (!artifact || !['baseline', 'config'].includes(artifact.kind))) throw new Error('Config not found.'); + if (artifact && (artifact.scope_type === null || artifact.scope_value !== server[artifact.scope_type])) { + throw new Error('This config does not apply to the selected server.'); + } + if (artifact?.kind === 'baseline' && artifact.scope_type !== 'game') throw new Error('Server composition requires a game baseline.'); + const baseline = artifact?.kind === 'baseline' ? artifact : server.game ? getBaselineForScope('game', server.game) : undefined; + function layer(row: ScriptRow): ConfigLayer | null { + const isDraft = row.id === draft?.artifactId; + const version = row.current_version_id ? getVersion(row.current_version_id) : undefined; + if (!isDraft && !version) return null; + return { + source: { id: row.id, name: row.name, version: isDraft ? null : version!.version, kind: row.kind as 'baseline' | 'config', draft: isDraft }, + text: isDraft ? draft!.text : version!.content.toString('utf8') + }; + } + const base = baseline ? layer(baseline) : null; + if (!base) return { text: '', sources: [], settings: [], warnings: [], reason: 'No promoted baseline for this game. Promote a baseline through a beta to inspect the managed configuration.' }; + const overlays = [ + ...(server.game ? listPromotedOverlaysForScope('game', server.game) : []), + ...(server.map ? listPromotedOverlaysForScope('map', server.map) : []) + ].filter(row => row.id !== artifact?.id); + if (artifact?.kind === 'config') overlays.push(artifact); + return inspectConfig(base, overlays.map(layer).filter((l): l is ConfigLayer => l !== null), server.game, server.map, server.map_slug); +} + +export async function GET({ url }: { url: URL }) { + const servers = listServers().filter(s => !s.destroyed_at).map(({ name, game, map }) => ({ name, game, map })); + try { + const server = url.searchParams.get('server'); + return json({ servers, inspection: server ? inspect(server) : null }, { headers: { 'Cache-Control': 'no-store' } }); + } catch (err) { + return json({ error: (err as Error).message }, { status: 400 }); + } +} + +export async function POST({ request }: { request: Request }) { + let body; + try { body = await request.json(); } catch { return json({ error: 'Invalid preview request.' }, { status: 400 }); } + if (!body || typeof body.server !== 'string' || !Number.isSafeInteger(body.artifactId) || typeof body.text !== 'string') { + return json({ error: 'Server, config ID and text are required.' }, { status: 400 }); + } + try { + return json({ inspection: inspect(body.server, body) }, { headers: { 'Cache-Control': 'no-store' } }); + } catch (err) { + return json({ error: (err as Error).message }, { status: 400 }); + } +} diff --git a/src/routes/api/scripts/[id]/versions/+server.ts b/src/routes/api/scripts/[id]/versions/+server.ts index c58b99f..69c4849 100644 --- a/src/routes/api/scripts/[id]/versions/+server.ts +++ b/src/routes/api/scripts/[id]/versions/+server.ts @@ -18,6 +18,10 @@ export async function POST({ params, request }: { params: { id: string }; reques const script = getScript(scriptId); if (!script) return json({ ok: false, error: 'not found' }, { status: 404 }); + if ((script.kind === 'config' || script.kind === 'baseline') && findRunningBetaForScript(scriptId)) { + return json({ ok: false, error: 'Finish the active beta before saving a config version.' }, { status: 409 }); + } + const form = await request.formData(); const notes = form.get('notes') ? String(form.get('notes')) : undefined; const file = form.get('content'); diff --git a/src/routes/configs/+page.svelte b/src/routes/configs/+page.svelte index 8703dd2..6f1d29b 100644 --- a/src/routes/configs/+page.svelte +++ b/src/routes/configs/+page.svelte @@ -1,397 +1,6 @@ - -
- - - {#if successBanner} - - {/if} - - - -

Upload a baseline (one per game)

-
- - - - - - - - - -
- -
-
-
- - - -

Create a config overlay

-
- - - - -
- - {#if overlayScopeType === 'map'} -