using System.Text.Json; using System.Text.Json.Serialization; namespace ReactorMaintenance.Simulation.Tests; public sealed class CampaignAuthoringTests { private sealed record CampaignManifestFile { public IReadOnlyList Levels { get; init; } = Array.Empty(); } private sealed record CampaignLevelEntry { [JsonInclude] public string Id { get; private set; } = string.Empty; public string Name { get; init; } = string.Empty; public string FlavorText { get; init; } = string.Empty; [JsonInclude] public string LevelPath { get; private set; } = string.Empty; } [Fact] public void CampaignManifestMatchesDesignOrder() { var manifest = LoadManifest(); Assert.Equal(s_ExpectedIds, manifest.Levels.Select(level => level.Id)); } [Fact] public void EveryCampaignLevelLoadsAndValidates() { foreach (var entry in LoadManifest().Levels) { var level = LoadLevel(entry); var report = m_Validator.Validate(level); Assert.True(report.IsValid, $"{entry.Id}: {string.Join("; ", report.Errors.Select(error => error.Message))}"); } } [Fact] public void TutorialRequiresExactlyOneLengthyActionBeforeActivation() { var level = LoadLevel(LoadManifest().Levels[0]); Assert.Equal(1, CountValidFirstLengthyActions(level)); var afterPulse = m_Engine.InteractProp(level); Assert.Equal(1, afterPulse.Global.Pulse); Assert.Equal(ELevelState.Ready, afterPulse.Global.LevelState); var won = m_Engine.ActivateReactor(afterPulse with { Robot = afterPulse.Robot with { Position = afterPulse.Reactors[0].ControlPosition } }); Assert.Equal(ELevelState.Won, won.Global.LevelState); } [Fact] public void EveryNonTutorialLevelHasAtLeastTwoValidFirstLengthyActionChoices() { foreach (var entry in LoadManifest().Levels.Skip(1)) { var level = LoadLevel(entry); Assert.True(CountValidFirstLengthyActions(level) >= 2, entry.Id); } } [Fact] public void CampaignDoesNotRequireWaitOrForecastBeforeTheFirstEye() { var manifest = LoadManifest(); var firstEyeIndex = manifest.Levels.ToList().FindIndex(level => level.Id == "the-first-eye"); Assert.Equal(7, firstEyeIndex); foreach (var entry in manifest.Levels.Take(firstEyeIndex)) { var level = LoadLevel(entry); Assert.DoesNotContain(level.Props, prop => prop.Type == EPropType.AllSeeingEyeTerminal); Assert.Empty(level.Forecasts); } } private int CountValidFirstLengthyActions(LevelState level) { var count = 0; foreach (var position in AllPositions(level).Where(level.IsFloor)) { var prop = level.GetProp(position); if (prop.Type != EPropType.None) { var next = m_Engine.InteractProp(level with { Robot = level.Robot with { Position = position } }); if (next.Global.Pulse == level.Global.Pulse + 1) count++; } foreach (var leak in level.Leaks.Where(leak => !leak.Repaired && leak.AccessPosition == position)) { var next = m_Engine.InteractLeak(level with { Robot = level.Robot with { Position = position } }, leak.Carrier, false); if (next.Global.Pulse == level.Global.Pulse + 1) count++; } } return count; } private static IEnumerable AllPositions(LevelState level) { for (var y = 0; y < level.Height; y++) { for (var x = 0; x < level.Width; x++) yield return new(x, y); } } private static LevelState LoadLevel(CampaignLevelEntry entry) { return LevelSerializer.Deserialize(File.ReadAllText(ToRepositoryPath(entry.LevelPath))); } private static CampaignManifestFile LoadManifest() { var json = File.ReadAllText(Path.Combine(RepositoryRoot(), "src", "ReactorMaintenance.Godot", "Data", "default_campaign_manifest.json")); return JsonSerializer.Deserialize(json, s_JsonOptions) ?? throw new InvalidOperationException("Campaign manifest is missing."); } private static string ToRepositoryPath(string resourcePath) { const string prefix = "res://"; if (!resourcePath.StartsWith(prefix, StringComparison.Ordinal)) throw new InvalidOperationException($"Unsupported Godot resource path: {resourcePath}"); return Path.Combine(RepositoryRoot(), "src", "ReactorMaintenance.Godot", resourcePath[prefix.Length..].Replace('/', Path.DirectorySeparatorChar)); } private static string RepositoryRoot() { var current = new DirectoryInfo(AppContext.BaseDirectory); while (current is not null && !File.Exists(Path.Combine(current.FullName, "ReactorMaintenance.slnx"))) current = current.Parent; return current?.FullName ?? throw new InvalidOperationException("Could not locate repository root."); } private static readonly string[] s_ExpectedIds = [ "wake-the-feed", "bleed-line", "valve-choice", "prime-the-pump", "sprinkler-debt", "split-flow", "door-circuit", "the-first-eye", "blind-grid", "first-spark", "break-before-make", "hot-bypass", "charged-water", "dry-before-live", "eye-in-the-storm", "three-key-start", "cascade-lockout", "critical-path" ]; private static readonly JsonSerializerOptions s_JsonOptions = new() { PropertyNameCaseInsensitive = true, Converters = { new JsonStringEnumConverter() } }; private readonly SimulationEngine m_Engine = new(); private readonly LevelValidator m_Validator = new(); }