diff --git a/docs/playerbots.md b/docs/playerbots.md index a80577b..fab1e64 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -20,7 +20,7 @@ This design is intended to scale without one thread, graphical client, renderer, | Loot | Ordinary; edge cases have focused evidence | Opens owned corpse containers through normal item use before inspecting contents, ranks known saleable loot by value and weight, and retries inaccessible corpses within bounds. Skinning and other secondary corpse actions are unsupported. | | Navigation | Ordinary; transition and recovery scenarios have focused evidence | Routes across the loaded map using a shared coarse topology, then plans and executes local paths and supported transitions. Supports tools, doors, floor changes, teleports, and registered NPC travel. Detailed search and recovery have budgets; unsupported transitions and changing obstacles can prevent a journey. Static topology changes may require a supported reload or restart. | | NPC service | Ordinary | Discovers loaded shops, bankers, spell trainers, and registered travel offers; buys supplies, sells known loot, banks, and verifies transactions. Custom dialogue or opaque travel conditions are excluded unless explicitly modeled. | -| Equipment and depots | Ordinary; restart and remote liquidation paths have focused evidence | Buys or equips supported upgrades, deposits retained loot, and can sell from local or remote depots. Two-handed loadout trade-offs are unsupported and those items remain protected. | +| Equipment and depots | Ordinary; restart and remote liquidation paths have focused evidence | Buys or equips supported upgrades, deposits retained loot, and can sell from local or remote depots. A Knight with an empty back slot can buy a standard backpack from a discovered shop. To upgrade a basic bag, it stages the complete bag in a discovered depot, buys with the empty back slot as the verified receipt destination, then retrieves the old bag into the new backpack. The bot stages only after its bank balance can pay the backpack price and preserve the configured reserve; carried coins inside the bag do not count for this preflight. Bounded failures restore the bag before purchase when possible. An unresolved depot recovery is stored on the player and tagged bag, stops the controller with an explicit failure, and resumes before normal goals after reload. If death equips a replacement basic bag, recovery deposits that complete bag before restoring the tagged original. Other containers and two-handed loadout trade-offs are unsupported and protected. | | Spells | Ordinary for audited Knight spells; calibration and overflow training have focused evidence | Can learn and use selected healing, support, and offensive spells through normal speech. Loaded spell rules remain authoritative. Observations may rank legal casts but never weaken safety or legality. | | Rewards and Oracle | Ordinary goal selection; claiming and departure have focused evidence | Claims supported shared container rewards and one legacy doublet case. The tagged Oracle departure path supports a level 8–10 character with no vocation. Scripted quests, levers, hazardous quest transit, and general dialogue reasoning are unsupported. | | Death and restart | Ordinary; failure paths have focused evidence | Uses normal death, save, temple login, and bounded relog recovery. It reconstructs intent from persisted state rather than resuming an interrupted route, conversation, open container, or transaction in place. | diff --git a/docs/testing.md b/docs/testing.md index 3d90cf2..021abe1 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -52,6 +52,7 @@ On Linux, the C++ contract checks require a C++17 compiler. Lua fixture checks r ```sh sh server/tests/playerbot_contracts.sh +sh server/tests/playerbot_equipment_purchase_contracts.sh lua scripts/test-playerbot-fixture-isolation.lua lua scripts/test-playerbot-hunt-fixture.lua lua scripts/test-playerbot-depot-fixture.lua diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index e26bb9c..a5befff 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -269,6 +269,7 @@ bool PlayerBotController::handleHealing(Player* player, const Position& currentP if (command.type == PlayerBotSurvivalCommandType::CastSpell) return dispatchSpellCommand(*player, currentPosition, command); if (command.type == PlayerBotSurvivalCommandType::Wait) return true; if (command.type == PlayerBotSurvivalCommandType::InterruptForService) { + if (progressionRuntime.equipmentBackpackRecoveryActive()) return false; std::ostringstream fields; fields << "\"action\":\"heal\",\"result\":\"skipped\",\"reason\":\"missing_supply\"" << ",\"method\":" << jsonString(snapshot.potionItemId == smallHealthPotionItemId ? "small_health_potion" : "health_potion") @@ -281,6 +282,7 @@ bool PlayerBotController::handleHealing(Player* player, const Position& currentP << ",\"resource_before\":0,\"resource_after\":0"; emit("action_result", currentPosition, fields.str()); if (progressionRuntime.session().active() != PlayerBotProgressionProcedure::None) { + if (interruptBackpackUpgradeForService(*player, currentPosition, "healing_supply_missing")) return true; if (progressionRuntime.session().active(PlayerBotProgressionProcedure::OracleDeparture)) { finishOracleDeparture(player, currentPosition, "interrupted", "healing_supply_missing"); } else { diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index f9150d8..ae16852 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -178,9 +178,11 @@ void PlayerBotController::start(const Position& position, bool recovered, uint32 refreshItemValues(); const bool startInHunt = !recovered && fixtureDriver.startInHunt(); Player* controlledPlayer = g_game.getPlayerByID(playerId); + const bool backpackUpgradeResumed = controlledPlayer && resumeBackpackUpgrade(*controlledPlayer, position); + if (!turnRouter.running()) return; const bool departureComplete = controlledPlayer && departurePlanner.hasCompleted(departureSnapshot(*controlledPlayer)); const bool departureRequired = controlledPlayer && departurePlanner.required(departureSnapshot(*controlledPlayer)); - const bool useGoalSelector = controlledPlayer && !startInHunt && + const bool useGoalSelector = controlledPlayer && !backpackUpgradeResumed && !startInHunt && (departureRequired || (!recovered && fixtureDriver.startWithGoalSelection())); if (!fixtureDriver.magicTrainingScenario() && !fixtureDriver.deferInitialization() && useGoalSelector && !selectTopLevelGoal(*controlledPlayer, position, "startup")) { @@ -231,7 +233,9 @@ void PlayerBotController::start(const Position& position, bool recovered, uint32 lifecycle << "\"status\":\"online\",\"message\":\"Playerbot online\"" << ",\"recovered\":" << (recovered ? "true" : "false") << ",\"recovery_count\":" << recoveryCount - << ",\"objective\":" << jsonString((fixtureDriver.magicTrainingScenario() || fixtureDriver.deferInitialization()) ? "fixture_pending" : useGoalSelector ? PlayerBotGoalArbiter::goalName(progressionRuntime.activeGoal()) : + << ",\"objective\":" << jsonString(backpackUpgradeResumed ? "buy_equipment" : + (fixtureDriver.magicTrainingScenario() || fixtureDriver.deferInitialization()) ? "fixture_pending" : + useGoalSelector ? PlayerBotGoalArbiter::goalName(progressionRuntime.activeGoal()) : (startInHunt ? "hunt" : "service")) << ",\"step_speed\":" << (g_game.getPlayerByID(playerId) ? g_game.getPlayerByID(playerId)->getSpeed() : 0) << ",\"spell_calibration_profiles\":" << survivalRuntime.calibrationSize() @@ -247,7 +251,9 @@ void PlayerBotController::start(const Position& position, bool recovered, uint32 emitFixtureEvents(fixtureDriver.runAdaptiveChallenge(*controlledPlayer), position); emitFixtureEvents(fixtureDriver.runDepotRiskFallbackContract(), position); } - if (fixtureDriver.magicTrainingScenario() || fixtureDriver.deferInitialization()) { + if (backpackUpgradeResumed) { + // Durable backpack recovery owns the normal progression command until resolved. + } else if (fixtureDriver.magicTrainingScenario() || fixtureDriver.deferInitialization()) { fixtureDriver.beginDelayedInitialization(); } else if (useGoalSelector) { // The selected goal initialized its own executor state. diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index 223769e..efa7ebd 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -251,6 +251,11 @@ class PlayerBotController : public std::enable_shared_from_this backpackUpgradeDepot; + int32_t backpackUpgradeToken = 0; + uint16_t backpackUpgradeDepotId = 0; size_t equipmentProviderScanOffset = 0; size_t equipmentOfferScanOffset = 0; PlayerBotSpellTrainingPlanner spellTrainingPlanner; diff --git a/server/src/playerbotequipment.cpp b/server/src/playerbotequipment.cpp index 65be18f..fbfaae7 100644 --- a/server/src/playerbotequipment.cpp +++ b/server/src/playerbotequipment.cpp @@ -22,7 +22,178 @@ namespace { constexpr size_t maximumEquipmentProviderApproaches = 4; constexpr uint64_t maximumEquipmentProviderPathNodes = 5000; constexpr size_t maximumEquipmentCatalogOffers = 64; + + // Internal playerbot state. Keep these below TFS's reserved 10,000,000 range and outside datapack quest keys. + constexpr uint32_t backpackUpgradeStatusStorage = 9990000; + constexpr uint32_t backpackUpgradeDepotStorage = 9990001; + constexpr uint32_t backpackUpgradeTokenStorage = 9990002; + constexpr int32_t backpackUpgradeTagged = 1; + constexpr int32_t backpackUpgradeStaged = 2; + constexpr int32_t backpackUpgradePurchased = 3; + const std::string backpackUpgradeMarker = "playerbot_backpack_upgrade"; + const std::string backpackUpgradeReplacementMarker = "playerbot_backpack_upgrade_replacement"; + + bool hasBackpackUpgradeToken(Item& item, const std::string& marker, int32_t token) + { + const ItemAttributes::CustomAttribute* attribute = item.getCustomAttribute(marker); + return attribute && attribute->value.type() == typeid(int64_t) && + boost::get(attribute->value) == token; + } + + void collectTaggedBags(Container& container, const std::string& marker, int32_t token, std::vector& matches) + { + for (ContainerIterator it = container.iterator(); it.hasNext(); it.advance()) { + Item* item = *it; + if (item->getID() == ITEM_BAG && hasBackpackUpgradeToken(*item, marker, token)) matches.push_back(item); + } + } + + Item* findTaggedBag(Player& player, uint16_t depotId, int32_t token, const std::string& marker, uint32_t* matchCount) + { + std::vector matches; + if (token > 0) { + for (int32_t slot = CONST_SLOT_FIRST; slot <= CONST_SLOT_LAST; ++slot) { + Item* root = player.getInventoryItem(static_cast(slot)); + if (!root) continue; + if (root->getID() == ITEM_BAG && hasBackpackUpgradeToken(*root, marker, token)) matches.push_back(root); + if (Container* container = root->getContainer()) collectTaggedBags(*container, marker, token, matches); + } + if (depotId != 0) { + if (DepotChest* chest = player.getDepotChest(depotId, false)) { + collectTaggedBags(*chest, marker, token, matches); + } + } + } + if (matchCount) *matchCount = static_cast(matches.size()); + return matches.size() == 1 ? matches.front() : nullptr; + } +} + +Item* PlayerBotController::taggedBackpackUpgradeBag(Player& player, uint32_t* matchCount) const +{ + return findTaggedBag(player, backpackUpgradeDepotId, backpackUpgradeToken, backpackUpgradeMarker, matchCount); +} + +Item* PlayerBotController::taggedBackpackUpgradeReplacementBag(Player& player, uint32_t* matchCount) const +{ + return findTaggedBag(player, backpackUpgradeDepotId, backpackUpgradeToken, backpackUpgradeReplacementMarker, matchCount); +} + +void PlayerBotController::clearBackpackUpgradePersistence(Player& player, bool clearItemMarker) +{ + if (clearItemMarker) { + uint32_t matches = 0; + if (Item* bag = taggedBackpackUpgradeBag(player, &matches); bag && matches == 1) { + bag->removeCustomAttribute(backpackUpgradeMarker); + } + matches = 0; + if (Item* replacement = taggedBackpackUpgradeReplacementBag(player, &matches); replacement && matches == 1) { + replacement->removeCustomAttribute(backpackUpgradeReplacementMarker); + } + } + player.addStorageValue(backpackUpgradeStatusStorage, -1); + player.addStorageValue(backpackUpgradeDepotStorage, -1); + backpackUpgradeDepot.reset(); + backpackUpgradeDepotId = 0; + backpackUpgradeToken = 0; +} + +bool PlayerBotController::resumeBackpackUpgrade(Player& player, const Position& position) +{ + int32_t status = -1; + int32_t depotId = -1; + int32_t token = -1; + if (!player.getStorageValue(backpackUpgradeStatusStorage, status) || status < backpackUpgradeTagged || + status > backpackUpgradePurchased) return false; + if (!player.getStorageValue(backpackUpgradeDepotStorage, depotId) || depotId <= 0 || depotId > UINT16_MAX || + !player.getStorageValue(backpackUpgradeTokenStorage, token) || token <= 0) { + stop("backpack_upgrade_persistence_invalid", position); + return true; + } + backpackUpgradeDepotId = static_cast(depotId); + backpackUpgradeToken = token; + backpackUpgradeDepot.reset(); + depotWorkflow.reset(); + sellLootPlan.reset(); + uint32_t matches = 0; + Item* bag = taggedBackpackUpgradeBag(player, &matches); + if (!bag || matches != 1) { + stop(matches == 0 ? "backpack_upgrade_marker_missing" : "backpack_upgrade_marker_duplicate", position); + return true; + } + uint32_t replacementMatches = 0; + taggedBackpackUpgradeReplacementBag(player, &replacementMatches); + if (replacementMatches > 1) { + stop("backpack_upgrade_replacement_marker_duplicate", position); + return true; + } + Item* backItem = player.getInventoryItem(CONST_SLOT_BACKPACK); + Container* acquired = backItem && backItem->getID() == ITEM_BACKPACK ? backItem->getContainer() : nullptr; + for (Cylinder* parent = bag->getParent(); acquired && parent; parent = parent->getParent()) { + if (parent == acquired) { + clearBackpackUpgradePersistence(player, true); + emit("backpack_upgrade_recovery", position, "\"result\":\"already_complete\""); + return false; + } + } + if (bag == backItem) { + clearBackpackUpgradePersistence(player, true); + emit("backpack_upgrade_recovery", position, "\"result\":\"already_restored\""); + return false; + } + if (acquired) { + status = backpackUpgradePurchased; + player.addStorageValue(backpackUpgradeStatusStorage, backpackUpgradePurchased); + } + const bool blockedReceipt = backItem && !acquired; + const bool purchasedBackpackMissing = status == backpackUpgradePurchased && !acquired; + PlayerBotEquipmentOfferEvaluation plan; + if (!blockedReceipt && status != backpackUpgradePurchased) { + auto selected = evaluateEquipmentOffers(player, position); + if (selected && selected->backpackAcquisition) plan = std::move(*selected); + } + if (plan.itemId == 0) { + plan.itemId = ITEM_BACKPACK; + plan.slot = CONST_SLOT_BACKPACK; + plan.backpackAcquisition = true; + } + plan.bagUpgrade = true; + progressionRuntime.resumeEquipmentBackpack(std::move(plan), true, acquired != nullptr); + if (purchasedBackpackMissing || blockedReceipt || + (status != backpackUpgradePurchased && progressionRuntime.equipmentPurchase().plan().npcId == 0)) { + const char* reason = purchasedBackpackMissing ? "purchased_backpack_missing" : + blockedReceipt ? "replacement_backpack_present" : "resume_offer_unavailable"; + progressionRuntime.beginEquipmentBackpackRecovery(reason, false); + emit("backpack_upgrade_recovery", position, "\"result\":\"resumed\",\"status\":\"restore\",\"reason\":" + jsonString(reason)); + return true; + } + emit("backpack_upgrade_recovery", position, "\"result\":\"resumed\",\"status\":" + + jsonString(status == backpackUpgradePurchased ? "retrieve" : "purchase")); + return true; +} + +bool PlayerBotController::interruptBackpackUpgradeForService(Player& player, const Position& position, const char* reason) +{ + if (!progressionRuntime.active(PlayerBotProgressionProcedure::BuyEquipment) || + !progressionRuntime.equipmentPurchase().plan().bagUpgrade) return false; + Item* backItem = player.getInventoryItem(CONST_SLOT_BACKPACK); + Item* taggedBag = taggedBackpackUpgradeBag(player); + if ((backpackUpgradeToken == 0 && backItem && backItem->getID() == ITEM_BAG) || taggedBag == backItem) { + clearBackpackUpgradePersistence(player, taggedBag != nullptr); + progressionRuntime.finish(); + beginService(&player, position, reason); + schedule(SCHEDULER_MINTICKS); + return true; + } + const bool purchased = player.getInventoryItem(CONST_SLOT_BACKPACK) && + player.getInventoryItem(CONST_SLOT_BACKPACK)->getID() == ITEM_BACKPACK; + progressionRuntime.beginEquipmentBackpackRecovery(reason, purchased); + resetNavigation(); + emit("backpack_upgrade_recovery", position, "\"result\":\"started\",\"reason\":" + jsonString(reason)); + schedule(SCHEDULER_MINTICKS); + return true; } + PlayerBotController::EquipmentHuntSummary PlayerBotController::equipmentHuntSummary(Player& player, const PlayerBotCombatProfile& profile) const { @@ -77,6 +248,8 @@ void PlayerBotController::emitEquipmentOffer(const Player& player, const Equipme << ",\"combat_ready\":" << (evaluation.candidateReady ? "true" : "false") << '}' << ",\"rule\":" << jsonString(PlayerBotEquipmentPolicy::decisionRuleName(evaluation.rule)) << ",\"carried\":" << (evaluation.carried ? "true" : "false") + << ",\"backpack_acquisition\":" << (evaluation.backpackAcquisition ? "true" : "false") + << ",\"bag_upgrade\":" << (evaluation.bagUpgrade ? "true" : "false") << ",\"provider_position\":{\"x\":" << evaluation.npcPosition.x << ",\"y\":" << evaluation.npcPosition.y << ",\"z\":" << static_cast(evaluation.npcPosition.z) << '}'; if (reason) { @@ -94,7 +267,7 @@ std::optional PlayerBotController const EquipmentLoadout currentLoadout = PlayerBotEquipmentAdapter::loadout(player); const PlayerBotCombatProfile currentProfile = equipmentPolicy.combatProfile(playerFacts, currentLoadout); const EquipmentHuntSummary currentHunts = equipmentHuntSummary(player, currentProfile); - const Item* backpackItem = player.getInventoryItem(CONST_SLOT_BACKPACK); + Item* backpackItem = player.getInventoryItem(CONST_SLOT_BACKPACK); const uint16_t potionItemId = recoveryPotionItemId(player.getVocationId()); const PlayerBotEquipmentReadinessInput readiness{ backpackItem && backpackItem->getContainer(), @@ -103,6 +276,13 @@ std::optional PlayerBotController returnCapacityThreshold, }; const bool currentReady = equipmentPolicy.loadoutReady(playerFacts, currentLoadout, readiness); + const bool bagUpgrade = backpackItem && backpackItem->getID() == ITEM_BAG; + Container* currentBackContainer = backpackItem ? backpackItem->getContainer() : nullptr; + const PlayerBotBackpackAcquisition backpackPlan = equipmentPolicy.standardBackpackAcquisition( + playerFacts, backpackItem ? backpackItem->getID() : 0, currentBackContainer, + currentBackContainer ? static_cast(currentBackContainer->size()) : 0, + currentBackContainer ? currentBackContainer->capacity() : 0); + const bool backpackAcquisition = backpackPlan.eligible; std::map evaluatedItems; std::map> providerRoutes; std::set providerRouteNodeLimits; @@ -188,6 +368,18 @@ std::optional PlayerBotController if (leftCost.has_value() != rightCost.has_value()) return leftCost.has_value(); return leftCost && rightCost && *leftCost != *rightCost && *leftCost < *rightCost; }); + Npc* backpackProvider = nullptr; + if (backpackAcquisition) { + for (Npc* provider : shopProviders) { + const auto& offers = provider->getShopOffers(); + if (std::any_of(offers.begin(), offers.end(), [](const ShopInfo& offer) { + return offer.itemId == ITEM_BACKPACK && offer.buyPrice != 0; + })) { + backpackProvider = provider; + break; + } + } + } const bool providersTruncated = shopProviders.size() > maximumEquipmentCatalogProviders; if (providersTruncated) { constexpr size_t nearbyProviders = maximumEquipmentCatalogProviders / 2; @@ -197,6 +389,9 @@ std::optional PlayerBotController for (size_t index = 0; index < maximumEquipmentCatalogProviders - nearbyProviders; ++index) { selected.push_back(shopProviders[nearbyProviders + (offset + index) % tailSize]); } + if (backpackProvider && std::find(selected.begin(), selected.end(), backpackProvider) == selected.end()) { + selected.back() = backpackProvider; + } equipmentProviderScanOffset = (offset + maximumEquipmentCatalogProviders - nearbyProviders) % tailSize; shopProviders = std::move(selected); } else { @@ -210,7 +405,7 @@ std::optional PlayerBotController ProviderCatalog provider{npc, {}}; for (const ShopInfo& offer : npc->getShopOffers()) { const PlayerBotEquipmentItemSnapshot item = PlayerBotEquipmentAdapter::item(offer.itemId); - if (item.head || item.armorSlot || item.legs || item.feet || item.left || item.right) { + if ((backpackAcquisition && offer.itemId == ITEM_BACKPACK) || item.head || item.armorSlot || item.legs || item.feet || item.left || item.right) { provider.offers.push_back(&offer); } } @@ -237,6 +432,11 @@ std::optional PlayerBotController if (!allCatalogOffers.empty()) { const size_t offset = equipmentOfferScanOffset % allCatalogOffers.size(); std::rotate(allCatalogOffers.begin(), allCatalogOffers.begin() + offset, allCatalogOffers.end()); + if (backpackAcquisition) { + std::stable_partition(allCatalogOffers.begin(), allCatalogOffers.end(), [](const CatalogOffer& candidate) { + return candidate.offer->itemId == ITEM_BACKPACK; + }); + } std::stable_partition(allCatalogOffers.begin(), allCatalogOffers.end(), [&carriedCatalogItems, totalMoney, reserve](const CatalogOffer& candidate) { const uint64_t price = candidate.offer->buyPrice; return carriedCatalogItems.find(candidate.offer->itemId) != carriedCatalogItems.end() || @@ -253,9 +453,24 @@ std::optional PlayerBotController const ShopInfo& offer = *catalogOffer.offer; ++catalogOffers; EquipmentOfferEvaluation evaluation; - const bool carried = carriedCatalogItems.find(offer.itemId) != carriedCatalogItems.end(); + const bool backpackOffer = backpackAcquisition && offer.itemId == ITEM_BACKPACK; + const bool carried = !backpackOffer && carriedCatalogItems.find(offer.itemId) != carriedCatalogItems.end(); if (auto item = evaluatedItems.find(offer.itemId); item != evaluatedItems.end()) { evaluation = item->second; + } else if (backpackOffer) { + evaluation.itemId = ITEM_BACKPACK; + evaluation.slot = CONST_SLOT_BACKPACK; + evaluation.replacedItemId = bagUpgrade ? ITEM_BAG : 0; + evaluation.profile = currentProfile; + evaluation.hunts = currentHunts; + evaluation.currentReady = currentReady; + evaluation.candidateReady = equipmentPolicy.loadoutReady(playerFacts, currentLoadout, + {true, readiness.suppliesReady, readiness.effectiveFreeCapacity, readiness.minimumFreeCapacity}, + Item::items[offer.itemId].weight); + evaluation.backpackAcquisition = true; + evaluation.bagUpgrade = bagUpgrade; + evaluation.rule = PlayerBotEquipmentDecisionRule::ReadinessRepair; + evaluatedItems.emplace(offer.itemId, evaluation); } else { evaluation = equipmentPolicy.evaluateCandidate( playerFacts, PlayerBotEquipmentAdapter::item(offer.itemId), currentLoadout, currentProfile, currentHunts, currentReady, readiness, @@ -339,6 +554,13 @@ std::optional PlayerBotController void PlayerBotController::beginEquipmentPurchase(Player& player, const Position& position, EquipmentOfferEvaluation evaluation) { + backpackUpgradeDepot.reset(); + if (evaluation.bagUpgrade) { + depotWorkflow.reset(); + sellLootPlan.reset(); + } + backpackUpgradeToken = 0; + backpackUpgradeDepotId = 0; progressionRuntime.beginEquipmentPurchase(std::move(evaluation)); const auto& purchase = progressionRuntime.equipmentPurchase().plan(); if (!purchase.carried) { @@ -349,7 +571,9 @@ void PlayerBotController::beginEquipmentPurchase(Player& player, const Position& << jsonString(PlayerBotEquipmentPolicy::decisionRuleName(purchase.rule)) << ",\"npc_id\":" << purchase.npcId << ",\"item_id\":" << purchase.itemId << ",\"price\":" << purchase.price << ",\"travel_steps\":" << purchase.travelSteps - << ",\"acquisition\":" << jsonString(purchase.carried ? "carried" : "purchase"); + << ",\"acquisition\":" << jsonString(purchase.carried ? "carried" : "purchase") + << ",\"backpack_acquisition\":" << (purchase.backpackAcquisition ? "true" : "false") + << ",\"bag_upgrade\":" << (purchase.bagUpgrade ? "true" : "false"); emit("strategy_selection", position, fields.str()); say(player, purchase.carried ? "Equipping a carried equipment upgrade." : "Going to buy a justified equipment upgrade."); @@ -358,11 +582,16 @@ void PlayerBotController::beginEquipmentPurchase(Player& player, const Position& void PlayerBotController::finishEquipmentPurchase(Player* player, const Position& position, const char* result, const char* reason) { + // Recovery reasons can belong to the session that finish() clears below. + const std::string completionReason = reason; + reason = completionReason.c_str(); const auto& purchase = progressionRuntime.equipmentPurchase().plan(); std::ostringstream fields; fields << "\"goal\":\"buy_equipment\",\"npc_id\":" << purchase.npcId << ",\"item_id\":" << purchase.itemId << ",\"price\":" << purchase.price << ",\"rule\":" << jsonString(PlayerBotEquipmentPolicy::decisionRuleName(purchase.rule)) + << ",\"backpack_acquisition\":" << (purchase.backpackAcquisition ? "true" : "false") + << ",\"bag_upgrade\":" << (purchase.bagUpgrade ? "true" : "false") << ",\"result\":" << jsonString(result) << ",\"reason\":" << jsonString(reason); emit("strategy_objective_result", position, fields.str()); emit("goal_result", position, @@ -380,11 +609,29 @@ void PlayerBotController::finishEquipmentPurchase(Player* player, const Position player->closeShopWindow(); say(*player, std::string("Equipment purchase ") + result + ": " + reason + '.'); } + const bool unresolvedPreservation = purchase.bagUpgrade && !succeeded && std::strncmp(reason, "old_bag_", 8) == 0; + const bool terminalFailure = unresolvedPreservation || std::strcmp(reason, "transaction_delta_mismatch") == 0; progressionRuntime.completeEquipmentPurchase(succeeded, succeeded ? equipmentPurchaseSuccessCooldown : equipmentPurchaseFailureCooldown); + if (player && purchase.bagUpgrade && !unresolvedPreservation) { + clearBackpackUpgradePersistence(*player, true); + } else if (!purchase.bagUpgrade) { + backpackUpgradeDepot.reset(); + backpackUpgradeDepotId = 0; + backpackUpgradeToken = 0; + } progressionRuntime.finish(); resetNavigation(); + if (terminalFailure) { + stop((std::string("equipment_purchase_") + reason).c_str(), position); + return; + } turnRouter.setCyclePhase(CyclePhase::Service); + if (!succeeded && player && std::strcmp(reason, "healing_supply_missing") == 0) { + beginService(player, position, reason); + schedule(SCHEDULER_MINTICKS); + return; + } if (succeeded && player && fixtureDriver.equipmentPurchaseCompletion(*player).pause) { return; } @@ -407,6 +654,40 @@ void PlayerBotController::processEquipmentPurchase(Player* player, const Positio Npc* shopOwner = player->getShopOwner(onBuy, onSell); PlayerBotEquipmentPurchaseObservation observation; observation.actionAvailable = player->canDoAction(); + if (purchase.backpackAcquisition) { + Item* backItem = player->getInventoryItem(CONST_SLOT_BACKPACK); + observation.backpackReceiptSafe = backItem == nullptr; + uint32_t markerMatches = 0; + Item* taggedBag = taggedBackpackUpgradeBag(*player, &markerMatches); + if (backpackUpgradeToken > 0 && markerMatches != 1) { + finishEquipmentPurchase(player, position, "failed", markerMatches == 0 ? + "old_bag_marker_missing" : "old_bag_marker_duplicate"); + return; + } + uint32_t replacementMatches = 0; + Item* replacementBag = taggedBackpackUpgradeReplacementBag(*player, &replacementMatches); + if (replacementMatches > 1) { + finishEquipmentPurchase(player, position, "failed", "old_bag_replacement_marker_duplicate"); + return; + } + observation.depotAvailable = markerMatches <= 1; + observation.oldBagEquipped = purchase.bagUpgrade && taggedBag && taggedBag == backItem; + observation.replacementBagEquipped = purchase.bagUpgrade && backItem && backItem->getID() == ITEM_BAG && backItem != taggedBag; + Container* acquired = backItem && backItem->getID() == ITEM_BACKPACK ? backItem->getContainer() : nullptr; + observation.equipmentVerified = acquired != nullptr; + observation.equipmentAvailable = acquired != nullptr; + observation.backpackDestinationOpen = acquired && player->getContainerID(acquired) >= 0; + for (Cylinder* parent = taggedBag ? taggedBag->getParent() : nullptr; parent; parent = parent->getParent()) { + if (parent == acquired) { observation.oldBagPreserved = true; break; } + } + if (backpackUpgradeDepotId != 0) { + DepotChest* chest = player->getDepotChest(backpackUpgradeDepotId, false); + observation.depotAvailable = observation.depotAvailable && chest != nullptr; + observation.depotOpen = chest && player->getContainerByID(depotChestContainerId) == chest; + observation.oldBagAtDepot = chest && taggedBag && taggedBag->getParent() == chest; + observation.replacementBagAtDepot = chest && replacementBag && replacementBag->getParent() == chest; + } + } if (!purchase.carried) { observation.providerAvailable = npc && playerBotNpcHasCapability(*npc, PlayerBotNpcCapability::Shop); observation.offerAvailable = offer && offer->buyPrice == purchase.price; @@ -416,14 +697,51 @@ void PlayerBotController::processEquipmentPurchase(Player* player, const Positio const uint64_t reserve = spellTrainingReserve(*player); const uint64_t money = player->getMoney() + player->getBankBalance(); observation.fundingAvailable = reserve != std::numeric_limits::max() && money >= purchase.price && money - purchase.price >= reserve; + observation.bankFundingAvailable = reserve != std::numeric_limits::max() && + player->getBankBalance() >= purchase.price && player->getBankBalance() - purchase.price >= reserve; } - if (progressionRuntime.equipmentPurchase().stage() == PlayerBotEquipmentPurchaseStage::Travel) { + const PlayerBotEquipmentPurchaseStage equipmentStage = progressionRuntime.equipmentPurchase().stage(); + if (purchase.bagUpgrade && (equipmentStage == PlayerBotEquipmentPurchaseStage::TravelDepot || + equipmentStage == PlayerBotEquipmentPurchaseStage::ReturnDepot)) { + if (!backpackUpgradeDepot) { + if (!discoverDepot(*player, position)) return; + const PlayerBotDepotSnapshot selected = depotWorkflow.snapshot(); + if (!selected.hasSelectedDepot) { schedule(blockedRouteRetryInterval); return; } + backpackUpgradeDepot = selected.selected; + backpackUpgradeDepotId = selected.selected.depotId; + if (backpackUpgradeToken == 0) { + Item* bag = player->getInventoryItem(CONST_SLOT_BACKPACK); + int32_t previousToken = 0; + player->getStorageValue(backpackUpgradeTokenStorage, previousToken); + backpackUpgradeToken = previousToken >= INT32_MAX ? 1 : std::max(1, previousToken + 1); + if (!bag || bag->getID() != ITEM_BAG) { + finishEquipmentPurchase(player, position, "failed", "old_bag_tag_source_unavailable"); + return; + } + std::string marker = backpackUpgradeMarker; + bag->setCustomAttribute(marker, static_cast(backpackUpgradeToken)); + player->addStorageValue(backpackUpgradeTokenStorage, backpackUpgradeToken); + player->addStorageValue(backpackUpgradeDepotStorage, backpackUpgradeDepotId); + player->addStorageValue(backpackUpgradeStatusStorage, backpackUpgradeTagged); + emit("backpack_upgrade_persistence", position, "\"result\":\"tagged\",\"depot_id\":" + + std::to_string(backpackUpgradeDepotId) + ",\"token\":" + std::to_string(backpackUpgradeToken)); + } + } + observation.depotReached = Position::areInRange<1, 1, 0>(position, backpackUpgradeDepot->approachPosition); + if (!observation.depotReached) { + if (!processNavigation(player, position, backpackUpgradeDepot->approachPosition)) { + observation.depotNavigationFailed = navigationRuntime.fixedTargetRouteFailureCount() >= maximumProgressionAttempts || + navigationRuntime.stepFailureCount() >= maximumRepeatedNavigationStepFailures; + } + } + } + if (equipmentStage == PlayerBotEquipmentPurchaseStage::Travel) { bool approachUnavailable = false; observation.navigationReached = processNpcApproach(player, position, npc, purchase.approachPosition, approachUnavailable); observation.navigationFailed = approachUnavailable || navigationRuntime.fixedTargetRouteFailureCount() >= maximumProgressionAttempts || navigationRuntime.stepFailureCount() >= maximumRepeatedNavigationStepFailures; - } else if (progressionRuntime.equipmentPurchase().stage() == PlayerBotEquipmentPurchaseStage::Purchase && + } else if (!purchase.bagUpgrade && progressionRuntime.equipmentPurchase().stage() == PlayerBotEquipmentPurchaseStage::Purchase && observation.providerAvailable && observation.offerAvailable && !observation.providerInRange) { progressionRuntime.restartEquipmentConversation(); player->closeShopWindow(false); @@ -431,8 +749,10 @@ void PlayerBotController::processEquipmentPurchase(Player* player, const Positio processNpcApproach(player, position, npc, purchase.approachPosition, approachUnavailable); if (approachUnavailable) finishEquipmentPurchase(player, position, "failed", "route_unavailable"); return; - } else if (progressionRuntime.equipmentPurchase().stage() == PlayerBotEquipmentPurchaseStage::Equip || - progressionRuntime.equipmentPurchase().stage() == PlayerBotEquipmentPurchaseStage::VerifyEquipment) { + } else if (equipmentStage == PlayerBotEquipmentPurchaseStage::Equip || + equipmentStage == PlayerBotEquipmentPurchaseStage::VerifyEquipment || + equipmentStage == PlayerBotEquipmentPurchaseStage::RetrieveBackpack || + equipmentStage == PlayerBotEquipmentPurchaseStage::VerifyBackpackRetrieved) { Item* purchased = g_game.findItemOfType(player, purchase.itemId, true); Item* equipped = player->getInventoryItem(purchase.slot); observation.equipmentVerified = equipped && equipped->getID() == purchase.itemId; @@ -471,7 +791,23 @@ void PlayerBotController::processEquipmentPurchase(Player* player, const Positio observation.money = player->getMoney(); observation.bankBalance = player->getBankBalance(); const PlayerBotProgressionOutcome result = progressionRuntime.advanceEquipmentPurchase(observation, maximumProgressionAttempts); + if (purchase.bagUpgrade && observation.oldBagAtDepot) { + int32_t persistedStatus = -1; + player->getStorageValue(backpackUpgradeStatusStorage, persistedStatus); + if (persistedStatus == backpackUpgradeTagged) { + player->addStorageValue(backpackUpgradeStatusStorage, backpackUpgradeStaged); + emit("backpack_upgrade_persistence", position, "\"result\":\"staged\",\"depot_id\":" + + std::to_string(backpackUpgradeDepotId) + ",\"token\":" + std::to_string(backpackUpgradeToken)); + } + } if (result.transaction.amount != 0) { + if (purchase.bagUpgrade) { + player->addStorageValue(backpackUpgradeStatusStorage, backpackUpgradePurchased); + emit("backpack_upgrade_persistence", position, "\"result\":\"purchased\",\"depot_id\":" + + std::to_string(backpackUpgradeDepotId) + ",\"token\":" + std::to_string(backpackUpgradeToken)); + } + + if (purchase.bagUpgrade) resetNavigation(); emit("action_result", position, "\"action\":\"buy_equipment\",\"result\":\"success\",\"item_id\":" + std::to_string(purchase.itemId) + ",\"price\":" + std::to_string(purchase.price) + ",\"carried_before\":" + std::to_string(result.transaction.money) + ",\"carried_after\":" + std::to_string(player->getMoney()) + ",\"bank_before\":" + @@ -480,8 +816,6 @@ void PlayerBotController::processEquipmentPurchase(Player* player, const Positio if (result.type == PlayerBotProgressionOutcomeType::Succeeded || result.type == PlayerBotProgressionOutcomeType::Failed) { if (result.type == PlayerBotProgressionOutcomeType::Failed && std::strcmp(result.reason, "transaction_delta_mismatch") == 0) { logActionFailure("buy_equipment", result.reason, position); - stop("equipment_purchase_delta_mismatch", position); - return; } if (result.type == PlayerBotProgressionOutcomeType::Failed && std::strcmp(result.reason, "transaction_rejected") == 0) logActionFailure("buy_equipment", result.reason, position); if (result.type == PlayerBotProgressionOutcomeType::Failed && std::strcmp(result.reason, "shop_window_unavailable") == 0) { @@ -509,7 +843,12 @@ void PlayerBotController::processEquipmentPurchase(Player* player, const Positio {observation.shopReady, observation.otherShopOpen}, maximumServiceAttempts); if (shop.result == PlayerBotNpcSessionResult::Failed) { logActionFailure("shop", shop.failureReason, position); - finishEquipmentPurchase(player, position, "failed", "shop_window_unavailable"); + if (purchase.bagUpgrade && observation.oldBagAtDepot) { + progressionRuntime.beginEquipmentBackpackRecovery("shop_window_unavailable", observation.equipmentVerified); + player->closeShopWindow(false); + resetNavigation(); + schedule(SCHEDULER_MINTICKS); + } else finishEquipmentPurchase(player, position, "failed", "shop_window_unavailable"); return; } if (shop.closeOtherShop) player->closeShopWindow(false); @@ -527,9 +866,35 @@ void PlayerBotController::processEquipmentPurchase(Player* player, const Positio static_cast(offer->subType), command.count, false, false); } if (result.command.type == PlayerBotProgressionCommandType::Open) { - Item* purchased = g_game.findItemOfType(player, purchase.itemId, true); - Container* container = purchased ? dynamic_cast(purchased->getParent()) : nullptr; - while (container && dynamic_cast(container->getParent()) && player->getContainerID(dynamic_cast(container->getParent())) < 0) container = dynamic_cast(container->getParent()); + Container* container = nullptr; + const char* action = "open_equipment_container"; + if (result.command.reason && std::strcmp(result.command.reason, "open_backpack_depot") == 0) { + if (!backpackUpgradeDepot || !Position::areInRange<1, 1, 0>(position, backpackUpgradeDepot->approachPosition)) { + schedule(blockedRouteRetryInterval); + return; + } + PlayerBotDepotSnapshot depot; + depot.hasSelectedDepot = true; + depot.selected = *backpackUpgradeDepot; + if (!openDepotLocker(*player, depot, position)) return; + Container* locker = player->getContainerByID(depotLockerContainerId); + DepotChest* chest = player->getDepotChest(backpackUpgradeDepot->depotId, false); + if (!locker || !chest || locker->getThingIndex(chest) < 0) { + schedule(navigationDecisionDelay(*player)); + return; + } + if (!openDepotChest(*player, depot, position)) return; + schedule(SCHEDULER_MINTICKS); + return; + } else if (result.command.reason && std::strcmp(result.command.reason, "open_backpack_upgrade_destination") == 0) { + Item* backpack = player->getInventoryItem(CONST_SLOT_BACKPACK); + container = backpack ? backpack->getContainer() : nullptr; + action = "open_backpack_upgrade_destination"; + } else { + Item* purchased = g_game.findItemOfType(player, purchase.itemId, true); + container = purchased ? dynamic_cast(purchased->getParent()) : nullptr; + while (container && dynamic_cast(container->getParent()) && player->getContainerID(dynamic_cast(container->getParent())) < 0) container = dynamic_cast(container->getParent()); + } if (container) { uint8_t id = rewardContainerIdBase; while (id <= maximumContainerId && player->getContainerByID(id)) ++id; @@ -537,28 +902,70 @@ void PlayerBotController::processEquipmentPurchase(Player* player, const Positio g_game.internalGetPosition(item, source, index); telemetry.recordActionAttempt(); g_game.playerUseItem(playerId, source, index, id, item->getClientID()); - emit("action_result", position, "\"action\":\"open_equipment_container\",\"result\":\"requested\",\"item_id\":" + std::to_string(item->getID()) + ",\"container_id\":" + std::to_string(id)); + emit("action_result", position, "\"action\":" + jsonString(action) + ",\"result\":\"requested\",\"item_id\":" + std::to_string(item->getID()) + ",\"container_id\":" + std::to_string(id)); } } if (result.command.type == PlayerBotProgressionCommandType::Equip) { Item* item = g_game.findItemOfType(player, purchase.itemId, true); slots_t slot = purchase.slot; - if (std::strcmp(result.command.reason, "preserve_displaced_equipment") == 0) { + Container* destination = nullptr; + const bool stageOldBag = result.command.reason && std::strcmp(result.command.reason, "depot_stage_old_bag") == 0; + const bool stageReplacementBag = result.command.reason && std::strcmp(result.command.reason, "depot_stage_replacement_bag") == 0; + const bool retrieveOldBag = result.command.reason && std::strcmp(result.command.reason, "depot_retrieve_old_bag") == 0; + const bool restoreOldBag = result.command.reason && std::strcmp(result.command.reason, "depot_restore_old_bag") == 0; + DepotChest* depotChest = backpackUpgradeDepot ? player->getDepotChest(backpackUpgradeDepot->depotId, false) : nullptr; + if (stageOldBag) { + Item* equippedBag = player->getInventoryItem(CONST_SLOT_BACKPACK); + item = equippedBag && hasBackpackUpgradeToken(*equippedBag, backpackUpgradeMarker, backpackUpgradeToken) ? equippedBag : nullptr; + destination = depotChest; + } else if (stageReplacementBag) { + Item* equippedBag = player->getInventoryItem(CONST_SLOT_BACKPACK); + item = equippedBag && equippedBag->getID() == ITEM_BAG ? equippedBag : nullptr; + if (item) { + if (Item* previous = taggedBackpackUpgradeReplacementBag(*player); previous && previous != item) { + previous->removeCustomAttribute(backpackUpgradeReplacementMarker); + } + std::string marker = backpackUpgradeReplacementMarker; + item->setCustomAttribute(marker, static_cast(backpackUpgradeToken)); + } + destination = depotChest; + } else if (retrieveOldBag || restoreOldBag) { + Item* taggedBag = taggedBackpackUpgradeBag(*player); + item = taggedBag && taggedBag->getParent() == depotChest ? taggedBag : nullptr; + if (retrieveOldBag) { + Item* backpack = player->getInventoryItem(CONST_SLOT_BACKPACK); + destination = backpack ? backpack->getContainer() : nullptr; + } + } + if (result.command.reason && std::strcmp(result.command.reason, "preserve_displaced_equipment") == 0) { for (const auto& [candidateSlot, itemId] : {std::pair{purchase.slot, purchase.replacedItemId}, {CONST_SLOT_LEFT, purchase.displacedLeftItemId}, {CONST_SLOT_RIGHT, purchase.displacedRightItemId}}) { Item* candidate = itemId == 0 ? nullptr : player->getInventoryItem(candidateSlot); if (candidate && candidate->getID() == itemId && candidate != item) { item = candidate; slot = candidateSlot; break; } } } - if (item) { + if (item && (!stageOldBag && !stageReplacementBag && !retrieveOldBag || destination)) { Position source; uint8_t index = 0; g_game.internalGetPosition(item, source, index); telemetry.recordActionAttempt(); - emit("action_result", position, "\"action\":" + jsonString(std::strcmp(result.command.reason, "preserve_displaced_equipment") == 0 ? "preserve_displaced_equipment" : "equip_equipment") + + const char* action = stageOldBag ? "depot_stage_old_bag" : stageReplacementBag ? "depot_stage_replacement_bag" : retrieveOldBag ? "depot_retrieve_old_bag" : + restoreOldBag ? "depot_restore_old_bag" : + (result.command.reason && std::strcmp(result.command.reason, "preserve_displaced_equipment") == 0 ? "preserve_displaced_equipment" : "equip_equipment"); + Position target(0xFFFF, purchase.slot, 0); + if (stageOldBag || stageReplacementBag || retrieveOldBag) { + const int8_t containerId = player->getContainerID(destination); + if (containerId < 0) { schedule(navigationDecisionDelay(*player)); return; } + target = Position(0xFFFF, 0x40 | static_cast(containerId), containerDestinationIndex(*destination, *item)); + } else if (restoreOldBag) { + target = Position(0xFFFF, CONST_SLOT_BACKPACK, 0); + } else if (result.command.reason && std::strcmp(result.command.reason, "preserve_displaced_equipment") == 0) { + target = Position(0xFFFF, 0, 0); + } + emit("action_result", position, "\"action\":" + jsonString(action) + ",\"result\":\"requested\",\"item_id\":" + std::to_string(item->getID()) + ",\"slot\":" + std::to_string(slot)); - g_game.playerMoveItem(player, source, item->getClientID(), index, - std::strcmp(result.command.reason, "preserve_displaced_equipment") == 0 ? Position(0xFFFF, 0, 0) : Position(0xFFFF, purchase.slot, 0), - item->getItemCount(), item, nullptr); + g_game.playerMoveItem(player, source, item->getClientID(), index, target, + item->getItemCount(), item, destination); } } if (result.command.type == PlayerBotProgressionCommandType::Navigate) return; + if (result.reason && std::strcmp(result.reason, "restore_old_bag") == 0) resetNavigation(); const bool actionIssued = result.command.type == PlayerBotProgressionCommandType::Shop || result.command.type == PlayerBotProgressionCommandType::Open || result.command.type == PlayerBotProgressionCommandType::Equip; diff --git a/server/src/playerbotequipmentpolicy.cpp b/server/src/playerbotequipmentpolicy.cpp index 1c32e3a..523a896 100644 --- a/server/src/playerbotequipmentpolicy.cpp +++ b/server/src/playerbotequipmentpolicy.cpp @@ -1,5 +1,3 @@ -#include "otpch.h" - #include "playerbotequipmentpolicy.h" #include @@ -105,7 +103,8 @@ std::optional PlayerBotEquipmentPolicy::findCa const auto& candidate = candidates[index]; auto upgrade = evaluateUpgrade(player, loadout, candidate.item); if (!candidate.actionable || !candidate.item.inContainer || !upgrade || (requiresKnightCombatReadiness(player) && - candidate.item.weaponType != PlayerBotEquipmentWeaponType::None && !isKnightMeleeWeapon(player, candidate.item)) || + candidate.item.weaponType != PlayerBotEquipmentWeaponType::None && + candidate.item.weaponType != PlayerBotEquipmentWeaponType::Shield && !isKnightMeleeWeapon(player, candidate.item)) || (selected && upgrade->benefit <= selected->upgrade.benefit)) continue; selected = PlayerBotEquipmentCarriedUpgrade{index, *upgrade}; } @@ -192,6 +191,18 @@ bool PlayerBotEquipmentPolicy::loadoutReady(const PlayerBotEquipmentPlayerSnapsh static_cast(readiness.effectiveFreeCapacity) >= static_cast(readiness.minimumFreeCapacity) + additionalWeight; } +PlayerBotBackpackAcquisition PlayerBotEquipmentPolicy::standardBackpackAcquisition( + const PlayerBotEquipmentPlayerSnapshot& player, uint16_t currentBackItemId, bool currentBackIsContainer, + uint32_t currentBackItems, uint32_t currentBackCapacity) const +{ + if (!requiresKnightCombatReadiness(player)) return {false, false, "unsupported_vocation"}; + if (currentBackItemId == 0) return {true, false, nullptr}; + if (currentBackItemId != 1987 || !currentBackIsContainer || currentBackItems > currentBackCapacity) { + return {false, false, "back_slot_not_upgradeable"}; + } + return {true, true, nullptr}; +} + PlayerBotEquipmentReadiness PlayerBotEquipmentPolicy::combatReadiness(const PlayerBotEquipmentPlayerSnapshot& player, const PlayerBotEquipmentLoadout& loadout, bool carriedUpgrade, const PlayerBotEquipmentReadinessInput& readiness) const { @@ -206,7 +217,7 @@ PlayerBotEquipmentReadiness PlayerBotEquipmentPolicy::combatReadiness(const Play if (weaponReady && armorReady && readiness.backpackReady && readiness.suppliesReady && readiness.effectiveFreeCapacity >= readiness.minimumFreeCapacity) { result.ready = true; return result; } if (!weaponReady) result.terminalReason = "missing_legal_melee_weapon"; else if (!armorReady) result.terminalReason = "missing_legal_armor"; - else if (!readiness.backpackReady) result.terminalReason = "missing_backpack"; + else if (!readiness.backpackReady) result.recovery = "acquire_backpack"; else result.recovery = "service"; return result; } diff --git a/server/src/playerbotequipmentpolicy.h b/server/src/playerbotequipmentpolicy.h index 279f3bd..bc3de2b 100644 --- a/server/src/playerbotequipmentpolicy.h +++ b/server/src/playerbotequipmentpolicy.h @@ -118,6 +118,8 @@ struct PlayerBotEquipmentOfferEvaluation { bool currentReady = false; bool candidateReady = false; bool carried = false; + bool backpackAcquisition = false; + bool bagUpgrade = false; bool simulated = false; std::string rejection; PlayerBotEquipmentDecisionRule rule = PlayerBotEquipmentDecisionRule::None; @@ -130,6 +132,12 @@ struct PlayerBotEquipmentReadiness { std::string terminalReason; }; +struct PlayerBotBackpackAcquisition { + bool eligible = false; + bool requiresStaging = false; + const char* rejection = nullptr; +}; + struct PlayerBotEquipmentReadinessInput { bool backpackReady = false; bool suppliesReady = false; @@ -159,6 +167,9 @@ class PlayerBotEquipmentPolicy const PlayerBotEquipmentLoadout& loadout) const; bool loadoutReady(const PlayerBotEquipmentPlayerSnapshot& player, const PlayerBotEquipmentLoadout& loadout, const PlayerBotEquipmentReadinessInput& readiness, uint32_t additionalWeight = 0) const; + PlayerBotBackpackAcquisition standardBackpackAcquisition(const PlayerBotEquipmentPlayerSnapshot& player, + uint16_t currentBackItemId, bool currentBackIsContainer, + uint32_t currentBackItems, uint32_t currentBackCapacity) const; PlayerBotEquipmentReadiness combatReadiness(const PlayerBotEquipmentPlayerSnapshot& player, const PlayerBotEquipmentLoadout& loadout, bool carriedUpgrade, const PlayerBotEquipmentReadinessInput& readiness) const; diff --git a/server/src/playerbotnpcsession.cpp b/server/src/playerbotnpcsession.cpp index 29dbf8c..d9728db 100644 --- a/server/src/playerbotnpcsession.cpp +++ b/server/src/playerbotnpcsession.cpp @@ -8,7 +8,7 @@ * (at your option) any later version. */ -#include "otpch.h" +#include #include "const.h" #include "playerbotnpcsession.h" diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index c04d55b..bcf876d 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -220,6 +220,16 @@ bool PlayerBotController::ensureCombatReady(Player* player, const Position& posi schedule(navigationInterval); return false; } + if (readiness.recovery == "acquire_backpack") { + auto backpack = evaluateEquipmentOffers(*player, position); + if (backpack && backpack->backpackAcquisition) { + beginEquipmentPurchase(*player, position, std::move(*backpack)); + schedule(SCHEDULER_MINTICKS); + } else { + stop("combat_readiness_backpack_unavailable", position); + } + return false; + } stop(("combat_readiness_" + readiness.terminalReason).c_str(), position); return false; } diff --git a/server/src/playerbotprogressionplanners.cpp b/server/src/playerbotprogressionplanners.cpp index 2fccd27..1c56a11 100644 --- a/server/src/playerbotprogressionplanners.cpp +++ b/server/src/playerbotprogressionplanners.cpp @@ -1,6 +1,7 @@ /** Pure candidate selection for playerbot progression. */ #include "otpch.h" +#include "creature.h" #include "playerbotprogressionplanners.h" #include "playerbotsupplypolicy.h" @@ -82,7 +83,10 @@ PlayerBotEquipmentProviderDecision PlayerBotEquipmentProviderPlanner::select(con if (displaced.second != 0 && countedSlots.insert(displaced.first).second) ++displacedSlots; } const uint32_t requiredSlots = displacedSlots + (evaluation.carried ? 0 : 1); - if (!rejection && (!offer.backpackAvailable || offer.freeBackpackSlots < requiredSlots)) rejection = "insufficient_displaced_item_space"; + if (!rejection && !evaluation.backpackAcquisition && + (!offer.backpackAvailable || offer.freeBackpackSlots < requiredSlots)) { + rejection = "insufficient_displaced_item_space"; + } if (!rejection && !evaluation.carried && !offer.purchaseAvailable) rejection = "offer_not_for_sale"; if (!rejection && !evaluation.carried && !snapshot.reserveAvailable) rejection = "recovery_reserve_unavailable"; if (!rejection && !evaluation.carried && snapshot.totalMoney < snapshot.reserve + evaluation.price) rejection = "unaffordable_after_reserves"; diff --git a/server/src/playerbotprogressionruntime.cpp b/server/src/playerbotprogressionruntime.cpp index a0f2ef5..5d53513 100644 --- a/server/src/playerbotprogressionruntime.cpp +++ b/server/src/playerbotprogressionruntime.cpp @@ -36,17 +36,17 @@ void PlayerBotProgressionRuntime::enterHunt() arbiter.setActiveGoal(PlayerBotGoalArbiter::TopLevelGoal::Hunt); } -void PlayerBotProgressionRuntime::completeReward(bool succeeded, std::chrono::steady_clock::duration cooldown) +void PlayerBotProgressionRuntime::completeReward(bool, std::chrono::steady_clock::duration cooldown) { arbiter.setCooldown(PlayerBotGoalArbiter::TopLevelGoal::PickupReward, cooldown); } -void PlayerBotProgressionRuntime::completeSpellTraining(bool succeeded, std::chrono::steady_clock::duration cooldown) +void PlayerBotProgressionRuntime::completeSpellTraining(bool, std::chrono::steady_clock::duration cooldown) { arbiter.setCooldown(PlayerBotGoalArbiter::TopLevelGoal::LearnSpell, cooldown); } -void PlayerBotProgressionRuntime::completeEquipmentPurchase(bool succeeded, std::chrono::steady_clock::duration cooldown) +void PlayerBotProgressionRuntime::completeEquipmentPurchase(bool, std::chrono::steady_clock::duration cooldown) { arbiter.setCooldown(PlayerBotGoalArbiter::TopLevelGoal::BuyEquipment, cooldown); } @@ -99,6 +99,14 @@ void PlayerBotProgressionRuntime::beginEquipmentPurchase(PlayerBotEquipmentOffer progression.begin(PlayerBotProgressionProcedure::BuyEquipment); } +void PlayerBotProgressionRuntime::resumeEquipmentBackpack(PlayerBotEquipmentOfferEvaluation plan, bool staged, bool purchased) +{ + beginEquipmentPurchase(std::move(plan)); + if (purchased) equipmentPurchaseSession.markPurchased(); + if (staged) equipmentPurchaseSession.setStage(purchased ? PlayerBotEquipmentPurchaseStage::ReturnDepot : + PlayerBotEquipmentPurchaseStage::Travel); +} + void PlayerBotProgressionRuntime::finish() { switch (progression.active()) { @@ -148,6 +156,13 @@ void PlayerBotProgressionRuntime::restartEquipmentConversation() npcSession.reset(equipmentPurchaseSession.plan().npcId); } +void PlayerBotProgressionRuntime::beginEquipmentBackpackRecovery(const char* reason, bool purchased) +{ + if (equipmentPurchaseSession.backpackRecoveryActive()) return; + if (purchased) equipmentPurchaseSession.markPurchased(); + equipmentPurchaseSession.beginBackpackRecovery(reason); +} + PlayerBotEquipmentShopCommand PlayerBotProgressionRuntime::advanceEquipmentShop(const PlayerBotNpcShopObservation& observation, uint32_t maximumRetries) { @@ -181,7 +196,7 @@ PlayerBotReadinessEquipmentCommand PlayerBotProgressionRuntime::beginReadinessEq if (observation.openContainerRequired) { if (!observation.containerAccessAvailable || ++readinessEquipment.attempts >= maximumRetries) { const PlayerBotReadinessEquipmentCommand command{PlayerBotReadinessEquipmentCommandType::ServiceFallback, 0, - CONST_SLOT_WHEREEVER, readinessEquipment.attempts, "access_attempts_exhausted"}; + static_cast(0), readinessEquipment.attempts, "access_attempts_exhausted"}; readinessEquipment = {}; return command; } @@ -220,7 +235,7 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceDeparture(const { const auto outcome = [this](PlayerBotProgressionCommandType type, PlayerBotProgressionOutcomeType result, const char* reason) { return PlayerBotProgressionOutcome{{type, progression.active(), static_cast(departureSession.stage()), departureSession.retries(), reason}, - result, departureSession.retries(), reason}; + result, departureSession.retries(), reason, {}}; }; switch (departureSession.stage()) { case PlayerBotOracleDepartureStage::Travel: @@ -262,7 +277,7 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceSpellTraining(co { const auto outcome = [this](PlayerBotProgressionCommandType type, PlayerBotProgressionOutcomeType result, const char* reason) { return PlayerBotProgressionOutcome{{type, progression.active(), static_cast(spellTrainingSession.stage()), spellTrainingSession.retries(), reason}, - result, spellTrainingSession.retries(), reason}; + result, spellTrainingSession.retries(), reason, {}}; }; switch (spellTrainingSession.stage()) { case PlayerBotSpellTrainingStage::Travel: @@ -306,7 +321,7 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceReward(const Pla { const auto outcome = [this](PlayerBotProgressionCommandType type, PlayerBotProgressionOutcomeType result, const char* reason) { return PlayerBotProgressionOutcome{{type, progression.active(), static_cast(rewardSession.stage()), rewardSession.retries(), reason}, - result, rewardSession.retries(), reason}; + result, rewardSession.retries(), reason, {}}; }; switch (rewardSession.stage()) { case PlayerBotRewardStage::Travel: @@ -350,7 +365,7 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceReward(const Pla } } rewardSession.resetRetries(); - if (rewardSession.plan().slot == CONST_SLOT_WHEREEVER) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Succeeded, "reward_bundle_claimed"); + if (rewardSession.plan().slot == static_cast(0)) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Succeeded, "reward_bundle_claimed"); rewardSession.setStage(PlayerBotRewardStage::EquipReward); return outcome(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, "reward_claimed"); case PlayerBotRewardStage::EquipReward: @@ -409,76 +424,188 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceReward(const Pla PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceEquipmentPurchase( const PlayerBotEquipmentPurchaseObservation& observation, uint32_t maximumRetries) { - const auto outcome = [this](PlayerBotProgressionCommandType type, PlayerBotProgressionOutcomeType result, const char* reason) { + const auto result = [this](PlayerBotProgressionCommandType type, PlayerBotProgressionOutcomeType value, const char* reason) { return PlayerBotProgressionOutcome{{type, progression.active(), static_cast(equipmentPurchaseSession.stage()), equipmentPurchaseSession.retries(), reason}, - result, equipmentPurchaseSession.retries(), reason}; + value, equipmentPurchaseSession.retries(), reason, {}}; + }; + const auto failOrRecover = [this, &result, &observation](const char* reason) { + if (equipmentPurchaseSession.plan().bagUpgrade && observation.oldBagAtDepot) { + if (observation.equipmentVerified) equipmentPurchaseSession.markPurchased(); + equipmentPurchaseSession.beginBackpackRecovery(reason); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "restore_old_bag"); + } + return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, reason); }; switch (equipmentPurchaseSession.stage()) { + case PlayerBotEquipmentPurchaseStage::TravelDepot: + if (!observation.depotAvailable || observation.depotNavigationFailed) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "backpack_depot_unavailable"); + if (!observation.depotReached) return result(PlayerBotProgressionCommandType::Navigate, PlayerBotProgressionOutcomeType::Pending, "stage_old_bag"); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::OpenDepotForStage); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); + case PlayerBotEquipmentPurchaseStage::OpenDepotForStage: + if (!observation.depotAvailable) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "backpack_depot_unavailable"); + if (!observation.depotOpen) { + if (!observation.actionAvailable) return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, "action_unavailable"); + if (equipmentPurchaseSession.incrementRetries() >= maximumRetries) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "backpack_depot_open_failed"); + return result(PlayerBotProgressionCommandType::Open, PlayerBotProgressionOutcomeType::Pending, "open_backpack_depot"); + } + equipmentPurchaseSession.resetRetries(); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::StageBackpack); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); + case PlayerBotEquipmentPurchaseStage::StageBackpack: + if (!observation.bankFundingAvailable) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "bank_funding_required"); + if (observation.oldBagAtDepot && observation.backpackReceiptSafe) { + equipmentPurchaseSession.resetRetries(); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::Travel); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); + } + if (!observation.oldBagEquipped || !observation.depotOpen) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "old_bag_stage_source_unavailable"); + if (!observation.actionAvailable) return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, "action_unavailable"); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::VerifyBackpackStaging); + return result(PlayerBotProgressionCommandType::Equip, PlayerBotProgressionOutcomeType::Pending, "depot_stage_old_bag"); + case PlayerBotEquipmentPurchaseStage::VerifyBackpackStaging: + if (observation.oldBagAtDepot && observation.backpackReceiptSafe) { + equipmentPurchaseSession.resetRetries(); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::Travel); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); + } + if (equipmentPurchaseSession.incrementRetries() >= maximumRetries) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "old_bag_stage_not_verified"); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::StageBackpack); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "depot_stage_old_bag"); case PlayerBotEquipmentPurchaseStage::Travel: - if (observation.navigationFailed) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "route_unavailable"); - if (!observation.navigationReached) return outcome(PlayerBotProgressionCommandType::Navigate, PlayerBotProgressionOutcomeType::Pending, nullptr); + if (observation.navigationFailed) return failOrRecover("route_unavailable"); + if (!observation.navigationReached) return result(PlayerBotProgressionCommandType::Navigate, PlayerBotProgressionOutcomeType::Pending, nullptr); equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::Purchase); - return outcome(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); case PlayerBotEquipmentPurchaseStage::Purchase: - if (!observation.providerAvailable) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "provider_unavailable"); - if (!observation.offerAvailable) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "offer_changed"); - if (!observation.providerInRange) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "provider_moved"); - if (!observation.shopReady) return outcome(PlayerBotProgressionCommandType::Shop, PlayerBotProgressionOutcomeType::Pending, "open_shop"); - if (!observation.fundingAvailable) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "reserve_changed"); + if (!observation.providerAvailable) return failOrRecover("provider_unavailable"); + if (!observation.offerAvailable) return failOrRecover("offer_changed"); + if (!observation.providerInRange) return failOrRecover("provider_moved"); + if (!observation.shopReady) return result(PlayerBotProgressionCommandType::Shop, PlayerBotProgressionOutcomeType::Pending, "open_shop"); + if (!observation.fundingAvailable) return failOrRecover("reserve_changed"); + if (equipmentPurchaseSession.plan().backpackAcquisition && !observation.backpackReceiptSafe) return failOrRecover("backpack_receipt_unsafe"); equipmentTransaction.beginShopTransaction({equipmentPurchaseSession.plan().itemId, 1, observation.itemCount, observation.money, observation.bankBalance, equipmentPurchaseSession.plan().price, 0}); equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::VerifyPurchase); - return outcome(PlayerBotProgressionCommandType::Shop, PlayerBotProgressionOutcomeType::Pending, "purchase_equipment"); - case PlayerBotEquipmentPurchaseStage::VerifyPurchase: - { - const PlayerBotServiceVerification verification = equipmentTransaction.verifyShopTransaction(observation.itemCount, - observation.money, observation.bankBalance, true, maximumRetries); - if (verification.result == PlayerBotServiceVerificationResult::Mismatch) { - return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "transaction_delta_mismatch"); - } - if (verification.result == PlayerBotServiceVerificationResult::Rejected) { - return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "transaction_rejected"); - } - if (verification.result == PlayerBotServiceVerificationResult::Success) { - equipmentPurchaseSession.resetRetries(); - equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::Equip); - return {command(PlayerBotProgressionCommandType::None), PlayerBotProgressionOutcomeType::Pending, 0, nullptr, - verification.before}; - } - return outcome(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "purchase_equipment"); + return result(PlayerBotProgressionCommandType::Shop, PlayerBotProgressionOutcomeType::Pending, "purchase_equipment"); + case PlayerBotEquipmentPurchaseStage::VerifyPurchase: { + const PlayerBotServiceVerification verification = equipmentTransaction.verifyShopTransaction(observation.itemCount, + observation.money, observation.bankBalance, true, maximumRetries); + if (verification.result == PlayerBotServiceVerificationResult::Mismatch) return failOrRecover("transaction_delta_mismatch"); + if (verification.result == PlayerBotServiceVerificationResult::Rejected) return failOrRecover("transaction_rejected"); + if (verification.result == PlayerBotServiceVerificationResult::Success) { + equipmentPurchaseSession.resetRetries(); + if (equipmentPurchaseSession.plan().bagUpgrade) { + equipmentPurchaseSession.markPurchased(); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::ReturnDepot); + } else equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::Equip); + return {command(PlayerBotProgressionCommandType::None), PlayerBotProgressionOutcomeType::Pending, 0, nullptr, verification.before}; + } + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "purchase_equipment"); + } + case PlayerBotEquipmentPurchaseStage::ReturnDepot: + if (!observation.depotAvailable || observation.depotNavigationFailed) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "old_bag_recovery_route_unavailable"); + if (!observation.depotReached) return result(PlayerBotProgressionCommandType::Navigate, PlayerBotProgressionOutcomeType::Pending, "recover_old_bag"); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::OpenDepotForRetrieve); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); + case PlayerBotEquipmentPurchaseStage::OpenDepotForRetrieve: + if (!observation.depotAvailable) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "old_bag_recovery_depot_unavailable"); + if (!observation.depotOpen) { + if (!observation.actionAvailable) return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, "action_unavailable"); + if (equipmentPurchaseSession.incrementRetries() >= maximumRetries) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "old_bag_recovery_depot_open_failed"); + return result(PlayerBotProgressionCommandType::Open, PlayerBotProgressionOutcomeType::Pending, "open_backpack_depot"); + } + equipmentPurchaseSession.resetRetries(); + equipmentPurchaseSession.setStage(equipmentPurchaseSession.backpackPurchased() && observation.equipmentVerified ? + PlayerBotEquipmentPurchaseStage::RetrieveBackpack : + (observation.replacementBagEquipped || observation.replacementBagAtDepot ? + PlayerBotEquipmentPurchaseStage::StageReplacementBackpack : PlayerBotEquipmentPurchaseStage::RestoreBackpack)); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); + case PlayerBotEquipmentPurchaseStage::StageReplacementBackpack: + if (observation.replacementBagAtDepot && observation.backpackReceiptSafe) { + equipmentPurchaseSession.resetRetries(); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::RestoreBackpack); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); + } + if (!observation.oldBagAtDepot || !observation.replacementBagEquipped || !observation.depotOpen) { + return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "old_bag_recovery_replacement_source_unavailable"); + } + if (!observation.actionAvailable) return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, "action_unavailable"); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::VerifyReplacementBackpackStaging); + return result(PlayerBotProgressionCommandType::Equip, PlayerBotProgressionOutcomeType::Pending, "depot_stage_replacement_bag"); + case PlayerBotEquipmentPurchaseStage::VerifyReplacementBackpackStaging: + if (observation.replacementBagAtDepot && observation.backpackReceiptSafe) { + equipmentPurchaseSession.resetRetries(); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::RestoreBackpack); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); } + if (equipmentPurchaseSession.incrementRetries() >= maximumRetries) { + return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "old_bag_recovery_replacement_stage_exhausted"); + } + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::StageReplacementBackpack); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "depot_stage_replacement_bag"); + case PlayerBotEquipmentPurchaseStage::RetrieveBackpack: + if (observation.oldBagPreserved) { + if (!equipmentPurchaseSession.backpackRecoveryReason().empty()) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, equipmentPurchaseSession.backpackRecoveryReason().c_str()); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::VerifyEquipment); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); + } + if (!observation.oldBagAtDepot || !observation.equipmentVerified) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "old_bag_recovery_source_unavailable"); + if (!observation.actionAvailable) return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, "action_unavailable"); + if (!observation.backpackDestinationOpen) return result(PlayerBotProgressionCommandType::Open, PlayerBotProgressionOutcomeType::Pending, "open_backpack_upgrade_destination"); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::VerifyBackpackRetrieved); + return result(PlayerBotProgressionCommandType::Equip, PlayerBotProgressionOutcomeType::Pending, "depot_retrieve_old_bag"); + case PlayerBotEquipmentPurchaseStage::VerifyBackpackRetrieved: + if (observation.oldBagPreserved) { + equipmentPurchaseSession.resetRetries(); + if (!equipmentPurchaseSession.backpackRecoveryReason().empty()) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, equipmentPurchaseSession.backpackRecoveryReason().c_str()); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::VerifyEquipment); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); + } + if (equipmentPurchaseSession.incrementRetries() >= maximumRetries) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "old_bag_recovery_exhausted"); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::RetrieveBackpack); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "depot_retrieve_old_bag"); + case PlayerBotEquipmentPurchaseStage::RestoreBackpack: + if (observation.oldBagEquipped) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, equipmentPurchaseSession.backpackRecoveryReason().c_str()); + if (!observation.oldBagAtDepot || !observation.backpackReceiptSafe) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "old_bag_restore_source_unavailable"); + if (!observation.actionAvailable) return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, "action_unavailable"); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::VerifyBackpackRestored); + return result(PlayerBotProgressionCommandType::Equip, PlayerBotProgressionOutcomeType::Pending, "depot_restore_old_bag"); + case PlayerBotEquipmentPurchaseStage::VerifyBackpackRestored: + if (observation.oldBagEquipped) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, equipmentPurchaseSession.backpackRecoveryReason().c_str()); + if (equipmentPurchaseSession.incrementRetries() >= maximumRetries) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "old_bag_restore_exhausted"); + equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::RestoreBackpack); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "depot_restore_old_bag"); case PlayerBotEquipmentPurchaseStage::Equip: if (observation.equipmentVerified) { equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::VerifyEquipment); - return outcome(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); } if (!observation.equipmentAvailable) { - if (equipmentPurchaseSession.incrementRetries() >= 3) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "purchased_item_unavailable"); - return outcome(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "purchased_item_unavailable"); + if (equipmentPurchaseSession.incrementRetries() >= maximumRetries) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "purchased_item_unavailable"); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "purchased_item_unavailable"); } if (!observation.openContainerRequired) equipmentPurchaseSession.observeContainerOpen(observation.containerDepth); if (observation.displacedMoveRequired || observation.openContainerRequired) { - if (observation.openContainerRequired && !observation.containerAccessAvailable) { - return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "purchased_item_container_unavailable"); - } - if (equipmentPurchaseSession.retries() >= 3) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, - observation.displacedMoveRequired ? "displaced_item_move_not_verified" : "purchased_item_container_unavailable"); + if (observation.openContainerRequired && !observation.containerAccessAvailable) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "purchased_item_container_unavailable"); + if (equipmentPurchaseSession.retries() >= maximumRetries) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, observation.displacedMoveRequired ? "displaced_item_move_not_verified" : "purchased_item_container_unavailable"); equipmentPurchaseSession.incrementRetries(); if (observation.openContainerRequired) equipmentPurchaseSession.beginContainerAccess(observation.containerDepth); - return outcome(observation.openContainerRequired ? PlayerBotProgressionCommandType::Open : PlayerBotProgressionCommandType::Equip, + return result(observation.openContainerRequired ? PlayerBotProgressionCommandType::Open : PlayerBotProgressionCommandType::Equip, PlayerBotProgressionOutcomeType::Retry, observation.openContainerRequired ? "open_equipment_container" : "preserve_displaced_equipment"); } - if (!observation.actionAvailable) return outcome(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, "action_unavailable"); - if (!observation.equipmentPositionAvailable) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "purchased_item_position_unavailable"); + if (!observation.actionAvailable) return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, "action_unavailable"); + if (!observation.equipmentPositionAvailable) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "purchased_item_position_unavailable"); equipmentPurchaseSession.captureDisplacedItemCounts(observation.displacedCounts); equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::VerifyEquipment); - return outcome(PlayerBotProgressionCommandType::Equip, PlayerBotProgressionOutcomeType::Pending, "equip_equipment"); + return result(PlayerBotProgressionCommandType::Equip, PlayerBotProgressionOutcomeType::Pending, "equip_equipment"); case PlayerBotEquipmentPurchaseStage::VerifyEquipment: - if (observation.equipmentVerified && equipmentPurchaseSession.displacedItemsPreserved(observation.displacedCounts)) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Succeeded, "upgrade_equipped"); - if (equipmentPurchaseSession.incrementRetries() >= 3) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, - equipmentPurchaseSession.displacedItemsPreserved(observation.displacedCounts) ? "equip_not_verified" : "displaced_item_lost"); - equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::Equip); - return outcome(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "equip_equipment"); + if (observation.equipmentVerified && (!equipmentPurchaseSession.plan().bagUpgrade || observation.oldBagPreserved) && equipmentPurchaseSession.displacedItemsPreserved(observation.displacedCounts)) { + return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Succeeded, equipmentPurchaseSession.plan().backpackAcquisition ? "backpack_acquired" : "upgrade_equipped"); + } + if (equipmentPurchaseSession.incrementRetries() >= maximumRetries) return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "equip_not_verified"); + equipmentPurchaseSession.setStage(equipmentPurchaseSession.plan().bagUpgrade ? PlayerBotEquipmentPurchaseStage::RetrieveBackpack : PlayerBotEquipmentPurchaseStage::Equip); + return result(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "equip_equipment"); } - return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "invalid_equipment_stage"); + return result(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "invalid_equipment_stage"); } diff --git a/server/src/playerbotprogressionruntime.h b/server/src/playerbotprogressionruntime.h index 7fb570b..e4a8b49 100644 --- a/server/src/playerbotprogressionruntime.h +++ b/server/src/playerbotprogressionruntime.h @@ -82,6 +82,7 @@ struct PlayerBotEquipmentPurchaseObservation { bool shopReady = false; bool otherShopOpen = false; bool fundingAvailable = true; + bool bankFundingAvailable = true; uint32_t itemCount = 0; uint64_t money = 0; uint64_t bankBalance = 0; @@ -93,6 +94,17 @@ struct PlayerBotEquipmentPurchaseObservation { bool containerAccessAvailable = true; size_t containerDepth = 0; bool displacedMoveRequired = false; + bool depotReached = false; + bool depotNavigationFailed = false; + bool depotAvailable = true; + bool depotOpen = false; + bool oldBagEquipped = false; + bool oldBagAtDepot = false; + bool replacementBagEquipped = false; + bool replacementBagAtDepot = false; + bool backpackReceiptSafe = true; + bool oldBagPreserved = false; + bool backpackDestinationOpen = false; std::map displacedCounts; }; @@ -100,7 +112,7 @@ struct PlayerBotReadinessEquipmentObservation { bool actionAvailable = false; bool upgradeAvailable = false; uint16_t itemId = 0; - slots_t slot = CONST_SLOT_WHEREEVER; + slots_t slot = static_cast(0); bool openContainerRequired = false; bool containerAccessAvailable = true; bool equipmentVerified = false; @@ -114,14 +126,14 @@ enum class PlayerBotReadinessEquipmentCommandType : uint8_t { struct PlayerBotReadinessEquipmentCommand { PlayerBotReadinessEquipmentCommandType type = PlayerBotReadinessEquipmentCommandType::None; uint16_t itemId = 0; - slots_t slot = CONST_SLOT_WHEREEVER; + slots_t slot = static_cast(0); uint32_t attempts = 0; const char* reason = nullptr; }; struct PlayerBotReadinessEquipmentSnapshot { uint16_t itemId = 0; - slots_t slot = CONST_SLOT_WHEREEVER; + slots_t slot = static_cast(0); uint32_t attempts = 0; bool pending = false; }; @@ -158,6 +170,7 @@ class PlayerBotProgressionRuntime { void beginDeparture(PlayerBotOracleDeparturePlan plan); void beginSpellTraining(PlayerBotSpellTrainingPlan plan); void beginEquipmentPurchase(PlayerBotEquipmentOfferEvaluation plan); + void resumeEquipmentBackpack(PlayerBotEquipmentOfferEvaluation plan, bool staged, bool purchased); void finish(); bool reportNpcReply(uint32_t playerId, uint32_t replyingPlayerId, uint32_t npcId, uint8_t type); bool greetingAcknowledged() const { return npcSession.isGreetingAcknowledged(); } @@ -165,6 +178,8 @@ class PlayerBotProgressionRuntime { void restartDepartureConversation(); void restartSpellTrainingConversation(); void restartEquipmentConversation(); + void beginEquipmentBackpackRecovery(const char* reason, bool purchased); + bool equipmentBackpackRecoveryActive() const { return equipmentPurchaseSession.backpackRecoveryActive(); } PlayerBotEquipmentShopCommand advanceEquipmentShop(const PlayerBotNpcShopObservation& observation, uint32_t maximumRetries); bool readinessEquipmentPending() const { return readinessEquipment.pending; } @@ -199,7 +214,7 @@ class PlayerBotProgressionRuntime { PlayerBotProgressionOutcome outcome(PlayerBotProgressionCommand command, PlayerBotProgressionOutcomeType type, uint32_t attempts = 0, const char* reason = nullptr) const { - return {command, type, attempts, reason}; + return {command, type, attempts, reason, {}}; } private: @@ -216,7 +231,7 @@ class PlayerBotProgressionRuntime { PlayerBotServiceSession equipmentTransaction; struct { uint16_t itemId = 0; - slots_t slot = CONST_SLOT_WHEREEVER; + slots_t slot = static_cast(0); uint32_t attempts = 0; bool pending = false; bool resumeService = false; diff --git a/server/src/playerbotprogressionsession.cpp b/server/src/playerbotprogressionsession.cpp index 8100748..05f1c0a 100644 --- a/server/src/playerbotprogressionsession.cpp +++ b/server/src/playerbotprogressionsession.cpp @@ -3,8 +3,6 @@ * Copyright (C) 2019 Mark Samman */ -#include "otpch.h" - #include "playerbotprogressionsession.h" void PlayerBotRewardSession::begin(PlayerBotRewardPlan value) @@ -85,8 +83,11 @@ void PlayerBotSpellTrainingSession::reset() void PlayerBotEquipmentPurchaseSession::begin(PlayerBotEquipmentOfferEvaluation value) { purchase = std::move(value); - currentStage = purchase.carried ? PlayerBotEquipmentPurchaseStage::Equip : PlayerBotEquipmentPurchaseStage::Travel; + currentStage = purchase.carried ? PlayerBotEquipmentPurchaseStage::Equip : + purchase.bagUpgrade ? PlayerBotEquipmentPurchaseStage::TravelDepot : PlayerBotEquipmentPurchaseStage::Travel; attempts = 0; + purchased = false; + recoveryReason.clear(); displaced.clear(); nestedContainer = {}; } @@ -96,6 +97,8 @@ void PlayerBotEquipmentPurchaseSession::reset() purchase = {}; currentStage = PlayerBotEquipmentPurchaseStage::Travel; attempts = 0; + purchased = false; + recoveryReason.clear(); displaced.clear(); nestedContainer = {}; } diff --git a/server/src/playerbotprogressionsession.h b/server/src/playerbotprogressionsession.h index 6ea5465..0cde6cb 100644 --- a/server/src/playerbotprogressionsession.h +++ b/server/src/playerbotprogressionsession.h @@ -11,7 +11,6 @@ #include #include -#include "creature.h" #include "playerbotequipmentpolicy.h" #include "position.h" @@ -32,7 +31,7 @@ struct PlayerBotRewardPlan { uint16_t rootOrdinal = 0; Position itemPosition; Position approachPosition; - slots_t slot = CONST_SLOT_WHEREEVER; + slots_t slot = static_cast(0); int32_t benefit = 0; std::string metric; int32_t currentValue = 0; @@ -191,9 +190,21 @@ class PlayerBotSpellTrainingSession }; enum class PlayerBotEquipmentPurchaseStage : uint8_t { + TravelDepot, + OpenDepotForStage, + StageBackpack, + VerifyBackpackStaging, Travel, Purchase, VerifyPurchase, + ReturnDepot, + OpenDepotForRetrieve, + StageReplacementBackpack, + VerifyReplacementBackpackStaging, + RetrieveBackpack, + VerifyBackpackRetrieved, + RestoreBackpack, + VerifyBackpackRestored, Equip, VerifyEquipment, }; @@ -210,6 +221,11 @@ class PlayerBotEquipmentPurchaseSession void begin(PlayerBotEquipmentOfferEvaluation purchase); void reset(); void setStage(PlayerBotEquipmentPurchaseStage stage) { currentStage = stage; } + void beginBackpackRecovery(const char* reason) { recoveryReason = reason ? reason : "backpack_purchase_failed"; currentStage = PlayerBotEquipmentPurchaseStage::ReturnDepot; attempts = 0; } + const std::string& backpackRecoveryReason() const { return recoveryReason; } + bool backpackRecoveryActive() const { return !recoveryReason.empty(); } + void markPurchased() { purchased = true; } + bool backpackPurchased() const { return purchased; } uint32_t incrementRetries() { return ++attempts; } void resetRetries() { attempts = 0; } void captureDisplacedItemCounts(std::map counts) { displaced = std::move(counts); } @@ -219,6 +235,8 @@ class PlayerBotEquipmentPurchaseSession PlayerBotEquipmentOfferEvaluation purchase; PlayerBotEquipmentPurchaseStage currentStage = PlayerBotEquipmentPurchaseStage::Travel; uint32_t attempts = 0; + bool purchased = false; + std::string recoveryReason; std::map displaced; PlayerBotNestedContainerAccessState nestedContainer; }; diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index 85b1c5a..e7eaac8 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -1169,6 +1169,7 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP const PlayerBotTopologyDistances distances = topology.distancesFrom( currentPosition, canUseRope, canUseShovel, player.getLevel()); for (const auto& entry : g_game.map.getDepotLockerPositions()) { + if (backpackUpgradeDepotId != 0 && entry.first != backpackUpgradeDepotId) continue; if (sellLootPlan && entry.first != sellLootPlan->sourceDepotId) continue; for (const Position& lockerPosition : entry.second) { ++result.indexedCandidates; diff --git a/server/src/playerbotservicesession.cpp b/server/src/playerbotservicesession.cpp index b71de30..2791495 100644 --- a/server/src/playerbotservicesession.cpp +++ b/server/src/playerbotservicesession.cpp @@ -3,8 +3,6 @@ * Copyright (C) 2019 Mark Samman */ -#include "otpch.h" - #include "playerbotservicesession.h" void PlayerBotServiceSession::reset() diff --git a/server/tests/playerbot_contracts.cpp b/server/tests/playerbot_contracts.cpp index 6066121..37d8317 100644 --- a/server/tests/playerbot_contracts.cpp +++ b/server/tests/playerbot_contracts.cpp @@ -7,6 +7,7 @@ #include "playerbot.h" #include "playerbotdepotworkflow.h" #include "playerboteconomy.h" +#include "playerbotequipmentpolicy.h" #include "playerbotgoalplanner.h" #include "playerbotcombatruntime.h" #include "playerbotcombattarget.h" @@ -1288,8 +1289,105 @@ void oracleRecovery() } +static void backpackAcquisition() +{ + PlayerBotEquipmentPolicy policy(4); + PlayerBotEquipmentPlayerSnapshot knight; + knight.vocationId = 4; + auto acquisition = policy.standardBackpackAcquisition(knight, 0, false, 0, 0); + assert(acquisition.eligible && !acquisition.requiresStaging && acquisition.rejection == nullptr); + + // A full bag is safe because the complete container is staged, not its contents. + acquisition = policy.standardBackpackAcquisition(knight, 1987, true, 8, 8); + assert(acquisition.eligible && acquisition.requiresStaging && acquisition.rejection == nullptr); + acquisition = policy.standardBackpackAcquisition(knight, 1987, false, 0, 0); + assert(!acquisition.eligible && std::string(acquisition.rejection) == "back_slot_not_upgradeable"); + for (uint16_t unchanged : {uint16_t{1988}, uint16_t{2000}}) { + acquisition = policy.standardBackpackAcquisition(knight, unchanged, true, 20, 20); + assert(!acquisition.eligible && std::string(acquisition.rejection) == "back_slot_not_upgradeable"); + } + knight.vocationId = 3; + acquisition = policy.standardBackpackAcquisition(knight, 0, false, 0, 0); + assert(!acquisition.eligible && std::string(acquisition.rejection) == "unsupported_vocation"); + + PlayerBotEquipmentLoadout loadout; + PlayerBotEquipmentReadinessInput readiness{false, true, 10000, 100}; + auto combat = policy.combatReadiness(PlayerBotEquipmentPlayerSnapshot{0, 0, 4}, loadout, false, readiness); + assert(combat.recovery.empty()); // Missing weapon remains the earlier, terminal readiness gap. + assert(combat.terminalReason == "missing_legal_melee_weapon"); + PlayerBotEquipmentItemSnapshot sword; + sword.itemId = 2376; + sword.weaponType = PlayerBotEquipmentWeaponType::Sword; + sword.attack = 14; + sword.left = sword.pickupable = true; + loadout.items[6] = sword; + PlayerBotEquipmentItemSnapshot armor; + armor.itemId = 2463; + armor.armorSlot = armor.pickupable = true; + armor.armor = 10; + loadout.items[4] = armor; + combat = policy.combatReadiness(PlayerBotEquipmentPlayerSnapshot{0, 0, 4}, loadout, false, readiness); + assert(combat.recovery == "acquire_backpack" && combat.terminalReason.empty()); +} + +static void carriedShieldUpgrade() +{ + PlayerBotEquipmentPolicy policy(4); + PlayerBotEquipmentPlayerSnapshot knight; + knight.vocationId = 4; + knight.level = 20; + PlayerBotEquipmentLoadout loadout; + auto& sword = loadout.items[6]; + sword.itemId = 2376; + sword.weaponType = PlayerBotEquipmentWeaponType::Sword; + sword.attack = 14; + sword.left = sword.pickupable = true; + loadout.itemIds[6] = sword.itemId; + PlayerBotEquipmentItemSnapshot shield; + shield.itemId = 2512; + shield.weaponType = PlayerBotEquipmentWeaponType::Shield; + shield.defense = 14; + shield.right = shield.pickupable = shield.inContainer = true; + auto selected = policy.findCarriedUpgrade(knight, loadout, {{shield, true}, {shield, true}}); + assert(selected && selected->index == 0); + assert(static_cast(selected->upgrade.slot) == 5); + assert(std::string(selected->upgrade.metric) == "defense"); + assert(selected->upgrade.benefit == 14); + loadout.items[5] = shield; + loadout.itemIds[5] = shield.itemId; + assert(!policy.findCarriedUpgrade(knight, loadout, {{shield, true}})); + shield.defense = 13; + assert(!policy.findCarriedUpgrade(knight, loadout, {{shield, true}})); + shield.defense = 16; + selected = policy.findCarriedUpgrade(knight, loadout, {{shield, true}}); + assert(selected && selected->upgrade.benefit == 2); + assert(!policy.findCarriedUpgrade(knight, loadout, {{shield, false}})); + shield.inContainer = false; + assert(!policy.findCarriedUpgrade(knight, loadout, {{shield, true}})); + shield.inContainer = true; + shield.minimumLevel = 21; + assert(!policy.findCarriedUpgrade(knight, loadout, {{shield, true}})); + shield.minimumLevel = 0; + shield.pickupable = false; + assert(!policy.findCarriedUpgrade(knight, loadout, {{shield, true}})); + shield.pickupable = true; + shield.attack = 100; + for (auto type : {PlayerBotEquipmentWeaponType::Distance, PlayerBotEquipmentWeaponType::Ammo, + PlayerBotEquipmentWeaponType::Other}) { + shield.weaponType = type; + assert(!policy.findCarriedUpgrade(knight, loadout, {{shield, true}})); + } + for (auto type : {PlayerBotEquipmentWeaponType::Sword, PlayerBotEquipmentWeaponType::Axe, + PlayerBotEquipmentWeaponType::Club}) { + shield.weaponType = type; + assert(policy.findCarriedUpgrade(knight, loadout, {{shield, true}})); + } +} + int main() { + backpackAcquisition(); + carriedShieldUpgrade(); loggingContracts(); huntEconomy(); patrolOpportunity(); diff --git a/server/tests/playerbot_contracts.sh b/server/tests/playerbot_contracts.sh index 4732850..94f6966 100644 --- a/server/tests/playerbot_contracts.sh +++ b/server/tests/playerbot_contracts.sh @@ -7,5 +7,5 @@ trap 'rm -rf "$build"' EXIT HUP INT TERM "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -I"$root/src" \ "$root/tests/playerbot_contracts.cpp" "$root/src/playerbotgoalplanner.cpp" \ "$root/src/playerbothuntruntime.cpp" "$root/src/playerbothuntplanningsession.cpp" \ - "$root/src/playerbothuntpolicy.cpp" -o "$build/playerbot_contracts" + "$root/src/playerbothuntpolicy.cpp" "$root/src/playerbotequipmentpolicy.cpp" -o "$build/playerbot_contracts" "$build/playerbot_contracts" diff --git a/server/tests/playerbot_equipment_purchase_contracts.cpp b/server/tests/playerbot_equipment_purchase_contracts.cpp new file mode 100644 index 0000000..e968a60 --- /dev/null +++ b/server/tests/playerbot_equipment_purchase_contracts.cpp @@ -0,0 +1,333 @@ +// Standalone state-machine regression; no game world or Docker required. +#include +#include +#include + +#include "playerbotprogressionruntime.h" + +namespace { +PlayerBotEquipmentOfferEvaluation bagPlan() +{ + PlayerBotEquipmentOfferEvaluation plan; + plan.itemId = 1988; + plan.price = 20; + plan.slot = static_cast(3); + plan.replacedItemId = 1987; + plan.backpackAcquisition = true; + plan.bagUpgrade = true; + return plan; +} + +void reachPurchase(PlayerBotProgressionRuntime& runtime) +{ + PlayerBotEquipmentPurchaseObservation observation; + observation.depotReached = true; + auto result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::None); + + observation.actionAvailable = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Open); + assert(std::strcmp(result.reason, "open_backpack_depot") == 0); + + observation.depotOpen = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::None); + + observation.oldBagEquipped = true; + observation.actionAvailable = false; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::None); + assert(std::strcmp(result.reason, "action_unavailable") == 0); + + observation.actionAvailable = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Equip); + assert(std::strcmp(result.reason, "depot_stage_old_bag") == 0); + + observation.oldBagEquipped = false; + observation.oldBagAtDepot = true; + observation.backpackReceiptSafe = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::None); + + observation.navigationReached = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::None); +} + +void bankFundingIsRequiredBeforeStaging() +{ + PlayerBotProgressionRuntime runtime; + runtime.beginEquipmentPurchase(bagPlan()); + PlayerBotEquipmentPurchaseObservation observation; + observation.depotReached = true; + runtime.advanceEquipmentPurchase(observation, 3); + observation.actionAvailable = true; + runtime.advanceEquipmentPurchase(observation, 3); + observation.depotOpen = true; + runtime.advanceEquipmentPurchase(observation, 3); + observation.oldBagEquipped = true; + observation.bankFundingAvailable = false; + auto result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.type == PlayerBotProgressionOutcomeType::Failed); + assert(std::strcmp(result.reason, "bank_funding_required") == 0); +} + +void successfulFullBagUpgrade() +{ + PlayerBotProgressionRuntime runtime; + runtime.beginEquipmentPurchase(bagPlan()); + reachPurchase(runtime); + + PlayerBotEquipmentPurchaseObservation observation; + observation.oldBagAtDepot = true; + observation.backpackReceiptSafe = true; + observation.shopReady = true; + observation.fundingAvailable = true; + observation.itemCount = 0; + observation.money = 100; + auto result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Shop); + assert(std::strcmp(result.reason, "purchase_equipment") == 0); + + observation.itemCount = 1; + observation.money = 80; + observation.equipmentVerified = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.transaction.amount == 1); + + observation.depotReached = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::None); + observation.depotOpen = false; + observation.actionAvailable = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Open); + observation.depotOpen = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::None); + + observation.actionAvailable = true; + observation.backpackDestinationOpen = false; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Open); + observation.backpackDestinationOpen = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Equip); + assert(std::strcmp(result.reason, "depot_retrieve_old_bag") == 0); + + observation.oldBagAtDepot = false; + observation.oldBagPreserved = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::None); + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.type == PlayerBotProgressionOutcomeType::Succeeded); + assert(std::strcmp(result.reason, "backpack_acquired") == 0); +} + +void prePurchaseFailureRestoresBag(bool providerAvailable, bool fundingAvailable, const char* expectedReason) +{ + PlayerBotProgressionRuntime runtime; + runtime.beginEquipmentPurchase(bagPlan()); + reachPurchase(runtime); + + PlayerBotEquipmentPurchaseObservation observation; + observation.oldBagAtDepot = true; + observation.backpackReceiptSafe = true; + observation.providerAvailable = providerAvailable; + observation.shopReady = true; + observation.fundingAvailable = fundingAvailable; + auto result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.type == PlayerBotProgressionOutcomeType::Retry); + assert(std::strcmp(result.reason, "restore_old_bag") == 0); + + observation.depotReached = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + observation.depotOpen = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + observation.actionAvailable = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Equip); + assert(std::strcmp(result.reason, "depot_restore_old_bag") == 0); + + observation.oldBagAtDepot = false; + observation.oldBagEquipped = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.type == PlayerBotProgressionOutcomeType::Failed); + assert(std::strcmp(result.reason, expectedReason) == 0); + runtime.finish(); + assert(runtime.active() == PlayerBotProgressionProcedure::None); + assert(!runtime.equipmentPurchase().plan().backpackAcquisition); +} + +void retrievalFailureIsBounded() +{ + PlayerBotProgressionRuntime runtime; + runtime.beginEquipmentPurchase(bagPlan()); + reachPurchase(runtime); + PlayerBotEquipmentPurchaseObservation observation; + observation.oldBagAtDepot = true; + observation.backpackReceiptSafe = true; + observation.shopReady = true; + observation.fundingAvailable = true; + observation.money = 100; + auto result = runtime.advanceEquipmentPurchase(observation, 3); + observation.itemCount = 1; + observation.money = 80; + observation.equipmentVerified = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + observation.depotReached = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + observation.depotOpen = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + result = runtime.advanceEquipmentPurchase(observation, 3); + observation.actionAvailable = true; + observation.backpackDestinationOpen = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Equip); + for (int attempt = 0; attempt < 2; ++attempt) { + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.type == PlayerBotProgressionOutcomeType::Retry); + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Equip); + } + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.type == PlayerBotProgressionOutcomeType::Failed); + assert(std::strcmp(result.reason, "old_bag_recovery_exhausted") == 0); +} + +void survivalInterruptionPreservesRecovery() +{ + PlayerBotProgressionRuntime runtime; + runtime.resumeEquipmentBackpack(bagPlan(), true, false); + runtime.beginEquipmentBackpackRecovery("healing_supply_missing", false); + PlayerBotEquipmentPurchaseObservation observation; + observation.oldBagAtDepot = true; + observation.depotReached = true; + auto result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::None); + observation.depotOpen = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(std::strcmp(result.reason, "action_unavailable") == 0); + observation.actionAvailable = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Equip); + observation.oldBagAtDepot = false; + observation.oldBagEquipped = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.type == PlayerBotProgressionOutcomeType::Failed); + assert(std::strcmp(result.reason, "healing_supply_missing") == 0); +} + +void recoveryInterruptionIsIdempotent() +{ + PlayerBotProgressionRuntime runtime; + runtime.resumeEquipmentBackpack(bagPlan(), true, false); + runtime.beginEquipmentBackpackRecovery("healing_supply_missing", false); + assert(runtime.equipmentBackpackRecoveryActive()); + PlayerBotEquipmentPurchaseObservation observation; + observation.depotReached = true; + auto result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::None); + assert(runtime.equipmentPurchase().stage() == PlayerBotEquipmentPurchaseStage::OpenDepotForRetrieve); + runtime.beginEquipmentBackpackRecovery("healing_supply_missing", false); + assert(runtime.equipmentPurchase().stage() == PlayerBotEquipmentPurchaseStage::OpenDepotForRetrieve); +} + +void replacementBagIsStagedBeforeOriginalRestoration() +{ + PlayerBotProgressionRuntime runtime; + runtime.resumeEquipmentBackpack(bagPlan(), true, false); + runtime.beginEquipmentBackpackRecovery("purchased_backpack_missing", false); + PlayerBotEquipmentPurchaseObservation observation; + observation.depotReached = true; + auto result = runtime.advanceEquipmentPurchase(observation, 3); + observation.depotOpen = true; + observation.oldBagAtDepot = true; + observation.replacementBagEquipped = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::None); + assert(runtime.equipmentPurchase().stage() == PlayerBotEquipmentPurchaseStage::StageReplacementBackpack); + observation.actionAvailable = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Equip); + assert(std::strcmp(result.reason, "depot_stage_replacement_bag") == 0); + observation.replacementBagEquipped = false; + observation.replacementBagAtDepot = true; + observation.backpackReceiptSafe = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(runtime.equipmentPurchase().stage() == PlayerBotEquipmentPurchaseStage::RestoreBackpack); + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Equip); + assert(std::strcmp(result.reason, "depot_restore_old_bag") == 0); + observation.oldBagAtDepot = false; + observation.oldBagEquipped = true; + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.type == PlayerBotProgressionOutcomeType::Failed); + assert(std::strcmp(result.reason, "purchased_backpack_missing") == 0); +} + +void durableStageRehydration() +{ + PlayerBotProgressionRuntime staged; + staged.resumeEquipmentBackpack(bagPlan(), true, false); + PlayerBotEquipmentPurchaseObservation observation; + observation.navigationReached = true; + auto result = staged.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::None); + observation.shopReady = true; + observation.fundingAvailable = true; + observation.backpackReceiptSafe = true; + result = staged.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Shop); + + PlayerBotProgressionRuntime purchased; + purchased.resumeEquipmentBackpack(bagPlan(), true, true); + observation = {}; + observation.depotReached = false; + result = purchased.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Navigate); + assert(std::strcmp(result.reason, "recover_old_bag") == 0); +} + +void rejectedReceiptIsBoundedAndRestored() +{ + PlayerBotProgressionRuntime runtime; + runtime.beginEquipmentPurchase(bagPlan()); + reachPurchase(runtime); + + PlayerBotEquipmentPurchaseObservation observation; + observation.oldBagAtDepot = true; + observation.backpackReceiptSafe = true; + observation.shopReady = true; + observation.fundingAvailable = true; + observation.money = 100; + auto result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.command.type == PlayerBotProgressionCommandType::Shop); + for (int attempt = 0; attempt < 2; ++attempt) { + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.type == PlayerBotProgressionOutcomeType::Retry); + } + result = runtime.advanceEquipmentPurchase(observation, 3); + assert(result.type == PlayerBotProgressionOutcomeType::Retry); + assert(std::strcmp(result.reason, "restore_old_bag") == 0); +} +} + +int main() +{ + bankFundingIsRequiredBeforeStaging(); + successfulFullBagUpgrade(); + prePurchaseFailureRestoresBag(true, false, "reserve_changed"); + prePurchaseFailureRestoresBag(false, true, "provider_unavailable"); + retrievalFailureIsBounded(); + survivalInterruptionPreservesRecovery(); + recoveryInterruptionIsIdempotent(); + replacementBagIsStagedBeforeOriginalRestoration(); + durableStageRehydration(); + rejectedReceiptIsBoundedAndRestored(); + std::cout << "playerbot equipment purchase contracts passed\n"; +} diff --git a/server/tests/playerbot_equipment_purchase_contracts.sh b/server/tests/playerbot_equipment_purchase_contracts.sh new file mode 100644 index 0000000..c21e0f6 --- /dev/null +++ b/server/tests/playerbot_equipment_purchase_contracts.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +build=$(mktemp -d) +trap 'rm -rf "$build"' EXIT HUP INT TERM + +for source in playerbotprogressionruntime playerbotprogressionsession playerbotservicesession playerbotnpcsession; do + "${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -ffunction-sections -fdata-sections -I"$root/src" \ + -c "$root/src/$source.cpp" -o "$build/$source.o" +done +"${CXX:-c++}" -std=c++17 -Wall -Wextra -Werror -ffunction-sections -fdata-sections -I"$root/src" \ + "$root/tests/playerbot_equipment_purchase_contracts.cpp" "$build"/*.o -Wl,--gc-sections \ + -o "$build/playerbot_equipment_purchase_contracts" +"$build/playerbot_equipment_purchase_contracts"