feat(platform): Campaign LA LA0 — extract ApplicationPathSet to AcDream.Platform

The launcher (LA3/LA4) needs the XDG/Windows path contract
(ApplicationPathSet/IApplicationPathEnvironment) without pulling in any
gameplay assembly. Move it out of AcDream.Runtime into a new BCL-only
AcDream.Platform project so the launcher-side Launcher.Core project can
reference it directly per the campaign plan (docs/plans/2026-08-14-launcher-campaign.md,
LA0). Namespace renamed AcDream.Runtime.Platform -> AcDream.Platform;
code is otherwise byte-identical (no logic changes).

AcDream.Runtime now carries a ProjectReference to AcDream.Platform and
re-exports it transitively, so App and Headless keep resolving the type
without a direct reference and K0's Headless single-ProjectReference
guard (HeadlessAssemblyReferencesOnlyTheRuntimeProject) stands unchanged.
The sibling Runtime dependency-boundary guard
(RuntimeProjectDeclaresOnlyApprovedProjectDependencies) does assert
Runtime's own project-reference set, so it needed a deliberate,
documented addition of AcDream.Platform to its expected list.

Moved tests/AcDream.Runtime.Tests/Platform/ApplicationPathSetTests.cs to
a new tests/AcDream.Platform.Tests/ project (namespace
AcDream.Platform.Tests) referencing only AcDream.Platform. Registered
both new projects in AcDream.slnx.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 15:19:02 +02:00
parent 7f24af1a37
commit cb6502c8a5
14 changed files with 49 additions and 10 deletions

View file

@ -19,6 +19,7 @@
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
<ProjectReference Include="..\AcDream.Core.Net\AcDream.Core.Net.csproj" />
<ProjectReference Include="..\AcDream.Content\AcDream.Content.csproj" />
<ProjectReference Include="..\AcDream.Platform\AcDream.Platform.csproj" />
<ProjectReference Include="..\AcDream.Plugin.Abstractions\AcDream.Plugin.Abstractions.csproj" />
</ItemGroup>
</Project>

View file

@ -1,171 +0,0 @@
namespace AcDream.Runtime.Platform;
/// <summary>
/// BCL-only view of the process environment used to resolve portable
/// application paths exactly once at a host composition root.
/// </summary>
public interface IApplicationPathEnvironment
{
bool IsWindows { get; }
string CurrentDirectory { get; }
string? GetEnvironmentVariable(string name);
string GetFolderPath(Environment.SpecialFolder folder);
}
internal sealed class ApplicationPathEnvironment
: IApplicationPathEnvironment
{
internal static ApplicationPathEnvironment Instance { get; } = new();
private ApplicationPathEnvironment()
{
}
public bool IsWindows => OperatingSystem.IsWindows();
public string CurrentDirectory => Environment.CurrentDirectory;
public string? GetEnvironmentVariable(string name) =>
Environment.GetEnvironmentVariable(name);
public string GetFolderPath(Environment.SpecialFolder folder) =>
Environment.GetFolderPath(folder);
}
/// <summary>
/// Canonical portable per-user paths shared by graphical and no-window hosts.
/// Hosts may override the three roots, but feature code only receives the
/// resolved immutable paths and never reads operating-system state itself.
/// </summary>
public sealed record ApplicationPathSet(
string ConfigDirectory,
string DataDirectory,
string CacheDirectory,
string? LegacyConfigDirectory)
{
public string SettingsFile =>
Path.Combine(ConfigDirectory, "settings.json");
public string KeyBindingsFile =>
Path.Combine(ConfigDirectory, "keybinds.json");
public string LogsDirectory =>
Path.Combine(DataDirectory, "logs");
public string ScreenshotsDirectory =>
Path.Combine(DataDirectory, "screenshots");
public string PluginsDirectory =>
Path.Combine(DataDirectory, "plugins");
public string DiagnosticsDirectory =>
Path.Combine(CacheDirectory, "diagnostics");
public static ApplicationPathSet Resolve(
string? configDirectory = null,
string? dataDirectory = null,
string? cacheDirectory = null,
IApplicationPathEnvironment? platform = null)
{
platform ??= ApplicationPathEnvironment.Instance;
string config;
string data;
string cache;
if (platform.IsWindows)
{
string roaming = RequireFolder(
platform,
Environment.SpecialFolder.ApplicationData);
string local = RequireFolder(
platform,
Environment.SpecialFolder.LocalApplicationData);
config = Path.Combine(roaming, "acdream");
data = Path.Combine(local, "acdream");
cache = Path.Combine(local, "acdream", "cache");
}
else
{
string home = RequireFolder(
platform,
Environment.SpecialFolder.UserProfile);
config = ResolveXdg(
platform,
"XDG_CONFIG_HOME",
Path.Combine(home, ".config"),
"acdream");
data = ResolveXdg(
platform,
"XDG_DATA_HOME",
Path.Combine(home, ".local", "share"),
"acdream");
cache = ResolveXdg(
platform,
"XDG_CACHE_HOME",
Path.Combine(home, ".cache"),
"acdream");
}
string? legacyConfigDirectory =
platform.IsWindows && configDirectory is null
? Path.Combine(
RequireFolder(
platform,
Environment.SpecialFolder.LocalApplicationData),
"acdream")
: null;
return new ApplicationPathSet(
Normalize(configDirectory ?? config, platform),
Normalize(dataDirectory ?? data, platform),
Normalize(cacheDirectory ?? cache, platform),
legacyConfigDirectory is null
? null
: Normalize(legacyConfigDirectory, platform));
}
private static string ResolveXdg(
IApplicationPathEnvironment platform,
string variable,
string fallback,
string leaf)
{
string? configured = platform.GetEnvironmentVariable(variable);
string root = string.IsNullOrWhiteSpace(configured)
? fallback
: configured;
return Path.Combine(root, leaf);
}
private static string RequireFolder(
IApplicationPathEnvironment platform,
Environment.SpecialFolder folder)
{
string value = platform.GetFolderPath(folder);
if (string.IsNullOrWhiteSpace(value))
{
throw new InvalidOperationException(
$"The operating system did not provide {folder}.");
}
return value;
}
private static string Normalize(
string path,
IApplicationPathEnvironment platform)
{
if (string.IsNullOrWhiteSpace(path))
{
throw new ArgumentException(
"Application directories cannot be empty.",
nameof(path));
}
return Path.TrimEndingDirectorySeparator(
Path.GetFullPath(path, platform.CurrentDirectory));
}
}