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,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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user