acdream/tools/run-connected-render-pack-remote-player-gate.ps1
Erik c51b07ef95 test(gates): sequence the two-client remote-shadow gate with harness signals
Imported from the codex worktree's uncommitted work
(C:/Users/erikn/.codex/worktrees/bd98/acdream on
codex/atmospheric-rendering-campaign), on top of its 8b7b601b.

The remote-player shadow gate ran two clients on fixed sleeps, so the
observer could move before the primary had taken its 'before' screenshot,
or the primary could take its 'after' shot before the observer had moved.
Now the harness publishes named signal files into each client's artifact
directory and the routes block on them:

- IRetailUiAutomationRuntime.TryIsAutomationSignalPublished, implemented
  by WorldLifecycleAutomationController over <artifactDir>/signals/
  <name>.signal (names validated by AutomationArtifactName, so no path
  escape).
- 'wait signal <name> [timeoutMs]' in RetailUiAutomationScriptRunner.
- run-connected-render-pack-remote-player-gate.ps1 publishes
  'primary-before' to the observer after the primary's before-shot
  completes, then 'observer-moved' to the primary after the observer's
  remote-observer-moved checkpoint.
- Both routes teleport with an explicit heading and wait 12 s to settle.

Build green; the three touched App test classes pass 58/58 including the
two new signal tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 17:44:13 +02:00

351 lines
15 KiB
PowerShell

[CmdletBinding()]
param(
[string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path,
[string]$Account = $env:ACDREAM_TEST_USER,
[string]$Password = $env:ACDREAM_TEST_PASS,
[string]$ObserverAccount = $env:ACDREAM_TEST_OBSERVER_USER,
[string]$ObserverPassword = $env:ACDREAM_TEST_OBSERVER_PASS,
[ValidateRange(0, [int]::MaxValue)][int]$PrimaryCharacterIndex = 0,
[ValidateRange(0, [int]::MaxValue)][int]$ObserverCharacterIndex = 0,
[switch]$SkipBuild,
[int]$LoginTimeoutSeconds = 90
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
. (Join-Path $PSScriptRoot 'connected-render-pack-gate-common.ps1')
if ([string]::IsNullOrWhiteSpace($Account)) { $Account = 'testaccount' }
if ([string]::IsNullOrWhiteSpace($Password)) { $Password = 'testpassword' }
if ([string]::IsNullOrWhiteSpace($ObserverAccount)) { $ObserverAccount = $Account }
if ([string]::IsNullOrWhiteSpace($ObserverPassword)) { $ObserverPassword = $Password }
if ([string]::Equals($Account, $ObserverAccount, [StringComparison]::OrdinalIgnoreCase)) {
throw ('Remote-player shadow evidence requires distinct ACE accounts. ' +
'Set ACDREAM_TEST_OBSERVER_USER and ACDREAM_TEST_OBSERVER_PASS.')
}
$startedUtc = [DateTime]::UtcNow.ToString('O')
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$root = Join-Path $Repository `
"artifacts\atmospheric-rendering\connected-remote-player-$stamp"
$observerRoot = Join-Path $root 'observer'
$primaryRoot = Join-Path $root 'primary'
$observerRoute = Join-Path $Repository `
'tools\connected-render-pack-remote-observer.route.txt'
$primaryRoute = Join-Path $Repository `
'tools\connected-render-pack-remote-primary.route.txt'
$exe = Join-Path $Repository `
'src\AcDream.App\bin\Release\net10.0\AcDream.App.exe'
$observerState = $null
$primaryState = $null
$observerConfig = $null
$primaryConfig = $null
$observer = $null
$primary = $null
$binaryIdentity = $null
$result = $null
$failure = $null
function Set-ClientEnvironment {
param(
[Parameter(Mandatory = $true)][object]$State,
[Parameter(Mandatory = $true)][string]$ArtifactDirectory,
[Parameter(Mandatory = $true)][string]$Route,
[Parameter(Mandatory = $true)][string]$SessionAccount,
[Parameter(Mandatory = $true)][string]$SessionPassword
)
$env:ACDREAM_CONFIG_DIR = $State.ConfigDirectory
$env:ACDREAM_DATA_DIR = $State.DataDirectory
$env:ACDREAM_CACHE_DIR = $State.CacheDirectory
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
$env:ACDREAM_LIVE = '1'
$env:ACDREAM_TEST_HOST = '127.0.0.1'
$env:ACDREAM_TEST_PORT = '9000'
$env:ACDREAM_TEST_USER = $SessionAccount
$env:ACDREAM_TEST_PASS = $SessionPassword
$env:ACDREAM_RETAIL_UI = '1'
$env:ACDREAM_DEVTOOLS = '0'
$env:ACDREAM_UI_PROBE_DUMP = '0'
$env:ACDREAM_UI_PROBE_SCRIPT = $Route
$env:ACDREAM_AUTOMATION_ARTIFACT_DIR = $ArtifactDirectory
$env:ACDREAM_NO_AUDIO = '1'
}
function Wait-ForLogPattern {
param(
[Parameter(Mandatory = $true)][Diagnostics.Process]$Client,
[Parameter(Mandatory = $true)][string]$Path,
[Parameter(Mandatory = $true)][string]$Pattern,
[Parameter(Mandatory = $true)][int]$TimeoutSeconds
)
$deadline = [DateTime]::UtcNow.AddSeconds($TimeoutSeconds)
while ([DateTime]::UtcNow -lt $deadline) {
$Client.Refresh()
if ($Client.HasExited) {
throw "client exited with code $($Client.ExitCode) while waiting for '$Pattern'"
}
if (Test-Path -LiteralPath $Path) {
$sessionFailure = Select-String -LiteralPath $Path `
-SimpleMatch 'live: session failed:' | Select-Object -Last 1
if ($null -ne $sessionFailure) {
throw "client session failed while waiting for '$Pattern': $($sessionFailure.Line)"
}
if (Select-String -LiteralPath $Path -SimpleMatch $Pattern -Quiet) {
return
}
}
Start-Sleep -Milliseconds 250
}
throw "timed out after $TimeoutSeconds seconds waiting for '$Pattern'"
}
function Close-ClientGracefully {
param([Parameter(Mandatory = $true)][Diagnostics.Process]$Client)
$Client.Refresh()
if ($Client.HasExited) { return }
if (-not $Client.CloseMainWindow() -or -not $Client.WaitForExit(30000)) {
throw "client $($Client.Id) did not accept graceful close within 30 seconds"
}
$Client.WaitForExit()
}
function Stop-ExactClient {
param([Diagnostics.Process]$Client)
if ($null -eq $Client) { return }
$Client.Refresh()
if (-not $Client.HasExited) {
$closed = $Client.CloseMainWindow()
if (-not $closed -or -not $Client.WaitForExit(10000)) {
Stop-Process -Id $Client.Id -Force -ErrorAction SilentlyContinue
}
}
}
function Publish-ClientSignal {
param(
[Parameter(Mandatory = $true)][string]$ClientRoot,
[Parameter(Mandatory = $true)][string]$Name
)
if ($Name -notmatch '^[A-Za-z0-9_-]{1,80}$') {
throw "invalid automation signal name '$Name'"
}
$signalDirectory = Join-Path $ClientRoot 'signals'
$null = New-Item -ItemType Directory -Force -Path $signalDirectory
Assert-ConnectedGateNoReparsePoint $signalDirectory
Set-Content -Encoding ascii -LiteralPath `
(Join-Path $signalDirectory "$Name.signal") -Value 'published'
}
function Read-StatusEvents {
param([Parameter(Mandatory = $true)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "session status stream is missing: '$Path'"
}
return @(Get-Content -LiteralPath $Path | ForEach-Object {
$_ | ConvertFrom-Json
})
}
function Assert-TerminalStatus {
param([Parameter(Mandatory = $true)][string]$Path)
$events = @(Read-StatusEvents $Path)
foreach ($name in @('started', 'connected', 'characterList', 'enteredWorld',
'disconnected', 'exited')) {
$matching = @($events | Where-Object { $_.e -ceq $name })
if ($matching.Count -ne 1) {
throw "'$Path' expected one '$name' event, observed $($matching.Count)"
}
}
$entered = @($events | Where-Object { $_.e -ceq 'enteredWorld' })[0]
$exited = @($events | Where-Object { $_.e -ceq 'exited' })[0]
if ([int]$exited.code -ne 0) {
throw "'$Path' terminal status code was $($exited.code)"
}
return $entered
}
function Read-RemoteMetadata {
param([Parameter(Mandatory = $true)][string]$Name)
Assert-ConnectedGateSafeLeafName $Name
$path = Join-Path $primaryRoot "screenshots\$Name.metadata.json"
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "remote-player metadata is missing: '$path'"
}
$metadata = Get-Content -Raw -LiteralPath $path | ConvertFrom-Json
$pack = $metadata.RenderPack
$classes = $pack.ShadowTransformChurn.CasterClasses
if ([int]$metadata.SchemaVersion -ne 1 -or [int]$pack.State -ne 2 -or
[string]$pack.PackId -cne 'acdream.atmospheric' -or
[string]$pack.PresetId -cne 'medium') {
throw "'$Name' did not capture active Atmospheric Medium metadata"
}
if (-not [bool]$pack.Outdoor -or [double]$pack.DirectionalShadowStrength -le 0) {
throw "'$Name' did not capture an active outdoor directional-shadow frame"
}
if ([int]$classes.LocalPlayers -ne 1 -or [int]$classes.RemotePlayers -lt 1) {
throw "'$Name' caster classes were local=$($classes.LocalPlayers), remote=$($classes.RemotePlayers)"
}
if ([int]$pack.ShadowCasterCount -le 0 -or [int]$pack.CascadeDrawCount -ne 3) {
throw "'$Name' did not record complete Medium shadow work"
}
return [pscustomobject][ordered]@{
Name = $Name
Path = $path
PackId = $pack.PackId
PresetId = $pack.PresetId
ShadowCasterCount = $pack.ShadowCasterCount
CascadeDrawCount = $pack.CascadeDrawCount
LocalPlayers = $classes.LocalPlayers
RemotePlayers = $classes.RemotePlayers
NonPlayerCreatures = $classes.NonPlayerCreatures
EquippedChildren = $classes.EquippedChildren
DirectionalShadowSourceKind = $pack.DirectionalShadowSourceKind
DirectionalShadowStrength = $pack.DirectionalShadowStrength
RemoteRootChanges = $pack.ShadowTransformChurn.LiveDynamicRootChanges
SharedWorldTransformUsedInstances = $pack.SharedWorldTransformUsedInstances
}
}
if (@(Get-Process -Name AcDream.App -ErrorAction SilentlyContinue).Count -gt 0) {
throw 'an AcDream.App client is already running; close it gracefully before the two-client gate'
}
if (@(Get-NetUDPEndpoint -LocalPort 9000 -ErrorAction SilentlyContinue).Count -eq 0) {
throw 'local ACE is not listening on UDP port 9000'
}
foreach ($required in @($observerRoute, $primaryRoute)) {
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
throw "route file not found: '$required'"
}
}
if (-not $SkipBuild) {
Write-Output 'Building Release solution...'
& dotnet build (Join-Path $Repository 'AcDream.slnx') -c Release --no-restore
if ($LASTEXITCODE -ne 0) { throw "Release build failed with exit code $LASTEXITCODE" }
}
if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) {
throw "client executable not found: '$exe'"
}
$null = New-Item -ItemType Directory -Path $observerRoot, $primaryRoot
foreach ($path in @($root, $observerRoot, $primaryRoot)) {
Assert-ConnectedGateNoReparsePoint $path
}
$observerState = New-ConnectedRenderPackGateState -Root $observerRoot -Preset medium
$primaryState = New-ConnectedRenderPackGateState -Root $primaryRoot -Preset medium
try {
$binaryIdentity = Get-ConnectedGateBinaryIdentity `
-Repository $Repository -Executable $exe -SkipBuild:$SkipBuild
$observerStatus = Join-Path $observerState.StateDirectory 'status\observer.jsonl'
$primaryStatus = Join-Path $primaryState.StateDirectory 'status\primary.jsonl'
$observerConfig = New-ConnectedGraphicalSessionConfig `
-State $observerState -Account $ObserverAccount `
-CharacterIndex $ObserverCharacterIndex -Plugins @() `
-StatusFile $observerStatus
$primaryConfig = New-ConnectedGraphicalSessionConfig `
-State $primaryState -Account $Account `
-CharacterIndex $PrimaryCharacterIndex -Plugins @() `
-StatusFile $primaryStatus
$observerStdout = Join-Path $observerRoot 'client.stdout.log'
$observerStderr = Join-Path $observerRoot 'client.stderr.log'
Set-ClientEnvironment $observerState $observerRoot $observerRoute `
$ObserverAccount $ObserverPassword
$observer = Start-Process -FilePath $exe -WorkingDirectory $Repository `
-ArgumentList @('--session-config', ('"' + $observerConfig + '"')) `
-RedirectStandardOutput $observerStdout `
-RedirectStandardError $observerStderr -PassThru
Wait-ForLogPattern $observer $observerStdout 'live: in world' $LoginTimeoutSeconds
Wait-ForLogPattern $observer $observerStdout `
'[world-gate] checkpoint name=remote-observer-ready' 120
$primaryStdout = Join-Path $primaryRoot 'client.stdout.log'
$primaryStderr = Join-Path $primaryRoot 'client.stderr.log'
Set-ClientEnvironment $primaryState $primaryRoot $primaryRoute `
$Account $Password
$primary = Start-Process -FilePath $exe -WorkingDirectory $Repository `
-ArgumentList @('--session-config', ('"' + $primaryConfig + '"')) `
-RedirectStandardOutput $primaryStdout `
-RedirectStandardError $primaryStderr -PassThru
Wait-ForLogPattern $primary $primaryStdout 'live: in world' $LoginTimeoutSeconds
Wait-ForLogPattern $primary $primaryStdout `
'[world-gate] screenshot-complete name=remote-player-before' 120
Publish-ClientSignal $observerRoot 'primary-before'
Wait-ForLogPattern $observer $observerStdout `
'[world-gate] checkpoint name=remote-observer-moved' 60
Publish-ClientSignal $primaryRoot 'observer-moved'
Wait-ForLogPattern $primary $primaryStdout `
'[world-gate] screenshot-complete name=remote-player-after' 120
Wait-ForLogPattern $primary $primaryStdout `
'[world-gate] checkpoint name=remote-primary' 30
Close-ClientGracefully $primary
Close-ClientGracefully $observer
if ($primary.ExitCode -ne 0 -or $observer.ExitCode -ne 0) {
throw "client exit codes were primary=$($primary.ExitCode), observer=$($observer.ExitCode)"
}
$primaryEntered = Assert-TerminalStatus $primaryStatus
$observerEntered = Assert-TerminalStatus $observerStatus
if ([uint32]$primaryEntered.characterId -eq [uint32]$observerEntered.characterId) {
throw 'both clients entered world with the same character identity'
}
$before = Read-RemoteMetadata 'remote-player-before'
$after = Read-RemoteMetadata 'remote-player-after'
$result = [pscustomobject][ordered]@{
Passed = $true
PrimaryProcessId = $primary.Id
ObserverProcessId = $observer.Id
PrimaryCharacterId = $primaryEntered.characterId
ObserverCharacterId = $observerEntered.characterId
PrimaryExitCode = $primary.ExitCode
ObserverExitCode = $observer.ExitCode
Before = $before
After = $after
}
Write-Output (
"PASS remote-player casters before=$($before.RemotePlayers) " +
"after=$($after.RemotePlayers) primary=0x$('{0:X8}' -f [uint32]$primaryEntered.characterId) " +
"observer=0x$('{0:X8}' -f [uint32]$observerEntered.characterId)")
}
catch {
$failure = $_.Exception.ToString()
}
finally {
Stop-ExactClient $primary
Stop-ExactClient $observer
if ($null -ne $primary) { $primary.Dispose() }
if ($null -ne $observer) { $observer.Dispose() }
if ($null -ne $primaryConfig -and $null -ne $primaryState) {
Remove-ConnectedGraphicalSessionConfig -State $primaryState -Path $primaryConfig
}
if ($null -ne $observerConfig -and $null -ne $observerState) {
Remove-ConnectedGraphicalSessionConfig -State $observerState -Path $observerConfig
}
if ($null -ne $primaryState) {
Restore-ConnectedRenderPackGateEnvironment $primaryState
}
if ($null -ne $observerState) {
Restore-ConnectedRenderPackGateEnvironment $observerState
}
$report = [pscustomobject][ordered]@{
Passed = $null -ne $result -and [string]::IsNullOrWhiteSpace($failure)
StartedUtc = $startedUtc
FinishedUtc = [DateTime]::UtcNow.ToString('O')
SourceCommit = if ($null -eq $binaryIdentity) { $null } else { $binaryIdentity.SourceCommit }
BinaryCommit = if ($null -eq $binaryIdentity) { $null } else { $binaryIdentity.BinaryCommit }
BinaryMatchesSource = if ($null -eq $binaryIdentity) { $false } else { $binaryIdentity.BinaryMatchesSource }
TrackedSourceStatus = if ($null -eq $binaryIdentity) { @() } else { @($binaryIdentity.SourceTrackedStatus) }
PrimaryCharacterIndex = $PrimaryCharacterIndex
ObserverCharacterIndex = $ObserverCharacterIndex
DistinctAccountsConfigured = $true
Result = $result
Failure = $failure
}
$reportPath = Join-Path $root 'report.json'
$report | ConvertTo-Json -Depth 10 |
Set-Content -Encoding utf8 -LiteralPath $reportPath
Write-Output "REPORT=$reportPath"
Write-Output "RESULT=$(if ($report.Passed) { 'PASS' } else { 'FAIL' })"
}
if (-not [string]::IsNullOrWhiteSpace($failure)) { throw $failure }
if ($null -eq $result) { throw 'remote-player gate produced no result' }