From c415203a557aace6bcde58675394442fa7500ae7 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 2 Aug 2026 19:08:54 +0200 Subject: [PATCH] server: pursue targets from last-seen positions --- docs/playerbots.md | 62 ++++++++---- docs/testing.md | 12 ++- scripts/test-playerbot-gameplay.ps1 | 92 ++++++++++++++++- server/src/playerbotcombat.cpp | 98 ++++++++++++++++++- server/src/playerbotcontroller.cpp | 2 + server/src/playerbotcontroller.h | 11 +++ .../playerbot-gameplay/playerbot_gameplay.lua | 22 ++++- 7 files changed, 274 insertions(+), 25 deletions(-) diff --git a/docs/playerbots.md b/docs/playerbots.md index f031027..be3aba8 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -19,9 +19,10 @@ development account `bot-one`, character `Bot One`, and `player_bots` registry. Provisioning is idempotent: it does not restore spent money, replace occupied equipment slots, or take over an unrelated same-named or deleted character. -A new Bot One starts at level 1 with the development hunt skills, starter -equipment, backpack, rope, shovel, 100 gp bank balance, and two 100-gp backpack -stacks. Global `freePremium` supplies premium access. +A new Bot One starts as a level 8 Knight at the Thais temple with development +hunt skills, plate equipment, sword, shield, backpack, rope, shovel, five small +health potions, one meat, 100 gp bank balance, and two 100-gp backpack stacks. +Global `freePremium` supplies premium access. Inspect setup and startup with: @@ -60,16 +61,18 @@ interrupted families cool down for 60 seconds. Callers provide destinations, not ordered transition checkpoints. The bounded navigator searches loaded map state within a 192-tile margin around its endpoints and stops after 100,000 expanded nodes. It supports ordinary floor -changes, configured ladders, rope and shovel holes, direct-use holes, simple -teleports, and unlocked doors. Cardinal movement costs 10; diagonal movement -costs 30. Failed steps are excluded for 10 seconds. Repeated A-B oscillation +changes, configured ladders, rope and shovel holes, direct-use holes, and +unlocked doors. Arbitrary map teleports are not navigation edges. Cardinal +movement costs 10; diagonal movement costs 30. Failed steps are excluded for 10 +seconds. Repeated A-B oscillation suppresses the implicated transition for two minutes. -Hunt regions come from all loaded hostile spawns on floors 6 through 15; there -is no player-distance or fixture-radius cap. The shared cache groups overlapping -eight-tile spawn kernels. Each controller scores candidates in bounded batches -using its health, equipment, weapon, defense, skill, cooldowns, and observed -performance. It then validates every suitable candidate incrementally through +Hunt regions come from all loaded hostile spawns on floors 6 through 15. The +shared cache groups overlapping eight-tile spawn kernels through spatial +buckets. Each controller considers regions within 200 weighted tiles of its +town temple and 300 weighted tiles of its current position, then scores them in +bounded batches using health, equipment, weapon, defense, skill, cooldowns, and +observed performance. It validates suitable candidates incrementally through the navigator and selects the highest route-adjusted score. Navigation remains behind the destination/reachability interface so a later navigator can replace tile planning without changing hunt selection. @@ -81,9 +84,20 @@ minutes. Completed hunts update a per-controller XP correction after at least 30 seconds and one kill; the sample is clamped to `0.25` through `2.0` and uses a 65/35 rolling blend. This state resets on relog or restart. -The planner is a bounded Rookgaard prototype, not whole-map navigation. -Regression fixtures use fixed destinations and do not validate dynamic region -selection. +When an attacked monster leaves normal positional or creature visibility, or +the attack association is lost, the bot clears chase and pursues a reachable +tile adjacent to its last observed position. It never updates this goal from a +hidden creature's live state. +Visible targets may update the approach goal and can be reacquired within six +tiles. Pursuit lasts at most five seconds and six tiles of Chebyshev displacement. +Reaching the approach point or exhausting either budget returns to patrol and +suppresses that target for ten seconds. A 60-second traversal combat timeout +retains its separate 120-second suppression. + +The map-derived region planner and bounded pursuit are prototypes, not +whole-map hierarchical navigation or general creature memory. Regression +fixtures use fixed destinations unless their focused mode explicitly exercises +dynamic planning or pursuit. ## Survival, service, and loot @@ -94,17 +108,18 @@ fullness limit. Service NPCs require an exact `playerbot_service` XML tag of `shop`, `banker`, or `oracle`. Shops publish their loaded offers to the bot; untagged shops and -tagged shops without offers are ignored. Reachable providers remain explicitly -allowlisted while navigation is bounded. The bot greets the selected NPC, +tagged shops without offers are ignored. Providers must remain within 200 +weighted tiles of the registered town temple. The bot greets the selected NPC, treats a private reply as focus acknowledgement, and opens the normal trade window. Reply text is not interpreted. The service cycle sells known surplus, restores five small health potions and one meat, deposits carried money, and withdraws 100 gp. Hunting ends after the configured duration or below 30 oz free capacity. Remaining top-level backpack -loot is dropped at the fake depot south of `(32105, 32195, 8)`. This is a public -world tile, not private or durable storage; equipped items, the root backpack, -currency, rope, shovel, and supply reserves are retained. +loot is moved through a reachable town depot locker into that player's real +depot chest. Nested containers are opened and deposits are verified through +normal item movement. Equipped items, the root backpack, currency, rope, +shovel, and supply reserves are retained. Item value comes from tagged shop offers. Currency uses intrinsic value; other loot must have a known buyer. Corpse contents are ranked by value per weight, @@ -134,7 +149,7 @@ next decision after restart. An unpromoted level 8 through 10 player can select the live tagged Oracle. The bot derives a route, says `hi`, `yes`, `thais`, `knight`, `yes`, then verifies vocation `4`, town `2`, and the registered Thais temple position. It remains -server-owned but stops because mainland behavior is not implemented. +server-owned and continues directly into mainland service. ## Recovery and configuration @@ -186,6 +201,13 @@ target changes, and action failures are emitted at most once per 60 seconds; `summary.suppressed_events` counts omissions. Counters cover one in-memory controller lifetime. Docker retains three 10 MiB server log files. +Target pursuit uses `action_result` with `action="target_pursuit"`. +`result="started"` includes `target_id` and `last_seen_position`; +`result="reacquired"` includes `target_id`; and `result="abandoned"` includes +`target_id` plus `reason`. Current abandonment reasons are +`last_seen_position_reached` and `pursuit_budget_exhausted`. +`state_transition` exposes entry to and exit from `target_pursuit`. + When `GOD Admin` is online, selected objectives and verified transactions also appear as private messages and orange status text. JSONL remains authoritative. diff --git a/docs/testing.md b/docs/testing.md index a3b774d..b0b6e05 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -31,12 +31,13 @@ pwsh -File scripts/test-playerbot-gameplay.ps1 ``` The baseline covers NPC discovery, sale and reserve purchase, bank deposit and -withdrawal, fake-depot handling, and return to hunting. Add switches that match +withdrawal, fixture-depot handling, and return to hunting. Add switches that match the changed behavior: | Switch | Coverage | | ------ | -------- | | `-FullNavigation` | Complete fixed A-B-C-B-A fixture route and temporary blockage recovery. | +| `-TargetPursuit` | Visible-only last-seen pursuit, bounded reacquisition, and out-of-budget abandonment. | | `-CorpseLoot` | Non-corpse, empty, guaranteed-loot, and container death items; open-before-inspect ordering. | | `-DeathTelemetry` | Death context, exponential relog, fresh controller state, abandonment, and removal. | | `-Healing` | Potion verification, threshold recovery, missing-stock service, purchase, and resume behavior. | @@ -44,13 +45,22 @@ the changed behavior: | `-PickupProgression` | Nested and multi-root reward inspection, claim verification, upgrades, restart recovery, and space rejection. | | `-GoalArbitration` | Pickup, service, hunt, and critical-healing precedence across safe boundaries. | | `-OracleDeparture` | Tagged Oracle route, dialogue, vocation/town/position verification, and restart persistence. | +| `-StaminaProjection` | Premium bonus, low-stamina penalty, and ordinary stamina projections. | +| `-HuntRegionPlanning` | Cached scanner batching, threat rejection, reachability, cooldowns, and observed correction. | +| `-CombatReadiness` | Equipment, supplies, capacity, service recovery, upgrades, and restart reconstruction. | +| `-Depot` | Real locker/chest discovery, nested deposits, move verification, retries, and restart checkpoints. | +| `-MainlandLoop` | Two real Thais hunt/depot cycles, local services, restart recovery, and teleport exclusion. | Navigation or looting changes require at least: ```powershell pwsh -File scripts/test-playerbot-gameplay.ps1 -FullNavigation -CorpseLoot +pwsh -File scripts/test-playerbot-gameplay.ps1 -TargetPursuit -Focused ``` +`-TargetPursuit` runs successful `target_pursuit` reacquisition and bounded +`target_pursuit_abandon` fallback scenarios. + Use `-Focused` with one or more scenario switches to skip the baseline. Use `-SkipBuild` only with a known-current `angelion-server:latest` image; it does not prove that the image matches the worktree. `-KeepStack` preserves the final diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index 51be6d2..94bb11a 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -2,6 +2,7 @@ param( [ValidateRange(30, 3600)] [int]$TimeoutSeconds = 300, [switch]$FullNavigation, + [switch]$TargetPursuit, [switch]$CorpseLoot, [switch]$DeathTelemetry, [switch]$Healing, @@ -977,6 +978,73 @@ function Assert-NavigationEvents { } } +function Assert-TargetPursuitEvents { + param([string]$Logs) + + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $started = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "target_pursuit" -and $_.result -eq "started" + }) + $reacquired = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "target_pursuit" -and $_.result -eq "reacquired" + }) + $defeated = @($events | Where-Object { + $_.event -eq "target_changed" -and $_.reason -eq "target_defeated" + }) + $terminal = @($events | Where-Object { $_.event -eq "terminal" }) + $firstPlan = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "plan" -and $_.result -eq "success" + }) | Select-Object -First 1 + $lastSeenPlanDistance = $started.Count -eq 1 -and $firstPlan.Count -eq 1 ? + [Math]::Max([Math]::Abs($started[0].last_seen_position.x - $firstPlan[0].destination.x), + [Math]::Abs($started[0].last_seen_position.y - $firstPlan[0].destination.y)) : 99 + $distance = $started.Count -eq 1 -and $reacquired.Count -eq 1 ? + [Math]::Max([Math]::Abs($started[0].position.x - $reacquired[0].position.x), + [Math]::Abs($started[0].position.y - $reacquired[0].position.y)) : 0 + if ($started.Count -ne 1 -or $reacquired.Count -ne 1 -or $defeated.Count -lt 1 -or + $started[0].target_id -ne $reacquired[0].target_id -or + $reacquired[0].target_id -ne $defeated[0].previous_target_id -or + $distance -lt 1 -or $distance -gt 6 -or $lastSeenPlanDistance -gt 1 -or $terminal.Count -ne 0) { + throw "Target pursuit failed. started=$($started.Count), reacquired=$($reacquired.Count), defeated=$($defeated.Count), distance=$distance, lastSeenPlanDistance=$lastSeenPlanDistance, terminal=$($terminal.Count)." + } +} + +function Assert-TargetPursuitAbandonEvents { + param([string]$Logs) + + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $started = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "target_pursuit" -and $_.result -eq "started" + }) + $abandoned = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "target_pursuit" -and $_.result -eq "abandoned" -and + $_.reason -in @("last_seen_position_reached", "pursuit_budget_exhausted") + }) + $reacquired = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "target_pursuit" -and $_.result -eq "reacquired" + }) + $routeUnavailable = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "navigate" -and $_.result -eq "failed" -and + $_.reason -eq "route_unavailable" + }) + $terminal = @($events | Where-Object { $_.event -eq "terminal" }) + $firstPlan = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "plan" -and $_.result -eq "success" + }) | Select-Object -First 1 + $lastSeenPlanDistance = $started.Count -eq 1 -and $firstPlan.Count -eq 1 ? + [Math]::Max([Math]::Abs($started[0].last_seen_position.x - $firstPlan[0].destination.x), + [Math]::Abs($started[0].last_seen_position.y - $firstPlan[0].destination.y)) : 99 + $distance = $started.Count -eq 1 -and $abandoned.Count -eq 1 ? + [Math]::Max([Math]::Abs($started[0].position.x - $abandoned[0].position.x), + [Math]::Abs($started[0].position.y - $abandoned[0].position.y)) : 0 + if ($started.Count -ne 1 -or $abandoned.Count -ne 1 -or + $started[0].target_id -ne $abandoned[0].target_id -or $distance -gt 6 -or + ($distance -eq 0 -and $routeUnavailable.Count -lt 1) -or $lastSeenPlanDistance -gt 1 -or + $reacquired.Count -ne 0 -or $terminal.Count -ne 0) { + throw "Target pursuit fallback failed. started=$($started.Count), abandoned=$($abandoned.Count), distance=$distance, routeUnavailable=$($routeUnavailable.Count), lastSeenPlanDistance=$lastSeenPlanDistance, reacquired=$($reacquired.Count), terminal=$($terminal.Count)." + } +} + function Assert-CorpseEvents { param([string]$Logs) @@ -1144,7 +1212,7 @@ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw "Docker is required to run the playerbot gameplay suite." } -$focusedScenarioRequested = $FullNavigation -or $CorpseLoot -or $DeathTelemetry -or $Healing -or $ValueLoot -or +$focusedScenarioRequested = $FullNavigation -or $TargetPursuit -or $CorpseLoot -or $DeathTelemetry -or $Healing -or $ValueLoot -or $PickupProgression -or $GoalArbitration -or $OracleDeparture -or $StaminaProjection -or $HuntRegionPlanning -or $CombatReadiness -or $Depot -or $MainlandLoop if ($Focused -and -not $focusedScenarioRequested) { @@ -1542,6 +1610,28 @@ try { } } + if ($TargetPursuit) { + Invoke-Scenario -Name "target_pursuit" -DefaultTimeoutSeconds 60 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "target_pursuit" + $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST TARGET_PURSUIT_HIDDEN' | Out-Null + Wait-ForLog -Pattern '"action":"target_pursuit","result":"reacquired"' | Out-Null + $pursuitLogs = Wait-ForLog -Pattern '"reason":"target_defeated"' + Assert-TargetPursuitEvents -Logs $pursuitLogs + } + Invoke-Scenario -Name "target_pursuit_abandon" -DefaultTimeoutSeconds 60 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "target_pursuit_abandon" + $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST TARGET_PURSUIT_HIDDEN' | Out-Null + $pursuitLogs = Wait-ForLog -Pattern '"action":"target_pursuit","result":"abandoned"' + Assert-TargetPursuitAbandonEvents -Logs $pursuitLogs + } + } + if ($CorpseLoot) { Invoke-Scenario -Name "corpse" -DefaultTimeoutSeconds 60 -Body { Invoke-Compose down --volumes --remove-orphans diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index 433cdb0..e5e4286 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -16,6 +16,19 @@ using namespace playerbot; namespace { + Position nearestTargetApproach(Player& player, const Position& currentPosition, const Position& targetPosition) + { + std::vector route; + if (!player.getPathTo(targetPosition, route, 1, 1, true, true, 32)) { + return targetPosition; + } + Position destination = currentPosition; + for (Direction direction : route) { + destination = getNextPosition(direction, destination); + } + return destination; + } + double projectedHuntStaminaMultiplier(const Player& player, double availableHuntSeconds) { const uint16_t staminaMinutes = player.getStaminaMinutes(); @@ -373,13 +386,90 @@ void PlayerBotController::finishTraversalCombat(Player* player, const Position& setStage(ScenarioStage::Traverse, currentPosition); } +void PlayerBotController::beginTargetPursuit(Player* player, const Position& currentPosition) +{ + g_game.playerSetAttackedCreature(playerId, 0); + clearNavigation(); + targetPursuitStarted = std::chrono::steady_clock::now(); + targetPursuitStartPosition = currentPosition; + targetPursuitDestination = nearestTargetApproach(*player, currentPosition, ratPosition); + setStage(ScenarioStage::TargetPursuit, currentPosition); + emit("action_result", currentPosition, + "\"action\":\"target_pursuit\",\"result\":\"started\",\"target_id\":" + + std::to_string(ratId) + ",\"last_seen_position\":{\"x\":" + std::to_string(ratPosition.x) + + ",\"y\":" + std::to_string(ratPosition.y) + ",\"z\":" + std::to_string(ratPosition.z) + '}'); +} + +void PlayerBotController::finishTargetPursuit(const Position& currentPosition, const char* reason) +{ + const uint32_t previousTargetId = ratId; + if (previousTargetId != 0) { + suppressedTraversalTargets[previousTargetId] = std::chrono::steady_clock::now() + lostTargetSuppression; + } + clearNavigation(); + clearRatTarget(currentPosition, reason); + setStage(ScenarioStage::Traverse, currentPosition); + emit("action_result", currentPosition, + "\"action\":\"target_pursuit\",\"result\":\"abandoned\",\"target_id\":" + + std::to_string(previousTargetId) + ",\"reason\":" + jsonString(reason)); +} + +void PlayerBotController::processTargetPursuit(Player* player, const Position& currentPosition) +{ + const auto now = std::chrono::steady_clock::now(); + const uint32_t pursuitDistance = std::max(Position::getDistanceX(targetPursuitStartPosition, currentPosition), + Position::getDistanceY(targetPursuitStartPosition, currentPosition)); + if (now - targetPursuitStarted >= lostTargetPursuitTimeout || + pursuitDistance > maximumLostTargetPursuitDistance) { + finishTargetPursuit(currentPosition, "pursuit_budget_exhausted"); + schedule(navigationInterval); + return; + } + + Creature* target = g_game.getCreatureByID(ratId); + if (!target || target->isRemoved() || target->isDead() || !player->canSee(target->getPosition()) || + !player->canSeeCreature(target)) { + target = nullptr; + } else { + ratPosition = target->getPosition(); + const uint32_t targetDistance = std::max(Position::getDistanceX(currentPosition, ratPosition), + Position::getDistanceY(currentPosition, ratPosition)); + if (targetDistance > maximumTargetReacquisitionDistance) { + targetPursuitDestination = nearestTargetApproach(*player, currentPosition, ratPosition); + target = nullptr; + } + } + if (target) { + ++counters.actionsAttempted; + g_game.playerSetFightModes(playerId, FIGHTMODE_ATTACK, true, false); + g_game.playerSetAttackedCreature(playerId, target->getID()); + if (player->getAttackedCreature() == target) { + combatStarted = std::chrono::steady_clock::now(); + clearNavigation(); + setStage(ScenarioStage::TraversalCombat, currentPosition); + emit("action_result", currentPosition, + "\"action\":\"target_pursuit\",\"result\":\"reacquired\",\"target_id\":" + + std::to_string(target->getID())); + schedule(navigationInterval); + return; + } + } + + if (currentPosition == targetPursuitDestination || + processNavigation(player, currentPosition, targetPursuitDestination)) { + finishTargetPursuit(currentPosition, "last_seen_position_reached"); + schedule(navigationInterval); + } +} + void PlayerBotController::processTraversalCombat(Player* player, const Position& currentPosition) { Creature* target = g_game.getCreatureByID(ratId); if (!target || target->isRemoved() || target->isDead()) { beginLoot(player, currentPosition); - } else if (!player->canSee(target->getPosition()) || player->getAttackedCreature() != target) { - finishTraversalCombat(player, currentPosition, "target_lost"); + } else if (!player->canSee(target->getPosition()) || !player->canSeeCreature(target) || + player->getAttackedCreature() != target) { + beginTargetPursuit(player, currentPosition); } else if (std::chrono::steady_clock::now() - combatStarted >= traversalCombatTimeout) { logActionFailure("attack", "combat_timeout", currentPosition); suppressedTraversalTargets[target->getID()] = std::chrono::steady_clock::now() + traversalTargetSuppression; @@ -871,6 +961,10 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre processTraversalCombat(player, currentPosition); return; } + if (scenarioStage == ScenarioStage::TargetPursuit) { + processTargetPursuit(player, currentPosition); + return; + } if (attackVisibleMonster(player, currentPosition)) { schedule(navigationInterval); return; diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index c93576e..93e47e1 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -33,6 +33,7 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment() std::strcmp(gameplayMode, "departure_recovery") == 0); const bool startInHunt = gameplayMode && (std::strcmp(gameplayMode, "navigation") == 0 || std::strcmp(gameplayMode, "corpse") == 0 || + (std::strcmp(gameplayMode, "target_pursuit") == 0 || std::strcmp(gameplayMode, "target_pursuit_abandon") == 0) || std::strcmp(gameplayMode, "healing") == 0 || std::strcmp(gameplayMode, "healing_resupply") == 0 || std::strcmp(gameplayMode, "value") == 0 || std::strcmp(gameplayMode, "departure_interrupt") == 0 || std::strcmp(gameplayMode, "stamina_bonus") == 0 || std::strcmp(gameplayMode, "stamina_boundary") == 0 || @@ -133,6 +134,7 @@ const char* PlayerBotController::stageName(ScenarioStage stage) case ScenarioStage::LootCorpse: return "loot_corpse"; case ScenarioStage::Traverse: return "traverse"; case ScenarioStage::TraversalCombat: return "traversal_combat"; + case ScenarioStage::TargetPursuit: return "target_pursuit"; case ScenarioStage::Stopped: return "stopped"; } return "unknown"; diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index fddc426..aea10d5 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -64,6 +64,10 @@ namespace playerbot { inline constexpr uint16_t ropeItemId = 2120; inline constexpr std::chrono::seconds traversalCombatTimeout(60); inline constexpr std::chrono::seconds traversalTargetSuppression(120); + inline constexpr std::chrono::seconds lostTargetPursuitTimeout(5); + inline constexpr std::chrono::seconds lostTargetSuppression(10); + inline constexpr uint32_t maximumLostTargetPursuitDistance = 6; + inline constexpr uint32_t maximumTargetReacquisitionDistance = 6; inline constexpr std::chrono::seconds navigationBlockSuppression(10); inline constexpr std::chrono::minutes navigationOscillationSuppression(2); inline constexpr std::chrono::seconds navigationStepTimeout(2); @@ -315,6 +319,7 @@ class PlayerBotController : public std::enable_shared_from_this evaluateEquipmentUpgrade(const Player& player, const Item& candidate) const; @@ -716,6 +724,9 @@ class PlayerBotController : public std::enable_shared_from_this suppressedTraversalTargets; CyclePhase cyclePhase = CyclePhase::ReturnToDepot; ServiceStage serviceStage = ServiceStage::Discover; diff --git a/server/tests/playerbot-gameplay/playerbot_gameplay.lua b/server/tests/playerbot-gameplay/playerbot_gameplay.lua index 9404c11..3a01dec 100644 --- a/server/tests/playerbot-gameplay/playerbot_gameplay.lua +++ b/server/tests/playerbot-gameplay/playerbot_gameplay.lua @@ -132,6 +132,17 @@ local function activateDefensiveMonster(playerId, monsterId) assert(monster and monster:selectTarget(player), "defensive blocker could not target Bot One") end +local function hidePursuitMonster(playerId, monsterId, distance) + local player = Player(playerId) + local monster = Monster(monsterId) + assert(player and not player:isRemoved(), "Bot One disappeared before target pursuit") + assert(monster and not monster:isRemoved(), "target pursuit monster disappeared before target loss") + local position = player:getPosition() + assert(monster:teleportTo(Position(position.x + distance, position.y, position.z), true), + "target pursuit fixture could not hide the monster") + print("PLAYERBOT_GAMEPLAY_TEST TARGET_PURSUIT_HIDDEN " .. monsterId) +end + local function spawnDefensiveMonsters(playerId) local player = Player(playerId) assert(player and not player:isRemoved(), "Bot One disappeared before defensive combat test spawn") @@ -421,7 +432,7 @@ function login.onLogin(player) end local mode = os.getenv("PLAYERBOT_GAMEPLAY_MODE") or "cycle" - assert(mode == "mainland" or mode == "cycle" or mode == "depot" or mode == "navigation" or mode == "corpse" or mode == "death" or mode == "healing" or + assert(mode == "mainland" or mode == "cycle" or mode == "depot" or mode == "navigation" or mode == "target_pursuit" or mode == "target_pursuit_abandon" or mode == "corpse" or mode == "death" or mode == "healing" or mode == "healing_resupply" or mode == "value" or mode == "progression" or mode == "progression_bundle" or mode == "progression_nested" or mode == "progression_resume" or mode == "progression_nested_resume" or mode == "progression_space" or @@ -671,6 +682,15 @@ function login.onLogin(player) end assert(player:teleportTo(depotPosition), "fake depot position could not be restored") + if mode == "target_pursuit" or mode == "target_pursuit_abandon" then + suppressNearbyMonsters(player:getId()) + local position = player:getPosition() + local monster = Game.createMonster(emptyMonsterName, Position(position.x + 4, position.y, position.z), true, true) + assert(monster, "target pursuit fixture could not create a visible monster") + addEvent(hidePursuitMonster, 1100, player:getId(), monster:getId(), mode == "target_pursuit" and 12 or 30) + print("PLAYERBOT_GAMEPLAY_TEST TARGET_PURSUIT_START " .. monster:getId()) + return true + end if mode == "navigation" then createTemporaryBlockers() suppressNearbyMonsters(player:getId())