diff --git a/docs/playerbots.md b/docs/playerbots.md index e4bc0bc..f031027 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -65,12 +65,14 @@ teleports, and unlocked doors. Cardinal movement costs 10; diagonal movement costs 30. Failed steps are excluded for 10 seconds. Repeated A-B oscillation suppresses the implicated transition for two minutes. -Rookgaard hunt regions come from loaded spawns on floors 6 through 15 within -180 tiles on each axis of temple `(32097, 32219, 7)`. The planner groups -overlapping eight-tile spawn kernels, then scores loaded health, attacks, armor, -experience, spawn chance, and interval against the bot's health, equipment, -weapon, defense, and skill. The navigator validates the highest-scoring -candidates and supplies spawn-adjacent patrol destinations. +Hunt regions come from all loaded hostile spawns on floors 6 through 15; there +is no player-distance or fixture-radius cap. The shared cache groups overlapping +eight-tile spawn kernels. Each controller scores candidates in bounded batches +using its health, equipment, weapon, defense, skill, cooldowns, and observed +performance. It then validates every suitable candidate incrementally through +the navigator and selects the highest route-adjusted score. Navigation remains +behind the destination/reachability interface so a later navigator can replace +tile planning without changing hunt selection. Predicted threat rejects a region above `0.35` expected fight damage per maximum health. It models up to three overlapping hostile spawns. Taking one maximum diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index b8447e9..981799d 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -10,6 +10,7 @@ param( [switch]$GoalArbitration, [switch]$OracleDeparture, [switch]$StaminaProjection, + [switch]$HuntRegionPlanning, [switch]$Focused, [switch]$SkipBuild, [switch]$KeepStack @@ -816,6 +817,55 @@ function Assert-StaminaProjectionEvents { } } +function Assert-HuntRegionPlanningEvents { + param([string]$Logs) + + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $scored = @($events | Where-Object { + $_.event -eq "hunt_region_scan" -and $_.phase -in @("scoring_started", "scored") + }) + $build = @($scored | Where-Object { $_.cache -eq "build" }) + $hit = @($scored | Where-Object { $_.cache -eq "hit" }) + $cancelled = @($events | Where-Object { $_.event -eq "hunt_region_scan" -and $_.phase -eq "cancelled" }) + $staleRevision = @($events | Where-Object { $_.event -eq "hunt_region_scan" -and $_.phase -eq "stale_revision" }) + $scoringYields = @($events | Where-Object { $_.event -eq "hunt_region_scan" -and $_.phase -eq "scoring_yield" }) + $yields = @($events | Where-Object { + $_.event -eq "hunt_region_scan" -and $_.phase -eq "reachability_yield" -and + $_.pathfinding_calls -ge 1 -and $_.batch_pathfinding_calls -le 1 -and $_.yields -ge 1 + }) + $selections = @($events | Where-Object { $_.event -eq "hunt_region_selection" -and $_.result -eq "selected" }) + $selection = if ($selections.Count -gt 0) { $selections[$selections.Count - 1] } else { $null } + $candidates = @($events | Where-Object { $_.event -eq "hunt_region_candidate" }) + $unreachableBeforeSelection = @($candidates | Where-Object { + $_.rejection_reason -eq "unreachable" -and $selection -and $_.region_id -lt $selection.region_id + }) + $outsideLocalFixture = @($candidates | Where-Object { + [Math]::Max([Math]::Abs($_.center.x - 32105), [Math]::Abs($_.center.y - 32195)) -gt 32 + }) + $completed = @($events | Where-Object { + $_.event -eq "hunt_region_scan" -and $_.phase -eq "selected" -and $_.decision_latency_us -gt 0 -and + $_.expanded_nodes -ge 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 } + $nodeBudget = @($candidates | Where-Object { $_.rejection_reason -eq "navigation_node_budget" }) + $nodeBudgetMisclassified = @($nodeBudget | Where-Object { + $regionId = $_.region_id + @($candidates | Where-Object { $_.region_id -eq $regionId -and $_.rejection_reason -eq "unreachable" }).Count -gt 0 + }) + $tooManyPathCalls = @($events | Where-Object { + $_.event -eq "hunt_region_scan" -and $_.phase -eq "reachability_yield" -and $_.batch_pathfinding_calls -gt 1 + }) + if ($build.Count -lt 2 -or $hit.Count -lt 1 -or $cancelled.Count -ne 1 -or $staleRevision.Count -ne 1 -or $scoringYields.Count -lt 1 -or + $yields.Count -lt 1 -or $tooManyPathCalls.Count -ne 0 -or -not $selection -or $selectedCandidate.Count -ne 1 -or + $bestScore -eq $null -or [Math]::Abs($selectedCandidate[0].score - $bestScore) -gt 0.01 -or + $nodeBudget.Count -lt 1 -or $nodeBudgetMisclassified.Count -ne 0 -or + $unreachableBeforeSelection.Count -lt 1 -or $outsideLocalFixture.Count -lt 1 -or $completed.Count -lt 1) { + throw "Hunt planning telemetry was incomplete. build=$($build.Count), hit=$($hit.Count), cancelled=$($cancelled.Count), stale=$($staleRevision.Count), scoring_yields=$($scoringYields.Count), yields=$($yields.Count), over_budget=$($tooManyPathCalls.Count), selection=$($selection.Count), fallback=$($unreachableBeforeSelection.Count), outside=$($outsideLocalFixture.Count), completed=$($completed.Count)." + } +} + function Assert-GoalArbitrationInterruptEvents { param([string]$Logs) @@ -928,7 +978,7 @@ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { } $focusedScenarioRequested = $FullNavigation -or $CorpseLoot -or $DeathTelemetry -or $Healing -or $ValueLoot -or - $PickupProgression -or $GoalArbitration -or $OracleDeparture -or $StaminaProjection + $PickupProgression -or $GoalArbitration -or $OracleDeparture -or $StaminaProjection -or $HuntRegionPlanning if ($Focused -and -not $focusedScenarioRequested) { throw "-Focused requires at least one focused scenario switch." } @@ -1076,6 +1126,18 @@ try { } } + if ($HuntRegionPlanning) { + Invoke-Scenario -Name "hunt_region_planning" -DefaultTimeoutSeconds 180 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "hunt_planning" + $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST HUNT_PLANNING_START' | Out-Null + $planningLogs = Wait-ForLog -Pattern '"event":"hunt_region_scan".*"phase":"selected"' + Assert-HuntRegionPlanningEvents -Logs $planningLogs + } + } + if ($OracleDeparture) { Invoke-Scenario -Name "oracle_departure" -DefaultTimeoutSeconds 180 -Body { Invoke-Compose down --volumes --remove-orphans diff --git a/server/src/game.cpp b/server/src/game.cpp index a44623c..5da317a 100644 --- a/server/src/game.cpp +++ b/server/src/game.cpp @@ -34,6 +34,7 @@ #include "items.h" #include "monster.h" #include "movement.h" +#include "playerbothuntregions.h" #include "scheduler.h" #include "server.h" #include "spells.h" @@ -5026,7 +5027,13 @@ bool Game::reload(ReloadTypes_t reloadType) case RELOAD_TYPE_EVENTS: return g_events->load(); case RELOAD_TYPE_GLOBALEVENTS: return g_globalEvents->reload(); case RELOAD_TYPE_ITEMS: return Item::items.reload(); - case RELOAD_TYPE_MONSTERS: return g_monsters.reload(); + case RELOAD_TYPE_MONSTERS: { + const bool reloaded = g_monsters.reload(); + if (reloaded) { + PlayerBotHuntRegionPlanner::invalidateCache(); + } + return reloaded; + } case RELOAD_TYPE_MOVEMENTS: return g_moveEvents->reload(); case RELOAD_TYPE_NPCS: { Npcs::reload(); @@ -5044,6 +5051,7 @@ bool Game::reload(ReloadTypes_t reloadType) std::cout << "[Error - Game::reload] Failed to reload monsters." << std::endl; std::terminate(); } + PlayerBotHuntRegionPlanner::invalidateCache(); return true; } @@ -5087,6 +5095,7 @@ bool Game::reload(ReloadTypes_t reloadType) std::cout << "[Error - Game::reload] Failed to reload monsters." << std::endl; std::terminate(); } + PlayerBotHuntRegionPlanner::invalidateCache(); g_actions->reload(); g_config.reload(); diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index 6e9f59e..150df81 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -15,6 +15,25 @@ // Playerbot survival, combat targeting, and hunt orchestration. using namespace playerbot; +namespace { + double projectedHuntStaminaMultiplier(const Player& player, double availableHuntSeconds) + { + const uint16_t staminaMinutes = player.getStaminaMinutes(); + if (staminaMinutes == 0) { + return 0; + } + if (!g_config.getBoolean(ConfigManager::STAMINA_SYSTEM)) { + return 1; + } + if (staminaMinutes > 2400 && player.isPremium() && availableHuntSeconds > 0) { + const double bonusSeconds = std::min(availableHuntSeconds, + std::max(0, staminaMinutes - 2402) * 60.0); + return 1 + 0.5 * bonusSeconds / availableHuntSeconds; + } + return staminaMinutes <= 840 ? 0.5 : 1; + } +} + void PlayerBotController::setExpectedCorpse(const Creature& target) { const Monster* monster = target.getMonster(); @@ -86,6 +105,7 @@ bool PlayerBotController::handleHealing(Player* player, const Position& currentP if (!needsHealing(*player)) { return false; } + cancelHuntRegionPlanning(); if (now < healRetryAfter || !player->canDoAction()) { return true; } @@ -467,6 +487,32 @@ void PlayerBotController::finishHuntRegion(const Player& player, const Position& activeHuntRegion.reset(); } +void PlayerBotController::cancelHuntRegionPlanning() +{ + huntRegionPlanning.reset(); +} + +void PlayerBotController::emitHuntRegionPlanning(const HuntRegionPlanning& planning, const Position& position, const char* phase) const +{ + const auto latencyUs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - planning.started).count(); + std::ostringstream fields; + fields << "\"phase\":" << jsonString(phase) + << ",\"cache\":" << jsonString(planning.cacheHit ? "hit" : "build") + << ",\"snapshot_time_us\":" << planning.snapshotTimeUs + << ",\"clustering_time_us\":" << planning.clusteringTimeUs + << ",\"scoring_time_us\":" << planning.scoringTimeUs + << ",\"candidate_count\":" << planning.totalCandidates + << ",\"scored_candidate_count\":" << planning.scoredCandidates + << ",\"suitable_candidate_count\":" << planning.suitableCandidates + << ",\"pathfinding_calls\":" << planning.pathfindingCalls + << ",\"batch_pathfinding_calls\":" << planning.batchPathfindingCalls + << ",\"expanded_nodes\":" << planning.expandedNodes + << ",\"yields\":" << planning.yields + << ",\"decision_latency_us\":" << latencyUs; + emit("hunt_region_scan", position, fields.str()); +} + bool PlayerBotController::selectHuntRegion(Player& player, const Position& position, const char* reason) { const auto now = std::chrono::steady_clock::now(); @@ -481,34 +527,172 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit } const uint32_t huntDurationSeconds = static_cast(std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS))); - std::vector regions = huntRegionPlanner.evaluate( - player, navigator, excludedRegions, huntRegionPerformance, huntDurationSeconds); - for (const PlayerBotHuntRegion& region : regions) { - emitHuntRegionCandidate(region, position); + const bool staleRevision = huntRegionPlanning && + huntRegionPlanning->cacheRevision != PlayerBotHuntRegionPlanner::getCacheRevision(); + if (huntRegionPlanning && + (huntRegionPlanning->playerPosition != player.getPosition() || huntRegionPlanning->playerLevel != player.getLevel() || + huntRegionPlanning->playerHealth != player.getHealth() || huntRegionPlanning->playerArmor != player.getArmor() || + huntRegionPlanning->playerDefense != player.getDefense() || huntRegionPlanning->staminaMinutes != player.getStaminaMinutes() || + huntRegionPlanning->excludedRegions != excludedRegions || + staleRevision)) { + if (staleRevision) { + emitHuntRegionPlanning(*huntRegionPlanning, position, "stale_revision"); + } + cancelHuntRegionPlanning(); + } + if (!huntRegionPlanning) { + PlayerBotHuntRegionScan scan = huntRegionPlanner.beginScan(); + HuntRegionPlanning planning; + planning.regions.reserve(scan.candidateCount); + planning.reason = reason; + planning.started = now; + planning.cacheHit = scan.cacheHit; + planning.snapshotTimeUs = scan.snapshotTimeUs; + planning.clusteringTimeUs = scan.clusteringTimeUs; + planning.cacheRevision = scan.revision; + planning.totalCandidates = static_cast(scan.candidateCount); + planning.playerPosition = player.getPosition(); + planning.playerLevel = player.getLevel(); + planning.playerHealth = player.getHealth(); + planning.playerArmor = player.getArmor(); + planning.playerDefense = player.getDefense(); + planning.staminaMinutes = player.getStaminaMinutes(); + planning.excludedRegions = excludedRegions; + huntRegionPlanning = std::move(planning); + emitHuntRegionPlanning(*huntRegionPlanning, position, "scoring_started"); + return false; } - std::ostringstream scan; - scan << "\"reason\":" << jsonString(reason) - << ",\"player_level\":" << player.getLevel() - << ",\"player_health\":" << player.getHealth() - << ",\"player_health_max\":" << player.getMaxHealth() - << ",\"player_armor\":" << player.getArmor() - << ",\"player_defense\":" << player.getDefense() - << ",\"candidate_count\":" << regions.size(); - emit("hunt_region_scan", position, scan.str()); + HuntRegionPlanning& planning = *huntRegionPlanning; + planning.batchPathfindingCalls = 0; + if (planning.phase == HuntRegionPlanning::Phase::Scoring) { + const auto scoringStarted = std::chrono::steady_clock::now(); + for (uint32_t scoredThisTurn = 0; + scoredThisTurn < huntRegionScoringCandidatesPerTurn && planning.nextScoringCandidate < planning.totalCandidates; + ++scoredThisTurn, ++planning.nextScoringCandidate) { + PlayerBotHuntRegion region; + if (!huntRegionPlanner.score(player, planning.cacheRevision, planning.nextScoringCandidate, excludedRegions, + huntRegionPerformance, huntDurationSeconds, region)) { + emitHuntRegionPlanning(planning, position, "stale_revision"); + cancelHuntRegionPlanning(); + return false; + } + planning.regions.push_back(std::move(region)); + ++planning.scoredCandidates; + } + planning.scoringTimeUs += std::chrono::duration_cast( + std::chrono::steady_clock::now() - scoringStarted).count(); + if (testPolicy.cancelHuntPlanningAtScoreBarrier && !huntPlanningFixtureCancelled && planning.scoredCandidates != 0) { + huntPlanningFixtureCancelled = true; + emitHuntRegionPlanning(planning, position, "cancelled"); + cancelHuntRegionPlanning(); + return false; + } + if (testPolicy.cancelHuntPlanningAtScoreBarrier && !huntPlanningFixtureStaleRevisionTriggered && planning.scoredCandidates != 0) { + huntPlanningFixtureStaleRevisionTriggered = true; + PlayerBotHuntRegionPlanner::invalidateCache(); + return false; + } + if (planning.nextScoringCandidate < planning.totalCandidates) { + ++planning.yields; + emitHuntRegionPlanning(planning, position, "scoring_yield"); + return false; + } + std::sort(planning.regions.begin(), planning.regions.end(), [](const PlayerBotHuntRegion& left, const PlayerBotHuntRegion& right) { + return left.score > right.score; + }); + uint32_t regionId = 1; + for (PlayerBotHuntRegion& region : planning.regions) { + region.id = regionId++; + } + planning.suitableCandidates = static_cast(std::count_if(planning.regions.begin(), planning.regions.end(), + [](const PlayerBotHuntRegion& region) { return region.suitable; })); + planning.phase = HuntRegionPlanning::Phase::Reachability; + emitHuntRegionPlanning(planning, position, "scored"); + return false; + } + + for (uint32_t pathsThisTurn = 0; pathsThisTurn < huntRegionPathfindingCallsPerTurn;) { + while (planning.nextCandidate < planning.regions.size()) { + PlayerBotHuntRegion& candidate = planning.regions[planning.nextCandidate]; + if (!candidate.suitable) { + ++planning.nextCandidate; + continue; + } + std::set blockedPositions; + for (const auto& blocked : temporarilyBlockedPositions) { + if (blocked.second > now) { + blockedPositions.insert(blocked.first); + } + } + std::deque route; + const bool forcedUnreachable = testPolicy.forceFirstHuntCandidateUnreachable && !planning.fixtureForcedUnreachable; + const bool forcedNodeLimit = testPolicy.forceSecondHuntCandidateNodeLimit && !planning.fixtureForcedNodeLimit && + planning.fixtureForcedUnreachable && !forcedUnreachable; + planning.fixtureForcedUnreachable = planning.fixtureForcedUnreachable || forcedUnreachable; + planning.fixtureForcedNodeLimit = planning.fixtureForcedNodeLimit || forcedNodeLimit; + PlayerBotNavigationResult planResult = PlayerBotNavigationResult::Unreachable; + if (!forcedUnreachable) { + const auto pathStarted = std::chrono::steady_clock::now(); + ++counters.pathfindingCalls; + ++planning.pathfindingCalls; + ++planning.batchPathfindingCalls; + planResult = navigator.plan(player, candidate.destination, blockedPositions, route, candidate.expandedNodes, + forcedNodeLimit ? 0 : playerBotNavigationMaximumExpandedNodes); + counters.pathfindingTimeUs += std::chrono::duration_cast( + std::chrono::steady_clock::now() - pathStarted).count(); + } + planning.expandedNodes += candidate.expandedNodes; + ++pathsThisTurn; + ++planning.nextCandidate; + if (planResult != PlayerBotNavigationResult::Reached) { + ++counters.pathfindingFailures; + candidate.rejectionReason = planResult == PlayerBotNavigationResult::NodeLimit ? + "navigation_node_budget" : "unreachable"; + } else { + candidate.reachable = true; + candidate.travelSteps = static_cast(route.size()); + candidate.estimatedTravelSeconds = 0; + for (const PlayerBotNavigationStep& step : route) { + candidate.estimatedTravelSeconds += step.action == PlayerBotNavigationAction::Move ? + player.getStepDuration(step.direction) / 1000.0 : 1.0; + } + candidate.availableHuntSeconds = std::max(0.0, huntDurationSeconds - candidate.estimatedTravelSeconds); + candidate.staminaExperienceMultiplier = projectedHuntStaminaMultiplier(player, candidate.availableHuntSeconds); + candidate.projectedExperience = candidate.experiencePerMinute * candidate.observedCorrection * + candidate.staminaExperienceMultiplier * candidate.availableHuntSeconds / 60.0; + candidate.score = candidate.projectedExperience; + } + ++planning.yields; + emitHuntRegionPlanning(planning, position, "reachability_yield"); + return false; + } + if (pathsThisTurn != 0) { + ++planning.yields; + emitHuntRegionPlanning(planning, position, "reachability_yield"); + return false; + } + break; + } - auto selected = std::max_element(regions.begin(), regions.end(), [](const PlayerBotHuntRegion& left, - const PlayerBotHuntRegion& right) { + auto selected = std::max_element(planning.regions.begin(), planning.regions.end(), [](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; + return leftAvailable == rightAvailable ? left.score < right.score : !leftAvailable; }); - if (selected == regions.end() || !selected->suitable || !selected->reachable) { + for (const PlayerBotHuntRegion& region : planning.regions) { + emitHuntRegionCandidate(region, position); + } + if (selected == planning.regions.end() || !selected->suitable || !selected->reachable) { + const bool validationBudgetExhausted = std::any_of(planning.regions.begin(), planning.regions.end(), + [](const PlayerBotHuntRegion& region) { return region.rejectionReason == "navigation_node_budget"; }); + emitHuntRegionPlanning(planning, position, "exhausted"); emit("hunt_region_selection", position, - "\"result\":\"failed\",\"reason\":\"no_suitable_reachable_region\""); + "\"result\":\"failed\",\"reason\":" + + jsonString(validationBudgetExhausted ? "route_validation_budget_exhausted" : "no_suitable_reachable_region")); + cancelHuntRegionPlanning(); + stop(validationBudgetExhausted ? "hunt_region_route_validation_budget_exhausted" : "hunt_region_unavailable", position); return false; } @@ -540,9 +724,23 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit speech << ". Projected " << std::fixed << std::setprecision(0) << selected->projectedExperience << " experience after " << selected->estimatedTravelSeconds << " seconds travel."; say(player, speech.str()); + emitHuntRegionPlanning(planning, position, "selected"); + cancelHuntRegionPlanning(); return true; } +void PlayerBotController::beginHuntCycle(Player* player, const Position& position, const char* reason) +{ + const int32_t duration = std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS)); + huntDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(duration); + clearNavigation(); + ++completedCycles; + std::ostringstream fields; + fields << "\"action\":\"hunt_cycle\",\"result\":\"started\",\"cycle\":" << completedCycles + << ",\"duration_seconds\":" << duration; + emit("action_result", position, fields.str()); +} + void PlayerBotController::startHunt(Player* player, const Position& position, const char* reason) { if (!player) { @@ -552,18 +750,13 @@ void PlayerBotController::startHunt(Player* player, const Position& position, co activeGoal = TopLevelGoal::Hunt; setCyclePhase(CyclePhase::Hunt, position, reason); huntRouteIndex = 0; - if (!testPolicy.fixedFixtureRoute && !activeHuntRegion && !selectHuntRegion(*player, position, "hunt_started")) { - stop("hunt_region_unavailable", position); - return; + if (!testPolicy.fixedFixtureRoute && !activeHuntRegion) { + if (!selectHuntRegion(*player, position, "hunt_started")) { + schedule(SCHEDULER_MINTICKS); + return; + } } - const int32_t duration = std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS)); - huntDeadline = std::chrono::steady_clock::now() + std::chrono::seconds(duration); - clearNavigation(); - ++completedCycles; - std::ostringstream fields; - fields << "\"action\":\"hunt_cycle\",\"result\":\"started\",\"cycle\":" << completedCycles - << ",\"duration_seconds\":" << duration; - emit("action_result", position, fields.str()); + beginHuntCycle(player, position, reason); } void PlayerBotController::processTraversal(Player* player, const Position& currentPosition) @@ -582,6 +775,18 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre processProgression(player, currentPosition); return; } + if (cyclePhase == CyclePhase::Hunt && !testPolicy.fixedFixtureRoute && !activeHuntRegion && !huntRegionPlanning) { + startHunt(player, currentPosition, "hunt_region_restart"); + return; + } + if (huntRegionPlanning) { + if (selectHuntRegion(*player, currentPosition, "hunt_planning")) { + beginHuntCycle(player, currentPosition, "hunt_region_selected"); + } else { + schedule(SCHEDULER_MINTICKS); + } + return; + } if (scenarioStage == ScenarioStage::LootCorpse) { lootCorpse(player, currentPosition); return; diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index c9ce34e..f244da8 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -36,14 +36,18 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment() std::strcmp(gameplayMode, "healing") == 0 || std::strcmp(gameplayMode, "healing_resupply") == 0 || std::strcmp(gameplayMode, "value") == 0 || std::strcmp(gameplayMode, "departure_interrupt") == 0 || std::strcmp(gameplayMode, "stamina_bonus") == 0 || std::strcmp(gameplayMode, "stamina_boundary") == 0 || - std::strcmp(gameplayMode, "stamina_normal") == 0); + std::strcmp(gameplayMode, "stamina_normal") == 0 || std::strcmp(gameplayMode, "hunt_planning") == 0); const bool fixedFixtureRoute = gameplayMode && std::strcmp(gameplayMode, "stamina_bonus") != 0 && std::strcmp(gameplayMode, "stamina_boundary") != 0 && - std::strcmp(gameplayMode, "stamina_normal") != 0; + std::strcmp(gameplayMode, "stamina_normal") != 0 && + std::strcmp(gameplayMode, "hunt_planning") != 0; return PlayerBotTestPolicy{ !regressionMode && (!gameplayMode || progressionMode), startInHunt, fixedFixtureRoute, + gameplayMode && std::strcmp(gameplayMode, "hunt_planning") == 0, + gameplayMode && std::strcmp(gameplayMode, "hunt_planning") == 0, + gameplayMode && std::strcmp(gameplayMode, "hunt_planning") == 0, }; }(); return policy; @@ -306,6 +310,7 @@ void PlayerBotController::stop(const char* reason, const Position& position) return; } + cancelHuntRegionPlanning(); setStage(ScenarioStage::Stopped, position); logSummary(position, true); emit("terminal", position, std::string("\"reason\":") + jsonString(reason)); @@ -327,6 +332,7 @@ bool PlayerBotController::findPath(Player* player, const Position& target, std:: void PlayerBotController::clearNavigation() { + cancelHuntRegionPlanning(); navigationSteps.clear(); navigationPending = false; worldChangePending = false; @@ -558,7 +564,8 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr uint64_t expandedNodes = 0; ++counters.pathfindingCalls; const auto startedAt = std::chrono::steady_clock::now(); - const bool planned = navigator.plan(*player, destination, blockedPositions, navigationSteps, expandedNodes); + const PlayerBotNavigationResult planResult = navigator.plan(*player, destination, blockedPositions, navigationSteps, expandedNodes); + const bool planned = planResult == PlayerBotNavigationResult::Reached; counters.pathfindingTimeUs += std::chrono::duration_cast( std::chrono::steady_clock::now() - startedAt).count(); if (!planned || navigationSteps.empty()) { diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index 272c91d..107bbc0 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -40,6 +40,8 @@ extern ConfigManager g_config; namespace playerbot { inline constexpr uint32_t navigationInterval = 1000; + inline constexpr uint32_t huntRegionPathfindingCallsPerTurn = 1; + inline constexpr uint32_t huntRegionScoringCandidatesPerTurn = 32; inline constexpr uint32_t blockedRouteRetryInterval = 500; inline constexpr std::chrono::seconds summaryInterval(60); inline constexpr std::chrono::seconds repeatedEventInterval(60); @@ -111,6 +113,9 @@ namespace playerbot { bool progressionEnabled; bool startInHunt; bool fixedFixtureRoute; + bool forceFirstHuntCandidateUnreachable; + bool forceSecondHuntCandidateNodeLimit; + bool cancelHuntPlanningAtScoreBarrier; }; std::string jsonString(const std::string& value); @@ -311,6 +316,41 @@ class PlayerBotController : public std::enable_shared_from_this regions; + std::string reason; + std::chrono::steady_clock::time_point started; + size_t nextCandidate = 0; + size_t nextScoringCandidate = 0; + Phase phase = Phase::Scoring; + uint32_t pathfindingCalls = 0; + uint32_t batchPathfindingCalls = 0; + uint64_t expandedNodes = 0; + uint32_t yields = 0; + uint32_t suitableCandidates = 0; + uint32_t scoredCandidates = 0; + uint32_t totalCandidates = 0; + bool cacheHit = false; + uint64_t snapshotTimeUs = 0; + uint64_t clusteringTimeUs = 0; + uint64_t scoringTimeUs = 0; + Position playerPosition; + uint32_t playerLevel = 0; + int32_t playerHealth = 0; + int32_t playerArmor = 0; + int32_t playerDefense = 0; + uint16_t staminaMinutes = 0; + bool fixtureForcedUnreachable = false; + bool fixtureForcedNodeLimit = false; + uint64_t cacheRevision = 0; + std::set excludedRegions; + }; + class DecisionTimer { public: @@ -541,10 +581,13 @@ class PlayerBotController : public std::enable_shared_from_this huntRegionPlanning; std::optional activeHuntRegion; std::map& huntRegionCooldowns; std::map huntRegionPerformance; @@ -671,6 +715,8 @@ class PlayerBotController : public std::enable_shared_from_this::max(); uint32_t navigationOscillationCount = 0; bool navigationOscillationDetected = false; + bool huntPlanningFixtureCancelled = false; + bool huntPlanningFixtureStaleRevisionTriggered = false; Position blockedNavigationTarget; std::chrono::steady_clock::time_point navigationStepStarted; std::chrono::steady_clock::time_point blockedNavigationTargetExpires; diff --git a/server/src/playerbotdeparture.cpp b/server/src/playerbotdeparture.cpp index 191835a..7d37f7e 100644 --- a/server/src/playerbotdeparture.cpp +++ b/server/src/playerbotdeparture.cpp @@ -65,7 +65,9 @@ bool PlayerBotController::findOracleDeparture(Player& player, const Position& po uint64_t expandedNodes = 0; ++counters.pathfindingCalls; const auto startedAt = std::chrono::steady_clock::now(); - const bool planned = candidate == position || navigator.plan(player, candidate, {}, steps, expandedNodes); + const PlayerBotNavigationResult planResult = candidate == position ? PlayerBotNavigationResult::Reached : + navigator.plan(player, candidate, {}, steps, expandedNodes); + const bool planned = planResult == PlayerBotNavigationResult::Reached; counters.pathfindingTimeUs += std::chrono::duration_cast( std::chrono::steady_clock::now() - startedAt).count(); if (!planned) { diff --git a/server/src/playerbothuntregions.cpp b/server/src/playerbothuntregions.cpp index f247439..16bfe55 100644 --- a/server/src/playerbothuntregions.cpp +++ b/server/src/playerbothuntregions.cpp @@ -34,9 +34,8 @@ namespace { constexpr int32_t rookRadius = 180; constexpr int32_t heatRadius = 8; constexpr int32_t maximumRegionRadius = 24; + constexpr uint16_t spawnBucketSize = heatRadius * 2 + 1; constexpr double maximumThreatRatio = 0.35; - constexpr size_t maximumReachabilityChecks = 12; - constexpr uint64_t maximumReachabilityExpandedNodes = 300000; bool isRookgaardSpawn(const Position& position) { @@ -105,16 +104,6 @@ namespace { return bestDistance == std::numeric_limits::max() ? spawnPosition : best; } - double estimateTravelSeconds(const Player& player, const std::deque& route) - { - double seconds = 0; - for (const PlayerBotNavigationStep& step : route) { - seconds += step.action == PlayerBotNavigationAction::Move ? - player.getStepDuration(step.direction) / 1000.0 : 1.0; - } - return seconds; - } - double projectedStaminaExperienceMultiplier(const Player& player, double availableHuntSeconds) { const uint16_t staminaMinutes = player.getStaminaMinutes(); @@ -132,63 +121,124 @@ namespace { } return staminaMinutes <= 840 ? 0.5 : 1; } -} -std::vector PlayerBotHuntRegionPlanner::evaluate(Player& player, - const PlayerBotNavigator& navigator, - const std::set& excludedRegions, - const std::map& performance, - uint32_t huntDurationSeconds) const -{ - const uint16_t staminaMinutes = player.getStaminaMinutes(); - std::vector spawns; - for (SpawnBlockSnapshot& spawn : g_game.map.spawns.getMonsterSpawnSnapshots()) { - spawn.monsterTypes.erase(std::remove_if(spawn.monsterTypes.begin(), spawn.monsterTypes.end(), - [](const auto& entry) { - return !entry.first || !entry.first->info.isHostile || !entry.first->info.isAttackable; - }), spawn.monsterTypes.end()); - if (isRookgaardSpawn(spawn.position) && !spawn.monsterTypes.empty()) { - spawns.push_back(std::move(spawn)); + struct CachedSpawnBlock { + Position position; + uint32_t interval = 0; + std::vector> monsters; + std::vector neighbors; + }; + + struct CachedRegion { + uint8_t floor = 0; + Position center; + std::vector members; + }; + + struct HuntRegionCache { + std::vector spawns; + std::vector regions; + uint64_t generation = 0; + bool initialized = false; + }; + + HuntRegionCache huntRegionCache; + uint64_t huntRegionCacheRevision = 0; + + void buildHuntRegionCache(uint64_t& snapshotTimeUs, uint64_t& clusteringTimeUs) + { + huntRegionCache = HuntRegionCache{}; + const auto snapshotStarted = std::chrono::steady_clock::now(); + for (SpawnBlockSnapshot& snapshot : g_game.map.spawns.getMonsterSpawnSnapshots()) { + snapshot.monsterTypes.erase(std::remove_if(snapshot.monsterTypes.begin(), snapshot.monsterTypes.end(), + [](const auto& entry) { + return !entry.first || !entry.first->info.isHostile || !entry.first->info.isAttackable; + }), snapshot.monsterTypes.end()); + if (isRookgaardSpawn(snapshot.position) && !snapshot.monsterTypes.empty()) { + huntRegionCache.spawns.push_back({snapshot.position, snapshot.interval, std::move(snapshot.monsterTypes)}); + } } - } + snapshotTimeUs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - snapshotStarted).count(); - std::vector assigned(spawns.size(), false); - std::vector regions; - for (size_t seed = 0; seed < spawns.size(); ++seed) { - if (assigned[seed]) { - continue; + const auto clusteringStarted = std::chrono::steady_clock::now(); + std::map> spawnBuckets; + for (size_t index = 0; index < huntRegionCache.spawns.size(); ++index) { + const Position& position = huntRegionCache.spawns[index].position; + spawnBuckets[Position(position.x / spawnBucketSize, position.y / spawnBucketSize, position.z)].push_back(index); } - std::vector members; - std::deque frontier = {seed}; - assigned[seed] = true; - while (!frontier.empty()) { - const size_t current = frontier.front(); - frontier.pop_front(); - members.push_back(current); - for (size_t candidate = 0; candidate < spawns.size(); ++candidate) { - if (!assigned[candidate] && heatOverlaps(spawns[current].position, spawns[candidate].position) && - Position::getDistanceX(spawns[seed].position, spawns[candidate].position) <= maximumRegionRadius && - Position::getDistanceY(spawns[seed].position, spawns[candidate].position) <= maximumRegionRadius) { - assigned[candidate] = true; - frontier.push_back(candidate); + for (CachedSpawnBlock& spawn : huntRegionCache.spawns) { + const int32_t bucketX = spawn.position.x / spawnBucketSize; + const int32_t bucketY = spawn.position.y / spawnBucketSize; + for (int32_t x = bucketX - 1; x <= bucketX + 1; ++x) { + for (int32_t y = bucketY - 1; y <= bucketY + 1; ++y) { + if (x < 0 || y < 0) { + continue; + } + auto nearby = spawnBuckets.find(Position(static_cast(x), static_cast(y), spawn.position.z)); + if (nearby == spawnBuckets.end()) { + continue; + } + for (size_t candidate : nearby->second) { + if (heatOverlaps(spawn.position, huntRegionCache.spawns[candidate].position)) { + spawn.neighbors.push_back(candidate); + } + } + } + } + } + std::vector assigned(huntRegionCache.spawns.size(), false); + for (size_t seed = 0; seed < huntRegionCache.spawns.size(); ++seed) { + if (assigned[seed]) { + continue; + } + CachedRegion region; + region.floor = huntRegionCache.spawns[seed].position.z; + std::deque frontier = {seed}; + assigned[seed] = true; + uint64_t xTotal = 0; + uint64_t yTotal = 0; + while (!frontier.empty()) { + const size_t current = frontier.front(); + frontier.pop_front(); + region.members.push_back(current); + xTotal += huntRegionCache.spawns[current].position.x; + yTotal += huntRegionCache.spawns[current].position.y; + for (size_t candidate : huntRegionCache.spawns[current].neighbors) { + if (!assigned[candidate] && + heatOverlaps(huntRegionCache.spawns[current].position, huntRegionCache.spawns[candidate].position) && + Position::getDistanceX(huntRegionCache.spawns[seed].position, huntRegionCache.spawns[candidate].position) <= maximumRegionRadius && + Position::getDistanceY(huntRegionCache.spawns[seed].position, huntRegionCache.spawns[candidate].position) <= maximumRegionRadius) { + assigned[candidate] = true; + frontier.push_back(candidate); + } } } + region.center = Position(static_cast(xTotal / region.members.size()), + static_cast(yTotal / region.members.size()), region.floor); + huntRegionCache.regions.push_back(std::move(region)); } + clusteringTimeUs = std::chrono::duration_cast( + std::chrono::steady_clock::now() - clusteringStarted).count(); + huntRegionCache.generation = g_game.map.spawns.getGeneration(); + huntRegionCache.initialized = true; + ++huntRegionCacheRevision; + } + PlayerBotHuntRegion scoreRegion(Player& player, size_t candidateIndex, const std::set& excludedRegions, + const std::map& performance, + uint32_t huntDurationSeconds) + { + const uint16_t staminaMinutes = player.getStaminaMinutes(); + const CachedRegion& cached = huntRegionCache.regions[candidateIndex]; PlayerBotHuntRegion region; - region.floor = spawns[seed].position.z; + region.floor = cached.floor; + region.center = cached.center; std::map profiles; - uint64_t xTotal = 0; - uint64_t yTotal = 0; - for (size_t member : members) { - const SpawnBlockSnapshot& spawn = spawns[member]; - xTotal += spawn.position.x; - yTotal += spawn.position.y; + for (size_t member : cached.members) { + const CachedSpawnBlock& spawn = huntRegionCache.spawns[member]; region.patrolPoints.push_back(nearestApproach(player, spawn.position)); - for (const auto& [monsterType, chance] : spawn.monsterTypes) { - if (!monsterType || !monsterType->info.isHostile || !monsterType->info.isAttackable) { - continue; - } + for (const auto& [monsterType, chance] : spawn.monsters) { PlayerBotHuntMonsterProfile& profile = profiles[monsterType->name]; profile.name = monsterType->name; profile.expectedSpawns += chance / 100.0; @@ -204,20 +254,17 @@ std::vector PlayerBotHuntRegionPlanner::evaluate(Player& pl } if (profiles.empty()) { - continue; + return region; } double worstFightDamage = 0; - for (size_t anchor : members) { + for (size_t anchor : cached.members) { struct LocalAttacker { double damagePerSecond; double fightSeconds; }; std::vector localAttackers; - for (size_t neighbor = 0; neighbor < spawns.size(); ++neighbor) { - if (!heatOverlaps(spawns[anchor].position, spawns[neighbor].position)) { - continue; - } - for (const auto& [monsterType, chance] : spawns[neighbor].monsterTypes) { + for (size_t neighbor : huntRegionCache.spawns[anchor].neighbors) { + for (const auto& [monsterType, chance] : huntRegionCache.spawns[neighbor].monsters) { (void)chance; localAttackers.push_back({expectedMonsterDamagePerSecond(*monsterType, player), monsterType->info.healthMax / @@ -239,8 +286,6 @@ std::vector PlayerBotHuntRegionPlanner::evaluate(Player& pl } worstFightDamage = std::max(worstFightDamage, fightDamage); } - region.center = Position(static_cast(xTotal / members.size()), - static_cast(yTotal / members.size()), region.floor); std::sort(region.patrolPoints.begin(), region.patrolPoints.end()); region.patrolPoints.erase(std::unique(region.patrolPoints.begin(), region.patrolPoints.end()), region.patrolPoints.end()); @@ -280,41 +325,44 @@ std::vector PlayerBotHuntRegionPlanner::evaluate(Player& pl region.staminaExperienceMultiplier * region.availableHuntSeconds / 60.0; region.score = region.projectedExperience; - regions.push_back(std::move(region)); + return region; } +} - std::sort(regions.begin(), regions.end(), [](const PlayerBotHuntRegion& left, const PlayerBotHuntRegion& right) { - return left.score > right.score; - }); - uint32_t regionId = 1; - size_t reachabilityChecks = 0; - uint64_t reachabilityExpandedNodes = 0; - for (PlayerBotHuntRegion& region : regions) { - region.id = regionId++; - if (!region.suitable || reachabilityChecks >= maximumReachabilityChecks || - reachabilityExpandedNodes >= maximumReachabilityExpandedNodes) { - if (region.suitable) { - region.rejectionReason = "reachability_budget"; - } - continue; - } - ++reachabilityChecks; - std::deque route; - std::set blocked; - region.reachable = navigator.plan(player, region.destination, blocked, route, region.expandedNodes); - reachabilityExpandedNodes += region.expandedNodes; - if (!region.reachable) { - region.rejectionReason = "unreachable"; - continue; - } - region.travelSteps = static_cast(route.size()); - region.estimatedTravelSeconds = estimateTravelSeconds(player, route); - region.availableHuntSeconds = std::max(0.0, huntDurationSeconds - region.estimatedTravelSeconds); - region.staminaExperienceMultiplier = projectedStaminaExperienceMultiplier(player, region.availableHuntSeconds); - region.projectedExperience = region.experiencePerMinute * region.observedCorrection * - region.staminaExperienceMultiplier * - region.availableHuntSeconds / 60.0; - region.score = region.projectedExperience; +void PlayerBotHuntRegionPlanner::invalidateCache() +{ + huntRegionCache = HuntRegionCache{}; + ++huntRegionCacheRevision; +} + +uint64_t PlayerBotHuntRegionPlanner::getCacheRevision() +{ + if (huntRegionCache.initialized && huntRegionCache.generation != g_game.map.spawns.getGeneration()) { + invalidateCache(); + } + return huntRegionCacheRevision; +} + +PlayerBotHuntRegionScan PlayerBotHuntRegionPlanner::beginScan() const +{ + PlayerBotHuntRegionScan scan; + scan.cacheHit = huntRegionCache.initialized && huntRegionCache.generation == g_game.map.spawns.getGeneration(); + if (!scan.cacheHit) { + buildHuntRegionCache(scan.snapshotTimeUs, scan.clusteringTimeUs); + } + scan.revision = huntRegionCacheRevision; + scan.candidateCount = huntRegionCache.regions.size(); + return scan; +} + +bool PlayerBotHuntRegionPlanner::score(Player& player, uint64_t revision, size_t candidateIndex, + const std::set& excludedRegions, + const std::map& performance, + uint32_t huntDurationSeconds, PlayerBotHuntRegion& region) const +{ + if (revision != getCacheRevision() || candidateIndex >= huntRegionCache.regions.size()) { + return false; } - return regions; + region = scoreRegion(player, candidateIndex, excludedRegions, performance, huntDurationSeconds); + return true; } diff --git a/server/src/playerbothuntregions.h b/server/src/playerbothuntregions.h index b4c8017..6e67453 100644 --- a/server/src/playerbothuntregions.h +++ b/server/src/playerbothuntregions.h @@ -61,13 +61,23 @@ struct PlayerBotHuntRegionPerformance { uint32_t samples = 0; }; +struct PlayerBotHuntRegionScan { + bool cacheHit = false; + uint64_t revision = 0; + uint64_t snapshotTimeUs = 0; + uint64_t clusteringTimeUs = 0; + size_t candidateCount = 0; +}; + class PlayerBotHuntRegionPlanner { public: - std::vector evaluate(Player& player, const PlayerBotNavigator& navigator, - const std::set& excludedRegions, - const std::map& performance, - uint32_t huntDurationSeconds) const; + static void invalidateCache(); + static uint64_t getCacheRevision(); + PlayerBotHuntRegionScan beginScan() const; + bool score(Player& player, uint64_t revision, size_t candidateIndex, const std::set& excludedRegions, + const std::map& performance, + uint32_t huntDurationSeconds, PlayerBotHuntRegion& region) const; }; #endif diff --git a/server/src/playerbotnavigation.cpp b/server/src/playerbotnavigation.cpp index c1d6b04..42a0e79 100644 --- a/server/src/playerbotnavigation.cpp +++ b/server/src/playerbotnavigation.cpp @@ -31,7 +31,6 @@ namespace { constexpr uint32_t cardinalCost = 10; constexpr uint32_t diagonalCost = 30; constexpr uint32_t transitionCost = 20; - constexpr uint32_t maximumExpandedNodes = 100000; constexpr int32_t searchMargin = 192; constexpr uint16_t ropeItemId = 2120; constexpr uint16_t shovelItemId = 2554; @@ -198,14 +197,15 @@ namespace { }; } -bool PlayerBotNavigator::plan(Player& player, const Position& destination, const std::set& blockedPositions, - std::deque& steps, uint64_t& expandedNodes) const +PlayerBotNavigationResult PlayerBotNavigator::plan(Player& player, const Position& destination, const std::set& blockedPositions, + std::deque& steps, uint64_t& expandedNodes, + uint64_t maximumExpandedNodes) const { steps.clear(); expandedNodes = 0; const Position start = player.getPosition(); if (start == destination) { - return true; + return PlayerBotNavigationResult::Reached; } std::priority_queue, std::greater> open; @@ -246,12 +246,12 @@ bool PlayerBotNavigator::plan(Player& player, const Position& destination, const auto parent = parents.find(positionKey(cursor)); if (parent == parents.end()) { steps.clear(); - return false; + return PlayerBotNavigationResult::Unreachable; } steps.push_front(parent->second.step); cursor = parent->second.position; } - return true; + return PlayerBotNavigationResult::Reached; } for (Direction direction : directions) { @@ -327,5 +327,5 @@ bool PlayerBotNavigator::plan(Player& player, const Position& destination, const } } } - return false; + return open.empty() ? PlayerBotNavigationResult::Unreachable : PlayerBotNavigationResult::NodeLimit; } diff --git a/server/src/playerbotnavigation.h b/server/src/playerbotnavigation.h index ce376b6..2c59ab1 100644 --- a/server/src/playerbotnavigation.h +++ b/server/src/playerbotnavigation.h @@ -20,6 +20,8 @@ class Player; +inline constexpr uint64_t playerBotNavigationMaximumExpandedNodes = 100000; + enum class PlayerBotNavigationAction : uint8_t { Move, Use, @@ -28,6 +30,12 @@ enum class PlayerBotNavigationAction : uint8_t { UseDoor, }; +enum class PlayerBotNavigationResult : uint8_t { + Reached, + Unreachable, + NodeLimit, +}; + struct PlayerBotNavigationStep { PlayerBotNavigationAction action = PlayerBotNavigationAction::Move; Direction direction = DIRECTION_NONE; @@ -39,9 +47,10 @@ struct PlayerBotNavigationStep { class PlayerBotNavigator { public: - bool plan(Player& player, const Position& destination, const std::set& blockedPositions, - std::deque& steps, - uint64_t& expandedNodes) const; + PlayerBotNavigationResult plan(Player& player, const Position& destination, const std::set& blockedPositions, + std::deque& steps, + uint64_t& expandedNodes, + uint64_t maximumExpandedNodes = playerBotNavigationMaximumExpandedNodes) const; }; #endif diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index be68438..d973fa9 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -459,7 +459,9 @@ bool PlayerBotController::planSimpleRewardApproach(Player& player, const Positio uint64_t candidateExpandedNodes = 0; ++counters.pathfindingCalls; const auto startedAt = std::chrono::steady_clock::now(); - const bool planned = candidate == currentPosition || navigator.plan(player, candidate, {}, steps, candidateExpandedNodes); + const PlayerBotNavigationResult planResult = candidate == currentPosition ? PlayerBotNavigationResult::Reached : + navigator.plan(player, candidate, {}, steps, candidateExpandedNodes); + const bool planned = planResult == PlayerBotNavigationResult::Reached; counters.pathfindingTimeUs += std::chrono::duration_cast( std::chrono::steady_clock::now() - startedAt).count(); if (!planned || (candidate != currentPosition && steps.empty())) { @@ -832,8 +834,7 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos const bool higherUtilityGoal = (departureCandidate.feasible && departureCandidate.utility > huntGoalUtility) || (service.feasible && service.utility > huntGoalUtility) || (pickup.feasible && pickup.utility > huntGoalUtility); - const bool huntFeasible = !higherUtilityGoal && - (testPolicy.fixedFixtureRoute || selectHuntRegion(player, position, "goal_feasibility")); + const bool huntFeasible = !higherUtilityGoal; const GoalCandidate hunt{TopLevelGoal::Hunt, huntFeasible, huntGoalUtility, higherUtilityGoal ? "deferred_lower_utility" : huntFeasible ? "autonomous_hunting_available" : "no_suitable_reachable_region"}; diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index cf7891d..4100ffd 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -198,7 +198,8 @@ bool PlayerBotController::approachServiceNpc(Player* player, ServiceNpc& service uint64_t expandedNodes = 0; ++counters.pathfindingCalls; const auto startedAt = std::chrono::steady_clock::now(); - const bool planned = navigator.plan(*player, candidate, {}, candidateSteps, expandedNodes); + const bool planned = navigator.plan(*player, candidate, {}, candidateSteps, expandedNodes) == + PlayerBotNavigationResult::Reached; counters.pathfindingTimeUs += std::chrono::duration_cast( std::chrono::steady_clock::now() - startedAt).count(); if (!planned || candidateSteps.empty()) { diff --git a/server/src/signals.cpp b/server/src/signals.cpp index b8c4447..f6314a0 100644 --- a/server/src/signals.cpp +++ b/server/src/signals.cpp @@ -28,6 +28,7 @@ #include "spells.h" #include "talkaction.h" #include "movement.h" +#include "playerbothuntregions.h" #include "weapons.h" #include "raids.h" #include "quests.h" @@ -103,6 +104,7 @@ void sighupHandler() std::cout << "Reloaded raids." << std::endl; g_monsters.reload(); + PlayerBotHuntRegionPlanner::invalidateCache(); std::cout << "Reloaded monsters." << std::endl; g_spells->reload(); diff --git a/server/src/spawn.cpp b/server/src/spawn.cpp index 9433f53..ce07481 100644 --- a/server/src/spawn.cpp +++ b/server/src/spawn.cpp @@ -51,6 +51,7 @@ bool Spawns::loadFromXml(const std::string& filename) this->filename = filename; loaded = true; + ++generation; for (auto spawnNode : doc.child("spawns").children()) { Position centerPos( @@ -240,6 +241,7 @@ void Spawns::clear() loaded = false; started = false; filename.clear(); + ++generation; } std::vector Spawns::getMonsterSpawnSnapshots() const diff --git a/server/src/spawn.h b/server/src/spawn.h index a517e58..bf2282c 100644 --- a/server/src/spawn.h +++ b/server/src/spawn.h @@ -99,6 +99,7 @@ class Spawns void startup(); void clear(); std::vector getMonsterSpawnSnapshots() const; + uint64_t getGeneration() const { return generation; } bool isStarted() const { return started; @@ -110,6 +111,7 @@ class Spawns std::string filename; bool loaded = false; bool started = false; + uint64_t generation = 0; }; #endif // FS_SPAWN_H diff --git a/server/tests/playerbot-gameplay/playerbot_gameplay.lua b/server/tests/playerbot-gameplay/playerbot_gameplay.lua index 88dd4e3..bbe5a61 100644 --- a/server/tests/playerbot-gameplay/playerbot_gameplay.lua +++ b/server/tests/playerbot-gameplay/playerbot_gameplay.lua @@ -338,8 +338,12 @@ function login.onLogin(player) mode == "arbitration" or mode == "arbitration_interrupt" or mode == "departure" or mode == "departure_interrupt" or mode == "departure_recovery" or mode == "stamina_bonus" or mode == "stamina_boundary" or - mode == "stamina_normal", + mode == "stamina_normal" or mode == "hunt_planning", "unknown PLAYERBOT_GAMEPLAY_MODE: " .. mode) + if mode == "hunt_planning" then + print("PLAYERBOT_GAMEPLAY_TEST HUNT_PLANNING_START") + return true + end if mode == "stamina_bonus" or mode == "stamina_boundary" or mode == "stamina_normal" then local staminaMinutes = mode == "stamina_bonus" and 2520 or mode == "stamina_boundary" and 2401 or 2400 assert(player:setStamina(staminaMinutes), "stamina projection fixture could not set stamina")