diff --git a/docs/playerbots.md b/docs/playerbots.md index cb4855f..c1e78a7 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -216,6 +216,7 @@ States, actions, results, statuses, and reasons use stable lowercase values. | Lifecycle | `lifecycle`, `state_transition`, `objective_transition`, `terminal` record ownership and controller state. | | Goals | `goal_candidate`, `goal_selection`, `goal_result` expose arbitration evidence and decision IDs. | | Rewards | `strategy_candidate`, `reward_inspection`, `strategy_selection`, `strategy_objective_result` expose bundle selection and verification. | +| Equipment offers | `equipment_offer_candidate` and `equipment_offer_shadow` expose loaded tagged-shop offers, loadout and hunt deltas, reserve and route checks, and the non-mutating shadow decision. | | Spell training | `spell_trainer_discovered`, `spell_candidate`, `strategy_selection`, `action_result`, and `goal_result` expose loaded offers, eligibility rejections, provider/route choice, and exact payment verification. | | Spell casting | `action_result` with `action="cast_spell"` records the need, semantic `policy_candidate`, selected method, mana reserve, normal-path request, engine result, observed outcome, and fallback. `legal_candidates` contains only normal-path casts confirmed by resource evidence. | | Actions | `action_result`, `target_changed`, `service_discovered`, `npc_reply`, `stuck` record externally relevant attempts and outcomes. | diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index 8518d18..cb6e23f 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -11,8 +11,9 @@ param( [switch]$GoalArbitration, [switch]$OracleDeparture, [switch]$StaminaProjection, - [switch]$HuntRegionPlanning, + [switch]$HuntRegionPlanning, [switch]$CombatReadiness, + [switch]$EquipmentOffers, [switch]$Depot, [switch]$MainlandLoop, [switch]$SpellTraining, @@ -933,6 +934,40 @@ function Assert-CombatReadinessEvents { } } +function Assert-EquipmentOfferEvents { + param([string]$Logs, [string]$Mode) + + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $shadow = @($events | Where-Object { $_.event -eq "equipment_offer_shadow" }) + $candidates = @($events | Where-Object { $_.event -eq "equipment_offer_candidate" }) + $purchases = @($events | Where-Object { $_.event -eq "action_result" -and $_.action -eq "buy_equipment" }) + $equipmentMoves = @($events | Where-Object { $_.event -eq "action_result" -and $_.action -eq "equip_equipment" }) + if ($shadow.Count -ne 1 -or $candidates.Count -lt 1 -or $purchases.Count -ne 0 -or $equipmentMoves.Count -ne 0) { + throw "Equipment shadow telemetry was incomplete or mutated player state. shadow=$($shadow.Count), candidates=$($candidates.Count), purchases=$($purchases.Count), equipmentMoves=$($equipmentMoves.Count)." + } + if ($Mode -eq "upgrade") { + $selected = @($candidates | Where-Object { + $_.result -eq "feasible" -and $_.npc_id -eq $shadow[0].npc_id -and $_.item_id -eq $shadow[0].item_id + }) + if ($shadow[0].result -ne "would_buy" -or $selected.Count -ne 1 -or $selected[0].replaced_item_id -ne 2382 -or + -not $selected[0].current -or -not $selected[0].candidate -or $selected[0].rule -notin @("pareto_improvement", "unlocks_suitable_hunt")) { + throw "Equipment shadow did not select a loaded strict weapon improvement." + } + } elseif ($Mode -eq "unaffordable") { + $unaffordable = @($candidates | Where-Object { $_.result -eq "rejected" -and $_.reason -eq "unaffordable_after_reserves" }) + if ($shadow[0].result -ne "no_decision" -or $unaffordable.Count -lt 1) { + throw "Equipment shadow did not preserve the supply reserve before evaluating a purchase." + } + } else { + $nonImproving = @($candidates | Where-Object { $_.result -eq "rejected" -and $_.reason -eq "non_improving" }) + $illegal = @($candidates | Where-Object { $_.result -eq "rejected" -and $_.reason -eq "unsupported_weapon_type" }) + $affordable = @($candidates | Where-Object { $_.reason -eq "unaffordable_after_reserves" }) + if ($shadow[0].result -ne "no_decision" -or $nonImproving.Count -lt 1 -or $illegal.Count -lt 1 -or $affordable.Count -ne 0) { + throw "Equipment shadow did not abstain from non-improving or two-handed tradeoff offers." + } + } +} + function Assert-GoalArbitrationInterruptEvents { param([string]$Logs) @@ -1311,7 +1346,7 @@ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { $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 -or $SpellTraining -or $SpellUse + $CombatReadiness -or $EquipmentOffers -or $Depot -or $MainlandLoop -or $SpellTraining -or $SpellUse if ($Focused -and -not $focusedScenarioRequested) { throw "-Focused requires at least one focused scenario switch." } @@ -1654,6 +1689,36 @@ try { } } + if ($EquipmentOffers) { + Invoke-Scenario -Name "equipment_offer_shadow_upgrade" -DefaultTimeoutSeconds 90 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "equipment_shadow" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_SHADOW_START' | Out-Null + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_SHADOW_PASS' | Out-Null + $upgradeLogs = Wait-ForPlayerbotEvent { $_.event -eq "equipment_offer_shadow" } + Assert-EquipmentOfferEvents -Logs $upgradeLogs -Mode "upgrade" + } + Invoke-Scenario -Name "equipment_offer_shadow_unaffordable" -DefaultTimeoutSeconds 90 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "equipment_shadow_unaffordable" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_SHADOW_UNAFFORDABLE_START' | Out-Null + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_SHADOW_UNAFFORDABLE_PASS' | Out-Null + $unaffordableLogs = Wait-ForPlayerbotEvent { $_.event -eq "equipment_offer_shadow" } + Assert-EquipmentOfferEvents -Logs $unaffordableLogs -Mode "unaffordable" + } + Invoke-Scenario -Name "equipment_offer_shadow_no_upgrade" -DefaultTimeoutSeconds 90 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "equipment_shadow_no_upgrade" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_SHADOW_NO_UPGRADE_START' | Out-Null + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_SHADOW_NO_UPGRADE_PASS' | Out-Null + $noUpgradeLogs = Wait-ForPlayerbotEvent { $_.event -eq "equipment_offer_shadow" } + Assert-EquipmentOfferEvents -Logs $noUpgradeLogs -Mode "no_upgrade" + } + } + if ($OracleDeparture) { Invoke-Scenario -Name "oracle_departure" -DefaultTimeoutSeconds 180 -Body { Invoke-Compose down --volumes --remove-orphans diff --git a/server/src/CMakeLists.txt b/server/src/CMakeLists.txt index 9d8413f..6f39878 100644 --- a/server/src/CMakeLists.txt +++ b/server/src/CMakeLists.txt @@ -48,6 +48,7 @@ set(tfs_SRC ${CMAKE_CURRENT_LIST_DIR}/playerbotcombat.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotcontroller.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotdeparture.cpp + ${CMAKE_CURRENT_LIST_DIR}/playerbotequipment.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotloot.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotprogression.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotspells.cpp diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index 4e27d6b..5075f16 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -31,7 +31,9 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment() std::strcmp(gameplayMode, "arbitration_interrupt") == 0 || std::strcmp(gameplayMode, "departure") == 0 || std::strcmp(gameplayMode, "departure_recovery") == 0 || - std::strcmp(gameplayMode, "spell_training") == 0); + std::strcmp(gameplayMode, "spell_training") == 0 || std::strcmp(gameplayMode, "equipment_shadow") == 0 || + std::strcmp(gameplayMode, "equipment_shadow_unaffordable") == 0 || + std::strcmp(gameplayMode, "equipment_shadow_no_upgrade") == 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) || @@ -44,9 +46,12 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment() std::strcmp(gameplayMode, "readiness_retention") == 0 || std::strcmp(gameplayMode, "spell_use") == 0); const bool fixedFixtureRoute = gameplayMode && std::strcmp(gameplayMode, "stamina_bonus") != 0 && std::strcmp(gameplayMode, "stamina_boundary") != 0 && - std::strcmp(gameplayMode, "stamina_normal") != 0 && - std::strcmp(gameplayMode, "hunt_planning") != 0 && - std::strcmp(gameplayMode, "mainland") != 0 && + std::strcmp(gameplayMode, "stamina_normal") != 0 && + std::strcmp(gameplayMode, "hunt_planning") != 0 && + std::strcmp(gameplayMode, "equipment_shadow") != 0 && + std::strcmp(gameplayMode, "equipment_shadow_unaffordable") != 0 && + std::strcmp(gameplayMode, "equipment_shadow_no_upgrade") != 0 && + std::strcmp(gameplayMode, "mainland") != 0 && std::strcmp(gameplayMode, "depot") != 0; const char* depotRestartPhase = std::getenv("PLAYERBOT_DEPOT_RESTART_PHASE"); const DepotRestartCheckpoint depotRestartCheckpoint = !depotRestartPhase ? DepotRestartCheckpoint::None : diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index 9fa1598..7bc8a1e 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -246,6 +246,42 @@ class PlayerBotController : public std::enable_shared_from_this itemIds{}; + }; + + struct EquipmentHuntSummary { + uint32_t suitableRegions = 0; + double bestProjectedExperience = 0; + double lowestThreatRatio = 0; + uint32_t evaluatedRegions = 0; + bool truncated = false; + }; + + enum class EquipmentDecisionRule : uint8_t { + None, + ParetoImprovement, + UnlocksHunt, + ReadinessRepair, + }; + + struct EquipmentOfferEvaluation { + uint32_t npcId = 0; + Position npcPosition; + uint16_t itemId = 0; + uint32_t price = 0; + uint16_t replacedItemId = 0; + uint16_t displacedLeftItemId = 0; + uint16_t displacedRightItemId = 0; + PlayerBotCombatProfile profile; + EquipmentHuntSummary hunts; + bool currentReady = false; + bool candidateReady = false; + std::string rejection; + EquipmentDecisionRule rule = EquipmentDecisionRule::None; + uint32_t travelSteps = 0; + }; + struct RewardItemInspection { uint16_t itemId; uint32_t count; @@ -480,6 +516,7 @@ class PlayerBotController : public std::enable_shared_from_this evaluateEquipmentUpgrade(const Player& player, const Item& candidate) const; + EquipmentLoadout equipmentLoadout(const Player& player) const; + bool applyEquipmentOffer(const Player& player, EquipmentLoadout& loadout, uint16_t itemId, + uint16_t& replacedItemId, uint16_t& displacedLeftItemId, uint16_t& displacedRightItemId, + std::string& rejection) const; + PlayerBotCombatProfile equipmentCombatProfile(const Player& player, const EquipmentLoadout& loadout) const; + bool equipmentLoadoutReady(const Player& player, const EquipmentLoadout& loadout, + uint32_t additionalWeight = 0) const; + EquipmentHuntSummary equipmentHuntSummary(Player& player, const PlayerBotCombatProfile& profile) const; + const char* equipmentDecisionRuleName(EquipmentDecisionRule rule) const; + void emitEquipmentOffer(const Player& player, const EquipmentOfferEvaluation& evaluation, + const PlayerBotCombatProfile& currentProfile, const EquipmentHuntSummary& currentHunts, + uint64_t reserve, const Position& position, const char* result, const char* reason) const; + void evaluateEquipmentOffers(Player& player, const Position& position); std::string rewardItemSignature(const Item& item) const; diff --git a/server/src/playerbotequipment.cpp b/server/src/playerbotequipment.cpp new file mode 100644 index 0000000..b9f5fe1 --- /dev/null +++ b/server/src/playerbotequipment.cpp @@ -0,0 +1,514 @@ +/** + * The Forgotten Server - a free and open-source MMORPG server emulator + * Copyright (C) 2019 Mark Samman + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#include "otpch.h" + +#include "playerbotcontroller.h" +#include "weapons.h" + +using namespace playerbot; + +namespace { + constexpr uint32_t maximumEquipmentProviderDistance = 200; + constexpr size_t maximumEquipmentHuntRegions = 32; + constexpr size_t maximumEquipmentProviderRoutes = 4; + constexpr size_t maximumEquipmentProviderApproaches = 4; + constexpr uint64_t maximumEquipmentProviderPathNodes = 5000; + constexpr size_t maximumEquipmentCatalogOffers = 64; + constexpr size_t maximumEquipmentUniqueItems = 16; + + skills_t skillForWeapon(WeaponType_t weaponType) + { + switch (weaponType) { + case WEAPON_SWORD: return SKILL_SWORD; + case WEAPON_CLUB: return SKILL_CLUB; + case WEAPON_AXE: return SKILL_AXE; + case WEAPON_DISTANCE: case WEAPON_AMMO: return SKILL_DISTANCE; + default: return SKILL_FIST; + } + } +} + +PlayerBotController::EquipmentLoadout PlayerBotController::equipmentLoadout(const Player& player) const +{ + EquipmentLoadout loadout; + for (int32_t slot = CONST_SLOT_FIRST; slot <= CONST_SLOT_LAST; ++slot) { + if (const Item* item = player.getInventoryItem(static_cast(slot))) { + loadout.itemIds[slot] = item->getID(); + } + } + return loadout; +} + +bool PlayerBotController::applyEquipmentOffer(const Player& player, EquipmentLoadout& loadout, uint16_t itemId, + uint16_t& replacedItemId, uint16_t& displacedLeftItemId, + uint16_t& displacedRightItemId, + std::string& rejection) const +{ + const ItemType& type = Item::items[itemId]; + if (!type.isPickupable()) { + rejection = "not_pickupable"; + return false; + } + if (player.getLevel() < type.minReqLevel) { + rejection = "level_ineligible"; + return false; + } + if (player.getMagicLevel() < type.minReqMagicLevel) { + rejection = "magic_level_ineligible"; + return false; + } + if ((type.wieldInfo & WIELDINFO_PREMIUM) != 0 && !player.isPremium()) { + rejection = "premium_ineligible"; + return false; + } + if (!type.vocationIds.empty() && type.vocationIds.find(player.getVocationId()) == type.vocationIds.end()) { + rejection = "vocation_ineligible"; + return false; + } + + auto itemTypeAt = [&loadout](slots_t slot) -> const ItemType* { + const uint16_t equippedItemId = loadout.itemIds[slot]; + return equippedItemId == 0 ? nullptr : &Item::items[equippedItemId]; + }; + auto isTwoHanded = [&itemTypeAt](slots_t hand) { + const ItemType* equipped = itemTypeAt(hand); + return equipped && (equipped->slotPosition & SLOTP_TWO_HAND) != 0; + }; + auto isWeapon = [&itemTypeAt](slots_t hand) { + const ItemType* equipped = itemTypeAt(hand); + return equipped && equipped->weaponType != WEAPON_NONE && equipped->weaponType != WEAPON_SHIELD; + }; + auto isShield = [&itemTypeAt](slots_t hand) { + const ItemType* equipped = itemTypeAt(hand); + return equipped && equipped->weaponType == WEAPON_SHIELD; + }; + + slots_t slot = CONST_SLOT_WHEREEVER; + if (type.slotPosition & SLOTP_HEAD) { + slot = CONST_SLOT_HEAD; + } else if (type.slotPosition & SLOTP_ARMOR) { + slot = CONST_SLOT_ARMOR; + } else if (type.slotPosition & SLOTP_LEGS) { + slot = CONST_SLOT_LEGS; + } else if (type.slotPosition & SLOTP_FEET) { + slot = CONST_SLOT_FEET; + } else if (type.weaponType == WEAPON_SHIELD) { + slot = isWeapon(CONST_SLOT_LEFT) && !isTwoHanded(CONST_SLOT_LEFT) ? CONST_SLOT_RIGHT : + isWeapon(CONST_SLOT_RIGHT) && !isTwoHanded(CONST_SLOT_RIGHT) ? CONST_SLOT_LEFT : CONST_SLOT_RIGHT; + } else if (type.weaponType != WEAPON_NONE && type.weaponType != WEAPON_AMMO && + (type.slotPosition & (SLOTP_LEFT | SLOTP_RIGHT)) != 0) { + if (requiresKnightCombatReadiness(player) && + type.weaponType != WEAPON_SWORD && type.weaponType != WEAPON_CLUB && type.weaponType != WEAPON_AXE) { + rejection = "unsupported_weapon_type"; + return false; + } + slot = isShield(CONST_SLOT_LEFT) ? CONST_SLOT_RIGHT : isShield(CONST_SLOT_RIGHT) ? CONST_SLOT_LEFT : + (type.slotPosition & SLOTP_LEFT) != 0 ? CONST_SLOT_LEFT : CONST_SLOT_RIGHT; + } else { + rejection = "unsupported_slot"; + return false; + } + + replacedItemId = (type.slotPosition & SLOTP_TWO_HAND) != 0 ? loadout.itemIds[CONST_SLOT_LEFT] : loadout.itemIds[slot]; + displacedLeftItemId = 0; + displacedRightItemId = 0; + if ((type.slotPosition & SLOTP_TWO_HAND) != 0) { + displacedLeftItemId = loadout.itemIds[CONST_SLOT_LEFT]; + displacedRightItemId = loadout.itemIds[CONST_SLOT_RIGHT]; + loadout.itemIds[CONST_SLOT_LEFT] = itemId; + loadout.itemIds[CONST_SLOT_RIGHT] = 0; + } else if (isTwoHanded(CONST_SLOT_LEFT) || isTwoHanded(CONST_SLOT_RIGHT)) { + displacedLeftItemId = loadout.itemIds[CONST_SLOT_LEFT]; + displacedRightItemId = loadout.itemIds[CONST_SLOT_RIGHT]; + loadout.itemIds[CONST_SLOT_LEFT] = 0; + loadout.itemIds[CONST_SLOT_RIGHT] = 0; + loadout.itemIds[slot] = itemId; + } else { + if (slot == CONST_SLOT_LEFT) { + displacedLeftItemId = loadout.itemIds[slot]; + } else if (slot == CONST_SLOT_RIGHT) { + displacedRightItemId = loadout.itemIds[slot]; + } + loadout.itemIds[slot] = itemId; + } + return true; +} + +PlayerBotCombatProfile PlayerBotController::equipmentCombatProfile(const Player& player, const EquipmentLoadout& loadout) const +{ + auto itemTypeAt = [&loadout](slots_t slot) -> const ItemType* { + const uint16_t itemId = loadout.itemIds[slot]; + return itemId == 0 ? nullptr : &Item::items[itemId]; + }; + + int32_t armor = 0; + for (slots_t slot : {CONST_SLOT_HEAD, CONST_SLOT_NECKLACE, CONST_SLOT_ARMOR, CONST_SLOT_LEGS, CONST_SLOT_FEET, CONST_SLOT_RING}) { + if (const ItemType* type = itemTypeAt(slot)) { + armor += type->armor; + } + } + + const ItemType* weapon = nullptr; + const ItemType* shield = nullptr; + for (slots_t slot : {CONST_SLOT_RIGHT, CONST_SLOT_LEFT}) { + const ItemType* type = itemTypeAt(slot); + if (!type || type->weaponType == WEAPON_NONE) { + continue; + } + if (type->weaponType == WEAPON_SHIELD) { + if (!shield || type->defense > shield->defense) { + shield = type; + } + } else { + weapon = type; + } + } + + int32_t defenseValue = 7; + int32_t defenseSkill = player.getSkillLevel(SKILL_FIST); + if (weapon) { + defenseValue = weapon->defense + weapon->extraDefense; + defenseSkill = player.getSkillLevel(skillForWeapon(weapon->weaponType)); + } + if (shield) { + defenseValue = weapon ? shield->defense + weapon->extraDefense : shield->defense; + defenseSkill = player.getSkillLevel(SKILL_SHIELD); + } + const int32_t defense = defenseSkill == 0 ? 1 : static_cast( + (defenseSkill / 4.0 + 2.23) * defenseValue * 0.15 * player.getDefenseFactor() * player.getVocation()->defenseMultiplier); + return {player.getLevel(), player.getMaxHealth(), static_cast(armor * player.getVocation()->armorMultiplier), defense, + weapon ? weapon->attack : 7, player.getSkillLevel(skillForWeapon(weapon ? weapon->weaponType : WEAPON_NONE)), + player.getAttackFactor()}; +} + +bool PlayerBotController::equipmentLoadoutReady(const Player& player, const EquipmentLoadout& loadout, + uint32_t additionalWeight) const +{ + const auto isKnightWeapon = [this, &player](uint16_t itemId) { + if (itemId == 0) { + return false; + } + const ItemType& type = Item::items[itemId]; + return isLegalEquipmentType(player, type) && type.attack > 0 && + (type.weaponType == WEAPON_SWORD || type.weaponType == WEAPON_CLUB || + type.weaponType == WEAPON_AXE) && + (type.slotPosition & (SLOTP_LEFT | SLOTP_RIGHT)) != 0; + }; + const uint16_t armorItemId = loadout.itemIds[CONST_SLOT_ARMOR]; + const bool armorReady = armorItemId != 0 && isLegalEquipmentType(player, Item::items[armorItemId]) && + (Item::items[armorItemId].slotPosition & SLOTP_ARMOR) != 0 && Item::items[armorItemId].armor > 0; + const Item* backpack = player.getInventoryItem(CONST_SLOT_BACKPACK); + const bool suppliesReady = getInventoryItemCount(player, smallHealthPotionItemId) >= minimumSmallHealthPotions && + getInventoryItemCount(player, meatItemId) >= minimumMeat; + const bool capacityReady = player.getFreeCapacity() >= returnCapacityThreshold + additionalWeight; + return (isKnightWeapon(loadout.itemIds[CONST_SLOT_LEFT]) || isKnightWeapon(loadout.itemIds[CONST_SLOT_RIGHT])) && armorReady && + backpack && backpack->getContainer() && suppliesReady && capacityReady; +} + +PlayerBotController::EquipmentHuntSummary PlayerBotController::equipmentHuntSummary(Player& player, + const PlayerBotCombatProfile& profile) const +{ + EquipmentHuntSummary summary; + summary.lowestThreatRatio = std::numeric_limits::max(); + std::set excludedRegions; + const auto now = std::chrono::steady_clock::now(); + for (const auto& [center, cooldown] : huntRegionCooldowns) { + if (cooldown > now) { + excludedRegions.insert(center); + } + } + const PlayerBotHuntRegionScan scan = huntRegionPlanner.beginScan(player); + const uint32_t huntDurationSeconds = static_cast(std::max(1, + g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS))); + for (size_t candidateIndex : scan.candidateIndices) { + if (summary.evaluatedRegions >= maximumEquipmentHuntRegions) { + summary.truncated = true; + break; + } + PlayerBotHuntRegion region; + if (!huntRegionPlanner.score(player, profile, scan.revision, candidateIndex, excludedRegions, + huntRegionPerformance, huntDurationSeconds, region)) { + continue; + } + ++summary.evaluatedRegions; + summary.lowestThreatRatio = std::min(summary.lowestThreatRatio, region.threatRatio); + if (region.suitable) { + ++summary.suitableRegions; + summary.bestProjectedExperience = std::max(summary.bestProjectedExperience, region.projectedExperience); + } + } + if (summary.lowestThreatRatio == std::numeric_limits::max()) { + summary.lowestThreatRatio = 0; + } + return summary; +} + +const char* PlayerBotController::equipmentDecisionRuleName(EquipmentDecisionRule rule) const +{ + switch (rule) { + case EquipmentDecisionRule::ParetoImprovement: return "pareto_improvement"; + case EquipmentDecisionRule::UnlocksHunt: return "unlocks_suitable_hunt"; + case EquipmentDecisionRule::ReadinessRepair: return "fills_readiness_gap"; + case EquipmentDecisionRule::None: return "none"; + } + return "none"; +} + +void PlayerBotController::emitEquipmentOffer(const Player& player, const EquipmentOfferEvaluation& evaluation, + const PlayerBotCombatProfile& currentProfile, + const EquipmentHuntSummary& currentHunts, uint64_t reserve, + const Position& position, const char* result, const char* reason) const +{ + std::ostringstream fields; + fields << std::fixed << std::setprecision(2) + << "\"result\":" << jsonString(result) << ",\"npc_id\":" << evaluation.npcId + << ",\"item_id\":" << evaluation.itemId << ",\"price\":" << evaluation.price + << ",\"replaced_item_id\":" << (evaluation.replacedItemId == 0 ? "null" : std::to_string(evaluation.replacedItemId)) + << ",\"carried_money\":" << player.getMoney() << ",\"bank_balance\":" << player.getBankBalance() + << ",\"reserve\":" << reserve << ",\"travel_steps\":" << evaluation.travelSteps + << ",\"displaced_left_item_id\":" << (evaluation.displacedLeftItemId == 0 ? "null" : std::to_string(evaluation.displacedLeftItemId)) + << ",\"displaced_right_item_id\":" << (evaluation.displacedRightItemId == 0 ? "null" : std::to_string(evaluation.displacedRightItemId)) + << ",\"current\":{\"armor\":" << currentProfile.armor << ",\"defense\":" << currentProfile.defense + << ",\"attack\":" << currentProfile.attack << ",\"suitable_regions\":" << currentHunts.suitableRegions + << ",\"evaluated_regions\":" << currentHunts.evaluatedRegions + << ",\"hunt_evaluation_truncated\":" << (currentHunts.truncated ? "true" : "false") + << ",\"best_projected_experience\":" << currentHunts.bestProjectedExperience + << ",\"lowest_threat_ratio\":" << currentHunts.lowestThreatRatio + << ",\"combat_ready\":" << (evaluation.currentReady ? "true" : "false") << '}' + << ",\"candidate\":{\"armor\":" << evaluation.profile.armor << ",\"defense\":" << evaluation.profile.defense + << ",\"attack\":" << evaluation.profile.attack << ",\"suitable_regions\":" << evaluation.hunts.suitableRegions + << ",\"evaluated_regions\":" << evaluation.hunts.evaluatedRegions + << ",\"hunt_evaluation_truncated\":" << (evaluation.hunts.truncated ? "true" : "false") + << ",\"best_projected_experience\":" << evaluation.hunts.bestProjectedExperience + << ",\"lowest_threat_ratio\":" << evaluation.hunts.lowestThreatRatio + << ",\"combat_ready\":" << (evaluation.candidateReady ? "true" : "false") << '}' + << ",\"rule\":" << jsonString(equipmentDecisionRuleName(evaluation.rule)) + << ",\"provider_position\":{\"x\":" << evaluation.npcPosition.x << ",\"y\":" << evaluation.npcPosition.y + << ",\"z\":" << static_cast(evaluation.npcPosition.z) << '}'; + if (reason) { + fields << ",\"reason\":" << jsonString(reason); + } + emit("equipment_offer_candidate", position, fields.str()); +} + +void PlayerBotController::evaluateEquipmentOffers(Player& player, const Position& position) +{ + if (!requiresKnightCombatReadiness(player)) { + return; + } + const uint64_t reserve = spellTrainingReserve(player); + const uint64_t totalMoney = player.getMoney() + player.getBankBalance(); + const EquipmentLoadout currentLoadout = equipmentLoadout(player); + const PlayerBotCombatProfile currentProfile = equipmentCombatProfile(player, currentLoadout); + const EquipmentHuntSummary currentHunts = equipmentHuntSummary(player, currentProfile); + const bool currentReady = equipmentLoadoutReady(player, currentLoadout); + std::map evaluatedItems; + std::map> providerRoutes; + std::set providerRouteNodeLimits; + std::optional selected; + uint32_t feasibleCandidates = 0; + bool providerRouteBudgetExhausted = false; + size_t catalogOffers = 0; + bool catalogTruncated = false; + + auto providerRoute = [&](Npc& npc) -> std::optional { + if (auto route = providerRoutes.find(npc.getID()); route != providerRoutes.end()) { + return route->second; + } + if (providerRoutes.size() >= maximumEquipmentProviderRoutes) { + providerRouteBudgetExhausted = true; + return std::nullopt; + } + std::vector approaches; + for (int32_t x = -3; x <= 3; ++x) { + for (int32_t y = -3; y <= 3; ++y) { + if (x != 0 || y != 0) { + approaches.emplace_back(npc.getPosition().x + x, npc.getPosition().y + y, npc.getPosition().z); + } + } + } + std::sort(approaches.begin(), approaches.end(), [&position](const Position& left, const Position& right) { + const int32_t leftDistance = std::max(Position::getDistanceX(position, left), Position::getDistanceY(position, left)); + const int32_t rightDistance = std::max(Position::getDistanceX(position, right), Position::getDistanceY(position, right)); + return leftDistance == rightDistance ? left < right : leftDistance < rightDistance; + }); + for (size_t approachIndex = 0; approachIndex < approaches.size() && approachIndex < maximumEquipmentProviderApproaches; + ++approachIndex) { + const Position& approach = approaches[approachIndex]; + Tile* tile = g_game.map.getTile(approach); + if (!tile || tile->queryAdd(0, player, 1, 0) != RETURNVALUE_NOERROR) { + continue; + } + std::deque steps; + uint64_t expandedNodes = 0; + ++counters.pathfindingCalls; + const PlayerBotNavigationResult result = approach == position ? PlayerBotNavigationResult::Reached : + navigator.plan(player, approach, {}, steps, expandedNodes, + maximumEquipmentProviderPathNodes); + if (result == PlayerBotNavigationResult::Reached) { + return providerRoutes.emplace(npc.getID(), static_cast(steps.size())).first->second; + } + if (result == PlayerBotNavigationResult::NodeLimit) { + providerRouteNodeLimits.insert(npc.getID()); + } + ++counters.pathfindingFailures; + } + return providerRoutes.emplace(npc.getID(), std::nullopt).first->second; + }; + + for (const auto& entry : g_game.getNpcs()) { + if (catalogTruncated) { + break; + } + Npc* npc = entry.second; + const std::string* capability = npc && !npc->isRemoved() ? npc->getParameter("playerbot_service") : nullptr; + if (!npc || !capability || *capability != "shop" || + serviceDistance(player.getTemplePosition(), {npc->getID(), npc->getPosition()}) > maximumEquipmentProviderDistance) { + continue; + } + for (const ShopInfo& offer : npc->getShopOffers()) { + if (offer.buyPrice == 0) { + continue; + } + if (catalogOffers >= maximumEquipmentCatalogOffers) { + catalogTruncated = true; + break; + } + ++catalogOffers; + EquipmentOfferEvaluation evaluation; + evaluation.npcId = npc->getID(); + evaluation.npcPosition = npc->getPosition(); + evaluation.itemId = offer.itemId; + evaluation.price = offer.buyPrice; + evaluation.currentReady = currentReady; + if (auto item = evaluatedItems.find(offer.itemId); item != evaluatedItems.end()) { + evaluation = item->second; + evaluation.npcId = npc->getID(); + evaluation.npcPosition = npc->getPosition(); + evaluation.price = offer.buyPrice; + } else { + if (evaluatedItems.size() >= maximumEquipmentUniqueItems) { + evaluation.profile = currentProfile; + evaluation.hunts = currentHunts; + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", + "unique_item_evaluation_budget_exhausted"); + continue; + } + EquipmentLoadout candidateLoadout = currentLoadout; + std::string rejection; + if (!applyEquipmentOffer(player, candidateLoadout, offer.itemId, evaluation.replacedItemId, + evaluation.displacedLeftItemId, + evaluation.displacedRightItemId, rejection)) { + evaluation.profile = currentProfile; + evaluation.hunts = currentHunts; + evaluation.rejection = rejection; + evaluatedItems.emplace(offer.itemId, evaluation); + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", rejection.c_str()); + continue; + } + if (Item::items[offer.itemId].weight > player.getFreeCapacity()) { + evaluation.profile = currentProfile; + evaluation.hunts = currentHunts; + evaluation.rejection = "insufficient_capacity"; + evaluatedItems.emplace(offer.itemId, evaluation); + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", "insufficient_capacity"); + continue; + } + evaluation.profile = equipmentCombatProfile(player, candidateLoadout); + evaluation.hunts = equipmentHuntSummary(player, evaluation.profile); + evaluation.candidateReady = equipmentLoadoutReady(player, candidateLoadout, Item::items[offer.itemId].weight); + const int32_t currentMaximumDamage = Weapons::getMaxWeaponDamage(currentProfile.level, currentProfile.attackSkill, + currentProfile.attack, currentProfile.attackFactor); + const int32_t candidateMaximumDamage = Weapons::getMaxWeaponDamage(evaluation.profile.level, + evaluation.profile.attackSkill, + evaluation.profile.attack, + evaluation.profile.attackFactor); + const bool noWorse = evaluation.profile.armor >= currentProfile.armor && + evaluation.profile.defense >= currentProfile.defense && + candidateMaximumDamage >= currentMaximumDamage && + evaluation.hunts.suitableRegions >= currentHunts.suitableRegions && + evaluation.hunts.lowestThreatRatio <= currentHunts.lowestThreatRatio && + evaluation.hunts.bestProjectedExperience >= currentHunts.bestProjectedExperience; + const bool better = evaluation.profile.armor > currentProfile.armor || + evaluation.profile.defense > currentProfile.defense || + candidateMaximumDamage > currentMaximumDamage || + evaluation.hunts.suitableRegions > currentHunts.suitableRegions || + evaluation.hunts.lowestThreatRatio < currentHunts.lowestThreatRatio || + evaluation.hunts.bestProjectedExperience > currentHunts.bestProjectedExperience; + if (currentReady && !evaluation.candidateReady) { + evaluation.rejection = "regresses_readiness"; + evaluatedItems.emplace(offer.itemId, evaluation); + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", "regresses_readiness"); + continue; + } + if (!noWorse) { + evaluation.rejection = better ? "ambiguous_tradeoff" : "non_improving"; + evaluatedItems.emplace(offer.itemId, evaluation); + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", + better ? "ambiguous_tradeoff" : "non_improving"); + continue; + } + if (!better) { + evaluation.rejection = "non_improving"; + evaluatedItems.emplace(offer.itemId, evaluation); + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", "non_improving"); + continue; + } + evaluation.rule = !currentReady && evaluation.candidateReady ? EquipmentDecisionRule::ReadinessRepair : + evaluation.hunts.suitableRegions > currentHunts.suitableRegions ? EquipmentDecisionRule::UnlocksHunt : + EquipmentDecisionRule::ParetoImprovement; + evaluatedItems.emplace(offer.itemId, evaluation); + } + if (!evaluation.rejection.empty()) { + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", + evaluation.rejection.c_str()); + continue; + } + if (reserve == std::numeric_limits::max()) { + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", "recovery_reserve_unavailable"); + continue; + } + if (totalMoney < reserve + evaluation.price) { + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", "unaffordable_after_reserves"); + continue; + } + const std::optional travelSteps = providerRoute(*npc); + if (!travelSteps) { + const char* reason = providerRouteBudgetExhausted ? "provider_evaluation_budget_exhausted" : + providerRouteNodeLimits.find(npc->getID()) != providerRouteNodeLimits.end() ? + "provider_route_node_budget_exhausted" : "provider_unreachable"; + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", reason); + continue; + } + evaluation.travelSteps = *travelSteps; + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "feasible", nullptr); + ++feasibleCandidates; + if (!selected || evaluation.rule > selected->rule || + (evaluation.rule == selected->rule && (evaluation.price < selected->price || + (evaluation.price == selected->price && (evaluation.travelSteps < selected->travelSteps || + (evaluation.travelSteps == selected->travelSteps && (evaluation.itemId < selected->itemId || + (evaluation.itemId == selected->itemId && evaluation.npcId < selected->npcId)))))))) { + selected = evaluation; + } + } + } + std::ostringstream fields; + fields << "\"result\":" << jsonString(selected ? "would_buy" : "no_decision") + << ",\"feasible_candidates\":" << feasibleCandidates + << ",\"catalog_offers_evaluated\":" << catalogOffers + << ",\"catalog_truncated\":" << (catalogTruncated ? "true" : "false") + << ",\"reason\":" << jsonString(selected ? equipmentDecisionRuleName(selected->rule) : "no_justified_offer"); + if (selected) { + fields << ",\"npc_id\":" << selected->npcId << ",\"item_id\":" << selected->itemId + << ",\"price\":" << selected->price << ",\"travel_steps\":" << selected->travelSteps; + } + emit("equipment_offer_shadow", position, fields.str()); +} diff --git a/server/src/playerbothuntregions.cpp b/server/src/playerbothuntregions.cpp index d846249..36fca34 100644 --- a/server/src/playerbothuntregions.cpp +++ b/server/src/playerbothuntregions.cpp @@ -50,10 +50,10 @@ namespace { Position::getDistanceY(left, right) <= heatRadius * 2; } - double expectedMonsterDamagePerSecond(const MonsterType& monsterType, const Player& player) + double expectedMonsterDamagePerSecond(const MonsterType& monsterType, const PlayerBotCombatProfile& profile) { double damagePerSecond = 0; - const double mitigation = player.getArmor() * 0.35 + player.getDefense() * 0.08; + const double mitigation = profile.armor * 0.35 + profile.defense * 0.08; for (const spellBlock_t& attack : monsterType.info.attackSpells) { const double averageDamage = (std::abs(attack.minCombatValue) + std::abs(attack.maxCombatValue)) / 2.0; if (averageDamage <= 0 || attack.speed == 0) { @@ -66,13 +66,10 @@ namespace { return std::max(0.5, damagePerSecond); } - double expectedPlayerDamagePerSecond(const Player& player, const MonsterType& monsterType) + double expectedPlayerDamagePerSecond(const PlayerBotCombatProfile& profile, const MonsterType& monsterType) { - const Item* weapon = player.getWeapon(true); - const int32_t attackValue = weapon ? weapon->getAttack() : 7; - const int32_t attackSkill = weapon ? player.getWeaponSkill(weapon) : player.getSkillLevel(SKILL_FIST); - const int32_t maximumDamage = Weapons::getMaxWeaponDamage(player.getLevel(), attackSkill, attackValue, - player.getAttackFactor()); + const int32_t maximumDamage = Weapons::getMaxWeaponDamage(profile.level, profile.attackSkill, profile.attack, + profile.attackFactor); const double averageDamage = std::max(1.0, maximumDamage / 2.0 - monsterType.info.armor * 0.25 - monsterType.info.defense * 0.15); return averageDamage / 2.0; @@ -224,7 +221,8 @@ namespace { ++huntRegionCacheRevision; } - PlayerBotHuntRegion scoreRegion(Player& player, size_t candidateIndex, const std::set& excludedRegions, + PlayerBotHuntRegion scoreRegion(Player& player, const PlayerBotCombatProfile& profile, size_t candidateIndex, + const std::set& excludedRegions, const std::map& performance, uint32_t huntDurationSeconds) { @@ -238,15 +236,15 @@ namespace { const CachedSpawnBlock& spawn = huntRegionCache.spawns[member]; region.patrolPoints.push_back(nearestApproach(player, spawn.position)); for (const auto& [monsterType, chance] : spawn.monsters) { - PlayerBotHuntMonsterProfile& profile = profiles[monsterType->name]; - profile.name = monsterType->name; - profile.expectedSpawns += chance / 100.0; - profile.experience = monsterType->info.experience; - profile.health = monsterType->info.healthMax; - profile.expectedDamagePerSecond = expectedMonsterDamagePerSecond(*monsterType, player); + PlayerBotHuntMonsterProfile& monsterProfile = profiles[monsterType->name]; + monsterProfile.name = monsterType->name; + monsterProfile.expectedSpawns += chance / 100.0; + monsterProfile.experience = monsterType->info.experience; + monsterProfile.health = monsterType->info.healthMax; + monsterProfile.expectedDamagePerSecond = expectedMonsterDamagePerSecond(*monsterType, profile); const double fightSeconds = monsterType->info.healthMax / - expectedPlayerDamagePerSecond(player, *monsterType); - profile.predictedFightDamage = profile.expectedDamagePerSecond * fightSeconds; + expectedPlayerDamagePerSecond(profile, *monsterType); + monsterProfile.predictedFightDamage = monsterProfile.expectedDamagePerSecond * fightSeconds; region.experiencePerMinute += monsterType->info.experience * (chance / 100.0) * (60000.0 / std::max(spawn.interval, 1)); } @@ -265,9 +263,9 @@ namespace { for (size_t neighbor : huntRegionCache.spawns[anchor].neighbors) { for (const auto& [monsterType, chance] : huntRegionCache.spawns[neighbor].monsters) { (void)chance; - localAttackers.push_back({expectedMonsterDamagePerSecond(*monsterType, player), + localAttackers.push_back({expectedMonsterDamagePerSecond(*monsterType, profile), monsterType->info.healthMax / - expectedPlayerDamagePerSecond(player, *monsterType)}); + expectedPlayerDamagePerSecond(profile, *monsterType)}); } } std::sort(localAttackers.begin(), localAttackers.end(), [](const LocalAttacker& left, const LocalAttacker& right) { @@ -307,7 +305,7 @@ namespace { const uint32_t templeDistance = Position::getDistanceX(templePosition, region.destination) + Position::getDistanceY(templePosition, region.destination) + Position::getDistanceZ(templePosition, region.destination) * 20; - region.threatRatio = worstFightDamage / std::max(player.getMaxHealth(), 1); + region.threatRatio = worstFightDamage / std::max(profile.maximumHealth, 1); region.suitable = region.threatRatio <= maximumThreatRatio && templeDistance <= maximumHuntDistanceFromTemple && geometricDistance <= maximumHuntTravelDistance; @@ -377,13 +375,26 @@ PlayerBotHuntRegionScan PlayerBotHuntRegionPlanner::beginScan(const Player& play } bool PlayerBotHuntRegionPlanner::score(Player& player, uint64_t revision, size_t candidateIndex, - const std::set& excludedRegions, - const std::map& performance, - uint32_t huntDurationSeconds, PlayerBotHuntRegion& region) const + const std::set& excludedRegions, + const std::map& performance, + uint32_t huntDurationSeconds, PlayerBotHuntRegion& region) const +{ + const Item* weapon = player.getWeapon(true); + const PlayerBotCombatProfile profile{ + player.getLevel(), player.getMaxHealth(), player.getArmor(), player.getDefense(), weapon ? weapon->getAttack() : 7, + weapon ? player.getWeaponSkill(weapon) : player.getSkillLevel(SKILL_FIST), player.getAttackFactor(), + }; + return score(player, profile, revision, candidateIndex, excludedRegions, performance, huntDurationSeconds, region); +} + +bool PlayerBotHuntRegionPlanner::score(Player& player, const PlayerBotCombatProfile& profile, uint64_t revision, + size_t candidateIndex, const std::set& excludedRegions, + const std::map& performance, + uint32_t huntDurationSeconds, PlayerBotHuntRegion& region) const { if (revision != getCacheRevision() || candidateIndex >= huntRegionCache.regions.size()) { return false; } - region = scoreRegion(player, candidateIndex, excludedRegions, performance, huntDurationSeconds); + region = scoreRegion(player, profile, candidateIndex, excludedRegions, performance, huntDurationSeconds); return true; } diff --git a/server/src/playerbothuntregions.h b/server/src/playerbothuntregions.h index c53433a..91520cc 100644 --- a/server/src/playerbothuntregions.h +++ b/server/src/playerbothuntregions.h @@ -22,6 +22,16 @@ class Player; class PlayerBotNavigator; +struct PlayerBotCombatProfile { + uint32_t level = 0; + int32_t maximumHealth = 0; + int32_t armor = 0; + int32_t defense = 0; + int32_t attack = 0; + int32_t attackSkill = 0; + float attackFactor = 1.0f; +}; + struct PlayerBotHuntMonsterProfile { std::string name; double expectedSpawns = 0; @@ -79,6 +89,10 @@ class PlayerBotHuntRegionPlanner bool score(Player& player, uint64_t revision, size_t candidateIndex, const std::set& excludedRegions, const std::map& performance, uint32_t huntDurationSeconds, PlayerBotHuntRegion& region) const; + bool score(Player& player, const PlayerBotCombatProfile& profile, uint64_t revision, size_t candidateIndex, + const std::set& excludedRegions, + const std::map& performance, + uint32_t huntDurationSeconds, PlayerBotHuntRegion& region) const; }; #endif diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index a0edbeb..8c4c599 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -76,10 +76,9 @@ bool PlayerBotController::requiresKnightCombatReadiness(const Player& player) co return player.getVocationId() == oracleVocationId; } -bool PlayerBotController::isLegalEquipmentItem(const Player& player, const Item& item) const +bool PlayerBotController::isLegalEquipmentType(const Player& player, const ItemType& type) const { - const ItemType& type = Item::items[item.getID()]; - if (!item.isPickupable() || player.getLevel() < type.minReqLevel || + if (!type.isPickupable() || player.getLevel() < type.minReqLevel || player.getMagicLevel() < type.minReqMagicLevel || ((type.wieldInfo & WIELDINFO_PREMIUM) != 0 && !player.isPremium())) { return false; @@ -87,6 +86,11 @@ bool PlayerBotController::isLegalEquipmentItem(const Player& player, const Item& return type.vocationIds.empty() || type.vocationIds.find(player.getVocationId()) != type.vocationIds.end(); } +bool PlayerBotController::isLegalEquipmentItem(const Player& player, const Item& item) const +{ + return !item.isRemoved() && isLegalEquipmentType(player, Item::items[item.getID()]); +} + bool PlayerBotController::isKnightMeleeWeapon(const Player& player, const Item& item) const { const WeaponType_t weaponType = item.getWeaponType(); @@ -1091,6 +1095,7 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos if (requiresRookgaardDeparture(player)) { return forceOracleDeparture(player, position, decisionReason); } + evaluateEquipmentOffers(player, position); 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 98a62a4..d6a4885 100644 --- a/server/tests/playerbot-gameplay/playerbot_gameplay.lua +++ b/server/tests/playerbot-gameplay/playerbot_gameplay.lua @@ -90,6 +90,20 @@ local function verifyReadiness(playerId, mode, attempts) print("PLAYERBOT_GAMEPLAY_TEST READINESS_" .. string.upper(mode) .. "_PASS") end +local function verifyEquipmentShadow(playerId, mode, money, leftItemId, rightItemId, armorItemId, position) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared during equipment shadow fixture") + local left = player:getSlotItem(CONST_SLOT_LEFT) + local right = player:getSlotItem(CONST_SLOT_RIGHT) + local armor = player:getSlotItem(CONST_SLOT_ARMOR) + local current = player:getPosition() + assert(player:getMoney() == money and left and left:getId() == leftItemId and + ((rightItemId == 0 and not right) or (right and right:getId() == rightItemId)) and + armor and armor:getId() == armorItemId and current.x == position.x and current.y == position.y and current.z == position.z, + "equipment shadow fixture mutated player state") + print("PLAYERBOT_GAMEPLAY_TEST " .. string.upper(mode) .. "_PASS") +end + local function verifyOracleDeparture(playerId, attempts) local player = Player(playerId) assert(player and not player:isRemoved(), "Bot One disappeared during Oracle departure") @@ -564,8 +578,42 @@ function login.onLogin(player) mode == "departure_recovery" or mode == "spell_training" or mode == "stamina_bonus" or mode == "stamina_boundary" or mode == "stamina_normal" or mode == "hunt_planning" or mode == "readiness_ready" or mode == "readiness_upgrade" or mode == "readiness_missing_weapon" or mode == "readiness_supplies" or - mode == "readiness_retention", - "unknown PLAYERBOT_GAMEPLAY_MODE: " .. mode) + mode == "readiness_retention" or mode == "equipment_shadow" or mode == "equipment_shadow_unaffordable" or + mode == "equipment_shadow_no_upgrade", + "unknown PLAYERBOT_GAMEPLAY_MODE: " .. mode) + if mode == "equipment_shadow" or mode == "equipment_shadow_unaffordable" or mode == "equipment_shadow_no_upgrade" then + local town = player:getTown() + assert(player:getLevel() == 8 and player:getVocation():getId() == 4 and town and town:getId() == thaisTownId, + "equipment shadow fixture did not load the level-8 Thais Knight") + if mode ~= "equipment_shadow_no_upgrade" then + local weapon = player:getSlotItem(CONST_SLOT_LEFT) + assert(weapon and weapon:remove(), "equipment shadow fixture could not clear the equipped sword") + assert(player:addItem(starterWeaponId, 1, false, 1, CONST_SLOT_LEFT), + "equipment shadow fixture could not equip the starter weapon") + else + local left = player:getSlotItem(CONST_SLOT_LEFT) + local right = player:getSlotItem(CONST_SLOT_RIGHT) + assert(left and left:remove() and right and right:remove(), + "equipment shadow fixture could not clear the one-handed loadout") + assert(player:addItem(2377, 1, false, 1, CONST_SLOT_LEFT), + "equipment shadow fixture could not equip the two-handed sword") + end + if mode == "equipment_shadow" or mode == "equipment_shadow_no_upgrade" then + local backpack = player:getSlotItem(CONST_SLOT_BACKPACK) + assert(backpack and backpack:addItem(2152, 20), "equipment shadow fixture could not add surplus money") + elseif mode == "equipment_shadow_unaffordable" then + removeAll(player, ITEM_GOLD_COIN) + end + suppressNearbyMonsters(player:getId()) + local left = player:getSlotItem(CONST_SLOT_LEFT) + local right = player:getSlotItem(CONST_SLOT_RIGHT) + local armor = player:getSlotItem(CONST_SLOT_ARMOR) + assert(left and armor, "equipment shadow fixture lost required equipment") + addEvent(verifyEquipmentShadow, 250, player:getId(), mode, player:getMoney(), left:getId(), + right and right:getId() or 0, armor:getId(), player:getPosition()) + print("PLAYERBOT_GAMEPLAY_TEST " .. string.upper(mode) .. "_START") + return true + end if mode == "mainland" then local town = player:getTown() assert(player:getLevel() == 8 and player:getVocation():getId() == 4 and town and town:getId() == thaisTownId,