177 lines
5.9 KiB
C#
177 lines
5.9 KiB
C#
using System.Text.Json;
|
|
using System.Text.Json.Serialization;
|
|
|
|
namespace ReactorMaintenance.Simulation.Tests;
|
|
|
|
public sealed class CampaignAuthoringTests
|
|
{
|
|
private sealed record CampaignManifestFile
|
|
{
|
|
public IReadOnlyList<CampaignLevelEntry> Levels { get; init; } = Array.Empty<CampaignLevelEntry>();
|
|
}
|
|
|
|
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<GridPosition> 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<CampaignManifestFile>(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();
|
|
} |