diff --git a/docs/playerbots.md b/docs/playerbots.md index bab8704..a80577b 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -16,7 +16,7 @@ This design is intended to scale without one thread, graphical client, renderer, | --- | --- | --- | | Goal selection | Ordinary | Chooses among survival, service, rewards, spell learning, equipment, magic training, and hunting only at safe boundaries; it is not a general-purpose planner. | | Hunting | Ordinary; map-derived selection has focused real-map coverage | Scores loaded hostile spawns against equipment, skills, health, supplies, route safety, expected XP, and cash pressure. Adaptive challenge and performance estimates reset with the controller; no long progression soak is established. | -| Combat and healing | Ordinary | Approaches reachable same-floor targets, handles attackers, uses melee, potions, and supported Knight spells. Transit combat is restricted to confirmed route blockers; group tactics and other vocations are not developed. | +| Combat and healing | Ordinary | Approaches reachable same-floor targets, handles attackers, uses melee, potions, and supported Knight spells. After an attempted step or movement-related route plan fails without positional progress, it can stop and fight one adjacent attacker without chase when the existing combat estimates say the fight is survivable. It prefers an attacker on the failed step, otherwise the easiest safe attacker, and retains that target until movement can resume, the target dies or moves out of reach, safety changes, or the workflow is interrupted. Group tactics and other vocations are not developed. | | Loot | Ordinary; edge cases have focused evidence | Opens owned corpse containers through normal item use before inspecting contents, ranks known saleable loot by value and weight, and retries inaccessible corpses within bounds. Skinning and other secondary corpse actions are unsupported. | | Navigation | Ordinary; transition and recovery scenarios have focused evidence | Routes across the loaded map using a shared coarse topology, then plans and executes local paths and supported transitions. Supports tools, doors, floor changes, teleports, and registered NPC travel. Detailed search and recovery have budgets; unsupported transitions and changing obstacles can prevent a journey. Static topology changes may require a supported reload or restart. | | NPC service | Ordinary | Discovers loaded shops, bankers, spell trainers, and registered travel offers; buys supplies, sells known loot, banks, and verifies transactions. Custom dialogue or opaque travel conditions are excluded unless explicitly modeled. | diff --git a/scripts/playerbot-gameplay/assertions-navigation.ps1 b/scripts/playerbot-gameplay/assertions-navigation.ps1 index f40b27c..389df84 100644 --- a/scripts/playerbot-gameplay/assertions-navigation.ps1 +++ b/scripts/playerbot-gameplay/assertions-navigation.ps1 @@ -326,78 +326,24 @@ function Assert-InaccessibleCorpseEvents { param([string]$Logs) $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) - $failedDetour = @($events | Where-Object { - $_.event -eq "navigation_progress" -and $_.result -eq "failed" -and - $_.reason -eq "route_unavailable" - }) $terminalResult = @($events | Where-Object { $_.event -eq "action_result" -and $_.action -eq "loot" -and $_.result -eq "failed" -and $_.reason -eq "corpse_inaccessible" }) - $combatPreemption = @($events | Where-Object { - $_.event -eq "target_changed" -and - $_.reason -in @("defensive_path_blocker", "defensive_attacker") - }) - $blockerEngaged = @($events | Where-Object { - $_.event -eq "action_result" -and $_.action -eq "defensive_combat" -and $_.result -eq "started" - }) - $blockerReleased = @($events | Where-Object { - $_.event -eq "action_result" -and $_.action -eq "defensive_combat" -and $_.result -ne "started" - }) - $targetCleared = @($events | Where-Object { - $_.event -eq "target_changed" -and $null -eq $_.target_id -and $_.reason -eq "transit_combat_budget" - }) - $suspended = @($events | Where-Object { - $_.event -eq "navigation_progress" -and $_.result -eq "suspended" -and $_.reason -eq "corpse_route_unchanged" - }) - $resumed = @($events | Where-Object { - $_.event -eq "navigation_progress" -and $_.result -eq "resumed" -and $_.reason -eq "corpse_retry" + $passageCombat = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "defensive_combat" }) $controllerTerminal = @($events | Where-Object { $_.event -in @("terminal", "death") -or ($_.event -eq "lifecycle" -and $_.status -in @("dead", "removed", "recovery_abandoned")) }) - if ($failedDetour.Count -lt 1 -or $combatPreemption.Count -ne 1 -or $blockerEngaged.Count -ne 1 -or - $blockerReleased.Count -ne 1 -or $targetCleared.Count -ne 1 -or - $suspended.Count -ne 1 -or $resumed.Count -ne 1 -or - $terminalResult.Count -ne 1 -or $controllerTerminal.Count -ne 0) { - throw "Inaccessible corpse work was not bounded. failed_detour=$($failedDetour.Count), preemption=$($combatPreemption.Count)/$($blockerEngaged.Count)/$($blockerReleased.Count), clear=$($targetCleared.Count), retry=$($suspended.Count)/$($resumed.Count), results=$($terminalResult.Count), terminal=$($controllerTerminal.Count)." + if ($terminalResult.Count -ne 1 -or $passageCombat.Count -ne 0 -or $controllerTerminal.Count -ne 0) { + throw "Inaccessible corpse work was not bounded. loot=$($terminalResult.Count), passage_combat=$($passageCombat.Count), terminal=$($controllerTerminal.Count)." } - $target = $combatPreemption[0] - $engaged = $blockerEngaged[0] - $released = $blockerReleased[0] $loot = $terminalResult[0] - if ($target.target_name -ne "Playerbot Corpse Blocker" -or $target.reason -ne "defensive_path_blocker" -or - $target.route_critical -ne $true -or $target.target_id -le 0 -or - $engaged.target_id -ne $target.target_id -or $engaged.route_critical -ne $true -or $engaged.chase -ne $false -or - $released.target_id -ne $target.target_id -or $released.result -ne "skipped" -or $released.reason -ne "transit_combat_budget" -or - $targetCleared[0].previous_target_id -ne $target.target_id -or - $loot.target_id -le 0 -or $loot.target_id -eq $target.target_id -or - $loot.navigation_failures -lt 1 -or $loot.navigation_failures -gt 6 -or - $loot.navigation_suspensions -ne 1 -or $loot.elapsed_ms -le 0 -or $loot.elapsed_ms -gt 70000 -or - -not $engaged.ts -or -not $released.ts) { - throw "Inaccessible corpse work was not bounded: invalid blocker, release, or loot budget." - } - # Five-second wall-clock budget, checked on the next running turn. Allow - # one navigationInterval (1000 ms) for scheduler dispatch, not the old combat timeout. - $combatMs = ([DateTimeOffset]$released.ts - [DateTimeOffset]$engaged.ts).TotalMilliseconds - if ($combatMs -lt 5000 -or $combatMs -gt 6000) { - throw "Inaccessible corpse work was not bounded: blocker combat lasted $combatMs ms." - } - $order = @($failedDetour[0], $target, $engaged, $targetCleared[0], $released, - $suspended[0], $resumed[0], $failedDetour[-1], $loot) - $previous = -1 - foreach ($event in $order) { - $index = [Array]::IndexOf($events, $event) - if ($index -le $previous) { - throw "Inaccessible corpse work was not bounded: detour, combat release, and resumed loot retry were out of order." - } - $previous = $index - } - if ($suspended[0].navigation_failures -lt 1 -or - $resumed[0].navigation_failures -ne $suspended[0].navigation_failures -or - $loot.navigation_failures -le $resumed[0].navigation_failures) { - throw "Inaccessible corpse work was not bounded: resumed loot did not retain and advance its failure budget." + if ($loot.target_id -le 0 -or $loot.elapsed_ms -lt 18000 -or $loot.elapsed_ms -gt 22000 -or + $loot.navigation_failures -gt 6 -or $loot.navigation_suspensions -gt 1) { + throw "Inaccessible corpse work was not bounded: invalid independent 20-second loot deadline." } } diff --git a/scripts/test-playerbot-navigation-assertions.ps1 b/scripts/test-playerbot-navigation-assertions.ps1 index 350b402..fb381b1 100644 --- a/scripts/test-playerbot-navigation-assertions.ps1 +++ b/scripts/test-playerbot-navigation-assertions.ps1 @@ -94,55 +94,31 @@ if ($DangerRetreatLogPath) { function New-InaccessibleCorpseFixture { return @( - @{ event = "navigation_progress"; result = "failed"; reason = "route_unavailable" } - @{ event = "target_changed"; target_id = 42; target_name = "Playerbot Corpse Blocker"; - reason = "defensive_path_blocker"; route_critical = $true } - @{ event = "action_result"; action = "defensive_combat"; result = "started"; target_id = 42; - chase = $false; route_critical = $true; ts = "2026-09-09T16:36:29.485Z" } - @{ event = "target_changed"; previous_target_id = 42; target_id = $null; reason = "transit_combat_budget" } - @{ event = "action_result"; action = "defensive_combat"; result = "skipped"; target_id = 42; - reason = "transit_combat_budget"; ts = "2026-09-09T16:36:34.486Z" } - @{ event = "navigation_progress"; result = "suspended"; reason = "corpse_route_unchanged"; navigation_failures = 3 } - @{ event = "navigation_progress"; result = "resumed"; reason = "corpse_retry"; navigation_failures = 3 } - @{ event = "navigation_progress"; result = "failed"; reason = "route_unavailable" } @{ event = "action_result"; action = "loot"; result = "failed"; reason = "corpse_inaccessible"; - target_id = 41; navigation_failures = 6; navigation_suspensions = 1; elapsed_ms = 12150 } + target_id = 41; navigation_failures = 6; navigation_suspensions = 1; elapsed_ms = 20056 } ) } Assert-InaccessibleCorpseEvents -Logs (ConvertTo-FixtureLogs (New-InaccessibleCorpseFixture)) -foreach ($case in @("old_timeout", "early_release", "late_release", "missing_timestamp", "optional_attacker", - "unconfirmed_blocker", "chase", "missing_chase", "wrong_release_target", "missing_target_clear", - "reacquired", "no_suspension", "no_resume", "no_retry", "detour_after_combat", "retry_before_resume", - "reset_failure_budget", "no_failure_progress", "too_many_failures", "loot_overrun", "duplicate_loot", "terminal")) { - $events = New-InaccessibleCorpseFixture +foreach ($case in @("early_deadline", "late_deadline", "combat_timeout", "too_many_failures", "duplicate_loot", "terminal")) { + $events = @(New-InaccessibleCorpseFixture) switch ($case) { - "old_timeout" { $events[4].result = "failed"; $events[4].reason = "combat_timeout" } - "early_release" { $events[4].ts = "2026-09-09T16:36:34.484Z" } - "late_release" { $events[4].ts = "2026-09-09T16:36:35.486Z" } - "missing_timestamp" { $events[4].Remove("ts") } - "optional_attacker" { $events[1].reason = "defensive_attacker" } - "unconfirmed_blocker" { $events[1].route_critical = $false } - "chase" { $events[2].chase = $true } - "missing_chase" { $events[2].Remove("chase") } - "wrong_release_target" { $events[4].target_id = 43 } - "missing_target_clear" { $events = @($events[0..2]) + @($events[4..8]) } - "reacquired" { $events = @($events[0..6]) + @($events[1], $events[2]) + @($events[7..8]) } - "no_suspension" { $events = @($events[0..4]) + @($events[6..8]) } - "no_resume" { $events = @($events[0..5]) + @($events[7..8]) } - "no_retry" { $events = @($events[0..6]) + @($events[8]) } - "detour_after_combat" { $events = @($events[1..4]) + @($events[0]) + @($events[5..8]) } - "retry_before_resume" { $events = @($events[0..5]) + @($events[7], $events[6], $events[8]) } - "reset_failure_budget" { $events[6].navigation_failures = 0 } - "no_failure_progress" { $events[8].navigation_failures = 3 } - "too_many_failures" { $events[8].navigation_failures = 7 } - "loot_overrun" { $events[8].elapsed_ms = 70001 } - "duplicate_loot" { $events += $events[8] } + "early_deadline" { $events[0].elapsed_ms = 17999 } + "late_deadline" { $events[0].elapsed_ms = 22001 } + "combat_timeout" { + $events = @( + @{ event = "action_result"; action = "defensive_combat"; result = "failed"; + reason = "combat_timeout"; target_id = 42 } + ) + $events + } + "too_many_failures" { $events[0].navigation_failures = 7 } + "duplicate_loot" { $events += $events[0] } "terminal" { $events += @{ event = "terminal"; reason = "controlled_player_dead" } } } Assert-Rejected "Inaccessible corpse $case" { Assert-InaccessibleCorpseEvents -Logs (ConvertTo-FixtureLogs $events) } "Inaccessible corpse work was not bounded" } + if ($InaccessibleCorpseLogPath) { Assert-InaccessibleCorpseEvents -Logs (Get-Content -Raw -LiteralPath $InaccessibleCorpseLogPath) } diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index 480ac76..e26bb9c 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -16,6 +16,8 @@ #include "playerbottopology.h" #include "spells.h" +#include + // Playerbot survival, combat targeting, and hunt orchestration. using namespace playerbot; @@ -74,6 +76,30 @@ namespace { weapon ? player.getWeaponSkill(weapon) : player.getSkillLevel(SKILL_FIST), player.getAttackFactor()}; } + std::optional> manageablePassageFight( + Player& player, const std::vector& attackers, const Creature& target) + { + const PlayerBotCombatProfile combat = huntCombatProfile(player); + double incomingDamagePerSecond = 0; + double targetFightSeconds = 0; + for (Creature* attacker : attackers) { + const PlayerBotFightEstimate estimate = PlayerBotHuntRegionAdapter::fightEstimate( + combat, attacker->getName(), attacker->getHealth()); + if (estimate.incomingDamagePerSecond <= 0 || estimate.fightSeconds <= 0) return std::nullopt; + incomingDamagePerSecond += estimate.incomingDamagePerSecond; + if (attacker->getID() == target.getID()) targetFightSeconds = estimate.fightSeconds; + } + if (targetFightSeconds <= 0) return std::nullopt; + const double predictedDamage = incomingDamagePerSecond * targetFightSeconds; + const PlayerBotRecoveryPrediction recovery = playerBotPredictRecovery( + huntPlanningFacts(player, combat), targetFightSeconds); + if (!playerBotPassageFightManageable(player.getHealth(), recovery.totalMinimumHealing, + incomingDamagePerSecond, targetFightSeconds)) { + return std::nullopt; + } + return std::pair{predictedDamage, targetFightSeconds}; + } + std::shared_ptr> huntTransportCatalog() { // Capture providers once per planning session. NPC movement must not @@ -388,64 +414,45 @@ bool PlayerBotController::attackDefensiveThreat(Player* player, const Position& if (huntCoordinator.traversalTarget()) return false; SpectatorVec spectators; g_game.map.getSpectators(spectators, currentPosition); - const auto now = std::chrono::steady_clock::now(); - const size_t adjacentAttackers = std::count_if(spectators.begin(), spectators.end(), [player, ¤tPosition](Creature* creature) { - return creature->getMonster() && !creature->isRemoved() && !creature->isDead() && - creature->getAttackedCreature() == player && player->canSee(creature->getPosition()) && - Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition()); - }); - if (!huntCoordinator.transitBreakoutActive() && (huntCoordinator.inTransit() || adjacentAttackers < 4)) { - for (Creature* creature : spectators) { - if (!creature->getMonster() || creature->isRemoved() || creature->isDead() || - !player->canSee(creature->getPosition()) || - !Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition())) continue; - if (!navigationRuntime.avoidPendingRouteBlocker(creature->getID(), creature->getPosition(), now, navigationBlockSuppression)) continue; - emit("navigation_progress", currentPosition, - "\"result\":\"replanning\",\"reason\":\"hostile_detour\",\"blocker_id\":" + - std::to_string(creature->getID()) + ",\"blocker_position\":{\"x\":" + - std::to_string(creature->getPosition().x) + ",\"y\":" + - std::to_string(creature->getPosition().y) + ",\"z\":" + - std::to_string(creature->getPosition().z) + "}"); - return true; - } - } - auto isRouteCritical = [this, now](const Creature* creature) { - return navigationRuntime.isRouteCritical(creature->getID(), creature->getPosition(), now); - }; - const bool blockerOnly = huntCoordinator.inTransit(); - std::vector candidates; + const bool movementFallback = huntCoordinator.transitMovementFallbackRequired(); + const std::optional intendedStep = huntCoordinator.transitIntendedStep(); + std::vector adjacentAttackers; for (Creature* creature : spectators) { - const bool adjacentAttacker = creature->getAttackedCreature() == player && - Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition()); - const bool routeCritical = isRouteCritical(creature) || - (huntCoordinator.transitBreakoutActive() && adjacentAttacker); if (!creature->getMonster() || creature->isRemoved() || creature->isDead() || - (!routeCritical && (blockerOnly || creature->getAttackedCreature() != player)) || - !player->canSee(creature->getPosition()) || - !Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition())) { - continue; - } + creature->getAttackedCreature() != player || !player->canSee(creature->getPosition()) || + !Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition())) continue; + adjacentAttackers.push_back(creature); + } - PlayerBotDefensiveTarget candidate; - candidate.id = creature->getID(); - candidate.position = creature->getPosition(); - candidate.name = creature->getName(); - candidate.routeCritical = routeCritical; - candidates.push_back(std::move(candidate)); + std::vector candidates; + if (movementFallback) { + for (Creature* creature : adjacentAttackers) { + const auto risk = manageablePassageFight(*player, adjacentAttackers, *creature); + if (!risk) continue; + PlayerBotDefensiveTarget candidate; + candidate.id = creature->getID(); + candidate.position = creature->getPosition(); + candidate.name = creature->getName(); + candidate.routeCritical = true; + candidate.intendedStep = intendedStep && creature->getPosition() == *intendedStep; + candidate.predictedFightDamage = risk->first; + candidate.predictedFightSeconds = risk->second; + candidates.push_back(std::move(candidate)); + } + } else if (!huntCoordinator.inTransit()) { + for (Creature* creature : adjacentAttackers) { + candidates.push_back({creature->getID(), creature->getPosition(), creature->getName()}); + } } const auto command = huntCoordinator.selectDefensiveAttack(std::move(candidates), currentPosition); - if (!command) { - return false; - } + if (!command) return false; Creature* target = g_game.getCreatureByID(command->target.id); - if (!target) { - return false; - } + if (!target) return false; telemetry.recordActionAttempt(); g_game.playerSetFightModes(playerId, FIGHTMODE_ATTACK, false, false); g_game.playerSetAttackedCreature(playerId, target->getID()); - const PlayerBotCombatDecision started = huntCoordinator.confirmCombatAttack(*command, player->getAttackedCreature() == target, - std::chrono::steady_clock::now()); + const PlayerBotCombatDecision started = huntCoordinator.confirmCombatAttack( + *command, player->getAttackedCreature() == target, std::chrono::steady_clock::now()); if (!started.result || std::strcmp(started.result, "started") != 0) { logActionFailure("defensive_combat", "target_rejected", currentPosition); return false; @@ -459,6 +466,19 @@ bool PlayerBotController::attackDefensiveThreat(Player* player, const Position& << static_cast(started.target.position.z) << "},\"reason\":" << jsonString(started.routeCritical ? "defensive_path_blocker" : "defensive_attacker") << ",\"route_critical\":" << (started.routeCritical ? "true" : "false"); + if (started.routeCritical) { + targetFields << ",\"fallback_cause\":\"movement_stalled\",\"selected_intended_step\":" + << (started.intendedStep ? "true" : "false") + << ",\"predicted_fight_damage\":" << started.predictedFightDamage + << ",\"predicted_fight_seconds\":" << started.predictedFightSeconds + << ",\"intended_step\":"; + if (intendedStep) { + targetFields << "{\"x\":" << intendedStep->x << ",\"y\":" << intendedStep->y + << ",\"z\":" << static_cast(intendedStep->z) << '}'; + } else { + targetFields << "null"; + } + } emit("target_changed", currentPosition, targetFields.str()); emit("action_result", currentPosition, "\"action\":\"defensive_combat\",\"result\":\"started\",\"target_id\":" + @@ -469,34 +489,88 @@ bool PlayerBotController::attackDefensiveThreat(Player* player, const Position& void PlayerBotController::finishDefensiveCombat(Player* player, const Position& currentPosition, const char* result, const char* reason) { - const uint32_t previousTarget = huntCoordinator.defensiveTarget() ? huntCoordinator.defensiveTarget()->id : 0; + const auto previous = huntCoordinator.defensiveTarget(); + const uint32_t previousTarget = previous ? previous->id : 0; + const std::optional intendedStep = previous && previous->routeCritical ? + huntCoordinator.transitIntendedStep() : std::nullopt; huntCoordinator.clearDefensiveTarget(); + if (previous && previous->routeCritical) huntCoordinator.clearTransitMovementFallback(); if (player->getAttackedCreature() && player->getAttackedCreature()->getID() == previousTarget) { g_game.playerSetAttackedCreature(playerId, 0); } resetNavigation(); emit("target_changed", currentPosition, "\"previous_target_id\":" + std::to_string(previousTarget) + ",\"target_id\":null,\"reason\":" + jsonString(reason)); - emit("action_result", currentPosition, "\"action\":\"defensive_combat\",\"result\":" + - jsonString(result) + ",\"target_id\":" + std::to_string(previousTarget) + - ",\"reason\":" + jsonString(reason)); + std::ostringstream resultFields; + resultFields << "\"action\":\"defensive_combat\",\"result\":" << jsonString(result) + << ",\"target_id\":" << previousTarget << ",\"reason\":" << jsonString(reason); + if (previous && previous->routeCritical) { + resultFields << ",\"fallback_cause\":\"movement_stalled\",\"intended_step\":"; + if (intendedStep) { + resultFields << "{\"x\":" << intendedStep->x << ",\"y\":" << intendedStep->y + << ",\"z\":" << static_cast(intendedStep->z) << '}'; + } else { + resultFields << "null"; + } + } + emit("action_result", currentPosition, resultFields.str()); } void PlayerBotController::processDefensiveCombat(Player* player, const Position& currentPosition) { const auto defensive = huntCoordinator.defensiveTarget(); Creature* target = defensive ? g_game.getCreatureByID(defensive->id) : nullptr; - if (huntCoordinator.inTransit() && target && target->getPosition() != defensive->position) { - finishDefensiveCombat(player, currentPosition, "skipped", "transit_blocker_moved"); - schedule(navigationInterval); - return; - } PlayerBotCombatTargetSnapshot observed; - if (target) observed = {true, target->isRemoved(), target->isDead(), player->canSee(target->getPosition()), player->canSeeCreature(target), - Position::areInRange<1, 1, 0>(currentPosition, target->getPosition()), target->getAttackedCreature() == player, - player->getAttackedCreature() == target, {target->getID(), target->getPosition(), target->getName()}}; + if (target) { + observed.present = true; + observed.removed = target->isRemoved(); + observed.dead = target->isDead(); + if (!observed.removed && !observed.dead) { + observed.visible = player->canSee(target->getPosition()); + observed.visibleCreature = player->canSeeCreature(target); + observed.adjacent = Position::areInRange<1, 1, 0>(currentPosition, target->getPosition()); + observed.attacksPlayer = target->getAttackedCreature() == player; + observed.attackedByPlayer = player->getAttackedCreature() == target; + observed.target = {target->getID(), target->getPosition(), target->getName()}; + } + } + const bool targetLifetimeComplete = defensive && + playerBotDefensiveLifetimeCompletion(*defensive, observed).has_value(); + if (defensive && defensive->routeCritical && target && !targetLifetimeComplete) { + const std::optional intendedStep = huntCoordinator.transitIntendedStep(); + if (intendedStep) { + Tile* intendedTile = g_game.map.getTile(*intendedStep); + if (intendedTile && intendedTile->queryAdd(0, *player, 1, 0) == RETURNVALUE_NOERROR) { + finishDefensiveCombat(player, currentPosition, "skipped", "transit_passage_open"); + schedule(navigationInterval); + return; + } + } + SpectatorVec spectators; + g_game.map.getSpectators(spectators, currentPosition); + std::vector adjacentAttackers; + for (Creature* creature : spectators) { + if (!creature->getMonster() || creature->isRemoved() || creature->isDead() || + !player->canSee(creature->getPosition()) || + !Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition())) continue; + if (creature->getAttackedCreature() == player || creature == target) { + adjacentAttackers.push_back(creature); + } + } + const bool targetAdjacent = Position::areInRange<1, 1, 0>(currentPosition, target->getPosition()); + const bool safe = targetAdjacent && manageablePassageFight(*player, adjacentAttackers, *target).has_value(); + if (!huntCoordinator.retainTransitDefense(defensive->id, currentPosition, target->getPosition(), safe)) { + const char* reason = !targetAdjacent ? "transit_blocker_moved" : + !safe ? "transit_passage_unsafe" : "transit_passage_open"; + finishDefensiveCombat(player, currentPosition, "skipped", reason); + schedule(navigationInterval); + return; + } + } const PlayerBotCombatDecision decision = huntCoordinator.advanceCombat({currentPosition, std::chrono::steady_clock::now(), {}, observed}); - if (decision.command == PlayerBotCombatCommand::CompleteDefensiveCombat) finishDefensiveCombat(player, currentPosition, decision.result, decision.reason); + if (decision.command == PlayerBotCombatCommand::CompleteDefensiveCombat) { + finishDefensiveCombat(player, currentPosition, decision.result, decision.reason); + } schedule(navigationInterval); } @@ -1307,14 +1381,17 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre return; } if (huntCoordinator.hasDefensiveCombat()) { - if (turnRouter.scenarioStage() == ScenarioStage::LootCorpse && huntCoordinator.lootNavigationSuspended() && - huntCoordinator.lootTimedOut(std::chrono::steady_clock::now())) { + if (PlayerBotTransitCombat::lootDeadlineRequiresRelease( + turnRouter.scenarioStage() == ScenarioStage::LootCorpse, + huntCoordinator.lootTimedOut(std::chrono::steady_clock::now()))) { finishLootFailure(player, currentPosition, "corpse_inaccessible"); + schedule(navigationInterval); + return; } processDefensiveCombat(player, currentPosition); - if (!huntCoordinator.transitBreakoutActive()) return; + return; } - if (attackDefensiveThreat(player, currentPosition) && !huntCoordinator.transitBreakoutActive()) { + if (attackDefensiveThreat(player, currentPosition)) { schedule(navigationInterval); return; } @@ -1515,6 +1592,7 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre if (!routeSafe) { navigation.plan = preflight.metrics; navigation.routeUnavailable = true; + if (!routeReached) huntCoordinator.observeTransitMovementFailure(currentPosition); navigation.routeUnsafe = routeAffordable; if (routeReached && !routeAffordable) { emit("navigation_progress", currentPosition, diff --git a/server/src/playerbotcombatruntime.cpp b/server/src/playerbotcombatruntime.cpp index 5035df2..774544b 100644 --- a/server/src/playerbotcombatruntime.cpp +++ b/server/src/playerbotcombatruntime.cpp @@ -57,8 +57,13 @@ std::optional PlayerBotCombatRuntime::selectDefensiveAt if (!selected) { return std::nullopt; } - return PlayerBotCombatDecision{PlayerBotCombatCommand::AttackDefensive, {selected->id, selected->position, selected->name}, {}, {}, - selected->routeCritical}; + PlayerBotCombatDecision decision{PlayerBotCombatCommand::AttackDefensive, + {selected->id, selected->position, selected->name}, {}, {}, + selected->routeCritical}; + decision.intendedStep = selected->intendedStep; + decision.predictedFightDamage = selected->predictedFightDamage; + decision.predictedFightSeconds = selected->predictedFightSeconds; + return decision; } PlayerBotCombatDecision PlayerBotCombatRuntime::confirmAttack(const PlayerBotCombatDecision& command, bool accepted, @@ -77,7 +82,15 @@ PlayerBotCombatDecision PlayerBotCombatRuntime::confirmAttack(const PlayerBotCom return result; } if (command.command == PlayerBotCombatCommand::AttackDefensive) { - session->value.beginDefensiveCombat({command.target.id, command.target.position, command.target.name, command.routeCritical}, now); + PlayerBotDefensiveTarget target; + target.id = command.target.id; + target.position = command.target.position; + target.name = command.target.name; + target.routeCritical = command.routeCritical; + target.intendedStep = command.intendedStep; + target.predictedFightDamage = command.predictedFightDamage; + target.predictedFightSeconds = command.predictedFightSeconds; + session->value.beginDefensiveCombat(std::move(target), now); result.result = "started"; return result; } @@ -89,9 +102,8 @@ PlayerBotCombatDecision PlayerBotCombatRuntime::advance(const PlayerBotCombatSna { if (const auto defensive = session->value.defensiveTarget()) { const PlayerBotCombatTargetSnapshot& target = snapshot.defensive; - if (!target.present || target.removed || target.dead) { - return {PlayerBotCombatCommand::CompleteDefensiveCombat, {defensive->id, defensive->position, defensive->name}, {}, {}, - defensive->routeCritical, "success", "target_defeated"}; + if (const auto completion = playerBotDefensiveLifetimeCompletion(*defensive, target)) { + return *completion; } if ((!defensive->routeCritical && !target.attacksPlayer) || !target.visible || !target.adjacent) { return {PlayerBotCombatCommand::CompleteDefensiveCombat, {defensive->id, defensive->position, defensive->name}, {}, {}, @@ -101,7 +113,11 @@ PlayerBotCombatDecision PlayerBotCombatRuntime::advance(const PlayerBotCombatSna return {PlayerBotCombatCommand::CompleteDefensiveCombat, {defensive->id, defensive->position, defensive->name}, {}, {}, defensive->routeCritical, "failed", "target_lost"}; } - if (session->value.defensiveCombatTimedOut(snapshot.now, config.combatTimeout)) { + // A movement fallback must not release and reacquire the same persistent + // attacker merely because the generic defensive-combat timer elapsed. + if (playerBotDefensiveCombatTimeoutApplies( + defensive->routeCritical, + session->value.defensiveCombatTimedOut(snapshot.now, config.combatTimeout))) { return {PlayerBotCombatCommand::CompleteDefensiveCombat, {defensive->id, defensive->position, defensive->name}, {}, {}, defensive->routeCritical, "failed", "combat_timeout"}; } diff --git a/server/src/playerbotcombatruntime.h b/server/src/playerbotcombatruntime.h index c4d1623..693fd18 100644 --- a/server/src/playerbotcombatruntime.h +++ b/server/src/playerbotcombatruntime.h @@ -35,6 +35,9 @@ struct PlayerBotCombatDecision { bool routeCritical = false; const char* result = nullptr; const char* reason = nullptr; + bool intendedStep = false; + double predictedFightDamage = 0; + double predictedFightSeconds = 0; }; struct PlayerBotCombatTargetSnapshot { @@ -56,11 +59,25 @@ struct PlayerBotCombatSnapshot { PlayerBotCombatTargetSnapshot defensive; }; +inline std::optional playerBotDefensiveLifetimeCompletion( + const PlayerBotDefensiveTarget& active, const PlayerBotCombatTargetSnapshot& observed) +{ + if (observed.present && !observed.removed && !observed.dead) return std::nullopt; + return PlayerBotCombatDecision{PlayerBotCombatCommand::CompleteDefensiveCombat, + {active.id, active.position, active.name}, {}, {}, + active.routeCritical, "success", "target_defeated"}; +} + struct PlayerBotCombatRuntimeConfig { std::chrono::steady_clock::duration combatTimeout; std::chrono::steady_clock::duration traversalSuppression; }; +inline bool playerBotDefensiveCombatTimeoutApplies(bool movementFallback, bool timedOut) +{ + return !movementFallback && timedOut; +} + // Owns combat transitions. The controller supplies already eligible world candidates and executes commands. class PlayerBotCombatRuntime { diff --git a/server/src/playerbotcombattarget.h b/server/src/playerbotcombattarget.h index 6fae91a..177c3c3 100644 --- a/server/src/playerbotcombattarget.h +++ b/server/src/playerbotcombattarget.h @@ -11,6 +11,7 @@ #ifndef FS_PLAYERBOTCOMBATTARGET_H #define FS_PLAYERBOTCOMBATTARGET_H +#include #include #include @@ -33,6 +34,9 @@ struct PlayerBotTraversalTarget : PlayerBotTarget { struct PlayerBotDefensiveTarget : PlayerBotTarget { bool routeCritical = false; + bool intendedStep = false; + double predictedFightDamage = 0; + double predictedFightSeconds = 0; }; struct PlayerBotTraversalCandidate : PlayerBotTarget { @@ -40,4 +44,30 @@ struct PlayerBotTraversalCandidate : PlayerBotTarget { bool attacksPlayer = false; }; +inline bool playerBotPassageFightManageable(int32_t currentHealth, uint32_t minimumRecovery, + double incomingDamagePerSecond, double fightSeconds) +{ + return currentHealth > 0 && incomingDamagePerSecond >= 0 && fightSeconds > 0 && + incomingDamagePerSecond * fightSeconds < currentHealth + minimumRecovery; +} + +inline bool playerBotPreferDefensiveTarget(const PlayerBotDefensiveTarget& left, + const PlayerBotDefensiveTarget& right, + const Position& currentPosition) +{ + if (left.routeCritical != right.routeCritical) return left.routeCritical; + if (left.intendedStep != right.intendedStep) return left.intendedStep; + if (left.predictedFightDamage != right.predictedFightDamage) { + return left.predictedFightDamage < right.predictedFightDamage; + } + if (left.predictedFightSeconds != right.predictedFightSeconds) { + return left.predictedFightSeconds < right.predictedFightSeconds; + } + const uint32_t leftDistance = std::max(Position::getDistanceX(currentPosition, left.position), + Position::getDistanceY(currentPosition, left.position)); + const uint32_t rightDistance = std::max(Position::getDistanceX(currentPosition, right.position), + Position::getDistanceY(currentPosition, right.position)); + return leftDistance == rightDistance ? left.id < right.id : leftDistance < rightDistance; +} + #endif diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index 15ecade..f9150d8 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -376,7 +376,6 @@ playerbot::PlayerBotTelemetrySummary PlayerBotController::telemetrySummary() con else if (!player->canDoAction()) summary.waitingReason = "action_delay"; } if (supplyRecovery.active()) summary.recovery = "supply"; - else if (huntCoordinator.transitBreakoutActive()) summary.recovery = "transit_breakout"; else if (huntCoordinator.retreatingFromDanger()) summary.recovery = "danger_retreat"; return summary; } @@ -1354,57 +1353,11 @@ void PlayerBotController::onHealthGain(Creature* healer, const Creature& target, survivalRuntime.observeHealthGain(healer && healer->getID() == playerId, target.getID() == playerId, gain); } -bool PlayerBotController::confirmAdjacentRouteBlockers(Player* player, const Position& currentPosition, - std::chrono::steady_clock::time_point now) +bool PlayerBotController::handleFixedTargetRouteExhausted(Player*, const Position& currentPosition, + const PlayerBotNavigationRuntimeOutcome& outcome, std::chrono::steady_clock::time_point, bool allowStop) { - if (!player) return false; - bool confirmed = false; - SpectatorVec spectators; - g_game.map.getSpectators(spectators, currentPosition); - for (Creature* creature : spectators) { - if (!creature->getMonster() || creature->isRemoved() || creature->isDead() || - !player->canSee(creature->getPosition()) || - !Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition())) { - continue; - } - navigationRuntime.confirmRouteBlocker(creature->getID(), creature->getPosition(), now, - navigationBlockSuppression); - confirmed = true; - } - return confirmed; -} - -bool PlayerBotController::handleFixedTargetRouteExhausted(Player* player, const Position& currentPosition, - const PlayerBotNavigationRuntimeOutcome& outcome, std::chrono::steady_clock::time_point now, bool allowStop) -{ - if (!outcome.fixedTargetRouteExhausted) return false; - bool adjacentConfirmedHostileBlocker = false; - SpectatorVec spectators; - g_game.map.getSpectators(spectators, currentPosition); - for (Creature* creature : spectators) { - if (creature->getMonster() && !creature->isRemoved() && !creature->isDead() && - player->canSee(creature->getPosition()) && - Position::areInRange<1, 1, 0>(currentPosition, creature->getPosition()) && - navigationRuntime.isRouteCritical(creature->getID(), creature->getPosition(), now)) { - adjacentConfirmedHostileBlocker = true; - break; - } - } - const bool breakoutStarted = huntCoordinator.beginTransitBreakout( - outcome.fixedTargetRouteExhausted, adjacentConfirmedHostileBlocker, - outcome.routeUnavailable, now); - if (huntCoordinator.transitBreakoutActive()) { - if (breakoutStarted) { - emit("navigation_progress", currentPosition, - "\"result\":\"recovering\",\"reason\":\"transit_breakout\",\"duration_seconds\":30"); - } - return true; - } - // A previous breakout failed to free the tile and no blocker is currently - // attackable: continuing would loop the same no-progress plan sequence. - if (allowStop || huntCoordinator.transitBreakoutAttempted()) { - stop("navigation_route_unavailable", currentPosition); - } + if (!outcome.fixedTargetRouteExhausted || !allowStop) return false; + stop("navigation_route_unavailable", currentPosition); return true; } @@ -1431,6 +1384,8 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr PlayerBotNavigationRuntimeOutcome outcome = navigationRuntime.process({ currentPosition, goal, player->getWalkDelay() > 0 || !player->canDoAction(), player->canDoAction(), timing, }); + huntCoordinator.observeTransitPosition(currentPosition); + if (outcome.fixedTargetChanged) huntCoordinator.clearTransitMovementFallback(); if (outcome.routeRequest && !allowRoutePlanning) { if (navigationOutcome) *navigationOutcome = outcome; return false; @@ -1448,9 +1403,11 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr std::min(fixturePlan.maximumExpandedNodes, routeNodeBudget), sameFloorOnly); } const PlayerBotPendingMovementResult movementResult = outcome.movementResult; + const std::optional failedMovementTarget = outcome.failedMovementTarget; const bool positionalProgress = outcome.positionalProgress; outcome = navigationRuntime.observePlan({goal, std::move(routePlan), player->canDoAction(), false, now}); outcome.movementResult = movementResult; + outcome.failedMovementTarget = failedMovementTarget; outcome.positionalProgress = positionalProgress; } if (outcome.plan.attempted && !outcome.routeUnavailable && @@ -1472,6 +1429,7 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr if (risk && outcome.plan.attempted && !outcome.routeUnavailable) { if (!playerBotNavigationRiskAccepts(*risk, outcome.plan.dangerCost, outcome.plan.maximumHealthLossPerSecond)) { + huntCoordinator.clearTransitMovementFallback(); outcome.routeUnsafe = true; outcome.routeUnavailable = true; navigationRuntime.reset(); @@ -1487,30 +1445,10 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr return false; } } - bool immediateEscapeStepOpen = outcome.nextStep.has_value(); - if (immediateEscapeStepOpen && outcome.nextStep->action == PlayerBotNavigationAction::Move) { - SpectatorVec spectators; - g_game.map.getSpectators(spectators, currentPosition); - immediateEscapeStepOpen = std::none_of(spectators.begin(), spectators.end(), [&outcome](Creature* creature) { - return creature->getMonster() && !creature->isRemoved() && !creature->isDead() && - creature->getPosition() == outcome.nextStep->target; - }); - } - if (huntCoordinator.observeTransitBreakoutNavigation(outcome.positionalProgress, immediateEscapeStepOpen)) { - if (huntCoordinator.hasDefensiveCombat()) { - finishDefensiveCombat(player, currentPosition, "skipped", "transit_breakout_route_open"); - } else { - resetNavigation(); - } - emit("navigation_progress", currentPosition, - "\"result\":\"recovered\",\"reason\":\"transit_breakout_route_open\""); - schedule(SCHEDULER_MINTICKS); - if (navigationOutcome) *navigationOutcome = outcome; - return false; - } if (navigationOutcome) *navigationOutcome = outcome; fixtureDriver.observeNavigationPlan(outcome.plan.attempted); if (outcome.destinationReached) { + huntCoordinator.clearTransitMovementFallback(); // A navigation leg (for example, coarse NPC approach) can finish // without completing the transit goal or renewing blocker attempts. resetNavigation(); @@ -1541,6 +1479,7 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr return false; } if (outcome.movementResult == PlayerBotPendingMovementResult::Mismatch) { + huntCoordinator.observeTransitMovementFailure(currentPosition, outcome.failedMovementTarget); telemetry.logActionFailure("navigate", "step_result_mismatch", currentPosition); if (outcome.stepFailureCount >= maximumRepeatedNavigationStepFailures) { schedule(blockedRouteRetryInterval); @@ -1560,6 +1499,7 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr if (outcome.plan.attempted) { telemetry.recordPathfinding(outcome.plan.elapsed, !outcome.routeUnavailable); if (outcome.routeUnavailable) { + huntCoordinator.observeTransitMovementFailure(currentPosition); telemetry.emit("navigation_progress", currentPosition, "\"result\":\"failed\",\"reason\":\"route_unavailable\",\"cycle_phase\":" + jsonString(cyclePhaseName()) + ",\"destination\":{\"x\":" + std::to_string(destination.x) + @@ -1573,7 +1513,6 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr std::to_string(outcome.plan.waypoint.x) + ",\"y\":" + std::to_string(outcome.plan.waypoint.y) + ",\"z\":" + std::to_string(static_cast(outcome.plan.waypoint.z)) + "}"); telemetry.logActionFailure("navigate", "route_unavailable", currentPosition); - confirmAdjacentRouteBlockers(player, currentPosition, now); if (handleFixedTargetRouteExhausted(player, currentPosition, outcome, now, true)) { schedule(blockedRouteRetryInterval); return false; @@ -1581,14 +1520,6 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr schedule(blockedRouteRetryInterval); return false; } - // Exhaustion can also accumulate while plans keep nominally succeeding - // (the only corridor step is occupied by a stationary monster). Escalate - // before dispatching another doomed step. - if (handleFixedTargetRouteExhausted(player, currentPosition, outcome, now, false)) { - schedule(blockedRouteRetryInterval); - if (navigationOutcome) *navigationOutcome = outcome; - return false; - } std::ostringstream fields; fields << "\"action\":\"plan\",\"result\":\"success\",\"steps\":" << outcome.plan.steps << ",\"expanded_nodes\":" << outcome.plan.expandedNodes @@ -1612,10 +1543,21 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr return false; } PlayerBotNavigationStep step = *outcome.nextStep; + if (step.action == PlayerBotNavigationAction::Move) { + SpectatorVec spectators; + g_game.map.getSpectators(spectators, currentPosition); + const bool occupied = std::any_of(spectators.begin(), spectators.end(), [&step](Creature* creature) { + return !creature->isRemoved() && !creature->isDead() && creature->getPosition() == step.target; + }); + if (!occupied) huntCoordinator.observeViableTransitMovement(); + } if (step.topologyPortal) { step = resolveTopologyPortal(*player, step, navigationRuntime.activeBlockedPositions(now)); } if (!executeNavigationStep(player, step)) { + if (step.action == PlayerBotNavigationAction::Move) { + huntCoordinator.observeTransitMovementFailure(currentPosition, step.target); + } navigationRuntime.observeStep({step, PlayerBotNavigationStepResult::Rejected, std::chrono::steady_clock::now(), navigationBlockSuppression}); telemetry.logActionFailure("navigate", "transition_unavailable", currentPosition); @@ -1715,18 +1657,6 @@ void PlayerBotController::navigate() finishTraversalCombat(player, currentPosition, "transit_goal_changed"); } } - const auto transitNow = std::chrono::steady_clock::now(); - if (huntCoordinator.transitBreakoutExpired(transitNow)) { - huntCoordinator.finishTransitBreakout(); - if (huntCoordinator.hasDefensiveCombat()) { - finishDefensiveCombat(player, currentPosition, "skipped", "transit_breakout_timeout"); - } - stop("navigation_route_unavailable", currentPosition); - return; - } - if (huntCoordinator.transitDefenseExpired(transitNow)) { - finishDefensiveCombat(player, currentPosition, "skipped", "transit_combat_budget"); - } const bool accessingReward = progressionRuntime.session().active(PlayerBotProgressionProcedure::PickupReward) && (progressionRuntime.reward().stage() == PlayerBotRewardStage::VerifyReward || progressionRuntime.reward().stage() == PlayerBotRewardStage::EquipReward || diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index 8bfbfa7..223769e 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -354,17 +354,11 @@ class PlayerBotController : public std::enable_shared_from_this intendedStep = std::nullopt) { - return hasDefensiveCombat() && transitCombat.defenseExpired(now); + transitCombat.observeMovementFailure(currentPosition, intendedStep); } - bool beginTransitBreakout(bool routeExhausted, bool adjacentConfirmedHostileBlocker, - bool routeUnavailable, std::chrono::steady_clock::time_point now) + void observeTransitPosition(const Position& currentPosition) { transitCombat.observePosition(currentPosition); } + void observeViableTransitMovement() { transitCombat.observeViableMovement(); } + bool transitMovementFallbackRequired() const { return transitCombat.movementFallbackRequired(); } + std::optional transitIntendedStep() const { return transitCombat.intendedStep(); } + bool retainTransitDefense(uint32_t blockerId, const Position& currentPosition, + const Position& blockerPosition, bool safe) const { - return transitCombat.beginBreakout( - routeExhausted, adjacentConfirmedHostileBlocker, routeUnavailable, now); + return transitCombat.retainsDefense(blockerId, currentPosition, blockerPosition, safe); } - bool transitBreakoutActive() const { return transitCombat.breakoutActive(); } - bool transitBreakoutAttempted() const { return transitCombat.breakoutWasAttempted(); } - bool transitBreakoutExpired(std::chrono::steady_clock::time_point now) const - { - return transitCombat.breakoutExpired(now); - } - bool observeTransitBreakoutNavigation(bool positionalProgress, bool routeAvailable) - { - return transitCombat.observeBreakoutNavigation(positionalProgress, routeAvailable); - } - void finishTransitBreakout() { transitCombat.finishBreakout(); } + void clearTransitMovementFallback() { transitCombat.clearFallback(); } void cancelPlanning(); void setSupplyRecovery(bool degraded) { huntRuntime.setSupplyRecovery(degraded); } diff --git a/server/src/playerbothuntregionadapter.cpp b/server/src/playerbothuntregionadapter.cpp index f326129..a5009d9 100644 --- a/server/src/playerbothuntregionadapter.cpp +++ b/server/src/playerbothuntregionadapter.cpp @@ -21,6 +21,7 @@ extern Game g_game; extern ConfigManager g_config; +extern Monsters g_monsters; extern Spells* g_spells; namespace { @@ -806,6 +807,15 @@ double PlayerBotHuntRegionAdapter::travelDanger(const PlayerBotCombatProfile& co return expectedDamagePerSecondAt(combat, position) / std::max(combat.maximumHealth, 1); } +PlayerBotFightEstimate PlayerBotHuntRegionAdapter::fightEstimate( + const PlayerBotCombatProfile& combat, const std::string& monsterName, int32_t remainingHealth) +{ + const MonsterType* monsterType = g_monsters.getMonsterType(monsterName, false); + if (!monsterType) return {}; + return {expectedMonsterDamagePerSecond(*monsterType, combat), + std::max(remainingHealth, 1) / expectedPlayerDamagePerSecond(combat, *monsterType)}; +} + PlayerBotHuntCorridorDanger PlayerBotHuntRegionAdapter::corridorDanger(const PlayerBotCombatProfile& combat, const std::deque& steps, const Position& destinationCenter, uint32_t stepDurationMs) { diff --git a/server/src/playerbothuntregionadapter.h b/server/src/playerbothuntregionadapter.h index de43e88..2698c73 100644 --- a/server/src/playerbothuntregionadapter.h +++ b/server/src/playerbothuntregionadapter.h @@ -5,6 +5,11 @@ #include "playerbothuntregions.h" #include "playerbotnavigation.h" +struct PlayerBotFightEstimate { + double incomingDamagePerSecond = 0; + double fightSeconds = 0; +}; + // The cache is shared across bots and is not synchronized; use this adapter // only from the server's serialized game execution context. Candidate indices // returned by beginScan() are valid only for that scan's revision. score() @@ -30,6 +35,9 @@ class PlayerBotHuntRegionAdapter static PlayerBotHuntPlanningProfile planningProfile(const Player& player, const PlayerBotCombatProfile& combat, double challengeFrontier); static double travelDanger(const PlayerBotCombatProfile& combat, const Position& position); + static PlayerBotFightEstimate fightEstimate(const PlayerBotCombatProfile& combat, + const std::string& monsterName, + int32_t remainingHealth); static PlayerBotHuntCorridorDanger corridorDanger(const PlayerBotCombatProfile& combat, const std::deque& steps, const Position& destinationCenter, uint32_t stepDurationMs); diff --git a/server/src/playerbotloot.cpp b/server/src/playerbotloot.cpp index b3fa924..88c7654 100644 --- a/server/src/playerbotloot.cpp +++ b/server/src/playerbotloot.cpp @@ -107,6 +107,9 @@ void PlayerBotController::finishLoot(Player* player, const Position& currentPosi void PlayerBotController::finishLootFailure(Player* player, const Position& currentPosition, const char* reason) { + if (huntCoordinator.hasDefensiveCombat()) { + finishDefensiveCombat(player, currentPosition, "skipped", reason); + } telemetry.recordActionFailure(); const auto elapsed = huntCoordinator.lootElapsedMilliseconds(std::chrono::steady_clock::now()); std::ostringstream fields; diff --git a/server/src/playerbotnavigationruntime.cpp b/server/src/playerbotnavigationruntime.cpp index 22c5b06..be8bf51 100644 --- a/server/src/playerbotnavigationruntime.cpp +++ b/server/src/playerbotnavigationruntime.cpp @@ -16,6 +16,7 @@ PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::process(const Play { PlayerBotNavigationRuntimeOutcome outcome; const bool sameFixedTarget = fixedTargetGoal && playerBotNavigationSameFixedObjective(*fixedTargetGoal, input.goal); + outcome.fixedTargetChanged = fixedTargetGoal && !sameFixedTarget; outcome.positionalProgress = fixedTargetFailures.observePosition( sameFixedTarget, input.goal.distance(input.currentPosition)); fixedTargetGoal = input.goal; @@ -37,10 +38,11 @@ PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::process(const Play return outcome; } + const std::optional pendingMoveTarget = session.pendingMoveTarget(); outcome.movementResult = session.observeMovement(input.currentPosition, input.actionPending, input.timing.now, input.timing.stepTimeout, input.timing.blockSuppression); - if (outcome.movementResult == PlayerBotPendingMovementResult::Mismatch && session.stepFailureCount() >= 3) { - session.confirmRequiredRouteBlocker(); + if (outcome.movementResult == PlayerBotPendingMovementResult::Mismatch) { + outcome.failedMovementTarget = pendingMoveTarget; } if (outcome.movementResult == PlayerBotPendingMovementResult::Waiting) { outcome.command = PlayerBotNavigationRuntimeCommand::Retry; @@ -75,9 +77,8 @@ PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::observePlan(Player if (observation.plan.metrics.result != PlayerBotNavigationResult::Reached || (!observation.startsNavigation && observation.plan.steps.empty())) { outcome.routeUnavailable = true; - const std::set activeBlockers = session.activeBlockedPositions(observation.now); + session.activeBlockedPositions(observation.now); fixedTargetFailures.observePlan(false); - if (!activeBlockers.empty()) session.confirmRequiredRouteBlocker(); outcome.fixedTargetRouteFailures = fixedTargetFailures.count(); outcome.fixedTargetRouteExhausted = fixedTargetFailures.exhausted(); outcome.command = outcome.fixedTargetRouteExhausted ? PlayerBotNavigationRuntimeCommand::Fail : diff --git a/server/src/playerbotnavigationruntime.h b/server/src/playerbotnavigationruntime.h index 15f67cd..1543696 100644 --- a/server/src/playerbotnavigationruntime.h +++ b/server/src/playerbotnavigationruntime.h @@ -121,7 +121,9 @@ class PlayerBotFixedTargetFailureTracker struct PlayerBotNavigationRuntimeOutcome { bool destinationReached = false; bool positionalProgress = false; + bool fixedTargetChanged = false; PlayerBotPendingMovementResult movementResult = PlayerBotPendingMovementResult::None; + std::optional failedMovementTarget; uint32_t stepFailureCount = 0; std::optional oscillation; std::optional pendingWorldChange; @@ -192,19 +194,6 @@ class PlayerBotNavigationRuntime size_t routeSize() const { return session.routeSize(); } bool hasPendingWork() const { return session.hasPendingWork(); } - bool avoidPendingRouteBlocker(uint32_t blockerId, const Position& position, std::chrono::steady_clock::time_point now, - std::chrono::steady_clock::duration suppression) - { - if (!session.avoidPendingRouteBlocker(blockerId, position, now, suppression)) return false; - fixedTargetFailures.observeBlockedPlan(); - return true; - } - bool isRouteCritical(uint32_t blockerId, const Position& position, std::chrono::steady_clock::time_point now) const { return session.isRouteCritical(blockerId, position, now); } - void confirmRouteBlocker(uint32_t blockerId, const Position& position, std::chrono::steady_clock::time_point now, - std::chrono::steady_clock::duration suppression) - { - session.confirmRouteBlocker(blockerId, position, now, suppression); - } std::set activeBlockedPositions(std::chrono::steady_clock::time_point now) { return session.activeBlockedPositions(now); } bool hasActiveRouteBlock(std::chrono::steady_clock::time_point now) const { return session.hasActiveRouteBlock(now); } bool oscillationDetected() const { return session.oscillationDetected(); } diff --git a/server/src/playerbotnavigationsession.cpp b/server/src/playerbotnavigationsession.cpp index 1f0fc8c..72cebdd 100644 --- a/server/src/playerbotnavigationsession.cpp +++ b/server/src/playerbotnavigationsession.cpp @@ -20,9 +20,6 @@ void PlayerBotNavigationSession::clear() target = PlayerBotNavigationGoal(); targetSet = false; blockedStepCount = 0; - pendingRouteBlocker.reset(); - pendingRouteBlockerId.reset(); - requiredRouteBlockerIds.clear(); } void PlayerBotNavigationSession::adopt(const PlayerBotNavigationGoal& goal, std::deque newSteps) @@ -40,14 +37,6 @@ void PlayerBotNavigationSession::prepareGoal(const PlayerBotNavigationGoal& goal return; } steps.clear(); - // Adjacent-approach goal flapping (depot discovery alternating between - // standable tiles of the same depot) must not erase confirmed route - // blockers, or a corridor blocker never becomes attackable. - if (!targetSet || !playerBotNavigationSameFixedObjective(target, goal)) { - clearRequiredRouteBlockers(); - pendingRouteBlocker.reset(); - pendingRouteBlockerId.reset(); - } target = goal; targetSet = true; blockedStepCount = 0; @@ -74,9 +63,6 @@ PlayerBotPendingMovementResult PlayerBotNavigationSession::observeMovement( if (!steps.empty()) { steps.pop_front(); } - clearRequiredRouteBlockers(); - pendingRouteBlocker.reset(); - pendingRouteBlockerId.reset(); return PlayerBotPendingMovementResult::Completed; } if (actionPending && now - stepStarted < timeout) { @@ -122,11 +108,6 @@ std::set PlayerBotNavigationSession::activeBlockedPositions(std::chron std::set active; for (auto it = temporarilyBlockedPositions.begin(); it != temporarilyBlockedPositions.end();) { if (it->second <= now) { - if (pendingRouteBlocker && *pendingRouteBlocker == it->first) { - if (pendingRouteBlockerId) requiredRouteBlockerIds.erase(*pendingRouteBlockerId); - pendingRouteBlocker.reset(); - pendingRouteBlockerId.reset(); - } it = temporarilyBlockedPositions.erase(it); } else { active.insert(it->first); @@ -141,46 +122,6 @@ void PlayerBotNavigationSession::suppress(const Position& position, std::chrono: temporarilyBlockedPositions[position] = expires; } -bool PlayerBotNavigationSession::avoidPendingRouteBlocker( - uint32_t blockerId, const Position& position, std::chrono::steady_clock::time_point now, - std::chrono::steady_clock::duration suppression) -{ - if (!movementPending || position != stepTarget) return false; - movementPending = false; - steps.clear(); - // A detour onto a tile that is already suppressed means the planner had no - // alternative around it (one-tile corridor): the blocker is persistent and - // must become attackable instead of looping as an optional detour. - const auto existing = temporarilyBlockedPositions.find(position); - const bool persistent = existing != temporarilyBlockedPositions.end() && now < existing->second; - temporarilyBlockedPositions[position] = now + suppression; - if (persistent) requiredRouteBlockerIds.insert(blockerId); - else requiredRouteBlockerIds.erase(blockerId); - pendingRouteBlocker = position; - pendingRouteBlockerId = blockerId; - return true; -} - -void PlayerBotNavigationSession::confirmRequiredRouteBlocker() -{ - if (pendingRouteBlocker && pendingRouteBlockerId && - temporarilyBlockedPositions.find(*pendingRouteBlocker) != temporarilyBlockedPositions.end()) { - requiredRouteBlockerIds.insert(*pendingRouteBlockerId); - } -} - -void PlayerBotNavigationSession::confirmRouteBlocker(uint32_t blockerId, const Position& position, - std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration suppression) -{ - requiredRouteBlockerIds.insert(blockerId); - auto expires = temporarilyBlockedPositions.find(position); - if (expires == temporarilyBlockedPositions.end()) { - temporarilyBlockedPositions.emplace(position, now + suppression); - } else if (expires->second < now + suppression) { - expires->second = now + suppression; - } -} - std::optional PlayerBotNavigationSession::observeProgress( const Position& currentPosition, const PlayerBotNavigationGoal& goal, std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration suppression) @@ -237,11 +178,3 @@ std::optional PlayerBotNavigationSession::observ detectedOscillation = true; return PlayerBotNavigationOscillation{suppressedTarget, suppressedExpected, previousPosition}; } - -bool PlayerBotNavigationSession::isRouteCritical(uint32_t blockerId, const Position& position, - std::chrono::steady_clock::time_point now) const -{ - const auto blocked = temporarilyBlockedPositions.find(position); - return requiredRouteBlockerIds.find(blockerId) != requiredRouteBlockerIds.end() && - blocked != temporarilyBlockedPositions.end() && now < blocked->second; -} diff --git a/server/src/playerbotnavigationsession.h b/server/src/playerbotnavigationsession.h index b3d8a81..c13b332 100644 --- a/server/src/playerbotnavigationsession.h +++ b/server/src/playerbotnavigationsession.h @@ -47,6 +47,10 @@ class PlayerBotNavigationSession void clearRoute() { steps.clear(); } size_t routeSize() const { return steps.size(); } bool hasPendingWork() const { return movementPending || worldChangePending || !steps.empty(); } + std::optional pendingMoveTarget() const + { + return movementPending ? std::optional(stepTarget) : std::nullopt; + } PlayerBotPendingMovementResult observeMovement(const Position& currentPosition, bool actionPending, std::chrono::steady_clock::time_point now, @@ -58,19 +62,12 @@ class PlayerBotNavigationSession std::set activeBlockedPositions(std::chrono::steady_clock::time_point now); void suppress(const Position& position, std::chrono::steady_clock::time_point expires); - bool avoidPendingRouteBlocker(uint32_t blockerId, const Position& position, std::chrono::steady_clock::time_point now, - std::chrono::steady_clock::duration suppression); - void confirmRequiredRouteBlocker(); - void confirmRouteBlocker(uint32_t blockerId, const Position& position, std::chrono::steady_clock::time_point now, - std::chrono::steady_clock::duration suppression); - void clearRequiredRouteBlockers() { requiredRouteBlockerIds.clear(); } - void clearBlockedPositions() { temporarilyBlockedPositions.clear(); clearRequiredRouteBlockers(); pendingRouteBlocker.reset(); pendingRouteBlockerId.reset(); } + void clearBlockedPositions() { temporarilyBlockedPositions.clear(); } std::optional observeProgress( const Position& currentPosition, const PlayerBotNavigationGoal& goal, std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration suppression); - bool isRouteCritical(uint32_t blockerId, const Position& position, std::chrono::steady_clock::time_point now) const; bool hasActiveRouteBlock(std::chrono::steady_clock::time_point now) const { return now < blockedTargetExpires; } bool oscillationDetected() const { return detectedOscillation; } uint32_t stepFailureCount() const { return blockedStepCount; } @@ -93,9 +90,6 @@ class PlayerBotNavigationSession std::chrono::steady_clock::time_point blockedTargetExpires; PlayerBotNavigationStep worldChangeStep; std::map temporarilyBlockedPositions; - std::set requiredRouteBlockerIds; - std::optional pendingRouteBlocker; - std::optional pendingRouteBlockerId; uint32_t blockedStepCount = 0; bool movementPending = false; bool worldChangePending = false; diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index 8056ba9..85b1c5a 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -876,6 +876,9 @@ void PlayerBotController::processService(Player* player, const Position& current [](const PlayerBotNavigationStep& step) { return step.action == PlayerBotNavigationAction::NpcTravel; }); routeObservation.approachRoute.requiresNpcTravel = lastRouteRequiresNpcTravel; if (reached) approachSteps = std::move(routePlan.steps); + else if (routePlan.metrics.result != PlayerBotNavigationResult::Reached) { + huntCoordinator.observeTransitMovementFailure(currentPosition); + } command = serviceWorkflow.advance(routeObservation, economyCatalog, dispositionPolicy); if (reached && command.type == PlayerBotServiceCommandType::Wait && command.outcome == PlayerBotServiceOutcome::Success) { @@ -1261,12 +1264,10 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP observation.maximumHealthLossPerSecond = routePlan.metrics.maximumHealthLossPerSecond; if (reached) { steps = std::move(routePlan.steps); - } else if (routePlan.metrics.attempted) { - // A failed fixed-goal validation is a no-progress failure toward the - // depot. Feed it into the runtime: adjacent hostiles confirm as - // route-critical so transit defense engages, and repeated failures - // accumulate toward exhaustion and breakout. - confirmAdjacentRouteBlockers(&player, currentPosition, startedAt); + } else if (routePlan.metrics.attempted && + routePlan.metrics.result != PlayerBotNavigationResult::Reached) { + huntCoordinator.observeTransitMovementFailure(currentPosition); + // Feed failed fixed-goal validation into the shared navigation bound. PlayerBotNavigationRoutePlan failure; failure.metrics = routePlan.metrics; const PlayerBotNavigationRuntimeOutcome fed = navigationRuntime.observePlan( @@ -1319,10 +1320,8 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP if (sellLootPlan) { deferSellLoot(player, currentPosition, reason); } else if (!navigationRuntime.activeBlockedPositions(now).empty()) { - // A temporarily suppressed route blocker (for example a monster in a - // one-tile cave corridor) makes every approach unreachable. Transit - // defense and breakout handle the blocker; retry discovery instead of - // the terminal stop. + // A temporarily suppressed blocker can make every approach unreachable. + // Retry discovery while the shared suppression remains active. schedule(blockedRouteRetryInterval); } else { stop("depot_unavailable", currentPosition); diff --git a/server/src/playerbottargetingsession.cpp b/server/src/playerbottargetingsession.cpp index c6064a4..b623697 100644 --- a/server/src/playerbottargetingsession.cpp +++ b/server/src/playerbottargetingsession.cpp @@ -47,12 +47,7 @@ std::optional PlayerBotTargetingSession::selectDefensi { std::sort(candidates.begin(), candidates.end(), [¤tPosition](const PlayerBotDefensiveTarget& left, const PlayerBotDefensiveTarget& right) { - if (left.routeCritical != right.routeCritical) { - return left.routeCritical; - } - const uint32_t leftDistance = targetDistance(currentPosition, left.position); - const uint32_t rightDistance = targetDistance(currentPosition, right.position); - return leftDistance == rightDistance ? left.id < right.id : leftDistance < rightDistance; + return playerBotPreferDefensiveTarget(left, right, currentPosition); }); return candidates.empty() ? std::nullopt : std::optional(candidates.front()); } diff --git a/server/src/playerbottransitcombat.h b/server/src/playerbottransitcombat.h index 29ed799..ec2fbd2 100644 --- a/server/src/playerbottransitcombat.h +++ b/server/src/playerbottransitcombat.h @@ -1,9 +1,9 @@ -/** Pure transit combat session. Navigation resets do not renew blocker attempts. */ +/** Pure transit movement-fallback state. Route replans do not change its chosen target. */ #ifndef FS_PLAYERBOTTRANSITCOMBAT_H #define FS_PLAYERBOTTRANSITCOMBAT_H -#include #include +#include #include "playerbotturnrouter.h" @@ -16,78 +16,81 @@ class PlayerBotTransitCombat return phase != PlayerBotCyclePhase::Hunt || !huntReached || progressionActive || stage == PlayerBotScenarioStage::LootCorpse; } - // Goal decisions and phase transitions start new episodes, not route - // replans or intermediate arrivals within the same goal. + static bool lootDeadlineRequiresRelease(bool lootCorpseStage, bool lootTimedOut) + { + return lootCorpseStage && lootTimedOut; + } + // Goal decisions and phase transitions start new episodes. Replans and + // intermediate arrivals within one goal do not. bool observe(bool transit, uint64_t goal, PlayerBotCyclePhase phase) { const bool changed = transit != active() || goal != goalId || phase != cyclePhase; if (changed) { finish(); - if (transit) begin(); + if (transit) travelling = true; goalId = goal; cyclePhase = phase; } return changed; } - void begin() { travelling = true; } void finish() { travelling = false; - defenseAttempted = false; - breakoutAttempted = false; - breakout = false; + clearFallback(); } bool active() const { return travelling; } - bool allowsDefense(uint32_t, bool routeCritical) const + + void observeMovementFailure(const Position& currentPosition, + std::optional intendedStep = std::nullopt) { - return !travelling || (routeCritical && (!defenseAttempted || breakout)); + stalled = true; + stalledPosition = currentPosition; + if (intendedStep) intendedMovementStep = intendedStep; } - void beginDefense(uint32_t, std::chrono::steady_clock::time_point now) + void observePosition(const Position& currentPosition) { - if (!travelling) return; - if (!defenseAttempted) defenseDeadline = now + std::chrono::seconds(5); - defenseAttempted = true; + if (stalled && currentPosition != stalledPosition) clearFallback(); } - bool defenseExpired(std::chrono::steady_clock::time_point now) const + void observeViableMovement() { clearFallback(); } + bool movementFallbackRequired() const { return stalled; } + std::optional intendedStep() const { return intendedMovementStep; } + + bool allowsDefense(uint32_t blockerId, bool routeCritical) const { - return travelling && defenseAttempted && !breakout && now >= defenseDeadline; + return (!travelling && !routeCritical) || (routeCritical && movementFallbackRequired() && + (defensiveBlockerId == 0 || defensiveBlockerId == blockerId)); } - bool beginBreakout(bool routeExhausted, bool adjacentConfirmedHostileBlocker, - [[maybe_unused]] bool routeUnavailable, - std::chrono::steady_clock::time_point now, - std::chrono::steady_clock::duration duration = std::chrono::seconds(30)) + void beginDefense(uint32_t blockerId, bool selectedIntendedStep) { - if (!travelling || breakoutAttempted || !routeExhausted || !adjacentConfirmedHostileBlocker) { - return false; - } - breakoutAttempted = true; - breakout = true; - breakoutDeadline = now + duration; - return true; + if (!movementFallbackRequired()) return; + defensiveBlockerId = blockerId; + defensiveTargetWasIntendedStep = selectedIntendedStep; } - bool breakoutWasAttempted() const { return breakoutAttempted; } - bool breakoutActive() const { return travelling && breakout; } - bool breakoutExpired(std::chrono::steady_clock::time_point now) const + bool retainsDefense(uint32_t blockerId, const Position& currentPosition, + const Position& blockerPosition, bool safe) const { - return breakoutActive() && now >= breakoutDeadline; + return movementFallbackRequired() && defensiveBlockerId == blockerId && safe && + currentPosition == stalledPosition && + (!defensiveTargetWasIntendedStep || !intendedMovementStep || + blockerPosition == *intendedMovementStep); } - bool observeBreakoutNavigation(bool positionalProgress, bool routeAvailable) + void clearFallback() { - if (!breakoutActive() || (!positionalProgress && !routeAvailable)) return false; - breakout = false; - return true; + stalled = false; + intendedMovementStep.reset(); + defensiveBlockerId = 0; + defensiveTargetWasIntendedStep = false; } - void finishBreakout() { breakout = false; } private: bool travelling = false; - bool defenseAttempted = false; - bool breakoutAttempted = false; - bool breakout = false; + bool stalled = false; + bool defensiveTargetWasIntendedStep = false; + uint32_t defensiveBlockerId = 0; uint64_t goalId = 0; PlayerBotCyclePhase cyclePhase = PlayerBotCyclePhase::Idle; - std::chrono::steady_clock::time_point defenseDeadline{}; - std::chrono::steady_clock::time_point breakoutDeadline{}; + Position stalledPosition; + std::optional intendedMovementStep; }; #endif diff --git a/server/tests/playerbot_contracts.cpp b/server/tests/playerbot_contracts.cpp index 66cc5dc..6066121 100644 --- a/server/tests/playerbot_contracts.cpp +++ b/server/tests/playerbot_contracts.cpp @@ -8,6 +8,8 @@ #include "playerbotdepotworkflow.h" #include "playerboteconomy.h" #include "playerbotgoalplanner.h" +#include "playerbotcombatruntime.h" +#include "playerbotcombattarget.h" #include "playerbotnavigationruntime.h" #include "playerbottransitcombat.h" #include "playerbottopology.h" @@ -514,83 +516,103 @@ void transitCombat() assert(PlayerBotTransitCombat::required(Phase::Hunt, Stage::LootCorpse, true, false)); assert(PlayerBotTransitCombat::required(Phase::Hunt, Stage::Traverse, true, true)); assert(!PlayerBotTransitCombat::required(Phase::Hunt, Stage::Traverse, true, false)); + assert(PlayerBotTransitCombat::lootDeadlineRequiresRelease(true, true)); + assert(!PlayerBotTransitCombat::lootDeadlineRequiresRelease(true, false)); + assert(!PlayerBotTransitCombat::lootDeadlineRequiresRelease(false, true)); + + const Position stalled(100, 100, 7); + const Position intended(101, 100, 7); PlayerBotTransitCombat episode; - const auto start = std::chrono::steady_clock::time_point{}; - assert(episode.observe(true, 1, Phase::ReturnToDepot)); - episode.beginDefense(42, start); - for (int turn = 0; turn < 100; ++turn) { - assert(!episode.observe(true, 1, Phase::ReturnToDepot)); - assert(!episode.allowsDefense(42, true)); - assert(!episode.allowsDefense(43, true)); // the budget belongs to the episode, not the monster ID - for (uint32_t id = 43; id < 48; ++id) assert(!episode.allowsDefense(id, false)); - } - // Wall-clock expiry is independent of how many turns healing consumes. - assert(episode.defenseExpired(start + std::chrono::seconds(5))); - assert(episode.observe(true, 2, Phase::ReturnToDepot)); - assert(episode.allowsDefense(42, true)); - episode.beginDefense(42, start); - assert(episode.observe(true, 2, Phase::Service)); + assert(episode.observe(true, 1, Phase::Service)); + assert(!episode.movementFallbackRequired()); // planning and legitimate waits do not create evidence + episode.observePosition(Position(101, 100, 7)); + assert(!episode.movementFallbackRequired()); // ordinary movement does not create evidence either + assert(!episode.allowsDefense(42, true)); + episode.observeMovementFailure(stalled); // failed NPC/depot planning has no adjacent waypoint + assert(episode.movementFallbackRequired() && !episode.intendedStep()); assert(episode.allowsDefense(42, true)); - episode.beginDefense(42, start); - // NPC approach completes a coarse leg and then a local leg under the - // same goal/phase. Neither arrival completes the transit episode. - for (int approachLeg = 0; approachLeg < 2; ++approachLeg) { - assert(!episode.observe(true, 2, Phase::Service)); - assert(!episode.allowsDefense(42, true)); - assert(episode.defenseExpired(start + std::chrono::seconds(5 + approachLeg))); - } - // Semantic completion (the next phase), rather than a route-leg arrival, - // permits a fresh attempt on a subsequent transit episode. - assert(episode.observe(false, 2, Phase::Hunt)); - assert(episode.allowsDefense(42, false)); - - PlayerBotTransitCombat retreat; - const auto now = std::chrono::steady_clock::time_point{}; - assert(!retreat.active()); - assert(retreat.allowsDefense(42, false)); - retreat.begin(); - assert(retreat.active()); - // Adjacent attackers (including a crowd) never override escape without - // navigation's failed-detour evidence. Repeated turns cannot reacquire them. - for (int turn = 0; turn < 100; ++turn) { - for (uint32_t id = 42; id < 46; ++id) assert(!retreat.allowsDefense(id, false)); - } - assert(retreat.allowsDefense(42, true)); - retreat.beginDefense(42, now); - assert(!retreat.defenseExpired(now + std::chrono::seconds(4))); - assert(retreat.defenseExpired(now + std::chrono::seconds(5))); - assert(!retreat.allowsDefense(42, true)); - // Re-entering service must not renew a spent combat budget. - retreat.begin(); - assert(!retreat.allowsDefense(42, true)); - assert(retreat.defenseExpired(now + std::chrono::seconds(75))); - assert(!retreat.allowsDefense(43, true)); - - // Route exhaustion can spend a separate bounded escape window. It permits - // route-critical targets regardless of the planner's nominal success (a - // corridor plan can "succeed" straight through a blocked tile), exits on - // real progress, and never renews on replans. - assert(!retreat.beginBreakout(false, true, true, now + std::chrono::seconds(75))); - assert(!retreat.beginBreakout(true, false, true, now + std::chrono::seconds(75))); - assert(retreat.beginBreakout(true, true, false, now + std::chrono::seconds(75))); - assert(retreat.breakoutActive() && retreat.allowsDefense(43, true)); - assert(!retreat.beginBreakout(true, true, true, now + std::chrono::seconds(76))); - assert(!retreat.breakoutExpired(now + std::chrono::seconds(104))); - assert(retreat.breakoutExpired(now + std::chrono::seconds(105))); - assert(retreat.observeBreakoutNavigation(true, false)); - assert(!retreat.breakoutActive() && !retreat.allowsDefense(43, true)); - assert(!retreat.beginBreakout(true, true, true, now + std::chrono::seconds(110))); - assert(retreat.observe(true, 2, Phase::ReturnToDepot)); - assert(retreat.beginBreakout(true, true, true, now + std::chrono::seconds(110))); - assert(retreat.observeBreakoutNavigation(false, true)); - assert(!retreat.breakoutActive()); - retreat.finish(); - assert(!retreat.active()); - assert(!retreat.defenseExpired(now + std::chrono::hours(1))); - assert(retreat.allowsDefense(42, false)); - retreat.begin(); - assert(retreat.allowsDefense(42, true)); - assert(!retreat.allowsDefense(42, false)); + episode.beginDefense(42, false); + // No five- or sixty-second timer can cycle a live passage target. + assert(episode.retainsDefense(42, stalled, intended, true)); + assert(episode.retainsDefense(42, stalled, intended, true)); + assert(!playerBotDefensiveCombatTimeoutApplies(true, true)); + assert(playerBotDefensiveCombatTimeoutApplies(false, true)); + PlayerBotDefensiveTarget runtimeTarget; + runtimeTarget.id = 42; + runtimeTarget.position = intended; + runtimeTarget.name = "Frost Troll"; + runtimeTarget.routeCritical = true; + PlayerBotCombatTargetSnapshot liveTarget; + liveTarget.present = true; + assert(!playerBotDefensiveLifetimeCompletion(runtimeTarget, liveTarget)); + liveTarget.dead = true; + const auto deadCompletion = playerBotDefensiveLifetimeCompletion(runtimeTarget, liveTarget); + assert(deadCompletion && deadCompletion->command == PlayerBotCombatCommand::CompleteDefensiveCombat); + assert(std::string(deadCompletion->result) == "success" && std::string(deadCompletion->reason) == "target_defeated"); + liveTarget.dead = false; + liveTarget.removed = true; + assert(std::string(playerBotDefensiveLifetimeCompletion(runtimeTarget, liveTarget)->reason) == "target_defeated"); + assert(!episode.allowsDefense(43, true)); + assert(!episode.retainsDefense(42, stalled, intended, false)); // live safety release + episode.clearFallback(); + assert(!episode.movementFallbackRequired() && !episode.allowsDefense(42, true)); + + episode.observeMovementFailure(stalled, intended); + episode.beginDefense(42, true); + assert(episode.retainsDefense(42, stalled, intended, true)); + assert(!episode.retainsDefense(42, stalled, Position(100, 101, 7), true)); // intended blocker moved + episode.clearFallback(); + episode.observeMovementFailure(stalled, intended); + episode.beginDefense(43, false); + assert(episode.retainsDefense(43, stalled, Position(100, 101, 7), true)); // alternate target is retained + assert(!episode.allowsDefense(42, true)); + episode.clearFallback(); + episode.observeMovementFailure(stalled); + episode.observePosition(Position(100, 101, 7)); + assert(!episode.movementFallbackRequired()); // actual progress clears the stall + episode.observeMovementFailure(stalled, intended); + episode.observeViableMovement(); + assert(!episode.movementFallbackRequired()); // a usable first step resumes navigation + episode.observeMovementFailure(stalled); + episode.beginDefense(42, false); + episode.clearFallback(); + assert(!episode.movementFallbackRequired()); // target death completes the fallback + + // Goal abandonment is an explicit interruption; replans within a goal are not. + episode.observeMovementFailure(stalled); + assert(!episode.observe(true, 1, Phase::Service)); + assert(episode.movementFallbackRequired()); + assert(episode.observe(true, 2, Phase::ReturnToDepot)); + assert(!episode.movementFallbackRequired()); + + // The same fallback contract also applies to active hunt patrol movement. + assert(episode.observe(false, 3, Phase::Hunt)); + episode.observeMovementFailure(stalled, intended); + assert(episode.movementFallbackRequired() && episode.allowsDefense(42, true)); + assert(episode.allowsDefense(42, false)); // ordinary hunt defense remains available + + // Four attackers are accepted when their real aggregate estimate is survivable. + assert(playerBotPassageFightManageable(500, 0, 20.0, 20.0)); + assert(!playerBotPassageFightManageable(400, 0, 20.0, 20.0)); + assert(playerBotPassageFightManageable(350, 100, 20.0, 20.0)); + + PlayerBotDefensiveTarget easy; + easy.id = 10; + easy.position = Position(99, 100, 7); + easy.routeCritical = true; + easy.predictedFightDamage = 100; + easy.predictedFightSeconds = 5; + PlayerBotDefensiveTarget intendedAttacker = easy; + intendedAttacker.id = 20; + intendedAttacker.position = intended; + intendedAttacker.intendedStep = true; + intendedAttacker.predictedFightDamage = 200; + assert(playerBotPreferDefensiveTarget(intendedAttacker, easy, stalled)); + intendedAttacker.intendedStep = false; + assert(playerBotPreferDefensiveTarget(easy, intendedAttacker, stalled)); + + episode.finish(); + assert(!episode.active() && !episode.movementFallbackRequired()); PlayerBotTurnRouter router; assert(router.route({}) == PlayerBotTurnCommand::ReturnToDepot);