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: 1 addition & 1 deletion docs/playerbots.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ This design is intended to scale without one thread, graphical client, renderer,
| --- | --- | --- |
| Goal selection | Ordinary | Chooses among survival, service, rewards, spell learning, equipment, magic training, and hunting only at safe boundaries; it is not a general-purpose planner. |
| Hunting | Ordinary; map-derived selection has focused real-map coverage | Scores loaded hostile spawns against equipment, skills, health, supplies, route safety, expected XP, and cash pressure. Adaptive challenge and performance estimates reset with the controller; no long progression soak is established. |
| Combat and healing | Ordinary | Approaches reachable same-floor targets, handles attackers, uses melee, potions, and supported Knight spells. Transit combat is restricted to confirmed route blockers; group tactics and other vocations are not developed. |
| Combat and healing | Ordinary | Approaches reachable same-floor targets, handles attackers, uses melee, potions, and supported Knight spells. After an attempted step or movement-related route plan fails without positional progress, it can stop and fight one adjacent attacker without chase when the existing combat estimates say the fight is survivable. It prefers an attacker on the failed step, otherwise the easiest safe attacker, and retains that target until movement can resume, the target dies or moves out of reach, safety changes, or the workflow is interrupted. Group tactics and other vocations are not developed. |
| Loot | Ordinary; edge cases have focused evidence | Opens owned corpse containers through normal item use before inspecting contents, ranks known saleable loot by value and weight, and retries inaccessible corpses within bounds. Skinning and other secondary corpse actions are unsupported. |
| Navigation | Ordinary; transition and recovery scenarios have focused evidence | Routes across the loaded map using a shared coarse topology, then plans and executes local paths and supported transitions. Supports tools, doors, floor changes, teleports, and registered NPC travel. Detailed search and recovery have budgets; unsupported transitions and changing obstacles can prevent a journey. Static topology changes may require a supported reload or restart. |
| NPC service | Ordinary | Discovers loaded shops, bankers, spell trainers, and registered travel offers; buys supplies, sells known loot, banks, and verifies transactions. Custom dialogue or opaque travel conditions are excluded unless explicitly modeled. |
Expand Down
68 changes: 7 additions & 61 deletions scripts/playerbot-gameplay/assertions-navigation.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -326,78 +326,24 @@ function Assert-InaccessibleCorpseEvents {
param([string]$Logs)

$events = @(ConvertFrom-PlayerbotLogs -Logs $Logs)
$failedDetour = @($events | Where-Object {
$_.event -eq "navigation_progress" -and $_.result -eq "failed" -and
$_.reason -eq "route_unavailable"
})
$terminalResult = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "loot" -and $_.result -eq "failed" -and
$_.reason -eq "corpse_inaccessible"
})
$combatPreemption = @($events | Where-Object {
$_.event -eq "target_changed" -and
$_.reason -in @("defensive_path_blocker", "defensive_attacker")
})
$blockerEngaged = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "defensive_combat" -and $_.result -eq "started"
})
$blockerReleased = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "defensive_combat" -and $_.result -ne "started"
})
$targetCleared = @($events | Where-Object {
$_.event -eq "target_changed" -and $null -eq $_.target_id -and $_.reason -eq "transit_combat_budget"
})
$suspended = @($events | Where-Object {
$_.event -eq "navigation_progress" -and $_.result -eq "suspended" -and $_.reason -eq "corpse_route_unchanged"
})
$resumed = @($events | Where-Object {
$_.event -eq "navigation_progress" -and $_.result -eq "resumed" -and $_.reason -eq "corpse_retry"
$passageCombat = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "defensive_combat"
})
$controllerTerminal = @($events | Where-Object {
$_.event -in @("terminal", "death") -or
($_.event -eq "lifecycle" -and $_.status -in @("dead", "removed", "recovery_abandoned"))
})
if ($failedDetour.Count -lt 1 -or $combatPreemption.Count -ne 1 -or $blockerEngaged.Count -ne 1 -or
$blockerReleased.Count -ne 1 -or $targetCleared.Count -ne 1 -or
$suspended.Count -ne 1 -or $resumed.Count -ne 1 -or
$terminalResult.Count -ne 1 -or $controllerTerminal.Count -ne 0) {
throw "Inaccessible corpse work was not bounded. failed_detour=$($failedDetour.Count), preemption=$($combatPreemption.Count)/$($blockerEngaged.Count)/$($blockerReleased.Count), clear=$($targetCleared.Count), retry=$($suspended.Count)/$($resumed.Count), results=$($terminalResult.Count), terminal=$($controllerTerminal.Count)."
if ($terminalResult.Count -ne 1 -or $passageCombat.Count -ne 0 -or $controllerTerminal.Count -ne 0) {
throw "Inaccessible corpse work was not bounded. loot=$($terminalResult.Count), passage_combat=$($passageCombat.Count), terminal=$($controllerTerminal.Count)."
}
$target = $combatPreemption[0]
$engaged = $blockerEngaged[0]
$released = $blockerReleased[0]
$loot = $terminalResult[0]
if ($target.target_name -ne "Playerbot Corpse Blocker" -or $target.reason -ne "defensive_path_blocker" -or
$target.route_critical -ne $true -or $target.target_id -le 0 -or
$engaged.target_id -ne $target.target_id -or $engaged.route_critical -ne $true -or $engaged.chase -ne $false -or
$released.target_id -ne $target.target_id -or $released.result -ne "skipped" -or $released.reason -ne "transit_combat_budget" -or
$targetCleared[0].previous_target_id -ne $target.target_id -or
$loot.target_id -le 0 -or $loot.target_id -eq $target.target_id -or
$loot.navigation_failures -lt 1 -or $loot.navigation_failures -gt 6 -or
$loot.navigation_suspensions -ne 1 -or $loot.elapsed_ms -le 0 -or $loot.elapsed_ms -gt 70000 -or
-not $engaged.ts -or -not $released.ts) {
throw "Inaccessible corpse work was not bounded: invalid blocker, release, or loot budget."
}
# Five-second wall-clock budget, checked on the next running turn. Allow
# one navigationInterval (1000 ms) for scheduler dispatch, not the old combat timeout.
$combatMs = ([DateTimeOffset]$released.ts - [DateTimeOffset]$engaged.ts).TotalMilliseconds
if ($combatMs -lt 5000 -or $combatMs -gt 6000) {
throw "Inaccessible corpse work was not bounded: blocker combat lasted $combatMs ms."
}
$order = @($failedDetour[0], $target, $engaged, $targetCleared[0], $released,
$suspended[0], $resumed[0], $failedDetour[-1], $loot)
$previous = -1
foreach ($event in $order) {
$index = [Array]::IndexOf($events, $event)
if ($index -le $previous) {
throw "Inaccessible corpse work was not bounded: detour, combat release, and resumed loot retry were out of order."
}
$previous = $index
}
if ($suspended[0].navigation_failures -lt 1 -or
$resumed[0].navigation_failures -ne $suspended[0].navigation_failures -or
$loot.navigation_failures -le $resumed[0].navigation_failures) {
throw "Inaccessible corpse work was not bounded: resumed loot did not retain and advance its failure budget."
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."
}
}

Expand Down
52 changes: 14 additions & 38 deletions scripts/test-playerbot-navigation-assertions.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -94,55 +94,31 @@ if ($DangerRetreatLogPath) {

function New-InaccessibleCorpseFixture {
return @(
@{ event = "navigation_progress"; result = "failed"; reason = "route_unavailable" }
@{ event = "target_changed"; target_id = 42; target_name = "Playerbot Corpse Blocker";
reason = "defensive_path_blocker"; route_critical = $true }
@{ event = "action_result"; action = "defensive_combat"; result = "started"; target_id = 42;
chase = $false; route_critical = $true; ts = "2026-09-09T16:36:29.485Z" }
@{ event = "target_changed"; previous_target_id = 42; target_id = $null; reason = "transit_combat_budget" }
@{ event = "action_result"; action = "defensive_combat"; result = "skipped"; target_id = 42;
reason = "transit_combat_budget"; ts = "2026-09-09T16:36:34.486Z" }
@{ event = "navigation_progress"; result = "suspended"; reason = "corpse_route_unchanged"; navigation_failures = 3 }
@{ event = "navigation_progress"; result = "resumed"; reason = "corpse_retry"; navigation_failures = 3 }
@{ event = "navigation_progress"; result = "failed"; reason = "route_unavailable" }
@{ event = "action_result"; action = "loot"; result = "failed"; reason = "corpse_inaccessible";
target_id = 41; navigation_failures = 6; navigation_suspensions = 1; elapsed_ms = 12150 }
target_id = 41; navigation_failures = 6; navigation_suspensions = 1; elapsed_ms = 20056 }
)
}
Assert-InaccessibleCorpseEvents -Logs (ConvertTo-FixtureLogs (New-InaccessibleCorpseFixture))
foreach ($case in @("old_timeout", "early_release", "late_release", "missing_timestamp", "optional_attacker",
"unconfirmed_blocker", "chase", "missing_chase", "wrong_release_target", "missing_target_clear",
"reacquired", "no_suspension", "no_resume", "no_retry", "detour_after_combat", "retry_before_resume",
"reset_failure_budget", "no_failure_progress", "too_many_failures", "loot_overrun", "duplicate_loot", "terminal")) {
$events = New-InaccessibleCorpseFixture
foreach ($case in @("early_deadline", "late_deadline", "combat_timeout", "too_many_failures", "duplicate_loot", "terminal")) {
$events = @(New-InaccessibleCorpseFixture)
switch ($case) {
"old_timeout" { $events[4].result = "failed"; $events[4].reason = "combat_timeout" }
"early_release" { $events[4].ts = "2026-09-09T16:36:34.484Z" }
"late_release" { $events[4].ts = "2026-09-09T16:36:35.486Z" }
"missing_timestamp" { $events[4].Remove("ts") }
"optional_attacker" { $events[1].reason = "defensive_attacker" }
"unconfirmed_blocker" { $events[1].route_critical = $false }
"chase" { $events[2].chase = $true }
"missing_chase" { $events[2].Remove("chase") }
"wrong_release_target" { $events[4].target_id = 43 }
"missing_target_clear" { $events = @($events[0..2]) + @($events[4..8]) }
"reacquired" { $events = @($events[0..6]) + @($events[1], $events[2]) + @($events[7..8]) }
"no_suspension" { $events = @($events[0..4]) + @($events[6..8]) }
"no_resume" { $events = @($events[0..5]) + @($events[7..8]) }
"no_retry" { $events = @($events[0..6]) + @($events[8]) }
"detour_after_combat" { $events = @($events[1..4]) + @($events[0]) + @($events[5..8]) }
"retry_before_resume" { $events = @($events[0..5]) + @($events[7], $events[6], $events[8]) }
"reset_failure_budget" { $events[6].navigation_failures = 0 }
"no_failure_progress" { $events[8].navigation_failures = 3 }
"too_many_failures" { $events[8].navigation_failures = 7 }
"loot_overrun" { $events[8].elapsed_ms = 70001 }
"duplicate_loot" { $events += $events[8] }
"early_deadline" { $events[0].elapsed_ms = 17999 }
"late_deadline" { $events[0].elapsed_ms = 22001 }
"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 }
"duplicate_loot" { $events += $events[0] }
"terminal" { $events += @{ event = "terminal"; reason = "controlled_player_dead" } }
}
Assert-Rejected "Inaccessible corpse $case" {
Assert-InaccessibleCorpseEvents -Logs (ConvertTo-FixtureLogs $events)
} "Inaccessible corpse work was not bounded"
}

if ($InaccessibleCorpseLogPath) {
Assert-InaccessibleCorpseEvents -Logs (Get-Content -Raw -LiteralPath $InaccessibleCorpseLogPath)
}
Expand Down
Loading