using System.Text.Json; using NullDepth.Models; var root = FindRoot(); var webRoot = Path.Combine(root, "src", "NullDepth", "wwwroot"); var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); var data = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(webRoot, "data", "game.json")), options) ?? throw new InvalidOperationException("game.json is empty."); var sk = ReadLanguage("sk"); var en = ReadLanguage("en"); var checks = 0; Check(data.Rooms.Count == 15, "The level must contain 15 rooms."); Check(data.Rooms.Select(r => r.Id).Distinct().Count() == data.Rooms.Count, "Room IDs must be unique."); Check(data.Rooms.Any(r => r.Id == data.StartRoomId), "Start room does not exist."); Check(sk.Keys.Order().SequenceEqual(en.Keys.Order()), "SK and EN localization keys differ."); var rooms = data.Rooms.ToDictionary(r => r.Id); var items = data.Items.ToDictionary(i => i.Id); var archive = data.Archive.ToDictionary(a => a.Id); foreach (var item in data.InitialItems) Check(items.ContainsKey(item), $"Unknown initial item: {item}"); foreach (var room in data.Rooms) { Key(room.NameKey); Key(room.DescriptionKey); Asset(room.Image); if (room.AlternateImage is not null) Asset(room.AlternateImage); foreach (var exit in room.Exits.Values) { Check(rooms.ContainsKey(exit.Target), $"Invalid exit target: {exit.Target}"); if (exit.LockedMessageKey is not null) Key(exit.LockedMessageKey); } foreach (var interaction in room.Interactions) { Key(interaction.LabelKey); Key(interaction.ContentKey); foreach (var item in interaction.AddItems) Check(items.ContainsKey(item), $"Unknown item: {item}"); if (interaction.ArchiveId is not null) Check(archive.ContainsKey(interaction.ArchiveId), $"Unknown archive entry: {interaction.ArchiveId}"); foreach (var choice in interaction.Choices) { Key(choice.LabelKey); if (choice.ResultKey is not null) Key(choice.ResultKey); foreach (var item in choice.AddItems) Check(items.ContainsKey(item), $"Unknown item: {item}"); } } } foreach (var item in data.Items) { Key(item.NameKey); Key(item.DescriptionKey); } foreach (var entry in data.Archive) { Key(entry.TitleKey); Key(entry.ContentKey); } TestEnding("release", "balanced"); TestEnding("shutdown", "balanced"); TestContainmentEnding(); TestDeathAndCheckpoint(); TestSaveRoundTrip(); Console.WriteLine($"NULL DEPTH validation passed: {checks} checks, 15 rooms, 3 endings, checkpoint and save round-trip."); return; Dictionary ReadLanguage(string language) => JsonSerializer.Deserialize>(File.ReadAllText(Path.Combine(webRoot, "localization", $"{language}.json")), options) ?? throw new InvalidOperationException($"{language}.json is empty."); void Key(string key) => Check(sk.ContainsKey(key) && en.ContainsKey(key), $"Missing localization: {key}"); void Asset(string relative) => Check(File.Exists(Path.Combine(webRoot, relative.Replace('/', Path.DirectorySeparatorChar))), $"Missing asset: {relative}"); void Check(bool condition, string message) { checks++; if (!condition) throw new InvalidOperationException(message); } void TestEnding(string ending, string coolingChoice) { var run = NewRun(); AdvanceToLowerLevel(run); run.Move("east"); run.Move("south"); run.Choose("power_routing", coolingChoice); run.Move("north"); run.Move("east"); run.Interact("cross_passage"); run.Move("east"); run.Move("east"); run.Choose("ai_dialogue", "openCore"); run.Move("east"); run.Choose("final_diagnostic", ending); Check(run.Ending == ending, $"Ending '{ending}' is not reachable."); } void TestContainmentEnding() { var run = NewRun(); run.Interact("contractor_terminal"); run.Move("east"); run.Move("east"); run.Move("south"); run.Move("west"); run.Interact("log_01"); run.Move("east"); run.Interact("auxiliary_grid"); run.Move("north"); run.Move("east"); run.Interact("elevator_descent"); run.Interact("elevator_resume"); run.Move("south"); run.Interact("emergency_supply"); run.Move("south"); run.Choose("manual_override", "takeOverride"); run.Move("north"); run.Move("east"); run.Interact("log_02"); run.Move("south"); run.Choose("power_routing", "coolingOnly"); run.Move("north"); run.Move("east"); run.Interact("cross_passage"); run.Move("east"); run.Interact("log_03"); run.Move("east"); run.Choose("ai_dialogue", "openCore"); run.Move("east"); run.Choose("final_diagnostic", "containment"); Check(run.Ending == "containment", "Containment ending is not reachable."); Check(run.Archive.Count == 3, "Recovered logs were not retained in the archive."); Check(run.Inventory.Contains("manual_override") && run.Inventory.Count >= 4, "Gameplay inventory is incomplete."); } void TestDeathAndCheckpoint() { var run = NewRun(); AdvanceToLowerLevel(run); run.Move("east"); run.Move("south"); run.Choose("power_routing", "balanced"); var checkpointRoom = run.Room; run.Move("north"); run.Move("west"); run.Move("south"); var death = run.Choose("manual_override", "forceLiveBus"); Check(death.Death, "Fatal choice is not marked as death."); run.RestoreCheckpoint(); Check(run.Room == checkpointRoom, "Checkpoint did not restore the expected room."); } void TestSaveRoundTrip() { var state = new GameState { CurrentRoomId = data.StartRoomId, ContentVersion = data.Version, Inventory = [.. data.InitialItems], Archive = ["log_01"] }; state.Flags.Add("entry_authorized"); state.VisitedRooms.Add(data.StartRoomId); var json = JsonSerializer.Serialize(state, options); var restored = JsonSerializer.Deserialize(json, options)!; Check(restored.Version == 3 && restored.ContentVersion == data.Version, "Save version was not serialized."); Check(restored.Inventory.SequenceEqual(state.Inventory) && restored.Archive.SequenceEqual(state.Archive), "Save round-trip lost inventory or archive data."); try { JsonSerializer.Deserialize("{broken", options); Check(false, "Corrupt save was accepted."); } catch (JsonException) { Check(true, "Corrupt save rejected."); } } Run NewRun() => new(data); void AdvanceToLowerLevel(Run run) { run.Interact("contractor_terminal"); run.Move("east"); run.Move("east"); run.Move("south"); run.Interact("auxiliary_grid"); run.Move("north"); run.Move("east"); run.Interact("elevator_descent"); run.Interact("elevator_resume"); run.Move("south"); } string FindRoot() { var directory = new DirectoryInfo(AppContext.BaseDirectory); while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "NullDepth.slnx"))) directory = directory.Parent; return directory?.FullName ?? throw new DirectoryNotFoundException("Repository root was not found."); } sealed class Run { private readonly GameData _data; private Snapshot _checkpoint; public string Room { get; private set; } public HashSet Flags { get; } = []; public List Inventory { get; } public List Archive { get; } = []; public string? Ending { get; private set; } public Run(GameData data) { _data = data; Room = data.StartRoomId; Inventory = [.. data.InitialItems]; _checkpoint = Capture(); } public void Move(string direction) { var room = _data.Rooms.Single(r => r.Id == Room); if (!room.Exits.TryGetValue(direction, out var exit) || !exit.RequiresFlags.All(Flags.Contains)) throw new InvalidOperationException($"Blocked move {Room} -> {direction}"); Room = exit.Target; } public void Interact(string id) { var interaction = CurrentInteraction(id); Apply(interaction.SetFlags, interaction.ClearFlags, interaction.AddItems); if (interaction.ArchiveId is not null && !Archive.Contains(interaction.ArchiveId)) Archive.Add(interaction.ArchiveId); if (interaction.Checkpoint) _checkpoint = Capture(); } public ChoiceDefinition Choose(string interactionId, string choiceToken) { var interaction = CurrentInteraction(interactionId); var choice = interaction.Choices.Single(c => c.LabelKey.EndsWith(choiceToken, StringComparison.OrdinalIgnoreCase) || c.Ending == choiceToken); if (!choice.RequiresFlags.All(Flags.Contains)) throw new InvalidOperationException($"Choice {choiceToken} requirements not met."); Apply(choice.SetFlags, choice.ClearFlags, choice.AddItems); Ending = choice.Ending; if (choice.Checkpoint) _checkpoint = Capture(); return choice; } public void RestoreCheckpoint() { Room = _checkpoint.Room; Flags.Clear(); Flags.UnionWith(_checkpoint.Flags); Inventory.Clear(); Inventory.AddRange(_checkpoint.Inventory); Archive.Clear(); Archive.AddRange(_checkpoint.Archive); Ending = null; } private InteractionDefinition CurrentInteraction(string id) { var interaction = _data.Rooms.Single(r => r.Id == Room).Interactions.Single(i => i.Id == id); if (!interaction.RequiresFlags.All(Flags.Contains) || interaction.ForbiddenFlags.Any(Flags.Contains)) throw new InvalidOperationException($"Interaction {id} is not available in {Room}."); return interaction; } private void Apply(IEnumerable set, IEnumerable clear, IEnumerable items) { foreach (var flag in clear) Flags.Remove(flag); foreach (var flag in set) Flags.Add(flag); foreach (var item in items) if (!Inventory.Contains(item)) Inventory.Add(item); } private Snapshot Capture() => new(Room, [.. Flags], [.. Inventory], [.. Archive]); private sealed record Snapshot(string Room, HashSet Flags, List Inventory, List Archive); }