From 000706ce342ac6e31e3efa899ea3e864675ca9a6 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Mon, 10 Aug 2026 12:53:15 +0200 Subject: [PATCH 1/2] server: buy justified equipment upgrades --- scripts/test-playerbot-gameplay.ps1 | 133 +++++- server/src/playerbotcontroller.cpp | 10 +- server/src/playerbotcontroller.h | 32 +- server/src/playerbotequipment.cpp | 403 ++++++++++++++++-- server/src/playerbotprogression.cpp | 40 +- .../playerbot-gameplay/playerbot_gameplay.lua | 78 +++- 6 files changed, 654 insertions(+), 42 deletions(-) diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index 8fca986..c107244 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -14,6 +14,7 @@ param( [switch]$HuntRegionPlanning, [switch]$CombatReadiness, [switch]$EquipmentOffers, + [switch]$EquipmentPurchases, [switch]$Depot, [switch]$MainlandLoop, [switch]$SpellTraining, @@ -968,6 +969,64 @@ function Assert-EquipmentOfferEvents { } } +function Assert-EquipmentPurchaseEvents { + param([string]$Logs, [switch]$Rejected, [switch]$Restart, [switch]$Resume) + + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $purchases = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "buy_equipment" -and $_.result -eq "success" + }) + $equips = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "equip_equipment" -and $_.result -eq "success" + }) + $results = @($events | Where-Object { + $_.event -eq "goal_result" -and $_.goal -eq "buy_equipment" + }) + $terminal = @($events | Where-Object { $_.event -eq "terminal" }) + if ($Restart) { + $online = @($events | Where-Object { + $_.event -eq "lifecycle" -and $_.status -eq "online" -and -not $_.recovered -and $_.objective -eq "service" + }) + if ($online.Count -ne 1 -or $purchases.Count -ne 0 -or $equips.Count -ne 0 -or $terminal.Count -ne 0) { + throw "Equipment purchase restart reconstruction failed. online=$($online.Count), purchases=$($purchases.Count), equips=$($equips.Count), terminal=$($terminal.Count)." + } + return + } + if ($Rejected) { + $fallback = @($events | Where-Object { + $_.event -eq "goal_selection" -and $_.decision_reason -eq "equipment_purchase_failed" -and + $_.from_goal -eq "buy_equipment" -and $_.to_goal -ne "buy_equipment" + }) + if ($results.Count -ne 1 -or $results[0].result -ne "failed" -or + $results[0].reason -ne "transaction_rejected" -or $fallback.Count -ne 1 -or + $purchases.Count -ne 0 -or $equips.Count -ne 0 -or $terminal.Count -ne 0) { + throw "Rejected equipment transaction did not preserve state and return to a valid goal." + } + return + } + if ($Resume) { + $selections = @($events | Where-Object { + $_.event -eq "strategy_selection" -and $_.goal -eq "buy_equipment" -and + $_.item_id -eq 2379 -and $_.acquisition -eq "carried" + }) + if ($selections.Count -ne 1 -or $purchases.Count -ne 0 -or $equips.Count -ne 1 -or + $results.Count -ne 1 -or $results[0].result -ne "success" -or $terminal.Count -ne 0) { + throw "Persisted equipment purchase state was not reconstructed as an equip-only goal." + } + return + } + $selections = @($events | Where-Object { + $_.event -eq "goal_selection" -and $_.to_goal -eq "buy_equipment" -and $_.item_id -eq 2379 -and $_.price -eq 5 + }) + if ($selections.Count -ne 1 -or $purchases.Count -ne 1 -or $equips.Count -ne 1 -or + $results.Count -ne 1 -or $results[0].result -ne "success" -or + $purchases[0].carried_before -ne 110 -or $purchases[0].carried_after -ne 105 -or + $purchases[0].bank_before -ne 100 -or $purchases[0].bank_after -ne 100 -or + -not $equips[0].combat_ready -or -not $equips[0].displaced_items_preserved -or $terminal.Count -ne 0) { + throw "Justified equipment purchase was not selected, paid, equipped, and verified exactly once." + } +} + function Assert-GoalArbitrationInterruptEvents { param([string]$Logs) @@ -1371,7 +1430,7 @@ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { $focusedScenarioRequested = $FullNavigation -or $TargetPursuit -or $CorpseLoot -or $DeathTelemetry -or $Healing -or $ValueLoot -or $PickupProgression -or $GoalArbitration -or $OracleDeparture -or $StaminaProjection -or $HuntRegionPlanning -or - $CombatReadiness -or $EquipmentOffers -or $Depot -or $MainlandLoop -or $SpellTraining -or $SpellUse + $CombatReadiness -or $EquipmentOffers -or $EquipmentPurchases -or $Depot -or $MainlandLoop -or $SpellTraining -or $SpellUse if ($Focused -and -not $focusedScenarioRequested) { throw "-Focused requires at least one focused scenario switch." } @@ -1744,6 +1803,78 @@ try { } } + if ($EquipmentPurchases) { + Invoke-Scenario -Name "equipment_purchase" -DefaultTimeoutSeconds 240 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "equipment_buy" + $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_PASS' | Out-Null + $purchaseLogs = Wait-ForPlayerbotEvent { + $_.event -eq "goal_result" -and $_.goal -eq "buy_equipment" -and $_.result -eq "success" + } + Assert-EquipmentPurchaseEvents -Logs $purchaseLogs + + $restartLineCount = @((Get-ServerLogs) -split "`r?`n").Count + Invoke-Compose stop server + Invoke-Compose up --detach server + $restartLogs = "" + while ([DateTime]::UtcNow -lt $currentScenarioDeadline) { + $restartLogs = ((Get-ServerLogs) -split "`r?`n" | Select-Object -Skip $restartLineCount) -join "`n" + $restartOnline = @(ConvertFrom-PlayerbotLogs -Logs $restartLogs | Where-Object { + $_.event -eq "lifecycle" -and $_.status -eq "online" -and -not $_.recovered -and $_.objective -eq "service" + }).Count -gt 0 + if ($restartLogs -match 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_RESTART_PASS' -and $restartOnline) { break } + Start-Sleep -Seconds 1 + } + if ($restartLogs -notmatch 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_RESTART_PASS' -or -not $restartOnline) { + Throw-WaitTimeout "Timed out waiting for equipment purchase restart reconstruction." + } + Assert-EquipmentPurchaseEvents -Logs $restartLogs -Restart + } + Invoke-Scenario -Name "equipment_purchase_resume" -DefaultTimeoutSeconds 240 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "equipment_buy_resume" + $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_RESUME_PASS' | Out-Null + $resumeLogs = Wait-ForPlayerbotEvent { + $_.event -eq "goal_result" -and $_.goal -eq "buy_equipment" -and $_.result -eq "success" + } + Assert-EquipmentPurchaseEvents -Logs $resumeLogs -Resume + } + Invoke-Scenario -Name "equipment_purchase_space" -DefaultTimeoutSeconds 240 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "equipment_buy_space" + $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" + Invoke-Compose up --detach + $spaceLogs = Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_SPACE_PASS' + $spaceEvents = @(ConvertFrom-PlayerbotLogs -Logs $spaceLogs) + $spaceRejections = @($spaceEvents | Where-Object { + $_.event -eq "equipment_offer_candidate" -and $_.item_id -eq 2379 -and + $_.reason -eq "insufficient_displaced_item_space" + }) + $spaceActions = @($spaceEvents | Where-Object { + $_.event -eq "action_result" -and $_.action -in @("buy_equipment", "equip_equipment") + }) + if ($spaceRejections.Count -lt 1 -or $spaceActions.Count -ne 0) { + throw "Equipment purchase did not reject insufficient displaced-item storage before payment." + } + } + Invoke-Scenario -Name "equipment_purchase_rejected" -DefaultTimeoutSeconds 240 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "equipment_buy_rejected" + $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_REJECTED_PASS' | Out-Null + $rejectedLogs = Wait-ForPlayerbotEvent { + $_.event -eq "goal_selection" -and $_.decision_reason -eq "equipment_purchase_failed" -and + $_.from_goal -eq "buy_equipment" -and $_.to_goal -ne "buy_equipment" + } + Assert-EquipmentPurchaseEvents -Logs $rejectedLogs -Rejected + } + } + if ($OracleDeparture) { Invoke-Scenario -Name "oracle_departure" -DefaultTimeoutSeconds 180 -Body { Invoke-Compose down --volumes --remove-orphans diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index e9bbcbf..20dbad9 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -33,7 +33,11 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment() std::strcmp(gameplayMode, "departure_recovery") == 0 || std::strcmp(gameplayMode, "spell_training") == 0 || std::strcmp(gameplayMode, "equipment_shadow") == 0 || std::strcmp(gameplayMode, "equipment_shadow_unaffordable") == 0 || - std::strcmp(gameplayMode, "equipment_shadow_no_upgrade") == 0); + std::strcmp(gameplayMode, "equipment_shadow_no_upgrade") == 0 || + std::strcmp(gameplayMode, "equipment_buy") == 0 || + std::strcmp(gameplayMode, "equipment_buy_resume") == 0 || + std::strcmp(gameplayMode, "equipment_buy_space") == 0 || + std::strcmp(gameplayMode, "equipment_buy_rejected") == 0); const bool startInHunt = gameplayMode && (std::strcmp(gameplayMode, "navigation") == 0 || std::strcmp(gameplayMode, "navigation_recovery") == 0 || std::strcmp(gameplayMode, "corpse") == 0 || @@ -77,6 +81,10 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment() gameplayMode && std::strcmp(gameplayMode, "hunt_planning") == 0, gameplayMode && std::strcmp(gameplayMode, "hunt_planning") == 0, gameplayMode && std::strcmp(gameplayMode, "navigation_recovery") == 0, + !gameplayMode || (std::strcmp(gameplayMode, "equipment_shadow") != 0 && + std::strcmp(gameplayMode, "equipment_shadow_unaffordable") != 0 && + std::strcmp(gameplayMode, "equipment_shadow_no_upgrade") != 0), + gameplayMode && std::strcmp(gameplayMode, "equipment_buy_rejected") == 0, }; }(); return policy; diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index d505654..e13a2b3 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -79,6 +79,8 @@ namespace playerbot { inline constexpr std::chrono::seconds pickupRewardFailureCooldown(60); inline constexpr std::chrono::minutes spellTrainingSuccessCooldown(5); inline constexpr std::chrono::seconds spellTrainingFailureCooldown(60); + inline constexpr std::chrono::minutes equipmentPurchaseSuccessCooldown(5); + inline constexpr std::chrono::seconds equipmentPurchaseFailureCooldown(60); // Top-level utilities are comparable arbitration scores. Baselines encode the default priority: // critical healing > departure > capacity service > useful rewards > ordinary service > hunting > // economic pickup. Dynamic service and reward adjustments may cross these baselines. Equal scores @@ -86,6 +88,7 @@ namespace playerbot { inline constexpr int32_t serviceGoalBaseUtility = 400; inline constexpr int32_t pickupRewardBaseUtility = 650; inline constexpr int32_t spellTrainingGoalUtility = 550; + inline constexpr int32_t equipmentPurchaseGoalUtility = 500; inline constexpr int32_t economicPickupBaseUtility = 250; inline constexpr int32_t huntGoalUtility = 300; inline constexpr int32_t oracleDepartureUtility = 950; @@ -147,6 +150,8 @@ namespace playerbot { bool forceSecondHuntCandidateNodeLimit; bool cancelHuntPlanningAtScoreBarrier; bool forceRepeatedNavigationStepFailures; + bool equipmentPurchasesEnabled; + bool forceEquipmentPurchaseRejected; }; std::string jsonString(const std::string& value); @@ -197,6 +202,7 @@ class PlayerBotController : public std::enable_shared_from_this evaluateEquipmentUpgrade(const Player& player, const Item& candidate) const; EquipmentLoadout equipmentLoadout(const Player& player) const; - bool applyEquipmentOffer(const Player& player, EquipmentLoadout& loadout, uint16_t itemId, + bool applyEquipmentOffer(const Player& player, EquipmentLoadout& loadout, uint16_t itemId, slots_t& slot, uint16_t& replacedItemId, uint16_t& displacedLeftItemId, uint16_t& displacedRightItemId, std::string& rejection) const; PlayerBotCombatProfile equipmentCombatProfile(const Player& player, const EquipmentLoadout& loadout) const; @@ -587,7 +605,10 @@ class PlayerBotController : public std::enable_shared_from_this evaluateEquipmentOffers(Player& player, const Position& position); + void beginEquipmentPurchase(Player& player, const Position& position, EquipmentOfferEvaluation evaluation); + void processEquipmentPurchase(Player* player, const Position& position); + void finishEquipmentPurchase(Player* player, const Position& position, const char* result, const char* reason); std::string rewardItemSignature(const Item& item) const; @@ -662,7 +683,8 @@ class PlayerBotController : public std::enable_shared_from_this rewardSteps); @@ -849,10 +871,13 @@ class PlayerBotController : public std::enable_shared_from_this rewardInspectionFingerprints; uint16_t pendingEquipmentItemId = 0; uint32_t pendingEquipmentItemCount = 0; + std::map pendingEquipmentDisplacedCounts; uint16_t pendingReadinessItemId = 0; slots_t pendingReadinessSlot = CONST_SLOT_WHEREEVER; uint32_t pendingReadinessAttempts = 0; diff --git a/server/src/playerbotequipment.cpp b/server/src/playerbotequipment.cpp index b9f5fe1..be117bc 100644 --- a/server/src/playerbotequipment.cpp +++ b/server/src/playerbotequipment.cpp @@ -47,7 +47,7 @@ PlayerBotController::EquipmentLoadout PlayerBotController::equipmentLoadout(cons return loadout; } -bool PlayerBotController::applyEquipmentOffer(const Player& player, EquipmentLoadout& loadout, uint16_t itemId, +bool PlayerBotController::applyEquipmentOffer(const Player& player, EquipmentLoadout& loadout, uint16_t itemId, slots_t& slot, uint16_t& replacedItemId, uint16_t& displacedLeftItemId, uint16_t& displacedRightItemId, std::string& rejection) const @@ -91,7 +91,7 @@ bool PlayerBotController::applyEquipmentOffer(const Player& player, EquipmentLoa return equipped && equipped->weaponType == WEAPON_SHIELD; }; - slots_t slot = CONST_SLOT_WHEREEVER; + slot = CONST_SLOT_WHEREEVER; if (type.slotPosition & SLOTP_HEAD) { slot = CONST_SLOT_HEAD; } else if (type.slotPosition & SLOTP_ARMOR) { @@ -291,6 +291,7 @@ void PlayerBotController::emitEquipmentOffer(const Player& player, const Equipme << ",\"lowest_threat_ratio\":" << evaluation.hunts.lowestThreatRatio << ",\"combat_ready\":" << (evaluation.candidateReady ? "true" : "false") << '}' << ",\"rule\":" << jsonString(equipmentDecisionRuleName(evaluation.rule)) + << ",\"carried\":" << (evaluation.carried ? "true" : "false") << ",\"provider_position\":{\"x\":" << evaluation.npcPosition.x << ",\"y\":" << evaluation.npcPosition.y << ",\"z\":" << static_cast(evaluation.npcPosition.z) << '}'; if (reason) { @@ -299,10 +300,11 @@ void PlayerBotController::emitEquipmentOffer(const Player& player, const Equipme emit("equipment_offer_candidate", position, fields.str()); } -void PlayerBotController::evaluateEquipmentOffers(Player& player, const Position& position) +std::optional PlayerBotController::evaluateEquipmentOffers( + Player& player, const Position& position) { if (!requiresKnightCombatReadiness(player)) { - return; + return std::nullopt; } const uint64_t reserve = spellTrainingReserve(player); const uint64_t totalMoney = player.getMoney() + player.getBankBalance(); @@ -311,15 +313,16 @@ void PlayerBotController::evaluateEquipmentOffers(Player& player, const Position const EquipmentHuntSummary currentHunts = equipmentHuntSummary(player, currentProfile); const bool currentReady = equipmentLoadoutReady(player, currentLoadout); std::map evaluatedItems; - std::map> providerRoutes; + std::map>> providerRoutes; std::set providerRouteNodeLimits; std::optional selected; uint32_t feasibleCandidates = 0; + size_t simulatedItems = 0; bool providerRouteBudgetExhausted = false; size_t catalogOffers = 0; bool catalogTruncated = false; - auto providerRoute = [&](Npc& npc) -> std::optional { + auto providerRoute = [&](Npc& npc) -> std::optional> { if (auto route = providerRoutes.find(npc.getID()); route != providerRoutes.end()) { return route->second; } @@ -340,13 +343,17 @@ void PlayerBotController::evaluateEquipmentOffers(Player& player, const Position const int32_t rightDistance = std::max(Position::getDistanceX(position, right), Position::getDistanceY(position, right)); return leftDistance == rightDistance ? left < right : leftDistance < rightDistance; }); - for (size_t approachIndex = 0; approachIndex < approaches.size() && approachIndex < maximumEquipmentProviderApproaches; - ++approachIndex) { + size_t evaluatedApproaches = 0; + for (size_t approachIndex = 0; approachIndex < approaches.size(); ++approachIndex) { const Position& approach = approaches[approachIndex]; Tile* tile = g_game.map.getTile(approach); if (!tile || tile->queryAdd(0, player, 1, 0) != RETURNVALUE_NOERROR) { continue; } + if (evaluatedApproaches >= maximumEquipmentProviderApproaches) { + break; + } + ++evaluatedApproaches; std::deque steps; uint64_t expandedNodes = 0; ++counters.pathfindingCalls; @@ -354,7 +361,7 @@ void PlayerBotController::evaluateEquipmentOffers(Player& player, const Position navigator.plan(player, approach, {}, steps, expandedNodes, maximumEquipmentProviderPathNodes); if (result == PlayerBotNavigationResult::Reached) { - return providerRoutes.emplace(npc.getID(), static_cast(steps.size())).first->second; + return providerRoutes.emplace(npc.getID(), std::make_pair(approach, static_cast(steps.size()))).first->second; } if (result == PlayerBotNavigationResult::NodeLimit) { providerRouteNodeLimits.insert(npc.getID()); @@ -363,6 +370,14 @@ void PlayerBotController::evaluateEquipmentOffers(Player& player, const Position } return providerRoutes.emplace(npc.getID(), std::nullopt).first->second; }; + auto preferCandidate = [](const EquipmentOfferEvaluation& candidate, const EquipmentOfferEvaluation& current) { + return candidate.rule > current.rule || + (candidate.rule == current.rule && (candidate.carried != current.carried ? candidate.carried : + candidate.price < current.price || + (candidate.price == current.price && (candidate.travelSteps < current.travelSteps || + (candidate.travelSteps == current.travelSteps && (candidate.itemId < current.itemId || + (candidate.itemId == current.itemId && candidate.npcId < current.npcId))))))); + }; for (const auto& entry : g_game.getNpcs()) { if (catalogTruncated) { @@ -389,22 +404,16 @@ void PlayerBotController::evaluateEquipmentOffers(Player& player, const Position evaluation.itemId = offer.itemId; evaluation.price = offer.buyPrice; evaluation.currentReady = currentReady; + evaluation.carried = g_game.findItemOfType(&player, offer.itemId, true) != nullptr; if (auto item = evaluatedItems.find(offer.itemId); item != evaluatedItems.end()) { evaluation = item->second; evaluation.npcId = npc->getID(); evaluation.npcPosition = npc->getPosition(); evaluation.price = offer.buyPrice; } else { - if (evaluatedItems.size() >= maximumEquipmentUniqueItems) { - evaluation.profile = currentProfile; - evaluation.hunts = currentHunts; - emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", - "unique_item_evaluation_budget_exhausted"); - continue; - } EquipmentLoadout candidateLoadout = currentLoadout; std::string rejection; - if (!applyEquipmentOffer(player, candidateLoadout, offer.itemId, evaluation.replacedItemId, + if (!applyEquipmentOffer(player, candidateLoadout, offer.itemId, evaluation.slot, evaluation.replacedItemId, evaluation.displacedLeftItemId, evaluation.displacedRightItemId, rejection)) { evaluation.profile = currentProfile; @@ -414,7 +423,7 @@ void PlayerBotController::evaluateEquipmentOffers(Player& player, const Position emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", rejection.c_str()); continue; } - if (Item::items[offer.itemId].weight > player.getFreeCapacity()) { + if (!evaluation.carried && Item::items[offer.itemId].weight > player.getFreeCapacity()) { evaluation.profile = currentProfile; evaluation.hunts = currentHunts; evaluation.rejection = "insufficient_capacity"; @@ -422,9 +431,18 @@ void PlayerBotController::evaluateEquipmentOffers(Player& player, const Position emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", "insufficient_capacity"); continue; } + if (simulatedItems >= maximumEquipmentUniqueItems) { + evaluation.profile = currentProfile; + evaluation.hunts = currentHunts; + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", + "unique_item_evaluation_budget_exhausted"); + continue; + } + ++simulatedItems; evaluation.profile = equipmentCombatProfile(player, candidateLoadout); evaluation.hunts = equipmentHuntSummary(player, evaluation.profile); - evaluation.candidateReady = equipmentLoadoutReady(player, candidateLoadout, Item::items[offer.itemId].weight); + evaluation.candidateReady = equipmentLoadoutReady( + player, candidateLoadout, evaluation.carried ? 0 : Item::items[offer.itemId].weight); const int32_t currentMaximumDamage = Weapons::getMaxWeaponDamage(currentProfile.level, currentProfile.attackSkill, currentProfile.attack, currentProfile.attackFactor); const int32_t candidateMaximumDamage = Weapons::getMaxWeaponDamage(evaluation.profile.level, @@ -472,6 +490,34 @@ void PlayerBotController::evaluateEquipmentOffers(Player& player, const Position evaluation.rejection.c_str()); continue; } + Item* backpackItem = player.getInventoryItem(CONST_SLOT_BACKPACK); + Container* backpack = backpackItem ? backpackItem->getContainer() : nullptr; + const uint32_t freeBackpackSlots = backpack ? backpack->capacity() - + std::min(backpack->capacity(), backpack->size()) : 0; + uint32_t displacedSlots = 0; + std::set countedSlots; + for (const auto& displaced : {std::pair{evaluation.slot, evaluation.replacedItemId}, + {CONST_SLOT_LEFT, evaluation.displacedLeftItemId}, + {CONST_SLOT_RIGHT, evaluation.displacedRightItemId}}) { + if (displaced.second != 0 && countedSlots.insert(displaced.first).second) { + ++displacedSlots; + } + } + const uint32_t requiredBackpackSlots = displacedSlots + (evaluation.carried ? 0 : 1); + if (!backpack || freeBackpackSlots < requiredBackpackSlots) { + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", + "insufficient_displaced_item_space"); + continue; + } + if (evaluation.carried) { + evaluation.travelSteps = 0; + emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "feasible", "carried_upgrade"); + ++feasibleCandidates; + if (!selected || preferCandidate(evaluation, *selected)) { + selected = evaluation; + } + continue; + } if (reserve == std::numeric_limits::max()) { emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", "recovery_reserve_unavailable"); continue; @@ -480,28 +526,25 @@ void PlayerBotController::evaluateEquipmentOffers(Player& player, const Position emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", "unaffordable_after_reserves"); continue; } - const std::optional travelSteps = providerRoute(*npc); - if (!travelSteps) { + const std::optional> route = providerRoute(*npc); + if (!route) { const char* reason = providerRouteBudgetExhausted ? "provider_evaluation_budget_exhausted" : providerRouteNodeLimits.find(npc->getID()) != providerRouteNodeLimits.end() ? "provider_route_node_budget_exhausted" : "provider_unreachable"; emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", reason); continue; } - evaluation.travelSteps = *travelSteps; + evaluation.approachPosition = route->first; + evaluation.travelSteps = route->second; emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "feasible", nullptr); ++feasibleCandidates; - if (!selected || evaluation.rule > selected->rule || - (evaluation.rule == selected->rule && (evaluation.price < selected->price || - (evaluation.price == selected->price && (evaluation.travelSteps < selected->travelSteps || - (evaluation.travelSteps == selected->travelSteps && (evaluation.itemId < selected->itemId || - (evaluation.itemId == selected->itemId && evaluation.npcId < selected->npcId)))))))) { + if (!selected || preferCandidate(evaluation, *selected)) { selected = evaluation; } } } std::ostringstream fields; - fields << "\"result\":" << jsonString(selected ? "would_buy" : "no_decision") + fields << "\"result\":" << jsonString(selected ? (selected->carried ? "would_equip" : "would_buy") : "no_decision") << ",\"feasible_candidates\":" << feasibleCandidates << ",\"catalog_offers_evaluated\":" << catalogOffers << ",\"catalog_truncated\":" << (catalogTruncated ? "true" : "false") @@ -511,4 +554,308 @@ void PlayerBotController::evaluateEquipmentOffers(Player& player, const Position << ",\"price\":" << selected->price << ",\"travel_steps\":" << selected->travelSteps; } emit("equipment_offer_shadow", position, fields.str()); + return selected; +} + +void PlayerBotController::beginEquipmentPurchase(Player& player, const Position& position, + EquipmentOfferEvaluation evaluation) +{ + equipmentPurchase = std::move(evaluation); + progressionObjective = ProgressionObjective::BuyEquipment; + equipmentPurchaseStage = equipmentPurchase.carried ? EquipmentPurchaseStage::Equip : EquipmentPurchaseStage::Travel; + progressionAttempts = 0; + pendingEquipmentDisplacedCounts.clear(); + if (!equipmentPurchase.carried) { + resetConversation(equipmentPurchase.npcId); + } + std::ostringstream fields; + fields << "\"goal\":\"buy_equipment\",\"reason\":" + << jsonString(equipmentDecisionRuleName(equipmentPurchase.rule)) + << ",\"npc_id\":" << equipmentPurchase.npcId << ",\"item_id\":" << equipmentPurchase.itemId + << ",\"price\":" << equipmentPurchase.price << ",\"travel_steps\":" << equipmentPurchase.travelSteps + << ",\"acquisition\":" << jsonString(equipmentPurchase.carried ? "carried" : "purchase"); + emit("strategy_selection", position, fields.str()); + say(player, equipmentPurchase.carried ? "Equipping a carried equipment upgrade." : + "Going to buy a justified equipment upgrade."); +} + +void PlayerBotController::finishEquipmentPurchase(Player* player, const Position& position, const char* result, + const char* reason) +{ + std::ostringstream fields; + fields << "\"goal\":\"buy_equipment\",\"npc_id\":" << equipmentPurchase.npcId + << ",\"item_id\":" << equipmentPurchase.itemId << ",\"price\":" << equipmentPurchase.price + << ",\"rule\":" << jsonString(equipmentDecisionRuleName(equipmentPurchase.rule)) + << ",\"result\":" << jsonString(result) << ",\"reason\":" << jsonString(reason); + emit("strategy_objective_result", position, fields.str()); + emit("goal_result", position, + "\"decision_id\":" + std::to_string(goalDecisionId) + + ",\"goal\":\"buy_equipment\",\"result\":" + jsonString(result) + + ",\"reason\":" + jsonString(reason)); + if (player) { + player->closeShopWindow(); + say(*player, std::string("Equipment purchase ") + result + ": " + reason + '.'); + } + const bool succeeded = std::strcmp(result, "success") == 0; + equipmentPurchaseCooldownUntil = std::chrono::steady_clock::now() + + (succeeded ? equipmentPurchaseSuccessCooldown : equipmentPurchaseFailureCooldown); + progressionObjective = ProgressionObjective::None; + equipmentPurchaseStage = EquipmentPurchaseStage::Travel; + equipmentPurchase = EquipmentOfferEvaluation{}; + progressionAttempts = 0; + pendingEquipmentDisplacedCounts.clear(); + serviceTargetId = 0; + conversationStep = ConversationStep::Greet; + clearNavigation(); + cyclePhase = CyclePhase::Service; + if (testPolicy.progressionEnabled && player) { + selectTopLevelGoal(*player, position, succeeded ? "equipment_purchase_complete" : "equipment_purchase_failed"); + } else { + activeGoal = TopLevelGoal::Service; + } + schedule(SCHEDULER_MINTICKS); +} + +void PlayerBotController::processEquipmentPurchase(Player* player, const Position& position) +{ + Npc* npc = nullptr; + const ShopInfo* offer = nullptr; + ServiceNpc provider; + if (!equipmentPurchase.carried) { + npc = g_game.getNpcByID(equipmentPurchase.npcId); + const std::string* capability = npc && !npc->isRemoved() ? npc->getParameter("playerbot_service") : nullptr; + if (!npc || !capability || *capability != "shop") { + finishEquipmentPurchase(player, position, "failed", "provider_unavailable"); + return; + } + provider = {equipmentPurchase.npcId, npc->getPosition()}; + offer = findOffer(provider, equipmentPurchase.itemId, true); + if (!offer || offer->buyPrice != equipmentPurchase.price) { + finishEquipmentPurchase(player, position, "failed", "offer_changed"); + return; + } + } + + if (equipmentPurchaseStage == EquipmentPurchaseStage::Travel) { + if (!processNavigation(player, position, equipmentPurchase.approachPosition)) { + if (fixedTargetRouteFailureCount >= maximumProgressionAttempts || + blockedStepCount >= maximumRepeatedNavigationStepFailures) { + finishEquipmentPurchase(player, position, "failed", "route_unavailable"); + } + return; + } + equipmentPurchaseStage = EquipmentPurchaseStage::Purchase; + clearNavigation(); + schedule(SCHEDULER_MINTICKS); + return; + } + + if (equipmentPurchaseStage == EquipmentPurchaseStage::Purchase) { + if (!Position::areInRange<3, 3, 0>(position, npc->getPosition())) { + finishEquipmentPurchase(player, position, "failed", "provider_moved"); + return; + } + if (!openServiceShop(player, provider, position)) { + if (serviceAttempts >= maximumServiceAttempts) { + finishEquipmentPurchase(player, position, "failed", "shop_window_unavailable"); + } + return; + } + const uint64_t reserve = spellTrainingReserve(*player); + const uint64_t totalMoney = player->getMoney() + player->getBankBalance(); + if (reserve == std::numeric_limits::max() || totalMoney < equipmentPurchase.price || + totalMoney - equipmentPurchase.price < reserve) { + finishEquipmentPurchase(player, position, "failed", "reserve_changed"); + return; + } + serviceBeforeItemCount = getInventoryItemCount(*player, equipmentPurchase.itemId); + serviceBeforeMoney = player->getMoney(); + serviceBeforeBalance = player->getBankBalance(); + equipmentPurchaseStage = EquipmentPurchaseStage::VerifyPurchase; + ++counters.actionsAttempted; + if (!testPolicy.forceEquipmentPurchaseRejected) { + g_game.playerPurchaseItem(playerId, Item::items[equipmentPurchase.itemId].clientId, + static_cast(offer->subType), 1, false, false); + } + schedule(navigationDecisionDelay(*player)); + return; + } + + if (equipmentPurchaseStage == EquipmentPurchaseStage::VerifyPurchase) { + const uint32_t currentCount = getInventoryItemCount(*player, equipmentPurchase.itemId); + const uint64_t expectedMoney = serviceBeforeMoney > equipmentPurchase.price ? + serviceBeforeMoney - equipmentPurchase.price : 0; + const uint64_t expectedBalance = equipmentPurchase.price > serviceBeforeMoney ? + serviceBeforeBalance - (equipmentPurchase.price - serviceBeforeMoney) : + serviceBeforeBalance; + const bool itemChanged = currentCount == serviceBeforeItemCount + 1; + const bool economyChanged = player->getMoney() == expectedMoney && player->getBankBalance() == expectedBalance; + if (itemChanged && economyChanged) { + emit("action_result", position, + "\"action\":\"buy_equipment\",\"result\":\"success\",\"item_id\":" + + std::to_string(equipmentPurchase.itemId) + ",\"price\":" + std::to_string(equipmentPurchase.price) + + ",\"carried_before\":" + std::to_string(serviceBeforeMoney) + + ",\"carried_after\":" + std::to_string(player->getMoney()) + + ",\"bank_before\":" + std::to_string(serviceBeforeBalance) + + ",\"bank_after\":" + std::to_string(player->getBankBalance())); + progressionAttempts = 0; + equipmentPurchaseStage = EquipmentPurchaseStage::Equip; + schedule(SCHEDULER_MINTICKS); + return; + } + if (currentCount != serviceBeforeItemCount || player->getMoney() != serviceBeforeMoney || + player->getBankBalance() != serviceBeforeBalance) { + logActionFailure("buy_equipment", "transaction_delta_mismatch", position); + stop("equipment_purchase_delta_mismatch", position); + return; + } + if (++progressionAttempts >= maximumProgressionAttempts) { + logActionFailure("buy_equipment", "transaction_rejected", position); + finishEquipmentPurchase(player, position, "failed", "transaction_rejected"); + return; + } + equipmentPurchaseStage = EquipmentPurchaseStage::Purchase; + conversationStep = ConversationStep::Ready; + schedule(navigationDecisionDelay(*player)); + return; + } + + if (equipmentPurchaseStage == EquipmentPurchaseStage::Equip) { + Item* equipped = player->getInventoryItem(equipmentPurchase.slot); + if (equipped && equipped->getID() == equipmentPurchase.itemId) { + equipmentPurchaseStage = EquipmentPurchaseStage::VerifyEquipment; + schedule(SCHEDULER_MINTICKS); + return; + } + Item* purchased = g_game.findItemOfType(player, equipmentPurchase.itemId, true); + if (!purchased) { + if (++progressionAttempts >= maximumProgressionAttempts) { + finishEquipmentPurchase(player, position, "failed", "purchased_item_unavailable"); + } else { + schedule(navigationDecisionDelay(*player)); + } + return; + } + if (!player->canDoAction()) { + schedule(navigationDecisionDelay(*player)); + return; + } + Item* displaced = nullptr; + slots_t displacedSlot = CONST_SLOT_WHEREEVER; + for (const auto& entry : {std::pair{equipmentPurchase.slot, equipmentPurchase.replacedItemId}, + {CONST_SLOT_LEFT, equipmentPurchase.displacedLeftItemId}, + {CONST_SLOT_RIGHT, equipmentPurchase.displacedRightItemId}}) { + Item* equippedItem = entry.second == 0 ? nullptr : player->getInventoryItem(entry.first); + if (equippedItem && equippedItem->getID() == entry.second && equippedItem != purchased) { + displaced = equippedItem; + displacedSlot = entry.first; + break; + } + } + if (displaced) { + if (progressionAttempts >= maximumProgressionAttempts) { + finishEquipmentPurchase(player, position, "failed", "displaced_item_move_not_verified"); + return; + } + Position displacedPosition; + uint8_t displacedIndex = 0; + g_game.internalGetPosition(displaced, displacedPosition, displacedIndex); + ++progressionAttempts; + ++counters.actionsAttempted; + emit("action_result", position, + "\"action\":\"preserve_displaced_equipment\",\"result\":\"requested\",\"item_id\":" + + std::to_string(displaced->getID()) + ",\"slot\":" + std::to_string(displacedSlot)); + g_game.playerMoveItem(player, displacedPosition, displaced->getClientID(), displacedIndex, + Position(0xFFFF, 0, 0), displaced->getItemCount(), displaced, nullptr); + schedule(navigationDecisionDelay(*player)); + return; + } + Container* sourceContainer = dynamic_cast(purchased->getParent()); + if (sourceContainer && player->getContainerID(sourceContainer) < 0) { + if (progressionAttempts >= maximumProgressionAttempts) { + finishEquipmentPurchase(player, position, "failed", "purchased_item_container_unavailable"); + return; + } + Container* containerToOpen = sourceContainer; + while (Container* parent = dynamic_cast(containerToOpen->getParent())) { + if (player->getContainerID(parent) >= 0) { + break; + } + containerToOpen = parent; + } + uint8_t containerId = rewardContainerIdBase; + while (containerId <= maximumContainerId && player->getContainerByID(containerId)) { + ++containerId; + } + Position containerPosition; + uint8_t containerIndex = 0; + Item* containerItem = static_cast(containerToOpen); + g_game.internalGetPosition(containerItem, containerPosition, containerIndex); + if (containerId > maximumContainerId || containerPosition.x != 0xFFFF) { + finishEquipmentPurchase(player, position, "failed", "purchased_item_container_unavailable"); + return; + } + ++progressionAttempts; + ++counters.actionsAttempted; + g_game.playerUseItem(playerId, containerPosition, containerIndex, containerId, containerItem->getClientID()); + emit("action_result", position, + "\"action\":\"open_equipment_container\",\"result\":\"requested\",\"item_id\":" + + std::to_string(containerItem->getID()) + ",\"container_id\":" + std::to_string(containerId)); + schedule(navigationDecisionDelay(*player)); + return; + } + Position sourcePosition; + uint8_t sourceIndex = 0; + g_game.internalGetPosition(purchased, sourcePosition, sourceIndex); + if (sourcePosition.x != 0xFFFF) { + finishEquipmentPurchase(player, position, "failed", "purchased_item_position_unavailable"); + return; + } + pendingEquipmentDisplacedCounts.clear(); + for (uint16_t itemId : {equipmentPurchase.replacedItemId, equipmentPurchase.displacedLeftItemId, + equipmentPurchase.displacedRightItemId}) { + if (itemId != 0) { + pendingEquipmentDisplacedCounts[itemId] = getInventoryItemCount(*player, itemId); + } + } + ++counters.actionsAttempted; + emit("action_result", position, + "\"action\":\"equip_equipment\",\"result\":\"requested\",\"item_id\":" + + std::to_string(purchased->getID()) + ",\"slot\":" + std::to_string(equipmentPurchase.slot) + + ",\"source_y\":" + std::to_string(sourcePosition.y) + + ",\"source_z\":" + std::to_string(sourcePosition.z) + + ",\"source_container\":" + (sourceContainer ? "true" : "false")); + g_game.playerMoveItem(player, sourcePosition, purchased->getClientID(), sourceIndex, + Position(0xFFFF, equipmentPurchase.slot, 0), purchased->getItemCount(), purchased, nullptr); + equipmentPurchaseStage = EquipmentPurchaseStage::VerifyEquipment; + schedule(navigationDecisionDelay(*player)); + return; + } + + Item* equipped = player->getInventoryItem(equipmentPurchase.slot); + const bool displacedPreserved = std::all_of(pendingEquipmentDisplacedCounts.begin(), + pendingEquipmentDisplacedCounts.end(), + [this, player](const auto& entry) { + return getInventoryItemCount(*player, entry.first) >= entry.second; + }); + if (!equipped || equipped->getID() != equipmentPurchase.itemId || !displacedPreserved) { + if (++progressionAttempts >= maximumProgressionAttempts) { + finishEquipmentPurchase(player, position, "failed", + displacedPreserved ? "equip_not_verified" : "displaced_item_lost"); + return; + } + equipmentPurchaseStage = EquipmentPurchaseStage::Equip; + schedule(navigationDecisionDelay(*player)); + return; + } + const EquipmentLoadout actualLoadout = equipmentLoadout(*player); + const PlayerBotCombatProfile actualProfile = equipmentCombatProfile(*player, actualLoadout); + const EquipmentHuntSummary actualHunts = equipmentHuntSummary(*player, actualProfile); + emit("action_result", position, + "\"action\":\"equip_equipment\",\"result\":\"success\",\"item_id\":" + + std::to_string(equipmentPurchase.itemId) + ",\"slot\":" + std::to_string(equipmentPurchase.slot) + + ",\"combat_ready\":" + (equipmentLoadoutReady(*player, actualLoadout) ? "true" : "false") + + ",\"suitable_regions\":" + std::to_string(actualHunts.suitableRegions) + + ",\"displaced_items_preserved\":true"); + finishEquipmentPurchase(player, position, "success", "upgrade_equipped"); } diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index 8c4c599..df279df 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -994,6 +994,7 @@ const char* PlayerBotController::topLevelGoalName(TopLevelGoal goal) const case TopLevelGoal::Service: return "service"; case TopLevelGoal::PickupReward: return "pickup_reward"; case TopLevelGoal::LearnSpell: return "learn_spell"; + case TopLevelGoal::BuyEquipment: return "buy_equipment"; case TopLevelGoal::Hunt: return "hunt"; } return "unknown"; @@ -1038,7 +1039,8 @@ PlayerBotController::GoalCandidate PlayerBotController::serviceGoalCandidate(con } void PlayerBotController::emitGoalCandidate(const Player& player, const GoalCandidate& candidate, const Position& position, const char* decisionReason, - const PickupReward* reward, const DeparturePlan* departure) const + const PickupReward* reward, const DeparturePlan* departure, + const EquipmentOfferEvaluation* equipment) const { std::ostringstream fields; const bool evaluated = candidate.reason != "deferred_lower_utility"; @@ -1061,6 +1063,12 @@ void PlayerBotController::emitGoalCandidate(const Player& player, const GoalCand << ",\"vocation\":\"knight\",\"vocation_id\":" << oracleVocationId << ",\"travel_steps\":" << departure->travelSteps; } + if (equipment) { + fields << ",\"npc_id\":" << equipment->npcId << ",\"item_id\":" << equipment->itemId + << ",\"price\":" << equipment->price << ",\"rule\":" + << jsonString(equipmentDecisionRuleName(equipment->rule)) + << ",\"travel_steps\":" << equipment->travelSteps; + } emit("goal_candidate", position, fields.str()); } @@ -1095,7 +1103,6 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos if (requiresRookgaardDeparture(player)) { return forceOracleDeparture(player, position, decisionReason); } - evaluateEquipmentOffers(player, position); const GoalCandidate service = serviceGoalCandidate(player); DeparturePlan departure; std::deque departureRoute; @@ -1126,10 +1133,20 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos spellTrainingFound ? spellTrainingGoalUtility : 0, spellTrainingCoolingDown ? "cooldown" : spellTrainingFound ? "eligible_reachable_spell" : "no_eligible_spell"}; + const bool equipmentPurchaseCoolingDown = equipmentPurchaseCooldownUntil > now; + const std::optional equipment = equipmentPurchaseCoolingDown ? std::nullopt : + evaluateEquipmentOffers(player, position); + const bool equipmentFound = testPolicy.equipmentPurchasesEnabled && equipment.has_value(); + const GoalCandidate buyEquipment{TopLevelGoal::BuyEquipment, equipmentFound, + equipmentFound ? equipmentPurchaseGoalUtility : 0, + equipmentPurchaseCoolingDown ? "cooldown" : + !testPolicy.equipmentPurchasesEnabled ? "shadow_only" : + equipmentFound ? equipmentDecisionRuleName(equipment->rule) : "no_justified_offer"}; const bool higherUtilityGoal = (departureCandidate.feasible && departureCandidate.utility > huntGoalUtility) || (service.feasible && service.utility > huntGoalUtility) || (pickup.feasible && pickup.utility > huntGoalUtility) || - (learnSpell.feasible && learnSpell.utility > huntGoalUtility); + (learnSpell.feasible && learnSpell.utility > huntGoalUtility) || + (buyEquipment.feasible && buyEquipment.utility > huntGoalUtility); const bool huntFeasible = !higherUtilityGoal; const GoalCandidate hunt{TopLevelGoal::Hunt, huntFeasible, huntGoalUtility, higherUtilityGoal ? "deferred_lower_utility" : @@ -1139,10 +1156,14 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos emitGoalCandidate(player, service, position, decisionReason); emitGoalCandidate(player, pickup, position, decisionReason, pickupFound ? &reward : nullptr); emitGoalCandidate(player, learnSpell, position, decisionReason); + emitGoalCandidate(player, buyEquipment, position, decisionReason, nullptr, nullptr, + equipmentFound ? &*equipment : nullptr); emitGoalCandidate(player, hunt, position, decisionReason); const GoalCandidate* selected = nullptr; - const std::array candidates = {&departureCandidate, &service, &pickup, &learnSpell, &hunt}; + const std::array candidates = { + &departureCandidate, &service, &pickup, &learnSpell, &buyEquipment, &hunt, + }; for (const GoalCandidate* candidate : candidates) { if (candidate->feasible && (!selected || candidate->utility > selected->utility)) { selected = candidate; @@ -1170,6 +1191,10 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos } else if (selected->goal == TopLevelGoal::LearnSpell) { fields << ",\"npc_id\":" << spellTraining.npcId << ",\"spell\":" << jsonString(spellTraining.spellName) << ",\"price\":" << spellTraining.price; + } else if (selected->goal == TopLevelGoal::BuyEquipment) { + fields << ",\"npc_id\":" << equipment->npcId << ",\"item_id\":" << equipment->itemId + << ",\"price\":" << equipment->price << ",\"rule\":" + << jsonString(equipmentDecisionRuleName(equipment->rule)); } emit("goal_selection", position, fields.str()); if (selected->goal == TopLevelGoal::Departure) { @@ -1178,6 +1203,8 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos beginPickupReward(player, position, std::move(reward), std::move(rewardSteps)); } else if (selected->goal == TopLevelGoal::LearnSpell) { beginSpellTraining(player, position, std::move(spellTraining), std::move(spellTrainingSteps)); + } else if (selected->goal == TopLevelGoal::BuyEquipment) { + beginEquipmentPurchase(player, position, *equipment); } else if (selected->goal == TopLevelGoal::Service) { beginService(&player, position, "goal_selected"); } else { @@ -1190,7 +1217,8 @@ const char* PlayerBotController::objectiveName() const { return progressionObjective == ProgressionObjective::OracleDeparture ? "oracle_departure" : progressionObjective == ProgressionObjective::PickupReward ? "pickup_reward" : - progressionObjective == ProgressionObjective::LearnSpell ? "learn_spell" : cyclePhaseName(); + progressionObjective == ProgressionObjective::LearnSpell ? "learn_spell" : + progressionObjective == ProgressionObjective::BuyEquipment ? "buy_equipment" : cyclePhaseName(); } void PlayerBotController::finishProgressionObjective(Player* player, const Position& position, const char* result, const char* reason, @@ -1387,5 +1415,7 @@ void PlayerBotController::processProgression(Player* player, const Position& cur processPickupReward(player, currentPosition); } else if (progressionObjective == ProgressionObjective::LearnSpell) { processSpellTraining(player, currentPosition); + } else if (progressionObjective == ProgressionObjective::BuyEquipment) { + processEquipmentPurchase(player, currentPosition); } } diff --git a/server/tests/playerbot-gameplay/playerbot_gameplay.lua b/server/tests/playerbot-gameplay/playerbot_gameplay.lua index 6013f60..5d55c4e 100644 --- a/server/tests/playerbot-gameplay/playerbot_gameplay.lua +++ b/server/tests/playerbot-gameplay/playerbot_gameplay.lua @@ -21,6 +21,7 @@ local healingPotionCount = 3 local starterArmorId = 2650 local starterWeaponId = 2382 local pickupRewardId = 2384 +local equipmentPurchaseItemId = 2379 local pickupRewardStorage = 64120 local nestedRewardStorage = 50083 local nestedRewardRootId = 1994 @@ -104,6 +105,36 @@ local function verifyEquipmentShadow(playerId, mode, money, leftItemId, rightIte print("PLAYERBOT_GAMEPLAY_TEST " .. string.upper(mode) .. "_PASS") end +local function verifyEquipmentPurchase(playerId, rejected, resumed, attempts) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared during equipment purchase fixture") + local left = player:getSlotItem(CONST_SLOT_LEFT) + local right = player:getSlotItem(CONST_SLOT_RIGHT) + local equippedId = left and left:getId() == equipmentPurchaseItemId and equipmentPurchaseItemId or + right and right:getId() == equipmentPurchaseItemId and equipmentPurchaseItemId or 0 + local totalMoney = player:getMoney() + player:getBankBalance() + local complete = rejected and equippedId == 0 and left and left:getId() == starterWeaponId and + player:getItemCount(equipmentPurchaseItemId) == 0 and totalMoney == 210 or + not rejected and equippedId == equipmentPurchaseItemId and player:getItemCount(starterWeaponId) == 1 and + totalMoney == 205 + if not complete and attempts > 0 then + addEvent(verifyEquipmentPurchase, 500, playerId, rejected, resumed, attempts - 1) + return + end + assert(complete, "equipment purchase fixture did not reach the expected persisted state") + print("PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY" .. (rejected and "_REJECTED" or resumed and "_RESUME" or "") .. "_PASS") +end + +local function verifyEquipmentPurchaseSpace(playerId) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared during equipment storage fixture") + local left = player:getSlotItem(CONST_SLOT_LEFT) + assert(left and left:getId() == starterWeaponId and player:getItemCount(equipmentPurchaseItemId) == 0 and + player:getMoney() + player:getBankBalance() == 210, + "equipment storage rejection mutated equipment or money") + print("PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_SPACE_PASS") +end + local function verifyOracleDeparture(playerId, attempts) local player = Player(playerId) assert(player and not player:isRemoved(), "Bot One disappeared during Oracle departure") @@ -579,12 +610,25 @@ function login.onLogin(player) mode == "stamina_normal" or mode == "hunt_planning" or mode == "readiness_ready" or mode == "readiness_upgrade" or mode == "readiness_missing_weapon" or mode == "readiness_supplies" or mode == "readiness_retention" or mode == "equipment_shadow" or mode == "equipment_shadow_unaffordable" or - mode == "equipment_shadow_no_upgrade", + mode == "equipment_shadow_no_upgrade" or mode == "equipment_buy" or mode == "equipment_buy_resume" or + mode == "equipment_buy_space" or + mode == "equipment_buy_rejected", "unknown PLAYERBOT_GAMEPLAY_MODE: " .. mode) - if mode == "equipment_shadow" or mode == "equipment_shadow_unaffordable" or mode == "equipment_shadow_no_upgrade" then + if mode == "equipment_shadow" or mode == "equipment_shadow_unaffordable" or mode == "equipment_shadow_no_upgrade" or + mode == "equipment_buy" or mode == "equipment_buy_resume" or mode == "equipment_buy_space" or + mode == "equipment_buy_rejected" then local town = player:getTown() assert(player:getLevel() == 8 and player:getVocation():getId() == 4 and town and town:getId() == thaisTownId, "equipment shadow fixture did not load the level-8 Thais Knight") + local currentLeft = player:getSlotItem(CONST_SLOT_LEFT) + local currentRight = player:getSlotItem(CONST_SLOT_RIGHT) + if mode == "equipment_buy" and ((currentLeft and currentLeft:getId() == equipmentPurchaseItemId) or + (currentRight and currentRight:getId() == equipmentPurchaseItemId)) then + assert(player:getItemCount(starterWeaponId) == 1 and player:getMoney() + player:getBankBalance() == 205, + "equipment purchase restart did not preserve equipment and economy") + print("PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_RESTART_PASS") + return true + end if mode ~= "equipment_shadow_no_upgrade" then local weapon = player:getSlotItem(CONST_SLOT_LEFT) assert(weapon and weapon:remove(), "equipment shadow fixture could not clear the equipped sword") @@ -603,14 +647,40 @@ function login.onLogin(player) assert(backpack and backpack:addItem(2152, 20), "equipment shadow fixture could not add surplus money") elseif mode == "equipment_shadow_unaffordable" then removeAll(player, ITEM_GOLD_COIN) + elseif mode == "equipment_buy_resume" then + removeAll(player, equipmentPurchaseItemId) + removeAll(player, ITEM_GOLD_COIN) + local backpack = player:getSlotItem(CONST_SLOT_BACKPACK) + assert(player:getBankBalance() == 100 and backpack and backpack:addItem(ITEM_GOLD_COIN, 105) and + backpack:addItem(equipmentPurchaseItemId, 1), + "equipment resume fixture could not set persisted purchase state") + else + removeAll(player, equipmentPurchaseItemId) + removeAll(player, ITEM_GOLD_COIN) + local backpack = player:getSlotItem(CONST_SLOT_BACKPACK) + assert(player:getBankBalance() == 100 and backpack and backpack:addItem(ITEM_GOLD_COIN, 110), + "equipment purchase fixture could not set the bounded surplus") + if mode == "equipment_buy_space" then + player:setCapacity(100000) + while backpack:getSize() < backpack:getCapacity() - 1 do + assert(backpack:addItem(ITEM_BAG, 1), "equipment storage fixture could not fill the backpack") + end + end end suppressNearbyMonsters(player:getId()) local left = player:getSlotItem(CONST_SLOT_LEFT) local right = player:getSlotItem(CONST_SLOT_RIGHT) local armor = player:getSlotItem(CONST_SLOT_ARMOR) assert(left and armor, "equipment shadow fixture lost required equipment") - addEvent(verifyEquipmentShadow, 250, player:getId(), mode, player:getMoney(), left:getId(), - right and right:getId() or 0, armor:getId(), player:getPosition()) + if mode == "equipment_buy_space" then + addEvent(verifyEquipmentPurchaseSpace, 20000, player:getId()) + elseif mode == "equipment_buy" or mode == "equipment_buy_resume" or mode == "equipment_buy_rejected" then + addEvent(verifyEquipmentPurchase, 500, player:getId(), mode == "equipment_buy_rejected", + mode == "equipment_buy_resume", 360) + else + addEvent(verifyEquipmentShadow, 250, player:getId(), mode, player:getMoney(), left:getId(), + right and right:getId() or 0, armor:getId(), player:getPosition()) + end print("PLAYERBOT_GAMEPLAY_TEST " .. string.upper(mode) .. "_START") return true end From 053cc94894f9c377fd5cd3508753608638de0e04 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:21:44 +0200 Subject: [PATCH 2/2] docs: describe equipment purchase coverage --- README.md | 4 +++- docs/playerbots.md | 2 +- docs/testing.md | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index e9cedb0..f71eede 100644 --- a/README.md +++ b/README.md @@ -130,10 +130,12 @@ provisioning, startup, lifecycle telemetry, and local game ports. The gameplay suite is local and PowerShell-based. It boots scenario worlds with fixture monsters and asserts on the event stream, covering corpse handling, value looting, healing, death recovery, goal arbitration and interruption, -reward claiming, Oracle departure, spell training, and spell use. +reward claiming, Oracle departure, equipment purchasing, spell training, and +spell use. ```powershell pwsh -File scripts/test-playerbot-gameplay.ps1 -FullNavigation -CorpseLoot +pwsh -File scripts/test-playerbot-gameplay.ps1 -Focused -EquipmentPurchases pwsh -File scripts/test-playerbot-gameplay.ps1 -Focused -SpellTraining pwsh -File scripts/test-playerbot-gameplay.ps1 -Focused -SpellUse ``` diff --git a/docs/playerbots.md b/docs/playerbots.md index c1e78a7..a27164a 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -216,7 +216,7 @@ States, actions, results, statuses, and reasons use stable lowercase values. | Lifecycle | `lifecycle`, `state_transition`, `objective_transition`, `terminal` record ownership and controller state. | | Goals | `goal_candidate`, `goal_selection`, `goal_result` expose arbitration evidence and decision IDs. | | Rewards | `strategy_candidate`, `reward_inspection`, `strategy_selection`, `strategy_objective_result` expose bundle selection and verification. | -| Equipment offers | `equipment_offer_candidate` and `equipment_offer_shadow` expose loaded tagged-shop offers, loadout and hunt deltas, reserve and route checks, and the non-mutating shadow decision. | +| Equipment | `equipment_offer_candidate` and `equipment_offer_shadow` expose loaded tagged-shop offers, loadout and hunt deltas, reserve and route checks, and non-mutating `would_buy` or `would_equip` decisions. Live `buy_equipment` goals use `strategy_selection`, `action_result`, `strategy_objective_result`, and `goal_result` to record purchase, carried-item recovery, displacement, equip verification, and fallback. | | Spell training | `spell_trainer_discovered`, `spell_candidate`, `strategy_selection`, `action_result`, and `goal_result` expose loaded offers, eligibility rejections, provider/route choice, and exact payment verification. | | Spell casting | `action_result` with `action="cast_spell"` records the need, semantic `policy_candidate`, selected method, mana reserve, normal-path request, engine result, observed outcome, and fallback. `legal_candidates` contains only normal-path casts confirmed by resource evidence. | | Actions | `action_result`, `target_changed`, `service_discovered`, `npc_reply`, `stuck` record externally relevant attempts and outcomes. | diff --git a/docs/testing.md b/docs/testing.md index f23c515..8061f28 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -48,6 +48,7 @@ the changed behavior: | `-StaminaProjection` | Premium bonus, low-stamina penalty, and ordinary stamina projections. | | `-HuntRegionPlanning` | Cached scanner batching, threat rejection, reachability, cooldowns, and observed correction. | | `-CombatReadiness` | Equipment, supplies, capacity, service recovery, upgrades, and restart reconstruction. | +| `-EquipmentPurchases` | Justified purchase and equip verification, clean restart persistence, carried-upgrade recovery, displaced-item-space rejection, and rejected transactions. | | `-Depot` | Real locker/chest discovery, nested deposits, move verification, retries, and restart checkpoints. | | `-MainlandLoop` | Two real Thais hunt/depot cycles, local services, restart recovery, and teleport exclusion. | | `-SpellTraining` | Tagged trainer discovery, reserve-backed affordability rejection, normal spell dialogue/payment, and restart persistence. |