Add HSchool.Content project for JSONC definitions, catalog, and map validation. Update solution structure to include new content and tests projects. Enhance school management to support mod packs and map instances, ensuring proper loading and validation. Revise documentation to reflect these changes and update tests for new functionality.
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace HSchool.Content;
|
||||
|
||||
/// <summary>
|
||||
/// Turns pack documents into a frozen <see cref="DefCatalog"/>. Callers supply already-read
|
||||
/// files; this type never looks at the disk.
|
||||
/// </summary>
|
||||
public sealed class CatalogLoader
|
||||
{
|
||||
public const string CorePackId = "core";
|
||||
|
||||
public DefCatalog Load(
|
||||
IReadOnlyList<string> packOrder,
|
||||
IReadOnlyList<ContentDocument> documents,
|
||||
IContentLog? log = null)
|
||||
{
|
||||
log ??= NullContentLog.Instance;
|
||||
var order = NormalizePackOrder(packOrder);
|
||||
|
||||
var defs = new Dictionary<(DefKind Kind, string Name), RawDef>();
|
||||
var localesRu = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var localesEn = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var patches = new List<(string PackId, PatchDocument Patch, string Source)>();
|
||||
|
||||
foreach (var packId in order)
|
||||
{
|
||||
foreach (var document in documents.Where(candidate => candidate.PackId == packId))
|
||||
{
|
||||
ReadDocument(document, defs, localesRu, localesEn, patches, log);
|
||||
}
|
||||
}
|
||||
|
||||
var resolved = ResolveInheritance(defs);
|
||||
ApplyPatches(resolved, patches);
|
||||
var catalog = Materialize(order, resolved, localesRu, localesEn);
|
||||
ResolveReferences(catalog);
|
||||
return catalog;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<string> NormalizePackOrder(IReadOnlyList<string> packOrder)
|
||||
{
|
||||
var order = new List<string> { CorePackId };
|
||||
foreach (var packId in packOrder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(packId) || packId.Equals(CorePackId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!order.Contains(packId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
order.Add(packId);
|
||||
}
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
public static MapLayout? LastDefaultMap(IReadOnlyList<string> packOrder, IReadOnlyList<ContentDocument> documents)
|
||||
{
|
||||
var order = NormalizePackOrder(packOrder);
|
||||
MapLayout? map = null;
|
||||
foreach (var packId in order)
|
||||
{
|
||||
foreach (var document in documents.Where(candidate => candidate.PackId == packId && PackPaths.IsDefaultMap(candidate.RelativePath)))
|
||||
{
|
||||
map = MapLayout.Parse(document.Text, $"{packId}:{document.RelativePath}");
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private static void ReadDocument(
|
||||
ContentDocument document,
|
||||
Dictionary<(DefKind Kind, string Name), RawDef> defs,
|
||||
Dictionary<string, string> localesRu,
|
||||
Dictionary<string, string> localesEn,
|
||||
List<(string PackId, PatchDocument Patch, string Source)> patches,
|
||||
IContentLog log)
|
||||
{
|
||||
var source = $"{document.PackId}:{document.RelativePath}";
|
||||
|
||||
if (PackPaths.TryGetDefKind(document.RelativePath, out var kind))
|
||||
{
|
||||
foreach (var json in EnumerateObjects(document.Text, source))
|
||||
{
|
||||
var defName = ReadDefName(json, source);
|
||||
var key = (kind, defName);
|
||||
if (defs.TryGetValue(key, out var previous))
|
||||
{
|
||||
log.Warning($"Def {kind}:{defName} from pack '{document.PackId}' replaces '{previous.PackId}'.");
|
||||
}
|
||||
|
||||
defs[key] = new RawDef(document.PackId, kind, defName, json, source);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (PackPaths.IsPatch(document.RelativePath))
|
||||
{
|
||||
foreach (var json in EnumerateObjects(document.Text, source))
|
||||
{
|
||||
var patch = Jsonc.Deserialize<PatchDocument>(json);
|
||||
if (string.IsNullOrWhiteSpace(patch.Target))
|
||||
{
|
||||
throw new ContentLoadException($"Patch in {source} has no target.");
|
||||
}
|
||||
|
||||
patches.Add((document.PackId, patch, source));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (PackPaths.TryGetLocaleLanguage(document.RelativePath, out var language))
|
||||
{
|
||||
var table = language == "en" ? localesEn : localesRu;
|
||||
MergeLocale(Jsonc.Parse(document.Text, source), table, document.PackId, log);
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<JsonObject> EnumerateObjects(string text, string source)
|
||||
{
|
||||
var node = Jsonc.Parse(text, source);
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject obj:
|
||||
yield return obj;
|
||||
break;
|
||||
|
||||
case JsonArray array:
|
||||
foreach (var item in array)
|
||||
{
|
||||
if (item is not JsonObject obj)
|
||||
{
|
||||
throw new ContentLoadException($"{source} has a non-object entry in an array.");
|
||||
}
|
||||
|
||||
yield return obj;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ContentLoadException($"{source} must be an object or an array of objects.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReadDefName(JsonObject json, string source)
|
||||
{
|
||||
if (json["defName"] is JsonValue value && value.TryGetValue<string>(out var name) && !string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return name;
|
||||
}
|
||||
|
||||
throw new ContentLoadException($"A def in {source} is missing defName.");
|
||||
}
|
||||
|
||||
private static void MergeLocale(JsonNode node, Dictionary<string, string> table, string packId, IContentLog log)
|
||||
{
|
||||
if (node is not JsonObject obj)
|
||||
{
|
||||
throw new ContentLoadException($"Localization in pack '{packId}' must be an object of strings.");
|
||||
}
|
||||
|
||||
foreach (var property in obj)
|
||||
{
|
||||
if (property.Value is not JsonValue value || !value.TryGetValue<string>(out var text))
|
||||
{
|
||||
throw new ContentLoadException($"Localization key '{property.Key}' in pack '{packId}' is not a string.");
|
||||
}
|
||||
|
||||
if (table.ContainsKey(property.Key))
|
||||
{
|
||||
log.Warning($"Locale key '{property.Key}' from pack '{packId}' replaces an earlier pack.");
|
||||
}
|
||||
|
||||
table[property.Key] = text;
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<(DefKind Kind, string Name), JsonObject> ResolveInheritance(
|
||||
Dictionary<(DefKind Kind, string Name), RawDef> defs)
|
||||
{
|
||||
var resolved = new Dictionary<(DefKind Kind, string Name), JsonObject>();
|
||||
var visiting = new HashSet<(DefKind Kind, string Name)>();
|
||||
|
||||
foreach (var key in defs.Keys)
|
||||
{
|
||||
ResolveOne(key, defs, resolved, visiting);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private static JsonObject ResolveOne(
|
||||
(DefKind Kind, string Name) key,
|
||||
Dictionary<(DefKind Kind, string Name), RawDef> defs,
|
||||
Dictionary<(DefKind Kind, string Name), JsonObject> resolved,
|
||||
HashSet<(DefKind Kind, string Name)> visiting)
|
||||
{
|
||||
if (resolved.TryGetValue(key, out var already))
|
||||
{
|
||||
return already;
|
||||
}
|
||||
|
||||
if (!defs.TryGetValue(key, out var raw))
|
||||
{
|
||||
throw new ContentLoadException($"Def {key.Kind}:{key.Name} was referenced as a parent but does not exist.");
|
||||
}
|
||||
|
||||
if (!visiting.Add(key))
|
||||
{
|
||||
throw new ContentLoadException($"Def {key.Kind}:{key.Name} has a cyclic parent chain.");
|
||||
}
|
||||
|
||||
JsonObject merged;
|
||||
var parentName = raw.Json["parent"] is JsonValue parentValue && parentValue.TryGetValue<string>(out var name)
|
||||
? name
|
||||
: null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(parentName))
|
||||
{
|
||||
merged = (JsonObject)raw.Json.DeepClone();
|
||||
}
|
||||
else
|
||||
{
|
||||
var parentCandidates = defs.Keys.Where(candidate => candidate.Name.Equals(parentName, StringComparison.Ordinal)).ToList();
|
||||
if (parentCandidates.Count == 0)
|
||||
{
|
||||
throw new ContentLoadException($"Def {raw.Kind}:{raw.DefName} parent '{parentName}' does not exist.");
|
||||
}
|
||||
|
||||
if (!parentCandidates.Any(candidate => candidate.Kind == raw.Kind))
|
||||
{
|
||||
throw new ContentLoadException($"Def {raw.Kind}:{raw.DefName} cannot inherit from a different kind '{parentName}'.");
|
||||
}
|
||||
|
||||
var parentJson = ResolveOne((raw.Kind, parentName), defs, resolved, visiting);
|
||||
merged = Merge(parentJson, raw.Json);
|
||||
}
|
||||
|
||||
visiting.Remove(key);
|
||||
resolved[key] = merged;
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Child fields replace parent fields wholesale, including arrays.
|
||||
/// <c>abstract</c> is a flag of this def, not inherited: a child of an abstract parent is
|
||||
/// concrete unless it also says <c>abstract: true</c>.
|
||||
/// </summary>
|
||||
private static JsonObject Merge(JsonObject parent, JsonObject child)
|
||||
{
|
||||
var result = (JsonObject)parent.DeepClone();
|
||||
foreach (var property in child)
|
||||
{
|
||||
result[property.Key] = property.Value?.DeepClone();
|
||||
}
|
||||
|
||||
if (!child.ContainsKey("abstract"))
|
||||
{
|
||||
result.Remove("abstract");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void ApplyPatches(
|
||||
Dictionary<(DefKind Kind, string Name), JsonObject> resolved,
|
||||
List<(string PackId, PatchDocument Patch, string Source)> patches)
|
||||
{
|
||||
foreach (var (_, patch, source) in patches)
|
||||
{
|
||||
var matches = resolved.Where(pair => pair.Key.Name.Equals(patch.Target, StringComparison.Ordinal)).ToList();
|
||||
if (matches.Count == 0)
|
||||
{
|
||||
throw new ContentLoadException($"Patch target '{patch.Target}' was not found ({source}).");
|
||||
}
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
PatchApplier.Apply(match.Value, patch);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static DefCatalog Materialize(
|
||||
IReadOnlyList<string> packIds,
|
||||
Dictionary<(DefKind Kind, string Name), JsonObject> resolved,
|
||||
Dictionary<string, string> ru,
|
||||
Dictionary<string, string> en)
|
||||
{
|
||||
var actions = new Dictionary<string, ActionDef>(StringComparer.Ordinal);
|
||||
var things = new Dictionary<string, ThingDef>(StringComparer.Ordinal);
|
||||
var positions = new Dictionary<string, PositionDef>(StringComparer.Ordinal);
|
||||
var works = new Dictionary<string, WorkDef>(StringComparer.Ordinal);
|
||||
var rooms = new Dictionary<string, RoomDef>(StringComparer.Ordinal);
|
||||
var buildings = new Dictionary<string, BuildingDef>(StringComparer.Ordinal);
|
||||
var floors = new Dictionary<string, FloorDef>(StringComparer.Ordinal);
|
||||
var territories = new Dictionary<string, TerritoryDef>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var (key, json) in resolved)
|
||||
{
|
||||
switch (key.Kind)
|
||||
{
|
||||
case DefKind.Action:
|
||||
actions[key.Name] = Jsonc.Deserialize<ActionDef>(json);
|
||||
break;
|
||||
case DefKind.Thing:
|
||||
things[key.Name] = Jsonc.Deserialize<ThingDef>(json);
|
||||
break;
|
||||
case DefKind.Position:
|
||||
positions[key.Name] = Jsonc.Deserialize<PositionDef>(json);
|
||||
break;
|
||||
case DefKind.Work:
|
||||
works[key.Name] = Jsonc.Deserialize<WorkDef>(json);
|
||||
break;
|
||||
case DefKind.Room:
|
||||
rooms[key.Name] = Jsonc.Deserialize<RoomDef>(json);
|
||||
break;
|
||||
case DefKind.Building:
|
||||
buildings[key.Name] = Jsonc.Deserialize<BuildingDef>(json);
|
||||
break;
|
||||
case DefKind.Floor:
|
||||
floors[key.Name] = Jsonc.Deserialize<FloorDef>(json);
|
||||
break;
|
||||
case DefKind.Territory:
|
||||
territories[key.Name] = Jsonc.Deserialize<TerritoryDef>(json);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return new DefCatalog(
|
||||
packIds,
|
||||
actions,
|
||||
things,
|
||||
positions,
|
||||
works,
|
||||
rooms,
|
||||
buildings,
|
||||
floors,
|
||||
territories,
|
||||
ru,
|
||||
en);
|
||||
}
|
||||
|
||||
private static void ResolveReferences(DefCatalog catalog)
|
||||
{
|
||||
foreach (var thing in catalog.Things.Values)
|
||||
{
|
||||
foreach (var action in thing.Actions)
|
||||
{
|
||||
if (!catalog.Actions.ContainsKey(action))
|
||||
{
|
||||
throw new ContentLoadException($"ThingDef '{thing.DefName}' references unknown ActionDef '{action}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var room in catalog.Rooms.Values)
|
||||
{
|
||||
foreach (var slot in room.Slots)
|
||||
{
|
||||
if (!catalog.Things.ContainsKey(slot.Thing))
|
||||
{
|
||||
throw new ContentLoadException($"RoomDef '{room.DefName}' slot '{slot.Key}' references unknown ThingDef '{slot.Thing}'.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var position in room.Positions)
|
||||
{
|
||||
if (!catalog.Positions.ContainsKey(position))
|
||||
{
|
||||
throw new ContentLoadException($"RoomDef '{room.DefName}' references unknown PositionDef '{position}'.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var work in room.Works)
|
||||
{
|
||||
if (!catalog.Works.ContainsKey(work))
|
||||
{
|
||||
throw new ContentLoadException($"RoomDef '{room.DefName}' references unknown WorkDef '{work}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RawDef(string PackId, DefKind Kind, string DefName, JsonObject Json, string Source);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace HSchool.Content;
|
||||
|
||||
/// <summary>A JSONC file from one pack. The server (or a test) already read the bytes.</summary>
|
||||
public sealed record ContentDocument(string PackId, string RelativePath, string Text);
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace HSchool.Content;
|
||||
|
||||
/// <summary>A pack failed to become a catalog. The school that asked for it must not start.</summary>
|
||||
public sealed class ContentLoadException : Exception
|
||||
{
|
||||
public ContentLoadException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public ContentLoadException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A map instance does not match its catalog or is not a connected yard-rooted graph.</summary>
|
||||
public sealed class MapValidationException : Exception
|
||||
{
|
||||
public MapValidationException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
namespace HSchool.Content;
|
||||
|
||||
/// <summary>
|
||||
/// Frozen set of defs and locale strings for one school. Built once at create/load; the worker
|
||||
/// never re-reads pack files afterwards.
|
||||
/// </summary>
|
||||
public sealed class DefCatalog
|
||||
{
|
||||
internal DefCatalog(
|
||||
IReadOnlyList<string> packIds,
|
||||
IReadOnlyDictionary<string, ActionDef> actions,
|
||||
IReadOnlyDictionary<string, ThingDef> things,
|
||||
IReadOnlyDictionary<string, PositionDef> positions,
|
||||
IReadOnlyDictionary<string, WorkDef> works,
|
||||
IReadOnlyDictionary<string, RoomDef> rooms,
|
||||
IReadOnlyDictionary<string, BuildingDef> buildings,
|
||||
IReadOnlyDictionary<string, FloorDef> floors,
|
||||
IReadOnlyDictionary<string, TerritoryDef> territories,
|
||||
IReadOnlyDictionary<string, string> ru,
|
||||
IReadOnlyDictionary<string, string> en)
|
||||
{
|
||||
PackIds = packIds;
|
||||
Actions = actions;
|
||||
Things = things;
|
||||
Positions = positions;
|
||||
Works = works;
|
||||
Rooms = rooms;
|
||||
Buildings = buildings;
|
||||
Floors = floors;
|
||||
Territories = territories;
|
||||
_ru = ru;
|
||||
_en = en;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> PackIds { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, ActionDef> Actions { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, ThingDef> Things { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, PositionDef> Positions { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, WorkDef> Works { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, RoomDef> Rooms { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, BuildingDef> Buildings { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, FloorDef> Floors { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, TerritoryDef> Territories { get; }
|
||||
|
||||
private readonly IReadOnlyDictionary<string, string> _ru;
|
||||
private readonly IReadOnlyDictionary<string, string> _en;
|
||||
|
||||
public bool TryGet(DefKind kind, string defName, out Def def)
|
||||
{
|
||||
Def? found = kind switch
|
||||
{
|
||||
DefKind.Action => Actions.GetValueOrDefault(defName),
|
||||
DefKind.Thing => Things.GetValueOrDefault(defName),
|
||||
DefKind.Position => Positions.GetValueOrDefault(defName),
|
||||
DefKind.Work => Works.GetValueOrDefault(defName),
|
||||
DefKind.Room => Rooms.GetValueOrDefault(defName),
|
||||
DefKind.Building => Buildings.GetValueOrDefault(defName),
|
||||
DefKind.Floor => Floors.GetValueOrDefault(defName),
|
||||
DefKind.Territory => Territories.GetValueOrDefault(defName),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (found is null)
|
||||
{
|
||||
def = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
def = found;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positions the location panel should list for this node type. Only RoomDefs carry them today.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> PositionsFor(DefKind kind, string defName) =>
|
||||
kind == DefKind.Room && Rooms.TryGetValue(defName, out var room) ? room.Positions : [];
|
||||
|
||||
/// <summary>Locale string for a def, walking <c>parent</c> when the key is missing. Falls back to <c>defName</c>.</summary>
|
||||
public string Label(string locale, Def def)
|
||||
{
|
||||
var table = locale.Equals("en", StringComparison.OrdinalIgnoreCase) ? _en : _ru;
|
||||
var current = def;
|
||||
while (true)
|
||||
{
|
||||
if (table.TryGetValue(current.DefName, out var label))
|
||||
{
|
||||
return label;
|
||||
}
|
||||
|
||||
if (current.Parent is null || !TryGet(KindOf(current), current.Parent, out var parent) || parent.DefName == current.DefName)
|
||||
{
|
||||
return def.DefName;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
internal static DefKind KindOf(Def def) => def switch
|
||||
{
|
||||
ActionDef => DefKind.Action,
|
||||
ThingDef => DefKind.Thing,
|
||||
PositionDef => DefKind.Position,
|
||||
WorkDef => DefKind.Work,
|
||||
RoomDef => DefKind.Room,
|
||||
BuildingDef => DefKind.Building,
|
||||
FloorDef => DefKind.Floor,
|
||||
TerritoryDef => DefKind.Territory,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(def)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
namespace HSchool.Content;
|
||||
|
||||
public enum DefKind
|
||||
{
|
||||
Action,
|
||||
Thing,
|
||||
Position,
|
||||
Work,
|
||||
Room,
|
||||
Building,
|
||||
Floor,
|
||||
Territory,
|
||||
}
|
||||
|
||||
/// <summary>Shared JSONC fields. Kind comes from the folder under <c>defs/</c>, not from the file.</summary>
|
||||
public abstract class Def
|
||||
{
|
||||
public required string DefName { get; init; }
|
||||
|
||||
public string? Parent { get; init; }
|
||||
|
||||
public bool Abstract { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ActionDef : Def;
|
||||
|
||||
public sealed class ThingDef : Def
|
||||
{
|
||||
public IReadOnlyList<string> Actions { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class PositionDef : Def;
|
||||
|
||||
public sealed class WorkDef : Def;
|
||||
|
||||
public sealed class RoomSlot
|
||||
{
|
||||
public required string Key { get; init; }
|
||||
|
||||
public required string Thing { get; init; }
|
||||
|
||||
public int Count { get; init; } = 1;
|
||||
}
|
||||
|
||||
public sealed class RoomDef : Def
|
||||
{
|
||||
public IReadOnlyList<RoomSlot> Slots { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> Positions { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<string> Works { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class BuildingDef : Def;
|
||||
|
||||
public sealed class FloorDef : Def;
|
||||
|
||||
public sealed class TerritoryDef : Def;
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>HSchool.Content</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace HSchool.Content;
|
||||
|
||||
/// <summary>Warnings from the loader — last-wins collisions, not fatal errors.</summary>
|
||||
public interface IContentLog
|
||||
{
|
||||
void Warning(string message);
|
||||
}
|
||||
|
||||
/// <summary>Drops warnings. Tests that do not care about collisions use this.</summary>
|
||||
public sealed class NullContentLog : IContentLog
|
||||
{
|
||||
public static NullContentLog Instance { get; } = new();
|
||||
|
||||
public void Warning(string message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace HSchool.Content;
|
||||
|
||||
/// <summary>RFC 6901 pointers, enough for the three patch ops. Unknown tokens fail the catalog.</summary>
|
||||
internal static class JsonPointer
|
||||
{
|
||||
public static void Add(JsonNode root, string pointer, JsonNode value)
|
||||
{
|
||||
var (parent, token) = LocateParent(root, pointer);
|
||||
if (token == "-")
|
||||
{
|
||||
if (parent is not JsonArray array)
|
||||
{
|
||||
throw new ContentLoadException($"JSON Pointer '{pointer}' cannot append: the parent is not an array.");
|
||||
}
|
||||
|
||||
array.Add(value.DeepClone());
|
||||
return;
|
||||
}
|
||||
|
||||
switch (parent)
|
||||
{
|
||||
case JsonObject obj:
|
||||
obj[token] = value.DeepClone();
|
||||
break;
|
||||
|
||||
case JsonArray array:
|
||||
array.Insert(ParseIndex(token, pointer, array.Count + 1), value.DeepClone());
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ContentLoadException($"JSON Pointer '{pointer}' cannot add here.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Replace(JsonNode root, string pointer, JsonNode value)
|
||||
{
|
||||
var (parent, token) = LocateParent(root, pointer);
|
||||
switch (parent)
|
||||
{
|
||||
case JsonObject obj when obj.ContainsKey(token):
|
||||
obj[token] = value.DeepClone();
|
||||
break;
|
||||
|
||||
case JsonArray array:
|
||||
var index = ParseIndex(token, pointer, array.Count);
|
||||
array[index] = value.DeepClone();
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ContentLoadException($"JSON Pointer '{pointer}' does not exist for replace.");
|
||||
}
|
||||
}
|
||||
|
||||
public static void Remove(JsonNode root, string pointer)
|
||||
{
|
||||
var (parent, token) = LocateParent(root, pointer);
|
||||
switch (parent)
|
||||
{
|
||||
case JsonObject obj when obj.Remove(token):
|
||||
break;
|
||||
|
||||
case JsonArray array:
|
||||
var index = ParseIndex(token, pointer, array.Count);
|
||||
array.RemoveAt(index);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ContentLoadException($"JSON Pointer '{pointer}' does not exist for remove.");
|
||||
}
|
||||
}
|
||||
|
||||
private static (JsonNode Parent, string Token) LocateParent(JsonNode root, string pointer)
|
||||
{
|
||||
var tokens = Tokens(pointer);
|
||||
if (tokens.Length == 0)
|
||||
{
|
||||
throw new ContentLoadException("A patch path cannot target the document root.");
|
||||
}
|
||||
|
||||
JsonNode current = root;
|
||||
for (var i = 0; i < tokens.Length - 1; i++)
|
||||
{
|
||||
current = Step(current, tokens[i], pointer);
|
||||
}
|
||||
|
||||
return (current, tokens[^1]);
|
||||
}
|
||||
|
||||
private static JsonNode Step(JsonNode node, string token, string pointer)
|
||||
{
|
||||
switch (node)
|
||||
{
|
||||
case JsonObject obj when obj.TryGetPropertyValue(token, out var child) && child is not null:
|
||||
return child;
|
||||
|
||||
case JsonArray array:
|
||||
var index = ParseIndex(token, pointer, array.Count);
|
||||
return array[index] ?? throw new ContentLoadException($"JSON Pointer '{pointer}' hit a null array slot.");
|
||||
|
||||
default:
|
||||
throw new ContentLoadException($"JSON Pointer '{pointer}' does not exist.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string[] Tokens(string pointer)
|
||||
{
|
||||
if (pointer.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
if (pointer[0] != '/')
|
||||
{
|
||||
throw new ContentLoadException($"JSON Pointer '{pointer}' must start with '/'.");
|
||||
}
|
||||
|
||||
return pointer.Split('/').Skip(1).Select(Unescape).ToArray();
|
||||
}
|
||||
|
||||
private static string Unescape(string token) => token.Replace("~1", "/", StringComparison.Ordinal).Replace("~0", "~", StringComparison.Ordinal);
|
||||
|
||||
private static int ParseIndex(string token, string pointer, int count)
|
||||
{
|
||||
if (!int.TryParse(token, out var index) || index < 0 || index >= count)
|
||||
{
|
||||
throw new ContentLoadException($"JSON Pointer '{pointer}' has a bad array index.");
|
||||
}
|
||||
|
||||
return index;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace HSchool.Content;
|
||||
|
||||
/// <summary>JSON with comments and trailing commas — the format of every def, patch, locale and map file.</summary>
|
||||
public static class Jsonc
|
||||
{
|
||||
public static JsonSerializerOptions SerializerOptions { get; } = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
public static JsonDocumentOptions DocumentOptions { get; } = new()
|
||||
{
|
||||
// Skip, not Allow: JsonDocument will not store comments. The files may still contain them.
|
||||
CommentHandling = JsonCommentHandling.Skip,
|
||||
AllowTrailingCommas = true,
|
||||
};
|
||||
|
||||
public static JsonNode Parse(string text, string? source = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var node = JsonNode.Parse(text, documentOptions: DocumentOptions);
|
||||
if (node is null)
|
||||
{
|
||||
throw new ContentLoadException(source is null ? "JSONC was empty." : $"JSONC {source} was empty.");
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
var where = source is null ? "JSONC" : $"JSONC {source}";
|
||||
throw new ContentLoadException($"{where} is not valid: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static T Deserialize<T>(JsonNode node)
|
||||
{
|
||||
var value = node.Deserialize<T>(SerializerOptions);
|
||||
if (value is null)
|
||||
{
|
||||
throw new ContentLoadException($"Could not read a {typeof(T).Name} from JSONC.");
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
namespace HSchool.Content;
|
||||
|
||||
/// <summary>One school's map instance: tree grouping plus a walkable graph of territory and rooms.</summary>
|
||||
public sealed class MapLayout
|
||||
{
|
||||
public TerritoryNode? Territory { get; init; }
|
||||
|
||||
public IReadOnlyList<BuildingNode> Buildings { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<FloorNode> Floors { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<RoomNode> Rooms { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<MapLink> Links { get; init; } = [];
|
||||
|
||||
public static MapLayout Parse(string jsonc, string? source = null) =>
|
||||
Jsonc.Deserialize<MapLayout>(Jsonc.Parse(jsonc, source));
|
||||
}
|
||||
|
||||
public sealed class TerritoryNode
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required string Def { get; init; }
|
||||
}
|
||||
|
||||
public sealed class BuildingNode
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required string Def { get; init; }
|
||||
}
|
||||
|
||||
public sealed class FloorNode
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required string Def { get; init; }
|
||||
|
||||
public required string Building { get; init; }
|
||||
|
||||
public string? Label { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RoomNode
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
public required string Def { get; init; }
|
||||
|
||||
public required string Building { get; init; }
|
||||
|
||||
public required string Floor { get; init; }
|
||||
|
||||
public IReadOnlyList<SlotFill> Slots { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class SlotFill
|
||||
{
|
||||
public required string Key { get; init; }
|
||||
|
||||
public required string Thing { get; init; }
|
||||
}
|
||||
|
||||
public sealed class MapLink
|
||||
{
|
||||
public required string A { get; init; }
|
||||
|
||||
public required string B { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
namespace HSchool.Content;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that a map instance is a connected undirected graph rooted at a yard, with every
|
||||
/// <c>def</c> present and non-abstract in the school's catalog.
|
||||
/// </summary>
|
||||
public static class MapValidator
|
||||
{
|
||||
public static void Validate(MapLayout map, DefCatalog catalog)
|
||||
{
|
||||
if (map.Territory is not { Id.Length: > 0, Def.Length: > 0 } territory)
|
||||
{
|
||||
throw new MapValidationException("The map has no territory.");
|
||||
}
|
||||
|
||||
if (map.Rooms.Count == 0)
|
||||
{
|
||||
throw new MapValidationException("A map needs a territory and at least one room.");
|
||||
}
|
||||
|
||||
RequireConcrete(catalog, DefKind.Territory, territory.Def, territory.Id);
|
||||
|
||||
var ids = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
AddId(ids, territory.Id, "territory");
|
||||
|
||||
var buildings = new Dictionary<string, BuildingNode>(StringComparer.Ordinal);
|
||||
foreach (var building in map.Buildings)
|
||||
{
|
||||
AddId(ids, building.Id, "building");
|
||||
RequireConcrete(catalog, DefKind.Building, building.Def, building.Id);
|
||||
buildings[building.Id] = building;
|
||||
}
|
||||
|
||||
var floors = new Dictionary<string, FloorNode>(StringComparer.Ordinal);
|
||||
foreach (var floor in map.Floors)
|
||||
{
|
||||
AddId(ids, floor.Id, "floor");
|
||||
RequireConcrete(catalog, DefKind.Floor, floor.Def, floor.Id);
|
||||
if (!buildings.ContainsKey(floor.Building))
|
||||
{
|
||||
throw new MapValidationException($"Floor '{floor.Id}' references unknown building '{floor.Building}'.");
|
||||
}
|
||||
|
||||
floors[floor.Id] = floor;
|
||||
}
|
||||
|
||||
var rooms = new Dictionary<string, RoomNode>(StringComparer.Ordinal);
|
||||
foreach (var room in map.Rooms)
|
||||
{
|
||||
AddId(ids, room.Id, "room");
|
||||
RequireConcrete(catalog, DefKind.Room, room.Def, room.Id);
|
||||
if (!buildings.ContainsKey(room.Building))
|
||||
{
|
||||
throw new MapValidationException($"Room '{room.Id}' references unknown building '{room.Building}'.");
|
||||
}
|
||||
|
||||
if (!floors.TryGetValue(room.Floor, out var floor))
|
||||
{
|
||||
throw new MapValidationException($"Room '{room.Id}' references unknown floor '{room.Floor}'.");
|
||||
}
|
||||
|
||||
if (!floor.Building.Equals(room.Building, StringComparison.Ordinal))
|
||||
{
|
||||
throw new MapValidationException($"Room '{room.Id}' is on floor '{room.Floor}', which belongs to another building.");
|
||||
}
|
||||
|
||||
ValidateSlotFills(room, catalog);
|
||||
rooms[room.Id] = room;
|
||||
}
|
||||
|
||||
var walkable = new HashSet<string>(StringComparer.Ordinal) { territory.Id };
|
||||
foreach (var roomId in rooms.Keys)
|
||||
{
|
||||
walkable.Add(roomId);
|
||||
}
|
||||
|
||||
var adjacency = walkable.ToDictionary(id => id, _ => new List<string>(), StringComparer.Ordinal);
|
||||
foreach (var link in map.Links)
|
||||
{
|
||||
if (!walkable.Contains(link.A) || !walkable.Contains(link.B))
|
||||
{
|
||||
throw new MapValidationException($"Link '{link.A}' → '{link.B}' points at an unknown node.");
|
||||
}
|
||||
|
||||
if (link.A.Equals(link.B, StringComparison.Ordinal))
|
||||
{
|
||||
throw new MapValidationException($"Link '{link.A}' → '{link.B}' is a self-loop.");
|
||||
}
|
||||
|
||||
adjacency[link.A].Add(link.B);
|
||||
adjacency[link.B].Add(link.A);
|
||||
}
|
||||
|
||||
foreach (var (id, neighbours) in adjacency)
|
||||
{
|
||||
if (neighbours.Count == 0)
|
||||
{
|
||||
throw new MapValidationException($"Node '{id}' is isolated.");
|
||||
}
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
var queue = new Queue<string>();
|
||||
queue.Enqueue(territory.Id);
|
||||
seen.Add(territory.Id);
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
var id = queue.Dequeue();
|
||||
foreach (var next in adjacency[id])
|
||||
{
|
||||
if (seen.Add(next))
|
||||
{
|
||||
queue.Enqueue(next);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (seen.Count != walkable.Count)
|
||||
{
|
||||
throw new MapValidationException("The map graph is not connected.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSlotFills(RoomNode room, DefCatalog catalog)
|
||||
{
|
||||
if (!catalog.Rooms.TryGetValue(room.Def, out var roomDef))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var keys = roomDef.Slots.Select(slot => slot.Key).ToHashSet(StringComparer.Ordinal);
|
||||
foreach (var fill in room.Slots)
|
||||
{
|
||||
if (!keys.Contains(fill.Key))
|
||||
{
|
||||
throw new MapValidationException($"Room '{room.Id}' fills unknown slot '{fill.Key}'.");
|
||||
}
|
||||
|
||||
if (!catalog.Things.TryGetValue(fill.Thing, out var thing) || thing.Abstract)
|
||||
{
|
||||
throw new MapValidationException($"Room '{room.Id}' slot '{fill.Key}' uses unknown or abstract ThingDef '{fill.Thing}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireConcrete(DefCatalog catalog, DefKind kind, string defName, string nodeId)
|
||||
{
|
||||
if (!catalog.TryGet(kind, defName, out var def))
|
||||
{
|
||||
throw new MapValidationException($"Unknown {kind} '{defName}' on '{nodeId}'.");
|
||||
}
|
||||
|
||||
if (def.Abstract)
|
||||
{
|
||||
throw new MapValidationException($"Abstract def '{defName}' cannot be placed on the map ('{nodeId}').");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddId(Dictionary<string, string> ids, string id, string kind)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(id))
|
||||
{
|
||||
throw new MapValidationException($"A {kind} is missing an id.");
|
||||
}
|
||||
|
||||
if (!ids.TryAdd(id, kind))
|
||||
{
|
||||
throw new MapValidationException($"Map id '{id}' is used more than once.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace HSchool.Content;
|
||||
|
||||
internal static class PackPaths
|
||||
{
|
||||
public static string Normalize(string relativePath) => relativePath.Replace('\\', '/').TrimStart('/');
|
||||
|
||||
public static bool IsJsonc(string relativePath)
|
||||
{
|
||||
var path = Normalize(relativePath);
|
||||
return path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".json", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static bool TryGetDefKind(string relativePath, out DefKind kind)
|
||||
{
|
||||
kind = default;
|
||||
var parts = Normalize(relativePath).Split('/');
|
||||
if (parts.Length < 3 || !parts[0].Equals("defs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return TryMapFolder(parts[1], out kind);
|
||||
}
|
||||
|
||||
public static bool IsPatch(string relativePath)
|
||||
{
|
||||
var path = Normalize(relativePath);
|
||||
return path.StartsWith("patches/", StringComparison.OrdinalIgnoreCase) && IsJsonc(path);
|
||||
}
|
||||
|
||||
public static bool TryGetLocaleLanguage(string relativePath, out string language)
|
||||
{
|
||||
language = string.Empty;
|
||||
var path = Normalize(relativePath);
|
||||
if (!path.StartsWith("localizations/", StringComparison.OrdinalIgnoreCase) || !IsJsonc(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var file = Path.GetFileNameWithoutExtension(path);
|
||||
if (file.Equals("ru", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
language = "ru";
|
||||
return true;
|
||||
}
|
||||
|
||||
if (file.Equals("en", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
language = "en";
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool IsDefaultMap(string relativePath) =>
|
||||
Normalize(relativePath).Equals("maps/default.jsonc", StringComparison.OrdinalIgnoreCase)
|
||||
|| Normalize(relativePath).Equals("maps/default.json", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static bool TryMapFolder(string folder, out DefKind kind)
|
||||
{
|
||||
switch (folder.ToLowerInvariant())
|
||||
{
|
||||
case "actions":
|
||||
kind = DefKind.Action;
|
||||
return true;
|
||||
case "things":
|
||||
kind = DefKind.Thing;
|
||||
return true;
|
||||
case "positions":
|
||||
kind = DefKind.Position;
|
||||
return true;
|
||||
case "works":
|
||||
kind = DefKind.Work;
|
||||
return true;
|
||||
case "rooms":
|
||||
kind = DefKind.Room;
|
||||
return true;
|
||||
case "buildings":
|
||||
kind = DefKind.Building;
|
||||
return true;
|
||||
case "floors":
|
||||
kind = DefKind.Floor;
|
||||
return true;
|
||||
case "territories":
|
||||
kind = DefKind.Territory;
|
||||
return true;
|
||||
default:
|
||||
kind = default;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace HSchool.Content;
|
||||
|
||||
internal sealed class PatchDocument
|
||||
{
|
||||
public required string Target { get; init; }
|
||||
|
||||
public IReadOnlyList<PatchOp> Ops { get; init; } = [];
|
||||
}
|
||||
|
||||
internal sealed class PatchOp
|
||||
{
|
||||
public required string Op { get; init; }
|
||||
|
||||
public required string Path { get; init; }
|
||||
|
||||
public JsonNode? Value { get; init; }
|
||||
}
|
||||
|
||||
internal static class PatchApplier
|
||||
{
|
||||
public static void Apply(JsonObject target, PatchDocument patch)
|
||||
{
|
||||
foreach (var op in patch.Ops)
|
||||
{
|
||||
var name = op.Op.Trim().ToLowerInvariant();
|
||||
switch (name)
|
||||
{
|
||||
case "add":
|
||||
JsonPointer.Add(target, op.Path, RequireValue(op, patch.Target));
|
||||
break;
|
||||
|
||||
case "replace":
|
||||
JsonPointer.Replace(target, op.Path, RequireValue(op, patch.Target));
|
||||
break;
|
||||
|
||||
case "remove":
|
||||
JsonPointer.Remove(target, op.Path);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new ContentLoadException($"Unknown patch op '{op.Op}' on '{patch.Target}'.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode RequireValue(PatchOp op, string target)
|
||||
{
|
||||
if (op.Value is null)
|
||||
{
|
||||
throw new ContentLoadException($"Patch '{op.Op}' on '{target}' needs a value.");
|
||||
}
|
||||
|
||||
return op.Value;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Simulation;
|
||||
@@ -15,6 +16,7 @@ internal sealed class GameLoopService(
|
||||
ClientRegistry clients,
|
||||
GameMetrics metrics,
|
||||
SchoolStore store,
|
||||
ModContent mods,
|
||||
ILoggerFactory loggerFactory,
|
||||
ILogger<GameLoopService> logger) : BackgroundService
|
||||
{
|
||||
@@ -170,7 +172,7 @@ internal sealed class GameLoopService(
|
||||
var id = _nextId++;
|
||||
store.WriteNextId(_nextId);
|
||||
|
||||
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true);
|
||||
var worker = SpawnWorker(id, normalized, command.StartDate, running: true, ClockSpeed.DefaultIndex, isNew: true, modIds: null, map: null);
|
||||
Track(worker);
|
||||
worker.Start();
|
||||
|
||||
@@ -327,15 +329,25 @@ internal sealed class GameLoopService(
|
||||
save.GameTime,
|
||||
save.Running,
|
||||
save.SpeedIndex,
|
||||
isNew: false);
|
||||
Track(worker);
|
||||
isNew: false,
|
||||
save.ModIds,
|
||||
save.Map);
|
||||
worker.Start();
|
||||
}
|
||||
|
||||
if (_workers.Count > 0)
|
||||
{
|
||||
await Task.WhenAll(_workers.Values.Select(worker => worker.Started)).ConfigureAwait(false);
|
||||
logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count);
|
||||
try
|
||||
{
|
||||
await worker.Started.ConfigureAwait(false);
|
||||
Track(worker);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"School {SchoolId} \"{Name}\" was not started; the save file is unchanged.",
|
||||
save.Id,
|
||||
save.Name);
|
||||
await worker.StopAsync(persist: false).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,9 +362,22 @@ internal sealed class GameLoopService(
|
||||
{
|
||||
await Task.WhenAll(stopping).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (_workers.Count > 0)
|
||||
{
|
||||
logger.LogInformation("Restored {Count} school(s) from disk.", _workers.Count);
|
||||
}
|
||||
}
|
||||
|
||||
private SchoolWorker SpawnWorker(int id, string name, DateTime time, bool running, int speedIndex, bool isNew) =>
|
||||
private SchoolWorker SpawnWorker(
|
||||
int id,
|
||||
string name,
|
||||
DateTime time,
|
||||
bool running,
|
||||
int speedIndex,
|
||||
bool isNew,
|
||||
IReadOnlyList<string>? modIds,
|
||||
MapLayout? map) =>
|
||||
new(
|
||||
id,
|
||||
name,
|
||||
@@ -360,10 +385,13 @@ internal sealed class GameLoopService(
|
||||
running,
|
||||
speedIndex,
|
||||
isNew,
|
||||
modIds,
|
||||
map,
|
||||
_options,
|
||||
clients,
|
||||
metrics,
|
||||
store,
|
||||
mods,
|
||||
loggerFactory.CreateLogger($"HSchool.Server.Game.SchoolWorker.{id}"));
|
||||
|
||||
private void Track(SchoolWorker worker)
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
internal sealed class LoggerContentLog(ILogger logger) : IContentLog
|
||||
{
|
||||
public void Warning(string message) => logger.LogWarning("{Message}", message);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using HSchool.Content;
|
||||
using HSchool.Simulation;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>mods/<id>/</c> from disk and hands the files to <see cref="CatalogLoader"/>.
|
||||
/// Content itself never sees these paths.
|
||||
/// </summary>
|
||||
internal sealed class ModContent
|
||||
{
|
||||
private readonly CatalogLoader _loader = new();
|
||||
private readonly ILogger<ModContent> _logger;
|
||||
|
||||
public ModContent(IOptions<SimulationOptions> options, IHostEnvironment environment, ILogger<ModContent> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
|
||||
var configured = options.Value.ModsDirectory;
|
||||
Root = Path.IsPathRooted(configured)
|
||||
? configured
|
||||
: Path.GetFullPath(Path.Combine(environment.ContentRootPath, configured));
|
||||
|
||||
logger.LogInformation("Mod packs directory is {Directory}.", Root);
|
||||
}
|
||||
|
||||
public string Root { get; }
|
||||
|
||||
public bool PackExists(string packId) => Directory.Exists(PackPath(packId));
|
||||
|
||||
public IReadOnlyList<string> NormalizePackIds(IReadOnlyList<string>? extraModIds) =>
|
||||
CatalogLoader.NormalizePackOrder(extraModIds ?? []);
|
||||
|
||||
public IReadOnlyList<ContentDocument> ReadDocuments(IReadOnlyList<string> packIds)
|
||||
{
|
||||
var documents = new List<ContentDocument>();
|
||||
foreach (var packId in packIds)
|
||||
{
|
||||
var packRoot = PackPath(packId);
|
||||
if (!Directory.Exists(packRoot))
|
||||
{
|
||||
throw new SchoolContentUnavailableException($"Mod folder '{packId}' is missing under {Root}.");
|
||||
}
|
||||
|
||||
foreach (var path in Directory.EnumerateFiles(packRoot, "*.*", SearchOption.AllDirectories))
|
||||
{
|
||||
if (!path.EndsWith(".jsonc", StringComparison.OrdinalIgnoreCase)
|
||||
&& !path.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var relative = Path.GetRelativePath(packRoot, path).Replace('\\', '/');
|
||||
documents.Add(new ContentDocument(packId, relative, File.ReadAllText(path)));
|
||||
}
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
public DefCatalog LoadCatalog(IReadOnlyList<string> packIds, ILogger workerLog)
|
||||
{
|
||||
var documents = ReadDocuments(packIds);
|
||||
try
|
||||
{
|
||||
return _loader.Load(packIds, documents, new LoggerContentLog(workerLog));
|
||||
}
|
||||
catch (ContentLoadException ex)
|
||||
{
|
||||
throw new SchoolContentUnavailableException(ex.Message, ex);
|
||||
}
|
||||
}
|
||||
|
||||
public MapLayout LoadMap(IReadOnlyList<string> packIds, MapLayout? saved)
|
||||
{
|
||||
if (saved is not null)
|
||||
{
|
||||
return saved;
|
||||
}
|
||||
|
||||
var documents = ReadDocuments(packIds);
|
||||
var map = CatalogLoader.LastDefaultMap(packIds, documents);
|
||||
if (map is null)
|
||||
{
|
||||
throw new SchoolContentUnavailableException(
|
||||
$"No maps/default.jsonc found for packs [{string.Join(", ", packIds)}].");
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
private string PackPath(string packId) => Path.Combine(Root, packId);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// The school's pack list cannot be turned into a catalog (missing folder, broken defs, bad map).
|
||||
/// The save file stays on disk; this school simply does not start.
|
||||
/// </summary>
|
||||
internal sealed class SchoolContentUnavailableException : Exception
|
||||
{
|
||||
public SchoolContentUnavailableException(string message) : base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public SchoolContentUnavailableException(string message, Exception innerException) : base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,29 @@
|
||||
using System.Text.Json;
|
||||
using HSchool.Content;
|
||||
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 record SchoolSave(int Format, int Id, string Name, DateTime GameTime, bool Running, int SpeedIndex);
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <summary>Allocates school ids that survive a process restart.</summary>
|
||||
internal sealed record SchoolSaveIndex(int NextId);
|
||||
@@ -16,7 +34,7 @@ internal sealed record SchoolSaveIndex(int NextId);
|
||||
/// </summary>
|
||||
internal sealed class SchoolStore
|
||||
{
|
||||
public const int CurrentFormat = 1;
|
||||
public const int CurrentFormat = 2;
|
||||
|
||||
private const string IndexFileName = "index.json";
|
||||
|
||||
@@ -99,7 +117,17 @@ internal sealed class SchoolStore
|
||||
continue;
|
||||
}
|
||||
|
||||
saves.Add(save with { GameTime = DateTime.SpecifyKind(save.GameTime, DateTimeKind.Utc) });
|
||||
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,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Diagnostics;
|
||||
using System.Threading.Channels;
|
||||
using HSchool.Content;
|
||||
using HSchool.Protocol;
|
||||
using HSchool.Server.Net;
|
||||
using HSchool.Simulation;
|
||||
@@ -8,8 +9,8 @@ namespace HSchool.Server.Game;
|
||||
|
||||
/// <summary>
|
||||
/// Dedicated thread for one school: fixed-step clock, that school's Arch world, that school's
|
||||
/// save file. Awaits are resolved with <c>GetResult</c> so <see cref="School.Tick"/> stays on
|
||||
/// this thread instead of hopping back onto the pool.
|
||||
/// frozen catalog, that school's save file. Awaits are resolved with <c>GetResult</c> so
|
||||
/// <see cref="School.Tick"/> stays on this thread instead of hopping back onto the pool.
|
||||
/// </summary>
|
||||
internal sealed class SchoolWorker
|
||||
{
|
||||
@@ -19,12 +20,15 @@ internal sealed class SchoolWorker
|
||||
private readonly ClientRegistry _clients;
|
||||
private readonly GameMetrics _metrics;
|
||||
private readonly SchoolStore _store;
|
||||
private readonly ModContent _mods;
|
||||
private readonly ILogger _logger;
|
||||
private readonly Channel<WorkerCommand> _mailbox = Channel.CreateUnbounded<WorkerCommand>(
|
||||
new UnboundedChannelOptions { SingleReader = true, SingleWriter = false });
|
||||
private readonly TaskCompletionSource _started = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly CancellationTokenSource _stopping = new();
|
||||
private readonly bool _isNew;
|
||||
private readonly IReadOnlyList<string>? _modIds;
|
||||
private readonly MapLayout? _savedMap;
|
||||
|
||||
private readonly int _id;
|
||||
private readonly string _name;
|
||||
@@ -44,10 +48,13 @@ internal sealed class SchoolWorker
|
||||
bool running,
|
||||
int speedIndex,
|
||||
bool isNew,
|
||||
IReadOnlyList<string>? modIds,
|
||||
MapLayout? savedMap,
|
||||
SimulationOptions options,
|
||||
ClientRegistry clients,
|
||||
GameMetrics metrics,
|
||||
SchoolStore store,
|
||||
ModContent mods,
|
||||
ILogger logger)
|
||||
{
|
||||
_id = id;
|
||||
@@ -56,10 +63,13 @@ internal sealed class SchoolWorker
|
||||
_running = running;
|
||||
_speedIndex = speedIndex;
|
||||
_isNew = isNew;
|
||||
_modIds = modIds;
|
||||
_savedMap = savedMap;
|
||||
_options = options;
|
||||
_clients = clients;
|
||||
_metrics = metrics;
|
||||
_store = store;
|
||||
_mods = mods;
|
||||
_logger = logger;
|
||||
_snapshot = new SchoolState(id, name, time, running, (byte)speedIndex);
|
||||
}
|
||||
@@ -113,6 +123,11 @@ internal sealed class SchoolWorker
|
||||
{
|
||||
RunLoop(_stopping.Token);
|
||||
}
|
||||
catch (SchoolContentUnavailableException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "School {SchoolId} was not started; the save file is unchanged.", _id);
|
||||
_started.TrySetException(ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "School {SchoolId} worker died.", _id);
|
||||
@@ -122,9 +137,30 @@ internal sealed class SchoolWorker
|
||||
|
||||
private void RunLoop(CancellationToken cancellationToken)
|
||||
{
|
||||
var packIds = _mods.NormalizePackIds(_modIds);
|
||||
foreach (var packId in packIds)
|
||||
{
|
||||
if (!_mods.PackExists(packId))
|
||||
{
|
||||
throw new SchoolContentUnavailableException(
|
||||
$"School {_id} needs mod '{packId}', but that folder is missing.");
|
||||
}
|
||||
}
|
||||
|
||||
var catalog = _mods.LoadCatalog(packIds, _logger);
|
||||
var map = _mods.LoadMap(packIds, _savedMap);
|
||||
try
|
||||
{
|
||||
MapValidator.Validate(map, catalog);
|
||||
}
|
||||
catch (MapValidationException ex)
|
||||
{
|
||||
throw new SchoolContentUnavailableException(ex.Message, ex);
|
||||
}
|
||||
|
||||
var school = _isNew
|
||||
? School.Create(_id, _name, _time)
|
||||
: School.Load(_id, _name, _time, _running, _speedIndex);
|
||||
? School.Create(_id, _name, _time, catalog, map)
|
||||
: School.Load(_id, _name, _time, _running, _speedIndex, catalog, map);
|
||||
|
||||
_school = school;
|
||||
PublishSnapshot();
|
||||
@@ -298,13 +334,17 @@ internal sealed class SchoolWorker
|
||||
return;
|
||||
}
|
||||
|
||||
_store.Save(new SchoolSave(
|
||||
SchoolStore.CurrentFormat,
|
||||
school.Id,
|
||||
school.Name,
|
||||
school.Clock.Time,
|
||||
school.Clock.IsRunning,
|
||||
school.Clock.SpeedIndex));
|
||||
_store.Save(new SchoolSave
|
||||
{
|
||||
Format = SchoolStore.CurrentFormat,
|
||||
Id = school.Id,
|
||||
Name = school.Name,
|
||||
GameTime = school.Clock.Time,
|
||||
Running = school.Clock.IsRunning,
|
||||
SpeedIndex = school.Clock.SpeedIndex,
|
||||
ModIds = school.Catalog?.PackIds,
|
||||
Map = school.Map,
|
||||
});
|
||||
}
|
||||
|
||||
private void BroadcastClock()
|
||||
|
||||
@@ -12,6 +12,14 @@
|
||||
<ProjectReference Include="..\HSchool.Protocol\HSchool.Protocol.csproj" />
|
||||
<ProjectReference Include="..\HSchool.ServiceDefaults\HSchool.ServiceDefaults.csproj" />
|
||||
<ProjectReference Include="..\HSchool.Simulation\HSchool.Simulation.csproj" />
|
||||
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="mods\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -19,6 +19,7 @@ builder.Services
|
||||
.Validate(options => options.GameMinutesPerRealSecond > 0, "Simulation:GameMinutesPerRealSecond must be positive.")
|
||||
.Validate(options => GameClock.IsValidStartDate(options.DefaultStartDate), "Simulation:DefaultStartDate is out of range.")
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.SavesDirectory), "Simulation:SavesDirectory must be set.")
|
||||
.Validate(options => !string.IsNullOrWhiteSpace(options.ModsDirectory), "Simulation:ModsDirectory must be set.")
|
||||
.Validate(options => options.SaveIntervalSeconds is > 0 and <= 3600, "Simulation:SaveIntervalSeconds must be between 1 and 3600.")
|
||||
.ValidateOnStart();
|
||||
|
||||
@@ -26,6 +27,7 @@ builder.Services.AddSingleton<GameCommandQueue>();
|
||||
builder.Services.AddSingleton<ClientRegistry>();
|
||||
builder.Services.AddSingleton<GameMetrics>();
|
||||
builder.Services.AddSingleton<SchoolStore>();
|
||||
builder.Services.AddSingleton<ModContent>();
|
||||
builder.Services.AddSingleton<GameSocketHandler>();
|
||||
builder.Services.AddSingleton<GameLoopService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<GameLoopService>());
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"GameMinutesPerRealSecond": 5,
|
||||
"DefaultStartDate": "2012-04-03T06:00:00",
|
||||
"SavesDirectory": "saves",
|
||||
"ModsDirectory": "mods",
|
||||
"SaveIntervalSeconds": 30
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "Sit" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "MainBuilding" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "StandardFloor" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "Principal" }
|
||||
@@ -0,0 +1,2 @@
|
||||
// Empty on purpose: a corridor is a walkable room with no furniture of its own.
|
||||
{ "defName": "Corridor" }
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"defName": "PrincipalsOffice",
|
||||
"slots": [
|
||||
{ "key": "directorChair", "thing": "DirectorsChair" },
|
||||
{ "key": "desk", "thing": "Desk" },
|
||||
{ "key": "guestChair", "thing": "Chair", "count": 2 },
|
||||
],
|
||||
"positions": ["Principal"],
|
||||
"works": ["PrincipalOfficeWork", "TeachLesson", "WalkSchool"],
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "SchoolYard" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "Chair", "actions": ["Sit"] }
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "Desk" }
|
||||
@@ -0,0 +1 @@
|
||||
{ "defName": "DirectorsChair", "parent": "Chair" }
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{ "defName": "PrincipalOfficeWork" },
|
||||
{ "defName": "TeachLesson" },
|
||||
{ "defName": "WalkSchool" },
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"Sit": "Sit",
|
||||
"Chair": "Chair",
|
||||
"DirectorsChair": "Principal's chair",
|
||||
"Desk": "Desk",
|
||||
"Principal": "Principal",
|
||||
"PrincipalOfficeWork": "Principal's work",
|
||||
"TeachLesson": "Teach a lesson",
|
||||
"WalkSchool": "Walk the school",
|
||||
"SchoolYard": "Yard",
|
||||
"MainBuilding": "Main building",
|
||||
"StandardFloor": "Floor",
|
||||
"Corridor": "Corridor",
|
||||
"PrincipalsOffice": "Principal's office",
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"Sit": "Сесть",
|
||||
"Chair": "Стул",
|
||||
"DirectorsChair": "Кресло директора",
|
||||
"Desk": "Стол",
|
||||
"Principal": "Директор",
|
||||
"PrincipalOfficeWork": "Работа директора",
|
||||
"TeachLesson": "Урок",
|
||||
"WalkSchool": "Обход школы",
|
||||
"SchoolYard": "Двор",
|
||||
"MainBuilding": "Главный корпус",
|
||||
"StandardFloor": "Этажи",
|
||||
"Corridor": "Коридор",
|
||||
"PrincipalsOffice": "Кабинет директора",
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
// Walkable yard is the tree root and a graph node. Rooms reach it through the porch/corridor.
|
||||
"territory": { "id": "yard", "def": "SchoolYard" },
|
||||
"buildings": [
|
||||
{ "id": "main", "def": "MainBuilding" },
|
||||
],
|
||||
"floors": [
|
||||
{ "id": "floor-1", "def": "StandardFloor", "building": "main", "label": "1" },
|
||||
],
|
||||
"rooms": [
|
||||
{
|
||||
"id": "corridor-1",
|
||||
"def": "Corridor",
|
||||
"building": "main",
|
||||
"floor": "floor-1",
|
||||
},
|
||||
{
|
||||
"id": "principals-office",
|
||||
"def": "PrincipalsOffice",
|
||||
"building": "main",
|
||||
"floor": "floor-1",
|
||||
"slots": [
|
||||
{ "key": "directorChair", "thing": "DirectorsChair" },
|
||||
{ "key": "desk", "thing": "Desk" },
|
||||
{ "key": "guestChair", "thing": "Chair" },
|
||||
],
|
||||
},
|
||||
],
|
||||
"links": [
|
||||
{ "a": "yard", "b": "corridor-1" },
|
||||
{ "a": "corridor-1", "b": "principals-office" },
|
||||
],
|
||||
}
|
||||
@@ -9,4 +9,8 @@
|
||||
<PackageReference Include="Arch" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HSchool.Content\HSchool.Content.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
using Arch.Core;
|
||||
using HSchool.Content;
|
||||
|
||||
namespace HSchool.Simulation;
|
||||
|
||||
/// <summary>
|
||||
/// One save: a name, a calendar and the ECS world that will hold everything the school is made of.
|
||||
/// The world is empty for now — pupils, rooms and staff land in it as the game grows — but it is
|
||||
/// created and destroyed with the school so ownership is never in question.
|
||||
/// One save: a name, a calendar, a frozen def catalog, a map instance, and the ECS world that will
|
||||
/// hold everything the school is made of. The world is empty for now — pupils, rooms and staff
|
||||
/// land in it as the game grows — but it is created and destroyed with the school so ownership is
|
||||
/// never in question.
|
||||
/// </summary>
|
||||
public sealed class School : IDisposable
|
||||
{
|
||||
@@ -14,21 +16,36 @@ public sealed class School : IDisposable
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
internal School(int id, string name, DateTime startDate)
|
||||
internal School(int id, string name, DateTime startDate, DefCatalog? catalog, MapLayout? map)
|
||||
{
|
||||
Id = id;
|
||||
Name = name;
|
||||
Clock = new GameClock(startDate);
|
||||
Catalog = catalog;
|
||||
Map = map;
|
||||
World = World.Create();
|
||||
}
|
||||
|
||||
/// <summary>A brand-new school: calendar running at the start date, empty world.</summary>
|
||||
public static School Create(int id, string name, DateTime startDate) => new(id, name, startDate);
|
||||
public static School Create(
|
||||
int id,
|
||||
string name,
|
||||
DateTime startDate,
|
||||
DefCatalog? catalog = null,
|
||||
MapLayout? map = null) =>
|
||||
new(id, name, startDate, catalog, map);
|
||||
|
||||
/// <summary>Rebuilds a school from a save. Time, pause and speed come from disk, not defaults.</summary>
|
||||
public static School Load(int id, string name, DateTime time, bool running, int speedIndex)
|
||||
public static School Load(
|
||||
int id,
|
||||
string name,
|
||||
DateTime time,
|
||||
bool running,
|
||||
int speedIndex,
|
||||
DefCatalog? catalog = null,
|
||||
MapLayout? map = null)
|
||||
{
|
||||
var school = new School(id, name, time);
|
||||
var school = new School(id, name, time, catalog, map);
|
||||
school.Clock.IsRunning = running;
|
||||
school.Clock.SpeedIndex = speedIndex;
|
||||
return school;
|
||||
@@ -40,6 +57,12 @@ public sealed class School : IDisposable
|
||||
|
||||
public GameClock Clock { get; }
|
||||
|
||||
/// <summary>Frozen at create/load. Null only in clock-only unit tests.</summary>
|
||||
public DefCatalog? Catalog { get; }
|
||||
|
||||
/// <summary>The school's map instance. Null only in clock-only unit tests.</summary>
|
||||
public MapLayout? Map { get; }
|
||||
|
||||
/// <summary>The Arch world backing this school. Only this school's worker thread may touch it.</summary>
|
||||
public World World { get; }
|
||||
|
||||
|
||||
@@ -31,6 +31,12 @@ public sealed class SimulationOptions
|
||||
/// </summary>
|
||||
public string SavesDirectory { get; set; } = "saves";
|
||||
|
||||
/// <summary>
|
||||
/// Directory of mod packs (<c>core</c> and optional add-ons). Relative paths are resolved
|
||||
/// against the content root.
|
||||
/// </summary>
|
||||
public string ModsDirectory { get; set; } = "mods";
|
||||
|
||||
/// <summary>
|
||||
/// How often a running school writes its clock to disk. Create, pause, speed and shutdown
|
||||
/// write immediately; the tick itself never does.
|
||||
|
||||
Reference in New Issue
Block a user