diff --git a/docs/playerbots.md b/docs/playerbots.md index fab1e64..5445daa 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -31,6 +31,8 @@ Focused scenarios often use fixed destinations, controlled monsters, teleports, Navigation is hierarchical: [`PlayerBotTopology`](../server/src/playerbottopology.cpp) indexes the loaded map into connected local regions and transition edges. The [controller](../server/src/playerbotcontroller.cpp) selects a global route, refines its next segment into tile-level actions, and considers eligible NPC travel connections. Whole-map routing describes its scope, not a guarantee that every destination is reachable. +Hunt departure validates a return route and protects its fare. Patrols reuse return-route evidence across complete, same-floor walking paths with no modeled danger; added danger, transitions, route failures, or changed route inputs require revalidation. Rejected patrol plans enter bounded recovery rather than retrying indefinitely. Depot-bound travel needs enough money for the trip, not for a later potion restock; route safety rules still apply. Supplier travel after reaching the depot retains the restock reserve. + ## Knowledge and game boundaries Bots may use static facts a player could learn and remember: map geography, known spawn areas, quest locations, and loaded NPC services. They must not read hidden live state to bypass game mechanics. In particular, a bot identifies corpses through normal container and ownership metadata, opens them normally, and only then inspects contents. diff --git a/scripts/playerbot-gameplay/assertions-navigation.ps1 b/scripts/playerbot-gameplay/assertions-navigation.ps1 index 389df84..2a59278 100644 --- a/scripts/playerbot-gameplay/assertions-navigation.ps1 +++ b/scripts/playerbot-gameplay/assertions-navigation.ps1 @@ -341,9 +341,11 @@ function Assert-InaccessibleCorpseEvents { throw "Inaccessible corpse work was not bounded. loot=$($terminalResult.Count), passage_combat=$($passageCombat.Count), terminal=$($controllerTerminal.Count)." } $loot = $terminalResult[0] - if ($loot.target_id -le 0 -or $loot.elapsed_ms -lt 18000 -or $loot.elapsed_ms -gt 22000 -or - $loot.navigation_failures -gt 6 -or $loot.navigation_suspensions -gt 1) { - throw "Inaccessible corpse work was not bounded: invalid independent 20-second loot deadline." + # The current controller gives one 2-second retry after three failures, then + # ends this fixture at the six-failure bound before the 20-second deadline. + if ($loot.target_id -le 0 -or $loot.elapsed_ms -le 0 -or $loot.elapsed_ms -ge 20000 -or + $loot.navigation_failures -ne 6 -or $loot.navigation_suspensions -ne 1) { + throw "Inaccessible corpse work was not bounded by the six-failure recovery contract." } } @@ -351,19 +353,46 @@ function Assert-CorpseDetourEvents { param([string]$Logs) $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) - $detours = @($events | Where-Object { - $_.event -eq "navigation_progress" -and $_.reason -eq "hostile_detour" -and - $_.blocker_id -gt 0 + $corpseRoute = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "plan" -and $_.result -eq "success" -and + $_.danger_aware -eq $true -and $_.steps -gt 0 -and $null -ne $_.destination }) $loot = @($events | Where-Object { - $_.event -eq "action_result" -and $_.action -eq "loot" -and $_.result -eq "success" + $_.event -eq "action_result" -and $_.action -eq "loot" -and $_.result -eq "success" -and $_.item_id -eq 2148 }) $defensiveCombat = @($events | Where-Object { $_.event -eq "action_result" -and $_.action -eq "defensive_combat" -and $_.result -eq "started" }) + $controllerTerminal = @($events | Where-Object { + $_.event -in @("terminal", "death") -or + ($_.event -eq "lifecycle" -and $_.status -in @("dead", "removed", "recovery_abandoned")) + }) + $fixtureDisplaced = $Logs -match 'PLAYERBOT_GAMEPLAY_TEST CORPSE_INACCESSIBLE_DISPLACED' + if (-not $fixtureDisplaced -or $corpseRoute.Count -lt 1 -or $loot.Count -lt 1 -or + $defensiveCombat.Count -ne 0 -or $controllerTerminal.Count -ne 0) { + throw "The displaced corpse was not reached through a safe route. fixture_displaced=$fixtureDisplaced, route=$($corpseRoute.Count), loot=$($loot.Count), defensive=$($defensiveCombat.Count), terminal=$($controllerTerminal.Count)." + } +} + +function Assert-DepotRiskRouteEvents { + param([string]$Logs) + + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $contract = @($events | Where-Object { + $_.event -eq "depot_risk_fallback_contract" -and $_.safe_precedence -eq $true -and + $_.retained_across_turns -eq $true -and $_.ranked_fallback -eq $true -and + $_.requested_revalidation -eq $true -and $_.failed_revalidation_rejected -eq $true + }) + $safeRoute = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "depot_discover" -and $_.result -eq "success" -and + $_.risk_fallback -eq $false -and $_.unsafe_routes -gt 0 -and $_.route_steps -gt 0 -and + $null -ne $_.danger_cost -and $_.danger_cost -le 500 -and + $null -ne $_.maximum_health_loss_per_second -and $_.maximum_health_loss_per_second -le 0.08 + }) $terminal = @($events | Where-Object { $_.event -eq "terminal" }) - if ($detours.Count -lt 1 -or $loot.Count -lt 1 -or $defensiveCombat.Count -ne 0 -or $terminal.Count -ne 0) { - throw "The corpse blocker was not bypassed. detours=$($detours.Count), loot=$($loot.Count), defensive=$($defensiveCombat.Count), terminal=$($terminal.Count)." + if ($Logs -notmatch 'PLAYERBOT_GAMEPLAY_TEST DEPOT_RISK_FALLBACK_PASS' -or $contract.Count -ne 1 -or + $safeRoute.Count -lt 1 -or $terminal.Count -ne 0) { + throw "The depot risk route did not reject unsafe candidates and cross the ramp safely. contract=$($contract.Count), safe_route=$($safeRoute.Count), terminal=$($terminal.Count)." } } diff --git a/scripts/playerbot-gameplay/scenarios-combat-loot.ps1 b/scripts/playerbot-gameplay/scenarios-combat-loot.ps1 index 65aa4b6..798266f 100644 --- a/scripts/playerbot-gameplay/scenarios-combat-loot.ps1 +++ b/scripts/playerbot-gameplay/scenarios-combat-loot.ps1 @@ -17,7 +17,7 @@ } Assert-CorpseDetourEvents -Logs $corpseLogs } - # Allow server startup in addition to the asserted 70-second loot bound. + # Allow server startup in addition to the bounded six-failure recovery. Invoke-Scenario -Name "corpse_inaccessible" -DefaultTimeoutSeconds 90 -Body { Invoke-Compose down --volumes --remove-orphans $env:PLAYERBOT_GAMEPLAY_MODE = "corpse_inaccessible" diff --git a/scripts/playerbot-gameplay/scenarios-service.ps1 b/scripts/playerbot-gameplay/scenarios-service.ps1 index 1e25664..79389c7 100644 --- a/scripts/playerbot-gameplay/scenarios-service.ps1 +++ b/scripts/playerbot-gameplay/scenarios-service.ps1 @@ -116,23 +116,8 @@ $env:PLAYERBOT_DEPOT_MOVE_CASE = "normal" Invoke-Compose up --detach Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST DEPOT_RISK_FALLBACK_START' | Out-Null - Wait-ForLog -Pattern '"action":"depot_discover","result":"success".*"risk_fallback":true' | Out-Null $logs = Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST DEPOT_RISK_FALLBACK_PASS' - $events = @(ConvertFrom-PlayerbotLogs -Logs $logs) - $contract = @($events | Where-Object { - $_.event -eq "depot_risk_fallback_contract" -and $_.safe_precedence -eq $true -and - $_.retained_across_turns -eq $true -and $_.ranked_fallback -eq $true -and - $_.requested_revalidation -eq $true -and $_.failed_revalidation_rejected -eq $true - }) - $fallback = @($events | Where-Object { - $_.event -eq "action_result" -and $_.action -eq "depot_discover" -and $_.result -eq "success" -and - $_.risk_fallback -eq $true -and $_.unsafe_routes -gt 0 -and $_.route_steps -gt 0 -and - ($_.danger_cost -gt 500 -or $_.maximum_health_loss_per_second -gt 0.08) - }) - $terminal = @($events | Where-Object { $_.event -eq "terminal" }) - if ($contract.Count -ne 1 -or $fallback.Count -ne 1 -or $terminal.Count -ne 0) { - throw "The depot risk fallback contract or swamp-troll escape failed. contract=$($contract.Count), fallback=$($fallback.Count), terminal=$($terminal.Count)." - } + Assert-DepotRiskRouteEvents -Logs $logs } Invoke-Scenario -Name "real_depot" -DefaultTimeoutSeconds 240 -Body { diff --git a/scripts/test-playerbot-navigation-assertions.ps1 b/scripts/test-playerbot-navigation-assertions.ps1 index fb381b1..1162ee1 100644 --- a/scripts/test-playerbot-navigation-assertions.ps1 +++ b/scripts/test-playerbot-navigation-assertions.ps1 @@ -95,22 +95,22 @@ if ($DangerRetreatLogPath) { function New-InaccessibleCorpseFixture { return @( @{ event = "action_result"; action = "loot"; result = "failed"; reason = "corpse_inaccessible"; - target_id = 41; navigation_failures = 6; navigation_suspensions = 1; elapsed_ms = 20056 } + target_id = 41; navigation_failures = 6; navigation_suspensions = 1; elapsed_ms = 5100 } ) } Assert-InaccessibleCorpseEvents -Logs (ConvertTo-FixtureLogs (New-InaccessibleCorpseFixture)) -foreach ($case in @("early_deadline", "late_deadline", "combat_timeout", "too_many_failures", "duplicate_loot", "terminal")) { +foreach ($case in @("timeout_deadline", "combat_timeout", "wrong_failure_bound", "wrong_suspension_bound", "duplicate_loot", "terminal")) { $events = @(New-InaccessibleCorpseFixture) switch ($case) { - "early_deadline" { $events[0].elapsed_ms = 17999 } - "late_deadline" { $events[0].elapsed_ms = 22001 } + "timeout_deadline" { $events[0].elapsed_ms = 20000 } "combat_timeout" { $events = @( @{ event = "action_result"; action = "defensive_combat"; result = "failed"; reason = "combat_timeout"; target_id = 42 } ) + $events } - "too_many_failures" { $events[0].navigation_failures = 7 } + "wrong_failure_bound" { $events[0].navigation_failures = 5 } + "wrong_suspension_bound" { $events[0].navigation_suspensions = 0 } "duplicate_loot" { $events += $events[0] } "terminal" { $events += @{ event = "terminal"; reason = "controlled_player_dead" } } } @@ -123,6 +123,55 @@ if ($InaccessibleCorpseLogPath) { Assert-InaccessibleCorpseEvents -Logs (Get-Content -Raw -LiteralPath $InaccessibleCorpseLogPath) } +function New-CorpseDetourFixture { + return @( + @{ event = "action_result"; action = "plan"; result = "success"; danger_aware = $true; steps = 3; + destination = @{ x = 32105; y = 32194; z = 8 } } + @{ event = "action_result"; action = "loot"; result = "success"; item_id = 2148; count = 1 } + ) +} +$detourLogs = "PLAYERBOT_GAMEPLAY_TEST CORPSE_INACCESSIBLE_DISPLACED`n" + + (ConvertTo-FixtureLogs (New-CorpseDetourFixture)) +# The current controller does not emit the removed hostile_detour telemetry. +Assert-CorpseDetourEvents -Logs $detourLogs +foreach ($case in @("missing_displacement", "no_route", "defensive_combat", "terminal")) { + $events = @(New-CorpseDetourFixture) + $logs = $detourLogs + switch ($case) { + "missing_displacement" { $logs = ConvertTo-FixtureLogs $events } + "no_route" { $events = @($events | Where-Object action -ne "plan"); $logs = "PLAYERBOT_GAMEPLAY_TEST CORPSE_INACCESSIBLE_DISPLACED`n" + (ConvertTo-FixtureLogs $events) } + "defensive_combat" { $events += @{ event = "action_result"; action = "defensive_combat"; result = "started" }; $logs = "PLAYERBOT_GAMEPLAY_TEST CORPSE_INACCESSIBLE_DISPLACED`n" + (ConvertTo-FixtureLogs $events) } + "terminal" { $events += @{ event = "terminal"; reason = "controlled_player_dead" }; $logs = "PLAYERBOT_GAMEPLAY_TEST CORPSE_INACCESSIBLE_DISPLACED`n" + (ConvertTo-FixtureLogs $events) } + } + Assert-Rejected "Corpse detour $case" { + Assert-CorpseDetourEvents -Logs $logs + } "The displaced corpse was not reached through a safe route" +} + +function New-DepotRiskRouteFixture { + return @( + @{ event = "depot_risk_fallback_contract"; safe_precedence = $true; retained_across_turns = $true; + ranked_fallback = $true; requested_revalidation = $true; failed_revalidation_rejected = $true } + @{ event = "action_result"; action = "depot_discover"; result = "success"; risk_fallback = $false; + unsafe_routes = 34; route_steps = 577; danger_cost = 323; maximum_health_loss_per_second = 0.0137222 } + ) +} +$depotRiskLogs = "PLAYERBOT_GAMEPLAY_TEST DEPOT_RISK_FALLBACK_PASS`n" + + (ConvertTo-FixtureLogs (New-DepotRiskRouteFixture)) +Assert-DepotRiskRouteEvents -Logs $depotRiskLogs +foreach ($case in @("fallback_selected", "no_unsafe_rejections", "unsafe_safe_route", "terminal")) { + $events = @(New-DepotRiskRouteFixture) + switch ($case) { + "fallback_selected" { $events[1].risk_fallback = $true } + "no_unsafe_rejections" { $events[1].unsafe_routes = 0 } + "unsafe_safe_route" { $events[1].danger_cost = 501 } + "terminal" { $events += @{ event = "terminal"; reason = "depot_unavailable" } } + } + Assert-Rejected "Depot risk route $case" { + Assert-DepotRiskRouteEvents -Logs ("PLAYERBOT_GAMEPLAY_TEST DEPOT_RISK_FALLBACK_PASS`n" + (ConvertTo-FixtureLogs $events)) + } "The depot risk route did not reject unsafe candidates" +} + function New-MainlandFixture { return @( @{ event = "action_result"; action = "hunt_cycle"; result = "started"; cycle = 1 } diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index a5befff..2c655da 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -1290,8 +1290,11 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit huntReturnDestination = selected.exitDepotDestination; huntTravelBudgetPhase = HuntTravelBudgetPhase::Outbound; huntExitFareReserve = selected.exitFare; - huntSupplyFareReserve = selected.supplyFare; huntRecoveryPotionReserve = selected.recoveryPotionReserve; + huntReturnCoverageVariantId = selected.atlasVariantId; + huntExitRouteProtected = true; + huntReturnCoverage.validate(huntReturnCoverageVariantId, selected.atlasRevision, + huntReturnCoverageContext(player, selected.destination)); const uint32_t healthLossCost = static_cast(risk.healthLossCost); huntPotionReturnThreshold = recoveryPotionRouteReserve( player.getVocationId(), player.getMaxHealth(), huntReturnRouteDangerCost, healthLossCost); @@ -1548,11 +1551,13 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre const PlayerBotNavigationRiskProfile patrolRisk; const bool activeRegionPatrol = huntCoordinator.huntActive(); auto observePatrolFailure = [&](const PlayerBotNavigationRuntimeOutcome& failure) { + if (failure.routeUnavailable && failure.plan.attempted) huntReturnCoverage.invalidate(); if (fixtureDriver.navigationRecovery(failure.routeUnavailable).pause) navigationRuntime.clearBlockedPositions(); const PlayerBotHuntPatrolOutcome recovery = huntCoordinator.observeHuntPatrolNavigation(failure, now, maximumRepeatedNavigationStepFailures, failure.routeUnsafe ? 1 : maximumPatrolRouteFailures); if (recovery.command != PlayerBotHuntPatrolCommand::SkipWaypoint && recovery.command != PlayerBotHuntPatrolCommand::RegionExhausted) return; + huntReturnCoverage.invalidate(); const char* recoveryReason = failure.routeUnsafe ? "route_danger_above_tolerance" : recovery.reason; emit("hunt_region_patrol", currentPosition, "\"result\":\"skipped\",\"reason\":" + jsonString(recoveryReason) + ",\"step_failures\":" + std::to_string(recovery.stepFailures) + ",\"route_failures\":" + std::to_string(recovery.routeFailures) + @@ -1579,31 +1584,34 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre const Position routeDestination = validateReturn ? huntReturnDestination : patrol.destination; // The outbound plan is installed below, so it must include a real approach // to the first transport NPC. Return preflight consumes metrics only. - const PlayerBotNavigationRoutePlan preflight = planHuntTravelRoute( + PlayerBotNavigationRoutePlan preflight = planHuntTravelRoute( *player, routeStart, routeDestination, huntPatrolPreflightBlockedPositions, validateReturn); - telemetry.recordPathfinding(preflight.metrics.elapsed, - preflight.metrics.result == PlayerBotNavigationResult::Reached); + if (preflight.metrics.attempted) { + telemetry.recordPathfinding(preflight.metrics.elapsed, + preflight.metrics.result == PlayerBotNavigationResult::Reached); + } const bool routeReached = preflight.metrics.result == PlayerBotNavigationResult::Reached; - const HuntTravelBudgetPhase budgetPhase = validateReturn ? HuntTravelBudgetPhase::Exit : + const HuntTravelBudgetPhase budgetPhase = validateReturn ? HuntTravelBudgetPhase::ReturnToDepot : HuntTravelBudgetPhase::Outbound; const bool routeAffordable = routeReached && huntTravelFareAffordable( *player, preflight.metrics.fare, budgetPhase); const bool routeSafe = routeAffordable && playerBotNavigationRiskAccepts( patrolRisk, preflight.metrics.dangerCost, preflight.metrics.maximumHealthLossPerSecond); if (!routeSafe) { - navigation.plan = preflight.metrics; - navigation.routeUnavailable = true; + navigation = playerBotHuntRejectedPatrolPreflight(preflight.metrics, routeAffordable); if (!routeReached) huntCoordinator.observeTransitMovementFailure(currentPosition); - navigation.routeUnsafe = routeAffordable; if (routeReached && !routeAffordable) { emit("navigation_progress", currentPosition, - "\"result\":\"skipped\",\"reason\":\"route_fare_breaks_hunt_reserve\",\"phase\":\"patrol_preflight\",\"direction\":" + - jsonString(validateReturn ? "return" : "outbound") + ",\"fare\":" + - std::to_string(preflight.metrics.fare) + ",\"future_fare_reserve\":" + - std::to_string(huntTravelFutureFareReserve(budgetPhase)) + - ",\"recovery_funds_reserve\":" + std::to_string(recoverySpendingReserve( - *player, potionStockTarget(*player, huntRecoveryPotionReserve)))); + "\"result\":\"skipped\",\"reason\":" + + jsonString(playerBotHuntTravelFareRejectionReason(budgetPhase)) + + ",\"phase\":\"patrol_preflight\",\"direction\":" + + jsonString(validateReturn ? "return" : "outbound") + ",\"budget_phase\":" + + jsonString(playerBotHuntTravelBudgetPhaseName(budgetPhase)) + ",\"fare\":" + + std::to_string(preflight.metrics.fare) + ",\"return_fare_reserve\":" + + std::to_string(huntTravelReturnFareReserve(budgetPhase)) + + ",\"recovery_funds_reserve\":" + + std::to_string(huntTravelRecoveryFundsReserve(*player, budgetPhase))); } else if (navigation.routeUnsafe) { emit("navigation_progress", currentPosition, "\"result\":\"skipped\",\"reason\":\"route_danger_above_tolerance\",\"phase\":\"patrol_preflight\",\"direction\":" + @@ -1617,26 +1625,63 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre observePatrolFailure(navigation); return; } + const uint64_t coverageRevision = PlayerBotHuntRegionPlanner::getCacheRevision(); + bool destinationCovered = false; + bool extendsCoverage = false; if (!validateReturn) { - huntPatrolValidationDestination = patrol.destination; - huntPatrolValidationOrigin = currentPosition; - huntPatrolOutboundPlan = preflight; - schedule(SCHEDULER_MINTICKS); - return; + destinationCovered = huntReturnCoverage.covers( + huntReturnCoverageVariantId, coverageRevision, + huntReturnCoverageContext(*player, patrol.destination)); + extendsCoverage = huntReturnCoverage.covers( + huntReturnCoverageVariantId, coverageRevision, + huntReturnCoverageContext(*player, currentPosition)) && + playerBotNavigationIsReversibleLocalWalk(currentPosition, patrol.destination, preflight); + if (!destinationCovered && !extendsCoverage) { + huntPatrolValidationDestination = patrol.destination; + huntPatrolValidationOrigin = currentPosition; + huntPatrolOutboundPlan = std::move(preflight); + schedule(SCHEDULER_MINTICKS); + return; + } } - const uint32_t requiredReserve = recoveryPotionRouteReserve( - player->getVocationId(), player->getMaxHealth(), preflight.metrics.dangerCost, - static_cast(patrolRisk.healthLossCost)); - if (requiredReserve > huntPotionReturnThreshold) { - huntPotionReturnThreshold = requiredReserve; - huntPotionRestockTarget = potionStockTarget(*player, requiredReserve); - emit("hunt_supply_reserve", currentPosition, - "\"source\":\"patrol_return_route\",\"route_danger_cost\":" + - std::to_string(preflight.metrics.dangerCost) + ",\"return_threshold\":" + - std::to_string(huntPotionReturnThreshold) + ",\"restock_target\":" + - std::to_string(huntPotionRestockTarget)); - } - PlayerBotNavigationRoutePlan outboundPlan = std::move(*huntPatrolOutboundPlan); + if (validateReturn) { + huntExitFareReserve = preflight.metrics.fare; + huntReturnRouteDangerCost = preflight.metrics.dangerCost; + const uint32_t requiredReserve = recoveryPotionRouteReserve( + player->getVocationId(), player->getMaxHealth(), preflight.metrics.dangerCost, + static_cast(patrolRisk.healthLossCost)); + if (requiredReserve > huntPotionReturnThreshold) { + huntPotionReturnThreshold = requiredReserve; + huntPotionRestockTarget = potionStockTarget(*player, requiredReserve); + emit("hunt_supply_reserve", currentPosition, + "\"source\":\"patrol_return_route\",\"route_danger_cost\":" + + std::to_string(preflight.metrics.dangerCost) + ",\"return_threshold\":" + + std::to_string(huntPotionReturnThreshold) + ",\"restock_target\":" + + std::to_string(huntPotionRestockTarget)); + } + if (!huntTravelFareAffordable(*player, huntPatrolOutboundPlan->metrics.fare, + HuntTravelBudgetPhase::Outbound)) { + navigation = playerBotHuntRejectedPatrolPreflight(huntPatrolOutboundPlan->metrics, false); + emit("navigation_progress", currentPosition, + "\"result\":\"skipped\",\"reason\":" + + jsonString(playerBotHuntTravelFareRejectionReason(HuntTravelBudgetPhase::Outbound)) + + ",\"phase\":\"patrol_preflight\",\"direction\":\"outbound\",\"budget_phase\":" + + jsonString(playerBotHuntTravelBudgetPhaseName(HuntTravelBudgetPhase::Outbound)) + + ",\"fare\":" + std::to_string(huntPatrolOutboundPlan->metrics.fare) + + ",\"return_fare_reserve\":" + std::to_string(huntExitFareReserve) + + ",\"recovery_funds_reserve\":0"); + schedule(blockedRouteRetryInterval); + observePatrolFailure(navigation); + return; + } + huntReturnCoverage.validate(huntReturnCoverageVariantId, coverageRevision, + huntReturnCoverageContext(*player, patrol.destination)); + } else if (extendsCoverage && !destinationCovered) { + huntReturnCoverage.validate(huntReturnCoverageVariantId, coverageRevision, + huntReturnCoverageContext(*player, patrol.destination)); + } + PlayerBotNavigationRoutePlan outboundPlan = validateReturn ? + std::move(*huntPatrolOutboundPlan) : std::move(preflight); resetNavigation(); observeNavigationPlan(patrol.destination, std::move(outboundPlan.steps)); huntPatrolValidatedDestination = patrol.destination; @@ -1648,6 +1693,7 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre activeRegionPatrol ? &patrolRisk : nullptr, !activeRegionPatrol)) { if (activeRegionPatrol && navigation.routeRequest) { std::set blockedPositions = std::move(navigation.routeRequest->blockedPositions); + huntReturnCoverage.invalidate(); resetNavigation(); huntPatrolPreflightBlockedPositions = std::move(blockedPositions); schedule(SCHEDULER_MINTICKS); diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index ae16852..f7d7643 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -262,6 +262,7 @@ void PlayerBotController::start(const Position& position, bool recovered, uint32 startHunt(g_game.getPlayerByID(playerId), position, "focused_fixture"); } else if (fixtureDriver.depotScenario()) { progressionRuntime.enterService(); + huntTravelBudgetPhase = HuntTravelBudgetPhase::ReturnToDepot; turnRouter.setCyclePhase(CyclePhase::ReturnToDepot); } else { beginService(controlledPlayer, position, "startup"); @@ -567,12 +568,14 @@ bool PlayerBotController::executeNavigationStep(Player* player, const PlayerBotN Position::getDistanceY(npc->getPosition(), player->getPosition())) > 3) return false; if (!huntTravelFareAffordable(*player, step.price, huntTravelBudgetPhase)) { telemetry.emit("npc_travel", player->getPosition(), - "\"result\":\"refused\",\"reason\":\"fare_breaks_hunt_reserve\",\"npc_id\":" + - std::to_string(step.npcId) + ",\"npc_name\":" + jsonString(npc->getName()) + - ",\"fare\":" + std::to_string(step.price) + ",\"future_fare_reserve\":" + - std::to_string(huntTravelFutureFareReserve(huntTravelBudgetPhase)) + - ",\"recovery_funds_reserve\":" + std::to_string(recoverySpendingReserve( - *player, potionStockTarget(*player, huntRecoveryPotionReserve)))); + "\"result\":\"refused\",\"reason\":" + + jsonString(playerBotHuntTravelFareRejectionReason(huntTravelBudgetPhase)) + + ",\"budget_phase\":" + jsonString(playerBotHuntTravelBudgetPhaseName(huntTravelBudgetPhase)) + + ",\"npc_id\":" + std::to_string(step.npcId) + ",\"npc_name\":" + jsonString(npc->getName()) + + ",\"fare\":" + std::to_string(step.price) + ",\"return_fare_reserve\":" + + std::to_string(huntTravelReturnFareReserve(huntTravelBudgetPhase)) + + ",\"recovery_funds_reserve\":" + + std::to_string(huntTravelRecoveryFundsReserve(*player, huntTravelBudgetPhase))); return false; } npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, "hi"); @@ -1199,6 +1202,7 @@ std::optional PlayerBotController::planNpcTravelRo } } PlayerBotNavigationRoutePlan route; + route.metrics.attempted = true; route.metrics.result = PlayerBotNavigationResult::Reached; route.metrics.expandedNodes = graphExpandedNodes; route.metrics.steps = selectedRouteSteps; @@ -1224,24 +1228,33 @@ std::optional PlayerBotController::planNpcTravelRo return route; } -uint64_t PlayerBotController::huntTravelFutureFareReserve(HuntTravelBudgetPhase phase) const +uint64_t PlayerBotController::huntTravelReturnFareReserve(HuntTravelBudgetPhase phase) const { - if (phase == HuntTravelBudgetPhase::Outbound) { - return huntExitFareReserve > UINT64_MAX - huntSupplyFareReserve ? UINT64_MAX : - huntExitFareReserve + huntSupplyFareReserve; - } - return phase == HuntTravelBudgetPhase::Exit ? huntSupplyFareReserve : 0; + return playerBotHuntTravelReturnFareReserve(phase, huntExitFareReserve); +} + +uint64_t PlayerBotController::huntTravelRecoveryFundsReserve( + const Player& player, HuntTravelBudgetPhase phase) const +{ + if (phase != HuntTravelBudgetPhase::Supply) return 0; + return recoverySpendingReserve(player, potionStockTarget(player, huntRecoveryPotionReserve)); } bool PlayerBotController::huntTravelFareAffordable( const Player& player, uint64_t fare, HuntTravelBudgetPhase phase) const { - if (phase == HuntTravelBudgetPhase::None || fare == 0) return true; - const uint64_t recoveryReserve = recoverySpendingReserve( - player, potionStockTarget(player, huntRecoveryPotionReserve)); return playerBotHuntTravelPaymentAffordable(player.getMoney() + player.getBankBalance(), - recoveryReserve, fare, - huntTravelFutureFareReserve(phase)); + huntTravelRecoveryFundsReserve(player, phase), + fare, huntExitFareReserve, phase); +} + +PlayerBotHuntReturnCoverageContext PlayerBotController::huntReturnCoverageContext( + Player& player, const Position& coveredPosition) const +{ + return {PlayerBotTopology::instance().generation(), g_game.getNpcGeneration(), player.getLevel(), + coveredPosition, huntReturnDestination, huntExitFareReserve, + g_game.findItemOfType(&player, playerbot::ropeItemId, true) != nullptr, + g_game.findItemOfType(&player, shovelToolItemId, true) != nullptr, player.isPremium()}; } PlayerBotNavigationRoutePlan PlayerBotController::planHuntTravelRoute( @@ -1422,13 +1435,16 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr navigationRuntime.reset(); if (navigationOutcome) *navigationOutcome = outcome; telemetry.emit("navigation_progress", currentPosition, - "\"result\":\"skipped\",\"reason\":\"route_fare_breaks_hunt_reserve\",\"destination\":{\"x\":" + - std::to_string(destination.x) + ",\"y\":" + std::to_string(destination.y) + - ",\"z\":" + std::to_string(static_cast(destination.z)) + - "},\"fare\":" + std::to_string(outcome.plan.fare) + - ",\"future_fare_reserve\":" + std::to_string(huntTravelFutureFareReserve(huntTravelBudgetPhase)) + - ",\"recovery_funds_reserve\":" + std::to_string(recoverySpendingReserve( - *player, potionStockTarget(*player, huntRecoveryPotionReserve)))); + "\"result\":\"skipped\",\"reason\":" + + jsonString(playerBotHuntTravelFareRejectionReason(huntTravelBudgetPhase)) + + ",\"budget_phase\":" + jsonString(playerBotHuntTravelBudgetPhaseName(huntTravelBudgetPhase)) + + ",\"destination\":{\"x\":" + std::to_string(destination.x) + ",\"y\":" + + std::to_string(destination.y) + ",\"z\":" + + std::to_string(static_cast(destination.z)) + "},\"fare\":" + + std::to_string(outcome.plan.fare) + ",\"return_fare_reserve\":" + + std::to_string(huntTravelReturnFareReserve(huntTravelBudgetPhase)) + + ",\"recovery_funds_reserve\":" + + std::to_string(huntTravelRecoveryFundsReserve(*player, huntTravelBudgetPhase))); schedule(blockedRouteRetryInterval); return false; } diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index efa7ebd..e5863e5 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -159,7 +159,7 @@ class PlayerBotController : public std::enable_shared_from_this huntDepotExitCandidates(Player& player, const Position& source) const; std::vector huntSupplyExitCandidates(Player& player, const Position& source) const; - uint64_t huntTravelFutureFareReserve(HuntTravelBudgetPhase phase) const; + uint64_t huntTravelReturnFareReserve(HuntTravelBudgetPhase phase) const; + uint64_t huntTravelRecoveryFundsReserve(const Player& player, HuntTravelBudgetPhase phase) const; bool huntTravelFareAffordable(const Player& player, uint64_t fare, HuntTravelBudgetPhase phase) const; + PlayerBotHuntReturnCoverageContext huntReturnCoverageContext( + Player& player, const Position& coveredPosition) const; uint32_t navigationDecisionDelay(const Player& player) const; @@ -570,10 +573,12 @@ class PlayerBotController : public std::enable_shared_from_this huntRouteFailureCounts; HuntTravelBudgetPhase huntTravelBudgetPhase = HuntTravelBudgetPhase::None; uint64_t huntExitFareReserve = 0; - uint64_t huntSupplyFareReserve = 0; uint32_t huntRecoveryPotionReserve = 0; Position huntReturnDestination; uint32_t huntReturnRouteDangerCost = 0; + uint64_t huntReturnCoverageVariantId = 0; + bool huntExitRouteProtected = false; + PlayerBotHuntReturnCoverage huntReturnCoverage; std::optional huntPatrolValidationDestination; std::optional huntPatrolValidationOrigin; std::optional huntPatrolOutboundPlan; diff --git a/server/src/playerbothuntregions.h b/server/src/playerbothuntregions.h index 8244155..4c90c4c 100644 --- a/server/src/playerbothuntregions.h +++ b/server/src/playerbothuntregions.h @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -362,12 +363,112 @@ inline bool playerBotHuntTravelAffordable(uint64_t funds, uint64_t recoveryReser return totalFare <= funds && recoveryReserve <= funds - totalFare; } +enum class PlayerBotHuntTravelBudgetPhase : uint8_t { + None, + Outbound, + ReturnToDepot, + Supply, +}; + +inline uint64_t playerBotHuntTravelRecoveryReserve(PlayerBotHuntTravelBudgetPhase phase, + uint64_t recoveryReserve) +{ + return phase == PlayerBotHuntTravelBudgetPhase::Supply ? recoveryReserve : 0; +} + +inline uint64_t playerBotHuntTravelReturnFareReserve(PlayerBotHuntTravelBudgetPhase phase, + uint64_t returnFare) +{ + return phase == PlayerBotHuntTravelBudgetPhase::Outbound ? returnFare : 0; +} + inline bool playerBotHuntTravelPaymentAffordable(uint64_t funds, uint64_t recoveryReserve, - uint64_t fare, uint64_t futureFareReserve) + uint64_t fare, uint64_t returnFare, + PlayerBotHuntTravelBudgetPhase phase) { - return fare == 0 || playerBotHuntTravelAffordable(funds, recoveryReserve, fare, futureFareReserve); + if (phase == PlayerBotHuntTravelBudgetPhase::None || + phase == PlayerBotHuntTravelBudgetPhase::ReturnToDepot) { + return fare <= funds; + } + return playerBotHuntTravelAffordable(funds, + playerBotHuntTravelRecoveryReserve(phase, recoveryReserve), fare, + playerBotHuntTravelReturnFareReserve(phase, returnFare)); +} + +inline const char* playerBotHuntTravelBudgetPhaseName(PlayerBotHuntTravelBudgetPhase phase) +{ + switch (phase) { + case PlayerBotHuntTravelBudgetPhase::Outbound: return "outbound"; + case PlayerBotHuntTravelBudgetPhase::ReturnToDepot: return "return_to_depot"; + case PlayerBotHuntTravelBudgetPhase::Supply: return "supply"; + default: return "none"; + } +} + +inline const char* playerBotHuntTravelFareRejectionReason(PlayerBotHuntTravelBudgetPhase phase) +{ + switch (phase) { + case PlayerBotHuntTravelBudgetPhase::Outbound: return "fare_breaks_return_reserve"; + case PlayerBotHuntTravelBudgetPhase::Supply: return "fare_breaks_restock_reserve"; + default: return "fare_unaffordable"; + } } +struct PlayerBotHuntReturnCoverageContext { + uint64_t topologyGeneration = 0; + uint64_t npcGeneration = 0; + uint32_t level = 0; + Position coveredPosition; + Position depotDestination; + uint64_t fare = 0; + bool canUseRope = false; + bool canUseShovel = false; + bool premium = false; + + bool operator==(const PlayerBotHuntReturnCoverageContext& other) const + { + return topologyGeneration == other.topologyGeneration && npcGeneration == other.npcGeneration && + level == other.level && coveredPosition == other.coveredPosition && + depotDestination == other.depotDestination && fare == other.fare && + canUseRope == other.canUseRope && canUseShovel == other.canUseShovel && premium == other.premium; + } +}; + +// Region selection validates a safe route from one patrol position to a depot. +// Coverage can move to another position only across a complete reversible local +// walking plan; geometric region membership alone is not connectivity evidence. +class PlayerBotHuntReturnCoverage +{ + public: + void validate(uint64_t variantId, uint64_t atlasRevision, + const PlayerBotHuntReturnCoverageContext& context) + { + evidence = Evidence{variantId, atlasRevision, context, true}; + } + + bool covers(uint64_t variantId, uint64_t atlasRevision, + const PlayerBotHuntReturnCoverageContext& context) const + { + return evidence && evidence->routeValid && evidence->variantId == variantId && + evidence->atlasRevision == atlasRevision && evidence->context == context; + } + + void invalidate() + { + if (evidence) evidence->routeValid = false; + } + bool valid() const { return evidence && evidence->routeValid; } + + private: + struct Evidence { + uint64_t variantId = 0; + uint64_t atlasRevision = 0; + PlayerBotHuntReturnCoverageContext context; + bool routeValid = false; + }; + std::optional evidence; +}; + inline bool playerBotHuntNeedsSupplyRoute(double expectedPotions, uint32_t availablePotions, uint32_t routeReserve) { diff --git a/server/src/playerbothuntruntime.h b/server/src/playerbothuntruntime.h index 7ae28fc..0f22222 100644 --- a/server/src/playerbothuntruntime.h +++ b/server/src/playerbothuntruntime.h @@ -161,6 +161,17 @@ struct PlayerBotHuntPatrolOutcome { std::optional cooldown; }; +inline PlayerBotNavigationRuntimeOutcome playerBotHuntRejectedPatrolPreflight( + const PlayerBotNavigationPlanMetrics& plan, bool routeUnsafe) +{ + PlayerBotNavigationRuntimeOutcome outcome; + outcome.plan = plan; + outcome.plan.attempted = true; + outcome.routeUnavailable = true; + outcome.routeUnsafe = routeUnsafe; + return outcome; +} + class PlayerBotHuntRuntime { public: diff --git a/server/src/playerbotnavigationruntime.h b/server/src/playerbotnavigationruntime.h index 1543696..ef18f66 100644 --- a/server/src/playerbotnavigationruntime.h +++ b/server/src/playerbotnavigationruntime.h @@ -37,6 +37,26 @@ struct PlayerBotNavigationRoutePlan { std::deque steps; }; +// Only a complete, unexposed same-floor walk can extend known return coverage. +// Added danger needs a new return-risk estimate. Portals, NPC travel, item use, +// doors, and partial approaches may be one-way even within one hunt region. +inline bool playerBotNavigationIsReversibleLocalWalk( + const Position& source, const Position& destination, const PlayerBotNavigationRoutePlan& plan) +{ + if (plan.metrics.result != PlayerBotNavigationResult::Reached || source.z != destination.z || + plan.metrics.dangerCost != 0 || plan.metrics.maximumHealthLossPerSecond != 0 || + (source != destination && plan.steps.empty()) || plan.metrics.fare != 0 || + plan.metrics.steps != plan.steps.size()) return false; + Position cursor = source; + for (const PlayerBotNavigationStep& step : plan.steps) { + if (step.action != PlayerBotNavigationAction::Move || step.topologyPortal || + step.target != step.expectedPosition || step.target == cursor || + !Position::areInRange<1, 1, 0>(cursor, step.target)) return false; + cursor = step.expectedPosition; + } + return cursor == destination; +} + struct PlayerBotNavigationRuntimeTiming { std::chrono::steady_clock::time_point now; std::chrono::steady_clock::duration stepTimeout; diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index e7eaac8..bfc6b2b 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -474,11 +474,16 @@ void PlayerBotController::setCyclePhase(CyclePhase phase, const Position& positi if (turnRouter.cyclePhase() == phase) { return; } + if (phase == CyclePhase::ReturnToDepot) { + huntTravelBudgetPhase = HuntTravelBudgetPhase::ReturnToDepot; + } if (phase == CyclePhase::Hunt) { huntTravelBudgetPhase = HuntTravelBudgetPhase::None; huntExitFareReserve = 0; - huntSupplyFareReserve = 0; huntRecoveryPotionReserve = 0; + huntReturnCoverageVariantId = 0; + huntExitRouteProtected = false; + huntReturnCoverage.invalidate(); } const char* previous = cyclePhaseName(); if (turnRouter.cyclePhase() == CyclePhase::Hunt && phase != CyclePhase::Hunt) { @@ -499,9 +504,7 @@ void PlayerBotController::setCyclePhase(CyclePhase phase, const Position& positi void PlayerBotController::beginReturn(Player* player, const Position& position, const char* reason) { - if (huntTravelBudgetPhase != HuntTravelBudgetPhase::None) { - huntTravelBudgetPhase = HuntTravelBudgetPhase::Exit; - } + huntTravelBudgetPhase = HuntTravelBudgetPhase::ReturnToDepot; pendingHuntCompletionReason.clear(); const auto traversalTarget = huntCoordinator.traversalTarget(); const uint32_t previousTarget = traversalTarget ? traversalTarget->id : 0; @@ -1253,9 +1256,8 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP const bool executable = valid && fareAccepted && (candidate.approachPosition == currentPosition || (routePlan.metrics.result == PlayerBotNavigationResult::Reached && !routePlan.steps.empty())); const bool liquidationSource = sellLootPlan && candidate.depotId == sellLootPlan->sourceDepotId; - const bool protectedHuntExit = huntTravelBudgetPhase == HuntTravelBudgetPhase::Exit; const bool reached = executable && playerBotDepotRouteSafetyAccepted( - routeSafe, command.snapshot.validatingRiskFallback, liquidationSource, protectedHuntExit); + routeSafe, command.snapshot.validatingRiskFallback, liquidationSource, huntExitRouteProtected); telemetry.recordPathfinding(std::chrono::duration_cast(std::chrono::steady_clock::now() - startedAt), executable); observation.routeResult = reached ? PlayerBotDepotRouteResult::Reached : executable ? PlayerBotDepotRouteResult::Unsafe : PlayerBotDepotRouteResult::Unreachable; @@ -1524,8 +1526,9 @@ void PlayerBotController::processDeposit(Player* player, const Position& current PlayerBotDepotObservation observation; if (command.snapshot.hasSelectedDepot) { observation.atApproach = fixtureDepot.synthetic || Position::areInRange<1, 1, 0>(currentPosition, command.snapshot.selected.lockerPosition); - if (observation.atApproach && huntTravelBudgetPhase == HuntTravelBudgetPhase::Exit) { + if (observation.atApproach && huntTravelBudgetPhase == HuntTravelBudgetPhase::ReturnToDepot) { huntTravelBudgetPhase = HuntTravelBudgetPhase::Supply; + huntExitRouteProtected = false; } observation.lockerOpen = fixtureDepot.synthetic || player->getContainerByID(depotLockerContainerId) != nullptr; observation.chestOpen = fixtureDepot.synthetic || player->getContainerByID(depotChestContainerId) != nullptr; diff --git a/server/tests/playerbot_contracts.cpp b/server/tests/playerbot_contracts.cpp index 37d8317..12b059c 100644 --- a/server/tests/playerbot_contracts.cpp +++ b/server/tests/playerbot_contracts.cpp @@ -488,6 +488,25 @@ void modeledPatrolFailure() const auto skipped = fallback.observePatrolNavigation(failure, now, 3, 3); assert(skipped.command == PlayerBotHuntPatrolCommand::SkipWaypoint && !skipped.cooldown); assert(fallback.patrolTarget().destination == points[1]); + + // A rejected NPC-fare preflight is a route attempt. Three deterministic + // failures exhaust a modeled patrol instead of retrying until the deadline. + modeled.viability.reachableSpawns = 4; + PlayerBotHuntRuntime fareFailures(points); + fareFailures.selectPlanningRegion(modeled, player, now); + PlayerBotNavigationPlanMetrics rejectedFarePlan; + rejectedFarePlan.result = PlayerBotNavigationResult::Reached; + rejectedFarePlan.fare = 50; + const PlayerBotNavigationRuntimeOutcome rejectedFare = + playerBotHuntRejectedPatrolPreflight(rejectedFarePlan, false); + assert(rejectedFare.plan.attempted && rejectedFare.routeUnavailable && !rejectedFare.routeUnsafe); + assert(fareFailures.observePatrolNavigation(rejectedFare, now, 3, 3).command == + PlayerBotHuntPatrolCommand::Continue); + assert(fareFailures.observePatrolNavigation(rejectedFare, now, 3, 3).command == + PlayerBotHuntPatrolCommand::Continue); + const auto exhaustedFare = fareFailures.observePatrolNavigation(rejectedFare, now, 3, 3); + assert(exhaustedFare.command == PlayerBotHuntPatrolCommand::RegionExhausted); + assert(exhaustedFare.routeFailures == 3); } void navigationFailureAccounting() @@ -985,7 +1004,7 @@ void remoteHuntTravelGuards() assert(!playerBotNpcTravelApproachComplete(false, false, false)); assert(!playerBotNpcTravelApproachComplete(false, true, false)); - // Free routes need no cash reserve; paid routes still preserve recovery funds. + // Free routes need no cash reserve; planned paid routes account for all fares. assert(playerBotHuntTravelAffordable(0, 100, 0, 0, 0)); assert(playerBotHuntTravelAffordable(50, 100, 0, 0, 0)); assert(!playerBotHuntTravelAffordable(50, 100, 1, 0, 0)); @@ -993,19 +1012,107 @@ void remoteHuntTravelGuards() assert(!playerBotHuntTravelAffordable(499, 100, 100, 200, 100)); assert(!playerBotHuntTravelAffordable(500, 100, 250, 200)); assert(playerBotHuntTravelAffordable(550, 100, 250, 200)); - assert(playerBotHuntTravelPaymentAffordable(0, 100, 0, 200)); - assert(!playerBotHuntTravelPaymentAffordable(500, 100, 250, 200)); - assert(playerBotHuntTravelPaymentAffordable(550, 100, 250, 200)); - // Exit validation reserves only the later supplier fare; counting the exit - // fare again as future money would reject this exactly funded route. - assert(playerBotHuntTravelPaymentAffordable(400, 100, 200, 100)); - assert(!playerBotHuntTravelPaymentAffordable(400, 100, 200, 300)); + + using BudgetPhase = PlayerBotHuntTravelBudgetPhase; + // Returning to a depot spends only the actual fare. A future restock budget + // must not strand 198 gold behind a 50-gold Svargrond fare. + assert(playerBotHuntTravelPaymentAffordable(198, 820, 50, 500, BudgetPhase::ReturnToDepot)); + assert(!playerBotHuntTravelPaymentAffordable(49, 820, 50, 0, BudgetPhase::ReturnToDepot)); + // Outbound travel preserves the selected return fare, including when the + // current outbound leg itself is free. Restock affordability is separate. + assert(playerBotHuntTravelPaymentAffordable(100, 820, 50, 50, BudgetPhase::Outbound)); + assert(!playerBotHuntTravelPaymentAffordable(99, 820, 50, 50, BudgetPhase::Outbound)); + assert(playerBotHuntTravelPaymentAffordable(50, 820, 0, 50, BudgetPhase::Outbound)); + assert(!playerBotHuntTravelPaymentAffordable(49, 820, 0, 50, BudgetPhase::Outbound)); + // A return revalidation can raise the reserve after the outbound plan was saved. + assert(playerBotHuntTravelPaymentAffordable(150, 820, 50, 50, BudgetPhase::Outbound)); + assert(!playerBotHuntTravelPaymentAffordable(150, 820, 50, 110, BudgetPhase::Outbound)); + assert(playerBotHuntTravelPaymentAffordable(50, 820, 50, 0, BudgetPhase::None)); + assert(std::string(playerBotHuntTravelBudgetPhaseName(BudgetPhase::ReturnToDepot)) == "return_to_depot"); + assert(std::string(playerBotHuntTravelFareRejectionReason(BudgetPhase::Outbound)) == + "fare_breaks_return_reserve"); + assert(std::string(playerBotHuntTravelFareRejectionReason(BudgetPhase::ReturnToDepot)) == + "fare_unaffordable"); const uint64_t recoveryBeforeRestock = playerBotRecoverySpendingReserve(2, 10, 45, 100); const uint64_t recoveryAfterRestock = playerBotRecoverySpendingReserve(10, 10, 45, 100); assert(recoveryBeforeRestock == 460 && recoveryAfterRestock == 100); - assert(!playerBotHuntTravelPaymentAffordable(200, recoveryBeforeRestock, 50, 0)); - assert(playerBotHuntTravelPaymentAffordable(200, recoveryAfterRestock, 50, 0)); + assert(!playerBotHuntTravelPaymentAffordable( + 200, recoveryBeforeRestock, 50, 0, BudgetPhase::Supply)); + assert(playerBotHuntTravelPaymentAffordable( + 200, recoveryAfterRestock, 50, 0, BudgetPhase::Supply)); + + const Position coveredPosition(32090, 31263, 7); + const Position nextPosition(32091, 31263, 7); + const Position depotPosition(32080, 31250, 7); + PlayerBotHuntReturnCoverage coverage; + PlayerBotHuntReturnCoverageContext coverageContext{ + 11, 22, 17, coveredPosition, depotPosition, 50, true, true, false}; + coverage.validate(42, 7, coverageContext); + assert(coverage.covers(42, 7, coverageContext)); + assert(!coverage.covers(43, 7, coverageContext)); + assert(!coverage.covers(42, 8, coverageContext)); + auto uncoveredPosition = coverageContext; + uncoveredPosition.coveredPosition = nextPosition; + assert(!coverage.covers(42, 7, uncoveredPosition)); + auto changedCoverage = coverageContext; + ++changedCoverage.topologyGeneration; + assert(!coverage.covers(42, 7, changedCoverage)); + coverage.validate(42, 7, changedCoverage); + assert(coverage.covers(42, 7, changedCoverage)); + ++changedCoverage.npcGeneration; + assert(!coverage.covers(42, 7, changedCoverage)); + coverage.invalidate(); // A route failure always forces one fresh return validation. + assert(!coverage.valid()); + + PlayerBotNavigationRoutePlan localWalk; + localWalk.metrics.result = PlayerBotNavigationResult::Reached; + localWalk.metrics.steps = 1; + PlayerBotNavigationStep localStep; + localStep.action = PlayerBotNavigationAction::Move; + localStep.direction = DIRECTION_EAST; + localStep.target = localStep.expectedPosition = nextPosition; + localWalk.steps.push_back(localStep); + assert(playerBotNavigationIsReversibleLocalWalk(coveredPosition, nextPosition, localWalk)); + coverage.validate(42, 7, coverageContext); + if (playerBotNavigationIsReversibleLocalWalk(coveredPosition, nextPosition, localWalk)) { + coverage.validate(42, 7, uncoveredPosition); + } + assert(coverage.covers(42, 7, uncoveredPosition)); + + PlayerBotNavigationRoutePlan exposedWalk = localWalk; + exposedWalk.metrics.dangerCost = 1; + assert(!playerBotNavigationIsReversibleLocalWalk(coveredPosition, nextPosition, exposedWalk)); + exposedWalk.metrics.dangerCost = 0; + exposedWalk.metrics.maximumHealthLossPerSecond = 0.001; + assert(!playerBotNavigationIsReversibleLocalWalk(coveredPosition, nextPosition, exposedWalk)); + PlayerBotNavigationRoutePlan portalWalk = localWalk; + portalWalk.steps.front().topologyPortal = true; + coverage.validate(42, 7, coverageContext); + assert(!playerBotNavigationIsReversibleLocalWalk(coveredPosition, nextPosition, portalWalk)); + assert(!coverage.covers(42, 7, uncoveredPosition)); + PlayerBotNavigationRoutePlan npcTravel = localWalk; + npcTravel.steps.front().action = PlayerBotNavigationAction::NpcTravel; + assert(!playerBotNavigationIsReversibleLocalWalk(coveredPosition, nextPosition, npcTravel)); + PlayerBotNavigationRoutePlan floorChange = localWalk; + floorChange.steps.front().expectedPosition.z = 8; + assert(!playerBotNavigationIsReversibleLocalWalk(coveredPosition, Position(32091, 31263, 8), floorChange)); + PlayerBotNavigationRoutePlan partialExactMetrics = localWalk; + assert(!playerBotNavigationIsReversibleLocalWalk( + coveredPosition, Position(32092, 31263, 7), partialExactMetrics)); + PlayerBotNavigationRoutePlan discontinuousWalk = localWalk; + discontinuousWalk.metrics.steps = 2; + PlayerBotNavigationStep jumpedStep = localStep; + jumpedStep.target = jumpedStep.expectedPosition = Position(32093, 31263, 7); + discontinuousWalk.steps.push_back(jumpedStep); + assert(!playerBotNavigationIsReversibleLocalWalk( + coveredPosition, Position(32093, 31263, 7), discontinuousWalk)); + PlayerBotNavigationRoutePlan incrementalNpcApproach = localWalk; + incrementalNpcApproach.metrics.steps = 2; // The uninstalled next step is a free NPC transition. + assert(!playerBotNavigationIsReversibleLocalWalk( + coveredPosition, nextPosition, incrementalNpcApproach)); assert(playerBotDepotRouteSafetyAccepted(true, false, false, true)); + // Return-payment policy does not imply selected-hunt route protection: + // startup/service returns retain the established risky-depot fallback. assert(playerBotDepotRouteSafetyAccepted(false, true, false, false)); assert(!playerBotDepotRouteSafetyAccepted(false, true, false, true)); assert(!playerBotDepotRouteSafetyAccepted(false, true, true, false));