feat(launcher): prepare Campaign LA11 user gate

This commit is contained in:
Erik 2026-08-14 23:09:08 +02:00
parent 09d84387a8
commit f881e5b467
24 changed files with 3538 additions and 58 deletions

View file

@ -0,0 +1,333 @@
<#
.SYNOPSIS
Creates deterministic isolated Campaign LA A/B update feeds.
.DESCRIPTION
Packages caller-supplied published client and launcher roots for win-x64
and linux-x64, adds a deterministic release marker, calculates the exact
LA10 SHA-256/size manifest fields, and emits a loopback-only static server
plus a local A/B selector. It never downloads, connects, edits a payload
source, or writes outside -OutputDirectory.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$OutputDirectory,
[Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryA,
[Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryA,
[Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryA,
[Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryA,
[Parameter(Mandatory = $true)][string]$ClientWinX64DirectoryB,
[Parameter(Mandatory = $true)][string]$LauncherWinX64DirectoryB,
[Parameter(Mandatory = $true)][string]$ClientLinuxX64DirectoryB,
[Parameter(Mandatory = $true)][string]$LauncherLinuxX64DirectoryB,
[string]$VersionA = '1.0.1-la11.a',
[string]$VersionB = '1.0.1-la11.b',
[string]$MinimumLauncherVersion = '1.0.0',
[int]$Port = 43119,
[switch]$DryRun
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion.Major -lt 7) {
throw 'Campaign LA update fixture creation requires PowerShell 7 or newer.'
}
if (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
throw '-OutputDirectory must be absolute.'
}
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath($OutputDirectory))
if ($Port -lt 1024 -or $Port -gt 65535) { throw '-Port must be 1024..65535.' }
$semver = '^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$'
if ($VersionA -notmatch $semver -or $VersionB -notmatch $semver -or
$MinimumLauncherVersion -notmatch $semver -or $VersionA -ceq $VersionB) {
throw 'VersionA, VersionB, and MinimumLauncherVersion must be SemVer 2.0; A and B must differ.'
}
$parsedVersionA = [semver]$VersionA
$parsedVersionB = [semver]$VersionB
$parsedMinimumLauncherVersion = [semver]$MinimumLauncherVersion
if ($parsedVersionB.CompareTo($parsedVersionA) -le 0) {
throw 'VersionB must be newer than VersionA.'
}
if ($parsedMinimumLauncherVersion.CompareTo($parsedVersionA) -gt 0) {
throw 'MinimumLauncherVersion must not be newer than VersionA.'
}
$sources = [ordered]@{
'A-client-win-x64' = $ClientWinX64DirectoryA
'A-launcher-win-x64' = $LauncherWinX64DirectoryA
'A-client-linux-x64' = $ClientLinuxX64DirectoryA
'A-launcher-linux-x64' = $LauncherLinuxX64DirectoryA
'B-client-win-x64' = $ClientWinX64DirectoryB
'B-launcher-win-x64' = $LauncherWinX64DirectoryB
'B-client-linux-x64' = $ClientLinuxX64DirectoryB
'B-launcher-linux-x64' = $LauncherLinuxX64DirectoryB
}
foreach ($key in @($sources.Keys)) {
$source = [string]$sources[$key]
if (-not [IO.Path]::IsPathFullyQualified($source)) {
throw "Payload source '$key' must be absolute."
}
$source = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($source))
$sources[$key] = $source
if (-not $DryRun -and -not (Test-Path -LiteralPath $source -PathType Container)) {
throw "Payload source '$key' does not exist: $source"
}
}
function Require-PayloadFile([string]$Key, [string]$Name) {
if ($DryRun) { return }
if (-not (Test-Path -LiteralPath (Join-Path $sources[$Key] $Name) -PathType Leaf)) {
throw "Payload source '$Key' is missing root file '$Name'."
}
}
foreach ($release in @('A', 'B')) {
Require-PayloadFile "$release-client-win-x64" 'AcDream.App.exe'
Require-PayloadFile "$release-client-win-x64" 'acdream-headless.exe'
Require-PayloadFile "$release-launcher-win-x64" 'acdream-launcher.exe'
Require-PayloadFile "$release-client-linux-x64" 'AcDream.App'
Require-PayloadFile "$release-client-linux-x64" 'acdream-headless'
Require-PayloadFile "$release-launcher-linux-x64" 'acdream-launcher'
}
if (Test-Path -LiteralPath $OutputDirectory) {
if (@(Get-ChildItem -LiteralPath $OutputDirectory -Force).Count -gt 0) {
throw '-OutputDirectory must not already contain files.'
}
}
else { $null = New-Item -ItemType Directory -Path $OutputDirectory }
if ($DryRun) {
$plan = [ordered]@{
schemaVersion = 1
kind = 'campaign-la-update-fixture-plan'
outputDirectory = $OutputDirectory
versions = @($VersionA, $VersionB)
minimumLauncherVersion = $MinimumLauncherVersion
port = $Port
sources = $sources
writesOutsideOutputDirectory = $false
externalNetwork = $false
}
$plan | ConvertTo-Json -Depth 5 |
Set-Content -LiteralPath (Join-Path $OutputDirectory 'dry-run.json') -Encoding utf8NoBOM
Write-Host "Campaign LA update fixture dry run: $OutputDirectory"
return
}
Add-Type -AssemblyName System.IO.Compression
Add-Type -AssemblyName System.IO.Compression.FileSystem
$fixedTimestamp = [DateTimeOffset]::new(2000, 1, 1, 0, 0, 0, [TimeSpan]::Zero)
function New-DeterministicZip(
[string]$SourceDirectory,
[string]$Destination,
[string]$ReleaseLabel,
[string]$PayloadKind,
[string]$Rid) {
$destinationDirectory = Split-Path -Parent $Destination
$null = New-Item -ItemType Directory -Force -Path $destinationDirectory
$stream = [IO.FileStream]::new(
$Destination,
[IO.FileMode]::CreateNew,
[IO.FileAccess]::ReadWrite,
[IO.FileShare]::None)
try {
$archive = [IO.Compression.ZipArchive]::new(
$stream,
[IO.Compression.ZipArchiveMode]::Create,
$true,
[Text.Encoding]::UTF8)
try {
$files = @(Get-ChildItem -LiteralPath $SourceDirectory -File -Recurse |
Sort-Object { [IO.Path]::GetRelativePath($SourceDirectory, $_.FullName).Replace('\', '/') })
foreach ($file in $files) {
if (($file.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
throw "Payload contains a reparse point: $($file.FullName)"
}
$relative = [IO.Path]::GetRelativePath($SourceDirectory, $file.FullName).Replace('\', '/')
if ($relative.StartsWith('../', [StringComparison]::Ordinal) -or
[IO.Path]::IsPathRooted($relative)) {
throw "Payload path escaped its root: $relative"
}
$entry = $archive.CreateEntry($relative, [IO.Compression.CompressionLevel]::Optimal)
$entry.LastWriteTime = $fixedTimestamp
$executable = $relative -ceq 'AcDream.App' -or
$relative -ceq 'acdream-headless' -or
$relative -ceq 'acdream-launcher' -or
$relative.EndsWith('.sh', [StringComparison]::Ordinal)
$mode = if ($executable) { 0x81ED } else { 0x81A4 }
$entry.ExternalAttributes = $mode -shl 16
$input = [IO.File]::OpenRead($file.FullName)
$output = $entry.Open()
try { $input.CopyTo($output) }
finally { $output.Dispose(); $input.Dispose() }
}
$marker = $archive.CreateEntry(
'campaign-la-fixture-release.txt',
[IO.Compression.CompressionLevel]::Optimal)
$marker.LastWriteTime = $fixedTimestamp
$marker.ExternalAttributes = 0x81A4 -shl 16
$writer = [IO.StreamWriter]::new(
$marker.Open(),
[Text.UTF8Encoding]::new($false))
try {
$writer.NewLine = "`n"
$writer.Write("release=$ReleaseLabel`npayload=$PayloadKind`nrid=$Rid`n")
}
finally { $writer.Dispose() }
}
finally { $archive.Dispose() }
}
finally { $stream.Dispose() }
}
function Get-Artifact([string]$Path, [string]$Url) {
$item = Get-Item -LiteralPath $Path
return [ordered]@{
url = $Url
sha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
size = $item.Length
}
}
$releaseDefinitions = @(
[pscustomobject]@{ Label = 'A'; Version = $VersionA },
[pscustomobject]@{ Label = 'B'; Version = $VersionB }
)
foreach ($release in $releaseDefinitions) {
$releaseRoot = Join-Path $OutputDirectory $release.Label
foreach ($rid in @('win-x64', 'linux-x64')) {
New-DeterministicZip `
$sources["$($release.Label)-client-$rid"] `
(Join-Path $releaseRoot "client-$rid.zip") `
$release.Label 'client' $rid
New-DeterministicZip `
$sources["$($release.Label)-launcher-$rid"] `
(Join-Path $releaseRoot "launcher-$rid.zip") `
$release.Label 'launcher' $rid
}
$baseUri = "http://127.0.0.1:$Port/$($release.Label)"
$manifest = [ordered]@{
schemaVersion = 1
version = $release.Version
minimumLauncherVersion = $MinimumLauncherVersion
clients = [ordered]@{
'win-x64' = Get-Artifact `
(Join-Path $releaseRoot 'client-win-x64.zip') `
"$baseUri/client-win-x64.zip"
'linux-x64' = Get-Artifact `
(Join-Path $releaseRoot 'client-linux-x64.zip') `
"$baseUri/client-linux-x64.zip"
}
launchers = [ordered]@{
'win-x64' = Get-Artifact `
(Join-Path $releaseRoot 'launcher-win-x64.zip') `
"$baseUri/launcher-win-x64.zip"
'linux-x64' = Get-Artifact `
(Join-Path $releaseRoot 'launcher-linux-x64.zip') `
"$baseUri/launcher-linux-x64.zip"
}
}
$manifest | ConvertTo-Json -Depth 8 -Compress |
Set-Content -LiteralPath (Join-Path $releaseRoot 'manifest.json') -Encoding utf8NoBOM
}
Set-Content -LiteralPath (Join-Path $OutputDirectory 'active-release.txt') `
-Value 'A' -Encoding ascii -NoNewline
$server = @'
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$Root,
[Parameter(Mandatory = $true)][int]$Port
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$Root = [IO.Path]::TrimEndingDirectorySeparator([IO.Path]::GetFullPath($Root))
$prefix = "http://127.0.0.1:$Port/"
$listener = [Net.HttpListener]::new()
$listener.Prefixes.Add($prefix)
$listener.Start()
Write-Host "Campaign LA fixture listening on $prefix"
try {
while ($listener.IsListening) {
$context = $listener.GetContext()
try {
if ($context.Request.HttpMethod -cne 'GET') {
$context.Response.StatusCode = 405
continue
}
$relative = [Uri]::UnescapeDataString($context.Request.Url.AbsolutePath.TrimStart('/'))
if ($relative -ceq 'manifest.json') {
$active = (Get-Content -LiteralPath (Join-Path $Root 'active-release.txt') -Raw).Trim()
if ($active -notin @('A', 'B')) { throw 'active-release.txt must contain A or B.' }
$relative = "$active/manifest.json"
}
if ([string]::IsNullOrWhiteSpace($relative) -or $relative.Contains('..')) {
$context.Response.StatusCode = 404
continue
}
$path = [IO.Path]::GetFullPath((Join-Path $Root $relative))
if (-not $path.StartsWith($Root + [IO.Path]::DirectorySeparatorChar, [StringComparison]::Ordinal) -or
-not (Test-Path -LiteralPath $path -PathType Leaf)) {
$context.Response.StatusCode = 404
continue
}
$context.Response.ContentType = if ($path.EndsWith('.json', [StringComparison]::Ordinal)) {
'application/json'
} else { 'application/zip' }
$context.Response.StatusCode = 200
$context.Response.Headers['Cache-Control'] = 'no-store'
$context.Response.ContentLength64 = (Get-Item -LiteralPath $path).Length
$input = [IO.File]::OpenRead($path)
try { $input.CopyTo($context.Response.OutputStream) }
finally { $input.Dispose() }
}
catch {
$context.Response.StatusCode = 500
Write-Error $_
}
finally { $context.Response.Close() }
}
}
finally { $listener.Close() }
'@
$server | Set-Content -LiteralPath (Join-Path $OutputDirectory 'serve-fixture.ps1') -Encoding utf8NoBOM
$selector = @'
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][ValidateSet('A', 'B')][string]$Release,
[string]$Root = $PSScriptRoot
)
Set-StrictMode -Version Latest
$path = Join-Path ([IO.Path]::GetFullPath($Root)) 'active-release.txt'
Set-Content -LiteralPath $path -Value $Release -Encoding ascii -NoNewline
Write-Host "Campaign LA fixture active release: $Release"
'@
$selector | Set-Content -LiteralPath (Join-Path $OutputDirectory 'set-active-release.ps1') -Encoding utf8NoBOM
$inventory = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
Where-Object { $_.Name -ne 'fixture-report.json' } |
Sort-Object FullName |
ForEach-Object {
[ordered]@{
path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
size = $_.Length
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
}
})
$report = [ordered]@{
schemaVersion = 1
kind = 'campaign-la-update-fixture'
versions = [ordered]@{ A = $VersionA; B = $VersionB }
minimumLauncherVersion = $MinimumLauncherVersion
manifestUri = "http://127.0.0.1:$Port/manifest.json"
loopbackOnly = $true
initialRelease = 'A'
sourceDirectories = $sources
artifacts = $inventory
}
$report | ConvertTo-Json -Depth 8 |
Set-Content -LiteralPath (Join-Path $OutputDirectory 'fixture-report.json') -Encoding utf8NoBOM
Write-Host "Campaign LA update fixture: $OutputDirectory"

View file

@ -0,0 +1,394 @@
<#
.SYNOPSIS
Campaign LA11 display-free, connection-free automated preflight.
.DESCRIPTION
Runs the exact Release and portability ladder used before the launcher
user gate. It never starts App/Headless in connected mode, never opens a
window, never reads credentials, and never bakes retail DATs. All logs and
publishes are contained beneath one logs/campaign-la-gate-<timestamp>
directory. Use -DryRun to emit the complete command matrix without
executing it.
#>
[CmdletBinding()]
param(
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
[string]$OutputDirectory,
[switch]$DryRun,
[switch]$IncludeInstalledDat,
[string]$InstalledDatDirectory
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion.Major -lt 7) {
throw 'Campaign LA preflight requires PowerShell 7 or newer.'
}
$Repository = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath($Repository))
if (-not (Test-Path -LiteralPath (Join-Path $Repository 'AcDream.slnx') -PathType Leaf)) {
throw "Repository does not contain AcDream.slnx: $Repository"
}
if ($IncludeInstalledDat) {
if ([string]::IsNullOrWhiteSpace($InstalledDatDirectory) -or
-not [IO.Path]::IsPathFullyQualified($InstalledDatDirectory)) {
throw '-IncludeInstalledDat requires an absolute -InstalledDatDirectory.'
}
$InstalledDatDirectory = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath($InstalledDatDirectory))
foreach ($file in @(
'client_portal.dat',
'client_cell_1.dat',
'client_highres.dat',
'client_local_English.dat')) {
if (-not (Test-Path -LiteralPath (Join-Path $InstalledDatDirectory $file) -PathType Leaf)) {
throw "Installed DAT directory is missing $file."
}
}
}
$stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss')
if ([string]::IsNullOrWhiteSpace($OutputDirectory)) {
$OutputDirectory = Join-Path $Repository "logs/campaign-la-gate-$stamp"
}
elseif (-not [IO.Path]::IsPathFullyQualified($OutputDirectory)) {
$OutputDirectory = Join-Path $Repository $OutputDirectory
}
$OutputDirectory = [IO.Path]::TrimEndingDirectorySeparator(
[IO.Path]::GetFullPath($OutputDirectory))
$logsDirectory = Join-Path $OutputDirectory 'commands'
$publishDirectory = Join-Path $OutputDirectory 'publish'
$null = New-Item -ItemType Directory -Force -Path $logsDirectory
$commandResults = [Collections.Generic.List[object]]::new()
$failures = [Collections.Generic.List[string]]::new()
$startedUtc = [DateTime]::UtcNow
function Protect-Text([string]$Text) {
if ($null -eq $Text) { return '' }
$protected = $Text
foreach ($name in @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')) {
$value = [Environment]::GetEnvironmentVariable($name)
if (-not [string]::IsNullOrEmpty($value)) {
$protected = $protected.Replace($value, '<redacted>', [StringComparison]::Ordinal)
}
}
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)(--password|-password)(\s+|=)([^\s"'']+)',
'$1$2<redacted>')
$protected = [Text.RegularExpressions.Regex]::Replace(
$protected,
'(?i)("(?:password|credential|secret|token)"\s*:\s*")[^"]*(")',
'$1<redacted>$2')
return $protected
}
function Format-Command([string]$FilePath, [string[]]$Arguments) {
$parts = [Collections.Generic.List[string]]::new()
$parts.Add($FilePath)
foreach ($argument in $Arguments) {
if ($argument -match '[\s"]') {
$parts.Add('"' + $argument.Replace('"', '\"') + '"')
}
else { $parts.Add($argument) }
}
return $parts -join ' '
}
function Add-PlannedCommand(
[string]$Name,
[string]$FilePath,
[string[]]$Arguments,
[Collections.IDictionary]$Environment = @{}) {
$commandResults.Add([ordered]@{
name = $Name
command = Format-Command $FilePath $Arguments
status = 'planned'
startedUtc = $null
durationSeconds = 0
exitCode = $null
stdout = $null
stderr = $null
environmentKeys = @($Environment.Keys | Sort-Object)
})
}
function Invoke-GateCommand(
[string]$Name,
[string]$FilePath,
[string[]]$Arguments,
[Collections.IDictionary]$Environment = @{}) {
if ($DryRun) {
Add-PlannedCommand $Name $FilePath $Arguments $Environment
return
}
$safeName = $Name -replace '[^A-Za-z0-9_.-]', '-'
$stdoutRelative = "commands/$safeName.out.log"
$stderrRelative = "commands/$safeName.err.log"
$stdoutPath = Join-Path $OutputDirectory $stdoutRelative
$stderrPath = Join-Path $OutputDirectory $stderrRelative
$begin = [DateTime]::UtcNow
$watch = [Diagnostics.Stopwatch]::StartNew()
$exitCode = 74
try {
$startInfo = [Diagnostics.ProcessStartInfo]::new()
$startInfo.FileName = $FilePath
$startInfo.WorkingDirectory = $Repository
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
foreach ($argument in $Arguments) { $startInfo.ArgumentList.Add($argument) }
foreach ($entry in $Environment.GetEnumerator()) {
$startInfo.Environment[[string]$entry.Key] = [string]$entry.Value
}
$process = [Diagnostics.Process]::new()
$process.StartInfo = $startInfo
if (-not $process.Start()) { throw "Could not start $FilePath." }
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
$process.WaitForExit()
$stdout = $stdoutTask.GetAwaiter().GetResult()
$stderr = $stderrTask.GetAwaiter().GetResult()
$exitCode = $process.ExitCode
$process.Dispose()
[IO.File]::WriteAllText($stdoutPath, (Protect-Text $stdout))
[IO.File]::WriteAllText($stderrPath, (Protect-Text $stderr))
}
catch {
[IO.File]::WriteAllText($stderrPath, (Protect-Text ($_ | Out-String)))
}
finally {
$watch.Stop()
$commandResults.Add([ordered]@{
name = $Name
command = Format-Command $FilePath $Arguments
status = if ($exitCode -eq 0) { 'passed' } else { 'failed' }
startedUtc = $begin.ToString('O')
durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3)
exitCode = $exitCode
stdout = $stdoutRelative
stderr = $stderrRelative
environmentKeys = @($Environment.Keys | Sort-Object)
})
}
if ($exitCode -ne 0) {
throw "Preflight command '$Name' failed with exit code $exitCode."
}
}
function Add-InternalCheck([string]$Name, [scriptblock]$Action) {
if ($DryRun) {
$commandResults.Add([ordered]@{
name = $Name; command = '<internal contract check>'; status = 'planned'
startedUtc = $null; durationSeconds = 0; exitCode = $null
stdout = $null; stderr = $null; environmentKeys = @()
})
return
}
$begin = [DateTime]::UtcNow
$watch = [Diagnostics.Stopwatch]::StartNew()
$exitCode = 0
try { & $Action }
catch { $exitCode = 1; throw }
finally {
$watch.Stop()
$commandResults.Add([ordered]@{
name = $Name; command = '<internal contract check>'
status = if ($exitCode -eq 0) { 'passed' } else { 'failed' }
startedUtc = $begin.ToString('O')
durationSeconds = [Math]::Round($watch.Elapsed.TotalSeconds, 3)
exitCode = $exitCode; stdout = $null; stderr = $null; environmentKeys = @()
})
}
}
function Invoke-DotNet([string]$Name, [string[]]$Arguments) {
Invoke-GateCommand $Name 'dotnet' $Arguments
}
$portableBuildProjects = @(
'src/AcDream.Platform/AcDream.Platform.csproj',
'src/AcDream.Launcher.Core/AcDream.Launcher.Core.csproj',
'src/AcDream.Bake/AcDream.Bake.csproj',
'src/AcDream.Plugin.Abstractions/AcDream.Plugin.Abstractions.csproj',
'src/AcDream.Core/AcDream.Core.csproj',
'src/AcDream.Core.Net/AcDream.Core.Net.csproj',
'src/AcDream.Content/AcDream.Content.csproj',
'src/AcDream.Runtime/AcDream.Runtime.csproj',
'src/AcDream.Headless/AcDream.Headless.csproj'
)
$portableTestProjects = @(
'tests/AcDream.Platform.Tests/AcDream.Platform.Tests.csproj',
'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj',
'tests/AcDream.Bake.Tests/AcDream.Bake.Tests.csproj',
'tests/AcDream.Core.Net.Tests/AcDream.Core.Net.Tests.csproj',
'tests/AcDream.Content.Tests/AcDream.Content.Tests.csproj',
'tests/AcDream.Runtime.Tests/AcDream.Runtime.Tests.csproj',
'tests/AcDream.Headless.Tests/AcDream.Headless.Tests.csproj'
)
try {
Invoke-DotNet 'release-build' @(
'build', 'AcDream.slnx', '-c', 'Release', '--nologo', '-m:1')
Invoke-DotNet 'release-tests-serial' @(
'test', 'AcDream.slnx', '-c', 'Release', '--no-build', '--nologo', '-m:1',
'--', 'RunConfiguration.MaxCpuCount=1')
Invoke-DotNet 'focused-launcher-updater-core' @(
'test', 'tests/AcDream.Launcher.Core.Tests/AcDream.Launcher.Core.Tests.csproj',
'-c', 'Release', '--no-build', '--nologo',
'--filter', 'FullyQualifiedName~Updates')
Invoke-DotNet 'focused-launcher-updater-ui' @(
'test', 'tests/AcDream.Launcher.Tests/AcDream.Launcher.Tests.csproj',
'-c', 'Release', '--no-build', '--nologo',
'--filter', 'FullyQualifiedName~LauncherUpdateViewModelTests|FullyQualifiedName~LauncherStartupOptionsTests')
foreach ($project in $portableBuildProjects) {
$leaf = [IO.Path]::GetFileNameWithoutExtension($project)
Invoke-DotNet "portable-build-$leaf" @(
'build', $project, '-c', 'Release', '--no-restore', '--nologo', '-m:1')
}
foreach ($project in $portableTestProjects) {
$leaf = [IO.Path]::GetFileNameWithoutExtension($project)
Invoke-DotNet "portable-test-$leaf" @(
'test', $project, '-c', 'Release', '--no-build', '--nologo',
'--', 'RunConfiguration.MaxCpuCount=1')
}
foreach ($rid in @('win-x64', 'linux-x64')) {
$destination = Join-Path $publishDirectory $rid
Invoke-DotNet "publish-launcher-$rid" @(
'publish', 'src/AcDream.Launcher/AcDream.Launcher.csproj',
'-c', 'Release', '-r', $rid, '--self-contained', 'true',
'-p:PublishSingleFile=true', '-o', $destination, '--nologo')
Add-InternalCheck "publish-contract-$rid" {
$suffix = if ($rid.StartsWith('win-', [StringComparison]::Ordinal)) { '.exe' } else { '' }
foreach ($name in @("acdream-launcher$suffix", "acdream-bake$suffix")) {
if (-not (Test-Path -LiteralPath (Join-Path $destination $name) -PathType Leaf)) {
throw "$rid publish is missing $name."
}
}
if (Test-Path -LiteralPath (Join-Path $destination 'acdream-launcher.dll')) {
throw "$rid launcher publish is not single-file."
}
if (Test-Path -LiteralPath (Join-Path $destination 'acdream-bake.dll')) {
throw "$rid bake publish is not single-file."
}
if (-not $IsWindows -and $rid -eq 'linux-x64') {
$mode = [IO.File]::GetUnixFileMode((Join-Path $destination 'acdream-launcher'))
if (($mode -band [IO.UnixFileMode]::UserExecute) -eq 0) {
throw 'linux-x64 launcher is not executable.'
}
}
}
}
$nativeRid = if ($IsWindows) { 'win-x64' } else { 'linux-x64' }
$nativeSuffix = if ($IsWindows) { '.exe' } else { '' }
$nativeRoot = Join-Path $publishDirectory $nativeRid
$bogusRoot = if ($IsWindows) { 'Z:\definitely-not-installed' } else { '/definitely-not-installed' }
$bogusEnvironment = @{
DOTNET_ROOT = $bogusRoot
DOTNET_ROOT_X64 = $bogusRoot
DOTNET_MULTILEVEL_LOOKUP = '0'
}
Invoke-GateCommand 'native-launcher-bogus-dotnet-root' `
(Join-Path $nativeRoot "acdream-launcher$nativeSuffix") `
@('--verify-publish') $bogusEnvironment
Invoke-GateCommand 'native-bake-bogus-dotnet-root' `
(Join-Path $nativeRoot "acdream-bake$nativeSuffix") `
@('--help') $bogusEnvironment
if ($IncludeInstalledDat) {
$datEnvironment = @{
ACDREAM_DAT_DIR = $InstalledDatDirectory
ACDREAM_PROBE_LIVE_MOUNT = '1'
}
$datResults = Join-Path $OutputDirectory 'installed-dat-results'
Invoke-GateCommand 'installed-dat-character-management-readonly' 'dotnet' @(
'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj',
'-c', 'Release', '--no-build', '--nologo',
'--filter', 'FullyQualifiedName~CharacterManagementLiveDatTests',
'--results-directory', $datResults,
'--logger', 'trx;LogFileName=character-management.trx') $datEnvironment
Add-InternalCheck 'installed-dat-character-management-require-pass' {
$trx = Join-Path $datResults 'character-management.trx'
if (-not (Test-Path -LiteralPath $trx -PathType Leaf)) {
throw 'CharacterManagementLiveDatTests did not produce a TRX result.'
}
[xml]$result = Get-Content -LiteralPath $trx -Raw
$outcomes = @($result.TestRun.Results.UnitTestResult | ForEach-Object { $_.outcome })
if ($outcomes.Count -eq 0 -or $outcomes -ccontains 'NotExecuted' -or
@($outcomes | Where-Object { $_ -cne 'Passed' }).Count -gt 0) {
throw "CharacterManagementLiveDatTests must pass (not skip): $($outcomes -join ',')."
}
}
Invoke-GateCommand 'installed-dat-action-map-readonly' 'dotnet' @(
'test', 'tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj',
'-c', 'Release', '--no-build', '--nologo',
'--filter', 'FullyQualifiedName~RetailActionMapReader_LiveDatTests') $datEnvironment
Invoke-GateCommand 'installed-dat-portal-assets-readonly' 'dotnet' @(
'test', 'tests/AcDream.App.Tests/AcDream.App.Tests.csproj',
'-c', 'Release', '--no-build', '--nologo',
'--filter', 'FullyQualifiedName~PortalTunnelAssetTests.InstalledDat_ResolvesRetailPortalSetupAndAnimation') $datEnvironment
}
}
catch {
$failures.Add((Protect-Text ($_ | Out-String)).Trim())
}
finally {
$finishedUtc = [DateTime]::UtcNow
$head = (& git -C $Repository rev-parse HEAD).Trim()
$dirtyLines = @(& git -C $Repository status --porcelain=v1 --untracked-files=all)
$artifacts = @()
if (-not $DryRun) {
$artifacts = @(Get-ChildItem -LiteralPath $OutputDirectory -File -Recurse |
Where-Object { $_.FullName -ne (Join-Path $OutputDirectory 'report.json') } |
Sort-Object FullName |
ForEach-Object {
[ordered]@{
path = [IO.Path]::GetRelativePath($OutputDirectory, $_.FullName).Replace('\', '/')
size = $_.Length
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()
}
})
}
$failedCommands = @($commandResults | Where-Object { $_.status -eq 'failed' })
$report = [ordered]@{
schemaVersion = 1
kind = 'campaign-la-automated-preflight'
dryRun = [bool]$DryRun
success = ($failures.Count -eq 0 -and $failedCommands.Count -eq 0)
repository = $Repository
head = $head
dirty = ($dirtyLines.Count -gt 0)
dirtyPaths = @($dirtyLines | ForEach-Object { Protect-Text $_ })
platform = [ordered]@{
os = [Runtime.InteropServices.RuntimeInformation]::OSDescription
architecture = [Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
processArchitecture = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture.ToString()
rid = [Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier
framework = [Runtime.InteropServices.RuntimeInformation]::FrameworkDescription
powershell = $PSVersionTable.PSVersion.ToString()
}
startedUtc = $startedUtc.ToString('O')
finishedUtc = $finishedUtc.ToString('O')
durationSeconds = [Math]::Round(($finishedUtc - $startedUtc).TotalSeconds, 3)
installedDatIncluded = [bool]$IncludeInstalledDat
commands = @($commandResults)
failures = @($failures)
redaction = [ordered]@{
applied = $true
environmentValuesNeverReported = @('ACDREAM_TEST_PASS', 'ACDREAM_LA_GATE_SECRET')
credentialArgumentsAllowed = $false
}
artifacts = $artifacts
}
$reportPath = Join-Path $OutputDirectory 'report.json'
$report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $reportPath -Encoding utf8NoBOM
Write-Host "Campaign LA preflight report: $reportPath"
if (-not $report.success) { exit 1 }
}

View file

@ -0,0 +1,346 @@
<#
.SYNOPSIS
Strict Campaign LA v1 session-status and terminal-process validator.
.DESCRIPTION
Validates exact JSONL property sets and property order, lifecycle order for
probe/guiSelect/gui/headless, terminal semantics, plugin expectations,
credential redaction, and absence of launcher child-process leaks. The
report contains hashes and event names only; it does not copy account,
character, command, plugin-error, or other payload text.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$StatusFile,
[Parameter(Mandatory = $true)]
[ValidateSet('probe', 'guiSelect', 'gui', 'headless')][string]$Mode,
[string]$ExpectedSessionId,
[string[]]$ExpectedPlugin = @(),
[switch]$ExpectNoEnteredWorld,
[switch]$AllowPluginFailure,
[switch]$AllowLoginCommandFailure,
[switch]$AllowLauncherChildren,
[string[]]$ForbiddenEnvironmentVariable = @(
'ACDREAM_TEST_PASS',
'ACDREAM_LA_GATE_SECRET'),
[string]$ReportPath,
[int]$ProcessExitWaitSeconds = 5
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if ($PSVersionTable.PSVersion.Major -lt 7) {
throw 'Campaign LA status validation requires PowerShell 7 or newer.'
}
if ($ExpectNoEnteredWorld -and $Mode -ne 'guiSelect') {
throw '-ExpectNoEnteredWorld is valid only for a guiSelect row.'
}
if (-not [IO.Path]::IsPathFullyQualified($StatusFile)) {
$StatusFile = [IO.Path]::GetFullPath($StatusFile)
}
if (-not (Test-Path -LiteralPath $StatusFile -PathType Leaf)) {
throw "Status file does not exist: $StatusFile"
}
if ([string]::IsNullOrWhiteSpace($ReportPath)) {
$ReportPath = "$StatusFile.validation.json"
}
elseif (-not [IO.Path]::IsPathFullyQualified($ReportPath)) {
$ReportPath = [IO.Path]::GetFullPath($ReportPath)
}
$exactFields = @{
started = @('v', 'e', 't', 'sessionId')
connected = @('v', 'e', 't', 'sessionId')
characterList = @('v', 'e', 't', 'sessionId', 'accountName', 'slotCount', 'characters')
enteredWorld = @('v', 'e', 't', 'sessionId', 'characterId', 'characterName')
pluginLoaded = @('v', 'e', 't', 'sessionId', 'plugin')
pluginFailed = @('v', 'e', 't', 'sessionId', 'plugin', 'error')
loginCommandFailed = @('v', 'e', 't', 'sessionId', 'commandIndex', 'command', 'error')
disconnected = @('v', 'e', 't', 'sessionId', 'reason')
exited = @('v', 'e', 't', 'sessionId', 'code', 'reason')
}
$failures = [Collections.Generic.List[string]]::new()
$eventNames = [Collections.Generic.List[string]]::new()
$loadedPlugins = [Collections.Generic.List[string]]::new()
$sessionId = $null
$previousTimestamp = [DateTimeOffset]::MinValue
$terminalSeen = $false
function Get-Properties([Text.Json.JsonElement]$Element) {
$properties = [Collections.Generic.List[object]]::new()
foreach ($property in $Element.EnumerateObject()) { $properties.Add($property) }
return @($properties)
}
function Assert-String(
[Text.Json.JsonElement]$Root,
[string]$Name,
[bool]$AllowEmpty = $false) {
$value = $Root.GetProperty($Name)
if ($value.ValueKind -ne [Text.Json.JsonValueKind]::String) {
throw "field '$Name' is not a string"
}
$text = $value.GetString()
if (-not $AllowEmpty -and [string]::IsNullOrWhiteSpace($text)) {
throw "field '$Name' is empty"
}
return $text
}
function Assert-Int32([Text.Json.JsonElement]$Root, [string]$Name) {
$value = $Root.GetProperty($Name)
if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) {
throw "field '$Name' is not a number"
}
return $value.GetInt32()
}
function Assert-UInt32([Text.Json.JsonElement]$Root, [string]$Name) {
$value = $Root.GetProperty($Name)
if ($value.ValueKind -ne [Text.Json.JsonValueKind]::Number) {
throw "field '$Name' is not a number"
}
return $value.GetUInt32()
}
$lines = @(Get-Content -LiteralPath $StatusFile)
if ($lines.Count -eq 0) { $failures.Add('status stream is empty') }
for ($lineIndex = 0; $lineIndex -lt $lines.Count; $lineIndex++) {
$lineNumber = $lineIndex + 1
$line = $lines[$lineIndex]
if ([string]::IsNullOrWhiteSpace($line)) {
$failures.Add("line $lineNumber is empty")
continue
}
foreach ($variable in $ForbiddenEnvironmentVariable) {
$secret = [Environment]::GetEnvironmentVariable($variable)
if (-not [string]::IsNullOrEmpty($secret) -and
$line.Contains($secret, [StringComparison]::Ordinal)) {
$failures.Add("line $lineNumber contains the value of forbidden environment variable $variable")
}
}
if ($line -match '(?i)"(?:password|credential|secret|token)"\s*:') {
$failures.Add("line $lineNumber contains a credential-like JSON field")
}
$document = $null
try {
$document = [Text.Json.JsonDocument]::Parse($line)
$root = $document.RootElement
if ($root.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
throw 'root is not an object'
}
$properties = @(Get-Properties $root)
$names = @($properties | ForEach-Object { $_.Name })
if (@($names | Sort-Object -Unique).Count -ne $names.Count) {
throw 'object contains duplicate fields'
}
$eventName = Assert-String $root 'e'
if (-not $exactFields.ContainsKey($eventName)) {
throw "event '$eventName' is not in the v1 vocabulary"
}
$expected = $exactFields[$eventName]
if ($names.Count -ne $expected.Count -or
[string]::Join("`n", $names) -cne [string]::Join("`n", $expected)) {
throw "event '$eventName' fields/order are '$($names -join ',')'; expected '$($expected -join ',')'"
}
if ((Assert-Int32 $root 'v') -ne 1) { throw 'field v is not 1' }
$timestampText = Assert-String $root 't'
$timestamp = [DateTimeOffset]::MinValue
if (-not [DateTimeOffset]::TryParseExact(
$timestampText,
'O',
[Globalization.CultureInfo]::InvariantCulture,
[Globalization.DateTimeStyles]::RoundtripKind,
[ref]$timestamp) -or $timestamp.Offset -ne [TimeSpan]::Zero) {
throw 'field t is not an exact UTC round-trip timestamp'
}
if ($timestamp -lt $previousTimestamp) {
throw 'timestamp order moved backwards'
}
$previousTimestamp = $timestamp
$lineSessionId = Assert-String $root 'sessionId'
if ($null -eq $sessionId) { $sessionId = $lineSessionId }
if ($lineSessionId -cne $sessionId) { throw 'sessionId changed within the stream' }
if (-not [string]::IsNullOrWhiteSpace($ExpectedSessionId) -and
$lineSessionId -cne $ExpectedSessionId) {
throw 'sessionId does not match -ExpectedSessionId'
}
if ($terminalSeen) { throw 'an event appears after terminal exited' }
switch ($eventName) {
'characterList' {
$null = Assert-String $root 'accountName' $true
$slotCount = Assert-Int32 $root 'slotCount'
if ($slotCount -lt 0) { throw 'slotCount is negative' }
$characters = $root.GetProperty('characters')
if ($characters.ValueKind -ne [Text.Json.JsonValueKind]::Array) {
throw 'characters is not an array'
}
foreach ($character in $characters.EnumerateArray()) {
if ($character.ValueKind -ne [Text.Json.JsonValueKind]::Object) {
throw 'a character is not an object'
}
$characterNames = @((Get-Properties $character) | ForEach-Object { $_.Name })
$characterExpected = @('id', 'name', 'secondsGreyedOut')
if ([string]::Join("`n", $characterNames) -cne
[string]::Join("`n", $characterExpected)) {
throw 'a character fields/order is not id,name,secondsGreyedOut'
}
$null = Assert-UInt32 $character 'id'
$null = Assert-String $character 'name'
$null = Assert-UInt32 $character 'secondsGreyedOut'
}
}
'enteredWorld' {
$null = Assert-UInt32 $root 'characterId'
$null = Assert-String $root 'characterName'
}
'pluginLoaded' {
$loadedPlugins.Add((Assert-String $root 'plugin'))
}
'pluginFailed' {
$null = Assert-String $root 'plugin'
$null = Assert-String $root 'error'
if (-not $AllowPluginFailure) { throw 'pluginFailed is not allowed for this row' }
}
'loginCommandFailed' {
if ((Assert-Int32 $root 'commandIndex') -lt 0) {
throw 'commandIndex is negative'
}
$null = Assert-String $root 'command' $true
$null = Assert-String $root 'error'
if (-not $AllowLoginCommandFailure) {
throw 'loginCommandFailed is not allowed for this row'
}
}
'disconnected' { $null = Assert-String $root 'reason' }
'exited' {
$code = Assert-Int32 $root 'code'
$reason = Assert-String $root 'reason'
if ($code -ne 0) { throw "terminal exit code is $code, expected 0" }
$expectedReason = if ($Mode -eq 'probe') { 'probe' } else { 'graceful' }
if ($reason -cne $expectedReason) {
throw "terminal reason is '$reason', expected '$expectedReason'"
}
$terminalSeen = $true
}
}
$eventNames.Add($eventName)
}
catch {
$failures.Add("line ${lineNumber}: $($_.Exception.Message)")
}
finally { if ($null -ne $document) { $document.Dispose() } }
}
function Require-Count([string]$EventName, [int]$Count) {
$actual = @($eventNames | Where-Object { $_ -ceq $EventName }).Count
if ($actual -ne $Count) {
$failures.Add("event '$EventName' count is $actual, expected $Count")
}
}
function First-Index([string]$EventName) {
for ($index = 0; $index -lt $eventNames.Count; $index++) {
if ($eventNames[$index] -ceq $EventName) { return $index }
}
return -1
}
Require-Count 'started' 1
Require-Count 'connected' 1
Require-Count 'characterList' 1
Require-Count 'disconnected' 1
Require-Count 'exited' 1
$expectEnteredWorld = $Mode -ne 'probe' -and -not $ExpectNoEnteredWorld
Require-Count 'enteredWorld' $(if ($expectEnteredWorld) { 1 } else { 0 })
if ($eventNames.Count -gt 0 -and $eventNames[0] -cne 'started') {
$failures.Add('started is not the first event')
}
if ($eventNames.Count -gt 0 -and $eventNames[-1] -cne 'exited') {
$failures.Add('exited is not the final event')
}
$orderedRequired = if (-not $expectEnteredWorld) {
@('started', 'connected', 'characterList', 'disconnected', 'exited')
} else {
@('started', 'connected', 'characterList', 'enteredWorld', 'disconnected', 'exited')
}
$last = -1
foreach ($name in $orderedRequired) {
$next = First-Index $name
if ($next -ge 0 -and $next -le $last) {
$failures.Add("event '$name' is out of lifecycle order")
}
$last = $next
}
$connectedIndex = First-Index 'connected'
foreach ($index in 0..([Math]::Max(0, $eventNames.Count - 1))) {
if ($eventNames.Count -eq 0) { break }
if ($eventNames[$index] -in @('pluginLoaded', 'pluginFailed') -and
($index -le 0 -or $index -ge $connectedIndex)) {
$failures.Add("plugin event at index $index is outside started-to-connected startup")
}
}
foreach ($plugin in $ExpectedPlugin) {
if (-not ($loadedPlugins -ccontains $plugin)) {
$failures.Add("expected plugin '$plugin' did not emit pluginLoaded")
}
}
$expectedPluginSet = @($ExpectedPlugin | Sort-Object -Unique)
$loadedPluginSet = @($loadedPlugins | Sort-Object -Unique)
if ($loadedPlugins.Count -ne $loadedPluginSet.Count) {
$failures.Add('a plugin emitted pluginLoaded more than once')
}
if ([string]::Join("`n", $loadedPluginSet) -cne
[string]::Join("`n", $expectedPluginSet)) {
$failures.Add(
"loaded plugin set has $($loadedPluginSet.Count) member(s), expected $($expectedPluginSet.Count)")
}
$enteredWorldIndex = First-Index 'enteredWorld'
for ($index = 0; $index -lt $eventNames.Count; $index++) {
if ($eventNames[$index] -ceq 'loginCommandFailed' -and
($enteredWorldIndex -lt 0 -or $index -le $enteredWorldIndex)) {
$failures.Add("loginCommandFailed at index $index did not follow enteredWorld")
}
}
if (-not $AllowLauncherChildren) {
$deadline = [DateTime]::UtcNow.AddSeconds($ProcessExitWaitSeconds)
do {
$children = @(Get-Process -Name @('AcDream.App', 'acdream-headless') -ErrorAction SilentlyContinue)
if ($children.Count -eq 0) { break }
Start-Sleep -Milliseconds 100
} while ([DateTime]::UtcNow -lt $deadline)
if ($children.Count -gt 0) {
$failures.Add(
"launcher child process leak(s): $((@($children | ForEach-Object { $_.ProcessName + ':' + $_.Id })) -join ',')")
}
}
$reportDirectory = Split-Path -Parent $ReportPath
if (-not [string]::IsNullOrEmpty($reportDirectory)) {
$null = New-Item -ItemType Directory -Force -Path $reportDirectory
}
$report = [ordered]@{
schemaVersion = 1
kind = 'campaign-la-session-status-validation'
success = ($failures.Count -eq 0)
mode = $Mode
enteredWorldExpected = $expectEnteredWorld
statusFile = [IO.Path]::GetFileName($StatusFile)
statusSize = (Get-Item -LiteralPath $StatusFile).Length
statusSha256 = (Get-FileHash -LiteralPath $StatusFile -Algorithm SHA256).Hash.ToLowerInvariant()
lineCount = $lines.Count
eventNames = @($eventNames)
loadedPluginCount = $loadedPlugins.Count
terminalObserved = $terminalSeen
launcherChildrenAllowed = [bool]$AllowLauncherChildren
failures = @($failures)
validatedUtc = [DateTime]::UtcNow.ToString('O')
}
$report | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath $ReportPath -Encoding utf8NoBOM
Write-Host "Campaign LA status validation report: $ReportPath"
if (-not $report.success) {
$failures | ForEach-Object { Write-Error $_ }
exit 1
}