Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/playerbots.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ Focused scenarios often use fixed destinations, controlled monsters, teleports,

Navigation is hierarchical: [`PlayerBotTopology`](../server/src/playerbottopology.cpp) indexes the loaded map into connected local regions and transition edges. The [controller](../server/src/playerbotcontroller.cpp) selects a global route, refines its next segment into tile-level actions, and considers eligible NPC travel connections. Whole-map routing describes its scope, not a guarantee that every destination is reachable.

Hunt departure validates a return route and protects its fare. Patrols reuse return-route evidence across complete, same-floor walking paths with no modeled danger; added danger, transitions, route failures, or changed route inputs require revalidation. Rejected patrol plans enter bounded recovery rather than retrying indefinitely. Depot-bound travel needs enough money for the trip, not for a later potion restock; route safety rules still apply. Supplier travel after reaching the depot retains the restock reserve.

## Knowledge and game boundaries

Bots may use static facts a player could learn and remember: map geography, known spawn areas, quest locations, and loaded NPC services. They must not read hidden live state to bypass game mechanics. In particular, a bot identifies corpses through normal container and ownership metadata, opens them normally, and only then inspects contents.
Expand Down
47 changes: 38 additions & 9 deletions scripts/playerbot-gameplay/assertions-navigation.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -341,29 +341,58 @@ function Assert-InaccessibleCorpseEvents {
throw "Inaccessible corpse work was not bounded. loot=$($terminalResult.Count), passage_combat=$($passageCombat.Count), terminal=$($controllerTerminal.Count)."
}
$loot = $terminalResult[0]
if ($loot.target_id -le 0 -or $loot.elapsed_ms -lt 18000 -or $loot.elapsed_ms -gt 22000 -or
$loot.navigation_failures -gt 6 -or $loot.navigation_suspensions -gt 1) {
throw "Inaccessible corpse work was not bounded: invalid independent 20-second loot deadline."
# The current controller gives one 2-second retry after three failures, then
# ends this fixture at the six-failure bound before the 20-second deadline.
if ($loot.target_id -le 0 -or $loot.elapsed_ms -le 0 -or $loot.elapsed_ms -ge 20000 -or
$loot.navigation_failures -ne 6 -or $loot.navigation_suspensions -ne 1) {
throw "Inaccessible corpse work was not bounded by the six-failure recovery contract."
}
}

function Assert-CorpseDetourEvents {
param([string]$Logs)

$events = @(ConvertFrom-PlayerbotLogs -Logs $Logs)
$detours = @($events | Where-Object {
$_.event -eq "navigation_progress" -and $_.reason -eq "hostile_detour" -and
$_.blocker_id -gt 0
$corpseRoute = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "plan" -and $_.result -eq "success" -and
$_.danger_aware -eq $true -and $_.steps -gt 0 -and $null -ne $_.destination
})
$loot = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "loot" -and $_.result -eq "success"
$_.event -eq "action_result" -and $_.action -eq "loot" -and $_.result -eq "success" -and $_.item_id -eq 2148
})
$defensiveCombat = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "defensive_combat" -and $_.result -eq "started"
})
$controllerTerminal = @($events | Where-Object {
$_.event -in @("terminal", "death") -or
($_.event -eq "lifecycle" -and $_.status -in @("dead", "removed", "recovery_abandoned"))
})
$fixtureDisplaced = $Logs -match 'PLAYERBOT_GAMEPLAY_TEST CORPSE_INACCESSIBLE_DISPLACED'
if (-not $fixtureDisplaced -or $corpseRoute.Count -lt 1 -or $loot.Count -lt 1 -or
$defensiveCombat.Count -ne 0 -or $controllerTerminal.Count -ne 0) {
throw "The displaced corpse was not reached through a safe route. fixture_displaced=$fixtureDisplaced, route=$($corpseRoute.Count), loot=$($loot.Count), defensive=$($defensiveCombat.Count), terminal=$($controllerTerminal.Count)."
}
}

function Assert-DepotRiskRouteEvents {
param([string]$Logs)

$events = @(ConvertFrom-PlayerbotLogs -Logs $Logs)
$contract = @($events | Where-Object {
$_.event -eq "depot_risk_fallback_contract" -and $_.safe_precedence -eq $true -and
$_.retained_across_turns -eq $true -and $_.ranked_fallback -eq $true -and
$_.requested_revalidation -eq $true -and $_.failed_revalidation_rejected -eq $true
})
$safeRoute = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "depot_discover" -and $_.result -eq "success" -and
$_.risk_fallback -eq $false -and $_.unsafe_routes -gt 0 -and $_.route_steps -gt 0 -and
$null -ne $_.danger_cost -and $_.danger_cost -le 500 -and
$null -ne $_.maximum_health_loss_per_second -and $_.maximum_health_loss_per_second -le 0.08
})
$terminal = @($events | Where-Object { $_.event -eq "terminal" })
if ($detours.Count -lt 1 -or $loot.Count -lt 1 -or $defensiveCombat.Count -ne 0 -or $terminal.Count -ne 0) {
throw "The corpse blocker was not bypassed. detours=$($detours.Count), loot=$($loot.Count), defensive=$($defensiveCombat.Count), terminal=$($terminal.Count)."
if ($Logs -notmatch 'PLAYERBOT_GAMEPLAY_TEST DEPOT_RISK_FALLBACK_PASS' -or $contract.Count -ne 1 -or
$safeRoute.Count -lt 1 -or $terminal.Count -ne 0) {
throw "The depot risk route did not reject unsafe candidates and cross the ramp safely. contract=$($contract.Count), safe_route=$($safeRoute.Count), terminal=$($terminal.Count)."
}
}

Expand Down
2 changes: 1 addition & 1 deletion scripts/playerbot-gameplay/scenarios-combat-loot.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
}
Assert-CorpseDetourEvents -Logs $corpseLogs
}
# Allow server startup in addition to the asserted 70-second loot bound.
# Allow server startup in addition to the bounded six-failure recovery.
Invoke-Scenario -Name "corpse_inaccessible" -DefaultTimeoutSeconds 90 -Body {
Invoke-Compose down --volumes --remove-orphans
$env:PLAYERBOT_GAMEPLAY_MODE = "corpse_inaccessible"
Expand Down
17 changes: 1 addition & 16 deletions scripts/playerbot-gameplay/scenarios-service.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -116,23 +116,8 @@
$env:PLAYERBOT_DEPOT_MOVE_CASE = "normal"
Invoke-Compose up --detach
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST DEPOT_RISK_FALLBACK_START' | Out-Null
Wait-ForLog -Pattern '"action":"depot_discover","result":"success".*"risk_fallback":true' | Out-Null
$logs = Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST DEPOT_RISK_FALLBACK_PASS'
$events = @(ConvertFrom-PlayerbotLogs -Logs $logs)
$contract = @($events | Where-Object {
$_.event -eq "depot_risk_fallback_contract" -and $_.safe_precedence -eq $true -and
$_.retained_across_turns -eq $true -and $_.ranked_fallback -eq $true -and
$_.requested_revalidation -eq $true -and $_.failed_revalidation_rejected -eq $true
})
$fallback = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "depot_discover" -and $_.result -eq "success" -and
$_.risk_fallback -eq $true -and $_.unsafe_routes -gt 0 -and $_.route_steps -gt 0 -and
($_.danger_cost -gt 500 -or $_.maximum_health_loss_per_second -gt 0.08)
})
$terminal = @($events | Where-Object { $_.event -eq "terminal" })
if ($contract.Count -ne 1 -or $fallback.Count -ne 1 -or $terminal.Count -ne 0) {
throw "The depot risk fallback contract or swamp-troll escape failed. contract=$($contract.Count), fallback=$($fallback.Count), terminal=$($terminal.Count)."
}
Assert-DepotRiskRouteEvents -Logs $logs
}

Invoke-Scenario -Name "real_depot" -DefaultTimeoutSeconds 240 -Body {
Expand Down
59 changes: 54 additions & 5 deletions scripts/test-playerbot-navigation-assertions.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -95,22 +95,22 @@ if ($DangerRetreatLogPath) {
function New-InaccessibleCorpseFixture {
return @(
@{ event = "action_result"; action = "loot"; result = "failed"; reason = "corpse_inaccessible";
target_id = 41; navigation_failures = 6; navigation_suspensions = 1; elapsed_ms = 20056 }
target_id = 41; navigation_failures = 6; navigation_suspensions = 1; elapsed_ms = 5100 }
)
}
Assert-InaccessibleCorpseEvents -Logs (ConvertTo-FixtureLogs (New-InaccessibleCorpseFixture))
foreach ($case in @("early_deadline", "late_deadline", "combat_timeout", "too_many_failures", "duplicate_loot", "terminal")) {
foreach ($case in @("timeout_deadline", "combat_timeout", "wrong_failure_bound", "wrong_suspension_bound", "duplicate_loot", "terminal")) {
$events = @(New-InaccessibleCorpseFixture)
switch ($case) {
"early_deadline" { $events[0].elapsed_ms = 17999 }
"late_deadline" { $events[0].elapsed_ms = 22001 }
"timeout_deadline" { $events[0].elapsed_ms = 20000 }
"combat_timeout" {
$events = @(
@{ event = "action_result"; action = "defensive_combat"; result = "failed";
reason = "combat_timeout"; target_id = 42 }
) + $events
}
"too_many_failures" { $events[0].navigation_failures = 7 }
"wrong_failure_bound" { $events[0].navigation_failures = 5 }
"wrong_suspension_bound" { $events[0].navigation_suspensions = 0 }
"duplicate_loot" { $events += $events[0] }
"terminal" { $events += @{ event = "terminal"; reason = "controlled_player_dead" } }
}
Expand All @@ -123,6 +123,55 @@ if ($InaccessibleCorpseLogPath) {
Assert-InaccessibleCorpseEvents -Logs (Get-Content -Raw -LiteralPath $InaccessibleCorpseLogPath)
}

function New-CorpseDetourFixture {
return @(
@{ event = "action_result"; action = "plan"; result = "success"; danger_aware = $true; steps = 3;
destination = @{ x = 32105; y = 32194; z = 8 } }
@{ event = "action_result"; action = "loot"; result = "success"; item_id = 2148; count = 1 }
)
}
$detourLogs = "PLAYERBOT_GAMEPLAY_TEST CORPSE_INACCESSIBLE_DISPLACED`n" +
(ConvertTo-FixtureLogs (New-CorpseDetourFixture))
# The current controller does not emit the removed hostile_detour telemetry.
Assert-CorpseDetourEvents -Logs $detourLogs
foreach ($case in @("missing_displacement", "no_route", "defensive_combat", "terminal")) {
$events = @(New-CorpseDetourFixture)
$logs = $detourLogs
switch ($case) {
"missing_displacement" { $logs = ConvertTo-FixtureLogs $events }
"no_route" { $events = @($events | Where-Object action -ne "plan"); $logs = "PLAYERBOT_GAMEPLAY_TEST CORPSE_INACCESSIBLE_DISPLACED`n" + (ConvertTo-FixtureLogs $events) }
"defensive_combat" { $events += @{ event = "action_result"; action = "defensive_combat"; result = "started" }; $logs = "PLAYERBOT_GAMEPLAY_TEST CORPSE_INACCESSIBLE_DISPLACED`n" + (ConvertTo-FixtureLogs $events) }
"terminal" { $events += @{ event = "terminal"; reason = "controlled_player_dead" }; $logs = "PLAYERBOT_GAMEPLAY_TEST CORPSE_INACCESSIBLE_DISPLACED`n" + (ConvertTo-FixtureLogs $events) }
}
Assert-Rejected "Corpse detour $case" {
Assert-CorpseDetourEvents -Logs $logs
} "The displaced corpse was not reached through a safe route"
}

function New-DepotRiskRouteFixture {
return @(
@{ event = "depot_risk_fallback_contract"; safe_precedence = $true; retained_across_turns = $true;
ranked_fallback = $true; requested_revalidation = $true; failed_revalidation_rejected = $true }
@{ event = "action_result"; action = "depot_discover"; result = "success"; risk_fallback = $false;
unsafe_routes = 34; route_steps = 577; danger_cost = 323; maximum_health_loss_per_second = 0.0137222 }
)
}
$depotRiskLogs = "PLAYERBOT_GAMEPLAY_TEST DEPOT_RISK_FALLBACK_PASS`n" +
(ConvertTo-FixtureLogs (New-DepotRiskRouteFixture))
Assert-DepotRiskRouteEvents -Logs $depotRiskLogs
foreach ($case in @("fallback_selected", "no_unsafe_rejections", "unsafe_safe_route", "terminal")) {
$events = @(New-DepotRiskRouteFixture)
switch ($case) {
"fallback_selected" { $events[1].risk_fallback = $true }
"no_unsafe_rejections" { $events[1].unsafe_routes = 0 }
"unsafe_safe_route" { $events[1].danger_cost = 501 }
"terminal" { $events += @{ event = "terminal"; reason = "depot_unavailable" } }
}
Assert-Rejected "Depot risk route $case" {
Assert-DepotRiskRouteEvents -Logs ("PLAYERBOT_GAMEPLAY_TEST DEPOT_RISK_FALLBACK_PASS`n" + (ConvertTo-FixtureLogs $events))
} "The depot risk route did not reject unsafe candidates"
}

function New-MainlandFixture {
return @(
@{ event = "action_result"; action = "hunt_cycle"; result = "started"; cycle = 1 }
Expand Down
Loading