using System.Text.Json; using HSchool.Content; using HSchool.People; using HSchool.Schedule; using HSchool.Simulation; using Microsoft.Extensions.Options; namespace HSchool.Server.Game; /// On-disk record of one school. Extra JSON fields are ignored so later slices can grow it. internal sealed class SchoolSave { public int Format { get; init; } public int Id { get; init; } public string Name { get; init; } = ""; public DateTime GameTime { get; init; } public bool Running { get; init; } public int SpeedIndex { get; init; } public IReadOnlyList? ModIds { get; init; } public MapLayout? Map { get; init; } public string? CountryId { get; init; } public string? ClimatePresetId { get; init; } public string? NativeLanguage { get; init; } public IReadOnlyList? Presence { get; init; } public SchoolDressRules? DressRules { get; init; } /// Normalized player name. Missing or blank means ownerless. public string? Owner { get; init; } /// Portrait presets copied at create. Generation reads this, not the global template. public SwarmUiConfigFile? PortraitSettings { get; init; } } /// Allocates school ids that survive a process restart. internal sealed record SchoolSaveIndex(int NextId); /// /// JSON files under . The worker of a school is the /// only writer of that school's file; the supervisor reads the directory at start and on reload. /// internal sealed class SchoolStore { public const int CurrentFormat = 3; private const string IndexFileName = "index.json"; private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, PropertyNameCaseInsensitive = true, WriteIndented = true, }; private readonly ILogger _logger; public SchoolStore(IOptions options, IHostEnvironment environment, ILogger logger) { _logger = logger; var configured = options.Value.SavesDirectory; DirectoryPath = Path.IsPathRooted(configured) ? configured : Path.GetFullPath(Path.Combine(environment.ContentRootPath, configured)); Directory.CreateDirectory(DirectoryPath); logger.LogInformation("School saves directory is {Directory}.", DirectoryPath); } public string DirectoryPath { get; } public int ReadNextId() { var path = IndexPath(); if (!File.Exists(path)) { return 1; } try { var json = File.ReadAllText(path); var index = JsonSerializer.Deserialize(json, Json); return index is { NextId: > 0 } ? index.NextId : 1; } catch (Exception ex) { _logger.LogWarning(ex, "Could not read {Path}; school ids will start from the files on disk.", path); return 1; } } public void WriteNextId(int nextId) { WriteAtomic(IndexPath(), new SchoolSaveIndex(nextId)); } public IReadOnlyList LoadAll() { var saves = new List(); // The id inside the file decides which school this is and which file it is saved back to, // so two files claiming the same id would give the menu two cards over one worker and then // overwrite each other on the next save. var claimed = new Dictionary(); foreach (var path in Directory.EnumerateFiles(DirectoryPath, "*.json")) { var fileName = Path.GetFileName(path); if (string.Equals(fileName, IndexFileName, StringComparison.OrdinalIgnoreCase) || fileName.EndsWith(".people.json", StringComparison.OrdinalIgnoreCase) || fileName.EndsWith(".timetable.json", StringComparison.OrdinalIgnoreCase)) { continue; } try { var json = File.ReadAllText(path); var save = JsonSerializer.Deserialize(json, Json); if (save is null) { _logger.LogWarning("Save {Path} deserialized to nothing; leaving the file in place.", path); continue; } if (save.Id <= 0) { _logger.LogWarning( "Save {Path} has id {Id}; ids start at 1. Leaving the file in place.", path, save.Id); continue; } if (!GameClock.IsValidStartDate(save.GameTime)) { _logger.LogWarning( "Save {Path} has a game time outside the supported range; leaving the file in place.", path); continue; } // Claimed last, so a file rejected above does not reserve an id a good file needs. if (!claimed.TryAdd(save.Id, path)) { _logger.LogWarning( "Save {Path} claims id {Id}, already taken by {Owner}; leaving the file in place.", path, save.Id, claimed[save.Id]); continue; } saves.Add(new SchoolSave { Format = save.Format, Id = save.Id, Name = save.Name, GameTime = DateTime.SpecifyKind(save.GameTime, DateTimeKind.Utc), Running = save.Running, SpeedIndex = save.SpeedIndex, ModIds = save.ModIds, Map = save.Map, CountryId = save.CountryId, ClimatePresetId = save.ClimatePresetId, NativeLanguage = save.NativeLanguage, Presence = save.Presence, DressRules = save.DressRules, Owner = save.Owner, PortraitSettings = save.PortraitSettings, }); } catch (Exception ex) { _logger.LogWarning(ex, "Could not read save {Path}; leaving the file in place.", path); } } saves.Sort((left, right) => left.Id.CompareTo(right.Id)); return saves; } /// Older and newer files stay on disk for the menu to delete; they never start. public static bool CanStart(SchoolSave save) => save.Format == CurrentFormat && !string.IsNullOrWhiteSpace(save.CountryId); public void Save(SchoolSave save) { WriteAtomic(SchoolPath(save.Id), save); } public void Delete(int id) { var path = SchoolPath(id); if (File.Exists(path)) { File.Delete(path); } var people = PeoplePath(id); if (File.Exists(people)) { File.Delete(people); } var timetable = TimetablePath(id); if (File.Exists(timetable)) { File.Delete(timetable); } var portraits = PortraitsDirectory(id); if (Directory.Exists(portraits)) { Directory.Delete(portraits, recursive: true); } } public bool HasPortrait(int schoolId, string personId, PortraitKind kind) => File.Exists(PortraitPath(schoolId, personId, kind)); public (bool HasAvatar, bool HasCustom, bool HasFullBody) PortraitFlags(int schoolId, string personId) { var directory = PortraitsDirectory(schoolId); if (!Directory.Exists(directory)) { return (false, false, false); } return ( HasPortrait(schoolId, personId, PortraitKind.Avatar), HasPortrait(schoolId, personId, PortraitKind.Custom), HasPortrait(schoolId, personId, PortraitKind.Full)); } public string CustomPortraitPromptPath(int schoolId, string personId) { var safeId = SanitizePersonId(personId); return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.custom.prompt.txt"); } public string? TryReadCustomPortraitPrompt(int schoolId, string personId) { var path = CustomPortraitPromptPath(schoolId, personId); return File.Exists(path) ? File.ReadAllText(path) : null; } public string PortraitPath(int schoolId, string personId, PortraitKind kind) { var safeId = SanitizePersonId(personId); var suffix = PortraitKindParser.ToApiValue(kind); return Path.Combine(PortraitsDirectory(schoolId), $"{safeId}.{suffix}.png"); } public void SavePortrait(int schoolId, string personId, PortraitKind kind, ReadOnlySpan png, string? customPrompt = null) { var path = PortraitPath(schoolId, personId, kind); Directory.CreateDirectory(Path.GetDirectoryName(path)!); var temp = path + ".tmp"; File.WriteAllBytes(temp, png); File.Move(temp, path, overwrite: true); if (kind == PortraitKind.Custom && customPrompt is not null) { var promptPath = CustomPortraitPromptPath(schoolId, personId); var promptTemp = promptPath + ".tmp"; File.WriteAllText(promptTemp, customPrompt); File.Move(promptTemp, promptPath, overwrite: true); } } private static string SanitizePersonId(string personId) { foreach (var ch in personId) { if (ch is not (>= 'a' and <= 'z' or >= 'A' and <= 'Z' or >= '0' and <= '9' or '.' or '-' or '_')) { throw new ArgumentException("The person id contains invalid characters.", nameof(personId)); } } return personId; } public RosterDocument? TryReadPeople(int id) { var path = PeoplePath(id); if (!File.Exists(path)) { return null; } try { return RosterJson.Parse(File.ReadAllText(path)); } catch (Exception ex) { throw new SchoolContentUnavailableException( $"School {id} people file could not be read.", ex); } } public void SavePeople(int id, RosterDocument document) { WriteAtomic(PeoplePath(id), document, RosterJson.Options); } public Timetable? TryReadTimetable(int id) { var path = TimetablePath(id); if (!File.Exists(path)) { return null; } try { var json = File.ReadAllText(path); return JsonSerializer.Deserialize(json, Json); } catch (Exception ex) { throw new SchoolContentUnavailableException( $"School {id} timetable file could not be read.", ex); } } public void SaveTimetable(int id, Timetable table) { WriteAtomic(TimetablePath(id), table); } private string SchoolPath(int id) => Path.Combine(DirectoryPath, $"{id}.json"); private string PeoplePath(int id) => Path.Combine(DirectoryPath, $"{id}.people.json"); private string TimetablePath(int id) => Path.Combine(DirectoryPath, $"{id}.timetable.json"); private string PortraitsDirectory(int id) => Path.Combine(DirectoryPath, $"{id}.portraits"); private string IndexPath() => Path.Combine(DirectoryPath, IndexFileName); private static void WriteAtomic(string path, T value, JsonSerializerOptions? options = null) { var json = JsonSerializer.Serialize(value, options ?? Json); var temp = path + ".tmp"; File.WriteAllText(temp, json); File.Move(temp, path, overwrite: true); } }