Add authored campaign levels

This commit is contained in:
2026-05-14 10:56:15 +02:00
parent b68b87d475
commit 6699b3b891
21 changed files with 82165 additions and 1807 deletions

View File

@@ -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();
}