using System.Text.Json; using System.Text.Json.Serialization; using MinecraftClient.Pathing.Core; namespace MinecraftClient.Tests.Pathing.Execution.Contracts; public sealed class PathingContractStore { private readonly IReadOnlyDictionary planners; private readonly IReadOnlyDictionary timings; private PathingContractStore( IReadOnlyDictionary planners, IReadOnlyDictionary timings) { this.planners = planners; this.timings = timings; } public static PathingContractStore LoadFromRepositoryRoot() { string rootPath = FindRepositoryRoot(); string pathingDir = Path.Combine(rootPath, "MinecraftClient.Tests", "TestData", "Pathing"); string plannerJson = File.ReadAllText(Path.Combine(pathingDir, "pathing-planner-contracts.json")); string timingJson = File.ReadAllText(Path.Combine(pathingDir, "pathing-timing-budgets.json")); return LoadFromJson(plannerJson, timingJson); } public static PathingContractStore LoadFromJson(string plannerJson, string timingJson) { ArgumentException.ThrowIfNullOrWhiteSpace(plannerJson); ArgumentException.ThrowIfNullOrWhiteSpace(timingJson); var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true }; options.Converters.Add(new JsonStringEnumConverter()); PathingPlannerContract[] plannerContracts = JsonSerializer.Deserialize(plannerJson, options) ?? throw new InvalidOperationException("Failed to deserialize planner contracts."); PathingTimingBudget[] timingBudgets = JsonSerializer.Deserialize(timingJson, options) ?? throw new InvalidOperationException("Failed to deserialize timing budgets."); Dictionary plannerByScenario = BuildPlannerDictionary(plannerContracts); Dictionary timingByScenario = BuildTimingDictionary(timingBudgets); ValidateScenarioSetConsistency(plannerByScenario, timingByScenario); ValidateScenarioSegmentAlignment(plannerByScenario, timingByScenario); return new PathingContractStore( plannerByScenario, timingByScenario); } public PathingPlannerContract GetPlanner(string id) { ArgumentException.ThrowIfNullOrWhiteSpace(id); return planners.TryGetValue(id, out PathingPlannerContract? contract) ? contract : throw new KeyNotFoundException($"Planner contract '{id}' was not found."); } public PathingTimingBudget GetTiming(string id) { ArgumentException.ThrowIfNullOrWhiteSpace(id); return timings.TryGetValue(id, out PathingTimingBudget? budget) ? budget : throw new KeyNotFoundException($"Timing budget '{id}' was not found."); } private static string FindRepositoryRoot() { DirectoryInfo? current = new(AppContext.BaseDirectory); while (current is not null) { if (File.Exists(Path.Combine(current.FullName, "MinecraftClient.sln"))) return current.FullName; current = current.Parent; } throw new DirectoryNotFoundException("Unable to locate repository root from current test execution directory."); } private static Dictionary BuildPlannerDictionary(IEnumerable contracts) { var result = new Dictionary(StringComparer.Ordinal); foreach (PathingPlannerContract contract in contracts) { PathingPlannerContract normalized = ValidateAndNormalizePlanner(contract); if (!result.TryAdd(normalized.ScenarioId, normalized)) throw new InvalidDataException($"Duplicate planner contract scenario id '{normalized.ScenarioId}'."); } return result; } private static Dictionary BuildTimingDictionary(IEnumerable budgets) { var result = new Dictionary(StringComparer.Ordinal); foreach (PathingTimingBudget budget in budgets) { PathingTimingBudget normalized = ValidateAndNormalizeTiming(budget); if (!result.TryAdd(normalized.ScenarioId, normalized)) throw new InvalidDataException($"Duplicate timing budget scenario id '{normalized.ScenarioId}'."); } return result; } private static PathingPlannerContract ValidateAndNormalizePlanner(PathingPlannerContract contract) { if (string.IsNullOrWhiteSpace(contract.ScenarioId)) throw new InvalidDataException("Planner contract has blank scenario id."); if (contract.Segments is null || contract.Segments.Count == 0) throw new InvalidDataException($"Planner contract '{contract.ScenarioId}' must contain at least one segment."); var normalizedSegments = new List(contract.Segments.Count); for (int i = 0; i < contract.Segments.Count; i++) { PathingPlannerSegmentContract segment = contract.Segments[i]; normalizedSegments.Add(segment); } return contract with { Segments = normalizedSegments.AsReadOnly() }; } private static PathingTimingBudget ValidateAndNormalizeTiming(PathingTimingBudget budget) { if (string.IsNullOrWhiteSpace(budget.ScenarioId)) throw new InvalidDataException("Timing budget has blank scenario id."); if (budget.Segments is null || budget.Segments.Count == 0) throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' must contain at least one segment."); if (budget.ExpectedTotalTicks < 0 || budget.MaxTotalTicks < 0) throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' total ticks must be nonnegative."); if (budget.ExpectedTotalTicks > budget.MaxTotalTicks) throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' has ExpectedTotalTicks greater than MaxTotalTicks."); var normalizedSegments = new List(budget.Segments.Count); for (int i = 0; i < budget.Segments.Count; i++) { PathingSegmentTimingBudget segment = budget.Segments[i]; if (segment.ExpectedTicks < 0 || segment.MaxTicks < 0) throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' segment {i} has negative tick values."); if (segment.ExpectedTicks > segment.MaxTicks) throw new InvalidDataException($"Timing budget '{budget.ScenarioId}' segment {i} has ExpectedTicks greater than MaxTicks."); normalizedSegments.Add(segment); } return budget with { Segments = normalizedSegments.AsReadOnly() }; } private static void ValidateScenarioSetConsistency( IReadOnlyDictionary plannersByScenario, IReadOnlyDictionary timingsByScenario) { string[] plannerOnly = plannersByScenario.Keys.Except(timingsByScenario.Keys, StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal).ToArray(); string[] timingOnly = timingsByScenario.Keys.Except(plannersByScenario.Keys, StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal).ToArray(); if (plannerOnly.Length == 0 && timingOnly.Length == 0) return; string plannerOnlyList = plannerOnly.Length == 0 ? "" : string.Join(", ", plannerOnly); string timingOnlyList = timingOnly.Length == 0 ? "" : string.Join(", ", timingOnly); throw new InvalidDataException( $"Planner/timing scenario set mismatch. Missing timing entries for: {plannerOnlyList}. Missing planner entries for: {timingOnlyList}."); } private static void ValidateScenarioSegmentAlignment( IReadOnlyDictionary plannersByScenario, IReadOnlyDictionary timingsByScenario) { foreach ((string scenarioId, PathingPlannerContract planner) in plannersByScenario) { PathingTimingBudget timing = timingsByScenario[scenarioId]; if (planner.Segments.Count != timing.Segments.Count) { throw new InvalidDataException( $"Scenario '{scenarioId}' segment count mismatch. Planner has {planner.Segments.Count} segments, timing has {timing.Segments.Count}."); } for (int i = 0; i < planner.Segments.Count; i++) { MoveType plannerMove = planner.Segments[i].MoveType; MoveType timingMove = timing.Segments[i].MoveType; if (plannerMove != timingMove) { throw new InvalidDataException( $"Scenario '{scenarioId}' segment {i} move mismatch. Planner has {plannerMove}, timing has {timingMove}."); } } } } }