Add persistent HTTP journey live-fire harness
This commit is contained in:
parent
c88f8c5234
commit
742838e590
1 changed files with 346 additions and 0 deletions
346
tools/run_ghostlight_http_journey.ps1
Normal file
346
tools/run_ghostlight_http_journey.ps1
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$RunRoot = (Join-Path 'F:\GameCult\GhostlightDungeon\acceptance' ("http-journey-{0}-{1}" -f [DateTimeOffset]::UtcNow.ToString('yyyyMMdd-HHmmss'), [guid]::NewGuid().ToString('N'))),
|
||||
[string]$SourceRoot = 'F:\Projects\Ghostlight',
|
||||
[ValidateRange(1024, 65535)]
|
||||
[int]$Port = 8841,
|
||||
[ValidateRange(30, 600)]
|
||||
[int]$RequestTimeoutSeconds = 240
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$acceptanceRoot = [IO.Path]::GetFullPath('F:\GameCult\GhostlightDungeon\acceptance')
|
||||
$resolvedRoot = [IO.Path]::GetFullPath($RunRoot)
|
||||
if (-not $resolvedRoot.StartsWith($acceptanceRoot + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) {
|
||||
throw "HTTP journey output must remain below $acceptanceRoot"
|
||||
}
|
||||
if (Test-Path -LiteralPath $resolvedRoot) {
|
||||
throw "HTTP journey output already exists: $resolvedRoot"
|
||||
}
|
||||
$binary = Join-Path $SourceRoot 'target\debug\ghostlight-dungeon.exe'
|
||||
$secret = 'F:\GameCult\GhostlightDungeon\secrets\deepseek.dpapi'
|
||||
foreach ($required in @($binary, $secret)) {
|
||||
if (-not (Test-Path -LiteralPath $required -PathType Leaf)) {
|
||||
throw "Required journey input is absent: $required"
|
||||
}
|
||||
}
|
||||
|
||||
$listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, $Port)
|
||||
try {
|
||||
$listener.Start()
|
||||
} catch {
|
||||
throw "Loopback port $Port is already occupied"
|
||||
} finally {
|
||||
$listener.Stop()
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $resolvedRoot, (Join-Path $resolvedRoot 'secrets'), (Join-Path $resolvedRoot 'boundary') | Out-Null
|
||||
Copy-Item -LiteralPath $secret -Destination (Join-Path $resolvedRoot 'secrets\deepseek.dpapi')
|
||||
$baseUri = "http://127.0.0.1:$Port"
|
||||
$tokens = 1..2 | ForEach-Object {
|
||||
$bytes = [byte[]]::new(32)
|
||||
[Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
|
||||
try {
|
||||
[Convert]::ToBase64String($bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
|
||||
} finally {
|
||||
[Array]::Clear($bytes, 0, $bytes.Length)
|
||||
}
|
||||
}
|
||||
$inviteMaterial = $tokens -join ','
|
||||
$daemonGeneration = 0
|
||||
|
||||
function Save-Json([string]$Name, $Value) {
|
||||
$path = Join-Path $resolvedRoot "boundary\$Name.json"
|
||||
[IO.File]::WriteAllText($path, ($Value | ConvertTo-Json -Depth 100))
|
||||
$path
|
||||
}
|
||||
|
||||
function Start-JourneyDaemon {
|
||||
$script:daemonGeneration++
|
||||
$stdout = Join-Path $resolvedRoot ("daemon-{0}.stdout.log" -f $script:daemonGeneration)
|
||||
$stderr = Join-Path $resolvedRoot ("daemon-{0}.stderr.log" -f $script:daemonGeneration)
|
||||
$psi = [Diagnostics.ProcessStartInfo]::new()
|
||||
$psi.FileName = $binary
|
||||
$psi.WorkingDirectory = $SourceRoot
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.Environment['GHOSTLIGHT_DUNGEON_ROOT'] = $resolvedRoot
|
||||
$psi.Environment['GHOSTLIGHT_DUNGEON_BIND'] = "127.0.0.1:$Port"
|
||||
$psi.Environment['GHOSTLIGHT_INVITES'] = $inviteMaterial
|
||||
$process = [Diagnostics.Process]::Start($psi)
|
||||
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderrTask = $process.StandardError.ReadToEndAsync()
|
||||
[pscustomobject]@{
|
||||
Process = $process
|
||||
StdoutTask = $stdoutTask
|
||||
StderrTask = $stderrTask
|
||||
Stdout = $stdout
|
||||
Stderr = $stderr
|
||||
}
|
||||
}
|
||||
|
||||
function Stop-JourneyDaemon($Daemon) {
|
||||
if (-not $Daemon.Process.HasExited) {
|
||||
$Daemon.Process.Kill($true)
|
||||
$Daemon.Process.WaitForExit(10000)
|
||||
}
|
||||
[IO.File]::WriteAllText($Daemon.Stdout, $Daemon.StdoutTask.GetAwaiter().GetResult())
|
||||
[IO.File]::WriteAllText($Daemon.Stderr, $Daemon.StderrTask.GetAwaiter().GetResult())
|
||||
}
|
||||
|
||||
function Wait-JourneyHealth($Daemon) {
|
||||
$deadline = [DateTimeOffset]::UtcNow.AddSeconds($RequestTimeoutSeconds)
|
||||
do {
|
||||
if ($Daemon.Process.HasExited) {
|
||||
Stop-JourneyDaemon $Daemon
|
||||
throw "Journey daemon exited during startup; inspect $($Daemon.Stderr)"
|
||||
}
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri "$baseUri/health" -TimeoutSec 5
|
||||
if ($response.StatusCode -eq 200) {
|
||||
return $response.Content | ConvertFrom-Json -Depth 100
|
||||
}
|
||||
} catch {
|
||||
Start-Sleep -Milliseconds 250
|
||||
}
|
||||
} while ([DateTimeOffset]::UtcNow -lt $deadline)
|
||||
throw "Journey daemon did not become healthy within $RequestTimeoutSeconds seconds"
|
||||
}
|
||||
|
||||
function Invoke-JourneyRequest {
|
||||
param(
|
||||
[Parameter(Mandatory)][ValidateSet('GET', 'POST')][string]$Method,
|
||||
[Parameter(Mandatory)][string]$Path,
|
||||
[Microsoft.PowerShell.Commands.WebRequestSession]$Session,
|
||||
$Body,
|
||||
[string]$OutFile
|
||||
)
|
||||
$parameters = @{
|
||||
Uri = "$baseUri$Path"
|
||||
Method = $Method
|
||||
TimeoutSec = $RequestTimeoutSeconds
|
||||
}
|
||||
if ($Session) { $parameters.WebSession = $Session }
|
||||
if ($null -ne $Body) {
|
||||
$parameters.ContentType = 'application/json'
|
||||
$parameters.Body = $Body | ConvertTo-Json -Depth 100 -Compress
|
||||
}
|
||||
if ($OutFile) { $parameters.OutFile = $OutFile }
|
||||
$response = Invoke-WebRequest @parameters
|
||||
if ($OutFile) { return $response }
|
||||
if ([string]::IsNullOrWhiteSpace($response.Content)) { return $null }
|
||||
if ($response.Headers.'Content-Type' -like '*json*') {
|
||||
return $response.Content | ConvertFrom-Json -Depth 100
|
||||
}
|
||||
$response.Content
|
||||
}
|
||||
|
||||
function Get-SelectedCampaign($Session) {
|
||||
$campaigns = Invoke-JourneyRequest -Method GET -Path '/api/campaigns' -Session $Session
|
||||
$selected = @($campaigns.campaigns | Where-Object selected)
|
||||
if ($selected.Count -ne 1) {
|
||||
throw "Expected one selected campaign, found $($selected.Count)"
|
||||
}
|
||||
$selected[0]
|
||||
}
|
||||
|
||||
function Find-EveNode($Node, [string]$Id) {
|
||||
if ($null -eq $Node) { return $null }
|
||||
if ($Node.id -eq $Id) { return $Node }
|
||||
foreach ($child in @($Node.children)) {
|
||||
$found = Find-EveNode $child $Id
|
||||
if ($found) { return $found }
|
||||
}
|
||||
$null
|
||||
}
|
||||
|
||||
$daemon = $null
|
||||
try {
|
||||
$daemon = Start-JourneyDaemon
|
||||
$healthBefore = Wait-JourneyHealth $daemon
|
||||
Save-Json 'health-before' $healthBefore | Out-Null
|
||||
|
||||
$unauthorized = Invoke-WebRequest -Uri "$baseUri/api/surface" -SkipHttpErrorCheck
|
||||
if ($unauthorized.StatusCode -ne 401) {
|
||||
throw "Unauthenticated surface returned $($unauthorized.StatusCode), expected 401"
|
||||
}
|
||||
|
||||
$testerOne = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||
$testerTwo = [Microsoft.PowerShell.Commands.WebRequestSession]::new()
|
||||
Invoke-WebRequest -Uri "$baseUri/invite/$($tokens[0])" -WebSession $testerOne | Out-Null
|
||||
Invoke-WebRequest -Uri "$baseUri/invite/$($tokens[1])" -WebSession $testerTwo | Out-Null
|
||||
$consumed = Invoke-WebRequest -Uri "$baseUri/invite/$($tokens[0])" -SkipHttpErrorCheck
|
||||
if ($consumed.StatusCode -ne 401) {
|
||||
throw "Consumed invite returned $($consumed.StatusCode), expected 401"
|
||||
}
|
||||
$testerTwoInitial = Invoke-JourneyRequest -Method GET -Path '/api/campaigns' -Session $testerTwo
|
||||
if (@($testerTwoInitial.campaigns).Count -ne 0) {
|
||||
throw 'Second tester inherited another session campaign'
|
||||
}
|
||||
|
||||
$compile = Invoke-JourneyRequest -Method POST -Path '/api/compiler/custom' -Session $testerOne -Body @{
|
||||
campaign_name = 'The Embargo Ledger'
|
||||
who = 'a junior Lucent maintenance runner trained to visually inspect public equipment, carrying no tools and holding no institutional authority'
|
||||
where_ = 'a Lucent newsroom during an evidence embargo'
|
||||
when = 'the narrowest era the retrieved Aetheria evidence can honestly support'
|
||||
goal = 'verify one publicly observable inconsistency without taking custody of a source or inventing authority'
|
||||
}
|
||||
Save-Json 'compile-preview' $compile | Out-Null
|
||||
if (-not $compile.preview_id -or $compile.preview.locations.Count -lt 1) {
|
||||
throw 'Compiler returned no approvable bounded world'
|
||||
}
|
||||
$approve = Invoke-JourneyRequest -Method POST -Path "/api/compiler/approve/$($compile.preview_id)" -Session $testerOne
|
||||
Save-Json 'approve' $approve | Out-Null
|
||||
$original = Get-SelectedCampaign $testerOne
|
||||
|
||||
$forbidden = Invoke-WebRequest -Uri "$baseUri/api/campaigns/select/$($original.id)" -Method POST -WebSession $testerTwo -SkipHttpErrorCheck
|
||||
if ($forbidden.StatusCode -ne 403) {
|
||||
throw "Second tester selected the first tester's campaign with status $($forbidden.StatusCode)"
|
||||
}
|
||||
|
||||
$surfaceBefore = Invoke-JourneyRequest -Method GET -Path '/api/surface' -Session $testerOne
|
||||
Save-Json 'surface-before-turn' $surfaceBefore | Out-Null
|
||||
$speak = Invoke-JourneyRequest -Method POST -Path '/api/command' -Session $testerOne -Body @{
|
||||
type = 'speak'
|
||||
expected_revision = [uint64]$surfaceBefore.world_revision
|
||||
actor_id = 'player'
|
||||
text = 'I set my empty hands where everyone can see them. I am not asking for trust. Which part of this embargo can a runner verify without taking custody of your sources?'
|
||||
intended_effect = 'invite a bounded answer without compelling disclosure or claiming authority'
|
||||
}
|
||||
Save-Json 'speak' $speak | Out-Null
|
||||
|
||||
$afterSpeak = Get-SelectedCampaign $testerOne
|
||||
$impossible = Invoke-JourneyRequest -Method POST -Path '/api/command' -Session $testerOne -Body @{
|
||||
type = 'assess'
|
||||
expected_revision = [uint64]$afterSpeak.revision
|
||||
intent = @{
|
||||
actor_id = 'player'
|
||||
description = 'Teleport into a nonexistent sealed archive and declare every Lucent source legally mine.'
|
||||
intended_effect = 'gain remote access, custody, ownership, and institutional authority immediately'
|
||||
}
|
||||
}
|
||||
Save-Json 'assessment-impossible' $impossible | Out-Null
|
||||
if ($impossible.kind -ne 'assessed' -or $impossible.assessment.admissible) {
|
||||
throw 'Impossible overreach was admitted to a roll'
|
||||
}
|
||||
$afterImpossible = Get-SelectedCampaign $testerOne
|
||||
if ($afterImpossible.revision -ne $afterSpeak.revision) {
|
||||
throw 'Private assessment changed campaign revision or became visible world state'
|
||||
}
|
||||
|
||||
$surfaceForAction = Invoke-JourneyRequest -Method GET -Path '/api/surface' -Session $testerOne
|
||||
$ledger = Find-EveNode $surfaceForAction.surface.root 'dungeon.ledger.text'
|
||||
$capabilityLine = ([string]$ledger.props.value -split "`n" | Where-Object { $_ -like 'Capabilities:*' } | Select-Object -First 1)
|
||||
$capability = ($capabilityLine -replace '^Capabilities:\s*', '' -split ',' | Select-Object -First 1).Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($capability) -or $capability -eq 'none') {
|
||||
throw 'Compiled player role exposed no capability for the bounded action test'
|
||||
}
|
||||
$plausible = Invoke-JourneyRequest -Method POST -Path '/api/command' -Session $testerOne -Body @{
|
||||
type = 'assess'
|
||||
expected_revision = [uint64]$afterImpossible.revision
|
||||
intent = @{
|
||||
actor_id = 'player'
|
||||
description = "Use my existing capability exactly as recorded: $capability. I act only from my current position and touch nothing I do not possess."
|
||||
intended_effect = 'produce the smallest directly observable result that this existing capability, current location, and current access permit'
|
||||
}
|
||||
}
|
||||
Save-Json 'assessment-plausible' $plausible | Out-Null
|
||||
if ($plausible.kind -ne 'assessed' -or -not $plausible.assessment.admissible) {
|
||||
throw 'A bounded use of an existing capability was not admitted to a roll'
|
||||
}
|
||||
$attempt = Invoke-JourneyRequest -Method POST -Path '/api/command' -Session $testerOne -Body @{
|
||||
type = 'attempt'
|
||||
assessment_digest = $plausible.assessment.digest
|
||||
}
|
||||
Save-Json 'attempt' $attempt | Out-Null
|
||||
if ($attempt.kind -ne 'committed' -or -not $attempt.receipt.roll) {
|
||||
throw 'Confirmed assessment did not atomically commit a server roll'
|
||||
}
|
||||
|
||||
$afterAttempt = Get-SelectedCampaign $testerOne
|
||||
$wait = Invoke-JourneyRequest -Method POST -Path '/api/command' -Session $testerOne -Body @{
|
||||
type = 'wait'
|
||||
expected_revision = [uint64]$afterAttempt.revision
|
||||
minutes = 60
|
||||
}
|
||||
Save-Json 'wait' $wait | Out-Null
|
||||
$beforeFork = Get-SelectedCampaign $testerOne
|
||||
$surfaceAfter = Invoke-JourneyRequest -Method GET -Path '/api/surface' -Session $testerOne
|
||||
Save-Json 'surface-after-turns' $surfaceAfter | Out-Null
|
||||
|
||||
$fork = Invoke-JourneyRequest -Method POST -Path '/api/campaigns/fork' -Session $testerOne -Body @{ name = 'The Embargo Ledger — Fork' }
|
||||
Save-Json 'fork' $fork | Out-Null
|
||||
if ($fork.kind -ne 'forked' -or $fork.revision -ne 0) {
|
||||
throw 'Campaign fork did not begin as an isolated revision-zero branch'
|
||||
}
|
||||
$exportPath = Join-Path $resolvedRoot 'fork-export.cc'
|
||||
Invoke-JourneyRequest -Method GET -Path '/api/campaigns/export' -Session $testerOne -OutFile $exportPath | Out-Null
|
||||
if ((Get-Item -LiteralPath $exportPath).Length -le 0) {
|
||||
throw 'Campaign export is empty'
|
||||
}
|
||||
|
||||
Stop-JourneyDaemon $daemon
|
||||
$daemon = Start-JourneyDaemon
|
||||
$healthAfter = Wait-JourneyHealth $daemon
|
||||
Save-Json 'health-after-restart' $healthAfter | Out-Null
|
||||
$reloadedCampaigns = Invoke-JourneyRequest -Method GET -Path '/api/campaigns' -Session $testerOne
|
||||
Save-Json 'campaigns-after-restart' $reloadedCampaigns | Out-Null
|
||||
if (@($reloadedCampaigns.campaigns).Count -ne 2) {
|
||||
throw 'Daemon restart did not reload both owned campaign branches'
|
||||
}
|
||||
Invoke-JourneyRequest -Method POST -Path "/api/campaigns/select/$($original.id)" -Session $testerOne | Out-Null
|
||||
$reloadedOriginal = Get-SelectedCampaign $testerOne
|
||||
if ($reloadedOriginal.revision -ne $beforeFork.revision) {
|
||||
throw 'Original campaign revision changed across fork or daemon restart'
|
||||
}
|
||||
$reloadedSurface = Invoke-JourneyRequest -Method GET -Path '/api/surface' -Session $testerOne
|
||||
Save-Json 'surface-after-restart' $reloadedSurface | Out-Null
|
||||
$testerTwoAfter = Invoke-JourneyRequest -Method GET -Path '/api/campaigns' -Session $testerTwo
|
||||
if (@($testerTwoAfter.campaigns).Count -ne 0) {
|
||||
throw 'Second tester acquired the first tester campaign after restart'
|
||||
}
|
||||
|
||||
$story = Find-EveNode $reloadedSurface.surface.root 'dungeon.transcript'
|
||||
$narrations = @($story.children | Where-Object id -like 'narration-*')
|
||||
$transcript = @($story.children | Where-Object id -like 'turn-*')
|
||||
$commit = (& git -C $SourceRoot rev-parse HEAD).Trim()
|
||||
$summary = [ordered]@{
|
||||
schema = 'ghostlight.http_journey_smoke.v1'
|
||||
source_commit = $commit
|
||||
production_runtime_touched = $false
|
||||
listener = "127.0.0.1:$Port"
|
||||
startup_health = $healthBefore.status
|
||||
restart_health = $healthAfter.status
|
||||
unauthenticated_status = [int]$unauthorized.StatusCode
|
||||
consumed_invite_status = [int]$consumed.StatusCode
|
||||
cross_session_select_status = [int]$forbidden.StatusCode
|
||||
campaign_id = $original.id
|
||||
campaign_revision_after_journey = [uint64]$reloadedOriginal.revision
|
||||
fork_campaign_id = $fork.campaign_id
|
||||
reloaded_owned_campaigns = @($reloadedCampaigns.campaigns).Count
|
||||
second_tester_campaigns = @($testerTwoAfter.campaigns).Count
|
||||
compiled_locations = @($compile.preview.locations).Count
|
||||
compiled_cast = @($compile.preview.cast).Count + 1
|
||||
compiled_institutions = @($compile.preview.institutions).Count
|
||||
compile_gaps = @($compile.preview.gaps).Count
|
||||
impossible_admissible = [bool]$impossible.assessment.admissible
|
||||
impossible_bargains = @($impossible.assessment.bargains).Count
|
||||
plausible_capability = $capability
|
||||
plausible_dc = [int]$plausible.assessment.dc
|
||||
roll = $attempt.receipt.roll
|
||||
narration_count = $narrations.Count
|
||||
transcript_count = $transcript.Count
|
||||
export_bytes = (Get-Item -LiteralPath $exportPath).Length
|
||||
evidence_paths = Get-ChildItem -LiteralPath (Join-Path $resolvedRoot 'boundary') -File | Select-Object -ExpandProperty FullName
|
||||
}
|
||||
Save-Json 'summary' $summary | Out-Null
|
||||
$summary | ConvertTo-Json -Depth 20
|
||||
} finally {
|
||||
if ($daemon) {
|
||||
Stop-JourneyDaemon $daemon
|
||||
}
|
||||
[Array]::Clear($tokens, 0, $tokens.Length)
|
||||
$inviteMaterial = $null
|
||||
}
|
||||
Loading…
Reference in a new issue