diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index 6e07749..1914910 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -242,7 +242,7 @@ function Assert-CycleEvents { } function Assert-OracleDepartureEvents { - param([string]$Logs, [switch]$Restart) + param([string]$Logs, [switch]$Restart, [switch]$InterruptedByRestart) $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) if ($Restart) { @@ -273,9 +273,71 @@ function Assert-OracleDepartureEvents { $stopped = @($events | Where-Object { $_.event -eq "state_transition" -and $_.to -eq "stopped" }) + $wrongStoppedCount = if ($InterruptedByRestart) { $stopped.Count -lt 1 } else { $stopped.Count -ne 1 } if ($candidate.Count -lt 1 -or $selection.Count -lt 1 -or $result.Count -ne 1 -or - $goalResult.Count -ne 1 -or $stopped.Count -ne 1) { - throw "The bot did not complete and verify the selected Oracle departure." + $goalResult.Count -ne 1 -or $wrongStoppedCount) { + throw "The bot did not complete and verify the selected Oracle departure: candidate=$($candidate.Count), selection=$($selection.Count), result=$($result.Count), goalResult=$($goalResult.Count), stopped=$($stopped.Count)." + } +} + +function Assert-OracleLevelEightInterruptEvents { + param([string]$Logs, [switch]$Recovery) + + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $selection = @($events | Where-Object { + $_.event -eq "goal_selection" -and $_.to_goal -eq "oracle_departure" -and $_.forced -eq $true -and + $_.level -eq 8 -and $_.player_vocation_id -eq 0 + }) + $selectionIndex = -1 + for ($index = 0; $index -lt $events.Count; $index++) { + if ($events[$index].event -eq "goal_selection" -and $events[$index].to_goal -eq "oracle_departure" -and + $events[$index].forced -eq $true) { + $selectionIndex = $index + break + } + } + $postInterrupt = if ($selectionIndex -ge 0 -and $selectionIndex + 1 -lt $events.Count) { + @($events[($selectionIndex + 1)..($events.Count - 1)]) + } else { + @() + } + $huntAfterInterrupt = @($postInterrupt | Where-Object { + ($_.event -eq "goal_selection" -and $_.to_goal -eq "hunt") -or + ($_.event -eq "action_result" -and $_.action -in @("hunt_cycle", "hunt_waypoint", "loot")) -or + ($_.event -eq "target_changed" -and $_.reason -eq "visible_monster") + }) + if ($Recovery) { + $restored = @($events | Where-Object { + $_.event -eq "lifecycle" -and $_.status -eq "online" -and $_.objective -eq "oracle_departure" + }) + $healed = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "heal" -and $_.result -eq "success" + }) + if ($restored.Count -lt 1 -or $selection.Count -lt 1 -or $healed.Count -lt 1 -or + $huntAfterInterrupt.Count -ne 0) { + throw "The restored level-8 player did not remain committed to Oracle departure." + } + return + } + + $combat = @($events | Where-Object { + $_.event -eq "target_changed" -and $_.target_name -eq "Playerbot Level Eight Target" + }) + $interruptedHunt = @($events | Where-Object { + $_.event -eq "goal_result" -and $_.goal -eq "hunt" -and $_.result -eq "interrupted" -and + $_.reason -eq "level_eight_interrupt" + }) + $loot = @($events | Where-Object { $_.event -eq "action_result" -and $_.action -eq "loot" }) + $defensiveStart = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "defensive_combat" -and $_.result -eq "started" + }) + $defensiveComplete = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "defensive_combat" -and $_.result -eq "success" + }) + if ($combat.Count -lt 1 -or $interruptedHunt.Count -ne 1 -or $selection.Count -ne 1 -or + $loot.Count -ne 0 -or $defensiveStart.Count -lt 1 -or $defensiveComplete.Count -lt 1 -or + $huntAfterInterrupt.Count -ne 0) { + throw "Level 8 did not interrupt combat, looting, and hunting for Oracle departure." } } @@ -967,6 +1029,31 @@ try { $restartLogs = Wait-ForLog -Pattern '"objective":"departure_complete"' Assert-OracleDepartureEvents -Logs $restartLogs -Restart } + + Invoke-Scenario -Name "oracle_level_eight_interrupt" -DefaultTimeoutSeconds 180 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "departure_interrupt" + $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST ORACLE_LEVEL_EIGHT_INTERRUPT_START' | Out-Null + $interruptLogs = Wait-ForLog -Pattern '"action":"oracle_departure","result":"success"' + Assert-OracleDepartureEvents -Logs $interruptLogs + Assert-OracleLevelEightInterruptEvents -Logs $interruptLogs + } + + Invoke-Scenario -Name "oracle_level_eight_recovery" -DefaultTimeoutSeconds 180 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "departure_recovery" + $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST ORACLE_LEVEL_EIGHT_RECOVERY_PREPARED' | Out-Null + Invoke-Compose stop server + Invoke-Compose up --detach server + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST ORACLE_LEVEL_EIGHT_RECOVERY_START' | Out-Null + $recoveryLogs = Wait-ForLog -Pattern '"action":"oracle_departure","result":"success"' + Assert-OracleDepartureEvents -Logs $recoveryLogs -InterruptedByRestart + Assert-OracleLevelEightInterruptEvents -Logs $recoveryLogs -Recovery + } } if ($FullNavigation) { diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index 3c47d64..6bbf22a 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -566,7 +566,7 @@ void PlayerBotController::startHunt(Player* player, const Position& position, co void PlayerBotController::processTraversal(Player* player, const Position& currentPosition) { - if (cyclePhase != CyclePhase::Hunt) { + if (cyclePhase != CyclePhase::Hunt || progressionObjective == ProgressionObjective::OracleDeparture) { if (defensiveTargetId != 0) { processDefensiveCombat(player, currentPosition); return; diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index 94b1bf0..6271502 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -29,11 +29,12 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment() std::strcmp(gameplayMode, "progression_space") == 0 || std::strcmp(gameplayMode, "arbitration") == 0 || std::strcmp(gameplayMode, "arbitration_interrupt") == 0 || - std::strcmp(gameplayMode, "departure") == 0); + std::strcmp(gameplayMode, "departure") == 0 || + std::strcmp(gameplayMode, "departure_recovery") == 0); const bool startInHunt = gameplayMode && (std::strcmp(gameplayMode, "navigation") == 0 || std::strcmp(gameplayMode, "corpse") == 0 || std::strcmp(gameplayMode, "healing") == 0 || std::strcmp(gameplayMode, "healing_resupply") == 0 || - std::strcmp(gameplayMode, "value") == 0); + std::strcmp(gameplayMode, "value") == 0 || std::strcmp(gameplayMode, "departure_interrupt") == 0); return PlayerBotTestPolicy{ !regressionMode && (!gameplayMode || progressionMode), startInHunt, @@ -57,7 +58,8 @@ void PlayerBotController::start(const Position& position, bool recovered, uint32 const bool startInHunt = !recovered && testPolicy.startInHunt; Player* controlledPlayer = g_game.getPlayerByID(playerId); const bool departureComplete = controlledPlayer && hasCompletedRookgaardDeparture(*controlledPlayer); - const bool useGoalSelector = !recovered && testPolicy.progressionEnabled && controlledPlayer; + const bool departureRequired = controlledPlayer && requiresRookgaardDeparture(*controlledPlayer); + const bool useGoalSelector = controlledPlayer && (departureRequired || (!recovered && testPolicy.progressionEnabled)); if (useGoalSelector && !departureComplete && !selectTopLevelGoal(*controlledPlayer, position, "startup")) { return; } @@ -649,6 +651,14 @@ void PlayerBotController::navigate() schedule(blockedRouteRetryInterval); return; } + const bool waitingForRecovery = cyclePhase == CyclePhase::Service && needsHealing(*player); + if (!accessingReward && progressionObjective != ProgressionObjective::OracleDeparture && + requiresRookgaardDeparture(*player) && !waitingForRecovery) { + if (selectTopLevelGoal(*player, currentPosition, "level_eight_interrupt")) { + schedule(SCHEDULER_MINTICKS); + } + return; + } if (!accessingReward && !verifyingDeparture && handleFood(player, currentPosition)) { schedule(blockedRouteRetryInterval); return; diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index 71c97c3..272c91d 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -441,9 +441,11 @@ class PlayerBotController : public std::enable_shared_from_this& rewardSteps); bool hasCompletedRookgaardDeparture(const Player& player) const; + bool requiresRookgaardDeparture(const Player& player) const; bool findOracleDeparture(Player& player, const Position& position, DeparturePlan& plan, std::deque& departureSteps); + bool forceOracleDeparture(Player& player, const Position& position, const char* decisionReason); void beginOracleDeparture(Player& player, const Position& position, DeparturePlan plan, std::deque departureSteps); diff --git a/server/src/playerbotdeparture.cpp b/server/src/playerbotdeparture.cpp index b56f325..191835a 100644 --- a/server/src/playerbotdeparture.cpp +++ b/server/src/playerbotdeparture.cpp @@ -20,6 +20,11 @@ bool PlayerBotController::hasCompletedRookgaardDeparture(const Player& player) c return player.getVocation()->getId() != 0 && player.getTown() && player.getTown()->getID() != rookgaardTownId; } +bool PlayerBotController::requiresRookgaardDeparture(const Player& player) const +{ + return player.getVocation()->getId() == 0 && player.getLevel() >= oracleMinimumLevel; +} + bool PlayerBotController::findOracleDeparture(Player& player, const Position& position, DeparturePlan& plan, std::deque& departureSteps) { @@ -78,6 +83,65 @@ bool PlayerBotController::findOracleDeparture(Player& player, const Position& po return false; } +bool PlayerBotController::forceOracleDeparture(Player& player, const Position& position, const char* decisionReason) +{ + const TopLevelGoal previousGoal = activeGoal; + const bool interruptedHunt = previousGoal == TopLevelGoal::Hunt && cyclePhase == CyclePhase::Hunt; + if (interruptedHunt) { + finishHuntRegion(player, position, "level_eight_interrupt"); + emit("goal_result", position, + "\"decision_id\":" + std::to_string(goalDecisionId - 1) + + ",\"goal\":\"hunt\",\"result\":\"interrupted\",\"reason\":\"level_eight_interrupt\""); + } + + if (defensiveTargetId == 0) { + g_game.playerCancelAttackAndFollow(playerId); + } + clearRatTarget(position, "level_eight_interrupt"); + clearNavigation(); + pendingLootItemId = 0; + pendingDiscardItemId = 0; + expectedCorpseItemId = 0; + expectedCorpseLootable = false; + player.closeContainer(corpseContainerId); + setStage(ScenarioStage::Traverse, position); + progressionObjective = ProgressionObjective::None; + serviceStage = ServiceStage::Discover; + + DeparturePlan plan; + std::deque route; + const bool withinOracleLevelRange = player.getLevel() <= oracleMaximumLevel; + const bool found = withinOracleLevelRange && findOracleDeparture(player, position, plan, route); + const GoalCandidate candidate{TopLevelGoal::Departure, found, found ? oracleDepartureUtility : 0, + !withinOracleLevelRange ? "above_maximum_level" : + found ? "oracle_reachable" : "oracle_unreachable"}; + emitGoalCandidate(player, candidate, position, decisionReason, nullptr, found ? &plan : nullptr); + if (!found) { + emit("goal_selection", position, + "\"decision_id\":" + std::to_string(goalDecisionId) + ",\"decision_reason\":" + + jsonString(decisionReason) + ",\"from_goal\":" + jsonString(topLevelGoalName(previousGoal)) + + ",\"to_goal\":\"oracle_departure\",\"result\":\"failed\",\"reason\":" + + jsonString(candidate.reason) + ",\"forced\":true,\"level\":" + std::to_string(player.getLevel()) + + ",\"player_vocation_id\":" + std::to_string(player.getVocation()->getId()) + + ",\"vocation_id\":" + std::to_string(oracleVocationId)); + stop("oracle_departure_unavailable", position); + return false; + } + + activeGoal = TopLevelGoal::Departure; + emit("goal_selection", position, + "\"decision_id\":" + std::to_string(goalDecisionId) + ",\"decision_reason\":" + + jsonString(decisionReason) + ",\"from_goal\":" + jsonString(topLevelGoalName(previousGoal)) + + ",\"to_goal\":\"oracle_departure\",\"utility\":" + std::to_string(oracleDepartureUtility) + + ",\"reason\":\"forced_level_eight_departure\",\"forced\":true,\"level\":" + + std::to_string(player.getLevel()) + ",\"player_vocation_id\":" + + std::to_string(player.getVocation()->getId()) + ",\"npc_id\":" + std::to_string(plan.npcId) + + ",\"town_id\":" + std::to_string(oracleTownId) + ",\"vocation_id\":" + + std::to_string(oracleVocationId)); + beginOracleDeparture(player, position, std::move(plan), std::move(route)); + return true; +} + void PlayerBotController::beginOracleDeparture(Player& player, const Position& position, DeparturePlan plan, std::deque steps) { diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index fc6c305..be68438 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -804,6 +804,9 @@ void PlayerBotController::beginPickupReward(Player& player, const Position& posi bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& position, const char* decisionReason) { ++goalDecisionId; + if (requiresRookgaardDeparture(player)) { + return forceOracleDeparture(player, position, decisionReason); + } const GoalCandidate service = serviceGoalCandidate(player); DeparturePlan departure; std::deque departureRoute; diff --git a/server/tests/playerbot-gameplay/playerbot_gameplay.lua b/server/tests/playerbot-gameplay/playerbot_gameplay.lua index 7a92855..66a749d 100644 --- a/server/tests/playerbot-gameplay/playerbot_gameplay.lua +++ b/server/tests/playerbot-gameplay/playerbot_gameplay.lua @@ -11,6 +11,7 @@ local lootMonsterName = "Playerbot Loot Corpse" local nonlootableMonsterName = "Playerbot Nonlootable Corpse" local containerDeathItemMonsterName = "Playerbot Container Death Item" local defensiveMonsterName = "Playerbot Defensive Threat" +local levelEightMonsterName = "Playerbot Level Eight Target" local deathMonsterName = "Playerbot Death Threat" local valueMonsterName = "Playerbot Value Corpse" local healingPotionCount = 3 @@ -22,6 +23,7 @@ local nestedRewardStorage = 50083 local nestedRewardRootId = 1994 local nestedRewardShieldId = 2512 local economicRewardStorage = 50082 +local departureRecoveryStorage = 50090 local deathLoginCount = 0 local function verifyOracleDeparture(playerId, attempts) @@ -148,6 +150,40 @@ local function spawnCorpseMonster(playerId, monsterName) error("no adjacent tile was available for corpse test monster") end +local function spawnDepartureDefensiveThreat(playerId) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared before departure defensive combat") + local origin = player:getPosition() + for _, position in ipairs({ + Position(origin.x + 1, origin.y, origin.z), + Position(origin.x, origin.y + 1, origin.z), + Position(origin.x - 1, origin.y, origin.z), + Position(origin.x, origin.y - 1, origin.z), + }) do + local tile = Tile(position) + if tile and tile:isWalkable() then + local monster = Game.createMonster(defensiveMonsterName, position, true, true) + assert(monster and monster:selectTarget(player), "departure defensive threat could not target Bot One") + print("PLAYERBOT_GAMEPLAY_TEST ORACLE_DEPARTURE_DEFENSIVE_THREAT_SPAWNED") + return + end + end + error("no adjacent tile was available for the departure defensive threat") +end + +local function prepareLevelEightCombat(player) + local targetExperience = Game.getExperienceForLevel(8) - 1 + assert(player:getExperience() < targetExperience, "level-eight combat fixture expected a low-level player") + player:addExperience(targetExperience - player:getExperience()) + assert(player:getLevel() == 7, "level-eight combat fixture could not prepare level 7") + assert(player:teleportTo(depotPosition), "level-eight combat fixture could not leave the temple") + removeNearbyMonsters(player) + spawnCorpseMonster(player:getId(), levelEightMonsterName) + addEvent(spawnDepartureDefensiveThreat, 2500, player:getId()) + addEvent(verifyOracleDeparture, 500, player:getId(), 360) + print("PLAYERBOT_GAMEPLAY_TEST ORACLE_LEVEL_EIGHT_INTERRUPT_START") +end + local function removeBlockers(firstId, secondId, thirdId) for _, blockerId in ipairs({firstId, secondId, thirdId}) do local blocker = Npc(blockerId) @@ -300,7 +336,8 @@ function login.onLogin(player) mode == "progression_nested" or mode == "progression_resume" or mode == "progression_nested_resume" or mode == "progression_space" or mode == "arbitration" or - mode == "arbitration_interrupt" or mode == "departure", + mode == "arbitration_interrupt" or mode == "departure" or mode == "departure_interrupt" or + mode == "departure_recovery", "unknown PLAYERBOT_GAMEPLAY_MODE: " .. mode) if mode == "death" then deathLoginCount = deathLoginCount + 1 @@ -339,6 +376,33 @@ function login.onLogin(player) print("PLAYERBOT_GAMEPLAY_TEST ORACLE_DEPARTURE_START") return true end + if mode == "departure_interrupt" then + prepareLevelEightCombat(player) + return true + end + if mode == "departure_recovery" then + if player:getStorageValue(departureRecoveryStorage) ~= 1 then + local targetExperience = Game.getExperienceForLevel(9) - 1 + assert(player:getExperience() < targetExperience, + "Oracle recovery fixture expected a low-level player") + player:addExperience(targetExperience - player:getExperience()) + assert(player:getLevel() == 8, "Oracle recovery fixture could not prepare level 8") + assert(player:setStorageValue(departureRecoveryStorage, 1), + "Oracle recovery fixture could not persist its restart marker") + print("PLAYERBOT_GAMEPLAY_TEST ORACLE_LEVEL_EIGHT_RECOVERY_PREPARED") + return true + end + assert(player:getVocation():getId() == 0 and player:getLevel() == 8, + "Oracle recovery fixture did not restore a vocationless level-8 player") + assert(player:getExperience() == Game.getExperienceForLevel(9) - 1, + "Oracle recovery fixture did not restore the level-9 experience boundary") + assert(player:setHealth(math.floor(player:getMaxHealth() * 0.5)), + "Oracle recovery fixture could not lower Bot One's health") + suppressNearbyMonsters(player:getId()) + addEvent(verifyOracleDeparture, 500, player:getId(), 360) + print("PLAYERBOT_GAMEPLAY_TEST ORACLE_LEVEL_EIGHT_RECOVERY_START") + return true + end if mode == "arbitration" or mode == "arbitration_interrupt" then local position = player:getPosition() assert(position.x == 32097 and position.y == 32219 and position.z == 7, diff --git a/server/tests/playerbot-monsters/corpse.lua b/server/tests/playerbot-monsters/corpse.lua index 32cb749..f1288f8 100644 --- a/server/tests/playerbot-monsters/corpse.lua +++ b/server/tests/playerbot-monsters/corpse.lua @@ -1,8 +1,8 @@ -local function registerCorpseTestMonster(name, loot, corpseId, hostile, health, attacks) +local function registerCorpseTestMonster(name, loot, corpseId, hostile, health, attacks, experience) local monsterType = Game.createMonsterType(name) local monster = { description = name:lower(), - experience = 0, + experience = experience or 0, outfit = {lookType = 21}, health = health or 1, maxHealth = health or 1, @@ -35,6 +35,7 @@ registerCorpseTestMonster("Playerbot Loot Corpse", { registerCorpseTestMonster("Playerbot Nonlootable Corpse", {}, ITEM_GOLD_COIN) registerCorpseTestMonster("Playerbot Container Death Item", {}, ITEM_BAG) registerCorpseTestMonster("Playerbot Defensive Threat", {}, nil, false, 1) +registerCorpseTestMonster("Playerbot Level Eight Target", {}, nil, false, 1, nil, 1) registerCorpseTestMonster("Playerbot Value Corpse", { {id = 2826, chance = 100000, maxCount = 1}, })