Read authored settings from the Unity asset
A fixture that invents curve and tier values measures a game nobody plays, so the tool reads Assets/Resources/Settings.asset, whose GameSettings holds plain ServerShared classes, and fills them by field name. The settings command asserts counts rather than non-null: an empty array reads as populated, and that lie cost two rounds of parsing bugs in sequences. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
285b57713f
commit
a63aa38f00
2 changed files with 287 additions and 0 deletions
227
tools/AetherDb/AuthoredSettings.cs
Normal file
227
tools/AetherDb/AuthoredSettings.cs
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Unity.Mathematics;
|
||||
|
||||
// Reads the authored gameplay and generation settings out of Assets/Resources/Settings.asset, the Unity asset whose
|
||||
// GameSettings ScriptableObject holds plain ServerShared classes. Those classes carry curves and tiers with no
|
||||
// defaults, so a fixture that invented numbers would measure a game nobody plays; this reads the real ones.
|
||||
//
|
||||
// The asset is Unity YAML whose leaves under these subtrees are scalars, sequences of mappings, or float3 as x/y/z.
|
||||
// Parsed by indentation and matched to fields by name, so a renamed or added field simply goes unset rather than
|
||||
// silently mapping to the wrong thing. Anything it could not place is reported by Unplaced.
|
||||
public sealed class AuthoredSettings
|
||||
{
|
||||
private readonly Node _root;
|
||||
public List<string> Unplaced { get; } = new List<string>();
|
||||
|
||||
private AuthoredSettings(Node root) => _root = root;
|
||||
|
||||
// Prints the parsed tree under a subtree, for checking that sequences landed on the field that declares them
|
||||
public void Dump(string subtree, int maxDepth = 3)
|
||||
{
|
||||
var node = Find(_root, subtree);
|
||||
if (node == null)
|
||||
{
|
||||
Console.WriteLine($"{subtree}: not found");
|
||||
return;
|
||||
}
|
||||
Console.WriteLine($"{subtree}: {node.Children.Count} children, {node.Items.Count} items");
|
||||
DumpNode(node, 1, maxDepth);
|
||||
}
|
||||
|
||||
private static void DumpNode(Node node, int depth, int maxDepth)
|
||||
{
|
||||
if (depth > maxDepth) return;
|
||||
var pad = new string(' ', depth * 2);
|
||||
foreach (var child in node.Children)
|
||||
{
|
||||
Console.WriteLine($"{pad}{child.Key}: scalar={child.Value.Scalar ?? "-"} children={child.Value.Children.Count} items={child.Value.Items.Count}");
|
||||
DumpNode(child.Value, depth + 1, maxDepth);
|
||||
}
|
||||
for (var i = 0; i < node.Items.Count; i++)
|
||||
Console.WriteLine($"{pad}[{i}] scalar={node.Items[i].Scalar ?? "-"} children={node.Items[i].Children.Count}");
|
||||
}
|
||||
|
||||
public static AuthoredSettings Load(string repositoryRoot)
|
||||
{
|
||||
var path = Path.Combine(repositoryRoot, "Assets", "Resources", "Settings.asset");
|
||||
if (!File.Exists(path)) throw new FileNotFoundException($"no settings asset at {path}");
|
||||
return new AuthoredSettings(Parse(File.ReadAllLines(path)));
|
||||
}
|
||||
|
||||
// Fills a new instance of T from the named subtree, by field name
|
||||
public T Read<T>(string subtree) where T : new()
|
||||
{
|
||||
var node = Find(_root, subtree);
|
||||
if (node == null) throw new InvalidDataException($"settings asset has no {subtree} subtree");
|
||||
var value = new T();
|
||||
Populate(value, node, subtree);
|
||||
return value;
|
||||
}
|
||||
|
||||
private Node Find(Node node, string key)
|
||||
{
|
||||
if (node.Children.TryGetValue(key, out var found)) return found;
|
||||
foreach (var child in node.Children.Values)
|
||||
{
|
||||
var deeper = Find(child, key);
|
||||
if (deeper != null) return deeper;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void Populate(object target, Node node, string path)
|
||||
{
|
||||
foreach (var entry in node.Children)
|
||||
{
|
||||
var field = target.GetType().GetField(entry.Key, BindingFlags.Public | BindingFlags.Instance);
|
||||
if (field == null)
|
||||
{
|
||||
Unplaced.Add($"{path}.{entry.Key}");
|
||||
continue;
|
||||
}
|
||||
|
||||
var converted = Convert(entry.Value, field.FieldType, $"{path}.{entry.Key}");
|
||||
if (converted != null) field.SetValue(target, converted);
|
||||
}
|
||||
}
|
||||
|
||||
private object Convert(Node node, Type type, string path)
|
||||
{
|
||||
if (type == typeof(float)) return float.TryParse(node.Scalar, out var f) ? f : 0f;
|
||||
if (type == typeof(int)) return int.TryParse(node.Scalar, out var i) ? i : 0;
|
||||
if (type == typeof(uint)) return uint.TryParse(node.Scalar, out var u) ? u : 0u;
|
||||
if (type == typeof(bool)) return node.Scalar == "1" || string.Equals(node.Scalar, "true", StringComparison.OrdinalIgnoreCase);
|
||||
if (type == typeof(string)) return node.Scalar;
|
||||
|
||||
if (type == typeof(float3))
|
||||
return float3(Leaf(node, "x"), Leaf(node, "y"), Leaf(node, "z"));
|
||||
|
||||
if (type.IsArray)
|
||||
{
|
||||
var elementType = type.GetElementType();
|
||||
var array = Array.CreateInstance(elementType, node.Items.Count);
|
||||
for (var index = 0; index < node.Items.Count; index++)
|
||||
{
|
||||
// A sequence of scalars carries its value on the item itself; a sequence of mappings has children
|
||||
var item = node.Items[index];
|
||||
var converted = item.Scalar != null && item.Children.Count == 0
|
||||
? Convert(new Node { Scalar = item.Scalar }, elementType, $"{path}[{index}]")
|
||||
: Convert(item, elementType, $"{path}[{index}]");
|
||||
array.SetValue(converted, index);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
if (type.IsClass)
|
||||
{
|
||||
var value = Activator.CreateInstance(type);
|
||||
Populate(value, node, path);
|
||||
return value;
|
||||
}
|
||||
|
||||
Unplaced.Add($"{path} (unsupported type {type.Name})");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static float Leaf(Node node, string key) =>
|
||||
node.Children.TryGetValue(key, out var child) && float.TryParse(child.Scalar, out var value) ? value : 0f;
|
||||
|
||||
private static float3 float3(float x, float y, float z) => new float3(x, y, z);
|
||||
|
||||
// One YAML node: a scalar, a mapping of children, or a sequence of items.
|
||||
private sealed class Node
|
||||
{
|
||||
public string Scalar;
|
||||
public Dictionary<string, Node> Children { get; } = new Dictionary<string, Node>();
|
||||
public List<Node> Items { get; } = new List<Node>();
|
||||
}
|
||||
|
||||
// Indentation-driven parse of the three shapes this asset uses:
|
||||
// key: value / key: a scalar or a mapping
|
||||
// - value a sequence of scalars (NeutralFactions, MinimapZoomLevels)
|
||||
// - key: value + deeper keys a sequence of mappings (Tiers, BodySettingsCollections)
|
||||
// A mapping item's first key sits at the dash's own column while its siblings are indented past it, so one
|
||||
// frame stands for the whole item and accepts both depths. Inline flow mappings ({fileID: ...}) are Unity
|
||||
// object references, which nothing here reads, so they are kept as raw scalars rather than half parsed.
|
||||
private static Node Parse(string[] lines)
|
||||
{
|
||||
var root = new Node();
|
||||
// A frame is either a mapping a key opened, or one item of a sequence. The distinction matters because a
|
||||
// dash shares its column with its container: an item must pop the previous item without popping the
|
||||
// container that holds them both.
|
||||
var stack = new List<(int indent, bool isItemFrame, Node node)> { (-1, false, root) };
|
||||
|
||||
foreach (var raw in lines)
|
||||
{
|
||||
var trimmed = raw.TrimEnd();
|
||||
if (trimmed.Length == 0) continue;
|
||||
|
||||
var indent = trimmed.Length - trimmed.TrimStart(' ').Length;
|
||||
var content = trimmed.TrimStart(' ');
|
||||
if (content.StartsWith("#") || content.StartsWith("%") || content.StartsWith("---")) continue;
|
||||
|
||||
var isItem = content.StartsWith("- ");
|
||||
if (isItem) content = content.Substring(2).TrimStart();
|
||||
|
||||
// A key pops every frame at or inside its column. A dash pops frames deeper than its column, plus any
|
||||
// item frame at its column (the preceding item of the same sequence), leaving that sequence's container.
|
||||
while (stack.Count > 1)
|
||||
{
|
||||
var top = stack[stack.Count - 1];
|
||||
var pop = isItem
|
||||
? top.indent > indent || (top.indent == indent && top.isItemFrame)
|
||||
: top.indent >= indent;
|
||||
if (!pop) break;
|
||||
stack.RemoveAt(stack.Count - 1);
|
||||
}
|
||||
var container = stack[stack.Count - 1].node;
|
||||
|
||||
if (isItem)
|
||||
{
|
||||
if (content.StartsWith("{"))
|
||||
{
|
||||
container.Items.Add(new Node { Scalar = content });
|
||||
continue;
|
||||
}
|
||||
|
||||
var item = new Node();
|
||||
container.Items.Add(item);
|
||||
// The item's own keys sit at this column and deeper, so its frame claims the dash's column
|
||||
stack.Add((indent, true, item));
|
||||
container = item;
|
||||
|
||||
if (!content.Contains(":"))
|
||||
{
|
||||
item.Scalar = content;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
var separator = content.IndexOf(':', StringComparison.Ordinal);
|
||||
if (separator < 0) continue;
|
||||
|
||||
var key = content.Substring(0, separator);
|
||||
var value = content.Substring(separator + 1).Trim();
|
||||
if (value.Length == 0)
|
||||
{
|
||||
// A key with no value opens a container. Its frame sits past its own column so that a sequence
|
||||
// whose dashes share that column still attaches to it rather than to its parent.
|
||||
var child = new Node();
|
||||
container.Children[key] = child;
|
||||
stack.Add((indent, false, child));
|
||||
}
|
||||
else container.Children[key] = new Node { Scalar = value };
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,8 @@ public static class Program
|
|||
case "save": return Save();
|
||||
case "factions": return Factions();
|
||||
case "clear-boss-hulls": return ClearBossHulls(args.Contains("apply"));
|
||||
case "settings": return Settings();
|
||||
case "settings-dump": return SettingsDump();
|
||||
case "migrate-products": return MigrateProducts(args.Contains("apply"));
|
||||
default:
|
||||
Console.WriteLine("commands: doctor, census, station-fit, migrate-products [apply]");
|
||||
|
|
@ -146,6 +148,64 @@ public static class Program
|
|||
return unfillable;
|
||||
}
|
||||
|
||||
// The parsed shape of the settings asset, for checking where sequence items actually landed.
|
||||
private static int SettingsDump()
|
||||
{
|
||||
var authored = AuthoredSettings.Load(AetherDb.Open().Root);
|
||||
authored.Dump("TutorialGenerationSettings", 2);
|
||||
Console.WriteLine();
|
||||
authored.Dump("GameplaySettings", 2);
|
||||
return 0;
|
||||
}
|
||||
|
||||
// What the authored settings asset actually yields, so a fixture using it can be trusted before it reports
|
||||
// anything about galaxies. Prints the values that matter to generation and every field it could not place.
|
||||
private static int Settings()
|
||||
{
|
||||
var db = AetherDb.Open();
|
||||
var authored = AuthoredSettings.Load(db.Root);
|
||||
|
||||
var tutorial = authored.Read<TutorialGenerationSettings>("TutorialGenerationSettings");
|
||||
var background = authored.Read<SectorBackgroundSettings>("TutorialBackgroundSettings");
|
||||
var names = authored.Read<NameGeneratorSettings>("NameGeneratorSettings");
|
||||
var zones = authored.Read<ZoneGenerationSettings>("ZoneSettings");
|
||||
var gameplay = authored.Read<GameplaySettings>("GameplaySettings");
|
||||
|
||||
Console.WriteLine($"tutorial: protagonist {tutorial.ProtagonistFaction}, antagonist {tutorial.AntagonistFaction}, " +
|
||||
$"buffer {tutorial.BufferFaction}, quest {tutorial.QuestFaction}, " +
|
||||
$"neutrals [{string.Join(", ", tutorial.NeutralFactions ?? new string[0])}], " +
|
||||
$"{tutorial.ZoneCount} zones, link density {tutorial.LinkDensity}");
|
||||
Console.WriteLine($"background: frequency {background.NoiseFrequency}, amplitude {background.NoiseAmplitude}, " +
|
||||
$"cloud exponent {background.CloudExponent}, density at centre {background.CloudDensity(new float2(.5f)):0.000}");
|
||||
Console.WriteLine($"names: order {names.NameGeneratorOrder}, length {names.NameGeneratorMinLength}-{names.NameGeneratorMaxLength}");
|
||||
Console.WriteLine($"zones: sun mass {zones.SunMass}, planet mass {zones.PlanetMass}, satellite passes {zones.SatellitePasses}, " +
|
||||
$"belt probability {zones.BeltProbability}");
|
||||
Console.WriteLine($" zone radius curve {zones.ZoneRadius?.Minimum}-{zones.ZoneRadius?.Maximum} exponent {zones.ZoneRadius?.Exponent}");
|
||||
Console.WriteLine($" sub zone count {zones.SubZoneCount?.Minimum}-{zones.SubZoneCount?.Maximum} exponent {zones.SubZoneCount?.Exponent}");
|
||||
Console.WriteLine($"gameplay: weapon groups {gameplay.WeaponGroupCount}, shutdown performance {gameplay.DefaultEntitySettings?.ShutdownPerformance}");
|
||||
Console.WriteLine($" tiers: {string.Join(", ", (gameplay.Tiers ?? new RarityTier[0]).Select(t => $"{t.Name} q{t.Quality} r{t.Rarity}"))}");
|
||||
Console.WriteLine($" price modifier {gameplay.QualityPriceModifier?.Minimum}-{gameplay.QualityPriceModifier?.Maximum} exponent {gameplay.QualityPriceModifier?.Exponent}");
|
||||
|
||||
// Assert counts, not non-null: an empty array reads as populated and would make every number a fixture
|
||||
// reports downstream a fiction. These are the authored values as of the asset read above.
|
||||
var missing = new List<string>();
|
||||
if (zones.ZoneRadius == null || zones.ZoneRadius.Maximum <= 0) missing.Add("ZoneSettings.ZoneRadius");
|
||||
if (zones.SubZoneCount == null || zones.SubZoneCount.Maximum <= 0) missing.Add("ZoneSettings.SubZoneCount");
|
||||
if (zones.PlanetSafetyRadius == null || zones.PlanetSafetyRadius.Multiplier <= 0) missing.Add("ZoneSettings.PlanetSafetyRadius");
|
||||
if (gameplay.Tiers == null || gameplay.Tiers.Length != 5) missing.Add($"GameplaySettings.Tiers (expected 5, got {gameplay.Tiers?.Length ?? 0})");
|
||||
else if (gameplay.Tiers.Any(t => string.IsNullOrEmpty(t.Name) || t.Quality <= 0)) missing.Add("GameplaySettings.Tiers (a tier parsed without a name or quality)");
|
||||
if (gameplay.DefaultEntitySettings == null) missing.Add("GameplaySettings.DefaultEntitySettings");
|
||||
if (gameplay.QualityPriceModifier == null || gameplay.QualityPriceModifier.Maximum <= 0) missing.Add("GameplaySettings.QualityPriceModifier");
|
||||
if (tutorial.NeutralFactions == null || tutorial.NeutralFactions.Length != 2) missing.Add($"TutorialGenerationSettings.NeutralFactions (expected 2, got {tutorial.NeutralFactions?.Length ?? 0})");
|
||||
if (string.IsNullOrEmpty(tutorial.ProtagonistFaction)) missing.Add("TutorialGenerationSettings.ProtagonistFaction");
|
||||
|
||||
Console.WriteLine($"\n{authored.Unplaced.Count} fields in the asset had nowhere to go");
|
||||
foreach (var field in authored.Unplaced.Take(20)) Console.WriteLine($" {field}");
|
||||
Console.WriteLine($"{missing.Count} settings generation needs came back unset");
|
||||
foreach (var field in missing) Console.WriteLine($" {field}");
|
||||
return missing.Count;
|
||||
}
|
||||
|
||||
// Clears boss hull links that resolve to nothing. Galaxy.PlaceFactionsMain gives a boss zone to every faction
|
||||
// carrying a BossHull, so a dangling link claims a chokepoint that can never spawn a boss. Dry run unless
|
||||
// passed "apply".
|
||||
|
|
|
|||
Loading…
Reference in a new issue