From 573b4101c0850012c44930d3ebd0810d6c9df3d0 Mon Sep 17 00:00:00 2001 From: Ada Vale <104856138+AdaInTheLab@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:35:10 -0400 Subject: [PATCH] =?UTF-8?q?feat(config):=207D2D=203.0=20support=20?= =?UTF-8?q?=E2=80=94=20version-aware=20editor=20+=20serverconfig=20migrati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3.0 ("Dead Hot Summer") moved the world/gameplay sandbox settings out of serverconfig.xml into the in-game Sandbox, governed by a single SandboxCode. - Detect 3.0 via GameSupportsSandboxCode() (reflects EnumGamePrefs for the 3.0-only SandboxCode member — compiles against 2.x refs, true at runtime on 3.0). - Editor: a SandboxCode field, and it hides the 26 sandbox-governed fields on a 3.0 server (SandboxGovernedKeys, derived from the game's own EnumGamePrefs ∩ SandboxOptions — not from patch notes). - New: one-click "Migrate to 3.0" (banner when needed). MigrateConfigTo30() comments out the sandbox-governed props in serverconfig.xml (preserved, not deleted), ensures SandboxCode exists, keeps survivors + unmodeled props, takes a timestamped backup first, is idempotent, and refreshes the sticky .bak. Gated on a real 3.0 install; pure file work so it stays unit-testable. API: GET /api/config now returns needsMigration; POST /api/config/migrate-3.0 runs the migration (refuses on a non-3.0 server). Verified: C# builds clean; 13 ServerConfigService tests pass (3 new — neutralize + SandboxCode, idempotency, needs-migration detection); frontend (vue-tsc + vite) builds clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/api/config.ts | 20 +++ frontend/src/views/ConfigEditorView.vue | 95 +++++++++++++- .../Services/ServerConfigServiceTests.cs | 72 +++++++++- .../Services/ServerConfigFieldDefinitions.cs | 79 ++++++++++- .../Services/ServerConfigService.cs | 123 +++++++++++++++++- .../Web/Controllers/ConfigController.cs | 40 +++++- 6 files changed, 418 insertions(+), 11 deletions(-) diff --git a/frontend/src/api/config.ts b/frontend/src/api/config.ts index 723f266..eb661bd 100644 --- a/frontend/src/api/config.ts +++ b/frontend/src/api/config.ts @@ -9,6 +9,8 @@ export interface ConfigFieldDef { options?: string[] labels?: string[] description?: string + /** True when 7D2D 3.0 governs this setting via the SandboxCode; hidden in the editor on 3.0. */ + sandboxGoverned?: boolean } export interface ConfigFieldGroup { @@ -20,6 +22,18 @@ export interface ConfigResponse { properties: Record groups: ConfigFieldGroup[] configPath: string + /** True when the running game is 7D2D 3.0+ (supports the SandboxCode system). */ + is30?: boolean + /** True when serverconfig.xml still has 3.0-deprecated sandbox-governed props to clean up. */ + needsMigration?: boolean +} + +export interface MigrateResult { + changed: boolean + addedSandboxCode: boolean + neutralized: string[] + backupPath: string + message: string } export async function getConfig(): Promise { @@ -46,3 +60,9 @@ export async function getWorlds(): Promise { const res = await apiClient.get('/api/config/worlds') return res.data.data } + +/** Migrate serverconfig.xml to the 7D2D 3.0 layout (server backs up first; idempotent). */ +export async function migrateConfigTo30(): Promise { + const res = await apiClient.post('/api/config/migrate-3.0') + return res.data.data +} diff --git a/frontend/src/views/ConfigEditorView.vue b/frontend/src/views/ConfigEditorView.vue index 9760a0b..51ceaf6 100644 --- a/frontend/src/views/ConfigEditorView.vue +++ b/frontend/src/views/ConfigEditorView.vue @@ -2,7 +2,7 @@ import { ref, computed, onMounted } from 'vue' import { useI18n } from 'vue-i18n' import { useToast } from 'primevue/usetoast' -import { getConfig, getRawXml, saveConfig, saveRawXml, getWorlds, type ConfigFieldGroup } from '@/api/config' +import { getConfig, getRawXml, saveConfig, saveRawXml, getWorlds, migrateConfigTo30, type ConfigFieldGroup } from '@/api/config' import Button from 'primevue/button' import InputText from 'primevue/inputtext' import InputNumber from 'primevue/inputnumber' @@ -31,6 +31,9 @@ function formatFieldLabel(key: string): string { /** Keys handled by the DayNightCycleWidget — skip rendering them individually. */ const DAY_NIGHT_KEYS = ['DayNightLength', 'DayLightLength'] +/** The 7D2D 3.0 Sandbox code field — shown only on 3.0, hidden on 2.x. */ +const SANDBOX_CODE_KEY = 'SandboxCode' + const { t } = useI18n() const toast = useToast() @@ -54,8 +57,35 @@ const isDirty = computed(() => { return JSON.stringify(properties.value) !== JSON.stringify(originalProperties.value) }) +/** + * True when the running game is 7D2D 3.0+ (supports the SandboxCode system) — set from the + * backend's GameSupportsSandboxCode(). Controls whether the SandboxCode field is shown: it + * must appear on ANY 3.0 server, even before a code has been pasted in (otherwise you could + * never add one — chicken-and-egg). + */ +const is30 = ref(false) + +/** True when a 3.0 server's serverconfig.xml still carries the old sandbox-governed + * properties — i.e. a one-click "Migrate to 3.0" would do something. */ +const needsMigration = ref(false) +const migrating = ref(false) + +/** + * True when a non-empty SandboxCode is actually set. Only then does it override the + * individual sandbox-governed settings, so only then are those hidden. With an empty/absent + * code, 3.0 still reads the individual serverconfig properties (backward-compat), so they + * stay visible and editable. + */ +const hasSandboxCode = computed(() => { + const k = Object.keys(properties.value).find(k => k.toLowerCase() === SANDBOX_CODE_KEY.toLowerCase()) + return !!(k && properties.value[k] && properties.value[k].trim()) +}) + const coreGroup = computed(() => groups.value.find(g => g.key === 'core')) -const otherGroups = computed(() => groups.value.filter(g => g.key !== 'core')) +// Hide groups that have no visible fields (e.g. Blood Moon is entirely sandbox-governed on 3.0). +const otherGroups = computed(() => + groups.value.filter(g => g.key !== 'core' && visibleFieldsFor(g).length > 0) +) const groupLabels: Record = { core: 'config.group.core', @@ -96,6 +126,8 @@ async function loadConfig() { groups.value = configData.groups configPath.value = configData.configPath worlds.value = worldList + is30.value = configData.is30 ?? false + needsMigration.value = configData.needsMigration ?? false } catch (err) { toast.add({ severity: 'error', summary: t('common.error'), detail: t('config.failedToLoad'), life: 4000 }) } finally { @@ -103,6 +135,21 @@ async function loadConfig() { } } +/** One-click 3.0 migration: the server comments out the sandbox-governed props, + * adds SandboxCode, and backs up first. Reloads so the editor reflects the result. */ +async function runMigration() { + migrating.value = true + try { + const result = await migrateConfigTo30() + toast.add({ severity: 'success', summary: t('common.success', 'Done'), detail: result.message, life: 6000 }) + await loadConfig() + } catch (err) { + toast.add({ severity: 'error', summary: t('common.error'), detail: t('config.migrate30Failed', 'Migration failed'), life: 4000 }) + } finally { + migrating.value = false + } +} + async function loadRawXml() { try { rawXml.value = await getRawXml() @@ -190,10 +237,21 @@ function getSelectOptions(field: { key: string; options?: string[]; labels?: str * DayNightLength + DayLightLength because the DayNightCycleWidget covers them. */ function visibleFieldsFor(group: ConfigFieldGroup) { + let fields = group.fields + // DayNight pair is rendered by the DayNightCycleWidget, not as individual fields. if (group.key === 'gameplay') { - return group.fields.filter((f) => !DAY_NIGHT_KEYS.includes(f.key)) + fields = fields.filter((f) => !DAY_NIGHT_KEYS.includes(f.key)) + } + // The SandboxCode field only makes sense on a 3.0 server (show it even before one is set). + if (!is30.value) { + fields = fields.filter((f) => f.key !== SANDBOX_CODE_KEY) } - return group.fields + // Hide the sandbox-governed settings only when a SandboxCode actually overrides them. + // With no code set, 3.0 still reads these individual properties, so keep them editable. + if (hasSandboxCode.value) { + fields = fields.filter((f) => !f.sandboxGoverned) + } + return fields } function onDayNightUpdate(cfg: { DayNightLength: number; DayLightLength: number }) { @@ -241,6 +299,21 @@ onMounted(loadConfig) {{ t('config.unsavedChanges') }} + +
+ {{ t('config.migrate30Notice', 'This server is on 7 Days to Die 3.0, but serverconfig.xml still has the old per-setting properties that 3.0 moved into the Sandbox. Migrating comments them out (preserved, not deleted), adds a Sandbox Code field, and leaves everything else alone. A backup is saved first.') }} +
+
+ + + {{ t('config.sandboxUpgradeNotice', 'This is a 7 Days to Die 3.0 server. Your individual settings below are still active — 3.0 reads them when no Sandbox code is set. To switch to the new Sandbox system and unlock 3.0-only options, generate a code in-game (New Game → Sandbox Options → copy code) and paste it into the Sandbox Code field. Saving writes it to both serverconfig.xml and the sticky .bak, so it survives restarts.') }} + + + + {{ t('config.sandboxActiveNotice', 'A Sandbox code is set — it governs difficulty, XP, blood moon, loot, zombie behavior and other world settings, so those individual settings are hidden (the server reads them from the code, not serverconfig.xml). Clear the Sandbox Code field to return to individual settings.') }} + +

{{ t('common.loading') }}

@@ -297,7 +370,7 @@ onMounted(loadConfig)
span { + flex: 1; + min-width: 240px; +} + .loading-state { display: flex; flex-direction: column; diff --git a/src/KitsuneCommand.Tests/Services/ServerConfigServiceTests.cs b/src/KitsuneCommand.Tests/Services/ServerConfigServiceTests.cs index bfc5773..74b03d2 100644 --- a/src/KitsuneCommand.Tests/Services/ServerConfigServiceTests.cs +++ b/src/KitsuneCommand.Tests/Services/ServerConfigServiceTests.cs @@ -150,6 +150,63 @@ public void ReadConfig_ThrowsWhenFileNotFound() Assert.Throws(() => service.ReadConfig()); } + [Test] + public void MigrateConfigTo30_NeutralizesGovernedProps_AddsSandboxCode_KeepsSurvivors() + { + File.WriteAllText(_configPath, MigrationSample); + + var result = _service.MigrateConfigTo30(); + + Assert.That(result.Changed, Is.True); + Assert.That(result.AddedSandboxCode, Is.True); + Assert.That(result.Neutralized, Does.Contain("DeathPenalty")); + Assert.That(result.Neutralized, Does.Contain("XPMultiplier")); + Assert.That(result.Neutralized, Does.Contain("BloodMoonFrequency")); + + var config = _service.ReadConfig(); // live elements only (comments excluded) + // The sandbox-governed props are no longer live properties... + Assert.That(config, Does.Not.ContainKey("DeathPenalty")); + Assert.That(config, Does.Not.ContainKey("XPMultiplier")); + Assert.That(config, Does.Not.ContainKey("BloodMoonFrequency")); + // ...SandboxCode now exists, and the survivors are untouched. + Assert.That(config, Does.ContainKey("SandboxCode")); + Assert.That(config, Does.ContainKey("GameDifficulty")); // survivor — stays + Assert.That(config["GameDifficulty"], Is.EqualTo("3")); + Assert.That(config, Does.ContainKey("LootAbundance")); // survivor — stays + Assert.That(config["ServerName"], Is.EqualTo("My Test Server")); + + // Old values are preserved in comments, not destroyed. + var raw = _service.ReadRawXml(); + Assert.That(raw, Does.Contain("DeathPenalty")); + Assert.That(raw, Does.Contain("moved to the in-game Sandbox")); + + // A timestamped backup was written before the change. + Assert.That(result.BackupPath, Is.Not.Null); + Assert.That(File.Exists(result.BackupPath), Is.True); + } + + [Test] + public void MigrateConfigTo30_IsIdempotent() + { + File.WriteAllText(_configPath, MigrationSample); + Assert.That(_service.MigrateConfigTo30().Changed, Is.True); + + var second = _service.MigrateConfigTo30(); + Assert.That(second.Changed, Is.False); // already 3.0-shaped + Assert.That(second.Neutralized, Is.Empty); + Assert.That(second.AddedSandboxCode, Is.False); + Assert.That(second.BackupPath, Is.Null); // no backup churn on a no-op + } + + [Test] + public void NeedsMigrationTo30_TrueOnlyWhileGovernedPropsRemain() + { + File.WriteAllText(_configPath, MigrationSample); + Assert.That(_service.NeedsMigrationTo30(), Is.True); + _service.MigrateConfigTo30(); + Assert.That(_service.NeedsMigrationTo30(), Is.False); + } + /// /// Testable subclass that bypasses ModEntry/GameIO path resolution. /// @@ -162,7 +219,7 @@ public ServerConfigServiceTestable(string configPath) _fixedPath = configPath; } - public new string GetConfigPath() + public override string GetConfigPath() { if (File.Exists(_fixedPath)) return _fixedPath; @@ -254,5 +311,18 @@ public ServerConfigServiceTestable(string configPath) "; + + // A 2.x-shaped config carrying both sandbox-governed props (DeathPenalty, + // XPMultiplier, BloodMoonFrequency) and survivors (GameDifficulty, LootAbundance), + // with no SandboxCode yet — the exact input the 3.0 migration handles. + private const string MigrationSample = @" + + + + + + + +"; } } diff --git a/src/KitsuneCommand/Services/ServerConfigFieldDefinitions.cs b/src/KitsuneCommand/Services/ServerConfigFieldDefinitions.cs index 182f0a6..0b68c9c 100644 --- a/src/KitsuneCommand/Services/ServerConfigFieldDefinitions.cs +++ b/src/KitsuneCommand/Services/ServerConfigFieldDefinitions.cs @@ -3,8 +3,33 @@ namespace KitsuneCommand.Services { /// - /// Defines all known serverconfig.xml fields with metadata for the config editor UI. - /// Covers vanilla 7D2D V2 server settings. + /// Defines serverconfig.xml fields with metadata for the config editor UI. + /// + /// VERSION COVERAGE + /// • 2.x: every per-property field below is authoritative and edited individually. + /// • 3.0 ("Dead Hot Summer", 2026-06): 7D2D moved the world/gameplay "sandbox" + /// settings (difficulty, XP, blood moon, loot, zombie speeds, land claims, etc.) + /// out of serverconfig.xml and into the in-game Sandbox, encoded as a single + /// "SandboxCode" property. On a 3.0 server the individual sandbox-governed + /// properties are IGNORED in favor of SandboxCode. We keep those fields here so + /// 2.x servers still edit cleanly (the "keep the current structure for 2.x" + /// requirement) and add SandboxCode alongside them (additive, low-risk). + /// A version-aware editor that HIDES the sandbox-governed fields when a 3.0 server + /// is detected is the planned follow-up — but the exact removed/kept split must be + /// confirmed against a pristine 3.0 default serverconfig.xml first, so do NOT + /// delete any field here until then. (The serverconfig property is "SandboxCode" + /// per 3.0 hosting docs; the binary also exposes a "ServerSandboxCode" accessor — + /// reconcile the exact key against the pristine default during the follow-up.) + /// + /// VERSION-AGNOSTIC BY DESIGN + /// • Properties not modeled here are preserved untouched on save (see + /// ServerConfigService.SaveConfig), so a 2.x box never loses settings KC doesn't + /// render — and the "Folder and file locations" group (AdminFileName / + /// UserDataFolder / SaveGameFolder) is intentionally omitted: operators shouldn't + /// repoint data paths on a live world from a web panel. + /// • GameWorld options are merged at runtime with worlds discovered on disk (see + /// ServerConfigService.GetAvailableWorlds), so 3.0 Pregen worlds appear in the + /// dropdown automatically without being hardcoded. /// /// Descriptions are written to be useful at editing-time — what the field does, a /// sensible default, and the side-effect of cranking it up or down. Tone is warm @@ -15,7 +40,7 @@ public static class ServerConfigFieldDefinitions { public static List GetGroups() { - return new List + var groups = new List { new ConfigFieldGroup { @@ -96,6 +121,8 @@ public static List GetGroups() Key = "gameplay", Fields = new List { + TextField("SandboxCode", "", + "7D2D 3.0+ only. Paste the Sandbox code you generate in-game (New Game → Sandbox Options → copy code). On a 3.0 server this single value drives difficulty, XP, blood moon, loot, zombie behavior, land claims, and the rest of the world ruleset — the matching individual settings in this editor are ignored in its favor. Leave blank on 2.x servers, where those individual settings apply instead."), SelectField("GameDifficulty", "2", new[] { "0", "1", "2", "3", "4", "5" }, new[] { "Scavenger", "Adventurer", "Nomad", "Warrior", "Survivalist", "Insane" }, @@ -322,8 +349,52 @@ public static List GetGroups() } }, }; + + // Flag the 3.0 sandbox-governed fields so the editor can hide them on a 3.0 + // server (see SandboxGovernedKeys for how this set was derived). + foreach (var g in groups) + foreach (var f in g.Fields) + f.SandboxGoverned = SandboxGovernedKeys.Contains(f.Key); + return groups; } + /// + /// serverconfig.xml properties that 7D2D 3.0 moved into the in-game Sandbox + /// (governed by the SandboxCode property). On a 3.0 server these are read from the + /// SandboxCode, NOT from serverconfig.xml, so the editor hides them once a 3.0 + /// server is detected. + /// + /// Derived authoritatively from the game, NOT from patch notes: each key here is an + /// EnumGamePrefs member whose name also exists in the SandboxOptions enum of the 3.0 + /// Assembly-CSharp — which is exactly the game's own sandbox-link test + /// (GamePrefs.SetupSandboxReferences does Enum.TryParse<SandboxOptions>(prefName)). + /// Verified against the 3.0 "Dead Hot Summer" server build (2026-06). + /// + /// Note the non-obvious SURVIVORS that stay in serverconfig and are deliberately NOT + /// listed: GameDifficulty, BlockDamagePlayer, EnemyDifficulty, MaxSpawnedZombies, + /// MaxSpawnedAnimals, LootAbundance, all LandClaim*, PlayerSafeZone*, + /// BedrollDeadZoneSize. (e.g. BlockDamageAI/AIBM move but BlockDamagePlayer stays; + /// LootRespawnDays moves but LootAbundance stays.) + /// + private static readonly HashSet SandboxGovernedKeys = new HashSet(System.StringComparer.OrdinalIgnoreCase) + { + "DeathPenalty", "DropOnDeath", "DropOnQuit", "DayNightLength", "DayLightLength", + "QuestProgressionDailyLimit", "JarRefund", "BiomeProgression", "StormFreq", + "BlockDamageAI", "BlockDamageAIBM", "XPMultiplier", "EnemySpawnMode", + "ZombieFeralSense", "ZombieMove", "ZombieMoveNight", "ZombieFeralMove", + "ZombieBMMove", "AISmellMode", "BloodMoonFrequency", "BloodMoonRange", + "BloodMoonWarning", "BloodMoonEnemyCount", "LootRespawnDays", "AirDropFrequency", + "AirDropMarker", + }; + + /// + /// The 3.0 sandbox-governed property keys, for the serverconfig.xml 3.0 migration + /// in . Read-only — the set is + /// derived authoritatively from the game (see ). + /// + public static System.Collections.Generic.IReadOnlyCollection GetSandboxGovernedKeys() + => SandboxGovernedKeys; + private static ConfigFieldDef TextField(string key, string defaultValue, string description = null) => new ConfigFieldDef { Key = key, Type = "text", DefaultValue = defaultValue, Description = description }; @@ -373,6 +444,8 @@ public class ConfigFieldDef { public string Key { get; set; } public string Type { get; set; } // text, password, number, bool, select + // True when 7D2D 3.0 governs this setting via SandboxCode; the editor hides it on 3.0. + public bool SandboxGoverned { get; set; } public string DefaultValue { get; set; } public int? Min { get; set; } public int? Max { get; set; } diff --git a/src/KitsuneCommand/Services/ServerConfigService.cs b/src/KitsuneCommand/Services/ServerConfigService.cs index f408ea1..aa40f96 100644 --- a/src/KitsuneCommand/Services/ServerConfigService.cs +++ b/src/KitsuneCommand/Services/ServerConfigService.cs @@ -18,7 +18,7 @@ public class ServerConfigService /// /// Locates the serverconfig.xml file. Searches common locations. /// - public string GetConfigPath() + public virtual string GetConfigPath() { if (_configPath != null && File.Exists(_configPath)) return _configPath; @@ -202,5 +202,126 @@ public List GetFieldDefinitions() { return ServerConfigFieldDefinitions.GetGroups(); } + + /// + /// True when serverconfig.xml still carries 3.0-deprecated sandbox-governed + /// properties as live elements — i.e. a migration would do something. Lets the UI + /// offer "Migrate to 3.0" only when there's actually something to clean up (and not + /// after it's already been done). Returns false if the file can't be read. + /// + public bool NeedsMigrationTo30() + { + try + { + var path = GetConfigPath(); + if (path == null) return false; + var doc = XDocument.Load(path); + var governed = new HashSet( + ServerConfigFieldDefinitions.GetSandboxGovernedKeys(), StringComparer.OrdinalIgnoreCase); + return doc.Descendants("property") + .Any(p => governed.Contains(p.Attribute("name")?.Value ?? "")); + } + catch { return false; } + } + + /// + /// Migrate serverconfig.xml to the 7D2D 3.0 ("Dead Hot Summer") shape. + /// + /// 3.0 moved a set of world/gameplay settings out of serverconfig.xml and into the + /// in-game Sandbox, governed by a single SandboxCode property; on a 3.0 server the + /// old individual properties are IGNORED. This: + /// 1. comments out (does NOT delete) each sandbox-governed property, preserving its + /// value so a downgrade or reference stays possible; + /// 2. ensures a SandboxCode property exists (empty — pasted in via the editor); + /// 3. leaves the "survivor" properties and everything KC doesn't model untouched. + /// + /// Safety: takes a timestamped backup BEFORE writing, is idempotent (a second run is + /// a no-op), and refreshes the sticky .bak so the pre-start restore keeps the result. + /// The caller gates this on a real 3.0 install (ServerConfigService.GameSupportsSandboxCode); + /// the method itself is pure file work so it stays unit-testable off a live game. + /// + public ConfigMigrationResult MigrateConfigTo30() + { + var result = new ConfigMigrationResult(); + var path = GetConfigPath(); + if (path == null) + throw new FileNotFoundException("serverconfig.xml not found."); + + var doc = XDocument.Load(path, LoadOptions.PreserveWhitespace); + var root = doc.Root; + if (root == null) + throw new InvalidOperationException("serverconfig.xml has no root element."); + + var governed = new HashSet( + ServerConfigFieldDefinitions.GetSandboxGovernedKeys(), StringComparer.OrdinalIgnoreCase); + + // 1. Neutralize the sandbox-governed properties by replacing each with an XML + // comment that preserves its old name/value (never hard-delete operator data). + var toNeutralize = root.Descendants("property") + .Where(p => governed.Contains(p.Attribute("name")?.Value ?? "")) + .ToList(); + foreach (var prop in toNeutralize) + { + var name = prop.Attribute("name")?.Value ?? ""; + var value = prop.Attribute("value")?.Value ?? ""; + // XML comments can't contain "--"; swap any out so the document stays valid. + var body = $" property name=\"{name}\" value=\"{value}\" — moved to the in-game Sandbox in 3.0 (governed by SandboxCode) " + .Replace("--", "—"); + prop.ReplaceWith(new XComment(body)); + result.Neutralized.Add(name); + } + + // 2. Ensure SandboxCode exists for the operator to paste their code into. + var hasSandbox = root.Descendants("property") + .Any(p => string.Equals(p.Attribute("name")?.Value, "SandboxCode", StringComparison.OrdinalIgnoreCase)); + if (!hasSandbox) + { + root.Add(new XElement("property", + new XAttribute("name", "SandboxCode"), + new XAttribute("value", ""))); + result.AddedSandboxCode = true; + } + + result.Changed = result.Neutralized.Count > 0 || result.AddedSandboxCode; + if (!result.Changed) + return result; // already 3.0-shaped — idempotent no-op, no backup churn + + // 3. Timestamped backup BEFORE writing (distinct from the sticky .bak). + var stamp = DateTime.Now.ToString("yyyyMMdd-HHmmss"); + result.BackupPath = path + $".pre30-{stamp}.bak"; + File.Copy(path, result.BackupPath, true); + + doc.Save(path); + // Refresh the sticky .bak so the pre-start restore keeps the migrated file. + File.Copy(path, path + ".bak", true); + return result; + } + + /// + /// True when the running game is 7D2D 3.0+ (supports the SandboxCode system), + /// detected by reflecting EnumGamePrefs for a "SandboxCode" member. Done via + /// Enum.GetNames rather than a direct EnumGamePrefs.SandboxCode reference so it + /// compiles against the 2.x reference assemblies yet returns true at runtime on a + /// 3.0 server. The editor uses this to decide whether to show the SandboxCode field + /// (it should appear on any 3.0 server, even before a code has been pasted in). + /// + public bool GameSupportsSandboxCode() + { + try { return System.Array.IndexOf(System.Enum.GetNames(typeof(EnumGamePrefs)), "SandboxCode") >= 0; } + catch { return false; } + } + } + + /// Outcome of . + public class ConfigMigrationResult + { + /// True if the file was actually rewritten (false = already 3.0-shaped). + public bool Changed { get; set; } + /// True if a SandboxCode property was added (it was missing). + public bool AddedSandboxCode { get; set; } + /// Names of the sandbox-governed properties that were commented out. + public List Neutralized { get; set; } = new List(); + /// Path of the timestamped backup taken before writing, or null if no change. + public string BackupPath { get; set; } } } diff --git a/src/KitsuneCommand/Web/Controllers/ConfigController.cs b/src/KitsuneCommand/Web/Controllers/ConfigController.cs index 31c4f3e..d8b8f2b 100644 --- a/src/KitsuneCommand/Web/Controllers/ConfigController.cs +++ b/src/KitsuneCommand/Web/Controllers/ConfigController.cs @@ -40,7 +40,9 @@ public IHttpActionResult GetConfig() { properties, groups, - configPath + configPath, + is30 = _configService.GameSupportsSandboxCode(), + needsMigration = _configService.NeedsMigrationTo30() })); } catch (Exception ex) @@ -134,6 +136,42 @@ public IHttpActionResult GetWorlds() return Ok(ApiResponse.Ok(new List { "Navezgane" })); } } + + /// + /// Migrate serverconfig.xml to the 7D2D 3.0 layout: comment out the sandbox-governed + /// properties (3.0 reads those from SandboxCode) and ensure a SandboxCode property + /// exists. Refuses on a non-3.0 server. Backs up before writing; idempotent. + /// + [HttpPost] + [Route("migrate-3.0")] + [RoleAuthorize("admin")] + public IHttpActionResult MigrateTo30() + { + if (!_configService.GameSupportsSandboxCode()) + return Ok(ApiResponse.Error(400, "This server isn't running 7D2D 3.0 — nothing to migrate (the 3.0 Sandbox system isn't present).")); + + try + { + var result = _configService.MigrateConfigTo30(); + var message = result.Changed + ? $"Migrated to 3.0: commented out {result.Neutralized.Count} sandbox-governed setting(s)" + + (result.AddedSandboxCode ? " and added SandboxCode" : "") + + ". A backup was saved. Paste your Sandbox code, then restart to apply." + : "Already on the 3.0 layout — nothing to change."; + return Ok(ApiResponse.Ok(new + { + result.Changed, + result.AddedSandboxCode, + result.Neutralized, + result.BackupPath, + message + })); + } + catch (Exception ex) + { + return Ok(ApiResponse.Error(500, $"Migration failed: {ex.Message}")); + } + } } public class RawXmlRequest