Keep school owners and phase 41 opinions/portraits together; ResetAsync wipes all saves so shared AppHost tests stay isolated.
366 lines
12 KiB
C#
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);
|
|
}
|
|
}
|