Files
h-school/src/HSchool.Server/Game/SchoolStore.cs
T
Leonid Pershin 4b236a7916 Merge main into phase/39-school-owners.
Keep school owners and phase 41 opinions/portraits together; ResetAsync wipes all saves so shared AppHost tests stay isolated.
2026-08-20 08:36:13 +03:00

366 lines
12 KiB
C#

using System.Text.Json;
using HSchool.Content;
using HSchool.People;
using HSchool.Schedule;
using HSchool.Simulation;
using Microsoft.Extensions.Options;
namespace HSchool.Server.Game;
/// <summary>On-disk record of one school. Extra JSON fields are ignored so later slices can grow it.</summary>
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<string>? 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<PresenceSnapshot>? Presence { get; init; }
public SchoolDressRules? DressRules { get; init; }
/// <summary>Normalized player name. Missing or blank means ownerless.</summary>
public string? Owner { get; init; }
/// <summary>Portrait presets copied at create. Generation reads this, not the global template.</summary>
public SwarmUiConfigFile? PortraitSettings { get; init; }
}
/// <summary>Allocates school ids that survive a process restart.</summary>
internal sealed record SchoolSaveIndex(int NextId);
/// <summary>
/// JSON files under <see cref="SimulationOptions.SavesDirectory"/>. The worker of a school is the
/// only writer of that school's file; the supervisor reads the directory at start and on reload.
/// </summary>
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<SchoolStore> _logger;
public SchoolStore(IOptions<SimulationOptions> options, IHostEnvironment environment, ILogger<SchoolStore> 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<SchoolSaveIndex>(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<SchoolSave> LoadAll()
{
var saves = new List<SchoolSave>();
// 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<int, string>();
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<SchoolSave>(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;
}
/// <summary>Older and newer files stay on disk for the menu to delete; they never start.</summary>
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<byte> 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<Timetable>(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<T>(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);
}
}