diff --git a/tests/AcDream.App.Tests/Diagnostics/ConnectedRenderPackGateContractTests.cs b/tests/AcDream.App.Tests/Diagnostics/ConnectedRenderPackGateContractTests.cs index 035b55c2..fdc8ccbd 100644 --- a/tests/AcDream.App.Tests/Diagnostics/ConnectedRenderPackGateContractTests.cs +++ b/tests/AcDream.App.Tests/Diagnostics/ConnectedRenderPackGateContractTests.cs @@ -12,6 +12,8 @@ public sealed class ConnectedRenderPackGateContractTests private const string SoakScript = "run-connected-r6-soak.ps1"; private const string PackageLifecycleScript = "run-connected-render-pack-package-lifecycle.ps1"; + private const string RemotePlayerScript = + "run-connected-render-pack-remote-player-gate.ps1"; [Fact] public void ScreenshotJsonCarriesAuthoritativeCasterClassCounters() @@ -233,6 +235,40 @@ Remove-ConnectedGraphicalSessionConfig -State $state -Path $sessionConfigPath "checkpoint package-lifecycle"); } + [Fact] + public void ConnectedRemotePlayerGateUsesDistinctCharactersAndRequiresCasterEvidence() + { + string source = ReadTool(RemotePlayerScript); + Assert.Contains("PrimaryCharacterIndex = 0", source, StringComparison.Ordinal); + Assert.Contains("ObserverCharacterIndex = 1", source, StringComparison.Ordinal); + Assert.Contains("Primary and observer character indexes must differ", source, StringComparison.Ordinal); + Assert.Contains("Get-ConnectedGateBinaryIdentity", source, StringComparison.Ordinal); + Assert.Contains("-CharacterIndex $ObserverCharacterIndex", source, StringComparison.Ordinal); + Assert.Contains("-CharacterIndex $PrimaryCharacterIndex", source, StringComparison.Ordinal); + Assert.Contains("[int]$classes.RemotePlayers -lt 1", source, StringComparison.Ordinal); + Assert.Contains("both clients entered world with the same character identity", source, StringComparison.Ordinal); + Assert.Contains("Restore-ConnectedRenderPackGateEnvironment $primaryState", source, StringComparison.Ordinal); + Assert.Contains("Restore-ConnectedRenderPackGateEnvironment $observerState", source, StringComparison.Ordinal); + + string observer = ReadTool("connected-render-pack-remote-observer.route.txt"); + AssertAppearsInOrder( + observer, + "command /teleloc 0x09040008", + "wait materialized 1 90000", + "input down MovementForward", + "input up MovementForward", + "checkpoint remote-observer"); + + string primary = ReadTool("connected-render-pack-remote-primary.route.txt"); + AssertAppearsInOrder( + primary, + "command /teleloc 0x09040008", + "wait materialized 1 90000", + "screenshot remote-player-before 15000", + "screenshot remote-player-after 15000", + "checkpoint remote-primary"); + } + [Fact] public void EveryConnectedScreenshotMustProveExactFailureFreeActivation() { diff --git a/tools/connected-render-pack-remote-observer.route.txt b/tools/connected-render-pack-remote-observer.route.txt new file mode 100644 index 00000000..087883d0 --- /dev/null +++ b/tools/connected-render-pack-remote-observer.route.txt @@ -0,0 +1,14 @@ +# Secondary character for the connected remote-player shadow gate. It remains +# in the same outdoor receiver scene as the primary and crosses several metres +# while the primary captures before/after metadata. + +wait world-ready 90000 +wait world-visible 30000 +command /teleloc 0x09040008 14.4 188.6 87.705 +wait materialized 1 90000 +sleep 6000 +input down MovementForward +sleep 6000 +input up MovementForward +checkpoint remote-observer +sleep 30000 diff --git a/tools/connected-render-pack-remote-primary.route.txt b/tools/connected-render-pack-remote-primary.route.txt new file mode 100644 index 00000000..f3f55e12 --- /dev/null +++ b/tools/connected-render-pack-remote-primary.route.txt @@ -0,0 +1,13 @@ +# Primary character for the connected remote-player shadow gate. The observer +# is already materialized before this process starts. + +wait world-ready 90000 +wait world-visible 30000 +command /teleloc 0x09040008 11.4 188.6 87.705 +wait materialized 1 90000 +sleep 4000 +screenshot remote-player-before 15000 +sleep 6000 +screenshot remote-player-after 15000 +checkpoint remote-primary +sleep 1000 diff --git a/tools/run-connected-render-pack-remote-player-gate.ps1 b/tools/run-connected-render-pack-remote-player-gate.ps1 new file mode 100644 index 00000000..0db7562e --- /dev/null +++ b/tools/run-connected-render-pack-remote-player-gate.ps1 @@ -0,0 +1,313 @@ +[CmdletBinding()] +param( + [string]$Repository = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path, + [string]$Account = $env:ACDREAM_TEST_USER, + [string]$Password = $env:ACDREAM_TEST_PASS, + [ValidateRange(0, [int]::MaxValue)][int]$PrimaryCharacterIndex = 0, + [ValidateRange(0, [int]::MaxValue)][int]$ObserverCharacterIndex = 1, + [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 ($PrimaryCharacterIndex -eq $ObserverCharacterIndex) { + throw 'Primary and observer character indexes must differ.' +} + +$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 + ) + $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 = $Account + $env:ACDREAM_TEST_PASS = $Password + $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) -and + (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 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 $Account ` + -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 + $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 'live: teleport materialized' 90 + + $primaryStdout = Join-Path $primaryRoot 'client.stdout.log' + $primaryStderr = Join-Path $primaryRoot 'client.stderr.log' + Set-ClientEnvironment $primaryState $primaryRoot $primaryRoute + $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-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 + 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' }