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

@@ -4,9 +4,9 @@ This backlog tracks what must change so the implementation matches `docs/design.
## Audit Snapshot
- Current simulation tests pass: `55/55` in `tests/ReactorMaintenance.Simulation.Tests`.
- Current simulation tests pass: `60/60` in `tests/ReactorMaintenance.Simulation.Tests`.
- Godot has a usable UX scaffold and grid renderer; the full pulse playback, terminal-gated layer controls, and campaign content pass remain in later tasks.
- Existing campaign data is the older placeholder set. These levels and manifest entries must be replaced by the tutorial plus six-group campaign from `docs/CAMPAIGN.md`.
- Campaign data follows the tutorial plus six-group campaign from `docs/CAMPAIGN.md`.
- Unrelated Godot metadata or generated `.uid` files are not part of this backlog unless a later implementation task intentionally touches them.
## P0 Simulation Contract
@@ -110,7 +110,7 @@ This backlog tracks what must change so the implementation matches `docs/design.
- Powered terminal interaction enables local visibility and forecasts.
- Unpowered terminal interaction reveals nothing and still triggers a pulse.
- Leaving the terminal removes underground/forecast visibility.
- [ ] Add tests for campaign authoring invariants.
- [x] Add tests for campaign authoring invariants.
- Every campaign level loads and validates.
- Tutorial is solvable with exactly one `LengthyAction` before `ActivateReactor`.
- Every non-tutorial level has at least two valid first `LengthyAction` choices.
@@ -138,7 +138,7 @@ This backlog tracks what must change so the implementation matches `docs/design.
- Validate wall-mounted sprinkler valve geometry, outlet/access face, water connection, and exactly one linked control.
- Validate isolation valves sit on exactly one matching underground carrier.
- Warn on initially unready reactors, initially starved required consumers, unused supplies, and sprinkler valves with no useful suppression or pressure tradeoff.
- [ ] Replace placeholder campaign files.
- [x] Replace placeholder campaign files.
- Create tutorial plus Groups 1-6 from `docs/CAMPAIGN.md`.
- Update `default_campaign_manifest.json` to the final order.
- Remove or demote old placeholder levels so they are not presented as campaign content.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,112 @@
{
{
"levels": [
{
"id": "coolant-restart",
"name": "Coolant Restart",
"flavorText": "The lower coolant loop is starving the reactor core. Restore enough service to keep the first startup window alive.",
"levelPath": "res://Data/Levels/coolant_restart.json"
"id": "wake-the-feed",
"name": "Wake The Feed",
"flavorText": "A dormant fuel feed waits for one clean startup pulse.",
"levelPath": "res://Data/Levels/wake_the_feed.json"
},
{
"id": "fuel-bleed",
"name": "Fuel Bleed",
"flavorText": "A fuel manifold is venting through the maintenance deck. Isolate the leak before pressure cascades into the ignition zone.",
"levelPath": "res://Data/Levels/fuel_bleed.json"
"id": "bleed-line",
"name": "Bleed Line",
"flavorText": "A side branch is venting fuel while the reactor feed remains closed.",
"levelPath": "res://Data/Levels/bleed_line.json"
},
{
"id": "black-start",
"name": "Black Start",
"flavorText": "The final reactor needs fuel, coolant, and electricity in balance before the station can carry load again.",
"levelPath": "res://Data/Levels/black_start.json"
"id": "valve-choice",
"name": "Valve Choice",
"flavorText": "The main fuel feed and leak branch can both be isolated, but not with the same consequence.",
"levelPath": "res://Data/Levels/valve_choice.json"
},
{
"id": "prime-the-pump",
"name": "Prime The Pump",
"flavorText": "Two coolant consumers need different fixes before the reactor can accept flow.",
"levelPath": "res://Data/Levels/prime_the_pump.json"
},
{
"id": "sprinkler-debt",
"name": "Sprinkler Debt",
"flavorText": "A live sprinkler is useful water in the wrong place and pressure debt on the branch.",
"levelPath": "res://Data/Levels/sprinkler_debt.json"
},
{
"id": "split-flow",
"name": "Split Flow",
"flavorText": "Coolant branches compete with a damaged route and a required production target.",
"levelPath": "res://Data/Levels/split_flow.json"
},
{
"id": "door-circuit",
"name": "Door Circuit",
"flavorText": "The door circuit, consumer branch, and wall leak all depend on local voltage choices.",
"levelPath": "res://Data/Levels/door_circuit.json"
},
{
"id": "the-first-eye",
"name": "The First Eye",
"flavorText": "A powered eye can reveal the grid, but the first terminal use may be dark.",
"levelPath": "res://Data/Levels/the_first_eye.json"
},
{
"id": "blind-grid",
"name": "Blind Grid",
"flavorText": "The visible circuit is plausible, but the powered eye makes the hidden routing clear.",
"levelPath": "res://Data/Levels/blind_grid.json"
},
{
"id": "first-spark",
"name": "First Spark",
"flavorText": "Fuel and electricity leaks can meet if both systems are brought online carelessly.",
"levelPath": "res://Data/Levels/first_spark.json"
},
{
"id": "break-before-make",
"name": "Break Before Make",
"flavorText": "A door and a fuel leak shape the safe order before electricity goes live.",
"levelPath": "res://Data/Levels/break_before_make.json"
},
{
"id": "hot-bypass",
"name": "Hot Bypass",
"flavorText": "A heat shield buys access, but the stable route still depends on isolating the bypass.",
"levelPath": "res://Data/Levels/hot_bypass.json"
},
{
"id": "charged-water",
"name": "Charged Water",
"flavorText": "Sprinkler water and an electric leak make the corridor unsafe unless one side is contained.",
"levelPath": "res://Data/Levels/charged_water.json"
},
{
"id": "dry-before-live",
"name": "Dry Before Live",
"flavorText": "Useful actions must stop new water and let pulses dry the route before voltage spreads.",
"levelPath": "res://Data/Levels/dry_before_live.json"
},
{
"id": "eye-in-the-storm",
"name": "Eye In The Storm",
"flavorText": "The eye helps choose which wet route and electric branch can coexist.",
"levelPath": "res://Data/Levels/eye_in_the_storm.json"
},
{
"id": "three-key-start",
"name": "Three-Key Start",
"flavorText": "Fuel, coolant, and electricity each have one visible startup problem.",
"levelPath": "res://Data/Levels/three_key_start.json"
},
{
"id": "cascade-lockout",
"name": "Cascade Lockout",
"flavorText": "Suppression, ignition risk, and wet conduction all push against each other.",
"levelPath": "res://Data/Levels/cascade_lockout.json"
},
{
"id": "critical-path",
"name": "Critical Path",
"flavorText": "The final startup leaves several valid orders and a few remaining hazards to manage.",
"levelPath": "res://Data/Levels/critical_path.json"
}
]
}

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