diff --git a/docs/testing.md b/docs/testing.md index 945a14f..a94d930 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -29,6 +29,7 @@ pwsh -File scripts/test-playerbot-scenario-isolation.ps1 pwsh -File scripts/test-playerbot-depot-scenario.ps1 pwsh -File scripts/test-playerbot-death-scenario.ps1 pwsh -File scripts/test-playerbot-magic-training-assertions.ps1 +pwsh -File scripts/test-playerbot-supply-assertions.ps1 ``` On Linux, pure contract checks require a C++17 compiler; fixture-isolation checks @@ -39,6 +40,8 @@ sh server/tests/playerbot_contracts.sh lua scripts/test-playerbot-fixture-isolation.lua lua scripts/test-playerbot-depot-fixture.lua lua scripts/test-playerbot-death-fixture.lua +lua scripts/test-playerbot-transit-fixture.lua +lua scripts/test-playerbot-supply-fixture.lua ``` The depot Lua regression checks login-time inventory expectations, bounded waiting @@ -60,24 +63,142 @@ pwsh -File scripts/test-playerbot-gameplay.ps1 -Scenario real_depot,real_depot_r requires the server development headers. These checks supplement, not replace, live gameplay validation. -## Danger retreat +## Hunt supply budgets and early Exura + +Hunt selection keeps lethal, adaptive-challenge, topology, and route gates first. +Among safe candidates, it prefers a duration budget that fits carried potions +minus emergency and route reserves, then projected XP. If none fits, it chooses +the lowest expected potion consumption among safe candidates, with XP breaking +ties. This is not a new hunt eligibility gate: existing readiness and emergency +return rules still apply. A budget is not permission to spend the reserve. + +The uncalibrated static estimate uses spawn probabilities and intervals, existing +combat/clear throughput, and conservative local-crowd inflation. Each modeled +crowd's fight damage is divided by that same crowd's summed isolated damage; +the largest local ratio is applied to spawn-rate damage, with a floor of one. +Two identical attackers therefore inflate their already-summed damage by 1.5, +not three. It estimates whole potions over the hunt time remaining after travel, +using minimum healing. It credits only legal Exura's audited minimum and the active default regeneration +condition. Regeneration uses whole ticks during estimated combat time, expires +after its current lifetime, and gets no travel credit. Exura retains its mana +reserve and receives at most one mana pool of credit, not repeated refill cycles. +No current-health spending, future food, looted supplies, other spells, equipment +regeneration, expected profit, or observed supply calibration is assumed. + +The existing 256-candidate scoring turns and eight-route shortlist remain bounded. +Route validation now compares the safe shortlist instead of accepting its first +safe entry: at most one outbound or return path per turn. Actual travel time and +outbound/return potion reserves update the budget before final selection. This +can take more turns and does not promise the whole-map optimum. Potion changes +or mana loss invalidate the planning snapshot. + +`hunt_region_candidate` reports `supply_estimate_source=static_duration_budget`, +`supply_budget_fits`, `supply_expected_damage`, `supply_regeneration_healing`, +`supply_spell_healing`, `supply_expected_potions`, `supply_reserved_potions`, and +`supply_routine_potions`. `score` remains projected XP; it is not a composite +supply score. `route_validated=true` candidate events carry the revised budget. +Selections report `selection_rule=supply_budget_then_xp` or +`lowest_potion_consumption_then_xp`. + +Affordable, legally offered Light Healing precedes optional goals and cash +rebalancing. Mandatory departure, capacity, and healing-supply service remain +higher priority. Exura retains 100 gold and more than the emergency/route potion +threshold, rather than funding a ten-potion restock. Other spells keep the full +restock reserve. The selected reserve is checked again before NPC payment; no +spell is granted and datapack eligibility is unchanged. Goal candidates explain +this as `priority_recovery_spell` or `deferred_recovery_spell`; the selected +training plan also reports `potion_reserve`. + +Non-Docker coverage: `sh server/tests/playerbot_contracts.sh` checks duration, +scarce/plentiful and all-over-budget selection, XP tie-breaking, safety precedence, +route reserve growth and travel-time reconciliation, finite regeneration, Exura +legality/mana bounds, exact affordability, and goal priority. +The supply assertion regression above rejects incorrect selection, payment, +priority, and missing live verification evidence. The supply Lua regression +checks the live verifier's exact potion/payment counts and bounded wait. + +Parent/live validation (resets the disposable test stack): -`hunt_region_observed_danger` cancels both hunt and defensive targets and starts -an escape return. Navigation resets, healing, and repeated service requests do -not end retreat. Healing keeps its normal priority; optional adjacent attackers -(including crowds) cannot start combat, and level-eight departure waits until -retreat ends. Depot arrival or a new hunt restores ordinary defensive policy. +```powershell +pwsh -File scripts/test-playerbot-gameplay.ps1 -Scenario adaptive_challenge,spell_training_low_supplies,spell_training_low_supplies_unaffordable,spell_training,spell_training_shortlist,hunt_region_planning +``` + +`adaptive_challenge` additionally runs deterministic synthetic candidate facts +through the real bounded hunt scoring session/runtime: two potions select the +one-potion, lower-XP candidate; ten select the eight-potion, higher-XP candidate. +The all-over-budget case keeps two potions but extends the horizon to 25 minutes: +the easy candidate needs two potions, the costly one twelve, and neither fits. +The runtime must still select the easy candidate, not stop or revert to max XP. +Its `supply_budget_fixture` events include the horizon, both candidates' estimates +and budget-fit flags, and the selection rule. Their source is +`synthetic_runtime_candidates`, not measured live combat consumption. The two new spell fixtures use +the real trainer and payment path: two potions plus 270 gold learn Exura and +retain both potions/100 gold; 269 gold rejects it. Existing spell persistence, +shortlist, adaptive-challenge, and hunt-planning assertions remain in place. +Map extraction, actual route integration, and real combat consumption still need +live validation; the synthetic contrast alone does not establish sustainable XP. + +## Transit combat and danger retreat + +Travel to a hunt area, progression destinations, service/depot returns (including +ordinary returns), and corpse approaches prefer movement and detours. A crowd +alone never permits stop-and-fight. Deliberate combat inside the reached hunt +area remains separate; fixed fallback patrol fixtures remain deliberate hunts +from startup. Navigation must confirm failed-detour, route-critical +blocker evidence before transit combat can start; target telemetry retains +`route_critical:true` and `defensive_path_blocker`. + +Each blocker gets one no-chase attempt per transit episode, with a five-second +wall-clock budget checked before healing on each running scheduler turn. +Healing keeps its normal priority but cannot renew combat. Exhaustion emits +`transit_combat_budget`; movement of the blocker emits `transit_blocker_moved`. +Neither permits reacquisition in the same episode. Navigation resets, repeated +turns, and intermediate arrivals (including coarse/local NPC approach legs) do +not renew attempts. Hunt-area entry, a new goal decision, or a cycle-phase +transition ends the episode. Existing combat is +released with `transit_goal_changed` before it can preempt a new transit goal. +Loot retries and deadlines retain their existing bounds. + +`hunt_region_observed_danger` still cancels hunt/defensive targets and defers +level-eight departure until depot arrival or a new hunt. It now uses the same +combat policy as ordinary transit, rather than a separate escape exception. +These bounds do not guarantee escape: an enclosed bot can still fail to find a +route or die. Route-risk calibration and supply/healing decisions are unchanged. + +`sh server/tests/playerbot_contracts.sh` checks transit classification, optional +crowd rejection, wall-clock budgets across healing turns, no reacquisition, +episode retention across coarse/local approach arrivals, and semantic completion/ +new-goal resets. The deterministic live +`transit_return` fixture starts at `(32105,32191,8)` in the hall north of the +synthetic depot stall at `(32105,32195,8)`. Four stationary adjacent attackers +occupy the north/west tiles, leaving the straight southward route empty. This +geometry was checked against `World.otbm` and `items.otb`; the local Lua test uses +those ground-only tiles rather than an all-walkable mock. The fixture requires +normal return movement and depot arrival within 30 seconds with all attackers +untouched and no target selection: -Navigation still tries a detour first. Only a confirmed route-critical blocker -may be attacked during retreat, without chase, once per creature per return. -Its budget is five seconds, checked on the next running scheduler turn before -healing. Exhaustion emits `danger_retreat_combat_budget`; movement of the blocker -emits `danger_retreat_blocker_moved`. Both release combat and resume navigation. -This bounds engagement, not successful escape: a trapped bot can still fail to -find a route or die. Ordinary hunt, loot, and non-danger return defense are unchanged. +```powershell +pwsh -File scripts/test-playerbot-gameplay.ps1 -Scenario transit_return +``` + +The fixture fails rather than skips if the map lacks the required open geometry. +Its log assertion has positive and adversarial non-Docker checks in +`scripts/test-playerbot-navigation-assertions.ps1`. Live execution remains needed +to validate the fixture geometry and server integration. +`corpse_inaccessible` requires a failed detour before exactly one confirmed +route-critical, no-chase blocker engagement. It must clear the target with +`skipped/transit_combat_budget` after five seconds (at most one additional +one-second scheduler turn), not wait for the old `failed/combat_timeout` result. +The assertion also requires suspension, resumed corpse navigation with retained +failure counts, and further route failure before one `corpse_inaccessible` +result. The original limits remain: at most six navigation failures and 70 seconds, +without reacquisition or a controller terminal event. Replay a captured JSONL log +without Docker with: + +```powershell +pwsh -File scripts/test-playerbot-navigation-assertions.ps1 -InaccessibleCorpseLogPath /tmp/corpse-inaccessible.jsonl +``` -`sh server/tests/playerbot_contracts.sh` checks optional-attacker rejection, -blocker budgets, no reacquisition, repeated-retreat retention, and reset behavior. The live navigation/loot/defense/healing regression command is: ```powershell diff --git a/scripts/playerbot-gameplay/assertions-navigation.ps1 b/scripts/playerbot-gameplay/assertions-navigation.ps1 index d03ffac..f40b27c 100644 --- a/scripts/playerbot-gameplay/assertions-navigation.ps1 +++ b/scripts/playerbot-gameplay/assertions-navigation.ps1 @@ -1,3 +1,40 @@ +function Assert-TransitReturnEvents { + param([string]$Logs) + + if ($Logs -notmatch 'PLAYERBOT_GAMEPLAY_TEST TRANSIT_RETURN_START (\d+) (\d+) (\d+)') { + throw "Transit return failed: missing crowd fixture." + } + $origin = "$($Matches[1]),$($Matches[2]),$($Matches[3])" + if ($Logs -notmatch 'PLAYERBOT_GAMEPLAY_TEST TRANSIT_RETURN_STATE_PASS') { + throw "Transit return failed: missing untouched-attacker arrival verification." + } + $moved = $false + $returned = $false + foreach ($event in @(ConvertFrom-PlayerbotLogs -Logs $Logs)) { + if ($event.event -eq "target_changed" -and $null -ne $event.target_id) { + throw "Transit return failed: optional combat selected on an available route." + } + if ($event.event -in @("death", "terminal") -or + ($event.event -eq "lifecycle" -and $event.status -in @("dead", "removed", "recovery_abandoned"))) { + throw "Transit return failed: bot terminated before arrival." + } + if (($event.event -eq "objective_transition" -and $event.to -eq "return_to_depot") -or + ($event.event -eq "action_result" -and $event.action -eq "return" -and $event.result -eq "started")) { + if ($event.reason -eq "hunt_region_observed_danger") { + throw "Transit return failed: expected an ordinary return." + } + $returned = $true + } + if ($returned -and $null -ne $event.position -and + "$($event.position.x),$($event.position.y),$($event.position.z)" -ne $origin) { $moved = $true } + if ($event.event -eq "objective_transition" -and $event.to -eq "deposit_loot") { + if (-not $returned -or -not $moved) { throw "Transit return failed: no ordinary return movement." } + return + } + } + throw "Transit return failed: no depot arrival." +} + function Assert-DangerRetreatEvents { param([string]$Logs) @@ -298,26 +335,69 @@ function Assert-InaccessibleCorpseEvents { $_.reason -eq "corpse_inaccessible" }) $combatPreemption = @($events | Where-Object { - $_.event -eq "target_changed" -and $_.target_name -eq "Playerbot Corpse Blocker" -and + $_.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" -and - $combatPreemption.Count -ge 1 -and $_.target_id -eq $combatPreemption[0].target_id + $_.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" }) - $blockerTimedOut = @($events | Where-Object { - $_.event -eq "action_result" -and $_.action -eq "defensive_combat" -and - $_.result -eq "failed" -and $_.reason -eq "combat_timeout" + $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" + }) + $controllerTerminal = @($events | Where-Object { + $_.event -in @("terminal", "death") -or + ($_.event -eq "lifecycle" -and $_.status -in @("dead", "removed", "recovery_abandoned")) }) - $controllerTerminal = @($events | Where-Object { $_.event -eq "terminal" }) if ($failedDetour.Count -lt 1 -or $combatPreemption.Count -ne 1 -or $blockerEngaged.Count -ne 1 -or - $blockerTimedOut.Count -ne 1 -or - $terminalResult.Count -ne 1 -or - $terminalResult[0].target_id -le 0 -or - $terminalResult[0].navigation_failures -gt 6 -or $terminalResult[0].elapsed_ms -gt 70000 -or - $controllerTerminal.Count -ne 0) { - throw "Inaccessible corpse work was not bounded. failed_detour=$($failedDetour.Count), preemption=$($combatPreemption.Count)/$($blockerEngaged.Count)/$($blockerTimedOut.Count), results=$($terminalResult.Count), terminal=$($controllerTerminal.Count)." + $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)." + } + $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." } } diff --git a/scripts/playerbot-gameplay/assertions-progression.ps1 b/scripts/playerbot-gameplay/assertions-progression.ps1 index ce70975..7418f1d 100644 --- a/scripts/playerbot-gameplay/assertions-progression.ps1 +++ b/scripts/playerbot-gameplay/assertions-progression.ps1 @@ -340,8 +340,14 @@ function Assert-HuntRegionPlanningEvents { $_.event -eq "hunt_region_scan" -and $_.phase -eq "selected" -and $_.decision_latency_us -gt 0 }) $selectedCandidate = if ($selection) { @($candidates | Where-Object { $_.region_id -eq $selection.region_id }) } else { @() } - $reachableCandidates = @($candidates | Where-Object { $_.suitable -and $_.reachable }) - $bestScore = if ($reachableCandidates.Count -gt 0) { ($reachableCandidates | Measure-Object -Property score -Maximum).Maximum } else { $null } + $reachableCandidates = @($candidates | Where-Object { $_.suitable -and $_.reachable -and $_.route_validated }) + $budgetCandidates = @($reachableCandidates | Where-Object { $_.supply_budget_fits }) + $minimumPotions = if ($reachableCandidates.Count -gt 0) { ($reachableCandidates | Measure-Object -Property supply_expected_potions -Minimum).Minimum } else { $null } + $preferredCandidates = if ($budgetCandidates.Count -gt 0) { $budgetCandidates } else { + @($reachableCandidates | Where-Object { $_.supply_expected_potions -eq $minimumPotions }) + } + $bestScore = if ($preferredCandidates.Count -gt 0) { ($preferredCandidates | Measure-Object -Property score -Maximum).Maximum } else { $null } + $selectionRule = if ($budgetCandidates.Count -gt 0) { 'supply_budget_then_xp' } else { 'lowest_potion_consumption_then_xp' } $completedTopology = @($completed | Where-Object { $_.topology_time_us -ge 0 }) @@ -360,6 +366,10 @@ function Assert-HuntRegionPlanningEvents { if ($build.Count -lt 2 -or $hit.Count -lt 1 -or $cancelled.Count -ne 1 -or $staleRevision.Count -ne 1 -or $topologyScans.Count -lt 1 -or $routeValidations.Count -lt 1 -or -not $selection -or $selectedCandidate.Count -ne 1 -or $bestScore -eq $null -or [Math]::Abs($selectedCandidate[0].score - $bestScore) -gt 0.01 -or + $reachableCandidates.Count -gt 8 -or $selection.selection_rule -ne $selectionRule -or + -not $selectedCandidate[0].route_validated -or + ($budgetCandidates.Count -gt 0 -and -not $selectedCandidate[0].supply_budget_fits) -or + ($budgetCandidates.Count -eq 0 -and $selectedCandidate[0].supply_expected_potions -ne $minimumPotions) -or -not $selectedCandidate[0].topology_reachable -or $selectedCandidate[0].topology_travel_steps -lt 1 -or -not $selectedCandidate[0].reachable -or $selectedCandidate[0].route_danger_cost -lt 0 -or $outsideLocalFixture.Count -lt 1 -or $completedTopology.Count -lt 1 -or @@ -399,6 +409,25 @@ function Assert-HuntAreaArrivalEvents { } } +function Assert-SupplyBudgetEvents { + param([string]$Logs) + $fixtures = @(ConvertFrom-PlayerbotLogs -Logs $Logs | Where-Object { + $_.event -eq 'supply_budget_fixture' -and $_.source -eq 'synthetic_runtime_candidates' + }) + $scarce = @($fixtures | Where-Object { $_.potions -eq 2 -and $_.duration_seconds -eq 900 -and $_.selected_variant -eq 1 -and $_.budget_fits -and $_.expected_potions -eq 1 -and $_.scored_candidates -eq 2 -and $_.selection_rule -eq 'supply_budget_then_xp' }) + $plentiful = @($fixtures | Where-Object { $_.potions -eq 10 -and $_.duration_seconds -eq 900 -and $_.selected_variant -eq 2 -and $_.budget_fits -and $_.expected_potions -eq 8 -and $_.scored_candidates -eq 2 -and $_.selection_rule -eq 'supply_budget_then_xp' }) + $overBudget = @($fixtures | Where-Object { + $_.potions -eq 2 -and $_.duration_seconds -eq 1500 -and $_.scored_candidates -eq 2 -and + $_.easy_expected_potions -eq 2 -and $_.costly_expected_potions -eq 12 -and + $_.easy_budget_fits -eq $false -and $_.costly_budget_fits -eq $false -and + $_.selected_variant -eq 1 -and $_.budget_fits -eq $false -and $_.expected_potions -eq 2 -and + $_.selection_rule -eq 'lowest_potion_consumption_then_xp' + }) + if ($fixtures.Count -ne 3 -or $scarce.Count -ne 1 -or $plentiful.Count -ne 1 -or $overBudget.Count -ne 1) { + throw 'Duration-budget runtime selection failed the scarce/plentiful/all-over-budget supply contrast.' + } +} + function Assert-AdaptiveChallengeEvents { param([string]$Logs) diff --git a/scripts/playerbot-gameplay/assertions-spells.ps1 b/scripts/playerbot-gameplay/assertions-spells.ps1 index 605af55..c58905f 100644 --- a/scripts/playerbot-gameplay/assertions-spells.ps1 +++ b/scripts/playerbot-gameplay/assertions-spells.ps1 @@ -1,3 +1,31 @@ +function Assert-LowSupplySpellTrainingEvents { + param([string]$Logs, [switch]$Unaffordable) + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $candidates = @($events | Where-Object { + $_.event -eq 'spell_candidate' -and $_.spell -eq 'Light Healing' -and $_.price -eq 170 -and $_.reserve -eq 100 + }) + $purchases = @($events | Where-Object { $_.event -eq 'action_result' -and $_.action -eq 'learn_spell' }) + if ($Unaffordable) { + if (@($candidates | Where-Object { $_.result -eq 'rejected' -and $_.reason -eq 'unaffordable_after_reserves' }).Count -lt 1 -or $purchases.Count -ne 0) { + throw 'Exura did not respect the one-gold-short emergency reserve boundary.' + } + return + } + $selection = @($events | Where-Object { $_.event -eq 'goal_selection' -and $_.to_goal -eq 'learn_spell' }) + $priority = @($events | Where-Object { $_.event -eq 'goal_candidate' -and $_.goal -eq 'learn_spell' -and $_.feasible -and $_.reason -eq 'priority_recovery_spell' }) + if (@($candidates | Where-Object { $_.result -eq 'feasible' }).Count -lt 1 -or + $selection.Count -ne 1 -or $priority.Count -lt 1 -or $purchases.Count -ne 1 -or + $purchases[0].result -ne 'success' -or $purchases[0].spell -ne 'Light Healing' -or + $purchases[0].money_before -ne 270 -or $purchases[0].money_after -ne 100 -or + $Logs -notmatch 'PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_LOW_SUPPLIES_PASS') { + throw 'Low-supply Exura learning did not preserve exact payment, priority, and potion reserves.' + } + $firstSelection = @($events | Where-Object { $_.event -eq 'goal_selection' } | Select-Object -First 1) + if ($firstSelection.Count -ne 1 -or $firstSelection[0].to_goal -ne 'learn_spell') { + throw 'An optional goal preceded affordable Exura learning.' + } +} + function Assert-SpellTrainingEvents { param([string]$Logs, [switch]$Restart) diff --git a/scripts/playerbot-gameplay/scenarios-navigation.ps1 b/scripts/playerbot-gameplay/scenarios-navigation.ps1 index 4d9f229..0924e59 100644 --- a/scripts/playerbot-gameplay/scenarios-navigation.ps1 +++ b/scripts/playerbot-gameplay/scenarios-navigation.ps1 @@ -1,3 +1,16 @@ + if ($FullNavigation -or $selectedScenarios.Contains("transit_return")) { + Invoke-Scenario -Name "transit_return" -DefaultTimeoutSeconds 60 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "transit_return" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST TRANSIT_RETURN_STATE_PASS' | Out-Null + $transitLogs = Wait-ForPlayerbotEvent -Predicate { + $_.event -eq "objective_transition" -and $_.to -eq "deposit_loot" + } + Assert-TransitReturnEvents -Logs $transitLogs + } + } + if ($FullNavigation -or $selectedScenarios.Contains("carlin_service_route") -or $selectedScenarios.Contains("mutable_portal_route")) { Invoke-Scenario -Name "navigation" -DefaultTimeoutSeconds 240 -Body { Invoke-Compose down --volumes --remove-orphans diff --git a/scripts/playerbot-gameplay/scenarios-progression.ps1 b/scripts/playerbot-gameplay/scenarios-progression.ps1 index a723990..be07486 100644 --- a/scripts/playerbot-gameplay/scenarios-progression.ps1 +++ b/scripts/playerbot-gameplay/scenarios-progression.ps1 @@ -348,6 +348,7 @@ Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST ADAPTIVE_CHALLENGE_START' | Out-Null $challengeLogs = Wait-ForLog -Pattern '"event":"terminal".*"reason":"hunt_scope_exhausted"' Assert-AdaptiveChallengeEvents -Logs $challengeLogs + Assert-SupplyBudgetEvents -Logs $challengeLogs } } diff --git a/scripts/playerbot-gameplay/scenarios-spells.ps1 b/scripts/playerbot-gameplay/scenarios-spells.ps1 index c54799d..ad91c74 100644 --- a/scripts/playerbot-gameplay/scenarios-spells.ps1 +++ b/scripts/playerbot-gameplay/scenarios-spells.ps1 @@ -1,4 +1,20 @@ if ($SpellTraining) { + foreach ($lowSupplyCase in @('spell_training_low_supplies', 'spell_training_low_supplies_unaffordable')) { + Invoke-Scenario -Name $lowSupplyCase -DefaultTimeoutSeconds 180 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = $lowSupplyCase + $env:PLAYERBOT_HUNT_DURATION_SECONDS = '900' + Invoke-Compose up --detach + $unaffordable = $lowSupplyCase -eq 'spell_training_low_supplies_unaffordable' + if ($unaffordable) { + $logs = Wait-ForLog -Pattern '"spell":"Light Healing".*"reason":"unaffordable_after_reserves"' + } else { + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_LOW_SUPPLIES_PASS' | Out-Null + $logs = Wait-ForLog -Pattern '"action":"learn_spell","result":"success"' + } + Assert-LowSupplySpellTrainingEvents -Logs $logs -Unaffordable:$unaffordable + } + } Invoke-Scenario -Name "spell_training" -DefaultTimeoutSeconds 180 -Body { Invoke-Compose down --volumes --remove-orphans $env:PLAYERBOT_GAMEPLAY_MODE = "spell_training" diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index 03a5429..d17945a 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -67,7 +67,7 @@ $composeFile = Join-Path $projectRoot "server\compose.yaml" $gameplayComposeFile = Join-Path $projectRoot "server\compose.playerbot-gameplay.yaml" $composeArguments = @("compose", "-f", $composeFile, "-f", $gameplayComposeFile) $scenarioCatalog = @( - "cycle", + "cycle", "transit_return", "carlin_local_service", "mainland_loop", "slotted_loot_seller", "slotted_loot_no_seller", "slotted_loot_deposit_restart", "sell_loot", "sell_loot_remote_depot", "real_depot", "depot_risk_fallback", "real_depot_restart_approach", "real_depot_restart_locker", "real_depot_restart_chest", "real_depot_restart_deposit", "real_depot_restart_depart", "real_depot_partial_move", "real_depot_rejected_move", @@ -81,7 +81,7 @@ $scenarioCatalog = @( "equipment_purchase_rejected", "adaptive_challenge", "mainland_equipment_reward", "oracle_departure", "oracle_level_eight_interrupt", "oracle_level_eight_recovery", "navigation", "navigation_recovery", "carlin_service_route", "mutable_portal_route", "patrol_recovery", "target_approach", "target_approach_unreachable", "target_attacker_priority", - "spell_training", "spell_training_shortlist", "spell_use", "spell_calibration", "magic_training_haste", "magic_training_great_light", + "spell_training", "spell_training_shortlist", "spell_training_low_supplies", "spell_training_low_supplies_unaffordable", "spell_use", "spell_calibration", "magic_training_haste", "magic_training_great_light", "magic_training_light", "magic_training_refresh", "magic_training_reserve", "magic_training_exact_full", "magic_training_pz", "magic_training_absent", "magic_training_expired", "magic_training_failed", "magic_training_service", "magic_training_progression", "magic_training_post_hunt", @@ -94,8 +94,8 @@ foreach ($scenarioName in $scenarioCatalog) { throw "Duplicate gameplay scenario name: $scenarioName" } } -if ($scenarioCatalog.Count -ne 87) { - throw "The gameplay scenario catalog must contain 87 scenarios; found $($scenarioCatalog.Count)." +if ($scenarioCatalog.Count -ne 90) { + throw "The gameplay scenario catalog must contain 90 scenarios; found $($scenarioCatalog.Count)." } $requestedScenarioNames = @($Scenario | ForEach-Object { $_ -split ',' } | Where-Object { $_ }) $exactScenarioSelection = $requestedScenarioNames.Count -gt 0 diff --git a/scripts/test-playerbot-navigation-assertions.ps1 b/scripts/test-playerbot-navigation-assertions.ps1 index a3e5ecc..350b402 100644 --- a/scripts/test-playerbot-navigation-assertions.ps1 +++ b/scripts/test-playerbot-navigation-assertions.ps1 @@ -1,6 +1,6 @@ #Requires -Version 7.0 -param([string]$DangerRetreatLogPath) +param([string]$DangerRetreatLogPath, [string]$InaccessibleCorpseLogPath) $ErrorActionPreference = "Stop" @@ -32,6 +32,28 @@ function Assert-Rejected { throw "$Name unexpectedly passed." } +$transitMarkers = "PLAYERBOT_GAMEPLAY_TEST TRANSIT_RETURN_START 100 100 7`nPLAYERBOT_GAMEPLAY_TEST TRANSIT_RETURN_STATE_PASS`n" +$transitStart = @{ event = "action_result"; action = "return"; result = "started"; reason = "startup"; + position = @{ x = 100; y = 100; z = 7 } } +$transitEnd = @{ event = "objective_transition"; to = "deposit_loot"; + position = @{ x = 97; y = 100; z = 7 } } +Assert-TransitReturnEvents -Logs ($transitMarkers + (ConvertTo-FixtureLogs @($transitStart, $transitEnd))) +Assert-Rejected "Crowd caused optional combat" { + Assert-TransitReturnEvents -Logs ($transitMarkers + (ConvertTo-FixtureLogs @($transitStart, + @{ event = "target_changed"; target_id = 42; reason = "defensive_attacker"; route_critical = $false }, $transitEnd))) +} "Transit return failed: optional combat" +Assert-Rejected "Crowd caused unjustified blocker combat" { + Assert-TransitReturnEvents -Logs ($transitMarkers + (ConvertTo-FixtureLogs @($transitStart, + @{ event = "target_changed"; target_id = 42; reason = "defensive_path_blocker"; route_critical = $true }, $transitEnd))) +} "Transit return failed: optional combat" +Assert-Rejected "Crowd prevented movement" { + Assert-TransitReturnEvents -Logs ($transitMarkers + (ConvertTo-FixtureLogs @($transitStart, + @{ event = "objective_transition"; to = "deposit_loot"; position = $transitStart.position }))) +} "Transit return failed: no ordinary return movement" +Assert-Rejected "No live crowd verification" { + Assert-TransitReturnEvents -Logs (ConvertTo-FixtureLogs @($transitStart, $transitEnd)) +} "Transit return failed: missing crowd fixture" + $dangerStart = @{ event = "objective_transition"; to = "return_to_depot"; reason = "hunt_region_observed_danger"; position = @{ x = 100; y = 100; z = 7 } } $dangerEnd = @{ event = "objective_transition"; to = "deposit_loot"; @@ -70,6 +92,61 @@ if ($DangerRetreatLogPath) { Assert-DangerRetreatEvents -Logs (Get-Content -Raw -LiteralPath $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 } + ) +} +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 + 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] } + "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) +} + function New-MainlandFixture { return @( @{ event = "action_result"; action = "hunt_cycle"; result = "started"; cycle = 1 } diff --git a/scripts/test-playerbot-supply-assertions.ps1 b/scripts/test-playerbot-supply-assertions.ps1 new file mode 100644 index 0000000..952c395 --- /dev/null +++ b/scripts/test-playerbot-supply-assertions.ps1 @@ -0,0 +1,66 @@ +#Requires -Version 7.0 +$ErrorActionPreference = 'Stop' +. $PSScriptRoot/playerbot-gameplay/log-parsing.ps1 +. $PSScriptRoot/playerbot-gameplay/assertions-progression.ps1 +. $PSScriptRoot/playerbot-gameplay/assertions-spells.ps1 + +function Test-Evidence([array]$Events, [scriptblock]$Assertion, [bool]$Reject = $false, [string]$Marker = '') { + $logs = ($Events | ForEach-Object { + $_.component = 'playerbot'; $_.bot = 'Bot One' + $_ | ConvertTo-Json -Compress + }) -join "`n" + try { & $Assertion ($logs + "`n" + $Marker) } + catch { if ($Reject) { return }; throw } + if ($Reject) { throw 'Invalid supply evidence unexpectedly passed.' } +} +$supply = @( + @{ event = 'supply_budget_fixture'; source = 'synthetic_runtime_candidates'; potions = 2; duration_seconds = 900; selected_variant = 1; budget_fits = $true; expected_potions = 1; scored_candidates = 2; selection_rule = 'supply_budget_then_xp' } + @{ event = 'supply_budget_fixture'; source = 'synthetic_runtime_candidates'; potions = 10; duration_seconds = 900; selected_variant = 2; budget_fits = $true; expected_potions = 8; scored_candidates = 2; selection_rule = 'supply_budget_then_xp' } + @{ event = 'supply_budget_fixture'; source = 'synthetic_runtime_candidates'; potions = 2; duration_seconds = 1500; selected_variant = 1; budget_fits = $false; expected_potions = 2; scored_candidates = 2; easy_expected_potions = 2; costly_expected_potions = 12; easy_budget_fits = $false; costly_budget_fits = $false; selection_rule = 'lowest_potion_consumption_then_xp' } +) +$checkSupply = { param($logs) Assert-SupplyBudgetEvents -Logs $logs } +Test-Evidence $supply $checkSupply +$supply[0].selected_variant = 2 +Test-Evidence $supply $checkSupply $true +$supply[0].selected_variant = 1 +$supply[1].budget_fits = $false +Test-Evidence $supply $checkSupply $true +$supply[1].budget_fits = $true +# Neither the old max-XP fallback nor treating this as a fitting hunt may pass. +foreach ($mutation in @( + @{ selected_variant = 2; expected_potions = 12 } + @{ selected_variant = 0 } + @{ selection_rule = 'safe_xp_budget_fallback' } + @{ easy_budget_fits = $true } + @{ budget_fits = $true } + @{ costly_expected_potions = 8 } + @{ duration_seconds = 900 } +)) { + $original = $supply[2] + $supply[2] = $original.Clone() + foreach ($key in $mutation.Keys) { $supply[2][$key] = $mutation[$key] } + Test-Evidence $supply $checkSupply $true + $supply[2] = $original +} +Test-Evidence $supply[0..1] $checkSupply $true +Test-Evidence $supply $checkSupply + +$spell = @( + @{ event = 'spell_candidate'; spell = 'Light Healing'; price = 170; reserve = 100; result = 'feasible' } + @{ event = 'goal_candidate'; goal = 'learn_spell'; feasible = $true; reason = 'priority_recovery_spell' } + @{ event = 'goal_selection'; to_goal = 'learn_spell' } + @{ event = 'action_result'; action = 'learn_spell'; result = 'success'; spell = 'Light Healing'; money_before = 270; money_after = 100 } +) +$checkSpell = { param($logs) Assert-LowSupplySpellTrainingEvents -Logs $logs } +$marker = 'PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_LOW_SUPPLIES_PASS' +Test-Evidence $spell $checkSpell $false $marker +Test-Evidence $spell $checkSpell $true +$spell[3].money_after = 99 +Test-Evidence $spell $checkSpell $true $marker +$spell[3].money_after = 100 +Test-Evidence (@(@{ event = 'goal_selection'; to_goal = 'buy_equipment' }) + $spell) $checkSpell $true $marker +$rejected = @(@{ event = 'spell_candidate'; spell = 'Light Healing'; price = 170; reserve = 100; result = 'rejected'; reason = 'unaffordable_after_reserves' }) +$checkRejected = { param($logs) Assert-LowSupplySpellTrainingEvents -Logs $logs -Unaffordable } +Test-Evidence $rejected $checkRejected +Test-Evidence ($rejected + $spell[3]) $checkRejected $true +Write-Host 'Supply and Exura telemetry assertion contracts passed.' diff --git a/scripts/test-playerbot-supply-fixture.lua b/scripts/test-playerbot-supply-fixture.lua new file mode 100644 index 0000000..e778129 --- /dev/null +++ b/scripts/test-playerbot-supply-fixture.lua @@ -0,0 +1,33 @@ +-- Run from the repository root: lua scripts/test-playerbot-supply-fixture.lua +PlayerbotGameplayFixture = {potionItemId = 7618} +dofile('server/tests/playerbot-gameplay/includes/verifiers.inc') +local F = PlayerbotGameplayFixture +local learned, money, potions, scheduled, marker +local originalPrint = print +print = function(value) marker = value end +Player = function() + return { + isRemoved = function() return false end, + hasLearnedSpell = function(_, name) return name == 'Light Healing' and learned end, + getMoney = function() return money end, + getBankBalance = function() return 0 end, + getItemCount = function(_, id) assert(id == 7618); return potions end, + } +end +addEvent = function(callback, delay, id, attempts) + assert(callback == F.verifyLowSupplySpellTraining and delay == 100 and id == 1 and attempts == 1) + scheduled = true +end +learned, money, potions = false, 270, 2 +F.verifyLowSupplySpellTraining(1, 2) +assert(scheduled and not marker) +assert(not pcall(F.verifyLowSupplySpellTraining, 1, 0), 'unlearned Exura passed') +learned, money = true, 100 +F.verifyLowSupplySpellTraining(1, 0) +assert(marker == 'PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_LOW_SUPPLIES_PASS') +for _, invalid in ipairs({{99, 2}, {101, 2}, {100, 1}, {100, 3}}) do + money, potions = invalid[1], invalid[2] + assert(not pcall(F.verifyLowSupplySpellTraining, 1, 0), 'incorrect payment/potion reserve passed') +end +print = originalPrint +print('Low-supply Exura live verifier contracts passed.') diff --git a/scripts/test-playerbot-transit-fixture.lua b/scripts/test-playerbot-transit-fixture.lua new file mode 100644 index 0000000..c46a955 --- /dev/null +++ b/scripts/test-playerbot-transit-fixture.lua @@ -0,0 +1,84 @@ +-- Run from the repository root: lua scripts/test-playerbot-transit-fixture.lua +Position = function(x, y, z) return {x = x, y = y, z = z} end +PlayerbotGameplayFixture = { + depotPosition = Position(32105, 32195, 8), defensiveMonsterName = "threat", + suppressNearbyMonsters = function() end, +} +dofile("server/tests/playerbot-gameplay/includes/combat.inc") +local F = PlayerbotGameplayFixture +local position, queued, monsters, sealed, missingTile +-- Ground-only tiles extracted from World.otbm and checked against items.otb. +-- Unlike the old all-walkable mock, unlisted tiles (including the east stalls) +-- cannot satisfy fixture geometry. +local ground = { + ["32105,32191,8"] = 8423, ["32105,32192,8"] = 8423, + ["32105,32193,8"] = 8423, ["32105,32194,8"] = 8423, + ["32105,32195,8"] = 8423, + ["32104,32190,8"] = 8423, ["32105,32190,8"] = 8423, + ["32106,32190,8"] = 8426, ["32104,32191,8"] = 8428, +} +local player = { + getId = function() return 7 end, + isRemoved = function() return false end, + getPosition = function() return position end, + teleportTo = function(_, value) position = value; return true end, +} +Player = function(id) assert(id == 7); return player end +Tile = function(at) + local key = at.x .. "," .. at.y .. "," .. at.z + if not ground[key] or key == missingTile then return nil end + return {isWalkable = function() return not sealed end} +end +Monster = function(id) return monsters[id] end +Game = {createMonster = function(name, at) + assert(name == F.defensiveMonsterName) + assert(math.abs(at.x - position.x) <= 1 and math.abs(at.y - position.y) <= 1) + assert(at.z == position.z and (at.y < position.y or at.x < position.x), + "attacker must leave the southward route empty") + assert(Tile(at) and Tile(at):isWalkable()) + local id = #monsters + 1 + local monster = { + getId = function() return id end, + selectTarget = function(_, target) assert(target == player); return true end, + getHealth = function() return 1 end, + getMaxHealth = function() return 1 end, + } + monsters[id] = monster + return monster +end} +addEvent = function(fn, delay, id, ids, attempts) + assert(fn == F.verifyTransitReturn and delay == 100 and id == 7 and #ids == 4) + queued = function() queued = nil; fn(id, ids, attempts) end +end +local function prepare() + monsters, queued, sealed, missingTile = {}, nil, false, nil + F.prepareTransitReturn(player) + assert(#monsters == 4 and position.x == 32105 and position.y == 32191 and position.z == 8 and queued) +end +prepare() +queued() +assert(queued, "stationary bot must not pass arrival") +position = F.depotPosition +queued() +assert(not queued, "arrival must end polling") +prepare() +monsters[1] = nil +local ok, err = pcall(queued) +assert(not ok and err:find("optional attacker was attacked", 1, true)) +prepare() +ok, err = pcall(function() while queued do queued() end end) +assert(not ok and err:find("within 30 seconds", 1, true) and not queued) +sealed = true +ok, err = pcall(F.prepareTransitReturn, player) +assert(not ok and err:find("open southward depot route", 1, true)) +sealed = false +for _, case in ipairs({ + {"32105,32193,8", "open southward depot route"}, + {"32106,32190,8", "four adjacent attacker tiles"}, +}) do + missingTile = case[1] + monsters, queued = {}, nil + ok, err = pcall(F.prepareTransitReturn, player) + assert(not ok and err:find(case[2], 1, true) and #monsters == 0 and not queued) +end +print("PASS transit fixture: map-backed hall/stall geometry, four attackers, untouched arrival, lost attacker, polling bound, blocked/missing route and attacker tiles") diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index 467a713..215e6b8 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -54,6 +54,8 @@ namespace { observation.topologyGeneration = PlayerBotTopology::instance().generation(); observation.canUseRope = g_game.findItemOfType(&player, ropeItemId, true) != nullptr; observation.canUseShovel = g_game.findItemOfType(&player, 2554, true) != nullptr; + observation.potions = static_cast(player).getItemTypeCount(recoveryPotionItemId(player.getVocationId())); + observation.mana = player.getMana(); return observation; } @@ -377,8 +379,7 @@ bool PlayerBotController::attackDefensiveThreat(Player* player, const Position& creature->getAttackedCreature() == player && player->canSee(creature->getPosition()) && Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition()); }); - const bool overwhelmed = adjacentAttackers >= 4; - if (!overwhelmed || huntCoordinator.retreatingFromDanger()) { + if (huntCoordinator.inTransit() || adjacentAttackers < 4) { for (Creature* creature : spectators) { if (!creature->getMonster() || creature->isRemoved() || creature->isDead() || !player->canSee(creature->getPosition()) || @@ -396,13 +397,12 @@ bool PlayerBotController::attackDefensiveThreat(Player* player, const Position& auto isRouteCritical = [this, now](const Creature* creature) { return navigationRuntime.isRouteCritical(creature->getID(), creature->getPosition(), now); }; - const bool blockerOnly = (huntCoordinator.huntActive() && !huntRegionReached) || - turnRouter.scenarioStage() == ScenarioStage::LootCorpse; + const bool blockerOnly = huntCoordinator.inTransit(); std::vector candidates; for (Creature* creature : spectators) { const bool routeCritical = isRouteCritical(creature); if (!creature->getMonster() || creature->isRemoved() || creature->isDead() || - (!routeCritical && ((blockerOnly && !overwhelmed) || creature->getAttackedCreature() != player)) || + (!routeCritical && (blockerOnly || creature->getAttackedCreature() != player)) || !player->canSee(creature->getPosition()) || !Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition())) { continue; @@ -468,8 +468,8 @@ void PlayerBotController::processDefensiveCombat(Player* player, const Position& { const auto defensive = huntCoordinator.defensiveTarget(); Creature* target = defensive ? g_game.getCreatureByID(defensive->id) : nullptr; - if (huntCoordinator.retreatingFromDanger() && target && target->getPosition() != defensive->position) { - finishDefensiveCombat(player, currentPosition, "skipped", "danger_retreat_blocker_moved"); + if (huntCoordinator.inTransit() && target && target->getPosition() != defensive->position) { + finishDefensiveCombat(player, currentPosition, "skipped", "transit_blocker_moved"); schedule(navigationInterval); return; } @@ -669,6 +669,15 @@ void PlayerBotController::emitHuntRegionCandidate(const PlayerBotHuntRegion& reg << ",\"stamina_experience_multiplier\":" << region.staminaExperienceMultiplier << ",\"projected_experience\":" << region.projectedExperience << ",\"optimistic_projected_experience\":" << region.optimisticProjectedExperience + << ",\"route_validated\":" << (region.routeValidated ? "true" : "false") + << ",\"supply_estimate_source\":\"static_duration_budget\"" + << ",\"supply_budget_fits\":" << (region.supplyBudget.fits ? "true" : "false") + << ",\"supply_expected_damage\":" << region.supplyBudget.expectedDamage + << ",\"supply_regeneration_healing\":" << region.supplyBudget.regenerationHealing + << ",\"supply_spell_healing\":" << region.supplyBudget.spellHealing + << ",\"supply_expected_potions\":" << region.supplyBudget.expectedPotions + << ",\"supply_reserved_potions\":" << region.supplyBudget.reservedPotions + << ",\"supply_routine_potions\":" << region.supplyBudget.routinePotions << ",\"threat_ratio\":" << region.threatRatio << ",\"raw_threat_ratio\":" << region.rawThreatRatio << ",\"corridor_danger_available\":" << (region.corridorDangerAvailable ? "true" : "false") @@ -824,6 +833,7 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit huntRouteCandidateIndex = 0; huntRouteCandidatesValidated = 0; huntRouteValidationCandidate.reset(); + huntRouteBestCandidate.reset(); huntRouteRejectedVariants.clear(); } fixtureDriver.observeHuntPlanning(outcome); @@ -908,7 +918,6 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); return false; } - std::optional safeSelection; if (huntRouteValidationCandidate) { PlayerBotHuntRegion routed = *huntRouteValidationCandidate; const PlayerBotNavigationRoutePlan returnPlan = planCompleteNavigationRoute(player, routed.destination, position); @@ -927,10 +936,29 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); return false; } - huntReturnRouteDangerCost = returnPlan.metrics.dangerCost; - huntReturnDestination = position; - safeSelection = std::move(routed); + routed.routeValidated = true; + routed.returnRouteDangerCost = returnPlan.metrics.dangerCost; + const uint32_t returnReserve = recoveryPotionRouteReserve(player.getVocationId(), player.getMaxHealth(), + routed.returnRouteDangerCost, static_cast(risk.healthLossCost)); + const uint32_t outboundReserve = routed.routeDangerCost == 0 ? 0 : + recoveryPotionRouteReserve(player.getVocationId(), player.getMaxHealth(), routed.routeDangerCost, + static_cast(risk.healthLossCost)); + routed.supplyProfile = huntPlanningFacts(player, huntCombatProfile(player)).supply; + routed.reconcileSupplies(static_cast(std::min(UINT32_MAX, + static_cast(returnReserve) + outboundReserve))); + emitHuntRegionCandidate(routed, position); + if (!huntRouteBestCandidate || playerBotPreferHuntRegion(routed, *huntRouteBestCandidate)) { + huntRouteBestCandidate = std::move(routed); + } + ++huntRouteCandidateIndex; + huntRouteValidationCandidate.reset(); + if (huntRouteCandidateIndex < outcome.candidates.size() && huntRouteCandidatesValidated < maximumHuntRouteCandidates) { + if (retryAfter) *retryAfter = std::chrono::milliseconds(SCHEDULER_MINTICKS); + return false; + } } + std::optional safeSelection = std::move(huntRouteBestCandidate); + huntRouteBestCandidate.reset(); if (!safeSelection) { applyRouteRejections(); huntRouteCandidateIndex = 0; @@ -948,6 +976,8 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit huntPatrolValidationDestination.reset(); huntPatrolValidatedDestination.reset(); PlayerBotHuntRegion selected = std::move(*safeSelection); + huntReturnRouteDangerCost = selected.returnRouteDangerCost; + huntReturnDestination = position; const uint32_t healthLossCost = static_cast(risk.healthLossCost); huntPotionReturnThreshold = recoveryPotionRouteReserve( player.getVocationId(), player.getMaxHealth(), huntReturnRouteDangerCost, healthLossCost); @@ -960,8 +990,8 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit std::to_string(huntPotionReturnThreshold) + ",\"restock_target\":" + std::to_string(huntPotionRestockTarget)); huntCoordinator.selectPlanningRegion(selected, huntPlayerObservation(player), now); - emitHuntRegionCandidate(selected, position); emit("hunt_region_selection", position, "\"result\":\"selected\",\"region_id\":" + std::to_string(selected.id) + + ",\"selection_rule\":" + jsonString(playerBotHuntSelectionRule(selected)) + ",\"atlas_site_id\":" + std::to_string(selected.atlasSiteId) + ",\"atlas_variant_id\":" + std::to_string(selected.atlasVariantId) + ",\"atlas_pockets\":" + std::to_string(selected.atlasPocketCount) + ",\"atlas_spawns\":" + std::to_string(selected.atlasSpawnCount) + ",\"atlas_floors\":" + @@ -983,7 +1013,9 @@ void PlayerBotController::beginHuntCycle(Player* player, const Position& positio pendingHuntCompletionReason.clear(); const uint32_t duration = static_cast(std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS))); huntCoordinator.beginHuntCycle(std::chrono::steady_clock::now(), duration); - huntRegionReached = false; + // A fallback patrol is already a deliberate hunt; selected regions have a + // separate outbound leg before the bot enters their observed hunt area. + huntRegionReached = !fixtureDriver.huntObservation().selectRegion; resetNavigation(); emit("action_result", position, "\"action\":\"hunt_cycle\",\"result\":\"started\",\"cycle\":" + std::to_string(huntCoordinator.completedHuntCycles()) + ",\"duration_seconds\":" + std::to_string(duration)); schedule(SCHEDULER_MINTICKS); @@ -1017,6 +1049,7 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre huntCoordinator.insideHuntArea(currentPosition, Map::maxClientViewportX, Map::maxClientViewportX + 1, Map::maxClientViewportY, Map::maxClientViewportY + 1)) { huntRegionReached = true; + huntCoordinator.enterHuntArea(); const PlayerBotHuntPatrolOutcome patrol = huntCoordinator.huntPatrolTarget(); emit("hunt_area_entered", currentPosition, "\"region_id\":" + (patrol.regionId ? std::to_string(*patrol.regionId) : "null") + diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index c7f1ace..b145a20 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -1244,6 +1244,8 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr if (navigationOutcome) *navigationOutcome = outcome; fixtureDriver.observeNavigationPlan(outcome.plan.attempted); if (outcome.destinationReached) { + // A navigation leg (for example, coarse NPC approach) can finish + // without completing the transit goal or renewing blocker attempts. resetNavigation(); return true; } @@ -1423,9 +1425,21 @@ void PlayerBotController::navigate() return; } } - // Healing may consume consecutive turns; it must not extend blocker combat. - if (huntCoordinator.dangerDefenseExpired(std::chrono::steady_clock::now())) { - finishDefensiveCombat(player, currentPosition, "skipped", "danger_retreat_combat_budget"); + const bool transit = PlayerBotTransitCombat::required(turnRouter.cyclePhase(), turnRouter.scenarioStage(), + huntRegionReached, progressionRuntime.session().active() != PlayerBotProgressionProcedure::None); + const bool transitChanged = huntCoordinator.observeTransit(transit, progressionRuntime.decisionId(), turnRouter.cyclePhase()); + // Reconcile before healing and combat dispatch: a previous hunt target cannot + // hold a new travel goal hostage, even when recovery consumes consecutive turns. + if (transit && transitChanged) { + if (huntCoordinator.hasDefensiveCombat()) { + finishDefensiveCombat(player, currentPosition, "skipped", "transit_goal_changed"); + } + if (huntCoordinator.traversalTarget()) { + finishTraversalCombat(player, currentPosition, "transit_goal_changed"); + } + } + if (huntCoordinator.transitDefenseExpired(std::chrono::steady_clock::now())) { + finishDefensiveCombat(player, currentPosition, "skipped", "transit_combat_budget"); } const bool accessingReward = progressionRuntime.session().active(PlayerBotProgressionProcedure::PickupReward) && (progressionRuntime.reward().stage() == PlayerBotRewardStage::VerifyReward || diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index 099bd5e..864df70 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -300,7 +300,7 @@ class PlayerBotController : public std::enable_shared_from_this learningPriority = std::nullopt) const; @@ -533,6 +533,7 @@ class PlayerBotController : public std::enable_shared_from_this huntRouteValidationCandidate; + std::optional huntRouteBestCandidate; std::vector huntRouteRejectedVariants; Position huntReturnDestination; uint32_t huntReturnRouteDangerCost = 0; diff --git a/server/src/playerbotdangerretreat.h b/server/src/playerbotdangerretreat.h deleted file mode 100644 index aa00930..0000000 --- a/server/src/playerbotdangerretreat.h +++ /dev/null @@ -1,37 +0,0 @@ -/** Pure danger-return combat policy. Navigation resets must not end retreat. */ -#ifndef FS_PLAYERBOTDANGERRETREAT_H -#define FS_PLAYERBOTDANGERRETREAT_H - -#include -#include -#include - -class PlayerBotDangerRetreat -{ - public: - void begin() { retreating = true; } - void finish() { retreating = false; attempted.clear(); } - bool active() const { return retreating; } - bool allowsDefense(uint32_t id, bool routeCritical) const - { - return !retreating || (routeCritical && attempted.count(id) == 0); - } - void beginDefense(uint32_t id, std::chrono::steady_clock::time_point now) - { - if (!retreating) return; - attempted.insert(id); - deadline = now + std::chrono::seconds(5); - } - bool defenseExpired(std::chrono::steady_clock::time_point now) const - { - return retreating && now >= deadline; - } - - private: - bool retreating = false; - // One short attempt per blocker per return; timeout must not restart combat. - std::set attempted; - std::chrono::steady_clock::time_point deadline; -}; - -#endif diff --git a/server/src/playerbotfixturedriver.cpp b/server/src/playerbotfixturedriver.cpp index 2991142..3fcb3dc 100644 --- a/server/src/playerbotfixturedriver.cpp +++ b/server/src/playerbotfixturedriver.cpp @@ -321,6 +321,50 @@ std::vector playerbot::PlayerBotFixtureDriver: std::ostringstream fields; fields << std::fixed << std::setprecision(2) << "\"recovery_total\":" << recovery.totalMinimumHealing << ",\"recovery_spell_legal\":" << (recovery.lightHealingLegal ? "true" : "false") << ",\"recovery_spell_casts\":" << recovery.spellCasts << ",\"equipment_pressure_before\":" << current.threatRatio << ",\"equipment_pressure_after\":" << equipped.threatRatio << ",\"idle_observed_seconds\":" << idle << ",\"active_observed_seconds\":" << active << ",\"higher_score_preferred\":" << (playerBotPreferHuntRegion(higherScore, lowerScore) ? "true" : "false") << ",\"closest_target_preferred\":" << (preferredTarget && preferredTarget->target.id == 1 ? "true" : "false") << ",\"wounded_lethal\":" << (playerBotPredictedLethal(40, 40) ? "true" : "false") << ",\"zero_health_lethal\":" << (playerBotPredictedLethal(0, 0) ? "true" : "false") << ",\"helper_scope_exhausted\":" << (playerBotHuntScopeExhausted(exhausted) ? "true" : "false") << ",\"capacity_before_grace\":" << (capacityBeforeGrace ? "true" : "false") << ",\"capacity_at_grace\":" << (capacityAtGrace ? "true" : "false") << ",\"capacity_cycle_reset\":" << (capacityReset ? "true" : "false") << ",\"knight_route_reserve\":" << knightRouteReserve << ",\"rook_route_reserve\":" << rookRouteReserve << ",\"high_health_route_reserve\":" << highHealthRouteReserve << ",\"high_health_restock_target\":" << highHealthRestockTarget << ",\"net_value_loss_rejected\":" << (netValueLossRejected ? "true" : "false") << ",\"currency_priority_override\":" << (currencyPriorityOverride ? "true" : "false") << ",\"currency_hunt_capacity_excluded\":" << (currencyExcludedFromHuntCapacity ? "true" : "false") << ",\"large_restock_batched\":" << (largeRestockBatched ? "true" : "false") << ",\"preferred_food_consumed\":" << (preferredFoodConsumed ? "true" : "false") << ",\"missing_food_ignored\":" << (missingFoodIgnored ? "true" : "false") << ",\"food_replenished_after_eating\":" << (foodReplenishedAfterEating ? "true" : "false"); events.push_back({"adaptive_challenge_fixture", fields.str()}); + // Exercise the real bounded scoring session and runtime selection with + // deterministic candidate facts, not an authored live hunt list. + for (const auto& [potions, duration] : {std::pair{2, 900}, {10, 900}, {2, 1500}}) { + PlayerBotHuntRuntime runtime(std::vector{}); + PlayerBotHuntRuntimePlanningInput input; + input.player.health = input.player.maximumHealth = 200; + input.player.potions = potions; + input.huntDurationSeconds = duration; + input.start.emplace(); + input.start->scan.candidateCount = 2; + input.start->scan.candidateIndices = {0, 1}; + input.start->profile.currentHealth = 200; + const auto now = std::chrono::steady_clock::now(); + runtime.advancePlanning(input, now); + const auto work = runtime.advancePlanning(input, now); + std::vector scores; + for (const auto& candidate : work.scoreWork) { + PlayerBotHuntRegion region; + region.atlasVariantId = candidate.candidateIndex + 1; + region.suitable = region.reachable = true; + region.experiencePerMinute = candidate.candidateIndex == 0 ? 10 : 100; + region.expectedDamagePerSecond = candidate.candidateIndex == 0 ? 0.1 : 1; + region.combatFraction = 0.5; + region.supplyProfile.potions = potions; + region.supplyProfile.potionHealing = 125; + region.reconcileTravel(duration, 0, 1); + scores.push_back({candidate.candidateIndex, true, true, true, std::move(region)}); + } + runtime.completeScoreWork(scores, 0); + const auto selected = runtime.advancePlanning(input, now); + std::ostringstream supply; + supply << "\"source\":\"synthetic_runtime_candidates\",\"potions\":" << potions + << ",\"duration_seconds\":" << duration + << ",\"easy_expected_potions\":" << (scores.size() == 2 ? scores[0].region.supplyBudget.expectedPotions : -1) + << ",\"costly_expected_potions\":" << (scores.size() == 2 ? scores[1].region.supplyBudget.expectedPotions : -1) + << ",\"easy_budget_fits\":" << (scores.size() == 2 && scores[0].region.supplyBudget.fits ? "true" : "false") + << ",\"costly_budget_fits\":" << (scores.size() == 2 && scores[1].region.supplyBudget.fits ? "true" : "false") + << ",\"selection_rule\":" << jsonString(selected.selectedRegion ? playerBotHuntSelectionRule(*selected.selectedRegion) : "none") + << ",\"scored_candidates\":" << scores.size() + << ",\"selected_variant\":" << (selected.selectedRegion ? selected.selectedRegion->atlasVariantId : 0) + << ",\"budget_fits\":" << (selected.selectedRegion && selected.selectedRegion->supplyBudget.fits ? "true" : "false") + << ",\"expected_potions\":" << (selected.selectedRegion ? selected.selectedRegion->supplyBudget.expectedPotions : -1); + events.push_back({"supply_budget_fixture", supply.str()}); + } return events; } diff --git a/server/src/playerbotgoalplanner.cpp b/server/src/playerbotgoalplanner.cpp index e312da6..292c339 100644 --- a/server/src/playerbotgoalplanner.cpp +++ b/server/src/playerbotgoalplanner.cpp @@ -76,6 +76,19 @@ std::vector PlayerBotGoalPlanner::candidate !snapshot.sellLootCoolingDown && snapshot.sellLootPlanAvailable && snapshot.sellLootUtility > huntGoalUtility ? snapshot.sellLootUtility : 0, snapshot.sellLootCoolingDown ? "cooldown" : snapshot.sellLootPlanAvailable && snapshot.sellLootUtility > huntGoalUtility ? snapshot.sellLootReason : snapshot.sellLootPlanAvailable ? "utility_below_hunt" : snapshot.sellLootReason}; - return {departure, service, pickup, spell, equipment, magic, sellLoot, + std::vector result{departure, service, pickup, spell, equipment, magic, sellLoot, {Goal::Hunt, true, huntGoalUtility, "autonomous_hunting_available"}}; + if (snapshot.recoverySpellPlanAvailable && spell.feasible) { + for (auto& candidate : result) { + if (candidate.goal == Goal::LearnSpell) { + candidate.reason = "priority_recovery_spell"; + } else if (candidate.goal != Goal::Departure && + !(candidate.goal == Goal::Service && + (snapshot.lowCapacity || snapshot.criticalHealing || snapshot.missingPotions != 0))) { + candidate.feasible = false; + candidate.reason = "deferred_recovery_spell"; + } + } + } + return result; } diff --git a/server/src/playerbotgoalplanner.h b/server/src/playerbotgoalplanner.h index f0f1f68..21f323c 100644 --- a/server/src/playerbotgoalplanner.h +++ b/server/src/playerbotgoalplanner.h @@ -41,6 +41,7 @@ struct PlayerBotGoalPlannerSnapshot { bool sellLootPlanAvailable = false; int32_t sellLootUtility = 0; std::string sellLootReason; + bool recoverySpellPlanAvailable = false; }; class PlayerBotGoalPlanner { diff --git a/server/src/playerbothuntcoordinator.cpp b/server/src/playerbothuntcoordinator.cpp index ef51b6e..b9e1551 100644 --- a/server/src/playerbothuntcoordinator.cpp +++ b/server/src/playerbothuntcoordinator.cpp @@ -16,7 +16,7 @@ PlayerBotHuntCoordinator::PlayerBotHuntCoordinator( std::optional PlayerBotHuntCoordinator::selectTraversalAttack( std::vector candidates, const Position& currentPosition, std::chrono::steady_clock::time_point now) { - if (dangerRetreat.active()) return std::nullopt; + if (transitCombat.active()) return std::nullopt; return combatRuntime.selectTraversalAttack(std::move(candidates), currentPosition, now); } @@ -24,7 +24,7 @@ std::optional PlayerBotHuntCoordinator::selectDefensive std::vector candidates, const Position& currentPosition) const { candidates.erase(std::remove_if(candidates.begin(), candidates.end(), [this](const PlayerBotDefensiveTarget& target) { - return !dangerRetreat.allowsDefense(target.id, target.routeCritical); + return !transitCombat.allowsDefense(target.id, target.routeCritical); }), candidates.end()); return combatRuntime.selectDefensiveAttack(std::move(candidates), currentPosition); } @@ -34,7 +34,7 @@ PlayerBotCombatDecision PlayerBotHuntCoordinator::confirmCombatAttack(const Play { const auto result = combatRuntime.confirmAttack(command, accepted, now); if (accepted && result.command == PlayerBotCombatCommand::AttackDefensive) { - dangerRetreat.beginDefense(command.target.id, now); + transitCombat.beginDefense(command.target.id, now); } return result; } diff --git a/server/src/playerbothuntcoordinator.h b/server/src/playerbothuntcoordinator.h index df342a1..a703f9b 100644 --- a/server/src/playerbothuntcoordinator.h +++ b/server/src/playerbothuntcoordinator.h @@ -14,7 +14,7 @@ #include #include "playerbotcombatruntime.h" -#include "playerbotdangerretreat.h" +#include "playerbottransitcombat.h" #include "playerbothuntruntime.h" #include "playerbotlootworkflow.h" @@ -82,12 +82,18 @@ class PlayerBotHuntCoordinator int64_t lootElapsedMilliseconds(std::chrono::steady_clock::time_point now) const; std::chrono::steady_clock::time_point lootNavigationRetryAt() const; - void beginDangerRetreat() { dangerRetreat.begin(); } - void finishDangerRetreat() { dangerRetreat.finish(); } - bool retreatingFromDanger() const { return dangerRetreat.active(); } - bool dangerDefenseExpired(std::chrono::steady_clock::time_point now) const + void beginDangerRetreat() { dangerReturn = true; } + void finishDangerRetreat() { dangerReturn = false; } + bool retreatingFromDanger() const { return dangerReturn; } + bool observeTransit(bool transit, uint64_t goal, PlayerBotCyclePhase phase) { - return hasDefensiveCombat() && dangerRetreat.defenseExpired(now); + return transitCombat.observe(transit, goal, phase); + } + void enterHuntArea() { transitCombat.finish(); } + bool inTransit() const { return transitCombat.active(); } + bool transitDefenseExpired(std::chrono::steady_clock::time_point now) const + { + return hasDefensiveCombat() && transitCombat.defenseExpired(now); } void cancelPlanning(); @@ -138,7 +144,9 @@ class PlayerBotHuntCoordinator void applyCooldown(const std::optional& command, std::chrono::steady_clock::time_point now); - PlayerBotDangerRetreat dangerRetreat; + // Danger return also defers departure arbitration; combat uses the general transit policy. + bool dangerReturn = false; + PlayerBotTransitCombat transitCombat; PlayerBotCombatRuntime combatRuntime; PlayerBotLootWorkflow lootWorkflow; PlayerBotHuntRuntime huntRuntime; diff --git a/server/src/playerbothuntplanningsession.cpp b/server/src/playerbothuntplanningsession.cpp index 5afb4f7..ea35e23 100644 --- a/server/src/playerbothuntplanningsession.cpp +++ b/server/src/playerbothuntplanningsession.cpp @@ -29,6 +29,7 @@ bool PlayerBotHuntPlanningSession::invalidated(const PlayerBotHuntPlanningSnapsh { return current.playerPosition != planningSnapshot.playerPosition || current.playerLevel != planningSnapshot.playerLevel || current.currentHealth < planningSnapshot.currentHealth || current.staminaMinutes != planningSnapshot.staminaMinutes || + current.potions != planningSnapshot.potions || current.mana < planningSnapshot.mana || current.topologyGeneration != planningSnapshot.topologyGeneration || current.canUseRope != planningSnapshot.canUseRope || current.canUseShovel != planningSnapshot.canUseShovel || current.excludedVariants != planningSnapshot.excludedVariants || current.cacheRevision != planningSnapshot.cacheRevision; @@ -60,10 +61,7 @@ PlayerBotHuntPlanningProgress PlayerBotHuntPlanningSession::completeScoring() ++yieldCount; return PlayerBotHuntPlanningProgress::ScoringYield; } - std::stable_sort(scoredRegions.begin(), scoredRegions.end(), [](const PlayerBotHuntRegion& left, const PlayerBotHuntRegion& right) { - if (left.suitable != right.suitable) return left.suitable; - return left.score > right.score; - }); + std::stable_sort(scoredRegions.begin(), scoredRegions.end(), playerBotPreferHuntRegion); uint32_t regionId = 1; for (PlayerBotHuntRegion& region : scoredRegions) { region.id = regionId++; diff --git a/server/src/playerbothuntplanningsession.h b/server/src/playerbothuntplanningsession.h index 4e22a01..b374116 100644 --- a/server/src/playerbothuntplanningsession.h +++ b/server/src/playerbothuntplanningsession.h @@ -30,6 +30,8 @@ struct PlayerBotHuntPlanningSnapshot { std::set excludedVariants; bool canUseRope = false; bool canUseShovel = false; + uint32_t potions = 0; + uint32_t mana = 0; }; struct PlayerBotHuntPlanningStart { diff --git a/server/src/playerbothuntregionadapter.cpp b/server/src/playerbothuntregionadapter.cpp index 2dd17b0..6deca0a 100644 --- a/server/src/playerbothuntregionadapter.cpp +++ b/server/src/playerbothuntregionadapter.cpp @@ -6,6 +6,7 @@ #include "playerbottopology.h" #include "configmanager.h" +#include "condition.h" #include "game.h" #include "monsters.h" #include "player.h" @@ -411,6 +412,9 @@ namespace { std::set reachableMembers; double expectedCycleExperience = 0; double expectedClearSeconds = 0; + double spawnDamagePerSecond = 0; + double spawnCombatFraction = 0; + double crowdDamageInflation = 1; for (size_t member : cached.members) { const CachedSpawnBlock& spawn = huntAtlas.spawns[member]; const auto approach = nearestApproach(player, spawn.position, topologyDistances); @@ -442,6 +446,9 @@ namespace { (60000.0 / std::max(spawn.interval, 1)); expectedCycleExperience += monsterType->info.experience * probability; expectedClearSeconds += fightSeconds * probability; + const double spawnsPerSecond = probability * 1000.0 / std::max(spawn.interval, 1); + spawnDamagePerSecond += monsterProfile.predictedFightDamage * spawnsPerSecond; + spawnCombatFraction += fightSeconds * spawnsPerSecond; } } @@ -469,12 +476,16 @@ namespace { double remainingDamagePerSecond = 0; for (size_t index = 0; index < attackers; ++index) remainingDamagePerSecond += localAttackers[index].damagePerSecond; double fightDamage = 0; + double isolatedDamage = 0; double fightSeconds = 0; for (size_t index = 0; index < attackers; ++index) { fightDamage += remainingDamagePerSecond * localAttackers[index].fightSeconds; + isolatedDamage += localAttackers[index].damagePerSecond * localAttackers[index].fightSeconds; fightSeconds += localAttackers[index].fightSeconds; remainingDamagePerSecond -= localAttackers[index].damagePerSecond; } + crowdDamageInflation = std::max(crowdDamageInflation, + playerBotCrowdDamageInflation(fightDamage, isolatedDamage)); if (fightDamage > worstFightDamage) { worstFightDamage = fightDamage; worstFightSeconds = fightSeconds; @@ -499,6 +510,14 @@ namespace { if (region.clearExperiencePerMinute > 0) { region.experiencePerMinute = std::min(region.spawnExperiencePerMinute, region.clearExperiencePerMinute); } + const double throughput = region.spawnExperiencePerMinute > 0 ? + region.experiencePerMinute / region.spawnExperiencePerMinute : 1; + // Spawn rates already sum isolated fights. Apply only the largest local + // crowd/isolated ratio, not crowd damage divided by one monster's damage. + // This is conservative static prediction, not observed supply calibration. + region.expectedDamagePerSecond = spawnDamagePerSecond * throughput * crowdDamageInflation; + region.combatFraction = std::min(1.0, spawnCombatFraction * throughput); + region.supplyProfile = planningProfile.supply; region.destination = *std::min_element(region.patrolPoints.begin(), region.patrolPoints.end(), [&player](const Position& left, const Position& right) { const uint32_t leftDistance = Position::getDistanceX(player.getPosition(), left) + @@ -572,6 +591,7 @@ namespace { region.optimisticProjectedExperience = region.experiencePerMinute * region.observedCorrection * 1.5 * huntDurationSeconds / 60.0; region.score = region.projectedExperience; + region.reconcileSupplies(planningProfile.supply.reserve); region.reachable = withinPlanningScope; region.travelSteps = estimatedTravelSteps; return region; @@ -648,6 +668,23 @@ PlayerBotHuntPlanningProfile PlayerBotHuntRegionAdapter::planningProfile(const P playerbot::recoveryPotionItemId(player.getVocationId())); profile.potionMinimumHealing = playerbot::recoveryPotionMinimumHealing(player.getVocationId()); profile.challengeFrontier = challengeFrontier; + profile.supply.potions = profile.potionCount; + profile.supply.potionHealing = profile.potionMinimumHealing; + profile.supply.mana = profile.mana; + profile.supply.maximumMana = player.getMaxMana(); + if (Condition* food = player.getCondition(CONDITION_REGENERATION, CONDITIONID_DEFAULT)) { + profile.supply.regenerationSeconds = food->getTicks() == -1 ? + std::numeric_limits::max() : std::max(0, food->getTicks()) / 1000.0; + profile.supply.healthGain = std::max(0, food->getParam(CONDITION_PARAM_HEALTHGAIN)); + profile.supply.manaGain = std::max(0, food->getParam(CONDITION_PARAM_MANAGAIN)); + auto interval = [](int32_t ticks) { + // Conditions execute on creature ticks, resetting their counter on gain. + return ticks > 0 ? std::ceil(static_cast(ticks) / EVENT_CREATURE_THINK_INTERVAL) * + EVENT_CREATURE_THINK_INTERVAL / 1000.0 : 0; + }; + profile.supply.healthInterval = interval(food->getParam(CONDITION_PARAM_HEALTHTICKS)); + profile.supply.manaInterval = interval(food->getParam(CONDITION_PARAM_MANATICKS)); + } InstantSpell* spell = g_spells ? g_spells->getInstantSpellByName("Light Healing") : nullptr; if (!spell || spell->getWords() != "exura" || !spell->isLearnable() || !spell->isEnabled() || !spell->canCast(&player) || player.getLevel() < spell->getLevel() || @@ -660,6 +697,10 @@ PlayerBotHuntPlanningProfile PlayerBotHuntRegionAdapter::planningProfile(const P if (const PlayerBotSpellDescriptor* descriptor = playerBotSpellDescriptor("Light Healing")) { profile.lightHealingMinimum = playerBotSpellEnvelope(player, *descriptor).minimum; } + profile.supply.spellLegal = profile.lightHealingLegal; + profile.supply.spellMana = profile.lightHealingManaCost; + profile.supply.spellHealing = std::max(0, profile.lightHealingMinimum); + profile.supply.spellInterval = std::max(profile.lightHealingCooldown, 1) / 1000.0; return profile; } diff --git a/server/src/playerbothuntregions.cpp b/server/src/playerbothuntregions.cpp index 766bf47..fdcc2fb 100644 --- a/server/src/playerbothuntregions.cpp +++ b/server/src/playerbothuntregions.cpp @@ -42,14 +42,6 @@ bool playerBotPredictedLethal(int32_t currentHealth, double predictedDamage) return currentHealth <= 0 || predictedDamage >= currentHealth; } -bool playerBotPreferHuntRegion(const PlayerBotHuntRegion& left, const PlayerBotHuntRegion& right) -{ - const bool leftAvailable = left.suitable && left.reachable; - const bool rightAvailable = right.suitable && right.reachable; - if (leftAvailable != rightAvailable) return leftAvailable; - return left.score > right.score; -} - bool playerBotHuntScopeExhausted(const std::vector& regions) { return std::none_of(regions.begin(), regions.end(), [](const PlayerBotHuntRegion& region) { diff --git a/server/src/playerbothuntregions.h b/server/src/playerbothuntregions.h index 524bc88..0e4e8fa 100644 --- a/server/src/playerbothuntregions.h +++ b/server/src/playerbothuntregions.h @@ -12,6 +12,7 @@ #define FS_PLAYERBOTHUNTREGIONS_H #include "playerbotcombatprofile.h" +#include "playerbotsupplypolicy.h" #include "position.h" #include @@ -50,6 +51,7 @@ struct PlayerBotHuntPlanningProfile { int32_t lightHealingMinimum = 0; double challengeFrontier = 0; bool lightHealingLegal = false; + PlayerBotSupplyProfile supply; }; struct PlayerBotHuntMonsterProfile { @@ -103,6 +105,12 @@ struct PlayerBotHuntRegion { double challengeBandMinimum = 0; double challengeBandMaximum = 0; PlayerBotRecoveryPrediction recovery; + PlayerBotSupplyProfile supplyProfile; + PlayerBotSupplyBudget supplyBudget; + double expectedDamagePerSecond = 0; + double combatFraction = 0; + uint32_t returnRouteDangerCost = 0; + bool routeValidated = false; double score = 0; uint32_t travelSteps = 0; uint32_t topologyTravelSteps = 0; @@ -122,8 +130,16 @@ struct PlayerBotHuntRegion { staminaExperienceMultiplier = staminaMultiplier; projectedExperience = experiencePerMinute * observedCorrection * staminaExperienceMultiplier * availableHuntSeconds / 60.0; - // The scorer uses projected experience directly; route danger is an acceptance gate. + // XP is the tie-breaker within the duration-budget preference tier. score = projectedExperience; + reconcileSupplies(supplyProfile.reserve); + } + + void reconcileSupplies(uint32_t reserve) + { + supplyProfile.reserve = reserve; + supplyBudget = playerBotSupplyBudget(supplyProfile, expectedDamagePerSecond, combatFraction, + availableHuntSeconds, estimatedTravelSeconds); } }; @@ -165,7 +181,21 @@ PlayerBotHuntPlanningProfile playerBotHuntPlanningProfile(const Player& player, PlayerBotRecoveryPrediction playerBotPredictRecovery(const PlayerBotHuntPlanningProfile& profile, double predictedFightSeconds); bool playerBotPredictedLethal(int32_t currentHealth, double predictedDamage); -bool playerBotPreferHuntRegion(const PlayerBotHuntRegion& left, const PlayerBotHuntRegion& right); +inline bool playerBotPreferHuntRegion(const PlayerBotHuntRegion& left, const PlayerBotHuntRegion& right) +{ + const bool leftAvailable = left.suitable && left.reachable; + const bool rightAvailable = right.suitable && right.reachable; + if (leftAvailable != rightAvailable) return leftAvailable; + if (left.supplyBudget.fits != right.supplyBudget.fits) return left.supplyBudget.fits; + if (!left.supplyBudget.fits && left.supplyBudget.expectedPotions != right.supplyBudget.expectedPotions) { + return left.supplyBudget.expectedPotions < right.supplyBudget.expectedPotions; + } + return left.score > right.score; +} +inline const char* playerBotHuntSelectionRule(const PlayerBotHuntRegion& region) +{ + return region.supplyBudget.fits ? "supply_budget_then_xp" : "lowest_potion_consumption_then_xp"; +} bool playerBotHuntScopeExhausted(const std::vector& regions); class PlayerBotHuntRegionPlanner diff --git a/server/src/playerbothuntruntime.cpp b/server/src/playerbothuntruntime.cpp index dd04a45..2f8fb26 100644 --- a/server/src/playerbothuntruntime.cpp +++ b/server/src/playerbothuntruntime.cpp @@ -11,7 +11,7 @@ PlayerBotHuntRuntime::PlayerBotHuntRuntime(std::vector fallbackPatrol) PlayerBotHuntPlanningSnapshot PlayerBotHuntRuntime::snapshot(const PlayerBotHuntRuntimePlayerObservation& player, uint64_t revision) { return {player.position, player.level, player.health, player.staminaMinutes, revision, player.topologyGeneration, player.excludedVariants, - player.canUseRope, player.canUseShovel}; + player.canUseRope, player.canUseShovel, player.potions, player.mana}; } bool PlayerBotHuntRuntime::planningStartRequired(std::chrono::steady_clock::time_point now) const diff --git a/server/src/playerbothuntruntime.h b/server/src/playerbothuntruntime.h index 8f36fcd..11bd988 100644 --- a/server/src/playerbothuntruntime.h +++ b/server/src/playerbothuntruntime.h @@ -41,6 +41,8 @@ struct PlayerBotHuntRuntimePlayerObservation { std::set excludedVariants; bool canUseRope = false; bool canUseShovel = false; + uint32_t potions = 0; + uint32_t mana = 0; }; struct PlayerBotHuntRuntimeCooldownCommand { diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index 74f0063..9feff8e 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -1032,6 +1032,7 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos sellLootCoolingDown, sellLootPlan.has_value(), sellLootPlan ? static_cast(std::clamp( sellLootPlan->utility, std::numeric_limits::min(), std::numeric_limits::max())) : 0, sellLootPlan ? "profitable_liquidation_trip" : "no_profitable_liquidation_trip", + spellTrainingFound && spellTraining.spellName == "Light Healing", }; const PlayerBotGoalArbiter::GoalDecision decision = progressionRuntime.selectGoal(snapshot); emitGoalCandidate(player, decision.candidate(TopLevelGoal::Departure), decision.id, position, decisionReason, nullptr, diff --git a/server/src/playerbotprogressionplanners.cpp b/server/src/playerbotprogressionplanners.cpp index 851e5f0..2fccd27 100644 --- a/server/src/playerbotprogressionplanners.cpp +++ b/server/src/playerbotprogressionplanners.cpp @@ -2,6 +2,7 @@ #include "otpch.h" #include "playerbotprogressionplanners.h" +#include "playerbotsupplypolicy.h" #include #include @@ -34,12 +35,14 @@ PlayerBotSpellTrainingDecision PlayerBotSpellTrainingPlanner::select(const Playe PlayerBotSpellTrainingDecision decision; for (size_t offerIndex = 0; offerIndex < snapshot.offers.size(); ++offerIndex) { const auto& offer = snapshot.offers[offerIndex]; + const uint64_t reserve = offer.reserve.value_or(snapshot.reserve); + const bool reserveAvailable = offer.reserve ? *offer.reserve != UINT64_MAX : snapshot.reserveAvailable; const char* rejection = !offer.inScope ? "outside_thais_scope" : !offer.registryMatches ? "spell_registry_mismatch" : !offer.implementedUse ? "no_implemented_use" : !offer.vocationEligible ? "vocation_ineligible" : !offer.levelEligible ? "level_ineligible" : !offer.premiumEligible ? "premium_ineligible" : offer.known ? "already_learned" : - !offer.suppliesReady ? "supply_reserve_unmet" : !snapshot.reserveAvailable ? "recovery_reserve_unavailable" : - snapshot.totalMoney < snapshot.reserve + offer.price ? "unaffordable_after_reserves" : + !offer.suppliesReady ? "supply_reserve_unmet" : !reserveAvailable ? "recovery_reserve_unavailable" : + !playerBotAffordableAfterReserve(snapshot.totalMoney, reserve, offer.price) ? "unaffordable_after_reserves" : !offer.route.reachable ? "trainer_unreachable" : offer.route.dangerCost > snapshot.maximumRouteDangerCost ? "route_danger_above_tolerance" : offer.route.maximumDanger > snapshot.maximumRouteDanger ? "route_peak_danger_above_tolerance" : nullptr; @@ -48,7 +51,7 @@ PlayerBotSpellTrainingDecision PlayerBotSpellTrainingPlanner::select(const Playe continue; } PlayerBotSpellTrainingPlan candidate{offer.npcId, offer.npcPosition, offer.route.approachPosition, offer.spellName, - offer.keyword, offer.price, offer.level, offer.premium, offer.route.steps, snapshot.reserve}; + offer.keyword, offer.price, offer.level, offer.premium, offer.route.steps, reserve, offer.potionReserve}; const PlayerBotSpellOfferSnapshot* selectedOffer = decision.selectedOfferIndex ? &snapshot.offers[*decision.selectedOfferIndex] : nullptr; if (!selectedOffer || diff --git a/server/src/playerbotprogressionplanners.h b/server/src/playerbotprogressionplanners.h index 5f4c7a9..cdfbbb7 100644 --- a/server/src/playerbotprogressionplanners.h +++ b/server/src/playerbotprogressionplanners.h @@ -152,6 +152,8 @@ struct PlayerBotSpellOfferSnapshot { bool known = false; bool suppliesReady = false; PlayerBotRouteEstimate route; + std::optional reserve; + uint32_t potionReserve = 1; }; struct PlayerBotSpellTrainingPlannerSnapshot { diff --git a/server/src/playerbotprogressionsession.h b/server/src/playerbotprogressionsession.h index 78af51d..6ea5465 100644 --- a/server/src/playerbotprogressionsession.h +++ b/server/src/playerbotprogressionsession.h @@ -157,6 +157,7 @@ struct PlayerBotSpellTrainingPlan { bool premium = false; uint32_t travelSteps = 0; uint64_t reserve = 0; + uint32_t potionReserve = 1; }; enum class PlayerBotSpellTrainingStage : uint8_t { diff --git a/server/src/playerbotspells.cpp b/server/src/playerbotspells.cpp index 88c972a..ccfa9f1 100644 --- a/server/src/playerbotspells.cpp +++ b/server/src/playerbotspells.cpp @@ -278,9 +278,13 @@ bool PlayerBotController::tryOffensiveSpell(Player* player, const Position& curr survivalRuntime.decideOffensiveSpell(survivalSnapshot(*player, target), std::chrono::steady_clock::now())); } -uint64_t PlayerBotController::spellTrainingReserve(const Player& player) const +uint64_t PlayerBotController::spellTrainingReserve(const Player& player, bool emergencyOnly) const { const uint16_t potionItemId = recoveryPotionItemId(player.getVocationId()); + const uint32_t potionCount = inventoryPolicy.inventoryItemCount(player, potionItemId); + const uint32_t reserveTarget = emergencyOnly ? + (huntPotionReturnThreshold == UINT32_MAX ? UINT32_MAX : huntPotionReturnThreshold + 1) : healthPotionRestockTarget; + if (emergencyOnly && potionCount >= reserveTarget) return carriedGoldReserve; uint32_t potionPrice = std::numeric_limits::max(); for (Npc* npc : playerBotNpcProviders(g_game.getNpcs(), PlayerBotNpcCapability::Shop, player.getPosition())) { for (const ShopInfo& offer : npc->getShopOffers()) { @@ -292,10 +296,7 @@ uint64_t PlayerBotController::spellTrainingReserve(const Player& player) const if (potionPrice == std::numeric_limits::max()) { return std::numeric_limits::max(); } - const uint32_t potionCount = inventoryPolicy.inventoryItemCount(player, potionItemId); - const uint32_t potionGap = potionCount < healthPotionRestockTarget ? - healthPotionRestockTarget - potionCount : 0; - return carriedGoldReserve + static_cast(potionGap) * potionPrice; + return playerBotRecoverySpendingReserve(potionCount, reserveTarget, potionPrice, carriedGoldReserve); } void PlayerBotController::emitSpellCandidate(const Npc& npc, const NpcSpellOffer& offer, const Position& position, @@ -323,11 +324,13 @@ bool PlayerBotController::findSpellTraining(Player& player, const Position& posi std::deque& selectedSteps) { const uint64_t reserve = spellTrainingReserve(player); + const uint64_t healingReserve = spellTrainingReserve(player, true); const uint64_t totalMoney = player.getMoney() + player.getBankBalance(); const uint16_t vocationId = player.getVocationId(); const uint16_t baseVocationId = player.getVocation()->getFromVocation() == 0 ? vocationId : player.getVocation()->getFromVocation(); - const bool suppliesReady = inventoryPolicy.inventoryItemCount(player, recoveryPotionItemId(vocationId)) > healthPotionReturnThreshold; + const uint32_t potionCount = inventoryPolicy.inventoryItemCount(player, recoveryPotionItemId(vocationId)); + const bool suppliesReady = potionCount > huntPotionReturnThreshold; std::vector offers; std::vector> routes; uint64_t remainingPathNodes = maximumSpellTrainerPathNodes; @@ -452,15 +455,21 @@ bool PlayerBotController::findSpellTraining(Player& player, const Position& posi const bool levelEligible = player.getLevel() >= offer.level; const bool premiumEligible = !offer.premium || player.isPremium(); const bool alreadyLearned = player.hasLearnedInstantSpell(offer.spellName); - const bool affordable = reserve != std::numeric_limits::max() && - totalMoney >= reserve + offer.price; + const uint64_t offerReserve = offer.spellName == "Light Healing" ? healingReserve : reserve; + const bool affordable = playerBotAffordableAfterReserve(totalMoney, offerReserve, offer.price); const bool routeReachable = registryMatches && vocationEligible && levelEligible && premiumEligible && !alreadyLearned && suppliesReady && affordable && findTrainerApproach(); offers.push_back({npc->getID(), npc->getPosition(), npc->getName(), offer.spellName, offer.keyword, offer.price, offer.level, offer.premium, inScope, registryMatches, learningPriority.has_value(), learningPriority.value_or(UINT8_MAX), vocationEligible, levelEligible, premiumEligible, alreadyLearned, suppliesReady, - trainerRoute}); + trainerRoute, offerReserve}); + if (routeReachable) { + const uint32_t routeReserve = recoveryPotionRouteReserve(vocationId, player.getMaxHealth(), + trainerRoute.dangerCost, static_cast(PlayerBotNavigationRiskProfile{}.healthLossCost)); + offers.back().potionReserve = std::max(huntPotionReturnThreshold, routeReserve); + offers.back().suppliesReady = potionCount > offers.back().potionReserve; + } routes.push_back(trainerSteps); } } @@ -478,7 +487,7 @@ bool PlayerBotController::findSpellTraining(Player& player, const Position& posi Npc* npc = g_game.getNpcByID(offer.npcId); if (npc) emitSpellCandidate(*npc, {offer.spellName, offer.keyword, offer.price, offer.level, offer.premium, {}}, position, rejection == decision.rejections.end() ? "feasible" : "rejected", - rejection == decision.rejections.end() ? nullptr : rejection->reason.c_str(), reserve, offer.route.steps, + rejection == decision.rejections.end() ? nullptr : rejection->reason.c_str(), offer.reserve.value_or(reserve), offer.route.steps, offer.implementedUse ? std::optional(offer.learningPriority) : std::nullopt); } if (!decision.selected) return false; @@ -498,6 +507,7 @@ void PlayerBotController::beginSpellTraining(Player& player, const Position& pos std::to_string(training.npcId) + ",\"spell\":" + jsonString(training.spellName) + ",\"keyword\":" + jsonString(training.keyword) + ",\"price\":" + std::to_string(training.price) + ",\"reserve\":" + std::to_string(training.reserve) + + ",\"potion_reserve\":" + std::to_string(training.potionReserve) + ",\"travel_steps\":" + std::to_string(training.travelSteps)); say(player, "Going to learn " + training.spellName + "."); } @@ -538,6 +548,13 @@ void PlayerBotController::processSpellTraining(Player* player, const Position& c }); PlayerBotSpellTrainingObservation observation; observation.totalMoney = player->getMoney() + player->getBankBalance(); + if (progressionRuntime.spellTraining().stage() != PlayerBotSpellTrainingStage::Verify && + (!playerBotAffordableAfterReserve(observation.totalMoney, training.reserve, training.price) || + inventoryPolicy.inventoryItemCount(*player, recoveryPotionItemId(vocationId)) <= + std::max(huntPotionReturnThreshold, training.potionReserve))) { + finishSpellTraining(player, currentPosition, "failed", "recovery_reserve_changed"); + return; + } if (progressionRuntime.spellTraining().stage() == PlayerBotSpellTrainingStage::Travel) { bool approachUnavailable = false; observation.navigationReached = processNpcApproach(player, currentPosition, trainer, training.approachPosition, approachUnavailable); diff --git a/server/src/playerbotsupplypolicy.h b/server/src/playerbotsupplypolicy.h new file mode 100644 index 0000000..8bc47e4 --- /dev/null +++ b/server/src/playerbotsupplypolicy.h @@ -0,0 +1,92 @@ +/** Pure, uncalibrated hunt-duration consumption and recovery-spending policy. + * This preference never replaces immediate combat or navigation safety gates. + */ +#ifndef FS_PLAYERBOTSUPPLYPOLICY_H +#define FS_PLAYERBOTSUPPLYPOLICY_H + +#include +#include +#include +#include + +// Spawn-rate damage already includes every attacker's isolated fight. Only +// concurrent exposure is additional; both totals must describe the same crowd. +inline double playerBotCrowdDamageInflation(double crowdDamage, double isolatedDamage) +{ + return isolatedDamage > 0 ? std::max(1.0, crowdDamage / isolatedDamage) : 1.0; +} + +struct PlayerBotSupplyProfile { + uint32_t potions = 0; + uint32_t reserve = 1; + uint32_t potionHealing = 0; + uint32_t mana = 0; + uint32_t maximumMana = 0; + uint32_t spellMana = 0; + uint32_t spellHealing = 0; + double spellInterval = 0; + bool spellLegal = false; + // Only the currently active food condition; no future eating or loot credit. + double regenerationSeconds = 0; + uint32_t healthGain = 0; + double healthInterval = 0; + uint32_t manaGain = 0; + double manaInterval = 0; +}; + +struct PlayerBotSupplyBudget { + double expectedDamage = 0; + double regenerationHealing = 0; + double spellHealing = 0; + double expectedPotions = 0; + uint32_t reservedPotions = 1; + uint32_t routinePotions = 0; + bool fits = true; +}; + +inline PlayerBotSupplyBudget playerBotSupplyBudget(const PlayerBotSupplyProfile& profile, + double damagePerSecond, double combatFraction, double huntSeconds, double travelSeconds) +{ + PlayerBotSupplyBudget result; + result.reservedPotions = profile.reserve; + result.routinePotions = profile.potions > profile.reserve ? profile.potions - profile.reserve : 0; + result.expectedDamage = std::max(0.0, damagePerSecond) * std::max(0.0, huntSeconds); + const double combatSeconds = std::max(0.0, huntSeconds) * std::clamp(combatFraction, 0.0, 1.0); + // Discard travel regeneration and tick progress. Credit only combat time, + // capped by the condition's remaining lifetime after travel. + const double regenerationSeconds = std::min(std::max(0.0, huntSeconds), + std::max(0.0, profile.regenerationSeconds - travelSeconds)) * std::clamp(combatFraction, 0.0, 1.0); + auto regenerated = [regenerationSeconds](uint32_t gain, double interval) { + return interval > 0 ? gain * std::floor(regenerationSeconds / interval) : 0.0; + }; + result.regenerationHealing = std::min(result.expectedDamage, regenerated(profile.healthGain, profile.healthInterval)); + const uint64_t manaReserve = static_cast(profile.spellMana) + 20; + if (profile.spellLegal && profile.spellMana > 0 && profile.spellInterval > 0 && + profile.maximumMana >= manaReserve + profile.spellMana) { + // One mana pool only: do not assume repeated refill/cast cycles or that + // regeneration below the emergency reserve is spendable. + const double mana = std::max(0.0, std::min(profile.maximumMana, + profile.mana + regenerated(profile.manaGain, profile.manaInterval)) - manaReserve); + const double casts = std::min(std::floor(mana / profile.spellMana), + std::floor(combatSeconds / profile.spellInterval)); + result.spellHealing = std::min(result.expectedDamage - result.regenerationHealing, casts * profile.spellHealing); + } + const double deficit = std::max(0.0, result.expectedDamage - result.regenerationHealing - result.spellHealing); + result.expectedPotions = deficit == 0 ? 0 : profile.potionHealing > 0 ? + std::ceil(deficit / profile.potionHealing) : std::numeric_limits::max(); + result.fits = profile.potions > profile.reserve && result.expectedPotions <= result.routinePotions; + return result; +} + +inline uint64_t playerBotRecoverySpendingReserve(uint32_t potions, uint32_t target, uint32_t price, + uint32_t goldReserve) +{ + return goldReserve + static_cast(potions < target ? target - potions : 0) * price; +} + +inline bool playerBotAffordableAfterReserve(uint64_t money, uint64_t reserve, uint32_t price) +{ + return money >= reserve && money - reserve >= price; +} + +#endif diff --git a/server/src/playerbottestpolicy.cpp b/server/src/playerbottestpolicy.cpp index 5c066c3..15aea3d 100644 --- a/server/src/playerbottestpolicy.cpp +++ b/server/src/playerbottestpolicy.cpp @@ -35,6 +35,8 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm std::strcmp(gameplayMode, "departure_recovery") == 0 || std::strcmp(gameplayMode, "spell_training") == 0 || std::strcmp(gameplayMode, "spell_training_shortlist") == 0 || + std::strcmp(gameplayMode, "spell_training_low_supplies") == 0 || + std::strcmp(gameplayMode, "spell_training_low_supplies_unaffordable") == 0 || std::strcmp(gameplayMode, "equipment_shadow") == 0 || std::strcmp(gameplayMode, "equipment_shadow_unaffordable") == 0 || std::strcmp(gameplayMode, "equipment_shadow_no_upgrade") == 0 || @@ -92,6 +94,8 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm std::strcmp(gameplayMode, "carlin_local_service") != 0 && std::strcmp(gameplayMode, "readiness_supplies") != 0 && std::strcmp(gameplayMode, "spell_training") != 0 && + std::strcmp(gameplayMode, "spell_training_low_supplies") != 0 && + std::strcmp(gameplayMode, "spell_training_low_supplies_unaffordable") != 0 && std::strcmp(gameplayMode, "depot") != 0 && !depotRiskFallbackFixture && std::strcmp(gameplayMode, "slotted_loot_seller") != 0 && std::strcmp(gameplayMode, "slotted_loot_no_seller") != 0 && @@ -136,6 +140,8 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm gameplayMode && (std::strcmp(gameplayMode, "mainland_reward") == 0 || std::strcmp(gameplayMode, "spell_training") == 0 || std::strcmp(gameplayMode, "spell_training_shortlist") == 0 || + std::strcmp(gameplayMode, "spell_training_low_supplies") == 0 || + std::strcmp(gameplayMode, "spell_training_low_supplies_unaffordable") == 0 || std::strncmp(gameplayMode, "equipment_buy", 13) == 0), spellCalibrationFixture, magicTrainingFixture, diff --git a/server/src/playerbottransitcombat.h b/server/src/playerbottransitcombat.h new file mode 100644 index 0000000..9495675 --- /dev/null +++ b/server/src/playerbottransitcombat.h @@ -0,0 +1,59 @@ +/** Pure transit combat session. Navigation resets do not renew blocker attempts. */ +#ifndef FS_PLAYERBOTTRANSITCOMBAT_H +#define FS_PLAYERBOTTRANSITCOMBAT_H + +#include +#include +#include + +#include "playerbotturnrouter.h" + +class PlayerBotTransitCombat +{ + public: + static bool required(PlayerBotCyclePhase phase, PlayerBotScenarioStage stage, + bool huntReached, bool progressionActive) + { + return phase != PlayerBotCyclePhase::Hunt || !huntReached || progressionActive || + stage == PlayerBotScenarioStage::LootCorpse; + } + // Goal decisions and phase transitions start new episodes, not route + // replans or intermediate arrivals within the same goal. + bool observe(bool transit, uint64_t goal, PlayerBotCyclePhase phase) + { + const bool changed = transit != active() || goal != goalId || phase != cyclePhase; + if (changed) { + finish(); + if (transit) begin(); + goalId = goal; + cyclePhase = phase; + } + return changed; + } + void begin() { travelling = true; } + void finish() { travelling = false; attempted.clear(); } + bool active() const { return travelling; } + bool allowsDefense(uint32_t id, bool routeCritical) const + { + return !travelling || (routeCritical && attempted.count(id) == 0); + } + void beginDefense(uint32_t id, std::chrono::steady_clock::time_point now) + { + if (!travelling) return; + attempted.insert(id); + deadline = now + std::chrono::seconds(5); + } + bool defenseExpired(std::chrono::steady_clock::time_point now) const + { + return travelling && !attempted.empty() && now >= deadline; + } + + private: + bool travelling = false; + uint64_t goalId = 0; + PlayerBotCyclePhase cyclePhase = PlayerBotCyclePhase::Idle; + std::set attempted; + std::chrono::steady_clock::time_point deadline{}; +}; + +#endif diff --git a/server/tests/playerbot-gameplay/includes/combat.inc b/server/tests/playerbot-gameplay/includes/combat.inc index cabbc09..6624ba7 100644 --- a/server/tests/playerbot-gameplay/includes/combat.inc +++ b/server/tests/playerbot-gameplay/includes/combat.inc @@ -1,5 +1,49 @@ local F = PlayerbotGameplayFixture +-- World.otbm: the depot at (32105,32195,8) is a stall opening north onto +-- the hall. Its eastern neighbors are walls/stalls, not an open westward route. +-- Start in the hall at (32105,32191,8), with four attackers north/west and +-- the verified straight southward route into the stall left empty. +function F.prepareTransitReturn(player) + F.suppressNearbyMonsters(player:getId()) + local origin = Position(F.depotPosition.x, F.depotPosition.y - 4, F.depotPosition.z) + local offsets = {{-1, -1}, {0, -1}, {1, -1}, {-1, 0}} + for y = origin.y, F.depotPosition.y do + local tile = Tile(Position(origin.x, y, origin.z)) + assert(tile and tile:isWalkable(), "transit fixture requires an open southward depot route") + end + for _, offset in ipairs(offsets) do + local tile = Tile(Position(origin.x + offset[1], origin.y + offset[2], origin.z)) + assert(tile and tile:isWalkable(), "transit fixture requires four adjacent attacker tiles") + end + assert(player:teleportTo(origin), "transit fixture could not place Bot One") + local ids = {} + for _, offset in ipairs(offsets) do + local monster = Game.createMonster(F.defensiveMonsterName, + Position(origin.x + offset[1], origin.y + offset[2], origin.z), true, true) + assert(monster and monster:selectTarget(player), "transit attacker could not target Bot One") + ids[#ids + 1] = monster:getId() + end + print("PLAYERBOT_GAMEPLAY_TEST TRANSIT_RETURN_START " .. origin.x .. " " .. origin.y .. " " .. origin.z) + addEvent(F.verifyTransitReturn, 100, player:getId(), ids, 0) +end + +function F.verifyTransitReturn(playerId, ids, attempts) + local player = Player(playerId) + assert(player and not player:isRemoved(), "transit fixture lost Bot One") + for _, id in ipairs(ids) do + local monster = Monster(id) + assert(monster and monster:getHealth() == monster:getMaxHealth(), "transit optional attacker was attacked") + end + local position = player:getPosition() + if position.x == F.depotPosition.x and position.y == F.depotPosition.y and position.z == F.depotPosition.z then + print("PLAYERBOT_GAMEPLAY_TEST TRANSIT_RETURN_STATE_PASS") + return + end + assert(attempts < 299, "transit crowd prevented depot arrival within 30 seconds") + addEvent(F.verifyTransitReturn, 100, playerId, ids, attempts + 1) +end + function F.removeTargetSelectionCompetitor(monsterId) local monster = Monster(monsterId) if monster and not monster:isRemoved() then monster:remove() end diff --git a/server/tests/playerbot-gameplay/includes/login.inc b/server/tests/playerbot-gameplay/includes/login.inc index 495545e..1a641dd 100644 --- a/server/tests/playerbot-gameplay/includes/login.inc +++ b/server/tests/playerbot-gameplay/includes/login.inc @@ -37,12 +37,13 @@ function F.login.onLogin(player) end end F.selectHealthPotion(player) - assert(mode == "mainland" or mode == "carlin_local_service" or mode == "cycle" or mode == "depot" or mode == "depot_risk_fallback" or mode == "slotted_loot_seller" or mode == "slotted_loot_no_seller" or mode == "sell_loot" or mode == "sell_loot_remote_depot" or mode == "navigation" or mode == "navigation_recovery" or mode == "carlin_service_route" or mode == "mutable_portal_route" or mode == "patrol_recovery" or mode == "target_approach" or mode == "target_approach_unreachable" or mode == "target_attacker_priority" or mode == "hunt_area_arrival" or mode == "corpse" or mode == "corpse_detour" or mode == "corpse_inaccessible" or mode == "death" or mode == "healing" or mode == "spell_use" or + assert(mode == "transit_return" or mode == "mainland" or mode == "carlin_local_service" or mode == "cycle" or mode == "depot" or mode == "depot_risk_fallback" or mode == "slotted_loot_seller" or mode == "slotted_loot_no_seller" or mode == "sell_loot" or mode == "sell_loot_remote_depot" or mode == "navigation" or mode == "navigation_recovery" or mode == "carlin_service_route" or mode == "mutable_portal_route" or mode == "patrol_recovery" or mode == "target_approach" or mode == "target_approach_unreachable" or mode == "target_attacker_priority" or mode == "hunt_area_arrival" or mode == "corpse" or mode == "corpse_detour" or mode == "corpse_inaccessible" or mode == "death" or mode == "healing" or mode == "spell_use" or mode == "healing_resupply" or mode == "value" or mode == "progression" or mode == "progression_bundle" or mode == "progression_nested" or mode == "progression_resume" or mode == "progression_nested_resume" or mode == "progression_space" or mode == "arbitration" or mode == "arbitration_interrupt" or mode == "departure" or mode == "departure_interrupt" or mode == "departure_recovery" or mode == "spell_training" or - mode == "spell_training_shortlist" or mode == "stamina_bonus" or + mode == "spell_training_shortlist" or mode == "spell_training_low_supplies" or + mode == "spell_training_low_supplies_unaffordable" or mode == "stamina_bonus" or mode == "stamina_boundary" or mode == "stamina_normal" or mode == "hunt_planning" or mode == "spell_calibration" or mode == "readiness_ready" or mode == "adaptive_challenge" or mode == "readiness_upgrade" or mode == "readiness_missing_weapon" or mode == "readiness_supplies" or mode == "readiness_no_food" or @@ -654,7 +655,9 @@ function F.login.onLogin(player) print("PLAYERBOT_GAMEPLAY_TEST PICKUP_PROGRESSION_BUNDLE_START") return true end - if mode == "spell_training" then + if mode == "spell_training" or mode == "spell_training_low_supplies" or mode == "spell_training_low_supplies_unaffordable" then + local lowSupplies = mode ~= "spell_training" + local funding = mode == "spell_training_low_supplies_unaffordable" and 269 or lowSupplies and 270 or 300 if player:getStorageValue(F.spellTrainingStorage) == 1 then assert(player:hasLearnedSpell("Light Healing") and not player:hasLearnedSpell("Find Person"), "spell training priority did not persist across restart") @@ -677,18 +680,22 @@ function F.login.onLogin(player) for _, spellName in ipairs({"Find Person", "Light", "Light Healing", "Cure Poison", "Great Light"}) do player:forgetSpell(spellName) end F.removeAll(player, F.potionItemId) F.removeAll(player, F.meatItemId) - assert(player:addItem(F.potionItemId, 10), "spell training fixture could not supply potions") + assert(player:addItem(F.potionItemId, lowSupplies and 2 or 10), "spell training fixture could not supply potions") assert(player:addItem(F.meatItemId, 2), "spell training fixture could not supply food") player:removeCondition(CONDITION_REGENERATION) local food = Condition(CONDITION_REGENERATION, CONDITIONID_DEFAULT) food:setParameter(CONDITION_PARAM_TICKS, 1200000) assert(player:addCondition(food), "spell training fixture could not fill the food timer") local totalMoney = player:getMoney() + player:getBankBalance() - if totalMoney < 300 then assert(player:addMoney(300 - totalMoney), "spell training fixture could not fund Bot One") end - if totalMoney > 300 then assert(player:removeTotalMoney(totalMoney - 300), "spell training fixture could not normalize funding") end - assert(player:getMoney() + player:getBankBalance() == 300, "spell training fixture has the wrong starting money") + if totalMoney < funding then assert(player:addMoney(funding - totalMoney), "spell training fixture could not fund Bot One") end + if totalMoney > funding then assert(player:removeTotalMoney(totalMoney - funding), "spell training fixture could not normalize funding") end + assert(player:getMoney() + player:getBankBalance() == funding, "spell training fixture has the wrong starting money") F.suppressNearbyMonsters(player:getId()) - addEvent(F.verifySpellTraining, 500, player:getId(), 360) + if mode == "spell_training_low_supplies" then + addEvent(F.verifyLowSupplySpellTraining, 100, player:getId(), 1800) + elseif not lowSupplies then + addEvent(F.verifySpellTraining, 500, player:getId(), 360) + end assert(player:setStorageValue(F.fixtureReadyStorage, 1), "spell training fixture could not signal readiness") print("PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_START") return true @@ -804,6 +811,7 @@ function F.login.onLogin(player) print("PLAYERBOT_GAMEPLAY_TEST TARGET_APPROACH_START " .. monster:getId()) return true end + if mode == "transit_return" then F.prepareTransitReturn(player); return true end if mode == "navigation" then F.createTemporaryBlockers(); F.suppressNearbyMonsters(player:getId()); print("PLAYERBOT_GAMEPLAY_TEST NAVIGATION_START"); return true end if mode == "navigation_recovery" then F.suppressNearbyMonsters(player:getId()); print("PLAYERBOT_GAMEPLAY_TEST NAVIGATION_RECOVERY_START"); return true end if mode == "carlin_service_route" then diff --git a/server/tests/playerbot-gameplay/includes/verifiers.inc b/server/tests/playerbot-gameplay/includes/verifiers.inc index ae213f8..9e2df69 100644 --- a/server/tests/playerbot-gameplay/includes/verifiers.inc +++ b/server/tests/playerbot-gameplay/includes/verifiers.inc @@ -103,6 +103,20 @@ function F.verifyOracleDeparture(playerId, attempts) print("PLAYERBOT_GAMEPLAY_TEST ORACLE_DEPARTURE_PASS") end +function F.verifyLowSupplySpellTraining(playerId, attempts) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared during low-supply spell training") + if not player:hasLearnedSpell("Light Healing") and attempts > 0 then + addEvent(F.verifyLowSupplySpellTraining, 100, playerId, attempts - 1) + return + end + assert(player:hasLearnedSpell("Light Healing") and not player:hasLearnedSpell("Find Person"), + "low-supply training did not prioritize Light Healing") + assert(player:getMoney() + player:getBankBalance() == 100 and player:getItemCount(F.potionItemId) == 2, + "low-supply training did not retain the exact emergency reserves") + print("PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_LOW_SUPPLIES_PASS") +end + function F.verifySpellTraining(playerId, attempts) local player = Player(playerId) assert(player and not player:isRemoved(), "Bot One disappeared during spell training") diff --git a/server/tests/playerbot_contracts.cpp b/server/tests/playerbot_contracts.cpp index 4cd3e70..b64c699 100644 --- a/server/tests/playerbot_contracts.cpp +++ b/server/tests/playerbot_contracts.cpp @@ -5,14 +5,50 @@ #include #include "playerbotgoalplanner.h" -#include "playerbotdangerretreat.h" +#include "playerbottransitcombat.h" #include "playerbothuntregions.h" #include "playerbotturnrouter.h" namespace { -void dangerRetreat() +void transitCombat() { - PlayerBotDangerRetreat retreat; + using Phase = PlayerBotCyclePhase; + using Stage = PlayerBotScenarioStage; + assert(PlayerBotTransitCombat::required(Phase::ReturnToDepot, Stage::Traverse, true, false)); + assert(PlayerBotTransitCombat::required(Phase::Hunt, Stage::Traverse, false, false)); + assert(PlayerBotTransitCombat::required(Phase::Hunt, Stage::LootCorpse, true, false)); + assert(PlayerBotTransitCombat::required(Phase::Hunt, Stage::Traverse, true, true)); + assert(!PlayerBotTransitCombat::required(Phase::Hunt, Stage::Traverse, true, false)); + PlayerBotTransitCombat episode; + const auto start = std::chrono::steady_clock::time_point{}; + assert(episode.observe(true, 1, Phase::ReturnToDepot)); + episode.beginDefense(42, start); + for (int turn = 0; turn < 100; ++turn) { + assert(!episode.observe(true, 1, Phase::ReturnToDepot)); + assert(!episode.allowsDefense(42, true)); + for (uint32_t id = 43; id < 48; ++id) assert(!episode.allowsDefense(id, false)); + } + // Wall-clock expiry is independent of how many turns healing consumes. + assert(episode.defenseExpired(start + std::chrono::seconds(5))); + assert(episode.observe(true, 2, Phase::ReturnToDepot)); + assert(episode.allowsDefense(42, true)); + episode.beginDefense(42, start); + assert(episode.observe(true, 2, Phase::Service)); + assert(episode.allowsDefense(42, true)); + episode.beginDefense(42, start); + // NPC approach completes a coarse leg and then a local leg under the + // same goal/phase. Neither arrival completes the transit episode. + for (int approachLeg = 0; approachLeg < 2; ++approachLeg) { + assert(!episode.observe(true, 2, Phase::Service)); + assert(!episode.allowsDefense(42, true)); + assert(episode.defenseExpired(start + std::chrono::seconds(5 + approachLeg))); + } + // Semantic completion (the next phase), rather than a route-leg arrival, + // permits a fresh attempt on a subsequent transit episode. + assert(episode.observe(false, 2, Phase::Hunt)); + assert(episode.allowsDefense(42, false)); + + PlayerBotTransitCombat retreat; const auto now = std::chrono::steady_clock::time_point{}; assert(!retreat.active()); assert(retreat.allowsDefense(42, false)); @@ -54,6 +90,196 @@ void dangerRetreat() assert(router.route({}) == PlayerBotTurnCommand::None); } +void crowdDamageInflation() +{ + // One attacker: 10 damage/s for two seconds, with no concurrent exposure. + assert(playerBotCrowdDamageInflation(10 * 2, 10 * 2) == 1); + // Two identical attackers: both attack for the first fight, one for the + // second. Spawn-rate damage is already 2D; 1.5 produces 3D, not 6D. + const double identicalIsolated = 10 * 2 + 10 * 2; + const double identicalCrowd = (10 + 10) * 2 + 10 * 2; + const double identicalInflation = playerBotCrowdDamageInflation(identicalCrowd, identicalIsolated); + assert(identicalInflation == 1.5); + assert(identicalIsolated * identicalInflation == identicalCrowd); + // Unequal damage and durations, in the adapter's descending-DPS order: + // 20/s for two seconds, then 10/s for five. Normalize against BOTH isolated + // fights (90), not the strongest solo fight (50), nor another crowd's sum. + const double unequalIsolated = 20 * 2 + 10 * 5; + const double unequalCrowd = (20 + 10) * 2 + 10 * 5; + const double unequalInflation = playerBotCrowdDamageInflation(unequalCrowd, unequalIsolated); + assert(std::abs(unequalInflation - 11.0 / 9.0) < 1e-12); + assert(std::abs(unequalIsolated * unequalInflation - unequalCrowd) < 1e-12); + // The larger absolute-damage crowd need not have the larger ratio. + assert(unequalCrowd > identicalCrowd); + assert(std::max(identicalInflation, unequalInflation) == 1.5); + assert(playerBotCrowdDamageInflation(0, 0) == 1); + assert(playerBotCrowdDamageInflation(9, 10) == 1); +} + +void overBudgetHunts() +{ + PlayerBotHuntRegion easy, costly; + easy.suitable = costly.suitable = easy.reachable = costly.reachable = true; + easy.experiencePerMinute = 10; + costly.experiencePerMinute = 100; + easy.expectedDamagePerSecond = 0.1; + costly.expectedDamagePerSecond = 1; + easy.supplyProfile.potions = costly.supplyProfile.potions = 2; + easy.supplyProfile.potionHealing = costly.supplyProfile.potionHealing = 125; + easy.reconcileTravel(1500, 0, 1); + costly.reconcileTravel(1500, 0, 1); + assert(easy.supplyBudget.expectedPotions == 2 && costly.supplyBudget.expectedPotions == 12); + assert(!easy.supplyBudget.fits && !costly.supplyBudget.fits); + assert(easy.score < costly.score); + assert(playerBotPreferHuntRegion(easy, costly)); + assert(!playerBotPreferHuntRegion(costly, easy)); + assert(easy.suitable && costly.suitable); // Budget failure is not an eligibility gate. + assert(std::string(playerBotHuntSelectionRule(easy)) == "lowest_potion_consumption_then_xp"); + easy.suitable = false; + assert(playerBotPreferHuntRegion(costly, easy)); + easy.suitable = true; + easy.reachable = false; + assert(playerBotPreferHuntRegion(costly, easy)); + easy.reachable = true; + // Equal consumption uses XP; a complete tie does not reorder candidates. + PlayerBotHuntRegion equalCost = easy; + equalCost.score = easy.score + 1; + assert(playerBotPreferHuntRegion(equalCost, easy)); + assert(!playerBotPreferHuntRegion(easy, equalCost)); + equalCost.score = easy.score; + assert(!playerBotPreferHuntRegion(equalCost, easy) && !playerBotPreferHuntRegion(easy, equalCost)); + // Plentiful supplies preserve XP ordering even with different consumption. + easy.supplyProfile.potions = costly.supplyProfile.potions = 14; + easy.reconcileSupplies(1); + costly.reconcileSupplies(1); + assert(easy.supplyBudget.fits && costly.supplyBudget.fits); + assert(playerBotPreferHuntRegion(costly, easy)); + // Actual route reserves can remove one fit, then all fits. Compare again + // with the same policy used by route-shortlist selection. + costly.reconcileSupplies(3); + assert(!costly.supplyBudget.fits && playerBotPreferHuntRegion(easy, costly)); + easy.reconcileSupplies(13); + costly.reconcileSupplies(13); + assert(!easy.supplyBudget.fits && !costly.supplyBudget.fits); + assert(playerBotPreferHuntRegion(easy, costly)); + // Reconciled travel time changes the remaining hunt and expected consumption. + costly.reconcileTravel(1500, 1400, 1); + assert(costly.supplyBudget.expectedPotions == 1 && costly.supplyBudget.fits); + assert(costly.score < easy.score && playerBotPreferHuntRegion(costly, easy)); +} + +void supplyBudget() +{ + PlayerBotSupplyProfile profile; + profile.potions = 2; + profile.potionHealing = 125; + PlayerBotHuntRegion easy, costly; + easy.suitable = costly.suitable = easy.reachable = costly.reachable = true; + easy.experiencePerMinute = 10; + costly.experiencePerMinute = 100; + easy.expectedDamagePerSecond = 0.1; + costly.expectedDamagePerSecond = 1; + easy.combatFraction = costly.combatFraction = 0.5; + easy.supplyProfile = costly.supplyProfile = profile; + easy.reconcileTravel(900, 0, 1); + costly.reconcileTravel(900, 0, 1); + assert(easy.supplyBudget.expectedPotions == 1 && easy.supplyBudget.fits); + assert(costly.supplyBudget.expectedPotions == 8 && !costly.supplyBudget.fits); + assert(playerBotPreferHuntRegion(easy, costly)); + assert(!playerBotPreferHuntRegion(costly, easy)); + // Safety outranks economy, including an otherwise zero-consumption region. + easy.suitable = false; + assert(playerBotPreferHuntRegion(costly, easy)); + easy.suitable = true; + easy.reachable = false; + assert(playerBotPreferHuntRegion(costly, easy)); + easy.reachable = true; + costly.supplyProfile.potions = 10; + costly.reconcileSupplies(1); + assert(costly.supplyBudget.fits && playerBotPreferHuntRegion(costly, easy)); + costly.reconcileSupplies(3); + assert(!costly.supplyBudget.fits && playerBotPreferHuntRegion(easy, costly)); + costly.reconcileTravel(60, 0, 1); + assert(costly.supplyBudget.expectedPotions == 1 && costly.supplyBudget.fits); + profile.potions = 1; + assert(!playerBotSupplyBudget(profile, 0, 0, 900, 0).fits); + profile.potions = 0; + assert(!playerBotSupplyBudget(profile, 0, 0, 900, 0).fits); + profile.potions = 2; + profile.regenerationSeconds = 900; + profile.healthGain = 10; + profile.healthInterval = 10; + auto budget = playerBotSupplyBudget(profile, 1, 0.5, 900, 0); + assert(budget.regenerationHealing == 450 && budget.expectedPotions == 4); + budget = playerBotSupplyBudget(profile, 1, 0.5, 900, 900); + assert(budget.regenerationHealing == 0 && budget.expectedPotions == 8); + profile.regenerationSeconds = 100; + assert(playerBotSupplyBudget(profile, 1, 0.5, 900, 0).regenerationHealing == 50); + profile.regenerationSeconds = 9; + assert(playerBotSupplyBudget(profile, 1, 1, 900, 0).regenerationHealing == 0); + profile.regenerationSeconds = 900; + profile.mana = profile.maximumMana = 100; + profile.spellMana = 20; + profile.spellHealing = 40; + profile.spellInterval = 1; + budget = playerBotSupplyBudget(profile, 1, 0.5, 900, 0); + assert(budget.spellHealing == 0); // unlearned/illegal Exura gets no credit + profile.spellLegal = true; + budget = playerBotSupplyBudget(profile, 1, 0.5, 900, 0); + assert(budget.spellHealing == 120 && budget.expectedPotions == 3); + profile.mana = 0; + profile.manaGain = 1; + profile.manaInterval = 10; + assert(playerBotSupplyBudget(profile, 1, 0.5, 900, 0).spellHealing == 0); + profile.manaGain = 10; + assert(playerBotSupplyBudget(profile, 1, 0.5, 900, 0).spellHealing == 120); + profile.maximumMana = 59; + assert(playerBotSupplyBudget(profile, 1, 0.5, 900, 0).spellHealing == 0); + profile.potionHealing = 0; + budget = playerBotSupplyBudget(profile, 1, 0, 900, 0); + assert(std::isfinite(budget.expectedPotions) && !budget.fits); +} + +void recoverySpellPriority() +{ + using Goal = PlayerBotGoalArbiter::TopLevelGoal; + assert(playerBotRecoverySpendingReserve(2, 2, 45, 100) == 100); + assert(playerBotRecoverySpendingReserve(2, 10, 45, 100) == 460); + assert(playerBotRecoverySpendingReserve(2, 4, 45, 100) == 190); + assert(playerBotAffordableAfterReserve(270, 100, 170)); + assert(!playerBotAffordableAfterReserve(269, 100, 170)); + assert(!playerBotAffordableAfterReserve(34, 100, 170)); + assert(!playerBotAffordableAfterReserve(UINT64_MAX, UINT64_MAX, 170)); + PlayerBotGoalPlanner planner; + PlayerBotGoalPlannerSnapshot snapshot; + snapshot.spellPlanAvailable = snapshot.recoverySpellPlanAvailable = true; + snapshot.rewardPlanAvailable = true; + snapshot.rewardUtility = 2000; + snapshot.sellLootPlanAvailable = true; + snapshot.sellLootUtility = 3000; + snapshot.equipmentEnabled = snapshot.equipmentPlanAvailable = true; + snapshot.cashAdjustment = true; + auto candidates = planner.candidates(snapshot); + auto candidate = [&](Goal goal) -> const PlayerBotGoalArbiter::GoalCandidate& { + return *std::find_if(candidates.begin(), candidates.end(), [goal](const auto& value) { return value.goal == goal; }); + }; + assert(candidate(Goal::LearnSpell).feasible && candidate(Goal::LearnSpell).reason == "priority_recovery_spell"); + for (Goal goal : {Goal::PickupReward, Goal::BuyEquipment, Goal::SellLoot, Goal::Hunt, Goal::MagicTraining, Goal::Service}) { + assert(!candidate(goal).feasible && candidate(goal).reason == "deferred_recovery_spell"); + } + snapshot.lowCapacity = true; + candidates = planner.candidates(snapshot); + assert(candidate(Goal::Service).feasible && candidate(Goal::Service).utility > candidate(Goal::LearnSpell).utility); + snapshot.lowCapacity = false; + snapshot.spellCoolingDown = true; + candidates = planner.candidates(snapshot); + assert(!candidate(Goal::LearnSpell).feasible && candidate(Goal::PickupReward).feasible); + snapshot.spellCoolingDown = false; + snapshot.recoverySpellPlanAvailable = false; + candidates = planner.candidates(snapshot); + assert(candidate(Goal::SellLoot).feasible && candidate(Goal::SellLoot).utility > candidate(Goal::LearnSpell).utility); +} + void projection() { PlayerBotHuntRegion region; @@ -129,7 +355,11 @@ void oracleRecovery() int main() { - dangerRetreat(); + transitCombat(); + crowdDamageInflation(); + overBudgetHunts(); + supplyBudget(); + recoverySpellPriority(); projection(); oracleRecovery(); std::cout << "playerbot contracts passed\n"; diff --git a/server/tests/playerbot_contracts.sh b/server/tests/playerbot_contracts.sh index 4fe1631..3c2ee69 100644 --- a/server/tests/playerbot_contracts.sh +++ b/server/tests/playerbot_contracts.sh @@ -5,5 +5,5 @@ root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) build=$(mktemp -d) trap 'rm -rf "$build"' EXIT HUP INT TERM "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -I"$root/src" \ - "$root/tests/playerbot_contracts.cpp" -o "$build/playerbot_contracts" + "$root/tests/playerbot_contracts.cpp" "$root/src/playerbotgoalplanner.cpp" -o "$build/playerbot_contracts" "$build/playerbot_contracts"