mirror of
https://github.com/MaSzyna-EU07/StarterNG.git
synced 2026-10-16 23:49:19 +02:00
Add a JSON format for the vehicle database
This commit is contained in:
199
StarterNG.Tests/VehicleJsonTests.cs
Normal file
199
StarterNG.Tests/VehicleJsonTests.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System.Text;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
using StarterNG.Infrastructure.Vehicles;
|
||||
using StarterNG.Tests.Fakes;
|
||||
|
||||
namespace StarterNG.Tests;
|
||||
|
||||
public class VehicleJsonTests
|
||||
{
|
||||
private static readonly TexturesTxtParser Parser = new();
|
||||
|
||||
private static readonly VehicleJsonSerializer Serializer = new();
|
||||
|
||||
/// <summary>
|
||||
/// A vehicle folder exercising every piece of the legacy syntax at once: the
|
||||
/// headers and comments that carry no data, a category marker, a credit line
|
||||
/// with Polish text, an alternative model, a wreck and an archival section.
|
||||
/// </summary>
|
||||
private const string Ep07 = """
|
||||
# EP07 - lokomotywy elektryczne
|
||||
@ep07
|
||||
!=e
|
||||
ep07-135.mat=ep07.e3d,ep07 // 1.0,EP07-135,PKP Intercity,Warszawa Grochów,2019-04-01,Wrocław,Zażółć,Gęślą Jaźń
|
||||
ep07-174.mat=ep07.e3d,ep07=ep07p.e3d,ep07p
|
||||
ep07wrak.mat=ep07.e3d,ep07
|
||||
linia bez znaku rownosci
|
||||
$a
|
||||
ep07-stary.mat=ep07.e3d,ep07
|
||||
""";
|
||||
|
||||
/// <summary>A unit whose cars are coupled into a fixed set.</summary>
|
||||
private const string En57 = """
|
||||
^2
|
||||
en57-1000ra.mat=en57.e3d,en57
|
||||
en57-1000rb.mat=en57.e3d,en57
|
||||
""";
|
||||
|
||||
[Fact]
|
||||
public void An_entry_written_out_and_read_back_is_the_entry_it_started_as()
|
||||
{
|
||||
var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
|
||||
|
||||
var restored = Serializer.Read(Serializer.Write(entry));
|
||||
|
||||
Assert.Equal(Describe(entry), Describe(restored!));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_catalogue_built_from_json_matches_the_one_built_from_textures_txt()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithLegacyFile(Dynamic("pkp", "ep07"), Ep07)
|
||||
.WithLegacyFile(Dynamic("pkp", "en57"), En57);
|
||||
var installation = new TestInstallation(files);
|
||||
|
||||
installation.Vehicles.Load(installation.Library.Vehicles);
|
||||
var fromJson = ReloadThroughJson(installation);
|
||||
|
||||
Assert.Equal(5, installation.Library.Vehicles.Textures.Count);
|
||||
Assert.Equal(Describe(installation.Library.Vehicles), Describe(fromJson));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Headers_and_unparsable_lines_survive_the_trip_so_nothing_is_dropped()
|
||||
{
|
||||
var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
|
||||
|
||||
var restored = Serializer.Read(Serializer.Write(entry))!;
|
||||
|
||||
Assert.Equal(new[] { "# EP07 - lokomotywy elektryczne", "@ep07", "linia bez znaku rownosci" },
|
||||
restored.Unknown);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Polish_text_is_written_as_itself_rather_than_as_escapes()
|
||||
{
|
||||
var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
|
||||
|
||||
string json = Serializer.Write(entry);
|
||||
|
||||
Assert.Contains("Warszawa Grochów", json);
|
||||
Assert.DoesNotContain("\\u", json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void The_merged_database_is_read_before_the_packages_layered_on_it()
|
||||
{
|
||||
var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
|
||||
var package = Parser.Parse("pkp/en57/", Lines(En57))!;
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(Database("vehicles.json"), Serializer.WriteCollection(new[] { entry }))
|
||||
.WithFile(Database("aaa-pakiet.json"), Serializer.Write(package));
|
||||
var installation = new TestInstallation(files);
|
||||
var catalog = new VehicleCatalog(installation.MiniTextures);
|
||||
|
||||
int liveries = Repository(installation).Load(catalog);
|
||||
|
||||
Assert.Equal(6, liveries);
|
||||
Assert.Equal("legacy:pkp/ep07/ep07-135", catalog.Textures[0].Uuid);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_entry_from_a_newer_format_is_refused_instead_of_half_read()
|
||||
{
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(Database("z-przyszlosci.json"),
|
||||
"""{"uuid":"x","schema_version":99,"textures":[{"skinfile":"ep07-135"}]}""");
|
||||
var installation = new TestInstallation(files);
|
||||
var catalog = new VehicleCatalog(installation.MiniTextures);
|
||||
|
||||
int liveries = Repository(installation).Load(catalog);
|
||||
|
||||
Assert.Equal(0, liveries);
|
||||
Assert.Empty(catalog.Textures);
|
||||
Assert.Contains("z-przyszlosci.json", installation.Log.Text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void An_unreadable_package_is_logged_and_leaves_the_rest_of_the_database_alone()
|
||||
{
|
||||
var entry = Parser.Parse("pkp/ep07/", Lines(Ep07))!;
|
||||
var files = new InMemoryFileSystem()
|
||||
.WithFile(Database("aaa-polamany.json"), "{ to nie jest json")
|
||||
.WithFile(Database("bbb-dobry.json"), Serializer.Write(entry));
|
||||
var installation = new TestInstallation(files);
|
||||
var catalog = new VehicleCatalog(installation.MiniTextures);
|
||||
|
||||
int liveries = Repository(installation).Load(catalog);
|
||||
|
||||
Assert.Equal(4, liveries);
|
||||
Assert.Contains("aaa-polamany.json", installation.Log.Text);
|
||||
}
|
||||
|
||||
private static VehicleJsonRepository Repository(TestInstallation installation) =>
|
||||
new(installation.Files, installation.Paths, installation.Log, Serializer);
|
||||
|
||||
/// <summary>
|
||||
/// Writes every vehicle folder of an installation out as its own package file
|
||||
/// and loads the catalogue back from those, which is the migration the starter
|
||||
/// will do silently once this format is the one it reads.
|
||||
/// </summary>
|
||||
private static VehicleCatalog ReloadThroughJson(TestInstallation installation)
|
||||
{
|
||||
foreach (string maker in installation.Files.GetDirectories(installation.Paths.Dynamic))
|
||||
foreach (string vehicle in installation.Files.GetDirectories(maker))
|
||||
{
|
||||
string path = Path.Combine(vehicle, "textures.txt");
|
||||
if (!installation.Files.FileExists(path))
|
||||
continue;
|
||||
|
||||
string directory = $"{Path.GetFileName(maker)}/{Path.GetFileName(vehicle)}/";
|
||||
var entry = Parser.Parse(directory,
|
||||
installation.Files.ReadAllText(path, Encoding.GetEncoding(1250)).Split('\n'));
|
||||
if (entry is not null)
|
||||
installation.Files.WithFile(Database($"{directory.Replace('/', '.')}json"),
|
||||
Serializer.Write(entry));
|
||||
}
|
||||
|
||||
var catalog = new VehicleCatalog(installation.MiniTextures);
|
||||
Repository(installation).Load(catalog);
|
||||
return catalog;
|
||||
}
|
||||
|
||||
private static string[] Lines(string file) => file.Split('\n');
|
||||
|
||||
private static string Dynamic(string maker, string vehicle) =>
|
||||
TestInstallation.At("dynamic", maker, vehicle, "textures.txt");
|
||||
|
||||
private static string Database(string file) => TestInstallation.At("databases", "vehicles", file);
|
||||
|
||||
/// <summary>Everything an entry carries, flattened so a difference reads as one.</summary>
|
||||
private static string Describe(VehicleEntry entry) =>
|
||||
string.Join('\n',
|
||||
new[] { $"entry {entry.Uuid}" }
|
||||
.Concat(entry.Groups.Select(Describe))
|
||||
.Concat(entry.Textures.Select(Describe))
|
||||
.Concat(entry.Sets.Select(Describe))
|
||||
.Concat(entry.Unknown.Select(line => $"unknown {line}")));
|
||||
|
||||
/// <summary>The same for a whole catalogue, wrecks and resolved fields included.</summary>
|
||||
private static string Describe(VehicleCatalog catalog) =>
|
||||
string.Join('\n',
|
||||
catalog.GroupsById.OrderBy(pair => pair.Key, StringComparer.Ordinal).Select(pair => Describe(pair.Value))
|
||||
.Concat(catalog.TextureByUuid.OrderBy(pair => pair.Key, StringComparer.Ordinal)
|
||||
.Select(pair => Describe(pair.Value)))
|
||||
.Concat(catalog.Sets.Select(Describe)));
|
||||
|
||||
private static string Describe(VehicleGroup group) =>
|
||||
$"group {group.Id}|{group.Category}|{group.Mini}|{group.Archived}|{group.Implicit}";
|
||||
|
||||
private static string Describe(VehicleSet set) =>
|
||||
$"set {set.Uuid}|{set.Mode}|{set.Count}|{string.Join(',', set.TextureRefs)}";
|
||||
|
||||
private static string Describe(VehicleTexture texture) =>
|
||||
$"texture {texture.Uuid}|{texture.Directory}|{texture.Skinfile}|{texture.Model}|{texture.Group}|" +
|
||||
$"{texture.MiniRef}|{texture.TextureMini}|{texture.Wreck}|{texture.ResolvedClass}|" +
|
||||
$"{texture.ResolvedCategory}|{texture.ResolvedArchived}|{texture.Meta?.Raw}|" +
|
||||
string.Join(';', texture.Aliases.Select(alias => $"{alias.Model},{alias.MiniRef},{alias.TextureMini}"));
|
||||
}
|
||||
@@ -12,6 +12,9 @@ public interface IGamePaths
|
||||
|
||||
string Data { get; }
|
||||
|
||||
/// <summary>Where the JSON vehicle database and the packages layered on it live.</summary>
|
||||
string VehicleDatabase { get; }
|
||||
|
||||
string StarterConfig { get; }
|
||||
|
||||
string DiagnosticsLog { get; }
|
||||
|
||||
@@ -5,9 +5,25 @@ namespace StarterNG.Domain.Vehicles;
|
||||
public sealed class VehicleEntry
|
||||
{
|
||||
public string? Uuid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Version of the file format the entry was read from, zero when it came from
|
||||
/// a legacy textures.txt. Kept so a file written by a newer starter is refused
|
||||
/// outright instead of being read with half its meaning missing.
|
||||
/// </summary>
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public List<VehicleGroup> Groups { get; } = new();
|
||||
public List<VehicleTexture> Textures { get; } = new();
|
||||
public List<VehicleSet> Sets { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Source lines the reader did not turn into anything else - legacy headers,
|
||||
/// comments and malformed liveries - kept verbatim so an entry written back
|
||||
/// out does not quietly drop what its author put in the file. Content only:
|
||||
/// blank lines and the original spacing are not preserved.
|
||||
/// </summary>
|
||||
public List<string> Unknown { get; } = new();
|
||||
}
|
||||
|
||||
public sealed class VehicleGroup
|
||||
@@ -17,6 +33,13 @@ public sealed class VehicleGroup
|
||||
public string? Mini { get; set; }
|
||||
|
||||
public bool Archived { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// True when nothing declared the group and it was inferred from the liveries
|
||||
/// that share a thumbnail - which is every group textures.txt produces, as the
|
||||
/// legacy format has no syntax for declaring one.
|
||||
/// </summary>
|
||||
public bool Implicit { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VehicleTexture
|
||||
|
||||
@@ -22,6 +22,8 @@ public sealed class GamePaths : IGamePaths
|
||||
|
||||
public string Data => Path.Combine(Root, "data");
|
||||
|
||||
public string VehicleDatabase => Path.Combine(Root, "databases", "vehicles");
|
||||
|
||||
public string StarterConfig => Path.Combine(Root, "starter");
|
||||
|
||||
public string DiagnosticsLog => Path.Combine(StarterConfig, "bledy.txt");
|
||||
|
||||
@@ -25,7 +25,10 @@ public sealed class TexturesTxtParser
|
||||
|
||||
char first = line[0];
|
||||
if (first is '#' or '@' or '*')
|
||||
{
|
||||
entry.Unknown.Add(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("$a", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -47,11 +50,17 @@ public sealed class TexturesTxtParser
|
||||
}
|
||||
|
||||
if (line.IndexOf('=') < 0 || line.StartsWith("//", StringComparison.Ordinal))
|
||||
{
|
||||
entry.Unknown.Add(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
var texture = ParseLivery(line, directory, categorySign, archived, entry);
|
||||
if (texture is null)
|
||||
{
|
||||
entry.Unknown.Add(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
entry.Textures.Add(texture);
|
||||
|
||||
@@ -120,7 +129,8 @@ public sealed class TexturesTxtParser
|
||||
Id = groupId,
|
||||
Category = categorySign,
|
||||
Mini = lead.Mini,
|
||||
Archived = archived
|
||||
Archived = archived,
|
||||
Implicit = true
|
||||
});
|
||||
|
||||
var texture = new VehicleTexture
|
||||
|
||||
124
StarterNG/Infrastructure/Vehicles/VehicleJsonDocument.cs
Normal file
124
StarterNG/Infrastructure/Vehicles/VehicleJsonDocument.cs
Normal file
@@ -0,0 +1,124 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace StarterNG.Infrastructure.Vehicles;
|
||||
|
||||
// The vehicle database as it is written to disk (schema_version 1). Deliberately
|
||||
// separate from the domain model: the model is free to change shape with the
|
||||
// starter, the file format is not - packages written by someone else have to keep
|
||||
// parsing. The property names are the ones the format shipped with, so a file
|
||||
// written for the older reader still loads.
|
||||
//
|
||||
// Collections are nullable and left out when empty, which keeps a generated file
|
||||
// close to what a person would have typed by hand.
|
||||
|
||||
public sealed class VehicleEntryDocument
|
||||
{
|
||||
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("schema_version")] public int SchemaVersion { get; set; }
|
||||
|
||||
[JsonPropertyName("groups")] public List<VehicleGroupDocument>? Groups { get; set; }
|
||||
|
||||
[JsonPropertyName("textures")] public List<VehicleTextureDocument>? Textures { get; set; }
|
||||
|
||||
[JsonPropertyName("sets")] public List<VehicleSetDocument>? Sets { get; set; }
|
||||
|
||||
[JsonPropertyName("unknown")] public List<string>? Unknown { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>Root of a merged database holding many entries in one file.</summary>
|
||||
public sealed class VehicleCollectionDocument
|
||||
{
|
||||
[JsonPropertyName("vehicles")] public List<VehicleEntryDocument>? Vehicles { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VehicleGroupDocument
|
||||
{
|
||||
[JsonPropertyName("id")] public string? Id { get; set; }
|
||||
|
||||
[JsonPropertyName("category")] public string? Category { get; set; }
|
||||
|
||||
[JsonPropertyName("mini")] public string? Mini { get; set; }
|
||||
|
||||
[JsonPropertyName("archived")] public bool Archived { get; set; }
|
||||
|
||||
[JsonPropertyName("implicit")] public bool Implicit { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VehicleTextureDocument
|
||||
{
|
||||
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("directory")] public string? Directory { get; set; }
|
||||
|
||||
[JsonPropertyName("skinfile")] public string? Skinfile { get; set; }
|
||||
|
||||
[JsonPropertyName("model")] public string? Model { get; set; }
|
||||
|
||||
[JsonPropertyName("group")] public string? Group { get; set; }
|
||||
|
||||
[JsonPropertyName("mini_ref")] public string? MiniRef { get; set; }
|
||||
|
||||
[JsonPropertyName("texture_mini")] public string? TextureMini { get; set; }
|
||||
|
||||
[JsonPropertyName("wreck")] public bool Wreck { get; set; }
|
||||
|
||||
[JsonPropertyName("aliases")] public List<VehicleAliasDocument>? Aliases { get; set; }
|
||||
|
||||
[JsonPropertyName("meta")] public VehicleMetaDocument? Meta { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VehicleAliasDocument
|
||||
{
|
||||
[JsonPropertyName("model")] public string? Model { get; set; }
|
||||
|
||||
[JsonPropertyName("group")] public string? Group { get; set; }
|
||||
|
||||
[JsonPropertyName("mini_ref")] public string? MiniRef { get; set; }
|
||||
|
||||
[JsonPropertyName("texture_mini")] public string? TextureMini { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VehicleMetaDocument
|
||||
{
|
||||
[JsonPropertyName("raw")] public string? Raw { get; set; }
|
||||
|
||||
[JsonPropertyName("version")] public string? Version { get; set; }
|
||||
|
||||
[JsonPropertyName("vehicle")] public string? Vehicle { get; set; }
|
||||
|
||||
[JsonPropertyName("operator")] public string? Operator { get; set; }
|
||||
|
||||
[JsonPropertyName("depot")] public string? Depot { get; set; }
|
||||
|
||||
[JsonPropertyName("revision_date")] public string? RevisionDate { get; set; }
|
||||
|
||||
[JsonPropertyName("revision_place")] public string? RevisionPlace { get; set; }
|
||||
|
||||
[JsonPropertyName("texture_author")] public string? TextureAuthor { get; set; }
|
||||
|
||||
[JsonPropertyName("photo_author")] public string? PhotoAuthor { get; set; }
|
||||
|
||||
[JsonPropertyName("extra")] public List<string>? Extra { get; set; }
|
||||
}
|
||||
|
||||
public sealed class VehicleSetDocument
|
||||
{
|
||||
[JsonPropertyName("uuid")] public string? Uuid { get; set; }
|
||||
|
||||
[JsonPropertyName("mode")] public string? Mode { get; set; }
|
||||
|
||||
[JsonPropertyName("count")] public int Count { get; set; }
|
||||
|
||||
[JsonPropertyName("texture_refs")] public List<string>? TextureRefs { get; set; }
|
||||
}
|
||||
|
||||
// Source-generated metadata: PublishAot and PublishTrimmed rule out the reflection
|
||||
// based serializer. The generator pulls in every nested document type on its own,
|
||||
// and the reader's leniency is set on the options in VehicleJsonSerializer.
|
||||
[JsonSerializable(typeof(VehicleEntryDocument))]
|
||||
[JsonSerializable(typeof(VehicleCollectionDocument))]
|
||||
internal partial class VehicleJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
101
StarterNG/Infrastructure/Vehicles/VehicleJsonRepository.cs
Normal file
101
StarterNG/Infrastructure/Vehicles/VehicleJsonRepository.cs
Normal file
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using StarterNG.Application.Abstractions;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Infrastructure.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the vehicle catalogue from databases/vehicles: a merged vehicles.json
|
||||
/// holding the whole database, and one file per package layered on top of it.
|
||||
/// </summary>
|
||||
public sealed class VehicleJsonRepository : IVehicleRepository
|
||||
{
|
||||
private const string MergedFileName = "vehicles.json";
|
||||
|
||||
private readonly IFileSystem _files;
|
||||
private readonly IGamePaths _paths;
|
||||
private readonly IDiagnosticsLog _log;
|
||||
private readonly VehicleJsonSerializer _serializer;
|
||||
|
||||
public VehicleJsonRepository(IFileSystem files, IGamePaths paths, IDiagnosticsLog log,
|
||||
VehicleJsonSerializer serializer)
|
||||
{
|
||||
_files = files;
|
||||
_paths = paths;
|
||||
_log = log;
|
||||
_serializer = serializer;
|
||||
}
|
||||
|
||||
public int Load(VehicleCatalog catalog)
|
||||
{
|
||||
catalog.BeginLoad();
|
||||
|
||||
int liveries = 0;
|
||||
foreach (string file in DatabaseFiles())
|
||||
liveries += ReadInto(catalog, file);
|
||||
|
||||
catalog.EndLoad();
|
||||
return liveries;
|
||||
}
|
||||
|
||||
private int ReadInto(VehicleCatalog catalog, string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
string json = _files.ReadAllText(path);
|
||||
var entries = IsMerged(path)
|
||||
? _serializer.ReadCollection(json)
|
||||
: Listed(_serializer.Read(json));
|
||||
|
||||
int liveries = 0;
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (!VehicleJsonSerializer.IsReadable(entry))
|
||||
{
|
||||
_log.Log($"{Name(path)}: format w wersji {entry.SchemaVersion}, " +
|
||||
$"ten starter czyta do {VehicleJsonSerializer.SchemaVersion}");
|
||||
continue;
|
||||
}
|
||||
|
||||
liveries += entry.Textures.Count;
|
||||
catalog.Ingest(entry);
|
||||
}
|
||||
|
||||
return liveries;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.Log(Name(path), ex);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The merged database first and the packages after it, both in a fixed order,
|
||||
/// so which entry wins a clash is the same on every machine - the file system
|
||||
/// does not promise one.
|
||||
/// </summary>
|
||||
private IEnumerable<string> DatabaseFiles()
|
||||
{
|
||||
var files = _files.GetFiles(_paths.VehicleDatabase, "*.json")
|
||||
.OrderBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
foreach (string file in files.Where(IsMerged))
|
||||
yield return file;
|
||||
|
||||
foreach (string file in files.Where(file => !IsMerged(file)))
|
||||
yield return file;
|
||||
}
|
||||
|
||||
private static bool IsMerged(string path) =>
|
||||
string.Equals(Path.GetFileName(path), MergedFileName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static string Name(string path) => $"databases/vehicles/{Path.GetFileName(path)}";
|
||||
|
||||
private static List<VehicleEntry> Listed(VehicleEntry? entry) =>
|
||||
entry is null ? new List<VehicleEntry>() : new List<VehicleEntry> { entry };
|
||||
}
|
||||
215
StarterNG/Infrastructure/Vehicles/VehicleJsonSerializer.cs
Normal file
215
StarterNG/Infrastructure/Vehicles/VehicleJsonSerializer.cs
Normal file
@@ -0,0 +1,215 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using StarterNG.Domain.Vehicles;
|
||||
|
||||
namespace StarterNG.Infrastructure.Vehicles;
|
||||
|
||||
/// <summary>
|
||||
/// Reads and writes <see cref="VehicleEntry"/> in the JSON database format,
|
||||
/// mapping it on and off the document types that define the format.
|
||||
/// </summary>
|
||||
public sealed class VehicleJsonSerializer
|
||||
{
|
||||
/// <summary>The format this build writes, and the newest one it can read.</summary>
|
||||
public const int SchemaVersion = 1;
|
||||
|
||||
// Leniency a hand-written package file deserves: names matched case
|
||||
// insensitively, // and /* */ comments skipped, trailing commas allowed. The
|
||||
// relaxed encoder keeps Polish text in the credits and in the preserved legacy
|
||||
// comments readable instead of turning it into \uXXXX escapes, and the
|
||||
// source-generated resolver keeps all of it AOT-safe.
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
TypeInfoResolver = VehicleJsonContext.Default
|
||||
};
|
||||
|
||||
private static readonly JsonTypeInfo<VehicleEntryDocument> EntryInfo =
|
||||
(JsonTypeInfo<VehicleEntryDocument>)Options.GetTypeInfo(typeof(VehicleEntryDocument));
|
||||
|
||||
private static readonly JsonTypeInfo<VehicleCollectionDocument> CollectionInfo =
|
||||
(JsonTypeInfo<VehicleCollectionDocument>)Options.GetTypeInfo(typeof(VehicleCollectionDocument));
|
||||
|
||||
public string Write(VehicleEntry entry) => JsonSerializer.Serialize(ToDocument(entry), EntryInfo);
|
||||
|
||||
public string WriteCollection(IEnumerable<VehicleEntry> entries) =>
|
||||
JsonSerializer.Serialize(
|
||||
new VehicleCollectionDocument { Vehicles = entries.Select(ToDocument).ToList() },
|
||||
CollectionInfo);
|
||||
|
||||
/// <summary>One entry per file, the form a package is authored in.</summary>
|
||||
public VehicleEntry? Read(string json)
|
||||
{
|
||||
var document = JsonSerializer.Deserialize(json, EntryInfo);
|
||||
return document is null ? null : FromDocument(document);
|
||||
}
|
||||
|
||||
/// <summary>Many entries in one file, the form a whole database is shipped in.</summary>
|
||||
public List<VehicleEntry> ReadCollection(string json)
|
||||
{
|
||||
var document = JsonSerializer.Deserialize(json, CollectionInfo);
|
||||
if (document?.Vehicles is null)
|
||||
return new List<VehicleEntry>();
|
||||
|
||||
return document.Vehicles.Select(FromDocument).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether this build understands the file. An entry written by a newer
|
||||
/// starter is refused rather than half-read, an entry with no version at all
|
||||
/// is taken as the current one - the format carried no version when the first
|
||||
/// files were written by hand.
|
||||
/// </summary>
|
||||
public static bool IsReadable(VehicleEntry entry) => entry.SchemaVersion <= SchemaVersion;
|
||||
|
||||
private static VehicleEntryDocument ToDocument(VehicleEntry entry) =>
|
||||
new()
|
||||
{
|
||||
Uuid = entry.Uuid,
|
||||
SchemaVersion = SchemaVersion,
|
||||
Groups = OrNull(entry.Groups.Select(group => new VehicleGroupDocument
|
||||
{
|
||||
Id = group.Id,
|
||||
Category = group.Category,
|
||||
Mini = group.Mini,
|
||||
Archived = group.Archived,
|
||||
Implicit = group.Implicit
|
||||
}).ToList()),
|
||||
Textures = OrNull(entry.Textures.Select(ToDocument).ToList()),
|
||||
Sets = OrNull(entry.Sets.Select(set => new VehicleSetDocument
|
||||
{
|
||||
Uuid = set.Uuid,
|
||||
Mode = set.Mode,
|
||||
Count = set.Count,
|
||||
TextureRefs = OrNull(set.TextureRefs)
|
||||
}).ToList()),
|
||||
Unknown = OrNull(entry.Unknown)
|
||||
};
|
||||
|
||||
private static VehicleTextureDocument ToDocument(VehicleTexture texture) =>
|
||||
new()
|
||||
{
|
||||
Uuid = texture.Uuid,
|
||||
Directory = texture.Directory,
|
||||
Skinfile = texture.Skinfile,
|
||||
Model = texture.Model,
|
||||
Group = texture.Group,
|
||||
MiniRef = texture.MiniRef,
|
||||
TextureMini = texture.TextureMini,
|
||||
Wreck = texture.Wreck,
|
||||
Aliases = OrNull(texture.Aliases.Select(alias => new VehicleAliasDocument
|
||||
{
|
||||
Model = alias.Model,
|
||||
Group = alias.Group,
|
||||
MiniRef = alias.MiniRef,
|
||||
TextureMini = alias.TextureMini
|
||||
}).ToList()),
|
||||
Meta = texture.Meta is null
|
||||
? null
|
||||
: new VehicleMetaDocument
|
||||
{
|
||||
Raw = texture.Meta.Raw,
|
||||
Version = texture.Meta.Version,
|
||||
Vehicle = texture.Meta.Vehicle,
|
||||
Operator = texture.Meta.Operator,
|
||||
Depot = texture.Meta.Depot,
|
||||
RevisionDate = texture.Meta.RevisionDate,
|
||||
RevisionPlace = texture.Meta.RevisionPlace,
|
||||
TextureAuthor = texture.Meta.TextureAuthor,
|
||||
PhotoAuthor = texture.Meta.PhotoAuthor,
|
||||
Extra = OrNull(texture.Meta.Extra)
|
||||
}
|
||||
};
|
||||
|
||||
private static VehicleEntry FromDocument(VehicleEntryDocument document)
|
||||
{
|
||||
var entry = new VehicleEntry
|
||||
{
|
||||
Uuid = document.Uuid,
|
||||
SchemaVersion = document.SchemaVersion == 0 ? SchemaVersion : document.SchemaVersion
|
||||
};
|
||||
|
||||
foreach (var group in document.Groups ?? Enumerable.Empty<VehicleGroupDocument>())
|
||||
entry.Groups.Add(new VehicleGroup
|
||||
{
|
||||
Id = group.Id ?? "",
|
||||
Category = group.Category,
|
||||
Mini = group.Mini,
|
||||
Archived = group.Archived,
|
||||
Implicit = group.Implicit
|
||||
});
|
||||
|
||||
foreach (var texture in document.Textures ?? Enumerable.Empty<VehicleTextureDocument>())
|
||||
entry.Textures.Add(FromDocument(texture));
|
||||
|
||||
foreach (var set in document.Sets ?? Enumerable.Empty<VehicleSetDocument>())
|
||||
entry.Sets.Add(new VehicleSet
|
||||
{
|
||||
Uuid = set.Uuid,
|
||||
Mode = set.Mode,
|
||||
Count = set.Count,
|
||||
TextureRefs = set.TextureRefs ?? new List<string>()
|
||||
});
|
||||
|
||||
if (document.Unknown is not null)
|
||||
entry.Unknown.AddRange(document.Unknown);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static VehicleTexture FromDocument(VehicleTextureDocument document)
|
||||
{
|
||||
var texture = new VehicleTexture
|
||||
{
|
||||
Uuid = document.Uuid,
|
||||
Directory = document.Directory ?? "",
|
||||
Skinfile = document.Skinfile ?? "",
|
||||
Model = document.Model,
|
||||
Group = document.Group,
|
||||
MiniRef = document.MiniRef,
|
||||
TextureMini = document.TextureMini,
|
||||
Wreck = document.Wreck
|
||||
};
|
||||
|
||||
foreach (var alias in document.Aliases ?? Enumerable.Empty<VehicleAliasDocument>())
|
||||
texture.Aliases.Add(new VehicleAlias
|
||||
{
|
||||
Model = alias.Model,
|
||||
Group = alias.Group,
|
||||
MiniRef = alias.MiniRef,
|
||||
TextureMini = alias.TextureMini
|
||||
});
|
||||
|
||||
if (document.Meta is not null)
|
||||
{
|
||||
texture.Meta = new VehicleMeta
|
||||
{
|
||||
Raw = document.Meta.Raw,
|
||||
Version = document.Meta.Version,
|
||||
Vehicle = document.Meta.Vehicle,
|
||||
Operator = document.Meta.Operator,
|
||||
Depot = document.Meta.Depot,
|
||||
RevisionDate = document.Meta.RevisionDate,
|
||||
RevisionPlace = document.Meta.RevisionPlace,
|
||||
TextureAuthor = document.Meta.TextureAuthor,
|
||||
PhotoAuthor = document.Meta.PhotoAuthor
|
||||
};
|
||||
|
||||
if (document.Meta.Extra is not null)
|
||||
texture.Meta.Extra.AddRange(document.Meta.Extra);
|
||||
}
|
||||
|
||||
return texture;
|
||||
}
|
||||
|
||||
private static List<T>? OrNull<T>(List<T> items) => items.Count > 0 ? items : null;
|
||||
}
|
||||
Reference in New Issue
Block a user