Expanded game data model, versioned saves with migration, and robust inventory/archive systems. Added procedural/file-based audio, new overlays (inventory, archive, settings, confirmations), autosave indicator, and improved transitions. Introduced data validation project, updated localization, enhanced accessibility, and revised documentation. Comprehensive refactor for maintainability and user experience.
193 lines
9.6 KiB
C#
193 lines
9.6 KiB
C#
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<GameData>(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<string, string> ReadLanguage(string language) =>
|
|
JsonSerializer.Deserialize<Dictionary<string, string>>(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<GameState>(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<GameState>("{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<string> Flags { get; } = [];
|
|
public List<string> Inventory { get; }
|
|
public List<string> 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<string> set, IEnumerable<string> clear, IEnumerable<string> 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<string> Flags, List<string> Inventory, List<string> Archive);
|
|
}
|