Add authored campaign levels
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user