From be86db8e2e63dbd183f608f6e0fb18b61b8cee80 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:33:47 +0200 Subject: [PATCH 1/8] server: enforce playerbot component boundaries --- server/src/CMakeLists.txt | 1 + server/src/playerbotcombat.cpp | 243 +++- server/src/playerbotcombatprofile.h | 17 + server/src/playerbotcontroller.cpp | 118 +- server/src/playerbotcontroller.h | 59 +- server/src/playerbotdeparture.cpp | 33 +- server/src/playerbotdepotsession.cpp | 1 + server/src/playerbotdepotsession.h | 5 +- server/src/playerbotdepotworkflow.cpp | 164 ++- server/src/playerbotdepotworkflow.h | 111 +- server/src/playerbotequipment.cpp | 195 ++- server/src/playerbotequipmentadapter.cpp | 120 ++ server/src/playerbotequipmentadapter.h | 21 + server/src/playerbotequipmentpolicy.cpp | 482 +++---- server/src/playerbotequipmentpolicy.h | 130 +- server/src/playerbotfixturedriver.cpp | 81 +- server/src/playerbotfixturedriver.h | 12 +- server/src/playerbotgoalarbiter.h | 13 +- server/src/playerbotgoalplanner.cpp | 13 +- server/src/playerbotgoalplanner.h | 4 +- server/src/playerbothuntplanningsession.cpp | 10 - server/src/playerbothuntplanningsession.h | 3 +- server/src/playerbothuntregions.cpp | 31 +- server/src/playerbothuntregions.h | 26 +- server/src/playerbothuntruntime.cpp | 250 ++-- server/src/playerbothuntruntime.h | 150 ++- server/src/playerbotlootsession.cpp | 2 - server/src/playerbotlootsession.h | 47 +- server/src/playerbotnavigationruntime.cpp | 103 +- server/src/playerbotnavigationruntime.h | 68 +- server/src/playerbotnpcsession.cpp | 49 +- server/src/playerbotnpcsession.h | 17 +- server/src/playerbotprogression.cpp | 418 ++++--- server/src/playerbotprogressionplanners.cpp | 227 ++-- server/src/playerbotprogressionplanners.h | 78 +- server/src/playerbotprogressionruntime.cpp | 166 ++- server/src/playerbotprogressionruntime.h | 91 +- server/src/playerbotprogressionsession.h | 28 +- server/src/playerbotservice.cpp | 1254 +++++++------------ server/src/playerbotservicesession.h | 9 +- server/src/playerbotserviceworkflow.cpp | 443 +++++-- server/src/playerbotserviceworkflow.h | 161 +-- server/src/playerbotspells.cpp | 174 ++- server/src/playerbotsurvivalruntime.cpp | 181 ++- server/src/playerbotsurvivalruntime.h | 85 +- 45 files changed, 3174 insertions(+), 2720 deletions(-) create mode 100644 server/src/playerbotcombatprofile.h create mode 100644 server/src/playerbotequipmentadapter.cpp create mode 100644 server/src/playerbotequipmentadapter.h diff --git a/server/src/CMakeLists.txt b/server/src/CMakeLists.txt index fed29866..151c3a63 100644 --- a/server/src/CMakeLists.txt +++ b/server/src/CMakeLists.txt @@ -54,6 +54,7 @@ set(tfs_SRC ${CMAKE_CURRENT_LIST_DIR}/playerbotdeparture.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotequipment.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotequipmentpolicy.cpp + ${CMAKE_CURRENT_LIST_DIR}/playerbotequipmentadapter.cpp ${CMAKE_CURRENT_LIST_DIR}/playerboteconomy.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotgoalarbiter.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotgoalplanner.cpp diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index 98f95c70..cd801dba 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -11,10 +11,13 @@ #include "otpch.h" #include "playerbotcontroller.h" +#include "spells.h" // Playerbot survival, combat targeting, and hunt orchestration. using namespace playerbot; +extern Spells* g_spells; + namespace { Position nearestTargetApproach(Player& player, const Position& currentPosition, const Position& targetPosition) { @@ -45,6 +48,41 @@ namespace { } return staminaMinutes <= 840 ? 0.5 : 1; } + + PlayerBotHuntRuntimePlayerObservation huntPlayerObservation(const Player& player) + { + return {player.getPosition(), player.getLevel(), player.getHealth(), player.getMaxHealth(), + player.getStaminaMinutes(), player.getExperience()}; + } + + PlayerBotHuntPlanningProfile huntPlanningFacts(Player& player, const PlayerBotCombatProfile& combat) + { + return playerBotHuntPlanningProfile(player, combat, 0); + } + + PlayerBotCombatProfile huntCombatProfile(Player& player) + { + const Item* weapon = player.getWeapon(true); + return {player.getLevel(), player.getMaxHealth(), player.getArmor(), player.getDefense(), + weapon ? weapon->getAttack() : 7, + weapon ? player.getWeaponSkill(weapon) : player.getSkillLevel(SKILL_FIST), player.getAttackFactor()}; + } +} + +std::set PlayerBotController::activeHuntCooldowns(std::chrono::steady_clock::time_point now) +{ + std::set excluded; + for (auto it = huntRegionCooldowns.begin(); it != huntRegionCooldowns.end();) { + if (now >= it->second) it = huntRegionCooldowns.erase(it); + else { excluded.insert(it->first); ++it; } + } + return excluded; +} + +void PlayerBotController::applyHuntCooldown(const std::optional& cooldown, + std::chrono::steady_clock::time_point now) +{ + if (cooldown) huntRegionCooldowns[cooldown->region] = now + cooldown->duration; } PlayerBotExpectedCorpse PlayerBotController::expectedCorpseFor(const Creature& target) const @@ -59,13 +97,16 @@ PlayerBotExpectedCorpse PlayerBotController::expectedCorpseFor(const Creature& t return expectation; } -PlayerBotSurvivalSnapshot PlayerBotController::survivalSnapshot(const Player& player) const +PlayerBotSurvivalSnapshot PlayerBotController::survivalSnapshot(const Player& player, const Creature* target) const { PlayerBotSurvivalSnapshot snapshot; snapshot.health = player.getHealth(); snapshot.healthMaximum = player.getMaxHealth(); snapshot.mana = player.getMana(); snapshot.manaMaximum = player.getMaxMana(); + snapshot.manaSpent = player.getSpentMana(); + snapshot.level = player.getLevel(); + snapshot.magicLevel = player.getBaseMagicLevel(); snapshot.potionCount = inventoryPolicy.inventoryItemCount(player, smallHealthPotionItemId); snapshot.foodInventoryCount = inventoryPolicy.foodInventory(player).count; if (const uint16_t pendingFoodId = survivalRuntime.pendingFoodItemId()) { @@ -94,15 +135,59 @@ PlayerBotSurvivalSnapshot PlayerBotController::survivalSnapshot(const Player& pl snapshot.canDoAction = player.canDoAction(); snapshot.buyingPotions = serviceWorkflow.stage() == PlayerBotServiceStage::BuyPotions; snapshot.lootMovePending = lootWorkflow.hasPendingLootMove(); - snapshot.progressionActive = progressionSession.active() != PlayerBotProgressionProcedure::None; - snapshot.progressionDeparture = progressionSession.active(PlayerBotProgressionProcedure::OracleDeparture); + snapshot.progressionActive = progressionRuntime.session().active() != PlayerBotProgressionProcedure::None; + snapshot.progressionDeparture = progressionRuntime.session().active(PlayerBotProgressionProcedure::OracleDeparture); snapshot.hunting = cyclePhase == CyclePhase::Hunt; snapshot.combatOrPursuit = scenarioStage != ScenarioStage::Traverse || combatRuntime.hasActiveCombat() || const_cast(player).getAttackedCreature() != nullptr; snapshot.navigationPending = navigationRuntime.hasPendingWork(); snapshot.healingExhausted = player.hasCondition(CONDITION_EXHAUST_HEAL); + snapshot.combatExhausted = player.hasCondition(CONDITION_EXHAUST_COMBAT); snapshot.hasteActive = player.hasCondition(CONDITION_HASTE); + if (snapshot.hasteActive) snapshot.hasteTicks = player.getCondition(CONDITION_HASTE)->getTicks(); + snapshot.lightActive = player.hasCondition(CONDITION_LIGHT); + snapshot.regenerationActive = player.hasCondition(CONDITION_REGENERATION); + snapshot.protectionZone = player.getZone() == ZONE_PROTECTION; + if (const auto forecast = player.getManaRegenerationForecast()) { + snapshot.regenerationForecastActive = true; + snapshot.regenerationManaGain = forecast->gain; + snapshot.regenerationTickInterval = forecast->interval; + snapshot.regenerationTickRemaining = forecast->remaining; + } snapshot.routeSteps = navigationRuntime.routeSize(); + if (target) { + snapshot.target.id = target->getID(); + snapshot.target.health = target->getHealth(); + snapshot.target.targetClass = target->getMonster() ? "monster:" + target->getName() : "creature"; + snapshot.target.targetClass.resize(std::min(snapshot.target.targetClass.size(), 56)); + snapshot.target.valid = !target->isRemoved() && !target->isDead() && + const_cast(player).getAttackedCreature() == target && player.canSeeCreature(target) && + player.canSee(target->getPosition()) && + Position::areInRange<1, 1, 0>(player.getPosition(), target->getPosition()); + } + for (const PlayerBotSpellDescriptor& descriptor : playerBotSpellDescriptors()) { + PlayerBotSurvivalSpellObservation spell; + spell.name = descriptor.name; + InstantSpell* engineSpell = g_spells ? g_spells->getInstantSpellByName(descriptor.name) : nullptr; + spell.metadataMatches = engineSpell && engineSpell->getWords() == descriptor.words && engineSpell->isLearnable(); + spell.learned = player.hasLearnedInstantSpell(descriptor.name); + if (engineSpell) { + spell.targetReachable = !engineSpell->getNeedTarget() || + (target && !target->isRemoved() && engineSpell->canThrowSpell(&player, target)); + spell.manaCost = engineSpell->getManaCost(&player); + spell.envelope = playerBotSpellEnvelope(player, descriptor); + spell.magicTrainingEligible = descriptor.magicTrainingSafe && descriptor.magicTrainingPriority != 0 && + descriptor.magicTrainingEffect != PlayerBotTrainingEffect::None && spell.learned && + spell.metadataMatches && engineSpell->isEnabled() && player.getLevel() >= engineSpell->getLevel() && + player.getMagicLevel() >= engineSpell->getMagicLevel() && player.getSoul() >= engineSpell->getSoulCost() && + (!engineSpell->isPremium() || player.isPremium()) && + (!engineSpell->getNeedWeapon() || player.getWeapon(true)) && !snapshot.healingExhausted && + !engineSpell->getAggressive() && engineSpell->getSelfTarget() && !engineSpell->getNeedTarget() && + !engineSpell->getHasParam() && !engineSpell->getHasPlayerNameParam() && + !engineSpell->getNeedDirection() && !engineSpell->getNeedCasterTargetOrDirection(); + } + snapshot.spells.push_back(std::move(spell)); + } return snapshot; } @@ -128,7 +213,7 @@ void PlayerBotController::logHealResult(const char* result, const char* reason, bool PlayerBotController::handleHealing(Player* player, const Position& currentPosition) { const auto now = std::chrono::steady_clock::now(); - const PlayerBotSurvivalCommand command = survivalRuntime.decideHealing(*player, survivalSnapshot(*player), currentPosition, now); + const PlayerBotSurvivalCommand command = survivalRuntime.decideHealing(survivalSnapshot(*player), now); if (command.potionVerification) { const auto& verification = *command.potionVerification; if (verification.result == PlayerBotPotionVerificationResult::Success) { @@ -140,7 +225,7 @@ bool PlayerBotController::handleHealing(Player* player, const Position& currentP "ineffective_recovery" : "use_not_verified", verification.before, verification.after, currentPosition); } } - if (command.reason && command.candidate) { + if (!command.reason.empty() && !command.candidateName.empty()) { dispatchSpellCommand(*player, currentPosition, command); } if (command.type == PlayerBotSurvivalCommandType::None) return false; @@ -159,8 +244,8 @@ bool PlayerBotController::handleHealing(Player* player, const Position& currentP << ",\"resource_before\":0,\"resource_after\":0"; emit("action_result", currentPosition, fields.str()); } - if (progressionSession.active() != PlayerBotProgressionProcedure::None) { - if (progressionSession.active(PlayerBotProgressionProcedure::OracleDeparture)) { + if (progressionRuntime.session().active() != PlayerBotProgressionProcedure::None) { + if (progressionRuntime.session().active(PlayerBotProgressionProcedure::OracleDeparture)) { finishOracleDeparture(player, currentPosition, "interrupted", "healing_supply_missing"); } else { finishProgressionObjective(player, currentPosition, "interrupted", "healing_supply_missing", false); @@ -173,7 +258,7 @@ bool PlayerBotController::handleHealing(Player* player, const Position& currentP return false; } huntRuntime.cancelPlanning(); - Item* potion = g_game.findItemOfType(player, smallHealthPotionItemId, true); + Item* potion = g_game.findItemOfType(player, command.itemId, true); if (!potion) { return true; } @@ -608,7 +693,7 @@ void PlayerBotController::emitHuntRegionPlanning(const PlayerBotHuntPlanningSess void PlayerBotController::finishHuntRegion(const Player& player, const Position& position, const char* reason) { Player& mutablePlayer = const_cast(player); - const auto completion = huntRuntime.complete(mutablePlayer, reason, std::chrono::steady_clock::now(), + const auto completion = huntRuntime.complete(huntPlayerObservation(mutablePlayer), std::chrono::steady_clock::now(), static_cast(std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS)))); if (!completion) return; const auto& combat = completion->combat; @@ -638,12 +723,58 @@ void PlayerBotController::finishHuntRegion(const Player& player, const Position& if (Player* speakingPlayer = g_game.getPlayerByID(playerId)) say(*speakingPlayer, "Leaving hunt: " + std::string(reason) + ". " + std::to_string(combat.kills) + " kills, " + std::to_string(completion->experienceGained) + " experience."); } -bool PlayerBotController::selectHuntRegion(Player& player, const Position& position, const char* reason) +bool PlayerBotController::selectHuntRegion(Player& player, const Position& position, const char* reason, + std::chrono::steady_clock::duration* retryAfter) { const auto now = std::chrono::steady_clock::now(); - emitFixtureEvents(fixtureDriver.applyHuntPlanningHooks(huntRuntime), position); + const PlayerBotHuntPlanningObservation fixtureObservation = fixtureDriver.huntPlanningObservation(); const uint32_t duration = static_cast(std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS))); - PlayerBotHuntRuntimeOutcome outcome = huntRuntime.advancePlanning(player, reason, now, duration); + auto planningInput = [&]() { + PlayerBotHuntRuntimePlanningInput input; + input.player = huntPlayerObservation(player); + input.player.excludedRegions = activeHuntCooldowns(now); + input.cacheRevision = PlayerBotHuntRegionPlanner::getCacheRevision(); + input.huntDurationSeconds = duration; + input.reason = reason; + if (huntRuntime.planningStartRequired(now)) { + PlayerBotHuntRegionPlanner planner; + input.start = {{planner.beginScan(player), huntPlanningFacts(player, huntCombatProfile(player))}}; + } + return input; + }; + PlayerBotHuntRuntimeOutcome outcome = huntRuntime.advancePlanning(planningInput(), now, fixtureObservation); + if (outcome.invalidateCache) { + PlayerBotHuntRegionPlanner::invalidateCache(); + PlayerBotHuntPlanningObservation refreshedObservation = fixtureObservation; + refreshedObservation.invalidateCacheRevision = false; + outcome = huntRuntime.advancePlanning(planningInput(), now, refreshedObservation); + outcome.staleRevision = true; + } + if (!outcome.scoreWork.empty()) { + const auto started = std::chrono::steady_clock::now(); + std::vector scores; + scores.reserve(outcome.scoreWork.size()); + PlayerBotHuntRegionPlanner planner; + for (const PlayerBotHuntRuntimeScoreWork& work : outcome.scoreWork) { + auto score = planner.score(player, work.profile, work.cacheRevision, work.candidateIndex, + work.excludedRegions, work.performance, work.huntDurationSeconds); + scores.push_back({work.candidateIndex, score.valid, score.candidateFactsAvailable, + score.withinPlanningScope, std::move(score.region)}); + } + outcome = huntRuntime.completeScoreWork(scores, + std::chrono::duration_cast(std::chrono::steady_clock::now() - started).count()); + } + fixtureDriver.observeHuntPlanning(outcome); + if (outcome.command == PlayerBotHuntRuntimeCommand::PlanningCancelled) { + emit("hunt_region_scan", position, "\"phase\":\"cancelled\""); + } + if (outcome.command == PlayerBotHuntRuntimeCommand::ScopeReevaluationPending) { + if (retryAfter) *retryAfter = outcome.retryAfter; + return false; + } + if (outcome.staleRevision) { + emit("hunt_region_scan", position, "\"phase\":\"stale_revision\""); + } if (outcome.routeWork) { const PlayerBotFixtureRoutePlan fixturePlan = fixtureDriver.huntRoutePlan(playerBotNavigationMaximumExpandedNodes); PlayerBotNavigationRoutePlan plan; @@ -651,18 +782,32 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit plan.metrics.attempted = false; plan.metrics.result = PlayerBotNavigationResult::Unreachable; } else { - plan = navigationRuntime.plan(player, outcome.routeWork->destination, navigationRuntime.activeBlockedPositions(now), - fixturePlan.maximumExpandedNodes); + plan = planNavigationRoute(player, outcome.routeWork->destination, navigationRuntime.activeBlockedPositions(now), + fixturePlan.maximumExpandedNodes); telemetry.recordPathfinding(plan.metrics.elapsed, plan.metrics.result == PlayerBotNavigationResult::Reached); } - huntRuntime.completeRouteWork(player, *outcome.routeWork, plan, duration); + double travelSeconds = 0; + for (const PlayerBotNavigationStep& step : plan.steps) { + travelSeconds += step.action == PlayerBotNavigationAction::Move ? player.getStepDuration(step.direction) / 1000.0 : 1.0; + } + const double availableHuntSeconds = std::max(0.0, duration - travelSeconds); + huntRuntime.completeRouteWork(*outcome.routeWork, {plan.metrics.attempted, + plan.metrics.result == PlayerBotNavigationResult::Reached, plan.metrics.result == PlayerBotNavigationResult::NodeLimit, + plan.metrics.expandedNodes, static_cast(plan.steps.size()), travelSeconds, + projectedHuntStaminaMultiplier(player, availableHuntSeconds)}); + } + if (const auto planning = huntRuntime.planningSession()) { + const char* phase = outcome.command == PlayerBotHuntRuntimeCommand::PlanningStarted ? "scoring_started" : + outcome.command == PlayerBotHuntRuntimeCommand::PlanningYield ? "scoring_yield" : + outcome.command == PlayerBotHuntRuntimeCommand::PlanningScored ? "scored" : + outcome.command == PlayerBotHuntRuntimeCommand::RegionSelected ? "selected" : + outcome.routeWork ? "reachability_yield" : "planning_yield"; + emitHuntRegionPlanning(*planning, position, phase); } - if (const auto* planning = huntRuntime.planningSession()) emitHuntRegionPlanning(*planning, position, - outcome.command == PlayerBotHuntRuntimeCommand::PlanningStarted ? "scoring_started" : outcome.routeWork ? "reachability_yield" : "planning_yield"); for (const PlayerBotHuntRegion& candidate : outcome.candidates) emitHuntRegionCandidate(candidate, position); if (outcome.command == PlayerBotHuntRuntimeCommand::ScopeExhausted) { emit("hunt_region_selection", position, "\"result\":\"failed\",\"reason\":\"no_suitable_reachable_region\""); - emit("hunt_scope_exhausted", position, "\"attempt\":" + std::to_string(outcome.scopeExhaustionAttempt) + ",\"maximum_attempts\":3,\"retry_delay_ms\":" + std::to_string(std::chrono::duration_cast(outcome.retryAfter).count())); + emit("hunt_scope_exhausted", position, "\"reason\":\"local_scope_exhausted\",\"attempt\":" + std::to_string(outcome.scopeExhaustionAttempt) + ",\"maximum_attempts\":3,\"retry_delay_ms\":" + std::to_string(std::chrono::duration_cast(outcome.retryAfter).count())); if (outcome.stopForScopeExhaustion) stop("hunt_scope_exhausted", position); return false; } @@ -674,6 +819,7 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit for (size_t index = 0; index < selected.monsters.size(); ++index) { if (index != 0) speech << ", "; speech << selected.monsters[index].name; } speech << ". Projected " << std::fixed << std::setprecision(0) << selected.projectedExperience << " experience after " << selected.estimatedTravelSeconds << " seconds travel."; say(player, speech.str()); + huntRuntime.completePlanningSelection(); return true; } @@ -694,11 +840,13 @@ void PlayerBotController::startHunt(Player* player, const Position& position, co if (!ensureCombatReady(player, position, reason)) { return; } - progressionRuntime.setActiveGoal(TopLevelGoal::Hunt); + progressionRuntime.enterHunt(); setCyclePhase(CyclePhase::Hunt, position, reason); if (fixtureDriver.huntObservation().selectRegion && !huntRuntime.active()) { - if (!selectHuntRegion(*player, position, "hunt_started")) { - schedule(SCHEDULER_MINTICKS); + std::chrono::steady_clock::duration retryAfter{}; + if (!selectHuntRegion(*player, position, "hunt_started", &retryAfter)) { + const int64_t delay = std::chrono::duration_cast(retryAfter).count(); + schedule(static_cast(delay > 0 ? delay : SCHEDULER_MINTICKS)); return; } } @@ -707,11 +855,11 @@ void PlayerBotController::startHunt(Player* player, const Position& position, co void PlayerBotController::processTraversal(Player* player, const Position& currentPosition) { - if (readinessEquipmentPending) { + if (progressionRuntime.readinessEquipmentPending()) { processReadinessEquipment(player, currentPosition); return; } - if (cyclePhase != CyclePhase::Hunt || progressionSession.active() != PlayerBotProgressionProcedure::None) { + if (cyclePhase != CyclePhase::Hunt || progressionRuntime.session().active() != PlayerBotProgressionProcedure::None) { if (combatRuntime.hasDefensiveCombat()) { processDefensiveCombat(player, currentPosition); return; @@ -729,11 +877,11 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre processDefensiveCombat(player, currentPosition); return; } - if (progressionSession.active() != PlayerBotProgressionProcedure::None) { + if (progressionRuntime.session().active() != PlayerBotProgressionProcedure::None) { processProgression(player, currentPosition); return; } - if (goalArbiter.activeGoal() == TopLevelGoal::MagicTraining) { + if (progressionRuntime.activeGoal() == TopLevelGoal::MagicTraining) { processMagicTraining(*player, currentPosition); return; } @@ -745,10 +893,12 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre return; } if (huntRuntime.planningActive()) { - if (selectHuntRegion(*player, currentPosition, "hunt_planning")) { + std::chrono::steady_clock::duration retryAfter{}; + if (selectHuntRegion(*player, currentPosition, "hunt_planning", &retryAfter)) { beginHuntCycle(player, currentPosition, "hunt_region_selected"); } else { - schedule(SCHEDULER_MINTICKS); + const int64_t delay = std::chrono::duration_cast(retryAfter).count(); + schedule(static_cast(delay > 0 ? delay : SCHEDULER_MINTICKS)); } return; } @@ -785,13 +935,22 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre if (!discoverDepot(*player, currentPosition)) { return; } + const PlayerBotDepotSnapshot depot = depotWorkflow.snapshot(); + if (!depot.hasSelectedDepot) { + schedule(blockedRouteRetryInterval); + return; + } if (pauseDepotFixtureForRestart(*player, DepotRestartCheckpoint::Approach, currentPosition)) { return; } - if (!processNavigation(player, currentPosition, depotWorkflow.approachPosition())) { + if (!processNavigation(player, currentPosition, depot.selected.approachPosition)) { if (navigationRuntime.fixedTargetRouteFailureCount() != 0) { - depotWorkflow.rejectApproach(depotWorkflow.approachPosition(), std::chrono::steady_clock::now() + depotApproachSuppression); - clearDepotDiscovery(); + PlayerBotDepotObservation observation; + observation.currentPosition = currentPosition; + observation.now = std::chrono::steady_clock::now(); + observation.routeResult = PlayerBotDepotRouteResult::Unreachable; + depotWorkflow.advance(observation, depotRouteValidationsPerDecision, maximumDepotDiscoveryAttempts, + depotApproachSuppression); clearNavigation(); } return; @@ -805,13 +964,22 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre if (!discoverDepot(*player, currentPosition)) { return; } - if (!Position::areInRange<1, 1, 0>(currentPosition, depotWorkflow.lockerPosition())) { + const PlayerBotDepotSnapshot depot = depotWorkflow.snapshot(); + if (!depot.hasSelectedDepot) { + schedule(blockedRouteRetryInterval); + return; + } + if (!Position::areInRange<1, 1, 0>(currentPosition, depot.selected.approachPosition)) { setCyclePhase(CyclePhase::ReturnToDepot, currentPosition, "displaced_during_deposit"); clearNavigation(); - if (!processNavigation(player, currentPosition, depotWorkflow.approachPosition())) { + if (!processNavigation(player, currentPosition, depot.selected.approachPosition)) { if (navigationRuntime.fixedTargetRouteFailureCount() != 0) { - depotWorkflow.rejectApproach(depotWorkflow.approachPosition(), std::chrono::steady_clock::now() + depotApproachSuppression); - clearDepotDiscovery(); + PlayerBotDepotObservation observation; + observation.currentPosition = currentPosition; + observation.now = std::chrono::steady_clock::now(); + observation.routeResult = PlayerBotDepotRouteResult::Unreachable; + depotWorkflow.advance(observation, depotRouteValidationsPerDecision, maximumDepotDiscoveryAttempts, + depotApproachSuppression); clearNavigation(); } return; @@ -834,17 +1002,17 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre const auto now = std::chrono::steady_clock::now(); PlayerBotNavigationRuntimeOutcome navigation; if (!processNavigation(player, currentPosition, patrol.destination, &navigation)) { - if (fixtureDriver.navigationRecovery(navigation.routeUnavailable).pause) navigationRuntime.clearBlockedPositions(); + if (fixtureDriver.navigationRecovery(navigation.routeUnavailable).pause) navigationRuntime.resetPatrolRecovery(); const PlayerBotHuntPatrolOutcome recovery = huntRuntime.observePatrolNavigation(navigation, now, maximumRepeatedNavigationStepFailures, maximumPatrolRouteFailures); + applyHuntCooldown(recovery.cooldown, now); if (recovery.command == PlayerBotHuntPatrolCommand::SkipWaypoint || recovery.command == PlayerBotHuntPatrolCommand::RegionExhausted) { emit("hunt_region_patrol", currentPosition, "\"result\":\"skipped\",\"reason\":" + jsonString(recovery.reason) + ",\"step_failures\":" + std::to_string(recovery.stepFailures) + ",\"route_failures\":" + std::to_string(recovery.routeFailures) + ",\"elapsed_ms\":" + std::to_string(recovery.elapsedMs) + ",\"expanded_nodes\":" + std::to_string(recovery.expandedNodes) + ",\"region_id\":" + (recovery.regionId ? std::to_string(*recovery.regionId) : "null")); if (recovery.stepFailures != 0 || recovery.routeFailures != 0) telemetry.recordStuckEvent(); - navigationRuntime.clearBlockedPositions(); - navigationRuntime.resetStepFailures(); + navigationRuntime.resetPatrolRecovery(); clearNavigation(); if (recovery.command == PlayerBotHuntPatrolCommand::RegionExhausted) beginService(player, currentPosition, "hunt_region_patrol_unreachable"); } @@ -852,6 +1020,7 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre } const PlayerBotHuntPatrolOutcome reached = huntRuntime.observePatrolNavigation(navigation, now, maximumRepeatedNavigationStepFailures, maximumPatrolRouteFailures); + applyHuntCooldown(reached.cooldown, now); emit("action_result", currentPosition, "\"action\":\"hunt_waypoint\",\"result\":\"reached\",\"waypoint\":" + std::to_string(reached.waypoint) + ",\"region_id\":" + (reached.regionId ? std::to_string(*reached.regionId) : "null")); schedule(SCHEDULER_MINTICKS); diff --git a/server/src/playerbotcombatprofile.h b/server/src/playerbotcombatprofile.h new file mode 100644 index 00000000..586a078c --- /dev/null +++ b/server/src/playerbotcombatprofile.h @@ -0,0 +1,17 @@ +/** Immutable combat values shared by hunt and equipment decisions. */ +#ifndef FS_PLAYERBOTCOMBATPROFILE_H +#define FS_PLAYERBOTCOMBATPROFILE_H + +#include + +struct PlayerBotCombatProfile { + uint32_t level = 0; + int32_t maximumHealth = 0; + int32_t armor = 0; + int32_t defense = 0; + int32_t attack = 0; + int32_t attackSkill = 0; + float attackFactor = 1.0f; +}; + +#endif diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index 06e23f5d..0ce6e8a9 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -21,8 +21,10 @@ PlayerBotController::PlayerBotController(const Player& player, telemetry(player.getName(), player.getGUID()), equipmentPolicy(oracleVocationId), inventoryPolicy(economyCatalog.sellValues(), [this](const Player& candidatePlayer, const Item& item) { - return equipmentPolicy.evaluateUpgrade(candidatePlayer, item).has_value(); - }), huntRuntime(sharedHuntRegionCooldowns, {huntingLoop.begin(), huntingLoop.end()}) + return equipmentPolicy.evaluateUpgrade(PlayerBotEquipmentAdapter::player(candidatePlayer), + PlayerBotEquipmentAdapter::loadout(candidatePlayer), + PlayerBotEquipmentAdapter::item(item)).has_value(); + }), huntRegionCooldowns(sharedHuntRegionCooldowns), huntRuntime({huntingLoop.begin(), huntingLoop.end()}) {} void PlayerBotController::start(const Position& position, bool recovered, uint32_t recoveryCount) @@ -43,27 +45,27 @@ 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(goalArbiter.activeGoal()) : + << ",\"objective\":" << jsonString((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(); telemetry.emit("lifecycle", position, lifecycle.str()); if (controlledPlayer) { - emitFixtureEvents(fixtureDriver.runSpellCalibration(*controlledPlayer, survivalRuntime), position); - emitFixtureEvents(fixtureDriver.runAdaptiveChallenge(*controlledPlayer, huntRuntime), position); + emitFixtureEvents(fixtureDriver.runSpellCalibration(*controlledPlayer), position); + emitFixtureEvents(fixtureDriver.runAdaptiveChallenge(*controlledPlayer), position); } if (fixtureDriver.magicTrainingScenario() || fixtureDriver.deferInitialization()) { fixtureDriver.beginDelayedInitialization(); } else if (useGoalSelector) { // The selected goal initialized its own executor state. } else if (startInHunt) { - progressionRuntime.setActiveGoal(TopLevelGoal::Hunt); + progressionRuntime.enterHunt(); startHunt(g_game.getPlayerByID(playerId), position, "focused_fixture"); } else if (fixtureDriver.depotScenario()) { - progressionRuntime.setActiveGoal(TopLevelGoal::Service); + progressionRuntime.enterService(); cyclePhase = CyclePhase::ReturnToDepot; } else { - progressionRuntime.setActiveGoal(TopLevelGoal::Service); + progressionRuntime.enterService(); cyclePhase = CyclePhase::Service; } setStage(ScenarioStage::Traverse, position); @@ -137,16 +139,12 @@ std::optional PlayerBotController::clearTraversalTarge Item* PlayerBotController::findActionableSlottedItem(const Player& player, uint16_t itemId, slots_t& slot) const { - const auto now = std::chrono::steady_clock::now(); for (int32_t slotIndex = CONST_SLOT_FIRST; slotIndex <= CONST_SLOT_LAST; ++slotIndex) { const slots_t candidateSlot = static_cast(slotIndex); Item* item = player.getInventoryItem(candidateSlot); if (!item || !inventoryPolicy.isActionableSlottedItem(player, *item, candidateSlot, itemId)) { continue; } - if (serviceWorkflow.slottedSaleUnavailable(item->getID(), candidateSlot, now)) { - continue; - } slot = candidateSlot; return item; } @@ -200,13 +198,16 @@ bool PlayerBotController::findPath(Player* player, const Position& target, std:: void PlayerBotController::clearNavigation() { huntRuntime.cancelPlanning(); - navigationRuntime.clear(); + navigationRuntime.reset(); } -void PlayerBotController::adoptNavigationPlan(const Position& destination, std::deque steps) +void PlayerBotController::observeNavigationPlan(const Position& destination, std::deque steps) { - clearNavigation(); - navigationRuntime.adopt(destination, std::move(steps)); + PlayerBotNavigationRoutePlan plan; + plan.metrics.result = PlayerBotNavigationResult::Reached; + plan.metrics.steps = steps.size(); + plan.steps = std::move(steps); + navigationRuntime.observePlan({destination, std::move(plan), true, true, std::chrono::steady_clock::now()}); } void PlayerBotController::onDeath(const Player& player, const Creature* killer, const Creature* mostDamageKiller) @@ -218,9 +219,11 @@ void PlayerBotController::onDeath(const Player& player, const Creature* killer, lastPosition = player.getPosition(); if (huntRuntime.active() && cyclePhase == CyclePhase::Hunt && (scenarioStage == ScenarioStage::TraversalCombat || scenarioStage == ScenarioStage::TargetPursuit)) { - huntRuntime.observeDeath(true, std::chrono::steady_clock::now(), huntRegionCooldown); + const auto now = std::chrono::steady_clock::now(); + applyHuntCooldown(huntRuntime.observeDeath(true, huntRegionCooldown), now); } else { - huntRuntime.observeDeath(false, std::chrono::steady_clock::now(), huntRegionCooldown); + const auto now = std::chrono::steady_clock::now(); + applyHuntCooldown(huntRuntime.observeDeath(false, huntRegionCooldown), now); } finishHuntRegion(player, lastPosition, "death"); std::ostringstream fields; @@ -310,6 +313,22 @@ void PlayerBotController::onCombatDamage(Creature* attacker, const Creature& tar survivalRuntime.observeCombatDamage(attacker ? attacker->getID() : 0, target.getID(), playerId, damage); } +PlayerBotNavigationRoutePlan PlayerBotController::planNavigationRoute(Player& player, const Position& destination, + const std::set& blockedPositions, + uint64_t maximumExpandedNodes) const +{ + PlayerBotNavigationRoutePlan routePlan; + routePlan.metrics.attempted = true; + const auto startedAt = std::chrono::steady_clock::now(); + const PlayerBotNavigator navigator; + routePlan.metrics.result = navigator.plan(player, destination, blockedPositions, routePlan.steps, + routePlan.metrics.expandedNodes, maximumExpandedNodes); + routePlan.metrics.elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - startedAt); + routePlan.metrics.steps = routePlan.steps.size(); + return routePlan; +} + void PlayerBotController::onHealthGain(Creature* healer, const Creature& target, uint32_t gain) { survivalRuntime.observeHealthGain(healer && healer->getID() == playerId, target.getID() == playerId, gain); @@ -319,12 +338,27 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr PlayerBotNavigationRuntimeOutcome* navigationOutcome) { const auto now = std::chrono::steady_clock::now(); - const PlayerBotFixtureRoutePlan fixturePlan = fixtureDriver.navigationPlan(playerBotNavigationMaximumExpandedNodes); - const PlayerBotNavigationRuntimeOutcome outcome = navigationRuntime.process({ - *player, currentPosition, destination, player->getWalkDelay() > 0 || !player->canDoAction(), player->canDoAction(), - fixturePlan.forceFailure, - {now, navigationStepTimeout, navigationBlockSuppression, navigationOscillationSuppression}, + const PlayerBotNavigationRuntimeTiming timing = { + now, navigationStepTimeout, navigationBlockSuppression, navigationOscillationSuppression, + }; + PlayerBotNavigationRuntimeOutcome outcome = navigationRuntime.process({ + currentPosition, destination, player->getWalkDelay() > 0 || !player->canDoAction(), player->canDoAction(), timing, }); + if (outcome.routeRequest) { + const PlayerBotFixtureRoutePlan fixturePlan = fixtureDriver.navigationPlan(outcome.routeRequest->maximumExpandedNodes); + PlayerBotNavigationRoutePlan routePlan; + if (fixturePlan.forceFailure) { + routePlan.metrics.attempted = true; + routePlan.metrics.result = PlayerBotNavigationResult::Unreachable; + routePlan.metrics.expandedNodes = outcome.routeRequest->maximumExpandedNodes; + } else { + routePlan = planNavigationRoute(*player, outcome.routeRequest->destination, outcome.routeRequest->blockedPositions, + outcome.routeRequest->maximumExpandedNodes); + } + const std::optional pendingWorldChange = outcome.pendingWorldChange; + outcome = navigationRuntime.observePlan({destination, std::move(routePlan), player->canDoAction(), false, now}); + outcome.pendingWorldChange = pendingWorldChange; + } if (navigationOutcome) *navigationOutcome = outcome; fixtureDriver.observeNavigationPlan(outcome.plan.attempted); if (outcome.destinationReached) { @@ -363,10 +397,11 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr } } if (outcome.pendingWorldChange) { - if (Item* unchanged = findNavigationItem(*outcome.pendingWorldChange)) { - navigationRuntime.suppress(outcome.pendingWorldChange->target, now + navigationBlockSuppression); + const bool unchanged = findNavigationItem(*outcome.pendingWorldChange) != nullptr; + if (unchanged) { telemetry.logActionFailure("navigate", "transition_state_unchanged", currentPosition); } + navigationRuntime.observeWorldChange({*outcome.pendingWorldChange, unchanged, now, navigationBlockSuppression}); } if (outcome.plan.attempted) { telemetry.recordPathfinding(outcome.plan.elapsed, !outcome.routeUnavailable); @@ -392,20 +427,25 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr telemetry.emit("action_result", currentPosition, fields.str()); } - if (!outcome.nextStep) { + if (outcome.command != PlayerBotNavigationRuntimeCommand::Move && + outcome.command != PlayerBotNavigationRuntimeCommand::Use) { schedule(navigationDecisionDelay(*player)); return false; } + if (!outcome.nextStep) { + schedule(navigationDecisionDelay(*player)); + return false; + } const PlayerBotNavigationStep& step = *outcome.nextStep; if (!executeNavigationStep(player, step)) { - navigationRuntime.rejectNextStep(); + navigationRuntime.observeStep({step, PlayerBotNavigationStepResult::Rejected, std::chrono::steady_clock::now()}); telemetry.logActionFailure("navigate", "transition_unavailable", currentPosition); schedule(blockedRouteRetryInterval); return false; } - navigationRuntime.completeStep(step, std::chrono::steady_clock::now()); + navigationRuntime.observeStep({step, PlayerBotNavigationStepResult::Dispatched, std::chrono::steady_clock::now()}); schedule(navigationDecisionDelay(*player)); return false; } @@ -459,10 +499,10 @@ void PlayerBotController::navigate() return; } } else if (fixtureDriver.startInHunt()) { - progressionRuntime.setActiveGoal(TopLevelGoal::Hunt); + progressionRuntime.enterHunt(); startHunt(player, currentPosition, "focused_fixture"); } else { - progressionRuntime.setActiveGoal(TopLevelGoal::Service); + progressionRuntime.enterService(); cyclePhase = CyclePhase::Service; } schedule(navigationInterval); @@ -472,24 +512,26 @@ void PlayerBotController::navigate() recordActiveHuntCombat(*player); verifySpellCast(*player, currentPosition); if (huntRuntime.active() && cyclePhase == CyclePhase::Hunt) { - if (huntRuntime.dangerObserved(*player, std::chrono::steady_clock::now(), huntRegionCooldown)) { + const auto now = std::chrono::steady_clock::now(); + if (const auto cooldown = huntRuntime.dangerObserved(player->getMaxHealth(), now, huntRegionCooldown)) { + applyHuntCooldown(cooldown, now); beginService(player, currentPosition, "hunt_region_observed_danger"); schedule(navigationInterval); return; } } - const bool accessingReward = progressionSession.active(PlayerBotProgressionProcedure::PickupReward) && - (rewardSession.stage() == PlayerBotRewardStage::VerifyReward || - rewardSession.stage() == PlayerBotRewardStage::EquipReward || - rewardSession.stage() == PlayerBotRewardStage::VerifyEquipment); - const bool verifyingDeparture = progressionSession.active(PlayerBotProgressionProcedure::OracleDeparture) && - departureSession.stage() == PlayerBotOracleDepartureStage::Verify; + const bool accessingReward = progressionRuntime.session().active(PlayerBotProgressionProcedure::PickupReward) && + (progressionRuntime.reward().stage() == PlayerBotRewardStage::VerifyReward || + progressionRuntime.reward().stage() == PlayerBotRewardStage::EquipReward || + progressionRuntime.reward().stage() == PlayerBotRewardStage::VerifyEquipment); + const bool verifyingDeparture = progressionRuntime.session().active(PlayerBotProgressionProcedure::OracleDeparture) && + progressionRuntime.departure().stage() == PlayerBotOracleDepartureStage::Verify; if (!accessingReward && !verifyingDeparture && handleHealing(player, currentPosition)) { schedule(blockedRouteRetryInterval); return; } const bool waitingForRecovery = cyclePhase == CyclePhase::Service && survivalRuntime.needsHealing(survivalSnapshot(*player)); - if (!accessingReward && !progressionSession.active(PlayerBotProgressionProcedure::OracleDeparture) && + if (!accessingReward && !progressionRuntime.session().active(PlayerBotProgressionProcedure::OracleDeparture) && departurePlanner.required(departureSnapshot(*player)) && !waitingForRecovery) { if (selectTopLevelGoal(*player, currentPosition, "level_eight_interrupt")) { schedule(SCHEDULER_MINTICKS); diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index e4daf386..d34aa937 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -18,6 +18,7 @@ #include "playerbotdepotworkflow.h" #include "playerboteconomy.h" #include "playerbotequipmentpolicy.h" +#include "playerbotequipmentadapter.h" #include "playerbothuntruntime.h" #include "playerbotinventorypolicy.h" #include "playerbotlootworkflow.h" @@ -101,7 +102,6 @@ namespace playerbot { // critical healing > departure > capacity service > useful rewards > ordinary service > hunting > // economic pickup. Dynamic service and reward adjustments may cross these baselines. Equal scores // retain the candidate declaration order. - inline constexpr int32_t serviceGoalBaseUtility = 400; inline constexpr int32_t pickupRewardBaseUtility = 650; inline constexpr int32_t spellTrainingGoalUtility = 550; inline constexpr int32_t equipmentPurchaseGoalUtility = 500; @@ -109,12 +109,9 @@ namespace playerbot { inline constexpr int32_t economicPickupBaseUtility = 250; inline constexpr int32_t huntGoalUtility = 300; inline constexpr int32_t oracleDepartureUtility = 950; - inline constexpr int32_t capacityServiceUtility = 900; - inline constexpr int32_t criticalHealingServiceUtility = 1000; inline constexpr size_t maximumEquipmentCandidateSimulations = 16; inline constexpr int32_t missingPotionUtility = 15; inline constexpr int32_t foodPreferenceUtility = 20; - inline constexpr int32_t sellableItemUtility = 10; inline constexpr uint32_t returnCapacityThreshold = 30 * 100; inline constexpr uint32_t maximumServiceAttempts = 3; // Prevent a rejected slotted-item move from blocking the service/depot loop. @@ -173,7 +170,6 @@ class PlayerBotController : public std::enable_shared_from_this& services, const Position& position) const; - const ServiceNpc* findShopFor(uint16_t itemId, bool buying, const Position& position) const; - - const ServiceNpc* findLootSeller(Player* player, const Position& position, uint16_t& itemId) const; - bool prepareSlottedSaleItem(Player* player, uint16_t itemId, const Position& position); - - void completeServiceAction(Player* player, const char* action, const PlayerBotServiceTransaction& transaction, - const Position& position); - - void processServiceShop(Player* player, const Position& currentPosition, const ServiceNpc& service, const char* action, - uint16_t itemId, uint32_t amount, bool purchase); - - void processBank(Player* player, const Position& currentPosition, const ServiceNpc& banker); - void processService(Player* player, const Position& currentPosition); Item* findNavigationItem(const PlayerBotNavigationStep& step) const; bool executeNavigationStep(Player* player, const PlayerBotNavigationStep& step); + PlayerBotNavigationRoutePlan planNavigationRoute(Player& player, const Position& destination, + const std::set& blockedPositions = {}, + uint64_t maximumExpandedNodes = playerBotNavigationMaximumExpandedNodes) const; uint32_t navigationDecisionDelay(const Player& player) const; @@ -409,15 +389,14 @@ class PlayerBotController : public std::enable_shared_from_this steps); + void observeNavigationPlan(const Position& destination, std::deque steps); bool findDepositableItem(const Player& player, Container* container, Container*& source, Item*& depositItem, uint8_t& count) const; bool findDepotLocker(const Position& position, uint16_t expectedDepotId, uint16_t& lockerItemId) const; - void clearDepotDiscovery(); bool discoverDepot(Player& player, const Position& currentPosition); - bool openDepotLocker(Player& player, const Position& currentPosition); - bool openDepotChest(Player& player, const Position& currentPosition); + bool openDepotLocker(Player& player, const PlayerBotDepotSnapshot& depot, const Position& currentPosition); + bool openDepotChest(Player& player, const PlayerBotDepotSnapshot& depot, const Position& currentPosition); bool pauseDepotFixtureForRestart(Player& player, playerbot::DepotRestartCheckpoint checkpoint, const Position& currentPosition); bool openContainer(Player& player, Container& container, uint8_t containerId, const Position& currentPosition); @@ -431,10 +410,14 @@ class PlayerBotController : public std::enable_shared_from_this& events, const Position& position) const; void emitHuntRegionPlanning(const PlayerBotHuntPlanningSession& planning, const Position& position, const char* phase) const; + std::set activeHuntCooldowns(std::chrono::steady_clock::time_point now); + void applyHuntCooldown(const std::optional& cooldown, + std::chrono::steady_clock::time_point now); void finishHuntRegion(const Player& player, const Position& position, const char* reason); - bool selectHuntRegion(Player& player, const Position& position, const char* reason); + bool selectHuntRegion(Player& player, const Position& position, const char* reason, + std::chrono::steady_clock::duration* retryAfter = nullptr); void beginHuntCycle(Player* player, const Position& position, const char* reason); void startHunt(Player* player, const Position& position, const char* reason); @@ -483,22 +466,10 @@ class PlayerBotController : public std::enable_shared_from_this rewardInspectionFingerprints; - uint16_t pendingReadinessItemId = 0; - slots_t pendingReadinessSlot = CONST_SLOT_WHEREEVER; - uint32_t pendingReadinessAttempts = 0; - bool readinessEquipmentPending = false; - bool readinessResumeService = false; PlayerBotServiceWorkflow serviceWorkflow; PlayerBotNavigationRuntime navigationRuntime; + std::map& huntRegionCooldowns; PlayerBotHuntRuntime huntRuntime; bool deathObserved = false; }; diff --git a/server/src/playerbotdeparture.cpp b/server/src/playerbotdeparture.cpp index d3937bd6..877b0406 100644 --- a/server/src/playerbotdeparture.cpp +++ b/server/src/playerbotdeparture.cpp @@ -62,7 +62,7 @@ bool PlayerBotController::findOracleDeparture(Player& player, const Position& po uint64_t expandedNodes = 0; const auto startedAt = std::chrono::steady_clock::now(); const PlayerBotNavigationRoutePlan routePlan = candidate == position ? PlayerBotNavigationRoutePlan{} : - navigationRuntime.plan(player, candidate); + planNavigationRoute(player, candidate); const PlayerBotNavigationResult planResult = candidate == position ? PlayerBotNavigationResult::Reached : routePlan.metrics.result; if (candidate != position) { steps = routePlan.steps; @@ -87,8 +87,8 @@ bool PlayerBotController::findOracleDeparture(Player& player, const Position& po bool PlayerBotController::forceOracleDeparture(Player& player, const Position& position, const char* decisionReason) { - const TopLevelGoal previousGoal = goalArbiter.activeGoal(); - const uint64_t previousDecisionId = goalArbiter.decisionId(); + const TopLevelGoal previousGoal = progressionRuntime.activeGoal(); + const uint64_t previousDecisionId = progressionRuntime.decisionId(); const bool interruptedHunt = previousGoal == TopLevelGoal::Hunt && cyclePhase == CyclePhase::Hunt; if (interruptedHunt) { finishHuntRegion(player, position, "level_eight_interrupt"); @@ -106,16 +106,14 @@ bool PlayerBotController::forceOracleDeparture(Player& player, const Position& p player.closeContainer(corpseContainerId); setStage(ScenarioStage::Traverse, position); progressionRuntime.finish(); - serviceWorkflow.reset(); PlayerBotOracleDeparturePlan plan; std::deque route; const bool withinOracleLevelRange = player.getLevel() <= oracleMaximumLevel; const bool found = withinOracleLevelRange && findOracleDeparture(player, position, plan, route); - const GoalCandidate candidate{TopLevelGoal::Departure, found, found ? oracleDepartureUtility : 0, - !withinOracleLevelRange ? "above_maximum_level" : - found ? "oracle_reachable" : "oracle_unreachable"}; - const PlayerBotGoalArbiter::GoalDecision decision = progressionRuntime.force(candidate); + const char* candidateReason = !withinOracleLevelRange ? "above_maximum_level" : found ? "oracle_reachable" : "oracle_unreachable"; + const PlayerBotGoalArbiter::GoalDecision decision = progressionRuntime.interruptForDeparture( + found, found ? oracleDepartureUtility : 0, candidateReason); emitGoalCandidate(player, decision.candidate(TopLevelGoal::Departure), decision.id, position, decisionReason, nullptr, found ? &plan : nullptr); if (!found) { @@ -123,14 +121,13 @@ bool PlayerBotController::forceOracleDeparture(Player& player, const Position& p "\"decision_id\":" + std::to_string(decision.id) + ",\"decision_reason\":" + jsonString(decisionReason) + ",\"from_goal\":" + jsonString(PlayerBotGoalArbiter::goalName(previousGoal)) + ",\"to_goal\":\"oracle_departure\",\"result\":\"failed\",\"reason\":" + - jsonString(candidate.reason) + ",\"forced\":true,\"level\":" + std::to_string(player.getLevel()) + + jsonString(candidateReason) + ",\"forced\":true,\"level\":" + std::to_string(player.getLevel()) + ",\"player_vocation_id\":" + std::to_string(player.getVocation()->getId()) + ",\"vocation_id\":" + std::to_string(oracleVocationId)); stop("oracle_departure_unavailable", position); return false; } - progressionRuntime.apply(decision); emit("goal_selection", position, "\"decision_id\":" + std::to_string(decision.id) + ",\"decision_reason\":" + jsonString(decisionReason) + ",\"from_goal\":" + jsonString(PlayerBotGoalArbiter::goalName(previousGoal)) + @@ -148,9 +145,8 @@ void PlayerBotController::beginOracleDeparture(Player& player, const Position& p std::deque steps) { progressionRuntime.beginDeparture(std::move(plan)); - const auto& departure = departureSession.plan(); - serviceWorkflow.resetNpc(departure.npcId); - navigationRuntime.adopt(departure.approachPosition, std::move(steps)); + const auto& departure = progressionRuntime.departure().plan(); + observeNavigationPlan(departure.approachPosition, std::move(steps)); emit("strategy_selection", position, "\"goal\":\"oracle_departure\",\"npc_id\":" + std::to_string(departure.npcId) + ",\"town\":\"thais\",\"town_id\":" + std::to_string(oracleTownId) + @@ -162,11 +158,10 @@ void PlayerBotController::beginOracleDeparture(Player& player, const Position& p void PlayerBotController::finishOracleDeparture(Player* player, const Position& position, const char* result, const char* reason) { emit("goal_result", position, - "\"decision_id\":" + std::to_string(goalArbiter.decisionId()) + + "\"decision_id\":" + std::to_string(progressionRuntime.decisionId()) + ",\"goal\":\"oracle_departure\",\"result\":" + jsonString(result) + ",\"reason\":" + jsonString(reason)); progressionRuntime.finish(); - serviceWorkflow.resetNpc(); clearNavigation(); if (std::strcmp(result, "success") == 0) { if (player) { @@ -185,15 +180,15 @@ void PlayerBotController::finishOracleDeparture(Player* player, const Position& void PlayerBotController::processOracleDeparture(Player* player, const Position& currentPosition) { - const auto& departure = departureSession.plan(); + const auto& departure = progressionRuntime.departure().plan(); PlayerBotDepartureObservation observation; Npc* oracle = g_game.getNpcByID(departure.npcId); - if (departureSession.stage() == PlayerBotOracleDepartureStage::Travel) { + if (progressionRuntime.departure().stage() == PlayerBotOracleDepartureStage::Travel) { observation.navigationReached = processNavigation(player, currentPosition, departure.approachPosition); observation.navigationFailed = navigationRuntime.fixedTargetRouteFailureCount() >= maximumProgressionAttempts; } else { observation.npcAvailable = oracle && !oracle->isRemoved() && Position::areInRange<3, 3, 0>(currentPosition, oracle->getPosition()); - observation.greetingAcknowledged = serviceWorkflow.isGreetingAcknowledged(); + observation.greetingAcknowledged = progressionRuntime.greetingAcknowledged(); const bool correctVocation = player->getVocation()->getId() == oracleVocationId; const bool correctTown = player->getTown() && player->getTown()->getID() == oracleTownId; observation.departureVerified = correctVocation && correctTown && currentPosition == player->getTown()->getTemplePosition(); @@ -211,7 +206,7 @@ void PlayerBotController::processOracleDeparture(Player* player, const Position& } if (result.command.type == PlayerBotProgressionCommandType::Speak) { if (!oracle || oracle->isRemoved()) return; - if (std::strcmp(result.command.reason, "hi") == 0) serviceWorkflow.resetGreetingAcknowledgement(); + if (std::strcmp(result.command.reason, "hi") == 0) progressionRuntime.clearGreetingAcknowledgement(); telemetry.recordActionAttempt(); oracle->receiveSpeech(player, TALKTYPE_PRIVATE_PN, result.command.reason); } diff --git a/server/src/playerbotdepotsession.cpp b/server/src/playerbotdepotsession.cpp index 8041be45..f19d5071 100644 --- a/server/src/playerbotdepotsession.cpp +++ b/server/src/playerbotdepotsession.cpp @@ -119,6 +119,7 @@ PlayerBotDepotMoveVerification PlayerBotDepotSession::verifyMove(uint32_t invent verification.attempts = ++depotAttempts; if (depotAttempts < maximumAttempts) { clearMove(); + depotStage = PlayerBotDepotStage::Deposit; verification.result = PlayerBotDepotMoveResult::Retry; return verification; } diff --git a/server/src/playerbotdepotsession.h b/server/src/playerbotdepotsession.h index 3b141694..3b84606e 100644 --- a/server/src/playerbotdepotsession.h +++ b/server/src/playerbotdepotsession.h @@ -64,6 +64,10 @@ class PlayerBotDepotSession void clearDiscovery(); PlayerBotDepotStage stage() const { return depotStage; } + + private: + friend class PlayerBotDepotWorkflow; + void discover() { depotStage = PlayerBotDepotStage::Discover; } void openLocker() { depotStage = PlayerBotDepotStage::OpenLocker; } void openChest() { depotStage = PlayerBotDepotStage::OpenChest; } void deposit() { depotStage = PlayerBotDepotStage::Deposit; } @@ -105,7 +109,6 @@ class PlayerBotDepotSession PlayerBotDepotMoveVerification verifyMove(uint32_t inventoryCount, uint32_t destinationCount, uint32_t maximumAttempts); - private: PlayerBotDepotStage depotStage = PlayerBotDepotStage::Discover; uint32_t depotAttempts = 0; uint16_t selectedDepotId = 0; diff --git a/server/src/playerbotdepotworkflow.cpp b/server/src/playerbotdepotworkflow.cpp index d40807e8..4c123d2f 100644 --- a/server/src/playerbotdepotworkflow.cpp +++ b/server/src/playerbotdepotworkflow.cpp @@ -1,5 +1,6 @@ #include "otpch.h" +#include "creature.h" #include "playerbotdepotworkflow.h" void PlayerBotDepotWorkflow::reset() @@ -12,6 +13,7 @@ void PlayerBotDepotWorkflow::reset() inScopeCandidates = 0; standableCandidates = 0; suppressedApproaches = 0; + deferredSlottedDeposits.clear(); } void PlayerBotDepotWorkflow::clearDiscovery() @@ -30,46 +32,136 @@ PlayerBotDepotCommand PlayerBotDepotWorkflow::advance(const PlayerBotDepotObserv uint32_t maximumDiscoveryAttempts, std::chrono::steady_clock::duration suppression) { session.expireRejectedApproaches(observation.now); + for (auto it = deferredSlottedDeposits.begin(); it != deferredSlottedDeposits.end();) { + if (it->second <= observation.now) it = deferredSlottedDeposits.erase(it); + else ++it; + } + if (observation.actionResult == PlayerBotDepotActionResult::SelectedLockerUnavailable) { + clearDiscovery(); + session.resetAttempts(); + session.discover(); + } + if (observation.actionResult == PlayerBotDepotActionResult::MoveDestinationUnavailable && session.hasPendingMove()) { + session.clearMove(); + session.openChest(); + } + if (session.hasPendingMove() && observation.move.observed) { + const PlayerBotDepotMoveVerification verification = session.verifyMove( + observation.move.inventoryCount, observation.move.destinationCount, maximumDiscoveryAttempts); + PlayerBotDepotTelemetry telemetry; + telemetry.moveVerification = verification; + if (verification.result == PlayerBotDepotMoveResult::Mismatch || verification.result == PlayerBotDepotMoveResult::Rejected) { + return command(PlayerBotDepotCommandType::Fail, PlayerBotDepotOutcome::Rejected, telemetry); + } + if (verification.result == PlayerBotDepotMoveResult::Retry) { + return command(PlayerBotDepotCommandType::Wait, PlayerBotDepotOutcome::Retry, telemetry); + } + if (verification.result == PlayerBotDepotMoveResult::Deferred && verification.before.sourceSlot != CONST_SLOT_WHEREEVER) { + deferredSlottedDeposits[{verification.before.itemId, verification.before.sourceSlot}] = observation.now + suppression; + return command(PlayerBotDepotCommandType::Wait, PlayerBotDepotOutcome::Deferred, telemetry); + } + return command(PlayerBotDepotCommandType::SelectDeposit, + verification.result == PlayerBotDepotMoveResult::Moved ? PlayerBotDepotOutcome::Moved : PlayerBotDepotOutcome::Deferred, + telemetry); + } if (session.stage() == PlayerBotDepotStage::Discover) { - if (!observation.discoveryObserved) return {PlayerBotDepotCommandType::Scan}; + if (!observation.scan.observed) return command(PlayerBotDepotCommandType::Scan, PlayerBotDepotOutcome::Pending); clearDiscovery(); - beginDiscovery(observation.currentPosition); - for (const auto& candidate : observation.candidates) { + session.prepareCandidates(observation.currentPosition); + indexedCandidates = observation.scan.indexedCandidates; + inScopeCandidates = observation.scan.inScopeCandidates; + standableCandidates = observation.scan.standableCandidates; + for (const auto& candidate : observation.scan.candidates) { if (session.isApproachRejected(candidate.approachPosition)) { ++suppressedApproaches; continue; } recordCandidate(candidate); } sortCandidates(); } + if (session.stage() == PlayerBotDepotStage::Approach && observation.atApproach) { + session.openLocker(); + } + if (session.stage() == PlayerBotDepotStage::OpenLocker && observation.lockerOpen) { + session.resetAttempts(); + session.openChest(); + } + if (session.stage() == PlayerBotDepotStage::OpenChest && !observation.lockerOpen) { + session.openLocker(); + } + if (session.stage() == PlayerBotDepotStage::OpenChest && observation.chestOpen) { + session.resetAttempts(); + session.deposit(); + } + if (session.stage() == PlayerBotDepotStage::Deposit && observation.deposit.observed && !observation.deposit.hasDepositableItem) { + session.depart(); + } if (session.stage() == PlayerBotDepotStage::Discover && !hasCandidates()) { + if (suppressedApproaches != 0) { + return command(PlayerBotDepotCommandType::Wait, PlayerBotDepotOutcome::Deferred); + } const uint32_t attempts = session.incrementAttempts(); - return {attempts >= maximumDiscoveryAttempts ? PlayerBotDepotCommandType::Fail : PlayerBotDepotCommandType::Wait, - PlayerBotDepotOutcome::Unavailable}; + return command(attempts >= maximumDiscoveryAttempts ? PlayerBotDepotCommandType::Fail : PlayerBotDepotCommandType::Wait, + PlayerBotDepotOutcome::Unavailable); } if (session.stage() == PlayerBotDepotStage::Discover) { if (routeCandidate) { - if (!observation.routeObserved) return {PlayerBotDepotCommandType::ValidateRoute, PlayerBotDepotOutcome::Pending, *routeCandidate}; - if (observation.routeReachable) { + if (observation.routeResult == PlayerBotDepotRouteResult::NotObserved) { + return command(PlayerBotDepotCommandType::ValidateRoute, PlayerBotDepotOutcome::Pending); + } + if (observation.routeResult == PlayerBotDepotRouteResult::Reached) { const PlayerBotDepotCandidate selected = *routeCandidate; routeCandidate.reset(); - select(selected); - return {PlayerBotDepotCommandType::Navigate, PlayerBotDepotOutcome::Ready, selected}; + session.select(selected); + PlayerBotDepotTelemetry telemetry; + telemetry.routeSteps = observation.routeSteps; + telemetry.expandedNodes = observation.expandedNodes; + return command(PlayerBotDepotCommandType::Navigate, PlayerBotDepotOutcome::Ready, telemetry); } session.rejectApproach(routeCandidate->approachPosition, observation.now + suppression); routeCandidate.reset(); } for (uint32_t attempt = 0; attempt < routeBudget && hasNextCandidate(); ++attempt) { routeCandidate = takeNextCandidate(); - if (routeCandidate) return {PlayerBotDepotCommandType::ValidateRoute, PlayerBotDepotOutcome::Pending, *routeCandidate}; + if (routeCandidate) return command(PlayerBotDepotCommandType::ValidateRoute, PlayerBotDepotOutcome::Pending); } - return {hasNextCandidate() ? PlayerBotDepotCommandType::Wait : PlayerBotDepotCommandType::Fail, - hasNextCandidate() ? PlayerBotDepotOutcome::Retry : PlayerBotDepotOutcome::Unavailable}; - } - if (!observation.atApproach) return {PlayerBotDepotCommandType::Navigate, PlayerBotDepotOutcome::Pending, - {session.depotId(), session.lockerItemId(), session.lockerPosition(), session.approachPosition()}}; - if (!observation.lockerOpen) return {PlayerBotDepotCommandType::OpenLocker}; - if (!observation.chestOpen) return {PlayerBotDepotCommandType::OpenChest}; - if (!observation.hasDepositableItem) return {PlayerBotDepotCommandType::Depart, PlayerBotDepotOutcome::Success}; - return {observation.canDoAction ? PlayerBotDepotCommandType::SelectDeposit : PlayerBotDepotCommandType::Wait}; + if (hasNextCandidate()) return command(PlayerBotDepotCommandType::Wait, PlayerBotDepotOutcome::Retry); + const uint32_t attempts = session.incrementAttempts(); + if (attempts >= maximumDiscoveryAttempts) return command(PlayerBotDepotCommandType::Fail, PlayerBotDepotOutcome::Unavailable); + clearDiscovery(); + session.discover(); + return command(PlayerBotDepotCommandType::Scan, PlayerBotDepotOutcome::Retry); + } + if (session.stage() == PlayerBotDepotStage::Approach && observation.routeResult == PlayerBotDepotRouteResult::Unreachable) { + session.rejectApproach(session.approachPosition(), observation.now + suppression); + clearDiscovery(); + session.discover(); + return command(PlayerBotDepotCommandType::Scan, PlayerBotDepotOutcome::Retry); + } + if (!observation.atApproach) return command(PlayerBotDepotCommandType::Navigate, PlayerBotDepotOutcome::Pending); + if (!observation.lockerOpen) { + if (!observation.canDoAction) return command(PlayerBotDepotCommandType::Wait, PlayerBotDepotOutcome::Pending); + if (session.attempts() >= maximumDiscoveryAttempts) return command(PlayerBotDepotCommandType::Fail, PlayerBotDepotOutcome::Rejected); + session.incrementAttempts(); + return command(PlayerBotDepotCommandType::OpenLocker, PlayerBotDepotOutcome::Pending); + } + if (!observation.chestOpen) { + if (!observation.canDoAction) return command(PlayerBotDepotCommandType::Wait, PlayerBotDepotOutcome::Pending); + if (session.attempts() >= maximumDiscoveryAttempts) return command(PlayerBotDepotCommandType::Fail, PlayerBotDepotOutcome::Rejected); + session.incrementAttempts(); + return command(PlayerBotDepotCommandType::OpenChest, PlayerBotDepotOutcome::Pending); + } + if (session.stage() == PlayerBotDepotStage::Depart) return command(PlayerBotDepotCommandType::Depart, PlayerBotDepotOutcome::Success); + if (session.stage() == PlayerBotDepotStage::Deposit && observation.deposit.observed && observation.deposit.hasDepositableItem) { + if (observation.deposit.move.sourceSlot != CONST_SLOT_WHEREEVER) { + auto deferred = deferredSlottedDeposits.find({observation.deposit.move.itemId, observation.deposit.move.sourceSlot}); + if (deferred != deferredSlottedDeposits.end() && deferred->second > observation.now) { + return command(PlayerBotDepotCommandType::Wait, PlayerBotDepotOutcome::Deferred); + } + } + if (!observation.canDoAction) return command(PlayerBotDepotCommandType::Wait, PlayerBotDepotOutcome::Pending); + session.beginMove(observation.deposit.move); + return command(PlayerBotDepotCommandType::MoveDeposit, PlayerBotDepotOutcome::Ready); + } + return command(PlayerBotDepotCommandType::SelectDeposit, PlayerBotDepotOutcome::Pending); } void PlayerBotDepotWorkflow::recordCandidate(PlayerBotDepotCandidate candidate) @@ -112,3 +204,37 @@ std::optional PlayerBotDepotWorkflow::ear } return earliest; } + +std::optional PlayerBotDepotWorkflow::earliestDeferredDepositExpiry() const +{ + if (deferredSlottedDeposits.empty()) return std::nullopt; + auto earliest = deferredSlottedDeposits.begin()->second; + for (const auto& entry : deferredSlottedDeposits) earliest = std::min(earliest, entry.second); + return earliest; +} + +PlayerBotDepotSnapshot PlayerBotDepotWorkflow::snapshot() const +{ + PlayerBotDepotSnapshot result; + result.stage = session.stage(); + result.hasSelectedDepot = session.hasSelectedDepot(); + result.selected = {session.depotId(), session.lockerItemId(), session.lockerPosition(), session.approachPosition()}; + result.hasRouteCandidate = routeCandidate.has_value(); + if (routeCandidate) result.routeCandidate = *routeCandidate; + result.hasPendingMove = session.hasPendingMove(); + if (result.hasPendingMove) result.pendingMove = session.move(); + result.attempts = session.attempts(); + result.indexedCandidates = indexedCandidates; + result.inScopeCandidates = inScopeCandidates; + result.standableCandidates = standableCandidates; + result.suppressedApproaches = suppressedApproaches; + result.retryAt = earliestRejectedApproachExpiry(); + result.deferredDepositRetryAt = earliestDeferredDepositExpiry(); + return result; +} + +PlayerBotDepotCommand PlayerBotDepotWorkflow::command(PlayerBotDepotCommandType type, PlayerBotDepotOutcome outcome, + PlayerBotDepotTelemetry telemetry) const +{ + return {type, outcome, snapshot(), std::move(telemetry)}; +} diff --git a/server/src/playerbotdepotworkflow.h b/server/src/playerbotdepotworkflow.h index 4279148e..d0b66e45 100644 --- a/server/src/playerbotdepotworkflow.h +++ b/server/src/playerbotdepotworkflow.h @@ -20,84 +20,96 @@ enum class PlayerBotDepotCommandType : uint8_t { Wait, }; enum class PlayerBotDepotOutcome : uint8_t { Pending, Ready, Success, Retry, Moved, Partial, Deferred, Rejected, Unavailable }; +enum class PlayerBotDepotRouteResult : uint8_t { NotObserved, Reached, Unreachable }; +enum class PlayerBotDepotActionResult : uint8_t { None, SelectedLockerUnavailable, MoveDestinationUnavailable }; + +struct PlayerBotDepotScan { + bool observed = false; + uint32_t indexedCandidates = 0; + uint32_t inScopeCandidates = 0; + uint32_t standableCandidates = 0; + std::vector candidates; +}; + +struct PlayerBotDepotDepositSnapshot { + bool observed = false; + bool hasDepositableItem = false; + PlayerBotDepotMove move; +}; + +struct PlayerBotDepotMoveObservation { + bool observed = false; + uint32_t inventoryCount = 0; + uint32_t destinationCount = 0; +}; struct PlayerBotDepotObservation { Position currentPosition; std::chrono::steady_clock::time_point now; - bool discoveryObserved = false; - std::vector candidates; - bool routeObserved = false; - bool routeReachable = false; + PlayerBotDepotScan scan; + PlayerBotDepotRouteResult routeResult = PlayerBotDepotRouteResult::NotObserved; + uint32_t routeSteps = 0; + uint64_t expandedNodes = 0; bool atApproach = false; bool lockerOpen = false; bool chestOpen = false; bool canDoAction = false; - bool hasDepositableItem = false; + bool fixtureSynthetic = false; + PlayerBotDepotActionResult actionResult = PlayerBotDepotActionResult::None; + PlayerBotDepotDepositSnapshot deposit; + PlayerBotDepotMoveObservation move; +}; + +struct PlayerBotDepotSnapshot { + PlayerBotDepotStage stage = PlayerBotDepotStage::Discover; + bool hasSelectedDepot = false; + PlayerBotDepotCandidate selected; + bool hasRouteCandidate = false; + PlayerBotDepotCandidate routeCandidate; + bool hasPendingMove = false; + PlayerBotDepotMove pendingMove; + uint32_t attempts = 0; + uint32_t indexedCandidates = 0; + uint32_t inScopeCandidates = 0; + uint32_t standableCandidates = 0; + uint32_t suppressedApproaches = 0; + std::optional retryAt; + std::optional deferredDepositRetryAt; +}; + +struct PlayerBotDepotTelemetry { + uint32_t routeSteps = 0; + uint64_t expandedNodes = 0; + std::optional moveVerification; }; struct PlayerBotDepotCommand { PlayerBotDepotCommandType type = PlayerBotDepotCommandType::None; PlayerBotDepotOutcome outcome = PlayerBotDepotOutcome::Pending; - PlayerBotDepotCandidate candidate; + PlayerBotDepotSnapshot snapshot; + PlayerBotDepotTelemetry telemetry; }; class PlayerBotDepotWorkflow { public: void reset(); - void clearDiscovery(); - PlayerBotDepotStage stage() const { return session.stage(); } + PlayerBotDepotSnapshot snapshot() const; PlayerBotDepotCommand advance(const PlayerBotDepotObservation& observation, uint32_t routeBudget, uint32_t maximumDiscoveryAttempts, std::chrono::steady_clock::duration suppression); - void approachComplete() { session.openLocker(); } - void lockerOpened() { session.resetAttempts(); session.openChest(); } - void chestOpened() { session.resetAttempts(); session.deposit(); } - void reopenLocker() { session.openLocker(); } - void reopenChest() { session.openChest(); } - void resumeDeposit() { session.deposit(); } - void depart() { session.depart(); } - uint32_t attempts() const { return session.attempts(); } - uint32_t incrementAttempts() { return session.incrementAttempts(); } - void resetAttempts() { session.resetAttempts(); } - uint16_t depotId() const { return session.depotId(); } - uint16_t lockerItemId() const { return session.lockerItemId(); } - const Position& lockerPosition() const { return session.lockerPosition(); } - const Position& approachPosition() const { return session.approachPosition(); } - bool hasSelectedDepot() const { return session.hasSelectedDepot(); } - void select(PlayerBotDepotCandidate candidate) { session.select(candidate); } - bool candidatesPrepared() const { return session.candidatesPrepared(); } - void beginDiscovery(const Position& anchor) { session.prepareCandidates(anchor); } - const Position& discoveryAnchor() const { return session.discoveryAnchor(); } - void recordIndexedCandidate() { ++indexedCandidates; } - void recordInScopeCandidate() { ++inScopeCandidates; } - void recordStandableCandidate() { ++standableCandidates; } - void recordSuppressedApproach() { ++suppressedApproaches; } + private: + void clearDiscovery(); void recordCandidate(PlayerBotDepotCandidate candidate); void sortCandidates(); bool hasCandidates() const { return !discoveryCandidates.empty(); } bool hasNextCandidate() const; std::optional takeNextCandidate(); - uint32_t indexedCandidateCount() const { return indexedCandidates; } - uint32_t inScopeCandidateCount() const { return inScopeCandidates; } - uint32_t standableCandidateCount() const { return standableCandidates; } - uint32_t suppressedApproachCount() const { return suppressedApproaches; } - - void expireRejectedApproaches(std::chrono::steady_clock::time_point now) { session.expireRejectedApproaches(now); } - bool isApproachRejected(const Position& position) const { return session.isApproachRejected(position); } - void rejectApproach(const Position& position, std::chrono::steady_clock::time_point expires) { session.rejectApproach(position, expires); } std::optional earliestRejectedApproachExpiry() const; + std::optional earliestDeferredDepositExpiry() const; + PlayerBotDepotCommand command(PlayerBotDepotCommandType type, PlayerBotDepotOutcome outcome, + PlayerBotDepotTelemetry telemetry = {}) const; - bool hasPendingMove() const { return session.hasPendingMove(); } - const PlayerBotDepotMove& move() const { return session.move(); } - void beginMove(PlayerBotDepotMove move) { session.beginMove(move); } - void clearMove() { session.clearMove(); } - PlayerBotDepotMoveVerification verifyMove(uint32_t inventory, uint32_t destination, uint32_t maximumAttempts) - { - return session.verifyMove(inventory, destination, maximumAttempts); - } - - private: PlayerBotDepotSession session; std::vector discoveryCandidates; std::optional routeCandidate; @@ -106,6 +118,7 @@ class PlayerBotDepotWorkflow uint32_t inScopeCandidates = 0; uint32_t standableCandidates = 0; uint32_t suppressedApproaches = 0; + std::map, std::chrono::steady_clock::time_point> deferredSlottedDeposits; }; #endif diff --git a/server/src/playerbotequipment.cpp b/server/src/playerbotequipment.cpp index 309be4eb..ddbd673f 100644 --- a/server/src/playerbotequipment.cpp +++ b/server/src/playerbotequipment.cpp @@ -24,7 +24,25 @@ namespace { PlayerBotController::EquipmentHuntSummary PlayerBotController::equipmentHuntSummary(Player& player, const PlayerBotCombatProfile& profile) const { - return huntRuntime.scoreEquipmentHunts(player, profile, maximumEquipmentHuntRegions); + PlayerBotHuntRegionPlanner planner; + const PlayerBotHuntRegionScan scan = planner.beginScan(player); + const PlayerBotHuntPlanningProfile planning = huntRuntime.planningProfile(playerBotHuntPlanningProfile(player, profile, 0)); + const auto performance = huntRuntime.regionPerformance(); + const uint32_t duration = static_cast(std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS))); + std::vector regions; + regions.reserve(std::min(scan.candidateIndices.size(), maximumEquipmentHuntRegions)); + bool truncated = false; + for (size_t candidateIndex : scan.candidateIndices) { + if (regions.size() >= maximumEquipmentHuntRegions) { + truncated = true; + break; + } + auto score = planner.score(player, planning, scan.revision, candidateIndex, {}, performance, duration); + if (score.valid) { + regions.push_back(std::move(score.region)); + } + } + return huntRuntime.summarizeEquipmentHunts(regions, truncated); } void PlayerBotController::emitEquipmentOffer(const Player& player, const EquipmentOfferEvaluation& evaluation, @@ -68,13 +86,11 @@ void PlayerBotController::emitEquipmentOffer(const Player& player, const Equipme std::optional PlayerBotController::evaluateEquipmentOffers( Player& player, const Position& position) { - if (!equipmentPolicy.requiresKnightCombatReadiness(player)) { - return std::nullopt; - } const uint64_t reserve = spellTrainingReserve(player); const uint64_t totalMoney = player.getMoney() + player.getBankBalance(); - const EquipmentLoadout currentLoadout = equipmentPolicy.loadout(player); - const PlayerBotCombatProfile currentProfile = equipmentPolicy.combatProfile(player, currentLoadout); + const PlayerBotEquipmentPlayerSnapshot playerFacts = PlayerBotEquipmentAdapter::player(player); + 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); const PlayerBotEquipmentReadinessInput readiness{ @@ -83,13 +99,11 @@ std::optional PlayerBotController inventoryPolicy.effectiveFreeCapacity(player), returnCapacityThreshold, }; - const bool currentReady = equipmentPolicy.loadoutReady(player, currentLoadout, readiness); + const bool currentReady = equipmentPolicy.loadoutReady(playerFacts, currentLoadout, readiness); std::map evaluatedItems; std::map>> providerRoutes; std::set providerRouteNodeLimits; - std::optional selected; std::vector plannerOffers; - uint32_t feasibleCandidates = 0; size_t simulatedItems = 0; bool providerRouteBudgetExhausted = false; size_t catalogOffers = 0; @@ -130,7 +144,7 @@ std::optional PlayerBotController std::deque steps; uint64_t expandedNodes = 0; const PlayerBotNavigationRoutePlan routePlan = approach == position ? PlayerBotNavigationRoutePlan{} : - navigationRuntime.plan(player, approach, {}, maximumEquipmentProviderPathNodes); + planNavigationRoute(player, approach, {}, maximumEquipmentProviderPathNodes); const PlayerBotNavigationResult result = approach == position ? PlayerBotNavigationResult::Reached : routePlan.metrics.result; telemetry.recordPathfinding(std::chrono::microseconds::zero(), result == PlayerBotNavigationResult::Reached); if (approach != position) { @@ -157,9 +171,6 @@ std::optional PlayerBotController continue; } for (const ShopInfo& offer : npc->getShopOffers()) { - if (offer.buyPrice == 0) { - continue; - } if (catalogOffers >= maximumEquipmentCatalogOffers) { catalogTruncated = true; break; @@ -171,11 +182,11 @@ std::optional PlayerBotController evaluation = item->second; } else { evaluation = equipmentPolicy.evaluateCandidate( - player, offer.itemId, currentLoadout, currentProfile, currentHunts, currentReady, readiness, + playerFacts, PlayerBotEquipmentAdapter::item(offer.itemId), currentLoadout, currentProfile, currentHunts, currentReady, readiness, carried ? 0 : Item::items[offer.itemId].weight, simulatedItems < maximumEquipmentCandidateSimulations, - [this](Player& candidate, const PlayerBotCombatProfile& profile) { - return equipmentHuntSummary(candidate, profile); + [this, &player](const PlayerBotCombatProfile& profile) { + return equipmentHuntSummary(player, profile); }); if (evaluation.simulated) { ++simulatedItems; @@ -186,16 +197,6 @@ std::optional PlayerBotController evaluation.npcPosition = npc->getPosition(); evaluation.price = offer.buyPrice; evaluation.carried = carried; - if (!evaluation.rejection.empty()) { - emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", - evaluation.rejection.c_str()); - continue; - } - if (!evaluation.carried && Item::items[offer.itemId].weight > player.getFreeCapacity()) { - emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", - "insufficient_capacity"); - continue; - } Item* backpackItem = player.getInventoryItem(CONST_SLOT_BACKPACK); Container* backpack = backpackItem ? backpackItem->getContainer() : nullptr; const uint32_t freeBackpackSlots = backpack ? backpack->capacity() - @@ -210,55 +211,41 @@ std::optional PlayerBotController } } 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"); plannerOffers.push_back({evaluation, Item::items[offer.itemId].weight, freeBackpackSlots, backpack != nullptr, - false, {true, false, Position(), 0, 0}}); - ++feasibleCandidates; - if (!selected || PlayerBotEquipmentPolicy::prefers(evaluation, *selected)) { - selected = evaluation; - } - continue; - } - if (reserve == std::numeric_limits::max()) { - emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", "recovery_reserve_unavailable"); - continue; - } - if (totalMoney < reserve + evaluation.price) { - emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "rejected", "unaffordable_after_reserves"); + true, false, {true, false, Position(), 0, 0}}); continue; } 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; + if (route) { + evaluation.approachPosition = route->first; + evaluation.travelSteps = route->second; } - evaluation.approachPosition = route->first; - evaluation.travelSteps = route->second; - emitEquipmentOffer(player, evaluation, currentProfile, currentHunts, reserve, position, "feasible", nullptr); plannerOffers.push_back({evaluation, Item::items[offer.itemId].weight, freeBackpackSlots, backpack != nullptr, - false, {true, false, route->first, route->second, 0}}); - ++feasibleCandidates; - if (!selected || PlayerBotEquipmentPolicy::prefers(evaluation, *selected)) { - selected = evaluation; - } + offer.buyPrice != 0, providerRouteBudgetExhausted, + {route.has_value(), providerRouteNodeLimits.find(npc->getID()) != providerRouteNodeLimits.end(), + route ? route->first : Position(), route ? route->second : 0, 0}}); } } - const PlayerBotEquipmentProviderDecision plannerDecision = equipmentProviderPlanner.select({true, reserve, totalMoney, - reserve != std::numeric_limits::max(), player.getFreeCapacity(), std::move(plannerOffers)}); - selected = plannerDecision.selected; + const PlayerBotEquipmentProviderPlannerSnapshot plannerSnapshot{equipmentPolicy.requiresKnightCombatReadiness(playerFacts), reserve, + totalMoney, reserve != std::numeric_limits::max(), player.getFreeCapacity(), plannerOffers}; + const PlayerBotEquipmentProviderDecision plannerDecision = equipmentProviderPlanner.select(plannerSnapshot); + if (!plannerDecision.evaluated) return std::nullopt; + const std::optional& selected = plannerDecision.selected; + for (size_t offerIndex = 0; offerIndex < plannerOffers.size(); ++offerIndex) { + const auto& candidate = plannerOffers[offerIndex]; + const auto rejection = std::find_if(plannerDecision.rejections.begin(), plannerDecision.rejections.end(), + [offerIndex](const PlayerBotPlannerOfferRejection& result) { + return result.offerIndex == offerIndex; + }); + emitEquipmentOffer(player, candidate.evaluation, currentProfile, currentHunts, reserve, position, + rejection == plannerDecision.rejections.end() ? "feasible" : "rejected", + rejection == plannerDecision.rejections.end() ? nullptr : rejection->reason.c_str()); + } std::ostringstream fields; fields << "\"result\":" << jsonString(selected ? (selected->carried ? "would_equip" : "would_buy") : "no_decision") - << ",\"feasible_candidates\":" << feasibleCandidates + << ",\"feasible_candidates\":" << (selected ? 1 : 0) << ",\"catalog_offers_evaluated\":" << catalogOffers << ",\"catalog_truncated\":" << (catalogTruncated ? "true" : "false") << ",\"reason\":" << jsonString(selected ? PlayerBotEquipmentPolicy::decisionRuleName(selected->rule) : "no_justified_offer"); @@ -274,12 +261,8 @@ void PlayerBotController::beginEquipmentPurchase(Player& player, const Position& EquipmentOfferEvaluation evaluation) { progressionRuntime.beginEquipmentPurchase(std::move(evaluation)); - const auto& purchase = equipmentPurchaseSession.plan(); - serviceWorkflow.resetTransactions(); + const auto& purchase = progressionRuntime.equipmentPurchase().plan(); if (!purchase.carried) { - serviceWorkflow.resetNpc(purchase.npcId); - serviceWorkflow.clearApproach(); - serviceWorkflow.clearRejectedApproaches(); clearNavigation(); } std::ostringstream fields; @@ -296,7 +279,7 @@ void PlayerBotController::beginEquipmentPurchase(Player& player, const Position& void PlayerBotController::finishEquipmentPurchase(Player* player, const Position& position, const char* result, const char* reason) { - const auto& purchase = equipmentPurchaseSession.plan(); + const auto& purchase = progressionRuntime.equipmentPurchase().plan(); std::ostringstream fields; fields << "\"goal\":\"buy_equipment\",\"npc_id\":" << purchase.npcId << ",\"item_id\":" << purchase.itemId << ",\"price\":" << purchase.price @@ -304,7 +287,7 @@ void PlayerBotController::finishEquipmentPurchase(Player* player, const Position << ",\"result\":" << jsonString(result) << ",\"reason\":" << jsonString(reason); emit("strategy_objective_result", position, fields.str()); emit("goal_result", position, - "\"decision_id\":" + std::to_string(goalArbiter.decisionId()) + + "\"decision_id\":" + std::to_string(progressionRuntime.decisionId()) + ",\"goal\":\"buy_equipment\",\"result\":" + jsonString(result) + ",\"reason\":" + jsonString(reason)); const bool succeeded = std::strcmp(result, "success") == 0; @@ -318,11 +301,9 @@ void PlayerBotController::finishEquipmentPurchase(Player* player, const Position player->closeShopWindow(); say(*player, std::string("Equipment purchase ") + result + ": " + reason + '.'); } - progressionRuntime.setCooldown(TopLevelGoal::BuyEquipment, - succeeded ? equipmentPurchaseSuccessCooldown : equipmentPurchaseFailureCooldown); + progressionRuntime.completeEquipmentPurchase(succeeded, + succeeded ? equipmentPurchaseSuccessCooldown : equipmentPurchaseFailureCooldown); progressionRuntime.finish(); - serviceWorkflow.resetNpc(); - serviceWorkflow.resetTransactions(); clearNavigation(); cyclePhase = CyclePhase::Service; if (succeeded && player && fixtureDriver.equipmentPurchaseCompletion(*player).pause) { @@ -331,46 +312,39 @@ void PlayerBotController::finishEquipmentPurchase(Player* player, const Position if (player && fixtureDriver.goalLoop(true).selectGoal) { selectTopLevelGoal(*player, position, succeeded ? "equipment_purchase_complete" : "equipment_purchase_failed"); } else { - progressionRuntime.setActiveGoal(TopLevelGoal::Service); + progressionRuntime.enterService(); } schedule(SCHEDULER_MINTICKS); } void PlayerBotController::processEquipmentPurchase(Player* player, const Position& position) { - const auto& purchase = equipmentPurchaseSession.plan(); + const auto& purchase = progressionRuntime.equipmentPurchase().plan(); Npc* npc = purchase.carried ? nullptr : g_game.getNpcByID(purchase.npcId); const std::string* capability = npc && !npc->isRemoved() ? npc->getParameter("playerbot_service") : nullptr; const ServiceNpc provider{purchase.npcId, npc ? npc->getPosition() : Position()}; const ShopInfo* offer = purchase.carried || !npc ? nullptr : findOffer(provider, purchase.itemId, true); + int32_t onBuy = 0; + int32_t onSell = 0; + Npc* shopOwner = player->getShopOwner(onBuy, onSell); PlayerBotEquipmentPurchaseObservation observation; observation.actionAvailable = player->canDoAction(); if (!purchase.carried) { observation.providerAvailable = capability && *capability == "shop"; observation.offerAvailable = offer && offer->buyPrice == purchase.price; observation.providerInRange = npc && Position::areInRange<3, 3, 0>(position, npc->getPosition()); - observation.shopReady = serviceWorkflow.npcStep() == PlayerBotNpcConversationStep::Ready; + observation.shopReady = shopOwner == npc && !player->getShopItemList().empty(); + observation.otherShopOpen = shopOwner && shopOwner != npc; 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; } - if (equipmentPurchaseSession.stage() == PlayerBotEquipmentPurchaseStage::Travel) { + if (progressionRuntime.equipmentPurchase().stage() == PlayerBotEquipmentPurchaseStage::Travel) { observation.navigationReached = processNavigation(player, position, purchase.approachPosition); observation.navigationFailed = navigationRuntime.fixedTargetRouteFailureCount() >= maximumProgressionAttempts || navigationRuntime.stepFailureCount() >= maximumRepeatedNavigationStepFailures; - } else if (equipmentPurchaseSession.stage() == PlayerBotEquipmentPurchaseStage::VerifyPurchase) { - const PlayerBotServiceVerification verification = serviceWorkflow.verifyShopTransaction( - inventoryPolicy.inventoryItemCount(*player, purchase.itemId), player->getMoney(), player->getBankBalance(), true, purchase.price, - maximumProgressionAttempts); - observation.transactionSucceeded = verification.result == PlayerBotServiceVerificationResult::Success; - observation.transactionRejected = verification.result == PlayerBotServiceVerificationResult::Rejected; - observation.transactionMismatch = verification.result == PlayerBotServiceVerificationResult::Mismatch; - if (observation.transactionSucceeded) 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(verification.before.money) + ",\"carried_after\":" + std::to_string(player->getMoney()) + ",\"bank_before\":" + - std::to_string(verification.before.balance) + ",\"bank_after\":" + std::to_string(player->getBankBalance())); - } else if (equipmentPurchaseSession.stage() == PlayerBotEquipmentPurchaseStage::Equip || - equipmentPurchaseSession.stage() == PlayerBotEquipmentPurchaseStage::VerifyEquipment) { + } else if (progressionRuntime.equipmentPurchase().stage() == PlayerBotEquipmentPurchaseStage::Equip || + progressionRuntime.equipmentPurchase().stage() == PlayerBotEquipmentPurchaseStage::VerifyEquipment) { Item* purchased = g_game.findItemOfType(player, purchase.itemId, true); Item* equipped = player->getInventoryItem(purchase.slot); observation.equipmentVerified = equipped && equipped->getID() == purchase.itemId; @@ -405,9 +379,15 @@ void PlayerBotController::processEquipmentPurchase(Player* player, const Positio if (itemId != 0) observation.displacedCounts[itemId] = inventoryPolicy.inventoryItemCount(*player, itemId); } } - const PlayerBotProgressionOutcome result = progressionRuntime.advanceEquipmentPurchase(observation); - if (result.type == PlayerBotProgressionOutcomeType::Retry && result.reason && std::strcmp(result.reason, "purchase_equipment") == 0) { - serviceWorkflow.setNpcStep(PlayerBotNpcConversationStep::Ready); + observation.itemCount = inventoryPolicy.inventoryItemCount(*player, purchase.itemId); + observation.money = player->getMoney(); + observation.bankBalance = player->getBankBalance(); + const PlayerBotProgressionOutcome result = progressionRuntime.advanceEquipmentPurchase(observation, maximumProgressionAttempts); + if (result.transaction.amount != 0) { + 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\":" + + std::to_string(result.transaction.balance) + ",\"bank_after\":" + std::to_string(player->getBankBalance())); } if (result.type == PlayerBotProgressionOutcomeType::Succeeded || result.type == PlayerBotProgressionOutcomeType::Failed) { if (result.type == PlayerBotProgressionOutcomeType::Failed && std::strcmp(result.reason, "transaction_delta_mismatch") == 0) { @@ -416,12 +396,16 @@ void PlayerBotController::processEquipmentPurchase(Player* player, const Positio 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) { + logActionFailure("shop", result.command.reason, position); + } if (result.type == PlayerBotProgressionOutcomeType::Succeeded) { - const EquipmentLoadout loadout = equipmentPolicy.loadout(*player); - const EquipmentHuntSummary hunts = equipmentHuntSummary(*player, equipmentPolicy.combatProfile(*player, loadout)); + const PlayerBotEquipmentPlayerSnapshot playerFacts = PlayerBotEquipmentAdapter::player(*player); + const EquipmentLoadout loadout = PlayerBotEquipmentAdapter::loadout(*player); + const EquipmentHuntSummary hunts = equipmentHuntSummary(*player, equipmentPolicy.combatProfile(playerFacts, loadout)); emit("action_result", position, "\"action\":\"equip_equipment\",\"result\":\"success\",\"item_id\":" + std::to_string(purchase.itemId) + ",\"slot\":" + std::to_string(purchase.slot) + ",\"combat_ready\":" + - (equipmentPolicy.loadoutReady(*player, loadout, + (equipmentPolicy.loadoutReady(playerFacts, loadout, {player->getInventoryItem(CONST_SLOT_BACKPACK) && player->getInventoryItem(CONST_SLOT_BACKPACK)->getContainer(), inventoryPolicy.inventoryItemCount(*player, smallHealthPotionItemId) > smallHealthPotionReturnThreshold, inventoryPolicy.effectiveFreeCapacity(*player), returnCapacityThreshold}) ? "true" : "false") + @@ -432,19 +416,22 @@ void PlayerBotController::processEquipmentPurchase(Player* player, const Positio return; } if (result.command.type == PlayerBotProgressionCommandType::Shop && std::strcmp(result.command.reason, "open_shop") == 0) { - const PlayerBotNpcSessionOutcome opened = serviceWorkflow.openNpcShop(*player, *npc, maximumServiceAttempts); - for (uint8_t action = 0; action < opened.actionsIssued; ++action) telemetry.recordActionAttempt(); - if (opened.result == PlayerBotNpcSessionResult::Failed) { - logActionFailure("shop", serviceWorkflow.npcStep() == PlayerBotNpcConversationStep::Request ? "npc_focus_unconfirmed" : "shop_window_unavailable", position); + const PlayerBotEquipmentShopCommand shop = progressionRuntime.advanceEquipmentShop( + {observation.shopReady, observation.otherShopOpen}, maximumServiceAttempts); + if (shop.result == PlayerBotNpcSessionResult::Failed) { + logActionFailure("shop", shop.failureReason, position); finishEquipmentPurchase(player, position, "failed", "shop_window_unavailable"); return; } - schedule(serviceWorkflow.npcNextDelay() == 0 ? SCHEDULER_MINTICKS : serviceWorkflow.npcNextDelay()); + if (shop.closeOtherShop) player->closeShopWindow(false); + if (shop.speech && npc && !npc->isRemoved()) { + telemetry.recordActionAttempt(); + npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, shop.speech); + } + schedule(shop.delay == 0 ? SCHEDULER_MINTICKS : shop.delay); return; } if (result.command.type == PlayerBotProgressionCommandType::Shop) { - if (!serviceWorkflow.hasShopTransaction()) serviceWorkflow.beginShopTransaction({purchase.itemId, 1, - inventoryPolicy.inventoryItemCount(*player, purchase.itemId), player->getMoney(), player->getBankBalance()}); telemetry.recordActionAttempt(); const PlayerBotFixtureEngineCommand command = fixtureDriver.equipmentPurchaseCommand(); if (command.dispatch) g_game.playerPurchaseItem(playerId, Item::items[purchase.itemId].clientId, diff --git a/server/src/playerbotequipmentadapter.cpp b/server/src/playerbotequipmentadapter.cpp new file mode 100644 index 00000000..4045abe0 --- /dev/null +++ b/server/src/playerbotequipmentadapter.cpp @@ -0,0 +1,120 @@ +/** Engine extraction and object resolution for equipment decisions. */ +#include "otpch.h" + +#include "playerbotequipmentadapter.h" + +#include "game.h" +#include "container.h" +#include "item.h" +#include "player.h" + +namespace { + PlayerBotEquipmentWeaponType weaponType(WeaponType_t type) + { + switch (type) { + case WEAPON_SWORD: return PlayerBotEquipmentWeaponType::Sword; + case WEAPON_CLUB: return PlayerBotEquipmentWeaponType::Club; + case WEAPON_AXE: return PlayerBotEquipmentWeaponType::Axe; + case WEAPON_DISTANCE: return PlayerBotEquipmentWeaponType::Distance; + case WEAPON_AMMO: return PlayerBotEquipmentWeaponType::Ammo; + case WEAPON_SHIELD: return PlayerBotEquipmentWeaponType::Shield; + case WEAPON_NONE: return PlayerBotEquipmentWeaponType::None; + default: return PlayerBotEquipmentWeaponType::Other; + } + } + + PlayerBotEquipmentItemSnapshot itemSnapshot(uint16_t itemId, bool removed) + { + const ItemType& type = Item::items[itemId]; + PlayerBotEquipmentItemSnapshot result; + result.itemId = itemId; + result.pickupable = type.isPickupable(); + result.removed = removed; + result.minimumLevel = type.minReqLevel; + result.minimumMagicLevel = type.minReqMagicLevel; + result.premiumRequired = (type.wieldInfo & WIELDINFO_PREMIUM) != 0; + result.vocationIds.assign(type.vocationIds.begin(), type.vocationIds.end()); + result.head = (type.slotPosition & SLOTP_HEAD) != 0; + result.armorSlot = (type.slotPosition & SLOTP_ARMOR) != 0; + result.legs = (type.slotPosition & SLOTP_LEGS) != 0; + result.feet = (type.slotPosition & SLOTP_FEET) != 0; + result.left = (type.slotPosition & SLOTP_LEFT) != 0; + result.right = (type.slotPosition & SLOTP_RIGHT) != 0; + result.twoHanded = (type.slotPosition & SLOTP_TWO_HAND) != 0; + result.weaponType = weaponType(type.weaponType); + result.armor = type.armor; + result.defense = type.defense; + result.extraDefense = type.extraDefense; + result.attack = type.attack; + result.weight = type.weight; + return result; + } +} + +PlayerBotEquipmentPlayerSnapshot PlayerBotEquipmentAdapter::player(const Player& player) +{ + return {player.getLevel(), player.getMagicLevel(), player.getVocationId(), player.isPremium(), + player.getMaxHealth(), player.getSkillLevel(SKILL_FIST), player.getSkillLevel(SKILL_SWORD), + player.getSkillLevel(SKILL_CLUB), player.getSkillLevel(SKILL_AXE), player.getSkillLevel(SKILL_DISTANCE), + player.getSkillLevel(SKILL_SHIELD), player.getAttackFactor(), player.getDefenseFactor(), + player.getVocation()->armorMultiplier, player.getVocation()->defenseMultiplier}; +} + +PlayerBotEquipmentItemSnapshot PlayerBotEquipmentAdapter::item(const Item& item) +{ + PlayerBotEquipmentItemSnapshot result = itemSnapshot(item.getID(), item.isRemoved()); + result.armor = item.getArmor(); + result.defense = item.getDefense(); + result.attack = item.getAttack(); + result.count = item.getItemCount(); + result.container = item.getContainer() != nullptr; + return result; +} + +PlayerBotEquipmentItemSnapshot PlayerBotEquipmentAdapter::item(uint16_t itemId) +{ + return itemSnapshot(itemId, false); +} + +PlayerBotEquipmentLoadout PlayerBotEquipmentAdapter::loadout(const Player& player) +{ + PlayerBotEquipmentLoadout loadout; + for (int32_t slot = CONST_SLOT_FIRST; slot <= CONST_SLOT_LAST; ++slot) { + if (const Item* item = player.getInventoryItem(static_cast(slot))) { + loadout.itemIds[slot] = item->getID(); + loadout.items[slot] = PlayerBotEquipmentAdapter::item(*item); + loadout.items[slot].equipped = true; + } + } + return loadout; +} + +bool PlayerBotEquipmentAdapter::findCarriedUpgrade(const PlayerBotEquipmentPolicy& policy, Player& player, + Item*& selectedItem, PlayerBotEquipmentUpgrade& selectedUpgrade) +{ + std::vector candidates; + std::vector items; + for (int32_t slot = CONST_SLOT_FIRST; slot <= CONST_SLOT_LAST; ++slot) { + Item* root = player.getInventoryItem(static_cast(slot)); + Container* container = root ? root->getContainer() : nullptr; + if (!container) continue; + for (ContainerIterator it = container->iterator(); it.hasNext(); it.advance()) { + Item* candidate = *it; + Position source; + uint8_t index = 0; + g_game.internalGetPosition(candidate, source, index); + PlayerBotEquipmentItemSnapshot facts = PlayerBotEquipmentAdapter::item(*candidate); + facts.inContainer = true; + candidates.push_back({std::move(facts), source.x == 0xFFFF && (source.y & 0x40) != 0}); + items.push_back(candidate); + } + } + const auto selected = policy.findCarriedUpgrade(PlayerBotEquipmentAdapter::player(player), loadout(player), candidates); + if (!selected) { + selectedItem = nullptr; + return false; + } + selectedItem = items[selected->index]; + selectedUpgrade = selected->upgrade; + return true; +} diff --git a/server/src/playerbotequipmentadapter.h b/server/src/playerbotequipmentadapter.h new file mode 100644 index 00000000..1e5a3eaf --- /dev/null +++ b/server/src/playerbotequipmentadapter.h @@ -0,0 +1,21 @@ +/** Engine adapter for immutable equipment-policy observations. */ +#ifndef FS_PLAYERBOTEQUIPMENTADAPTER_H +#define FS_PLAYERBOTEQUIPMENTADAPTER_H + +#include "playerbotequipmentpolicy.h" + +class Item; +class Player; + +class PlayerBotEquipmentAdapter +{ + public: + static PlayerBotEquipmentPlayerSnapshot player(const Player& player); + static PlayerBotEquipmentItemSnapshot item(const Item& item); + static PlayerBotEquipmentItemSnapshot item(uint16_t itemId); + static PlayerBotEquipmentLoadout loadout(const Player& player); + static bool findCarriedUpgrade(const PlayerBotEquipmentPolicy& policy, Player& player, + Item*& selectedItem, PlayerBotEquipmentUpgrade& selectedUpgrade); +}; + +#endif diff --git a/server/src/playerbotequipmentpolicy.cpp b/server/src/playerbotequipmentpolicy.cpp index e7bd20f8..1c32e3ad 100644 --- a/server/src/playerbotequipmentpolicy.cpp +++ b/server/src/playerbotequipmentpolicy.cpp @@ -1,332 +1,209 @@ -/** - * The Forgotten Server - a free and open-source MMORPG server emulator - * Copyright (C) 2019 Mark Samman - */ - #include "otpch.h" #include "playerbotequipmentpolicy.h" -#include "game.h" -#include "item.h" -#include "player.h" -#include "weapons.h" +#include +#include +#include namespace { - skills_t skillForWeapon(WeaponType_t weaponType) + constexpr slots_t slot(uint8_t value) { return static_cast(value); } + constexpr slots_t head = slot(1); + constexpr slots_t necklace = slot(2); + constexpr slots_t armor = slot(4); + constexpr slots_t right = slot(5); + constexpr slots_t left = slot(6); + constexpr slots_t legs = slot(7); + constexpr slots_t feet = slot(8); + constexpr slots_t ring = slot(9); + + bool melee(PlayerBotEquipmentWeaponType type) + { + return type == PlayerBotEquipmentWeaponType::Sword || type == PlayerBotEquipmentWeaponType::Club || + type == PlayerBotEquipmentWeaponType::Axe; + } + + int32_t skill(const PlayerBotEquipmentPlayerSnapshot& player, PlayerBotEquipmentWeaponType type) { - switch (weaponType) { - case WEAPON_SWORD: return SKILL_SWORD; - case WEAPON_CLUB: return SKILL_CLUB; - case WEAPON_AXE: return SKILL_AXE; - case WEAPON_DISTANCE: case WEAPON_AMMO: return SKILL_DISTANCE; - default: return SKILL_FIST; + switch (type) { + case PlayerBotEquipmentWeaponType::Sword: return player.swordSkill; + case PlayerBotEquipmentWeaponType::Club: return player.clubSkill; + case PlayerBotEquipmentWeaponType::Axe: return player.axeSkill; + case PlayerBotEquipmentWeaponType::Distance: case PlayerBotEquipmentWeaponType::Ammo: return player.distanceSkill; + default: return player.fistSkill; } } + + int32_t maximumDamage(uint32_t level, int32_t skillLevel, int32_t attack, float factor) + { + return static_cast(std::round((level / 5) + (((((skillLevel / 4.) + 1) * (attack / 3.)) * 1.03) / factor))); + } } PlayerBotEquipmentPolicy::PlayerBotEquipmentPolicy(uint16_t combatReadinessVocationId) : combatReadinessVocationId(combatReadinessVocationId) -{ -} +{} -bool PlayerBotEquipmentPolicy::requiresKnightCombatReadiness(const Player& player) const +bool PlayerBotEquipmentPolicy::requiresKnightCombatReadiness(const PlayerBotEquipmentPlayerSnapshot& player) const { - return player.getVocationId() == combatReadinessVocationId; + return player.vocationId == combatReadinessVocationId; } -bool PlayerBotEquipmentPolicy::isLegalEquipmentType(const Player& player, const ItemType& type) const +bool PlayerBotEquipmentPolicy::isLegalEquipmentItem(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentItemSnapshot& item) const { - if (!type.isPickupable() || player.getLevel() < type.minReqLevel || - player.getMagicLevel() < type.minReqMagicLevel || - ((type.wieldInfo & WIELDINFO_PREMIUM) != 0 && !player.isPremium())) { - return false; - } - return type.vocationIds.empty() || type.vocationIds.find(player.getVocationId()) != type.vocationIds.end(); + return !item.removed && item.pickupable && player.level >= item.minimumLevel && + player.magicLevel >= item.minimumMagicLevel && (!item.premiumRequired || player.premium) && + (item.vocationIds.empty() || std::find(item.vocationIds.begin(), item.vocationIds.end(), player.vocationId) != item.vocationIds.end()); } -bool PlayerBotEquipmentPolicy::isLegalEquipmentItem(const Player& player, const Item& item) const +bool PlayerBotEquipmentPolicy::isKnightMeleeWeapon(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentItemSnapshot& item) const { - return !item.isRemoved() && isLegalEquipmentType(player, Item::items[item.getID()]); + return isLegalEquipmentItem(player, item) && item.attack > 0 && melee(item.weaponType) && (item.left || item.right); } -bool PlayerBotEquipmentPolicy::isKnightMeleeWeapon(const Player& player, const Item& item) const +bool PlayerBotEquipmentPolicy::isCombatEquipment(const PlayerBotEquipmentItemSnapshot& item) const { - const WeaponType_t weaponType = item.getWeaponType(); - return isLegalEquipmentItem(player, item) && item.getAttack() > 0 && - (weaponType == WEAPON_SWORD || weaponType == WEAPON_CLUB || weaponType == WEAPON_AXE) && - (item.getSlotPosition() & (SLOTP_LEFT | SLOTP_RIGHT)) != 0; + return item.head || item.armorSlot || item.legs || item.feet || item.weaponType != PlayerBotEquipmentWeaponType::None || + item.armor > 0 || item.defense > 0; } -bool PlayerBotEquipmentPolicy::isCombatEquipment(const Item& item) const +std::optional PlayerBotEquipmentPolicy::evaluateUpgrade(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentLoadout& loadout, const PlayerBotEquipmentItemSnapshot& candidate) const { - const ItemType& type = Item::items[item.getID()]; - return (type.slotPosition & (SLOTP_HEAD | SLOTP_ARMOR | SLOTP_LEGS | SLOTP_FEET)) != 0 || - type.weaponType != WEAPON_NONE || item.getArmor() > 0 || item.getDefense() > 0; -} - -std::optional PlayerBotEquipmentPolicy::evaluateUpgrade(const Player& player, const Item& candidate) const -{ - const ItemType& type = Item::items[candidate.getID()]; - if (!isLegalEquipmentItem(player, candidate)) { - return std::nullopt; - } - slots_t slot = CONST_SLOT_WHEREEVER; + if (!isLegalEquipmentItem(player, candidate)) return std::nullopt; + slots_t target = slot(0); const char* metric = nullptr; int32_t candidateValue = 0; - if (type.slotPosition & SLOTP_HEAD) { - slot = CONST_SLOT_HEAD; - metric = "armor"; - candidateValue = candidate.getArmor(); - } else if (type.slotPosition & SLOTP_ARMOR) { - slot = CONST_SLOT_ARMOR; - metric = "armor"; - candidateValue = candidate.getArmor(); - } else if (type.slotPosition & SLOTP_LEGS) { - slot = CONST_SLOT_LEGS; - metric = "armor"; - candidateValue = candidate.getArmor(); - } else if (type.slotPosition & SLOTP_FEET) { - slot = CONST_SLOT_FEET; - metric = "armor"; - candidateValue = candidate.getArmor(); - } else if (candidate.getWeaponType() == WEAPON_SHIELD) { - slot = CONST_SLOT_RIGHT; - metric = "defense"; - candidateValue = candidate.getDefense(); - } else if (!(type.slotPosition & SLOTP_TWO_HAND) && candidate.getWeaponType() != WEAPON_NONE && - candidate.getWeaponType() != WEAPON_AMMO) { - slot = CONST_SLOT_LEFT; - metric = "attack"; - candidateValue = candidate.getAttack(); + if (candidate.head) { target = head; metric = "armor"; candidateValue = candidate.armor; } + else if (candidate.armorSlot) { target = armor; metric = "armor"; candidateValue = candidate.armor; } + else if (candidate.legs) { target = legs; metric = "armor"; candidateValue = candidate.armor; } + else if (candidate.feet) { target = feet; metric = "armor"; candidateValue = candidate.armor; } + else if (candidate.weaponType == PlayerBotEquipmentWeaponType::Shield) { target = right; metric = "defense"; candidateValue = candidate.defense; } + else if (!candidate.twoHanded && candidate.weaponType != PlayerBotEquipmentWeaponType::None && candidate.weaponType != PlayerBotEquipmentWeaponType::Ammo) { + target = left; metric = "attack"; candidateValue = candidate.attack; } - if (slot == CONST_SLOT_WHEREEVER || candidateValue <= 0) { - return std::nullopt; - } - - const Item* equipped = player.getInventoryItem(slot); + if (target == slot(0) || candidateValue <= 0) return std::nullopt; + const PlayerBotEquipmentItemSnapshot& equipped = loadout.items[static_cast(target)]; int32_t currentValue = 0; - if (equipped) { - if (std::strcmp(metric, "armor") == 0) { - currentValue = equipped->getArmor(); - } else if (std::strcmp(metric, "defense") == 0) { - currentValue = equipped->getDefense(); - } else { - currentValue = equipped->getAttack(); - const int32_t candidateMaximumDamage = Weapons::getMaxWeaponDamage( - player.getLevel(), player.getWeaponSkill(&candidate), candidate.getAttack(), player.getAttackFactor()); - const int32_t currentMaximumDamage = Weapons::getMaxWeaponDamage( - player.getLevel(), player.getWeaponSkill(equipped), equipped->getAttack(), player.getAttackFactor()); - if (candidateMaximumDamage <= currentMaximumDamage) { - return std::nullopt; - } - } - } - if (candidateValue <= currentValue) { - return std::nullopt; - } - return PlayerBotEquipmentUpgrade{slot, candidateValue - currentValue, metric, currentValue, candidateValue}; -} - -bool PlayerBotEquipmentPolicy::findCarriedUpgrade(Player& player, Item*& selectedItem, - PlayerBotEquipmentUpgrade& selectedUpgrade) const -{ - selectedItem = nullptr; - for (int32_t slot = CONST_SLOT_FIRST; slot <= CONST_SLOT_LAST; ++slot) { - Item* root = player.getInventoryItem(static_cast(slot)); - Container* container = root ? root->getContainer() : nullptr; - if (!container) { - continue; - } - for (ContainerIterator it = container->iterator(); it.hasNext(); it.advance()) { - Item* candidate = *it; - std::optional upgrade = evaluateUpgrade(player, *candidate); - if (!upgrade || (requiresKnightCombatReadiness(player) && candidate->getWeaponType() != WEAPON_NONE && - !isKnightMeleeWeapon(player, *candidate))) { - continue; - } - Position source; - uint8_t index = 0; - g_game.internalGetPosition(candidate, source, index); - if (source.x != 0xFFFF || (source.y & 0x40) == 0 || - (selectedItem && upgrade->benefit <= selectedUpgrade.benefit)) { - continue; - } - selectedItem = candidate; - selectedUpgrade = *upgrade; - } + if (equipped.itemId != 0) { + currentValue = std::strcmp(metric, "armor") == 0 ? equipped.armor : std::strcmp(metric, "defense") == 0 ? equipped.defense : equipped.attack; + if (std::strcmp(metric, "attack") == 0 && maximumDamage(player.level, skill(player, candidate.weaponType), candidate.attack, player.attackFactor) <= + maximumDamage(player.level, skill(player, equipped.weaponType), equipped.attack, player.attackFactor)) return std::nullopt; } - return selectedItem != nullptr; + if (candidateValue <= currentValue) return std::nullopt; + return PlayerBotEquipmentUpgrade{target, candidateValue - currentValue, metric, currentValue, candidateValue}; } -PlayerBotEquipmentLoadout PlayerBotEquipmentPolicy::loadout(const Player& player) const +std::optional PlayerBotEquipmentPolicy::findCarriedUpgrade( + const PlayerBotEquipmentPlayerSnapshot& player, const PlayerBotEquipmentLoadout& loadout, + const std::vector& candidates) const { - PlayerBotEquipmentLoadout loadout; - for (int32_t slot = CONST_SLOT_FIRST; slot <= CONST_SLOT_LAST; ++slot) { - if (const Item* item = player.getInventoryItem(static_cast(slot))) { - loadout.itemIds[slot] = item->getID(); - } + std::optional selected; + for (size_t index = 0; index < candidates.size(); ++index) { + 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)) || + (selected && upgrade->benefit <= selected->upgrade.benefit)) continue; + selected = PlayerBotEquipmentCarriedUpgrade{index, *upgrade}; } - return loadout; + return selected; } -bool PlayerBotEquipmentPolicy::applyOffer(const Player& player, PlayerBotEquipmentLoadout& loadout, uint16_t itemId, - slots_t& slot, uint16_t& replacedItemId, uint16_t& displacedLeftItemId, - uint16_t& displacedRightItemId, std::string& rejection) const +bool PlayerBotEquipmentPolicy::applyOffer(const PlayerBotEquipmentPlayerSnapshot& player, PlayerBotEquipmentLoadout& loadout, + const PlayerBotEquipmentItemSnapshot& candidate, slots_t& target, uint16_t& replacedItemId, + uint16_t& displacedLeftItemId, uint16_t& displacedRightItemId, std::string& rejection) const { - const ItemType& type = Item::items[itemId]; - if (!type.isPickupable()) { rejection = "not_pickupable"; return false; } - if (player.getLevel() < type.minReqLevel) { rejection = "level_ineligible"; return false; } - if (player.getMagicLevel() < type.minReqMagicLevel) { rejection = "magic_level_ineligible"; return false; } - if ((type.wieldInfo & WIELDINFO_PREMIUM) != 0 && !player.isPremium()) { rejection = "premium_ineligible"; return false; } - if (!type.vocationIds.empty() && type.vocationIds.find(player.getVocationId()) == type.vocationIds.end()) { - rejection = "vocation_ineligible"; - return false; - } - - auto itemTypeAt = [&loadout](slots_t hand) -> const ItemType* { - const uint16_t equippedItemId = loadout.itemIds[hand]; - return equippedItemId == 0 ? nullptr : &Item::items[equippedItemId]; - }; - auto isTwoHanded = [&itemTypeAt](slots_t hand) { - const ItemType* equipped = itemTypeAt(hand); - return equipped && (equipped->slotPosition & SLOTP_TWO_HAND) != 0; - }; - auto isWeapon = [&itemTypeAt](slots_t hand) { - const ItemType* equipped = itemTypeAt(hand); - return equipped && equipped->weaponType != WEAPON_NONE && equipped->weaponType != WEAPON_SHIELD; - }; - auto isShield = [&itemTypeAt](slots_t hand) { - const ItemType* equipped = itemTypeAt(hand); - return equipped && equipped->weaponType == WEAPON_SHIELD; - }; - - slot = CONST_SLOT_WHEREEVER; - if (type.slotPosition & SLOTP_HEAD) slot = CONST_SLOT_HEAD; - else if (type.slotPosition & SLOTP_ARMOR) slot = CONST_SLOT_ARMOR; - else if (type.slotPosition & SLOTP_LEGS) slot = CONST_SLOT_LEGS; - else if (type.slotPosition & SLOTP_FEET) slot = CONST_SLOT_FEET; - else if (type.weaponType == WEAPON_SHIELD) { - slot = isWeapon(CONST_SLOT_LEFT) && !isTwoHanded(CONST_SLOT_LEFT) ? CONST_SLOT_RIGHT : - isWeapon(CONST_SLOT_RIGHT) && !isTwoHanded(CONST_SLOT_RIGHT) ? CONST_SLOT_LEFT : CONST_SLOT_RIGHT; - } else if (type.weaponType != WEAPON_NONE && type.weaponType != WEAPON_AMMO && - (type.slotPosition & (SLOTP_LEFT | SLOTP_RIGHT)) != 0) { - if (requiresKnightCombatReadiness(player) && type.weaponType != WEAPON_SWORD && - type.weaponType != WEAPON_CLUB && type.weaponType != WEAPON_AXE) { - rejection = "unsupported_weapon_type"; - return false; - } - slot = isShield(CONST_SLOT_LEFT) ? CONST_SLOT_RIGHT : isShield(CONST_SLOT_RIGHT) ? CONST_SLOT_LEFT : - (type.slotPosition & SLOTP_LEFT) != 0 ? CONST_SLOT_LEFT : CONST_SLOT_RIGHT; - } else { - rejection = "unsupported_slot"; - return false; + if (!candidate.pickupable) { rejection = "not_pickupable"; return false; } + if (player.level < candidate.minimumLevel) { rejection = "level_ineligible"; return false; } + if (player.magicLevel < candidate.minimumMagicLevel) { rejection = "magic_level_ineligible"; return false; } + if (candidate.premiumRequired && !player.premium) { rejection = "premium_ineligible"; return false; } + if (!candidate.vocationIds.empty() && std::find(candidate.vocationIds.begin(), candidate.vocationIds.end(), player.vocationId) == candidate.vocationIds.end()) { + rejection = "vocation_ineligible"; return false; } - - replacedItemId = (type.slotPosition & SLOTP_TWO_HAND) != 0 ? loadout.itemIds[CONST_SLOT_LEFT] : loadout.itemIds[slot]; + auto itemAt = [&loadout](slots_t value) -> const PlayerBotEquipmentItemSnapshot& { return loadout.items[static_cast(value)]; }; + auto twoHanded = [&itemAt](slots_t value) { return itemAt(value).itemId != 0 && itemAt(value).twoHanded; }; + auto weapon = [&itemAt](slots_t value) { const auto type = itemAt(value).weaponType; return itemAt(value).itemId != 0 && type != PlayerBotEquipmentWeaponType::None && type != PlayerBotEquipmentWeaponType::Shield; }; + auto shield = [&itemAt](slots_t value) { return itemAt(value).itemId != 0 && itemAt(value).weaponType == PlayerBotEquipmentWeaponType::Shield; }; + target = slot(0); + if (candidate.head) target = head; + else if (candidate.armorSlot) target = armor; + else if (candidate.legs) target = legs; + else if (candidate.feet) target = feet; + else if (candidate.weaponType == PlayerBotEquipmentWeaponType::Shield) target = weapon(left) && !twoHanded(left) ? right : weapon(right) && !twoHanded(right) ? left : right; + else if (candidate.weaponType != PlayerBotEquipmentWeaponType::None && candidate.weaponType != PlayerBotEquipmentWeaponType::Ammo && (candidate.left || candidate.right)) { + if (requiresKnightCombatReadiness(player) && !melee(candidate.weaponType)) { rejection = "unsupported_weapon_type"; return false; } + target = shield(left) ? right : shield(right) ? left : candidate.left ? left : right; + } else { rejection = "unsupported_slot"; return false; } + replacedItemId = candidate.twoHanded ? loadout.itemIds[static_cast(left)] : loadout.itemIds[static_cast(target)]; displacedLeftItemId = 0; displacedRightItemId = 0; - if ((type.slotPosition & SLOTP_TWO_HAND) != 0) { - displacedLeftItemId = loadout.itemIds[CONST_SLOT_LEFT]; - displacedRightItemId = loadout.itemIds[CONST_SLOT_RIGHT]; - loadout.itemIds[CONST_SLOT_LEFT] = itemId; - loadout.itemIds[CONST_SLOT_RIGHT] = 0; - } else if (isTwoHanded(CONST_SLOT_LEFT) || isTwoHanded(CONST_SLOT_RIGHT)) { - displacedLeftItemId = loadout.itemIds[CONST_SLOT_LEFT]; - displacedRightItemId = loadout.itemIds[CONST_SLOT_RIGHT]; - loadout.itemIds[CONST_SLOT_LEFT] = 0; - loadout.itemIds[CONST_SLOT_RIGHT] = 0; - loadout.itemIds[slot] = itemId; + if (candidate.twoHanded) { + displacedLeftItemId = loadout.itemIds[static_cast(left)]; displacedRightItemId = loadout.itemIds[static_cast(right)]; + loadout.itemIds[static_cast(left)] = candidate.itemId; loadout.items[static_cast(left)] = candidate; + loadout.itemIds[static_cast(right)] = 0; loadout.items[static_cast(right)] = {}; + } else if (twoHanded(left) || twoHanded(right)) { + displacedLeftItemId = loadout.itemIds[static_cast(left)]; displacedRightItemId = loadout.itemIds[static_cast(right)]; + loadout.itemIds[static_cast(left)] = 0; loadout.items[static_cast(left)] = {}; + loadout.itemIds[static_cast(right)] = 0; loadout.items[static_cast(right)] = {}; + loadout.itemIds[static_cast(target)] = candidate.itemId; loadout.items[static_cast(target)] = candidate; } else { - if (slot == CONST_SLOT_LEFT) displacedLeftItemId = loadout.itemIds[slot]; - else if (slot == CONST_SLOT_RIGHT) displacedRightItemId = loadout.itemIds[slot]; - loadout.itemIds[slot] = itemId; + if (target == left) displacedLeftItemId = loadout.itemIds[static_cast(target)]; + else if (target == right) displacedRightItemId = loadout.itemIds[static_cast(target)]; + loadout.itemIds[static_cast(target)] = candidate.itemId; loadout.items[static_cast(target)] = candidate; } return true; } -PlayerBotCombatProfile PlayerBotEquipmentPolicy::combatProfile(const Player& player, - const PlayerBotEquipmentLoadout& loadout) const +PlayerBotCombatProfile PlayerBotEquipmentPolicy::combatProfile(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentLoadout& loadout) const { - auto itemTypeAt = [&loadout](slots_t slot) -> const ItemType* { - const uint16_t itemId = loadout.itemIds[slot]; - return itemId == 0 ? nullptr : &Item::items[itemId]; - }; - int32_t armor = 0; - for (slots_t slot : {CONST_SLOT_HEAD, CONST_SLOT_NECKLACE, CONST_SLOT_ARMOR, CONST_SLOT_LEGS, CONST_SLOT_FEET, CONST_SLOT_RING}) { - if (const ItemType* type = itemTypeAt(slot)) armor += type->armor; - } - const ItemType* weapon = nullptr; - const ItemType* shield = nullptr; - for (slots_t slot : {CONST_SLOT_RIGHT, CONST_SLOT_LEFT}) { - const ItemType* type = itemTypeAt(slot); - if (!type || type->weaponType == WEAPON_NONE) continue; - if (type->weaponType == WEAPON_SHIELD) { - if (!shield || type->defense > shield->defense) shield = type; - } else { - weapon = type; - } + auto itemAt = [&loadout](slots_t value) -> const PlayerBotEquipmentItemSnapshot& { return loadout.items[static_cast(value)]; }; + int32_t armorValue = 0; + for (slots_t value : {head, necklace, armor, legs, feet, ring}) armorValue += itemAt(value).armor; + const PlayerBotEquipmentItemSnapshot* weapon = nullptr; + const PlayerBotEquipmentItemSnapshot* shield = nullptr; + for (slots_t value : {right, left}) { + const auto& item = itemAt(value); + if (item.itemId == 0 || item.weaponType == PlayerBotEquipmentWeaponType::None) continue; + if (item.weaponType == PlayerBotEquipmentWeaponType::Shield) { if (!shield || item.defense > shield->defense) shield = &item; } + else weapon = &item; } int32_t defenseValue = 7; - int32_t defenseSkill = player.getSkillLevel(SKILL_FIST); - if (weapon) { - defenseValue = weapon->defense + weapon->extraDefense; - defenseSkill = player.getSkillLevel(skillForWeapon(weapon->weaponType)); - } - if (shield) { - defenseValue = weapon ? shield->defense + weapon->extraDefense : shield->defense; - defenseSkill = player.getSkillLevel(SKILL_SHIELD); - } - const int32_t defense = defenseSkill == 0 ? 1 : static_cast( - (defenseSkill / 4.0 + 2.23) * defenseValue * 0.15 * player.getDefenseFactor() * player.getVocation()->defenseMultiplier); - return {player.getLevel(), player.getMaxHealth(), static_cast(armor * player.getVocation()->armorMultiplier), defense, - weapon ? weapon->attack : 7, player.getSkillLevel(skillForWeapon(weapon ? weapon->weaponType : WEAPON_NONE)), - player.getAttackFactor()}; + int32_t defenseSkill = player.fistSkill; + if (weapon) { defenseValue = weapon->defense + weapon->extraDefense; defenseSkill = skill(player, weapon->weaponType); } + if (shield) { defenseValue = weapon ? shield->defense + weapon->extraDefense : shield->defense; defenseSkill = player.shieldSkill; } + const int32_t defense = defenseSkill == 0 ? 1 : static_cast((defenseSkill / 4.0 + 2.23) * defenseValue * 0.15 * player.defenseFactor * player.defenseMultiplier); + return {player.level, player.maximumHealth, static_cast(armorValue * player.armorMultiplier), defense, + weapon ? weapon->attack : 7, skill(player, weapon ? weapon->weaponType : PlayerBotEquipmentWeaponType::None), player.attackFactor}; } -bool PlayerBotEquipmentPolicy::loadoutReady(const Player& player, const PlayerBotEquipmentLoadout& loadout, - const PlayerBotEquipmentReadinessInput& readiness, - uint32_t additionalWeight) const +bool PlayerBotEquipmentPolicy::loadoutReady(const PlayerBotEquipmentPlayerSnapshot& player, const PlayerBotEquipmentLoadout& loadout, + const PlayerBotEquipmentReadinessInput& readiness, uint32_t additionalWeight) const { - auto isKnightWeapon = [this, &player](uint16_t itemId) { - if (itemId == 0) return false; - const ItemType& type = Item::items[itemId]; - return isLegalEquipmentType(player, type) && type.attack > 0 && - (type.weaponType == WEAPON_SWORD || type.weaponType == WEAPON_CLUB || type.weaponType == WEAPON_AXE) && - (type.slotPosition & (SLOTP_LEFT | SLOTP_RIGHT)) != 0; - }; - const uint16_t armorItemId = loadout.itemIds[CONST_SLOT_ARMOR]; - const bool armorReady = armorItemId != 0 && isLegalEquipmentType(player, Item::items[armorItemId]) && - (Item::items[armorItemId].slotPosition & SLOTP_ARMOR) != 0 && Item::items[armorItemId].armor > 0; - return (isKnightWeapon(loadout.itemIds[CONST_SLOT_LEFT]) || isKnightWeapon(loadout.itemIds[CONST_SLOT_RIGHT])) && armorReady && - readiness.backpackReady && readiness.suppliesReady && - static_cast(readiness.effectiveFreeCapacity) >= - static_cast(readiness.minimumFreeCapacity) + additionalWeight; + const auto& leftItem = loadout.items[static_cast(left)]; + const auto& rightItem = loadout.items[static_cast(right)]; + const auto& armorItem = loadout.items[static_cast(armor)]; + const bool weaponReady = isKnightMeleeWeapon(player, leftItem) || isKnightMeleeWeapon(player, rightItem); + const bool armorReady = armorItem.itemId != 0 && isLegalEquipmentItem(player, armorItem) && armorItem.armorSlot && armorItem.armor > 0; + return weaponReady && armorReady && readiness.backpackReady && readiness.suppliesReady && + static_cast(readiness.effectiveFreeCapacity) >= static_cast(readiness.minimumFreeCapacity) + additionalWeight; } -PlayerBotEquipmentReadiness PlayerBotEquipmentPolicy::combatReadiness( - const Player& player, bool carriedUpgrade, const PlayerBotEquipmentReadinessInput& readiness) const +PlayerBotEquipmentReadiness PlayerBotEquipmentPolicy::combatReadiness(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentLoadout& loadout, bool carriedUpgrade, const PlayerBotEquipmentReadinessInput& readiness) const { PlayerBotEquipmentReadiness result; - if (!requiresKnightCombatReadiness(player)) { - result.ready = true; - return result; - } - const Item* left = player.getInventoryItem(CONST_SLOT_LEFT); - const Item* right = player.getInventoryItem(CONST_SLOT_RIGHT); - const Item* armor = player.getInventoryItem(CONST_SLOT_ARMOR); - const bool weaponReady = (left && isKnightMeleeWeapon(player, *left)) || (right && isKnightMeleeWeapon(player, *right)); - const bool armorReady = armor && isLegalEquipmentItem(player, *armor) && - (armor->getSlotPosition() & SLOTP_ARMOR) != 0 && armor->getArmor() > 0; - if (carriedUpgrade) { - result.recovery = "equip_carried"; - return result; - } - if (weaponReady && armorReady && readiness.backpackReady && readiness.suppliesReady && - readiness.effectiveFreeCapacity >= readiness.minimumFreeCapacity) { - result.ready = true; - return result; - } + if (!requiresKnightCombatReadiness(player)) { result.ready = true; return result; } + const auto& leftItem = loadout.items[static_cast(left)]; + const auto& rightItem = loadout.items[static_cast(right)]; + const auto& armorItem = loadout.items[static_cast(armor)]; + const bool weaponReady = isKnightMeleeWeapon(player, leftItem) || isKnightMeleeWeapon(player, rightItem); + const bool armorReady = armorItem.itemId != 0 && isLegalEquipmentItem(player, armorItem) && armorItem.armorSlot && armorItem.armor > 0; + if (carriedUpgrade) { result.recovery = "equip_carried"; return result; } + 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"; @@ -334,54 +211,32 @@ PlayerBotEquipmentReadiness PlayerBotEquipmentPolicy::combatReadiness( return result; } -PlayerBotEquipmentOfferEvaluation PlayerBotEquipmentPolicy::evaluateCandidate( - Player& player, uint16_t itemId, const PlayerBotEquipmentLoadout& currentLoadout, - const PlayerBotCombatProfile& currentProfile, const PlayerBotEquipmentHuntSummary& currentHunts, - bool currentReady, const PlayerBotEquipmentReadinessInput& readiness, uint32_t additionalWeight, - bool allowSimulation, const HuntSummaryEvaluator& huntSummary) const +PlayerBotEquipmentOfferEvaluation PlayerBotEquipmentPolicy::evaluateCandidate(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentItemSnapshot& candidate, const PlayerBotEquipmentLoadout& currentLoadout, + const PlayerBotCombatProfile& currentProfile, const PlayerBotEquipmentHuntSummary& currentHunts, bool currentReady, + const PlayerBotEquipmentReadinessInput& readiness, uint32_t additionalWeight, bool allowSimulation, + const HuntSummaryEvaluator& huntSummary) const { PlayerBotEquipmentOfferEvaluation evaluation; - evaluation.itemId = itemId; + evaluation.itemId = candidate.itemId; evaluation.currentReady = currentReady; PlayerBotEquipmentLoadout candidateLoadout = currentLoadout; - if (!applyOffer(player, candidateLoadout, itemId, evaluation.slot, evaluation.replacedItemId, - evaluation.displacedLeftItemId, evaluation.displacedRightItemId, evaluation.rejection)) { - evaluation.profile = currentProfile; - evaluation.hunts = currentHunts; - return evaluation; - } - if (!allowSimulation) { - evaluation.profile = currentProfile; - evaluation.hunts = currentHunts; - evaluation.rejection = "unique_item_evaluation_budget_exhausted"; - return evaluation; + if (!applyOffer(player, candidateLoadout, candidate, evaluation.slot, evaluation.replacedItemId, evaluation.displacedLeftItemId, evaluation.displacedRightItemId, evaluation.rejection)) { + evaluation.profile = currentProfile; evaluation.hunts = currentHunts; return evaluation; } + if (!allowSimulation) { evaluation.profile = currentProfile; evaluation.hunts = currentHunts; evaluation.rejection = "unique_item_evaluation_budget_exhausted"; return evaluation; } evaluation.simulated = true; evaluation.profile = combatProfile(player, candidateLoadout); - evaluation.hunts = huntSummary(player, evaluation.profile); + evaluation.hunts = huntSummary(evaluation.profile); evaluation.candidateReady = loadoutReady(player, candidateLoadout, readiness, additionalWeight); - const int32_t currentMaximumDamage = Weapons::getMaxWeaponDamage( - currentProfile.level, currentProfile.attackSkill, currentProfile.attack, currentProfile.attackFactor); - const int32_t candidateMaximumDamage = Weapons::getMaxWeaponDamage( - evaluation.profile.level, evaluation.profile.attackSkill, evaluation.profile.attack, evaluation.profile.attackFactor); - const bool noWorse = evaluation.profile.armor >= currentProfile.armor && - evaluation.profile.defense >= currentProfile.defense && - candidateMaximumDamage >= currentMaximumDamage && - evaluation.hunts.suitableRegions >= currentHunts.suitableRegions && - evaluation.hunts.lowestThreatRatio <= currentHunts.lowestThreatRatio && - evaluation.hunts.bestProjectedExperience >= currentHunts.bestProjectedExperience; - const bool better = evaluation.profile.armor > currentProfile.armor || - evaluation.profile.defense > currentProfile.defense || - candidateMaximumDamage > currentMaximumDamage || - evaluation.hunts.suitableRegions > currentHunts.suitableRegions || - evaluation.hunts.lowestThreatRatio < currentHunts.lowestThreatRatio || - evaluation.hunts.bestProjectedExperience > currentHunts.bestProjectedExperience; + const int32_t currentMaximumDamage = maximumDamage(currentProfile.level, currentProfile.attackSkill, currentProfile.attack, currentProfile.attackFactor); + const int32_t candidateMaximumDamage = maximumDamage(evaluation.profile.level, evaluation.profile.attackSkill, evaluation.profile.attack, evaluation.profile.attackFactor); + const bool noWorse = evaluation.profile.armor >= currentProfile.armor && evaluation.profile.defense >= currentProfile.defense && candidateMaximumDamage >= currentMaximumDamage && evaluation.hunts.suitableRegions >= currentHunts.suitableRegions && evaluation.hunts.lowestThreatRatio <= currentHunts.lowestThreatRatio && evaluation.hunts.bestProjectedExperience >= currentHunts.bestProjectedExperience; + const bool better = evaluation.profile.armor > currentProfile.armor || evaluation.profile.defense > currentProfile.defense || candidateMaximumDamage > currentMaximumDamage || evaluation.hunts.suitableRegions > currentHunts.suitableRegions || evaluation.hunts.lowestThreatRatio < currentHunts.lowestThreatRatio || evaluation.hunts.bestProjectedExperience > currentHunts.bestProjectedExperience; if (currentReady && !evaluation.candidateReady) evaluation.rejection = "regresses_readiness"; else if (!noWorse) evaluation.rejection = better ? "ambiguous_tradeoff" : "non_improving"; else if (!better) evaluation.rejection = "non_improving"; - else evaluation.rule = !currentReady && evaluation.candidateReady ? PlayerBotEquipmentDecisionRule::ReadinessRepair : - evaluation.hunts.suitableRegions > currentHunts.suitableRegions ? PlayerBotEquipmentDecisionRule::UnlocksHunt : - PlayerBotEquipmentDecisionRule::ParetoImprovement; + else evaluation.rule = !currentReady && evaluation.candidateReady ? PlayerBotEquipmentDecisionRule::ReadinessRepair : evaluation.hunts.suitableRegions > currentHunts.suitableRegions ? PlayerBotEquipmentDecisionRule::UnlocksHunt : PlayerBotEquipmentDecisionRule::ParetoImprovement; return evaluation; } @@ -397,12 +252,7 @@ const char* PlayerBotEquipmentPolicy::decisionRuleName(PlayerBotEquipmentDecisio } bool PlayerBotEquipmentPolicy::prefers(const PlayerBotEquipmentOfferEvaluation& candidate, - const PlayerBotEquipmentOfferEvaluation& current) + const PlayerBotEquipmentOfferEvaluation& 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))))))); + 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))))))); } diff --git a/server/src/playerbotequipmentpolicy.h b/server/src/playerbotequipmentpolicy.h index ccc1fdf4..279f3bdd 100644 --- a/server/src/playerbotequipmentpolicy.h +++ b/server/src/playerbotequipmentpolicy.h @@ -1,25 +1,75 @@ -/** - * Durable playerbot equipment decisions. World discovery, execution, and - * telemetry are deliberately supplied by callers. - */ +/** Durable equipment decisions over immutable caller-provided observations. */ #ifndef FS_PLAYERBOTEQUIPMENTPOLICY_H #define FS_PLAYERBOTEQUIPMENTPOLICY_H #include +#include #include +#include #include #include #include +#include -#include "creature.h" -#include "playerbothuntregions.h" +#include "playerbotcombatprofile.h" +#include "position.h" -class Item; -class ItemType; -class Player; +enum slots_t : uint8_t; + +// Standard 8.60 inventory slots are numbered 0 through 10. +inline constexpr size_t playerBotEquipmentSlotCount = 11; + +enum class PlayerBotEquipmentWeaponType : uint8_t { + None, Shield, Sword, Club, Axe, Distance, Ammo, Other, +}; + +struct PlayerBotEquipmentItemSnapshot { + uint16_t itemId = 0; + uint16_t minimumLevel = 0; + uint16_t minimumMagicLevel = 0; + uint32_t weight = 0; + uint32_t count = 0; + int32_t armor = 0; + int32_t defense = 0; + int32_t extraDefense = 0; + int32_t attack = 0; + std::vector vocationIds; + PlayerBotEquipmentWeaponType weaponType = PlayerBotEquipmentWeaponType::None; + bool pickupable = false; + bool removed = false; + bool premiumRequired = false; + bool head = false; + bool armorSlot = false; + bool legs = false; + bool feet = false; + bool left = false; + bool right = false; + bool twoHanded = false; + bool container = false; + bool inContainer = false; + bool equipped = false; +}; + +struct PlayerBotEquipmentPlayerSnapshot { + uint32_t level = 0; + uint32_t magicLevel = 0; + uint16_t vocationId = 0; + bool premium = false; + int32_t maximumHealth = 0; + int32_t fistSkill = 0; + int32_t swordSkill = 0; + int32_t clubSkill = 0; + int32_t axeSkill = 0; + int32_t distanceSkill = 0; + int32_t shieldSkill = 0; + float attackFactor = 1.0f; + float defenseFactor = 1.0f; + double armorMultiplier = 1.0; + double defenseMultiplier = 1.0; +}; struct PlayerBotEquipmentUpgrade { - slots_t slot = CONST_SLOT_WHEREEVER; + slots_t slot = static_cast(0); int32_t benefit = 0; const char* metric = nullptr; int32_t currentValue = 0; @@ -27,7 +77,18 @@ struct PlayerBotEquipmentUpgrade { }; struct PlayerBotEquipmentLoadout { - std::array itemIds{}; + std::array itemIds{}; + std::array items{}; +}; + +struct PlayerBotEquipmentCarriedCandidate { + PlayerBotEquipmentItemSnapshot item; + bool actionable = false; +}; + +struct PlayerBotEquipmentCarriedUpgrade { + size_t index = 0; + PlayerBotEquipmentUpgrade upgrade; }; struct PlayerBotEquipmentHuntSummary { @@ -39,10 +100,7 @@ struct PlayerBotEquipmentHuntSummary { }; enum class PlayerBotEquipmentDecisionRule : uint8_t { - None, - ParetoImprovement, - UnlocksHunt, - ReadinessRepair, + None, ParetoImprovement, UnlocksHunt, ReadinessRepair, }; struct PlayerBotEquipmentOfferEvaluation { @@ -51,7 +109,7 @@ struct PlayerBotEquipmentOfferEvaluation { Position approachPosition; uint16_t itemId = 0; uint32_t price = 0; - slots_t slot = CONST_SLOT_WHEREEVER; + slots_t slot = static_cast(0); uint16_t replacedItemId = 0; uint16_t displacedLeftItemId = 0; uint16_t displacedRightItemId = 0; @@ -82,27 +140,30 @@ struct PlayerBotEquipmentReadinessInput { class PlayerBotEquipmentPolicy { public: - using HuntSummaryEvaluator = std::function; + using HuntSummaryEvaluator = std::function; explicit PlayerBotEquipmentPolicy(uint16_t combatReadinessVocationId); - bool requiresKnightCombatReadiness(const Player& player) const; - bool isLegalEquipmentType(const Player& player, const ItemType& type) const; - bool isLegalEquipmentItem(const Player& player, const Item& item) const; - bool isKnightMeleeWeapon(const Player& player, const Item& item) const; - bool isCombatEquipment(const Item& item) const; - - std::optional evaluateUpgrade(const Player& player, const Item& candidate) const; - bool findCarriedUpgrade(Player& player, Item*& selectedItem, PlayerBotEquipmentUpgrade& selectedUpgrade) const; - PlayerBotEquipmentLoadout loadout(const Player& player) const; - bool applyOffer(const Player& player, PlayerBotEquipmentLoadout& loadout, uint16_t itemId, slots_t& slot, - uint16_t& replacedItemId, uint16_t& displacedLeftItemId, uint16_t& displacedRightItemId, - std::string& rejection) const; - PlayerBotCombatProfile combatProfile(const Player& player, const PlayerBotEquipmentLoadout& loadout) const; - bool loadoutReady(const Player& player, const PlayerBotEquipmentLoadout& loadout, + bool requiresKnightCombatReadiness(const PlayerBotEquipmentPlayerSnapshot& player) const; + bool isLegalEquipmentItem(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentItemSnapshot& item) const; + bool isKnightMeleeWeapon(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentItemSnapshot& item) const; + bool isCombatEquipment(const PlayerBotEquipmentItemSnapshot& item) const; + std::optional evaluateUpgrade(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentLoadout& loadout, + const PlayerBotEquipmentItemSnapshot& candidate) const; + std::optional findCarriedUpgrade(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentLoadout& loadout, + const std::vector& candidates) const; + PlayerBotCombatProfile combatProfile(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentLoadout& loadout) const; + bool loadoutReady(const PlayerBotEquipmentPlayerSnapshot& player, const PlayerBotEquipmentLoadout& loadout, const PlayerBotEquipmentReadinessInput& readiness, uint32_t additionalWeight = 0) const; - PlayerBotEquipmentReadiness combatReadiness(const Player& player, bool carriedUpgrade, + PlayerBotEquipmentReadiness combatReadiness(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentLoadout& loadout, bool carriedUpgrade, const PlayerBotEquipmentReadinessInput& readiness) const; - PlayerBotEquipmentOfferEvaluation evaluateCandidate(Player& player, uint16_t itemId, + PlayerBotEquipmentOfferEvaluation evaluateCandidate(const PlayerBotEquipmentPlayerSnapshot& player, + const PlayerBotEquipmentItemSnapshot& candidate, const PlayerBotEquipmentLoadout& currentLoadout, const PlayerBotCombatProfile& currentProfile, const PlayerBotEquipmentHuntSummary& currentHunts, @@ -116,6 +177,9 @@ class PlayerBotEquipmentPolicy const PlayerBotEquipmentOfferEvaluation& current); private: + bool applyOffer(const PlayerBotEquipmentPlayerSnapshot& player, PlayerBotEquipmentLoadout& loadout, + const PlayerBotEquipmentItemSnapshot& candidate, slots_t& slot, uint16_t& replacedItemId, + uint16_t& displacedLeftItemId, uint16_t& displacedRightItemId, std::string& rejection) const; const uint16_t combatReadinessVocationId; }; diff --git a/server/src/playerbotfixturedriver.cpp b/server/src/playerbotfixturedriver.cpp index 368d22e1..8694a42f 100644 --- a/server/src/playerbotfixturedriver.cpp +++ b/server/src/playerbotfixturedriver.cpp @@ -89,23 +89,21 @@ playerbot::PlayerBotFixtureRoutePlan playerbot::PlayerBotFixtureDriver::huntRout return {false, engineMaximumExpandedNodes}; } -std::vector playerbot::PlayerBotFixtureDriver::applyHuntPlanningHooks(PlayerBotHuntRuntime& runtime) +PlayerBotHuntPlanningObservation playerbot::PlayerBotFixtureDriver::huntPlanningObservation() const { - std::vector events; - if (policy.forceHuntScopeExhaustion) runtime.forceScopeExhaustionForTest(); - if (!policy.cancelHuntPlanningAtScoreBarrier || !runtime.planningActive()) return events; - if (!planningCancelled) { - planningCancelled = true; - runtime.fixtureCancelPlanning(); - events.push_back({"hunt_region_scan", "\"phase\":\"cancelled\""}); - return events; - } - if (!planningRevisionInvalidated) { - planningRevisionInvalidated = true; - runtime.fixtureInvalidatePlanningRevision(); - events.push_back({"hunt_region_scan", "\"phase\":\"stale_revision\""}); - } - return events; + PlayerBotHuntPlanningObservation observation; + observation.candidatesAvailable = !policy.forceHuntScopeExhaustion; + if (!policy.cancelHuntPlanningAtScoreBarrier) return observation; + observation.cancelAtScoreBarrier = !planningCancelled; + observation.invalidateCacheRevision = planningCancelled && huntPlanningStarts >= 2 && !planningRevisionInvalidated; + return observation; +} + +void playerbot::PlayerBotFixtureDriver::observeHuntPlanning(const PlayerBotHuntRuntimeOutcome& outcome) +{ + if (outcome.command == PlayerBotHuntRuntimeCommand::PlanningStarted) ++huntPlanningStarts; + if (outcome.command == PlayerBotHuntRuntimeCommand::PlanningCancelled) planningCancelled = true; + if (outcome.staleRevision) planningRevisionInvalidated = true; } void playerbot::PlayerBotFixtureDriver::beginDelayedInitialization() @@ -164,18 +162,19 @@ uint64_t playerbot::PlayerBotFixtureDriver::observedMagicTrainingMana(uint64_t e return policy.forceMagicTrainingVerificationFailure ? engineObservation + 1 : engineObservation; } -std::vector playerbot::PlayerBotFixtureDriver::runAdaptiveChallenge(Player& player, PlayerBotHuntRuntime& runtime) +std::vector playerbot::PlayerBotFixtureDriver::runAdaptiveChallenge(Player& player) { if (!policy.adaptiveChallengeFixture || adaptiveChallengeRun) return {}; adaptiveChallengeRun = true; std::vector events; + PlayerBotHuntPolicy adaptivePolicy; auto evidence = [&](double seconds, uint32_t kills, uint32_t recoveries, bool death = false) { - runtime.fixtureResetCombatEvidence(); - runtime.fixtureObserveCombat({seconds != 0, seconds, player.getMaxHealth(), player.getMaxHealth(), 1}); - for (uint32_t i = 0; i < kills; ++i) runtime.fixtureObserveKill(); - for (uint32_t i = 0; i < recoveries; ++i) runtime.fixtureObserveRecovery(true); - if (death) runtime.fixtureObserveDeath(); - const auto update = runtime.fixtureUpdateChallenge(300, player.getMaxHealth()); + adaptivePolicy.resetCombatEvidence(); + adaptivePolicy.observeCombat({seconds != 0, seconds, player.getMaxHealth(), player.getMaxHealth(), 1}); + for (uint32_t i = 0; i < kills; ++i) adaptivePolicy.observeKill(); + for (uint32_t i = 0; i < recoveries; ++i) adaptivePolicy.observeRecovery(true); + if (death) adaptivePolicy.observeDeath(); + const auto update = adaptivePolicy.updateChallengeFrontier({300, player.getMaxHealth()}); std::ostringstream fields; fields << std::fixed << std::setprecision(3) << "\"result\":" << jsonString(playerBotHuntChallengeResultName(update.result)) << ",\"reason\":\"adaptive_challenge_fixture\",\"frontier_before\":" << update.frontierBefore @@ -187,33 +186,45 @@ std::vector playerbot::PlayerBotFixtureDriver: << ",\"death\":" << (update.combat.deathObserved ? "true" : "false"); events.push_back({"hunt_challenge_frontier", fields.str()}); }; - runtime.fixtureResetCombatEvidence(); - runtime.fixtureObserveCombat({false, 30, player.getMaxHealth(), player.getMaxHealth(), 1}); - const double idle = runtime.fixtureCombatSummary().activeSeconds; - runtime.fixtureObserveCombat({true, 30, player.getMaxHealth(), player.getMaxHealth(), 1}); - const double active = runtime.fixtureCombatSummary().activeSeconds; + adaptivePolicy.resetCombatEvidence(); + adaptivePolicy.observeCombat({false, 30, player.getMaxHealth(), player.getMaxHealth(), 1}); + const double idle = adaptivePolicy.combatSummary().activeSeconds; + adaptivePolicy.observeCombat({true, 30, player.getMaxHealth(), player.getMaxHealth(), 1}); + const double active = adaptivePolicy.combatSummary().activeSeconds; evidence(0, 0, 0); evidence(30, 0, 0); evidence(30, 1, 0); evidence(30, 1, 0); evidence(30, 1, 1); evidence(30, 1, 0); evidence(30, 1, 0); evidence(30, 1, 0); evidence(0, 0, 0, true); const Item* weapon = player.getWeapon(true); const PlayerBotCombatProfile profile{player.getLevel(), player.getMaxHealth(), player.getArmor(), player.getDefense(), weapon ? weapon->getAttack() : 7, weapon ? player.getWeaponSkill(weapon) : player.getSkillLevel(SKILL_FIST), player.getAttackFactor()}; PlayerBotHuntRegion current, equipped; - runtime.fixtureScoreRegion(player, profile, current, equipped); - const PlayerBotRecoveryPrediction recovery = playerBotPredictRecovery(playerBotHuntPlanningProfile(player, profile, runtime.huntPolicy().challengeFrontier()), 30); + PlayerBotHuntRegionPlanner planner; + const PlayerBotHuntRegionScan scan = planner.beginScan(player); + const uint32_t duration = 300; + if (!scan.candidateIndices.empty()) { + const auto before = playerBotHuntPlanningProfile(player, profile, adaptivePolicy.challengeFrontier()); + PlayerBotCombatProfile upgraded = profile; + upgraded.armor += 20; + const auto after = playerBotHuntPlanningProfile(player, upgraded, adaptivePolicy.challengeFrontier()); + current = planner.score(player, before, scan.revision, scan.candidateIndices.front(), {}, + adaptivePolicy.regionPerformance(), duration).region; + equipped = planner.score(player, after, scan.revision, scan.candidateIndices.front(), {}, + adaptivePolicy.regionPerformance(), duration).region; + } + const PlayerBotRecoveryPrediction recovery = playerBotPredictRecovery(playerBotHuntPlanningProfile(player, profile, adaptivePolicy.challengeFrontier()), 30); PlayerBotHuntRegion inBand; inBand.score = 10; inBand.suitable = inBand.reachable = inBand.inChallengeBand = true; PlayerBotHuntRegion easier; easier.score = 1000; easier.suitable = easier.reachable = true; std::vector exhausted(1); exhausted.front().suitable = true; std::ostringstream fields; fields << std::fixed << std::setprecision(2) << "\"recovery_total\":" << recovery.totalMinimumHealing << ",\"recovery_spell_legal\":" << (recovery.lightHealingLegal ? "true" : "false") << ",\"recovery_spell_casts\":" << recovery.spellCasts << ",\"equipment_pressure_before\":" << current.threatRatio << ",\"equipment_pressure_after\":" << equipped.threatRatio << ",\"idle_observed_seconds\":" << idle << ",\"active_observed_seconds\":" << active << ",\"in_band_outranks_easier\":" << (playerBotPreferHuntRegion(inBand, easier) ? "true" : "false") << ",\"wounded_lethal\":" << (playerBotPredictedLethal(40, 40) ? "true" : "false") << ",\"zero_health_lethal\":" << (playerBotPredictedLethal(0, 0) ? "true" : "false") << ",\"helper_scope_exhausted\":" << (playerBotHuntScopeExhausted(exhausted) ? "true" : "false"); events.push_back({"adaptive_challenge_fixture", fields.str()}); - runtime.fixtureResetCombatEvidence(); return events; } -std::vector playerbot::PlayerBotFixtureDriver::runSpellCalibration(Player& player, PlayerBotSurvivalRuntime& runtime) +std::vector playerbot::PlayerBotFixtureDriver::runSpellCalibration(Player& player) { if (!policy.spellCalibrationFixture) return {}; std::vector events; + PlayerBotSpellCalibration calibration; auto observe = [&](const char* spell, const char* target, const PlayerBotSpellEnvelope& envelope, PlayerBotSpellEvidence evidence, int32_t value, const char* phase) { - const auto& profile = runtime.observeCalibrationFixture(spell, target, envelope, evidence, value); + const auto& profile = calibration.observe(spell, target, envelope, evidence, value); const bool math = std::strcmp(phase, "low_confidence") == 0 || std::strcmp(phase, "gradual_ranking") == 0 || std::strcmp(phase, "bounded_range") == 0; std::ostringstream fields; fields << "\"source\":" << jsonString(math ? "profile_math" : "classifier_helper") << ",\"phase\":" << jsonString(phase) << ",\"spell\":" << jsonString(spell) << ",\"target_class\":" << jsonString(target) << ",\"evidence\":" << jsonString(playerBotSpellEvidenceName(evidence)) << ",\"engine_bounds\":{\"minimum\":" << envelope.minimum << ",\"maximum\":" << envelope.maximum << ",\"duration_ms\":" << envelope.durationMs << "},\"calibration\":{\"accepted\":" << profile.accepted << ",\"rejected\":" << profile.rejected << ",\"ambiguous\":" << profile.ambiguous << ",\"minimum\":" << profile.minimum << ",\"maximum\":" << profile.maximum << ",\"conservative\":" << profile.conservative << ",\"ranking\":" << profile.ranking << ",\"confidence\":" << profile.confidence << '}'; @@ -228,8 +239,8 @@ std::vector playerbot::PlayerBotFixtureDriver: PlayerBotSpellObservation damage{true, false, false, true, false, false, false, std::max(1, rangeEnvelope.minimum)}; observe(ranged->name, "monster:fixture", rangeEnvelope, playerBotClassifySpellObservation(ranged->role, damage, 0, rangeEnvelope), damage.value, "single_target_damage"); observe(ranged->name, "monster:fixture", rangeEnvelope, PlayerBotSpellEvidence::MeleeOrOtherBotDamage, 0, "melee_ambiguous"); observe(ranged->name, "monster:fixture", rangeEnvelope, PlayerBotSpellEvidence::OtherAttacker, 0, "other_attacker_ambiguous"); observe(ranged->name, "monster:fixture", rangeEnvelope, PlayerBotSpellEvidence::TargetLost, 0, "target_loss_ambiguous"); observe(melee->name, "monster:fixture", meleeEnvelope, PlayerBotSpellEvidence::MultiTarget, 0, "multi_target_ambiguous"); observe(support->name, "self", supportEnvelope, PlayerBotSpellEvidence::Accepted, supportEnvelope.durationMs, "support_duration"); observe(support->name, "self", supportEnvelope, PlayerBotSpellEvidence::PreexistingOrReplacedCondition, 0, "support_preexisting_or_replaced"); for (uint16_t sample = 0; sample < 9; ++sample) observe(ranged->name, "monster:fixture", rangeEnvelope, PlayerBotSpellEvidence::Accepted, sample == 8 ? 70000 : rangeEnvelope.maximum + sample, sample == 0 ? "low_confidence" : sample == 8 ? "bounded_range" : "gradual_ranking"); - for (uint8_t profile = 0; profile <= 12; ++profile) { runtime.observeCalibrationFixture(ranged->name, "monster:eviction-" + std::to_string(profile), rangeEnvelope, PlayerBotSpellEvidence::Accepted, rangeEnvelope.minimum); if (auto evicted = runtime.takeCalibrationEviction()) events.push_back({"spell_calibration_eviction", "\"source\":\"profile_math\",\"evicted_profile\":" + jsonString(*evicted) + ",\"profile_count\":" + std::to_string(runtime.calibrationSize())}); } - const size_t before = runtime.calibrationSize(); runtime.clearCalibration(); events.push_back({"spell_calibration", "\"source\":\"profile_math\",\"phase\":\"fixture_profile_clear\",\"profiles_before\":" + std::to_string(before) + ",\"profiles_after\":" + std::to_string(runtime.calibrationSize()) + ",\"persistent\":false"}); + for (uint8_t profile = 0; profile <= 12; ++profile) { calibration.observe(ranged->name, "monster:eviction-" + std::to_string(profile), rangeEnvelope, PlayerBotSpellEvidence::Accepted, rangeEnvelope.minimum); if (auto evicted = calibration.takeEvictedProfile()) events.push_back({"spell_calibration_eviction", "\"source\":\"profile_math\",\"evicted_profile\":" + jsonString(*evicted) + ",\"profile_count\":" + std::to_string(calibration.size())}); } + const size_t before = calibration.size(); calibration.clear(); events.push_back({"spell_calibration", "\"source\":\"profile_math\",\"phase\":\"fixture_profile_clear\",\"profiles_before\":" + std::to_string(before) + ",\"profiles_after\":" + std::to_string(calibration.size()) + ",\"persistent\":false"}); return events; } diff --git a/server/src/playerbotfixturedriver.h b/server/src/playerbotfixturedriver.h index 049aedb5..7b44ef24 100644 --- a/server/src/playerbotfixturedriver.h +++ b/server/src/playerbotfixturedriver.h @@ -10,8 +10,8 @@ class Player; class DepotChest; -class PlayerBotHuntRuntime; -class PlayerBotSurvivalRuntime; +struct PlayerBotHuntPlanningObservation; +struct PlayerBotHuntRuntimeOutcome; namespace playerbot { enum class PlayerBotFixtureInitialization : uint8_t { NotPending, Waiting, Cancelled, Ready }; @@ -68,7 +68,8 @@ namespace playerbot { PlayerBotFixtureStorageObservation navigationRecovery(bool routeUnavailable) const; void resetHuntPlanningRouteFailures(); PlayerBotFixtureRoutePlan huntRoutePlan(uint64_t engineMaximumExpandedNodes); - std::vector applyHuntPlanningHooks(PlayerBotHuntRuntime& runtime); + PlayerBotHuntPlanningObservation huntPlanningObservation() const; + void observeHuntPlanning(const PlayerBotHuntRuntimeOutcome& outcome); void beginDelayedInitialization(); PlayerBotFixtureInitialization delayedInitializationStatus(Player& player); PlayerBotFixtureStorageObservation depotRestartObservation(Player& player, DepotRestartCheckpoint checkpoint); @@ -78,8 +79,8 @@ namespace playerbot { PlayerBotFixtureEngineCommand equipmentPurchaseCommand() const; PlayerBotFixtureStorageObservation equipmentPurchaseCompletion(Player& player) const; uint64_t observedMagicTrainingMana(uint64_t engineObservation) const; - std::vector runAdaptiveChallenge(Player& player, PlayerBotHuntRuntime& runtime); - std::vector runSpellCalibration(Player& player, PlayerBotSurvivalRuntime& runtime); + std::vector runAdaptiveChallenge(Player& player); + std::vector runSpellCalibration(Player& player); std::vector runMagicTraining(Player& player); private: @@ -87,6 +88,7 @@ namespace playerbot { uint32_t forcedNavigationStepFailuresRemaining = 0; uint32_t forcedNavigationPlanFailuresRemaining = 0; bool adaptiveChallengeRun = false; + uint8_t huntPlanningStarts = 0; bool planningCancelled = false; bool planningRevisionInvalidated = false; bool forcedUnreachable = false; diff --git a/server/src/playerbotgoalarbiter.h b/server/src/playerbotgoalarbiter.h index c2f6b7e4..9e482377 100644 --- a/server/src/playerbotgoalarbiter.h +++ b/server/src/playerbotgoalarbiter.h @@ -46,19 +46,20 @@ class PlayerBotGoalArbiter { const GoalCandidate& candidate(TopLevelGoal goal) const; }; - GoalDecision decide(std::vector candidates); - GoalDecision force(GoalCandidate candidate); - void apply(const GoalDecision& decision); - TopLevelGoal activeGoal() const; - void setActiveGoal(TopLevelGoal goal); uint64_t decisionId() const; bool isCoolingDown(TopLevelGoal goal, std::chrono::steady_clock::time_point now) const; - void setCooldown(TopLevelGoal goal, std::chrono::steady_clock::duration duration); static const char* goalName(TopLevelGoal goal); private: + friend class PlayerBotProgressionRuntime; + + GoalDecision decide(std::vector candidates); + GoalDecision force(GoalCandidate candidate); + void apply(const GoalDecision& decision); + void setActiveGoal(TopLevelGoal goal); + void setCooldown(TopLevelGoal goal, std::chrono::steady_clock::duration duration); std::chrono::steady_clock::time_point& cooldownUntil(TopLevelGoal goal); const std::chrono::steady_clock::time_point& cooldownUntil(TopLevelGoal goal) const; diff --git a/server/src/playerbotgoalplanner.cpp b/server/src/playerbotgoalplanner.cpp index b4933f14..c47f85af 100644 --- a/server/src/playerbotgoalplanner.cpp +++ b/server/src/playerbotgoalplanner.cpp @@ -6,6 +6,7 @@ #include "playerbotgoalplanner.h" #include +#include namespace { constexpr int32_t serviceGoalBaseUtility = 400; @@ -47,8 +48,12 @@ PlayerBotGoalArbiter::GoalCandidate PlayerBotGoalPlanner::serviceCandidate(const snapshot.cashAdjustment ? "cash_reserve" : "no_service_need"}; } -PlayerBotGoalArbiter::GoalDecision PlayerBotGoalPlanner::decide(const PlayerBotGoalPlannerSnapshot& snapshot, - PlayerBotGoalArbiter& arbiter) const +PlayerBotGoalArbiter::GoalCandidate PlayerBotGoalPlanner::forcedServiceCandidate(std::string reason) const +{ + return {PlayerBotGoalArbiter::TopLevelGoal::Service, true, criticalHealingServiceUtility, std::move(reason)}; +} + +std::vector PlayerBotGoalPlanner::candidates(const PlayerBotGoalPlannerSnapshot& snapshot) const { using Goal = PlayerBotGoalArbiter::TopLevelGoal; const auto departure = departureCandidate(snapshot); @@ -68,6 +73,6 @@ PlayerBotGoalArbiter::GoalDecision PlayerBotGoalPlanner::decide(const PlayerBotG !snapshot.magicCoolingDown && snapshot.magicTrainingReason.empty(), !snapshot.magicCoolingDown && snapshot.magicTrainingReason.empty() ? magicTrainingGoalUtility : 0, snapshot.magicCoolingDown ? "cooldown" : snapshot.magicTrainingReason.empty() ? "next_tick_overflow" : snapshot.magicTrainingReason}; - return arbiter.decide({departure, service, pickup, spell, equipment, magic, - {Goal::Hunt, true, huntGoalUtility, "autonomous_hunting_available"}}); + return {departure, service, pickup, spell, equipment, magic, + {Goal::Hunt, true, huntGoalUtility, "autonomous_hunting_available"}}; } diff --git a/server/src/playerbotgoalplanner.h b/server/src/playerbotgoalplanner.h index 00dac66a..b1a361fb 100644 --- a/server/src/playerbotgoalplanner.h +++ b/server/src/playerbotgoalplanner.h @@ -40,10 +40,10 @@ struct PlayerBotGoalPlannerSnapshot { class PlayerBotGoalPlanner { public: - PlayerBotGoalArbiter::GoalDecision decide(const PlayerBotGoalPlannerSnapshot& snapshot, - PlayerBotGoalArbiter& arbiter) const; + std::vector candidates(const PlayerBotGoalPlannerSnapshot& snapshot) const; PlayerBotGoalArbiter::GoalCandidate departureCandidate(const PlayerBotGoalPlannerSnapshot& snapshot) const; PlayerBotGoalArbiter::GoalCandidate serviceCandidate(const PlayerBotGoalPlannerSnapshot& snapshot) const; + PlayerBotGoalArbiter::GoalCandidate forcedServiceCandidate(std::string reason) const; }; #endif diff --git a/server/src/playerbothuntplanningsession.cpp b/server/src/playerbothuntplanningsession.cpp index 41ec0747..34f1e4e4 100644 --- a/server/src/playerbothuntplanningsession.cpp +++ b/server/src/playerbothuntplanningsession.cpp @@ -126,13 +126,3 @@ void PlayerBotHuntPlanningSession::refreshSuitableCandidates() suitableCandidateCount = static_cast(std::count_if(scoredRegions.begin(), scoredRegions.end(), [](const PlayerBotHuntRegion& region) { return region.suitable; })); } - -void PlayerBotHuntPlanningSession::rejectSuitableCandidates(const std::string& reason) -{ - for (PlayerBotHuntRegion& region : scoredRegions) { - region.suitable = false; - region.inChallengeBand = false; - region.rejectionReason = reason; - } - refreshSuitableCandidates(); -} diff --git a/server/src/playerbothuntplanningsession.h b/server/src/playerbothuntplanningsession.h index fd38647a..8b6815b4 100644 --- a/server/src/playerbothuntplanningsession.h +++ b/server/src/playerbothuntplanningsession.h @@ -96,10 +96,9 @@ class PlayerBotHuntPlanningSession const std::vector& regions() const { return scoredRegions; } const PlayerBotHuntRegion& region(size_t index) const { return scoredRegions.at(index); } - void rejectSuitableCandidates(const std::string& reason); + private: void refreshSuitableCandidates(); - private: enum class Phase : uint8_t { Scoring, Reachability, diff --git a/server/src/playerbothuntregions.cpp b/server/src/playerbothuntregions.cpp index 88d7dbc9..4b2f1e36 100644 --- a/server/src/playerbothuntregions.cpp +++ b/server/src/playerbothuntregions.cpp @@ -231,11 +231,11 @@ namespace { PlayerBotHuntRegion scoreRegion(Player& player, const PlayerBotHuntPlanningProfile& planningProfile, size_t candidateIndex, const std::set& excludedRegions, const std::map& performance, - uint32_t huntDurationSeconds) + uint32_t huntDurationSeconds, bool& withinPlanningScope) { const PlayerBotCombatProfile& profile = planningProfile.combat; - const uint16_t staminaMinutes = player.getStaminaMinutes(); - const CachedRegion& cached = huntRegionCache.regions[candidateIndex]; + const uint16_t staminaMinutes = player.getStaminaMinutes(); + const CachedRegion& cached = huntRegionCache.regions[candidateIndex]; PlayerBotHuntRegion region; region.floor = cached.floor; region.center = cached.center; @@ -329,13 +329,13 @@ namespace { region.inChallengeBand = region.threatRatio >= region.challengeBandMinimum && region.threatRatio <= region.challengeBandMaximum; region.predictedLethal = playerBotPredictedLethal(planningProfile.currentHealth, worstFightDamage); - region.suitable = !region.predictedLethal && region.threatRatio <= region.challengeBandMaximum && - playerbot::isInsideLocalPlanningArea(templePosition, region.destination) && - geometricDistance <= maximumHuntTravelDistance; + withinPlanningScope = playerbot::isInsideLocalPlanningArea(templePosition, region.destination) && + geometricDistance <= maximumHuntTravelDistance; + region.suitable = !region.predictedLethal && region.threatRatio <= region.challengeBandMaximum && withinPlanningScope; if (excludedRegions.find(region.center) != excludedRegions.end()) { region.suitable = false; region.rejectionReason = "observed_danger_cooldown"; - } else if (!playerbot::isInsideLocalPlanningArea(templePosition, region.destination) || geometricDistance > maximumHuntTravelDistance) { + } else if (!withinPlanningScope) { region.rejectionReason = "travel_distance"; } else if (region.predictedLethal) { region.rejectionReason = "predicted_lethal"; @@ -465,14 +465,17 @@ PlayerBotHuntRegionScan PlayerBotHuntRegionPlanner::beginScan(const Player& play return scan; } -bool PlayerBotHuntRegionPlanner::score(Player& player, const PlayerBotHuntPlanningProfile& profile, uint64_t revision, - size_t candidateIndex, const std::set& excludedRegions, - const std::map& performance, - uint32_t huntDurationSeconds, PlayerBotHuntRegion& region) const +PlayerBotHuntRegionScore PlayerBotHuntRegionPlanner::score(Player& player, const PlayerBotHuntPlanningProfile& profile, + uint64_t revision, size_t candidateIndex, const std::set& excludedRegions, + const std::map& performance, uint32_t huntDurationSeconds) const { + PlayerBotHuntRegionScore observation; if (revision != getCacheRevision() || candidateIndex >= huntRegionCache.regions.size()) { - return false; + return observation; } - region = scoreRegion(player, profile, candidateIndex, excludedRegions, performance, huntDurationSeconds); - return true; + observation.region = scoreRegion(player, profile, candidateIndex, excludedRegions, performance, + huntDurationSeconds, observation.withinPlanningScope); + observation.valid = true; + observation.candidateFactsAvailable = !observation.region.monsters.empty(); + return observation; } diff --git a/server/src/playerbothuntregions.h b/server/src/playerbothuntregions.h index 50b91eb8..04643f0c 100644 --- a/server/src/playerbothuntregions.h +++ b/server/src/playerbothuntregions.h @@ -11,6 +11,7 @@ #ifndef FS_PLAYERBOTHUNTREGIONS_H #define FS_PLAYERBOTHUNTREGIONS_H +#include "playerbotcombatprofile.h" #include "position.h" #include @@ -21,16 +22,6 @@ class Player; -struct PlayerBotCombatProfile { - uint32_t level = 0; - int32_t maximumHealth = 0; - int32_t armor = 0; - int32_t defense = 0; - int32_t attack = 0; - int32_t attackSkill = 0; - float attackFactor = 1.0f; -}; - struct PlayerBotRecoveryPrediction { double spellMinimumHealing = 0; double potionMinimumHealing = 0; @@ -114,6 +105,13 @@ struct PlayerBotHuntRegionScan { std::vector candidateIndices; }; +struct PlayerBotHuntRegionScore { + bool valid = false; + bool candidateFactsAvailable = false; + bool withinPlanningScope = false; + PlayerBotHuntRegion region; +}; + PlayerBotHuntPlanningProfile playerBotHuntPlanningProfile(const Player& player, const PlayerBotCombatProfile& combat, double challengeFrontier); PlayerBotRecoveryPrediction playerBotPredictRecovery(const PlayerBotHuntPlanningProfile& profile, @@ -128,10 +126,10 @@ class PlayerBotHuntRegionPlanner static void invalidateCache(); static uint64_t getCacheRevision(); PlayerBotHuntRegionScan beginScan(const Player& player) const; - bool score(Player& player, const PlayerBotHuntPlanningProfile& profile, uint64_t revision, size_t candidateIndex, - const std::set& excludedRegions, - const std::map& performance, - uint32_t huntDurationSeconds, PlayerBotHuntRegion& region) const; + PlayerBotHuntRegionScore score(Player& player, const PlayerBotHuntPlanningProfile& profile, uint64_t revision, + size_t candidateIndex, const std::set& excludedRegions, + const std::map& performance, + uint32_t huntDurationSeconds) const; }; #endif diff --git a/server/src/playerbothuntruntime.cpp b/server/src/playerbothuntruntime.cpp index 646c9b28..4fd277a1 100644 --- a/server/src/playerbothuntruntime.cpp +++ b/server/src/playerbothuntruntime.cpp @@ -2,91 +2,68 @@ #include "playerbothuntruntime.h" -#include "configmanager.h" -#include "player.h" +#include "playerbotequipmentpolicy.h" -extern ConfigManager g_config; +PlayerBotHuntRuntime::PlayerBotHuntRuntime(std::vector fallbackPatrol) : + fallbackPatrol(std::move(fallbackPatrol)) +{} -namespace { - double projectedHuntStaminaMultiplier(const Player& player, double availableHuntSeconds) - { - const uint16_t staminaMinutes = player.getStaminaMinutes(); - if (staminaMinutes == 0) return 0; - if (!g_config.getBoolean(ConfigManager::STAMINA_SYSTEM)) return 1; - if (staminaMinutes > 2400 && player.isPremium() && availableHuntSeconds > 0) { - const double bonusSeconds = std::min(availableHuntSeconds, std::max(0, staminaMinutes - 2402) * 60.0); - return 1 + 0.5 * bonusSeconds / availableHuntSeconds; - } - return staminaMinutes <= 840 ? 0.5 : 1; - } +PlayerBotHuntPlanningSnapshot PlayerBotHuntRuntime::snapshot(const PlayerBotHuntRuntimePlayerObservation& player, uint64_t revision) +{ + return {player.position, player.level, player.health, player.staminaMinutes, revision, player.excludedRegions}; } -PlayerBotHuntRuntime::PlayerBotHuntRuntime(std::map& sharedCooldowns, - std::vector fallbackPatrol) : - cooldowns(sharedCooldowns), fallbackPatrol(std::move(fallbackPatrol)) -{} - -PlayerBotHuntPlanningSnapshot PlayerBotHuntRuntime::snapshot(const Player& player, uint64_t revision, - std::chrono::steady_clock::time_point now) +bool PlayerBotHuntRuntime::planningStartRequired(std::chrono::steady_clock::time_point now) const { - std::set excluded; - for (auto it = cooldowns.begin(); it != cooldowns.end();) { - if (now >= it->second) it = cooldowns.erase(it); - else { excluded.insert(it->first); ++it; } - } - return {player.getPosition(), player.getLevel(), player.getHealth(), player.getStaminaMinutes(), revision, std::move(excluded)}; + return !planning && now >= scopeReevaluationAfter; } -PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::advancePlanning(Player& player, const char* reason, +PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::advancePlanning(const PlayerBotHuntRuntimePlanningInput& input, std::chrono::steady_clock::time_point now, - uint32_t huntDurationSeconds) + const PlayerBotHuntPlanningObservation& observation) { PlayerBotHuntRuntimeOutcome outcome; - const uint64_t revision = PlayerBotHuntRegionPlanner::getCacheRevision(); - const PlayerBotHuntPlanningSnapshot current = snapshot(player, revision, now); + if (observation.invalidateCacheRevision) { + planning.reset(); + pendingScoreCandidates.clear(); + outcome.staleRevision = true; + outcome.invalidateCache = true; + return outcome; + } + const PlayerBotHuntPlanningSnapshot current = snapshot(input.player, input.cacheRevision); if (!planning && now < scopeReevaluationAfter) { outcome.command = PlayerBotHuntRuntimeCommand::ScopeReevaluationPending; outcome.retryAfter = scopeReevaluationAfter - now; return outcome; } if (planning && planning->invalidated(current)) { - outcome.staleRevision = planning->snapshot().cacheRevision != revision; + outcome.staleRevision = planning->snapshot().cacheRevision != input.cacheRevision; + planning.reset(); + pendingScoreCandidates.clear(); + } + if (planning && observation.cancelAtScoreBarrier && !planning->scoring()) { planning.reset(); + pendingScoreCandidates.clear(); + outcome.command = PlayerBotHuntRuntimeCommand::PlanningCancelled; + return outcome; } if (!planning) { - const PlayerBotHuntRegionScan scan = planner.beginScan(player); - const Item* weapon = player.getWeapon(true); - const PlayerBotCombatProfile combat{player.getLevel(), player.getMaxHealth(), player.getArmor(), player.getDefense(), - weapon ? weapon->getAttack() : 7, weapon ? player.getWeaponSkill(weapon) : player.getSkillLevel(SKILL_FIST), player.getAttackFactor()}; - planning.emplace(PlayerBotHuntPlanningStart{scan, playerBotHuntPlanningProfile(player, combat, policy.challengeFrontier()), - snapshot(player, scan.revision, now), reason, now}); + if (!input.start) return outcome; + PlayerBotHuntPlanningProfile profile = planningProfile(input.start->profile); + planning.emplace(PlayerBotHuntPlanningStart{input.start->scan, std::move(profile), + snapshot(input.player, input.start->scan.revision), input.reason, now}); + plannedHuntDurationSeconds = input.huntDurationSeconds; outcome.command = PlayerBotHuntRuntimeCommand::PlanningStarted; return outcome; } planning->beginTurn(); if (planning->scoring()) { - const auto started = std::chrono::steady_clock::now(); while (const auto work = planning->nextScoringWork(32)) { - PlayerBotHuntRegion region; - if (!planner.score(player, planning->profile(), planning->snapshot().cacheRevision, work->candidateIndex, - planning->snapshot().excludedRegions, policy.regionPerformance(), huntDurationSeconds, region)) { - outcome.staleRevision = true; - planning.reset(); - return outcome; - } - planning->scoreCompleted(std::move(region)); - } - planning->addScoringTime(std::chrono::duration_cast(std::chrono::steady_clock::now() - started).count()); - if (planning->completeScoring() == PlayerBotHuntPlanningProgress::ScoringYield) { - outcome.command = PlayerBotHuntRuntimeCommand::PlanningYield; - return outcome; + pendingScoreCandidates.push_back(work->candidateIndex); + outcome.scoreWork.push_back({work->candidateIndex, planning->profile(), planning->snapshot().cacheRevision, + planning->snapshot().excludedRegions, policy.regionPerformance(), plannedHuntDurationSeconds}); } - if (forceScopeExhaustion) { - planning->rejectSuitableCandidates("fixture_scope_exhausted"); - forceScopeExhaustion = false; - } - outcome.command = PlayerBotHuntRuntimeCommand::PlanningYield; return outcome; } if (const auto work = planning->nextRouteValidationWork(1)) { @@ -97,53 +74,107 @@ PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::advancePlanning(Player& player const auto& regions = planning->regions(); outcome.candidates = regions; + if (!observation.candidatesAvailable) return exhaustScope(now, std::chrono::seconds(1)); if (playerBotHuntScopeExhausted(regions)) { - ++scopeExhaustions; - const bool validationBudget = std::any_of(regions.begin(), regions.end(), [](const auto& region) { return region.rejectionReason == "navigation_node_budget"; }); - outcome.command = PlayerBotHuntRuntimeCommand::ScopeExhausted; - outcome.scopeExhaustionAttempt = scopeExhaustions; - outcome.stopForScopeExhaustion = scopeExhaustions >= 3; - outcome.retryAfter = forceScopeExhaustion ? std::chrono::seconds(1) : std::chrono::seconds(30); - (void) validationBudget; - scopeReevaluationAfter = now + outcome.retryAfter; - planning.reset(); - return outcome; + return exhaustScope(now, std::chrono::seconds(30)); } auto selected = std::max_element(regions.begin(), regions.end(), [](const auto& left, const auto& right) { return playerBotPreferHuntRegion(right, left); }); - activate(*selected, player, now); + activate(*selected, input.player, now); outcome.command = PlayerBotHuntRuntimeCommand::RegionSelected; outcome.selectedRegion = activeRegion; - planning.reset(); scopeExhaustions = 0; scopeReevaluationAfter = {}; return outcome; } -void PlayerBotHuntRuntime::completeRouteWork(Player& player, const PlayerBotHuntRuntimeRouteWork& work, - const PlayerBotNavigationRoutePlan& routePlan, uint32_t huntDurationSeconds) +PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::completeScoreWork(const std::vector& observations, + uint64_t elapsedUs) +{ + PlayerBotHuntRuntimeOutcome outcome; + if (!planning || !planning->scoring() || observations.size() != pendingScoreCandidates.size()) return outcome; + for (size_t index = 0; index < observations.size(); ++index) { + if (!observations[index].valid || observations[index].candidateIndex != pendingScoreCandidates[index]) { + outcome.staleRevision = true; + planning.reset(); + pendingScoreCandidates.clear(); + return outcome; + } + PlayerBotHuntRegion region = observations[index].region; + applyCandidateSuitability(region, observations[index]); + planning->scoreCompleted(std::move(region)); + } + pendingScoreCandidates.clear(); + planning->addScoringTime(elapsedUs); + if (planning->completeScoring() == PlayerBotHuntPlanningProgress::ScoringYield) { + outcome.command = PlayerBotHuntRuntimeCommand::PlanningYield; + return outcome; + } + outcome.command = PlayerBotHuntRuntimeCommand::PlanningScored; + return outcome; +} + +void PlayerBotHuntRuntime::applyCandidateSuitability(PlayerBotHuntRegion& region, + const PlayerBotHuntRuntimeScoreObservation& observation) const +{ + region.challengeFrontier = policy.challengeFrontier(); + region.challengeBandMinimum = std::max(0.0, region.challengeFrontier - 0.05); + region.challengeBandMaximum = region.challengeFrontier + 0.05; + region.inChallengeBand = region.threatRatio >= region.challengeBandMinimum && + region.threatRatio <= region.challengeBandMaximum; + region.suitable = observation.candidateFactsAvailable && !region.predictedLethal && + region.threatRatio <= region.challengeBandMaximum && observation.withinPlanningScope; + if (planning->snapshot().excludedRegions.find(region.center) != planning->snapshot().excludedRegions.end()) { + region.suitable = false; + region.rejectionReason = "observed_danger_cooldown"; + } else if (!observation.withinPlanningScope) { + region.rejectionReason = "travel_distance"; + } else if (region.predictedLethal) { + region.rejectionReason = "predicted_lethal"; + } else if (!region.suitable) { + region.rejectionReason = "challenge_frontier"; + } else { + region.rejectionReason.clear(); + } +} + +PlayerBotHuntRuntimeOutcome PlayerBotHuntRuntime::exhaustScope(std::chrono::steady_clock::time_point now, + std::chrono::steady_clock::duration retryAfter) +{ + PlayerBotHuntRuntimeOutcome outcome; + outcome.candidates = planning->regions(); + ++scopeExhaustions; + outcome.command = PlayerBotHuntRuntimeCommand::ScopeExhausted; + outcome.scopeExhaustionAttempt = scopeExhaustions; + outcome.stopForScopeExhaustion = scopeExhaustions >= 3; + outcome.retryAfter = retryAfter; + scopeReevaluationAfter = now + retryAfter; + planning.reset(); + pendingScoreCandidates.clear(); + return outcome; +} + +void PlayerBotHuntRuntime::completeRouteWork(const PlayerBotHuntRuntimeRouteWork& work, + const PlayerBotHuntRuntimeRouteObservation& route) { if (!planning) return; - double travelSeconds = 0; - for (const PlayerBotNavigationStep& step : routePlan.steps) travelSeconds += step.action == PlayerBotNavigationAction::Move ? player.getStepDuration(step.direction) / 1000.0 : 1.0; - const double available = std::max(0.0, huntDurationSeconds - travelSeconds); - planning->routeValidationCompleted(work.regionIndex, routePlan.metrics.attempted, - routePlan.metrics.result == PlayerBotNavigationResult::Reached, routePlan.metrics.result == PlayerBotNavigationResult::NodeLimit, - routePlan.metrics.expandedNodes, static_cast(routePlan.steps.size()), travelSeconds, - projectedHuntStaminaMultiplier(player, available), huntDurationSeconds); + planning->routeValidationCompleted(work.regionIndex, route.pathfindingCalled, route.reachable, route.nodeLimit, + route.expandedNodes, route.travelSteps, route.estimatedTravelSeconds, + route.staminaExperienceMultiplier, plannedHuntDurationSeconds); planning->completeRouteValidation(); } -void PlayerBotHuntRuntime::activate(PlayerBotHuntRegion region, const Player& player, std::chrono::steady_clock::time_point now) +void PlayerBotHuntRuntime::activate(PlayerBotHuntRegion region, const PlayerBotHuntRuntimePlayerObservation& player, + std::chrono::steady_clock::time_point now) { auto first = std::find(region.patrolPoints.begin(), region.patrolPoints.end(), region.destination); if (first != region.patrolPoints.end()) std::rotate(region.patrolPoints.begin(), first, region.patrolPoints.end()); activeRegion = std::move(region); patrolIndex = 0; huntStarted = now; - huntStartExperience = player.getExperience(); - huntStartLevel = player.getLevel(); + huntStartExperience = player.experience; + huntStartLevel = player.level; policy.resetCombatEvidence(); resetPatrolFailures(); } @@ -161,18 +192,18 @@ bool PlayerBotHuntRuntime::matchesMonster(const std::string& name) const }); } -PlayerBotEquipmentHuntSummary PlayerBotHuntRuntime::scoreEquipmentHunts(Player& player, - const PlayerBotCombatProfile& profile, size_t maximumRegions) const +PlayerBotHuntPlanningProfile PlayerBotHuntRuntime::planningProfile(PlayerBotHuntPlanningProfile profile) const +{ + profile.challengeFrontier = policy.challengeFrontier(); + return profile; +} + +PlayerBotEquipmentHuntSummary PlayerBotHuntRuntime::summarizeEquipmentHunts(const std::vector& regions, + bool truncated) const { PlayerBotEquipmentHuntSummary summary; summary.lowestThreatRatio = std::numeric_limits::max(); - const PlayerBotHuntRegionScan scan = planner.beginScan(player); - const PlayerBotHuntPlanningProfile planningProfile = playerBotHuntPlanningProfile(player, profile, policy.challengeFrontier()); - const uint32_t duration = static_cast(std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS))); - for (size_t candidateIndex : scan.candidateIndices) { - if (summary.evaluatedRegions >= maximumRegions) { summary.truncated = true; break; } - PlayerBotHuntRegion region; - if (!planner.score(player, planningProfile, scan.revision, candidateIndex, {}, policy.regionPerformance(), duration, region)) continue; + for (const PlayerBotHuntRegion& region : regions) { ++summary.evaluatedRegions; summary.lowestThreatRatio = std::min(summary.lowestThreatRatio, region.threatRatio); if (region.suitable) { @@ -180,32 +211,19 @@ PlayerBotEquipmentHuntSummary PlayerBotHuntRuntime::scoreEquipmentHunts(Player& summary.bestProjectedExperience = std::max(summary.bestProjectedExperience, region.projectedExperience); } } + summary.truncated = truncated; if (summary.lowestThreatRatio == std::numeric_limits::max()) summary.lowestThreatRatio = 0; return summary; } -bool PlayerBotHuntRuntime::fixtureScoreRegion(Player& player, const PlayerBotCombatProfile& profile, - PlayerBotHuntRegion& current, PlayerBotHuntRegion& improved) const -{ - const PlayerBotHuntRegionScan scan = planner.beginScan(player); - if (scan.candidateIndices.empty()) return false; - const uint32_t duration = static_cast(std::max(1, g_config.getNumber(ConfigManager::PLAYERBOT_HUNT_DURATION_SECONDS))); - const PlayerBotHuntPlanningProfile before = playerBotHuntPlanningProfile(player, profile, policy.challengeFrontier()); - PlayerBotCombatProfile upgraded = profile; - upgraded.armor += 20; - const PlayerBotHuntPlanningProfile after = playerBotHuntPlanningProfile(player, upgraded, policy.challengeFrontier()); - return planner.score(player, before, scan.revision, scan.candidateIndices.front(), {}, policy.regionPerformance(), duration, current) && - planner.score(player, after, scan.revision, scan.candidateIndices.front(), {}, policy.regionPerformance(), duration, improved); -} - -bool PlayerBotHuntRuntime::dangerObserved(Player& player, std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration cooldown) +std::optional PlayerBotHuntRuntime::dangerObserved(int32_t maximumHealth, + std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration cooldown) { - if (!activeRegion || !policy.observeDanger(player.getMaxHealth(), now - huntStarted)) return false; - cooldowns[activeRegion->center] = now + cooldown; - return true; + if (!activeRegion || !policy.observeDanger(maximumHealth, now - huntStarted)) return std::nullopt; + return {{activeRegion->center, cooldown}}; } -std::optional PlayerBotHuntRuntime::complete(Player& player, const char* reason, +std::optional PlayerBotHuntRuntime::complete(const PlayerBotHuntRuntimePlayerObservation& player, std::chrono::steady_clock::time_point now, uint32_t configuredDurationSeconds) { @@ -213,21 +231,23 @@ std::optional PlayerBotHuntRuntime::complete(Pla PlayerBotHuntRuntimeCompletion result; result.region = *activeRegion; result.durationSeconds = static_cast(std::max(0, std::chrono::duration_cast(now - huntStarted).count())); - result.experienceGained = player.getExperience() >= huntStartExperience ? player.getExperience() - huntStartExperience : 0; + result.experienceGained = player.experience >= huntStartExperience ? player.experience - huntStartExperience : 0; result.levelBefore = huntStartLevel; result.combat = policy.combatSummary(); result.performance = policy.observePerformance(activeRegion->center, {result.durationSeconds, result.combat.kills, result.experienceGained, activeRegion->projectedExperience, activeRegion->observedCorrection, configuredDurationSeconds}); - result.challenge = policy.updateChallengeFrontier({result.durationSeconds, player.getMaxHealth()}); + result.challenge = policy.updateChallengeFrontier({result.durationSeconds, player.maximumHealth}); activeRegion.reset(); policy.resetCombatEvidence(); return result; } -void PlayerBotHuntRuntime::observeDeath(bool activeCombat, std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration cooldown) +std::optional PlayerBotHuntRuntime::observeDeath(bool activeCombat, + std::chrono::steady_clock::duration cooldown) { if (activeCombat) policy.observeDeath(); - if (activeRegion) cooldowns[activeRegion->center] = now + cooldown; + if (!activeRegion) return std::nullopt; + return {{activeRegion->center, cooldown}}; } PlayerBotHuntPatrolOutcome PlayerBotHuntRuntime::patrolTarget() const @@ -274,7 +294,7 @@ PlayerBotHuntPatrolOutcome PlayerBotHuntRuntime::observePatrolNavigation(const P activeRegion->patrolPoints.erase(activeRegion->patrolPoints.begin() + patrolIndex); if (activeRegion->patrolPoints.empty()) { outcome.command = PlayerBotHuntPatrolCommand::RegionExhausted; - cooldowns[activeRegion->center] = now + std::chrono::minutes(10); + outcome.cooldown = {{activeRegion->center, std::chrono::minutes(10)}}; } else patrolIndex %= activeRegion->patrolPoints.size(); } else if (!fallbackPatrol.empty()) patrolIndex = (patrolIndex + 1) % fallbackPatrol.size(); resetPatrolFailures(); diff --git a/server/src/playerbothuntruntime.h b/server/src/playerbothuntruntime.h index 1830c627..2f6e9c71 100644 --- a/server/src/playerbothuntruntime.h +++ b/server/src/playerbothuntruntime.h @@ -7,18 +7,24 @@ #include "playerbothuntplanningsession.h" #include "playerbothuntpolicy.h" -#include "playerbotequipmentpolicy.h" #include "playerbotnavigationruntime.h" +#include +#include #include #include +#include +#include +#include -class Player; +struct PlayerBotEquipmentHuntSummary; enum class PlayerBotHuntRuntimeCommand : uint8_t { None, PlanningStarted, PlanningYield, + PlanningScored, + PlanningCancelled, RouteValidationRequested, RegionSelected, ScopeExhausted, @@ -30,17 +36,82 @@ struct PlayerBotHuntRuntimeRouteWork { Position destination; }; +struct PlayerBotHuntRuntimePlayerObservation { + Position position; + uint32_t level = 0; + int32_t health = 0; + int32_t maximumHealth = 0; + uint16_t staminaMinutes = 0; + uint64_t experience = 0; + std::set excludedRegions; +}; + +struct PlayerBotHuntRuntimeCooldownCommand { + Position region; + std::chrono::steady_clock::duration duration{}; +}; + +struct PlayerBotHuntRuntimePlanningStartObservation { + PlayerBotHuntRegionScan scan; + PlayerBotHuntPlanningProfile profile; +}; + +struct PlayerBotHuntRuntimePlanningInput { + PlayerBotHuntRuntimePlayerObservation player; + uint64_t cacheRevision = 0; + uint32_t huntDurationSeconds = 0; + std::string reason; + std::optional start; +}; + +struct PlayerBotHuntRuntimeScoreWork { + size_t candidateIndex = 0; + PlayerBotHuntPlanningProfile profile; + uint64_t cacheRevision = 0; + std::set excludedRegions; + std::map performance; + uint32_t huntDurationSeconds = 0; +}; + +struct PlayerBotHuntRuntimeScoreObservation { + size_t candidateIndex = 0; + bool valid = false; + bool candidateFactsAvailable = false; + bool withinPlanningScope = false; + PlayerBotHuntRegion region; +}; + +struct PlayerBotHuntRuntimeRouteObservation { + bool pathfindingCalled = false; + bool reachable = false; + bool nodeLimit = false; + uint64_t expandedNodes = 0; + uint32_t travelSteps = 0; + double estimatedTravelSeconds = 0; + double staminaExperienceMultiplier = 0; +}; + struct PlayerBotHuntRuntimeOutcome { PlayerBotHuntRuntimeCommand command = PlayerBotHuntRuntimeCommand::None; std::optional routeWork; + std::vector scoreWork; std::optional selectedRegion; std::vector candidates; bool staleRevision = false; + bool invalidateCache = false; bool stopForScopeExhaustion = false; std::chrono::steady_clock::duration retryAfter{}; uint32_t scopeExhaustionAttempt = 0; }; +// Callers supply fixture inputs as immutable planning observations. Runtime owns +// the resulting session and cache state transitions. +struct PlayerBotHuntPlanningObservation { + bool candidatesAvailable = true; + bool cancelAtScoreBarrier = false; + bool invalidateCacheRevision = false; +}; + struct PlayerBotHuntRuntimeCompletion { PlayerBotHuntRegion region; PlayerBotHuntCombatSummary combat; @@ -68,28 +139,37 @@ struct PlayerBotHuntPatrolOutcome { uint32_t routeFailures = 0; uint64_t elapsedMs = 0; uint64_t expandedNodes = 0; + std::optional cooldown; }; class PlayerBotHuntRuntime { public: - PlayerBotHuntRuntime(std::map& sharedCooldowns, - std::vector fallbackPatrol); + explicit PlayerBotHuntRuntime(std::vector fallbackPatrol); - PlayerBotHuntRuntimeOutcome advancePlanning(Player& player, const char* reason, + bool planningStartRequired(std::chrono::steady_clock::time_point now) const; + PlayerBotHuntRuntimeOutcome advancePlanning(const PlayerBotHuntRuntimePlanningInput& input, std::chrono::steady_clock::time_point now, - uint32_t huntDurationSeconds); - void completeRouteWork(Player& player, const PlayerBotHuntRuntimeRouteWork& work, - const PlayerBotNavigationRoutePlan& routePlan, uint32_t huntDurationSeconds); - void cancelPlanning() { planning.reset(); } + const PlayerBotHuntPlanningObservation& observation = {}); + PlayerBotHuntRuntimeOutcome completeScoreWork(const std::vector& observations, + uint64_t elapsedUs); + void completeRouteWork(const PlayerBotHuntRuntimeRouteWork& work, + const PlayerBotHuntRuntimeRouteObservation& route); + // Keep the completed session through final telemetry, then release it. + void completePlanningSelection() { planning.reset(); pendingScoreCandidates.clear(); } + void cancelPlanning() { planning.reset(); pendingScoreCandidates.clear(); } bool planningActive() const { return planning.has_value(); } - const PlayerBotHuntPlanningSession* planningSession() const { return planning ? &*planning : nullptr; } + std::optional planningSession() const + { + if (planning) return *planning; + return std::nullopt; + } void beginCycle(std::chrono::steady_clock::time_point now, uint32_t durationSeconds); bool deadlineReached(std::chrono::steady_clock::time_point now) const { return huntDeadline != std::chrono::steady_clock::time_point{} && now >= huntDeadline; } uint32_t completedCycles() const { return cycles; } bool active() const { return activeRegion.has_value(); } - const PlayerBotHuntRegion* region() const { return activeRegion ? &*activeRegion : nullptr; } + std::optional region() const { return activeRegion; } bool matchesMonster(const std::string& name) const; void sampleCombat(const PlayerBotHuntCombatSnapshot& snapshot) { policy.sampleCombat(snapshot); } @@ -97,30 +177,17 @@ class PlayerBotHuntRuntime void observeRecovery(bool potion) { policy.observeRecovery(potion); } void observeKill() { policy.observeKill(); } bool observeDanger(int32_t maximumHealth, std::chrono::steady_clock::duration age) { return policy.observeDanger(maximumHealth, age); } - bool dangerObserved(Player& player, std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration cooldown); - std::optional complete(Player& player, const char* reason, + std::optional dangerObserved(int32_t maximumHealth, + std::chrono::steady_clock::time_point now, + std::chrono::steady_clock::duration cooldown); + std::optional complete(const PlayerBotHuntRuntimePlayerObservation& player, std::chrono::steady_clock::time_point now, uint32_t configuredDurationSeconds); - void observeDeath(bool activeCombat, std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration cooldown); - const PlayerBotHuntPolicy& huntPolicy() const { return policy; } - PlayerBotEquipmentHuntSummary scoreEquipmentHunts(Player& player, const PlayerBotCombatProfile& profile, - size_t maximumRegions) const; - - // Explicit fixture hooks. Production code never obtains mutable policy or planner access. - void fixtureResetCombatEvidence() { policy.resetCombatEvidence(); } - void fixtureObserveCombat(const PlayerBotHuntCombatSample& sample) { policy.observeCombat(sample); } - void fixtureObserveKill() { policy.observeKill(); } - void fixtureObserveRecovery(bool potion) { policy.observeRecovery(potion); } - void fixtureObserveDeath() { policy.observeDeath(); } - PlayerBotHuntCombatSummary fixtureCombatSummary() const { return policy.combatSummary(); } - PlayerBotHuntChallengeUpdate fixtureUpdateChallenge(uint64_t seconds, int32_t maximumHealth) - { - return policy.updateChallengeFrontier({seconds, maximumHealth}); - } - bool fixtureScoreRegion(Player& player, const PlayerBotCombatProfile& profile, - PlayerBotHuntRegion& current, PlayerBotHuntRegion& improved) const; - void fixtureCancelPlanning() { cancelPlanning(); } - void fixtureInvalidatePlanningRevision() { PlayerBotHuntRegionPlanner::invalidateCache(); } + std::optional observeDeath(bool activeCombat, + std::chrono::steady_clock::duration cooldown); + PlayerBotHuntPlanningProfile planningProfile(PlayerBotHuntPlanningProfile profile) const; + std::map regionPerformance() const { return policy.regionPerformance(); } + PlayerBotEquipmentHuntSummary summarizeEquipmentHunts(const std::vector& regions, bool truncated) const; PlayerBotHuntPatrolOutcome patrolTarget() const; PlayerBotHuntPatrolOutcome observePatrolNavigation(const PlayerBotNavigationRuntimeOutcome& navigation, @@ -128,19 +195,17 @@ class PlayerBotHuntRuntime uint32_t repeatedStepLimit, uint32_t routeFailureLimit); void resetPatrolFailures(); - // Fixture-only hooks keep test mutation at the runtime boundary. - void forceScopeExhaustionForTest() { forceScopeExhaustion = true; } - private: - PlayerBotHuntPlanningSnapshot snapshot(const Player& player, uint64_t revision, - std::chrono::steady_clock::time_point now); - void activate(PlayerBotHuntRegion region, const Player& player, std::chrono::steady_clock::time_point now); + PlayerBotHuntPlanningSnapshot snapshot(const PlayerBotHuntRuntimePlayerObservation& player, uint64_t revision); + PlayerBotHuntRuntimeOutcome exhaustScope(std::chrono::steady_clock::time_point now, + std::chrono::steady_clock::duration retryAfter); + void applyCandidateSuitability(PlayerBotHuntRegion& region, const PlayerBotHuntRuntimeScoreObservation& observation) const; + void activate(PlayerBotHuntRegion region, const PlayerBotHuntRuntimePlayerObservation& player, + std::chrono::steady_clock::time_point now); - PlayerBotHuntRegionPlanner planner; std::optional planning; PlayerBotHuntPolicy policy; std::optional activeRegion; - std::map& cooldowns; std::vector fallbackPatrol; std::chrono::steady_clock::time_point scopeReevaluationAfter; std::chrono::steady_clock::time_point huntStarted; @@ -154,7 +219,8 @@ class PlayerBotHuntRuntime uint32_t cycles = 0; uint64_t huntStartExperience = 0; uint32_t huntStartLevel = 0; - bool forceScopeExhaustion = false; + uint32_t plannedHuntDurationSeconds = 0; + std::vector pendingScoreCandidates; }; #endif diff --git a/server/src/playerbotlootsession.cpp b/server/src/playerbotlootsession.cpp index 28c26d88..0490e126 100644 --- a/server/src/playerbotlootsession.cpp +++ b/server/src/playerbotlootsession.cpp @@ -40,7 +40,6 @@ void PlayerBotLootSession::reset() navigationSuspensionCount = 0; started = {}; retryAt = {}; - selectedLoot.reset(); pendingLoot.reset(); pendingDiscard.reset(); unavailableItems.clear(); @@ -106,7 +105,6 @@ PlayerBotLootNavigationTransition PlayerBotLootSession::observeNavigationFailure void PlayerBotLootSession::beginLootMove(PlayerBotLootMove move) { - selectedLoot = move; pendingLoot = move; } diff --git a/server/src/playerbotlootsession.h b/server/src/playerbotlootsession.h index 8b02046b..3a5cc4ca 100644 --- a/server/src/playerbotlootsession.h +++ b/server/src/playerbotlootsession.h @@ -12,6 +12,8 @@ #include "playerbottargetingsession.h" +class PlayerBotLootWorkflow; + enum class PlayerBotLootNavigationTransition : uint8_t { None, Resumed, @@ -50,10 +52,6 @@ struct PlayerBotLootDiscardVerification { class PlayerBotLootSession { public: - void begin(uint32_t targetId, const Position& deathPosition, PlayerBotExpectedCorpse expectedCorpse, - const Position& currentPosition, std::chrono::steady_clock::time_point now); - void reset(); - uint32_t targetId() const { return target; } const PlayerBotExpectedCorpse& expectedCorpse() const { return expectation; } const Position& deathPosition() const { return lastKnownDeathPosition; } @@ -61,25 +59,40 @@ class PlayerBotLootSession uint16_t observedCorpseItemId() const { return observedItemId; } uint32_t observedCorpseOwnerId() const { return observedOwnerId; } bool corpseObserved() const { return observed; } - void observeCorpse(uint16_t itemId, uint32_t ownerId, const Position& position); bool corpseContainerOpen() const { return corpseOpen; } - void setCorpseContainerOpen(bool open) { corpseOpen = open; } bool backpackContainerOpen() const { return backpackOpen; } - void setBackpackContainerOpen(bool open) { backpackOpen = open; } - uint32_t incrementSearchAttempts() { return ++searchAttemptCount; } - uint32_t incrementOpenAttempts() { return ++openAttemptCount; } uint32_t searchAttempts() const { return searchAttemptCount; } uint32_t openAttempts() const { return openAttemptCount; } bool lootedCurrentCorpse() const { return looted; } - void markLooted() { looted = true; } bool timedOut(std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration timeout) const; int64_t elapsedMilliseconds(std::chrono::steady_clock::time_point now) const; bool navigationSuspended() const { return navigationPaused; } uint32_t navigationFailures() const { return navigationFailureCount; } uint32_t navigationSuspensions() const { return navigationSuspensionCount; } + std::chrono::steady_clock::time_point navigationRetryAt() const { return retryAt; } + + bool hasPendingLootMove() const { return pendingLoot.has_value(); } + const std::optional& pendingLootMove() const { return pendingLoot; } + + bool hasPendingDiscardMove() const { return pendingDiscard.has_value(); } + const std::optional& pendingDiscardMove() const { return pendingDiscard; } + + bool lootItemUnavailable(uint16_t itemId) const; + const std::set& unavailableLootItems() const { return unavailableItems; } + + private: + friend class PlayerBotLootWorkflow; + + void begin(uint32_t targetId, const Position& deathPosition, PlayerBotExpectedCorpse expectedCorpse, + const Position& currentPosition, std::chrono::steady_clock::time_point now); + void reset(); + void observeCorpse(uint16_t itemId, uint32_t ownerId, const Position& position); + uint32_t incrementSearchAttempts() { return ++searchAttemptCount; } + uint32_t incrementOpenAttempts() { return ++openAttemptCount; } + void markLooted() { looted = true; } PlayerBotLootNavigationTransition resumeNavigation(const Position& currentPosition, std::chrono::steady_clock::time_point now); PlayerBotLootNavigationTransition observeNavigationFailure(const Position& currentPosition, @@ -87,25 +100,12 @@ class PlayerBotLootSession uint32_t maximumFailures, uint32_t suspendThreshold, std::chrono::milliseconds retryInterval); - std::chrono::steady_clock::time_point navigationRetryAt() const { return retryAt; } - - bool hasPendingLootMove() const { return pendingLoot.has_value(); } - const std::optional& pendingLootMove() const { return pendingLoot; } - const std::optional& selectedLootMove() const { return selectedLoot; } - void selectLootMove(PlayerBotLootMove move) { selectedLoot = move; } void beginLootMove(PlayerBotLootMove move); std::optional verifyLootMove(uint32_t inventoryCount); - - bool hasPendingDiscardMove() const { return pendingDiscard.has_value(); } - const std::optional& pendingDiscardMove() const { return pendingDiscard; } void beginDiscardMove(PlayerBotLootDiscardMove move); std::optional verifyDiscardMove(uint32_t inventoryCount); - - bool lootItemUnavailable(uint16_t itemId) const; - const std::set& unavailableLootItems() const { return unavailableItems; } void suppressLootItem(uint16_t itemId); - private: uint32_t target = 0; PlayerBotExpectedCorpse expectation; Position lastKnownDeathPosition; @@ -120,7 +120,6 @@ class PlayerBotLootSession uint32_t navigationSuspensionCount = 0; std::chrono::steady_clock::time_point started; std::chrono::steady_clock::time_point retryAt; - std::optional selectedLoot; std::optional pendingLoot; std::optional pendingDiscard; std::set unavailableItems; diff --git a/server/src/playerbotnavigationruntime.cpp b/server/src/playerbotnavigationruntime.cpp index 0d7f6280..9a284bf8 100644 --- a/server/src/playerbotnavigationruntime.cpp +++ b/server/src/playerbotnavigationruntime.cpp @@ -12,29 +12,13 @@ #include "playerbotnavigationruntime.h" -#include "player.h" - -PlayerBotNavigationRoutePlan PlayerBotNavigationRuntime::plan(Player& player, const Position& destination, - const std::set& blockedPositions, - uint64_t maximumExpandedNodes) const -{ - PlayerBotNavigationRoutePlan routePlan; - routePlan.metrics.attempted = true; - const auto startedAt = std::chrono::steady_clock::now(); - routePlan.metrics.result = navigator.plan(player, destination, blockedPositions, routePlan.steps, - routePlan.metrics.expandedNodes, maximumExpandedNodes); - routePlan.metrics.elapsed = std::chrono::duration_cast( - std::chrono::steady_clock::now() - startedAt); - routePlan.metrics.steps = routePlan.steps.size(); - return routePlan; -} - PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::process(const PlayerBotNavigationRuntimeInput& input) { PlayerBotNavigationRuntimeOutcome outcome; if (input.currentPosition == input.destination) { session.clear(); outcome.destinationReached = true; + outcome.command = PlayerBotNavigationRuntimeCommand::None; outcome.stepFailureCount = session.stepFailureCount(); return outcome; } @@ -42,6 +26,7 @@ PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::process(const Play if (const auto oscillation = session.observeProgress(input.currentPosition, input.destination, input.timing.now, input.timing.oscillationSuppression)) { outcome.oscillation = oscillation; + outcome.command = PlayerBotNavigationRuntimeCommand::Retry; outcome.stepFailureCount = session.stepFailureCount(); return outcome; } @@ -49,6 +34,7 @@ PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::process(const Play outcome.movementResult = session.observeMovement(input.currentPosition, input.actionPending, input.timing.now, input.timing.stepTimeout, input.timing.blockSuppression); if (outcome.movementResult == PlayerBotPendingMovementResult::Waiting) { + outcome.command = PlayerBotNavigationRuntimeCommand::Retry; outcome.stepFailureCount = session.stepFailureCount(); return outcome; } @@ -57,41 +43,70 @@ PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::process(const Play session.prepareDestination(input.destination); if (session.routeEmpty()) { outcome.blockedPositions = session.activeBlockedPositions(input.timing.now); - PlayerBotNavigationRoutePlan routePlan; - if (input.forcePlanFailure) { - routePlan.metrics.attempted = true; - routePlan.metrics.result = PlayerBotNavigationResult::Unreachable; - routePlan.metrics.expandedNodes = playerBotNavigationMaximumExpandedNodes; - } else { - routePlan = plan(input.player, input.destination, outcome.blockedPositions); - } - outcome.plan = routePlan.metrics; - if (routePlan.metrics.result != PlayerBotNavigationResult::Reached || routePlan.steps.empty()) { - outcome.routeUnavailable = true; - if (outcome.blockedPositions.empty()) ++fixedTargetRouteFailures; - outcome.fixedTargetRouteFailures = fixedTargetRouteFailures; - outcome.fixedTargetRouteExhausted = fixedTargetRouteFailures >= 20; - outcome.stepFailureCount = session.stepFailureCount(); - return outcome; - } - fixedTargetRouteFailures = 0; - // Keep the old route active until the complete replacement route has been planned. - session.installRoute(input.destination, std::move(routePlan.steps)); + outcome.routeRequest = {input.destination, outcome.blockedPositions, playerBotNavigationMaximumExpandedNodes}; + outcome.command = PlayerBotNavigationRuntimeCommand::Plan; + outcome.stepFailureCount = session.stepFailureCount(); + return outcome; } - if (input.canDoAction && !session.routeEmpty()) { - outcome.nextStep = session.nextStep(); + dispatchNextStep(input.canDoAction, outcome); + outcome.stepFailureCount = session.stepFailureCount(); + return outcome; +} + +PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::observePlan(PlayerBotNavigationPlanObservation observation) +{ + PlayerBotNavigationRuntimeOutcome outcome; + outcome.plan = observation.plan.metrics; + if (observation.plan.metrics.result != PlayerBotNavigationResult::Reached || + (!observation.startsNavigation && observation.plan.steps.empty())) { + outcome.routeUnavailable = true; + if (session.activeBlockedPositions(observation.now).empty()) ++fixedTargetRouteFailures; + outcome.fixedTargetRouteFailures = fixedTargetRouteFailures; + outcome.fixedTargetRouteExhausted = fixedTargetRouteFailures >= 20; + outcome.command = outcome.fixedTargetRouteExhausted ? PlayerBotNavigationRuntimeCommand::Fail : + PlayerBotNavigationRuntimeCommand::Retry; + outcome.stepFailureCount = session.stepFailureCount(); + return outcome; } + fixedTargetRouteFailures = 0; + // A planned route replaces existing work only after planning completed. + if (observation.startsNavigation) session.adopt(observation.destination, std::move(observation.plan.steps)); + else session.installRoute(observation.destination, std::move(observation.plan.steps)); + dispatchNextStep(observation.canDoAction, outcome); + outcome.stepFailureCount = session.stepFailureCount(); + return outcome; +} + +PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::observeStep(const PlayerBotNavigationStepObservation& observation) +{ + PlayerBotNavigationRuntimeOutcome outcome; + if (observation.result == PlayerBotNavigationStepResult::Rejected) { + session.clearRoute(); + } else if (observation.step.action == PlayerBotNavigationAction::UseDoor || observation.step.action == PlayerBotNavigationAction::UseShovel) { + session.beginWorldChange(observation.step); + } else { + session.beginMovement(observation.step, observation.now); + } + outcome.stepFailureCount = session.stepFailureCount(); + return outcome; +} + +PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::observeWorldChange(const PlayerBotNavigationWorldChangeObservation& observation) +{ + if (observation.unchanged) session.suppress(observation.step.target, observation.now + observation.suppression); + PlayerBotNavigationRuntimeOutcome outcome; outcome.stepFailureCount = session.stepFailureCount(); return outcome; } -void PlayerBotNavigationRuntime::completeStep(const PlayerBotNavigationStep& step, - std::chrono::steady_clock::time_point now) +void PlayerBotNavigationRuntime::dispatchNextStep(bool canDoAction, PlayerBotNavigationRuntimeOutcome& outcome) const { - if (step.action == PlayerBotNavigationAction::UseDoor || step.action == PlayerBotNavigationAction::UseShovel) { - session.beginWorldChange(step); + if (!canDoAction || session.routeEmpty()) { + outcome.command = PlayerBotNavigationRuntimeCommand::Retry; return; } - session.beginMovement(step, now); + outcome.nextStep = session.nextStep(); + outcome.command = outcome.nextStep->action == PlayerBotNavigationAction::Move ? + PlayerBotNavigationRuntimeCommand::Move : PlayerBotNavigationRuntimeCommand::Use; } diff --git a/server/src/playerbotnavigationruntime.h b/server/src/playerbotnavigationruntime.h index 3ab5123a..c249666e 100644 --- a/server/src/playerbotnavigationruntime.h +++ b/server/src/playerbotnavigationruntime.h @@ -16,8 +16,6 @@ #include #include -class Player; - struct PlayerBotNavigationPlanMetrics { PlayerBotNavigationResult result = PlayerBotNavigationResult::Unreachable; uint64_t expandedNodes = 0; @@ -38,13 +36,26 @@ struct PlayerBotNavigationRuntimeTiming { std::chrono::steady_clock::duration oscillationSuppression; }; +enum class PlayerBotNavigationRuntimeCommand : uint8_t { + None, + Plan, + Move, + Use, + Retry, + Fail, +}; + +struct PlayerBotNavigationRouteRequest { + Position destination; + std::set blockedPositions; + uint64_t maximumExpandedNodes = playerBotNavigationMaximumExpandedNodes; +}; + struct PlayerBotNavigationRuntimeInput { - Player& player; Position currentPosition; Position destination; bool actionPending = false; bool canDoAction = false; - bool forcePlanFailure = false; PlayerBotNavigationRuntimeTiming timing; }; @@ -59,22 +70,49 @@ struct PlayerBotNavigationRuntimeOutcome { bool routeUnavailable = false; uint32_t fixedTargetRouteFailures = 0; bool fixedTargetRouteExhausted = false; + PlayerBotNavigationRuntimeCommand command = PlayerBotNavigationRuntimeCommand::None; + std::optional routeRequest; std::optional nextStep; }; +// Planner and dispatcher results are immutable observations. The runtime owns +// all resulting route and pending-step state transitions. +struct PlayerBotNavigationPlanObservation { + Position destination; + PlayerBotNavigationRoutePlan plan; + bool canDoAction = false; + bool startsNavigation = false; + std::chrono::steady_clock::time_point now; +}; + +enum class PlayerBotNavigationStepResult : uint8_t { + Dispatched, + Rejected, +}; + +struct PlayerBotNavigationStepObservation { + PlayerBotNavigationStep step; + PlayerBotNavigationStepResult result = PlayerBotNavigationStepResult::Rejected; + std::chrono::steady_clock::time_point now; +}; + +struct PlayerBotNavigationWorldChangeObservation { + PlayerBotNavigationStep step; + bool unchanged = false; + std::chrono::steady_clock::time_point now; + std::chrono::steady_clock::duration suppression; +}; + class PlayerBotNavigationRuntime { public: - PlayerBotNavigationRoutePlan plan(Player& player, const Position& destination, - const std::set& blockedPositions = {}, - uint64_t maximumExpandedNodes = playerBotNavigationMaximumExpandedNodes) const; PlayerBotNavigationRuntimeOutcome process(const PlayerBotNavigationRuntimeInput& input); + PlayerBotNavigationRuntimeOutcome observePlan(PlayerBotNavigationPlanObservation observation); + PlayerBotNavigationRuntimeOutcome observeStep(const PlayerBotNavigationStepObservation& observation); + PlayerBotNavigationRuntimeOutcome observeWorldChange(const PlayerBotNavigationWorldChangeObservation& observation); - void clear() { session.clear(); fixedTargetRouteFailures = 0; } - void adopt(const Position& destination, std::deque steps) { session.adopt(destination, std::move(steps)); } - void rejectNextStep() { session.clearRoute(); } - void completeStep(const PlayerBotNavigationStep& step, std::chrono::steady_clock::time_point now); - void suppress(const Position& position, std::chrono::steady_clock::time_point expires) { session.suppress(position, expires); } + void reset() { session.clear(); fixedTargetRouteFailures = 0; } + void resetPatrolRecovery() { session.clearBlockedPositions(); session.resetStepFailures(); } size_t routeSize() const { return session.routeSize(); } bool hasPendingWork() const { return session.hasPendingWork(); } @@ -84,12 +122,8 @@ class PlayerBotNavigationRuntime bool oscillationDetected() const { return session.oscillationDetected(); } uint32_t stepFailureCount() const { return session.stepFailureCount(); } uint32_t fixedTargetRouteFailureCount() const { return fixedTargetRouteFailures; } - void resetFixedTargetRouteFailures() { fixedTargetRouteFailures = 0; } - void clearBlockedPositions() { session.clearBlockedPositions(); } - void resetStepFailures() { session.resetStepFailures(); } - private: - PlayerBotNavigator navigator; + void dispatchNextStep(bool canDoAction, PlayerBotNavigationRuntimeOutcome& outcome) const; PlayerBotNavigationSession session; uint32_t fixedTargetRouteFailures = 0; }; diff --git a/server/src/playerbotnpcsession.cpp b/server/src/playerbotnpcsession.cpp index bae5d134..29dbf8ca 100644 --- a/server/src/playerbotnpcsession.cpp +++ b/server/src/playerbotnpcsession.cpp @@ -10,11 +10,9 @@ #include "otpch.h" +#include "const.h" #include "playerbotnpcsession.h" -#include "player.h" -#include "npc.h" - namespace { constexpr uint32_t npcReplyDelay = 1000; } @@ -42,48 +40,27 @@ bool PlayerBotNpcSession::retryLimitReached(uint32_t maximumRetries) return ++retries >= maximumRetries; } -PlayerBotNpcSessionOutcome PlayerBotNpcSession::establishFocus(Player& player, Npc& npc, uint32_t maximumRetries) +PlayerBotNpcSessionOutcome PlayerBotNpcSession::advanceShop(const PlayerBotNpcShopObservation& observation, uint32_t maximumRetries) { - if (conversationStep == PlayerBotNpcConversationStep::Greet) { - npc.receiveSpeech(&player, TALKTYPE_PRIVATE_PN, "hi"); - conversationStep = PlayerBotNpcConversationStep::Request; - pendingDelay = npcReplyDelay; - return {PlayerBotNpcSessionResult::Pending, 1}; - } - if (conversationStep != PlayerBotNpcConversationStep::Request || greetingAcknowledged) { - return {PlayerBotNpcSessionResult::Ready, 0}; - } - if (retryLimitReached(maximumRetries)) { - return {PlayerBotNpcSessionResult::Failed, 0}; - } - conversationStep = PlayerBotNpcConversationStep::Greet; - pendingDelay = npcReplyDelay; - return {PlayerBotNpcSessionResult::Pending, 0}; -} - -PlayerBotNpcSessionOutcome PlayerBotNpcSession::openShop(Player& player, Npc& npc, uint32_t maximumRetries) -{ - int32_t onBuy; - int32_t onSell; - Npc* shopOwner = player.getShopOwner(onBuy, onSell); - if (shopOwner == &npc && !player.getShopItemList().empty()) { + if (observation.shopOpen) { if (conversationStep != PlayerBotNpcConversationStep::Verify) { conversationStep = PlayerBotNpcConversationStep::Ready; retries = 0; } return {PlayerBotNpcSessionResult::Ready, 0}; } - if (shopOwner && shopOwner != &npc && conversationStep == PlayerBotNpcConversationStep::Greet) { - player.closeShopWindow(false); + if (conversationStep == PlayerBotNpcConversationStep::Greet) { + conversationStep = PlayerBotNpcConversationStep::Request; + pendingDelay = npcReplyDelay; + return {PlayerBotNpcSessionResult::Pending, 1}; } - - if (conversationStep == PlayerBotNpcConversationStep::Greet || - conversationStep == PlayerBotNpcConversationStep::Request) { - const PlayerBotNpcSessionOutcome focus = establishFocus(player, npc, maximumRetries); - if (focus.result != PlayerBotNpcSessionResult::Ready) { - return focus; + if (conversationStep == PlayerBotNpcConversationStep::Request) { + if (!observation.greetingAcknowledged) { + if (retryLimitReached(maximumRetries)) return {PlayerBotNpcSessionResult::Failed, 0}; + conversationStep = PlayerBotNpcConversationStep::Greet; + pendingDelay = npcReplyDelay; + return {PlayerBotNpcSessionResult::Pending, 0}; } - npc.receiveSpeech(&player, TALKTYPE_PRIVATE_PN, "trade"); conversationStep = PlayerBotNpcConversationStep::Ready; pendingDelay = npcReplyDelay; return {PlayerBotNpcSessionResult::Pending, 1}; diff --git a/server/src/playerbotnpcsession.h b/server/src/playerbotnpcsession.h index 13e65e68..59749213 100644 --- a/server/src/playerbotnpcsession.h +++ b/server/src/playerbotnpcsession.h @@ -13,9 +13,6 @@ #include -class Npc; -class Player; - enum class PlayerBotNpcSessionResult : uint8_t { Pending, Ready, @@ -27,6 +24,12 @@ struct PlayerBotNpcSessionOutcome { uint8_t actionsIssued = 0; }; +struct PlayerBotNpcShopObservation { + bool shopOpen = false; + bool otherShopOpen = false; + bool greetingAcknowledged = false; +}; + enum class PlayerBotNpcConversationStep : uint8_t { Greet, Request, @@ -47,16 +50,18 @@ class PlayerBotNpcSession void resetGreetingAcknowledgement() { greetingAcknowledged = false; } bool isGreetingAcknowledged() const { return greetingAcknowledged; } + private: + friend class PlayerBotServiceWorkflow; + friend class PlayerBotProgressionRuntime; + PlayerBotNpcConversationStep step() const { return conversationStep; } void setStep(PlayerBotNpcConversationStep step) { conversationStep = step; } void resetRetries() { retries = 0; } bool retryLimitReached(uint32_t maximumRetries); - PlayerBotNpcSessionOutcome establishFocus(Player& player, Npc& npc, uint32_t maximumRetries); - PlayerBotNpcSessionOutcome openShop(Player& player, Npc& npc, uint32_t maximumRetries); + PlayerBotNpcSessionOutcome advanceShop(const PlayerBotNpcShopObservation& observation, uint32_t maximumRetries); uint32_t nextDelay() const { return pendingDelay; } - private: uint32_t targetNpcId = 0; uint32_t retries = 0; uint32_t pendingDelay = 0; diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index f55665b7..821b3869 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -16,6 +16,25 @@ // Goal arbitration and reward discovery, claiming, and equipment. using namespace playerbot; +namespace { + std::string rewardItemSignature(const Item& item) + { + std::ostringstream signature; + signature << item.getID() << ':' << item.getSubType(); + if (const Container* container = item.getContainer()) { + signature << '['; + bool first = true; + for (const Item* child : container->getItemList()) { + if (!first) signature << ','; + first = false; + signature << rewardItemSignature(*child); + } + signature << ']'; + } + return signature.str(); + } +} + void PlayerBotController::emitCombatReadiness(const Player& player, const Position& position, const char* result, const std::string& recovery, const std::string& terminalReason) const { @@ -23,11 +42,13 @@ void PlayerBotController::emitCombatReadiness(const Player& player, const Positi Item* right = player.getInventoryItem(CONST_SLOT_RIGHT); Item* armor = player.getInventoryItem(CONST_SLOT_ARMOR); Item* backpack = player.getInventoryItem(CONST_SLOT_BACKPACK); + const PlayerBotEquipmentPlayerSnapshot playerFacts = PlayerBotEquipmentAdapter::player(player); + const EquipmentLoadout loadout = PlayerBotEquipmentAdapter::loadout(player); const PlayerBotFoodInventory food = inventoryPolicy.foodInventory(player); const uint32_t usableCapacity = inventoryPolicy.effectiveFreeCapacity(player); - const bool weaponReady = (left && equipmentPolicy.isKnightMeleeWeapon(player, *left)) || - (right && equipmentPolicy.isKnightMeleeWeapon(player, *right)); - const bool armorReady = armor && equipmentPolicy.isLegalEquipmentItem(player, *armor) && armor->getArmor() > 0; + const bool weaponReady = equipmentPolicy.isKnightMeleeWeapon(playerFacts, loadout.items[CONST_SLOT_LEFT]) || + equipmentPolicy.isKnightMeleeWeapon(playerFacts, loadout.items[CONST_SLOT_RIGHT]); + const bool armorReady = armor && equipmentPolicy.isLegalEquipmentItem(playerFacts, loadout.items[CONST_SLOT_ARMOR]) && armor->getArmor() > 0; std::ostringstream fields; fields << "\"result\":" << jsonString(result) << ",\"vocation_id\":" << player.getVocationId() @@ -64,131 +85,113 @@ PlayerBotEquipmentReadinessInput PlayerBotController::equipmentReadinessInput(co inventoryPolicy.effectiveFreeCapacity(player), returnCapacityThreshold}; } -bool PlayerBotController::beginReadinessEquipment(Player* player, const Position& position, const char* reason) +bool PlayerBotController::beginReadinessEquipment(Player* player, const Position& position, const char* reason, bool resumeService) { + if (!player) return false; Item* item = nullptr; EquipmentUpgrade upgrade{}; - if (!player || !equipmentPolicy.findCarriedUpgrade(*player, item, upgrade) || !player->canDoAction()) { - return false; + const bool available = PlayerBotEquipmentAdapter::findCarriedUpgrade(equipmentPolicy, *player, item, upgrade); + PlayerBotReadinessEquipmentObservation observation; + observation.actionAvailable = player->canDoAction(); + observation.upgradeAvailable = available; + if (available) { + observation.itemId = item->getID(); + observation.slot = upgrade.slot; } - Position source; - uint8_t sourceIndex = 0; - g_game.internalGetPosition(item, source, sourceIndex); - Container* sourceContainer = dynamic_cast(item->getParent()); - if (sourceContainer && player->getContainerID(sourceContainer) < 0) { - Container* containerToOpen = sourceContainer; + Container* containerToOpen = available ? dynamic_cast(item->getParent()) : nullptr; + if (containerToOpen && player->getContainerID(containerToOpen) < 0) { + observation.openContainerRequired = true; while (Container* parent = dynamic_cast(containerToOpen->getParent())) { - if (player->getContainerID(parent) >= 0) { - break; - } + if (player->getContainerID(parent) >= 0) break; containerToOpen = parent; } - Position containerPosition; - uint8_t containerIndex = 0; + Position source; + uint8_t index = 0; + g_game.internalGetPosition(static_cast(containerToOpen), source, index); + uint8_t containerId = rewardContainerIdBase; + while (containerId <= maximumContainerId && player->getContainerByID(containerId)) ++containerId; + observation.containerAccessAvailable = containerId <= maximumContainerId && source.x == 0xFFFF; + } + const PlayerBotReadinessEquipmentCommand command = progressionRuntime.beginReadinessEquipment( + observation, resumeService, maximumProgressionAttempts); + if (command.type == PlayerBotReadinessEquipmentCommandType::None) return false; + if (command.type == PlayerBotReadinessEquipmentCommandType::ServiceFallback) { + emit("action_result", position, "\"action\":\"open_readiness_container\",\"result\":\"failed\",\"reason\":" + jsonString(command.reason)); + beginService(player, position, "readiness_container_access_failed"); + schedule(navigationInterval); + return true; + } + if (command.type == PlayerBotReadinessEquipmentCommandType::OpenContainer) { + Position source; + uint8_t index = 0; Item* containerItem = static_cast(containerToOpen); - g_game.internalGetPosition(containerItem, containerPosition, containerIndex); + g_game.internalGetPosition(containerItem, source, index); uint8_t containerId = rewardContainerIdBase; - while (containerId <= maximumContainerId && player->getContainerByID(containerId)) { - ++containerId; - } - ++pendingReadinessAttempts; - if (containerId > maximumContainerId || containerPosition.x != 0xFFFF || - pendingReadinessAttempts > maximumProgressionAttempts) { - return false; - } + while (containerId <= maximumContainerId && player->getContainerByID(containerId)) ++containerId; telemetry.recordActionAttempt(); - g_game.playerUseItem(playerId, containerPosition, containerIndex, containerId, containerItem->getClientID()); + g_game.playerUseItem(playerId, source, index, containerId, containerItem->getClientID()); emit("action_result", position, "\"action\":\"open_readiness_container\",\"result\":\"requested\",\"item_id\":" + std::to_string(containerItem->getID()) + ",\"container_id\":" + std::to_string(containerId)); schedule(navigationDecisionDelay(*player)); return true; } - pendingReadinessItemId = item->getID(); - pendingReadinessSlot = upgrade.slot; - readinessEquipmentPending = true; - pendingReadinessAttempts = 0; + Position source; + uint8_t index = 0; + g_game.internalGetPosition(item, source, index); telemetry.recordActionAttempt(); - g_game.playerMoveItem(player, source, item->getClientID(), sourceIndex, - Position(0xFFFF, upgrade.slot, 0), item->getItemCount(), item, nullptr); + g_game.playerMoveItem(player, source, item->getClientID(), index, Position(0xFFFF, command.slot, 0), item->getItemCount(), item, nullptr); emit("action_result", position, "\"action\":\"equip_readiness\",\"result\":\"requested\",\"item_id\":" + - std::to_string(item->getID()) + ",\"slot\":" + std::to_string(upgrade.slot) + - ",\"reason\":" + jsonString(reason)); + std::to_string(command.itemId) + ",\"slot\":" + std::to_string(command.slot) + ",\"reason\":" + jsonString(reason)); schedule(navigationDecisionDelay(*player)); return true; } void PlayerBotController::processReadinessEquipment(Player* player, const Position& position) { - Item* equipped = pendingReadinessSlot == CONST_SLOT_WHEREEVER ? nullptr : player->getInventoryItem(pendingReadinessSlot); - if (equipped && equipped->getID() == pendingReadinessItemId) { - Item* carriedUpgrade = nullptr; - EquipmentUpgrade upgrade{}; - const bool hasCarriedUpgrade = equipmentPolicy.findCarriedUpgrade(*player, carriedUpgrade, upgrade); - const PlayerBotEquipmentReadiness readiness = equipmentPolicy.combatReadiness( - *player, hasCarriedUpgrade, equipmentReadinessInput(*player)); - const bool ready = readiness.ready; - emit("action_result", position, "\"action\":\"equip_readiness\",\"result\":\"success\",\"item_id\":" + - std::to_string(pendingReadinessItemId) + ",\"slot\":" + std::to_string(pendingReadinessSlot)); - emitCombatReadiness(*player, position, ready ? "ready" : "recovery", readiness.recovery, readiness.terminalReason); - readinessEquipmentPending = false; - pendingReadinessAttempts = 0; - if (readinessResumeService) { - readinessResumeService = false; - depotWorkflow.resumeDeposit(); - schedule(SCHEDULER_MINTICKS); - return; - } - if (ready) { - if (huntRuntime.active()) { - schedule(SCHEDULER_MINTICKS); - return; - } - startHunt(player, position, "readiness_carried_upgrade"); - return; - } - ensureCombatReady(player, position, "readiness_upgrade_incomplete"); - return; - } - if (++pendingReadinessAttempts >= maximumProgressionAttempts) { - Item* carriedUpgrade = nullptr; - EquipmentUpgrade upgrade{}; - const PlayerBotEquipmentReadiness readiness = equipmentPolicy.combatReadiness( - *player, equipmentPolicy.findCarriedUpgrade(*player, carriedUpgrade, upgrade), equipmentReadinessInput(*player)); - readinessEquipmentPending = false; - readinessResumeService = false; + const PlayerBotReadinessEquipmentSnapshot pending = progressionRuntime.readinessEquipmentSnapshot(); + Item* equipped = pending.slot == CONST_SLOT_WHEREEVER ? nullptr : player->getInventoryItem(pending.slot); + Item* carriedUpgrade = nullptr; + EquipmentUpgrade upgrade{}; + const PlayerBotEquipmentPlayerSnapshot playerFacts = PlayerBotEquipmentAdapter::player(*player); + const EquipmentLoadout loadout = PlayerBotEquipmentAdapter::loadout(*player); + const PlayerBotEquipmentReadiness readiness = equipmentPolicy.combatReadiness( + playerFacts, loadout, PlayerBotEquipmentAdapter::findCarriedUpgrade(equipmentPolicy, *player, carriedUpgrade, upgrade), equipmentReadinessInput(*player)); + const PlayerBotReadinessEquipmentCommand command = progressionRuntime.advanceReadinessEquipment( + {false, false, 0, CONST_SLOT_WHEREEVER, false, true, equipped && equipped->getID() == pending.itemId, readiness.ready}, maximumProgressionAttempts); + if (command.type == PlayerBotReadinessEquipmentCommandType::ServiceFallback) { emit("action_result", position, "\"action\":\"equip_readiness\",\"result\":\"failed\",\"reason\":\"move_not_verified\""); emitCombatReadiness(*player, position, "failed", readiness.recovery, readiness.terminalReason); - if (!readiness.terminalReason.empty()) { - stop(("combat_readiness_" + readiness.terminalReason).c_str(), position); - } else { - beginService(player, position, "readiness_equipment_move_failed"); - schedule(navigationInterval); - } + if (!readiness.terminalReason.empty()) stop(("combat_readiness_" + readiness.terminalReason).c_str(), position); + else { beginService(player, position, "readiness_equipment_move_failed"); schedule(navigationInterval); } return; } - readinessEquipmentPending = false; - if (!beginReadinessEquipment(player, position, "readiness_retry")) { - if (pendingReadinessAttempts >= maximumProgressionAttempts) { - emit("action_result", position, - "\"action\":\"open_readiness_container\",\"result\":\"failed\",\"reason\":\"access_attempts_exhausted\""); - pendingReadinessAttempts = 0; - beginService(player, position, "readiness_container_access_failed"); - schedule(navigationInterval); - } else { - schedule(navigationDecisionDelay(*player)); - } + if (command.type == PlayerBotReadinessEquipmentCommandType::Retry && !(equipped && equipped->getID() == pending.itemId)) { + if (!beginReadinessEquipment(player, position, "readiness_retry")) schedule(navigationDecisionDelay(*player)); + return; + } + emit("action_result", position, "\"action\":\"equip_readiness\",\"result\":\"success\",\"item_id\":" + + std::to_string(command.itemId) + ",\"slot\":" + std::to_string(command.slot)); + emitCombatReadiness(*player, position, readiness.ready ? "ready" : "recovery", readiness.recovery, readiness.terminalReason); + if (command.type == PlayerBotReadinessEquipmentCommandType::ResumeService) { schedule(SCHEDULER_MINTICKS); return; } + if (command.type == PlayerBotReadinessEquipmentCommandType::StartHunt) { + if (huntRuntime.active()) { schedule(SCHEDULER_MINTICKS); return; } + startHunt(player, position, "readiness_carried_upgrade"); + return; } + ensureCombatReady(player, position, "readiness_upgrade_incomplete"); } bool PlayerBotController::ensureCombatReady(Player* player, const Position& position, const char* reason) { - if (!player || !equipmentPolicy.requiresKnightCombatReadiness(*player)) { + if (!player || !equipmentPolicy.requiresKnightCombatReadiness(PlayerBotEquipmentAdapter::player(*player))) { return true; } Item* carriedUpgrade = nullptr; EquipmentUpgrade upgrade{}; + const PlayerBotEquipmentPlayerSnapshot playerFacts = PlayerBotEquipmentAdapter::player(*player); + const EquipmentLoadout loadout = PlayerBotEquipmentAdapter::loadout(*player); const PlayerBotEquipmentReadiness readiness = equipmentPolicy.combatReadiness( - *player, equipmentPolicy.findCarriedUpgrade(*player, carriedUpgrade, upgrade), equipmentReadinessInput(*player)); + playerFacts, loadout, PlayerBotEquipmentAdapter::findCarriedUpgrade(equipmentPolicy, *player, carriedUpgrade, upgrade), equipmentReadinessInput(*player)); if (readiness.ready) { if (std::strcmp(reason, "readiness_continuous_check") != 0) { emitCombatReadiness(*player, position, "ready", {}, {}); @@ -197,17 +200,7 @@ bool PlayerBotController::ensureCombatReady(Player* player, const Position& posi } emitCombatReadiness(*player, position, "recovery", readiness.recovery, readiness.terminalReason); if (readiness.recovery == "equip_carried") { - if (!beginReadinessEquipment(player, position, reason)) { - if (pendingReadinessAttempts >= maximumProgressionAttempts) { - emit("action_result", position, - "\"action\":\"open_readiness_container\",\"result\":\"failed\",\"reason\":\"access_attempts_exhausted\""); - pendingReadinessAttempts = 0; - beginService(player, position, "readiness_container_access_failed"); - schedule(navigationInterval); - } else { - schedule(navigationDecisionDelay(*player)); - } - } + if (!beginReadinessEquipment(player, position, reason)) schedule(navigationDecisionDelay(*player)); return false; } if (readiness.recovery == "service") { @@ -262,14 +255,14 @@ uint32_t PlayerBotController::matchingRewardRootCount(Player& player, const std: uint32_t count = 0; for (int32_t slot = CONST_SLOT_FIRST; slot <= CONST_SLOT_LAST; ++slot) { Item* item = player.getInventoryItem(static_cast(slot)); - if (item && PlayerBotRewardPlanner::itemSignature(*item) == signature) { + if (item && rewardItemSignature(*item) == signature) { ++count; } } if (Container* backpack = playerBackpack(player)) { count += static_cast(std::count_if(backpack->getItemList().begin(), backpack->getItemList().end(), - [this, &signature](const Item* item) { - return PlayerBotRewardPlanner::itemSignature(*item) == signature; + [&signature](const Item* item) { + return rewardItemSignature(*item) == signature; })); } return count; @@ -279,7 +272,7 @@ Item* PlayerBotController::findMatchingRewardRoot(Player& player, const std::str { for (int32_t slot = CONST_SLOT_FIRST; slot <= CONST_SLOT_LAST; ++slot) { Item* item = player.getInventoryItem(static_cast(slot)); - if (item && PlayerBotRewardPlanner::itemSignature(*item) == signature) { + if (item && rewardItemSignature(*item) == signature) { return item; } } @@ -288,7 +281,7 @@ Item* PlayerBotController::findMatchingRewardRoot(Player& player, const std::str return nullptr; } for (Item* item : backpack->getItemList()) { - if (PlayerBotRewardPlanner::itemSignature(*item) == signature) { + if (rewardItemSignature(*item) == signature) { return item; } } @@ -317,7 +310,7 @@ PlayerBotRewardObservation::ItemAccess PlayerBotController::observeRewardItemAcc PlayerBotRewardObservation::ItemAccess::ActionUnavailable; } - const auto& reward = rewardSession.plan(); + const auto& reward = progressionRuntime.reward().plan(); Item* root = findMatchingRewardRoot(player, reward.rootSignature); if (!root) { Item* extracted = g_game.findItemOfType(&player, reward.itemId, true); @@ -354,7 +347,7 @@ PlayerBotRewardObservation::ItemAccess PlayerBotController::observeRewardItemAcc Item* PlayerBotController::rewardItemForAccess(Player& player) const { - const auto& reward = rewardSession.plan(); + const auto& reward = progressionRuntime.reward().plan(); Item* root = findMatchingRewardRoot(player, reward.rootSignature); if (!root) { Item* extracted = g_game.findItemOfType(&player, reward.itemId, true); @@ -373,8 +366,8 @@ void PlayerBotController::openRewardBackpack(Player& player) void PlayerBotController::openRewardContainer(Player& player, const Position& position, size_t depth) { - Item* root = findMatchingRewardRoot(player, rewardSession.plan().rootSignature); - Item* ancestor = root ? resolveRewardPath(root, rewardSession.plan().selectedItemPath, depth) : nullptr; + Item* root = findMatchingRewardRoot(player, progressionRuntime.reward().plan().rootSignature); + Item* ancestor = root ? resolveRewardPath(root, progressionRuntime.reward().plan().selectedItemPath, depth) : nullptr; if (!ancestor) return; Position sourcePosition; uint8_t sourceIndex = 0; @@ -436,7 +429,7 @@ bool PlayerBotController::planSimpleRewardApproach(Player& player, const Positio uint64_t candidateExpandedNodes = 0; const auto startedAt = std::chrono::steady_clock::now(); const PlayerBotNavigationRoutePlan routePlan = candidate == currentPosition ? PlayerBotNavigationRoutePlan{} : - navigationRuntime.plan(player, candidate); + planNavigationRoute(player, candidate); const PlayerBotNavigationResult planResult = candidate == currentPosition ? PlayerBotNavigationResult::Reached : routePlan.metrics.result; if (candidate != currentPosition) { steps = routePlan.steps; @@ -528,12 +521,86 @@ void PlayerBotController::emitRewardInspection(uint16_t uniqueId, const Position bool PlayerBotController::findPickupReward(Player& player, const Position& position, PlayerBotRewardPlan& reward, std::deque& rewardSteps) { - const EquipmentLoadout currentLoadout = equipmentPolicy.loadout(player); - const PlayerBotCombatProfile currentProfile = equipmentPolicy.combatProfile(player, currentLoadout); + const PlayerBotEquipmentPlayerSnapshot playerFacts = PlayerBotEquipmentAdapter::player(player); + const EquipmentLoadout currentLoadout = PlayerBotEquipmentAdapter::loadout(player); + const PlayerBotCombatProfile currentProfile = equipmentPolicy.combatProfile(playerFacts, currentLoadout); const EquipmentHuntSummary currentHunts = equipmentHuntSummary(player, currentProfile); - const bool currentReady = equipmentPolicy.loadoutReady(player, currentLoadout, equipmentReadinessInput(player)); + const bool currentReady = equipmentPolicy.loadoutReady(playerFacts, currentLoadout, equipmentReadinessInput(player)); std::map, EquipmentOfferEvaluation> equipmentEvaluations; size_t simulatedItems = 0; + auto inspect = [&](const std::vector& roots, uint32_t additionalWeight) { + PlayerBotRewardInspectionSnapshot snapshot; + std::function&, const std::string&)> inspectItem; + inspectItem = [&](const Item& item, uint16_t rootOrdinal, std::vector& path, const std::string& rootSignature) { + PlayerBotRewardItemObservation observed; + observed.itemId = item.getID(); + observed.count = item.getItemCount(); + observed.depth = static_cast(path.size()); + observed.rootOrdinal = rootOrdinal; + observed.path = path; + observed.rootSignature = rootSignature; + observed.worth = item.getWorth(); + observed.container = item.getContainer() != nullptr; + observed.potion = item.getID() == smallHealthPotionItemId; + observed.food = playerbot::PlayerBotInventoryPolicy::isFoodItem(item.getID()); + observed.rope = item.getID() == ropeItemId; + observed.shovel = item.getID() == 2554; + const ItemType& type = Item::items[item.getID()]; + const bool unsupportedTwoHandedWeapon = (type.slotPosition & SLOTP_TWO_HAND) != 0 && type.weaponType != WEAPON_NONE; + observed.sellValue = observed.worth == 0 && !unsupportedTwoHandedWeapon ? + economyCatalog.sellValue(item.getID()) * item.getItemCount() : 0; + const PlayerBotEquipmentItemSnapshot itemFacts = PlayerBotEquipmentAdapter::item(item); + observed.equipmentCandidate = equipmentPolicy.isCombatEquipment(itemFacts); + if (observed.equipmentCandidate) { + const auto cacheKey = std::make_pair(item.getID(), additionalWeight); + if (const auto cached = equipmentEvaluations.find(cacheKey); cached != equipmentEvaluations.end()) observed.equipment = cached->second; + else { + EquipmentOfferEvaluation evaluation = equipmentPolicy.evaluateCandidate( + playerFacts, itemFacts, currentLoadout, currentProfile, currentHunts, currentReady, + equipmentReadinessInput(player), additionalWeight, simulatedItems < maximumEquipmentCandidateSimulations, + [this, &player](const PlayerBotCombatProfile& profile) { return equipmentHuntSummary(player, profile); }); + if (evaluation.simulated) ++simulatedItems; + equipmentEvaluations.emplace(cacheKey, evaluation); + observed.equipment = std::move(evaluation); + } + if (observed.equipment->rejection.empty()) { + const uint16_t currentItemId = currentLoadout.itemIds[observed.equipment->slot]; + const ItemType* currentType = currentItemId == 0 ? nullptr : &Item::items[currentItemId]; + const bool armorSlot = observed.equipment->slot == CONST_SLOT_HEAD || observed.equipment->slot == CONST_SLOT_ARMOR || + observed.equipment->slot == CONST_SLOT_LEGS || observed.equipment->slot == CONST_SLOT_FEET; + const bool shield = type.weaponType == WEAPON_SHIELD; + observed.metric = armorSlot ? "armor" : shield ? "defense" : "attack"; + observed.candidateValue = armorSlot ? type.armor : shield ? type.defense : type.attack; + observed.currentValue = !currentType ? 0 : armorSlot ? currentType->armor : shield ? currentType->defense : currentType->attack; + } + } + snapshot.items.push_back(std::move(observed)); + if (const Container* container = item.getContainer()) { + uint16_t childOrdinal = 0; + for (const Item* child : container->getItemList()) { + path.push_back(childOrdinal++); + inspectItem(*child, rootOrdinal, path, rootSignature); + path.pop_back(); + } + } + }; + for (uint16_t rootOrdinal = 0; rootOrdinal < roots.size(); ++rootOrdinal) { + const Item& root = *roots[rootOrdinal]; + const std::string signature = rewardItemSignature(root); + snapshot.rootItemIds.push_back(root.getID()); + snapshot.rootSignatures.push_back(signature); + if (root.isStackable()) snapshot.stackableRootCounts[root.getID()] += root.getItemCount(); + else snapshot.nonStackableRootSignatures.push_back(signature); + std::vector path; + inspectItem(root, rootOrdinal, path, signature); + } + const PlayerBotRewardInspectionContext context{currentLoadout, + inventoryPolicy.inventoryItemCount(player, smallHealthPotionItemId), inventoryPolicy.foodInventory(player).count, + g_game.findItemOfType(&player, ropeItemId, true) != nullptr, g_game.findItemOfType(&player, 2554, true) != nullptr, + smallHealthPotionItemId, smallHealthPotionRestockTarget, preferredFoodCount, ropeItemId, 2554, + missingPotionUtility, foodPreferenceUtility}; + return rewardPlanner.inspect(snapshot, context); + }; std::vector candidates; for (const auto& entry : g_game.getUniqueItems()) { Item* rewardObject = entry.second; @@ -584,18 +651,10 @@ bool PlayerBotController::findPickupReward(Player& player, const Position& posit } const bool claimed = isRewardClaimed(player, rewardObject->getUniqueId()); const uint32_t acquisitionWeight = claimed ? 0 : totalWeight; - const PlayerBotRewardInspectionContext inspectionContext{ - player, equipmentPolicy, inventoryPolicy, economyCatalog, currentLoadout, currentProfile, currentHunts, - equipmentReadinessInput(player), currentReady, acquisitionWeight, maximumEquipmentCandidateSimulations, - smallHealthPotionItemId, smallHealthPotionRestockTarget, preferredFoodCount, ropeItemId, 2554, - missingPotionUtility, foodPreferenceUtility, - [this](Player& candidate, const PlayerBotCombatProfile& profile) { return equipmentHuntSummary(candidate, profile); }, - [&player](uint16_t itemId) { return g_game.findItemOfType(&player, itemId, true) != nullptr; }, - equipmentEvaluations, simulatedItems, - }; - RewardInspection inspection = containerReward ? - rewardPlanner.inspectBundle(*contents, inspectionContext) : - rewardPlanner.inspectKnownReward(*knownReward, inspectionContext); + std::vector roots; + if (containerReward) roots.assign(contents->getItemList().begin(), contents->getItemList().end()); + else roots.push_back(knownReward.get()); + RewardInspection inspection = inspect(roots, acquisitionWeight); if (inspection.itemCount != 0) { emitRewardInspection(rewardObject->getUniqueId(), tile->getPosition(), inspection, position); } @@ -625,19 +684,23 @@ bool PlayerBotController::findPickupReward(Player& player, const Position& posit } std::map> plannedRoutes; + const PlayerBotRewardPlannerSnapshot routeSnapshot{player.getFreeCapacity(), pickupRewardBaseUtility, + economicPickupBaseUtility, huntGoalUtility, candidates}; + for (size_t candidateIndex : rewardPlanner.routeCandidates(routeSnapshot)) { + PlayerBotRewardCandidateSnapshot& candidate = candidates[candidateIndex]; + std::deque steps; + if (!planSimpleRewardApproach(player, candidate.plan.itemPosition, candidate.route.approachPosition, steps, + candidate.route.expandedNodes)) continue; + candidate.route.reachable = true; + candidate.route.steps = static_cast(steps.size()); + plannedRoutes.emplace(candidate.plan.uniqueId, std::move(steps)); + PlayerBotRewardPlan routed = candidate.plan; + routed.travelSteps = candidate.route.steps; + if (rewardPlanner.utility(routed, routeSnapshot) > routeSnapshot.huntUtility) break; + } const PlayerBotRewardPlannerSnapshot snapshot{player.getFreeCapacity(), pickupRewardBaseUtility, economicPickupBaseUtility, huntGoalUtility, std::move(candidates)}; - const PlayerBotRewardDecision decision = rewardPlanner.select(snapshot, - [this, &player, &plannedRoutes](const PlayerBotRewardPlan& candidate) { - PlayerBotRouteEstimate estimate; - std::deque steps; - if (!planSimpleRewardApproach(player, candidate.itemPosition, estimate.approachPosition, steps, - estimate.expandedNodes)) return estimate; - estimate.reachable = true; - estimate.steps = static_cast(steps.size()); - plannedRoutes.emplace(candidate.uniqueId, std::move(steps)); - return estimate; - }); + const PlayerBotRewardDecision decision = rewardPlanner.select(snapshot); for (const auto& outcome : decision.outcomes) { emitRewardCandidate(outcome.plan, position, outcome.result, outcome.reason); } @@ -658,32 +721,6 @@ uint32_t PlayerBotController::saleableItemCount(const Player& player) const return count; } -PlayerBotController::GoalCandidate PlayerBotController::serviceGoalCandidate(const Player& player) const -{ - const uint32_t potionCount = inventoryPolicy.inventoryItemCount(player, smallHealthPotionItemId); - const uint32_t missingPotions = potionCount <= smallHealthPotionReturnThreshold ? - smallHealthPotionRestockTarget - potionCount : 0; - const uint32_t sellable = saleableItemCount(player); - const bool lowCapacity = inventoryPolicy.effectiveFreeCapacity(player) < returnCapacityThreshold; - const bool criticalHealing = survivalRuntime.needsHealing(survivalSnapshot(player)) && missingPotions != 0; - const bool cashAdjustment = player.getMoney() != inventoryPolicy.desiredCarriedGold(player); - const bool feasible = lowCapacity || missingPotions != 0 || sellable != 0 || cashAdjustment; - int32_t utility = feasible ? serviceGoalBaseUtility : 0; - utility += static_cast(missingPotions * missingPotionUtility + - std::min(sellable, 20) * sellableItemUtility); - utility += cashAdjustment ? 10 : 0; - if (lowCapacity) { - utility = std::max(utility, capacityServiceUtility); - } - if (criticalHealing) { - utility = std::max(utility, criticalHealingServiceUtility); - } - const char* reason = criticalHealing ? "critical_healing" : lowCapacity ? "capacity" : - missingPotions != 0 ? "healing_reserve" : - sellable != 0 ? "sellable_inventory" : cashAdjustment ? "cash_reserve" : "no_service_need"; - return GoalCandidate{TopLevelGoal::Service, feasible, utility, reason}; -} - void PlayerBotController::emitGoalCandidate(const Player& player, const GoalCandidate& candidate, uint64_t decisionId, const Position& position, const char* decisionReason, const PlayerBotRewardPlan* reward, const PlayerBotOracleDeparturePlan* departure, const EquipmentOfferEvaluation* equipment) const @@ -740,9 +777,9 @@ void PlayerBotController::beginPickupReward(Player& player, const Position& posi if (itemId != 0) displaced[itemId] = inventoryPolicy.inventoryItemCount(player, itemId); } progressionRuntime.beginReward(std::move(reward), std::move(displaced)); - const auto& selected = rewardSession.plan(); + const auto& selected = progressionRuntime.reward().plan(); if (!selected.resumeEquipment) { - navigationRuntime.adopt(selected.approachPosition, std::move(rewardSteps)); + observeNavigationPlan(selected.approachPosition, std::move(rewardSteps)); } std::ostringstream fields; fields << "\"goal\":\"pickup_reward\",\"acquisition_source\":\"map_reward\",\"candidate_id\":" << selected.uniqueId @@ -769,7 +806,7 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos PlayerBotRewardPlan reward; std::deque rewardSteps; const auto now = std::chrono::steady_clock::now(); - const bool pickupCoolingDown = goalArbiter.isCoolingDown(TopLevelGoal::PickupReward, now); + const bool pickupCoolingDown = progressionRuntime.isCoolingDown(TopLevelGoal::PickupReward, now); const bool pickupFound = !pickupCoolingDown && findPickupReward(player, position, reward, rewardSteps); const PlayerBotRewardPlannerSnapshot rewardSnapshot{ 0, pickupRewardBaseUtility, economicPickupBaseUtility, huntGoalUtility, {}, @@ -777,15 +814,15 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos const int32_t pickupUtility = pickupFound ? rewardPlanner.utility(reward, rewardSnapshot) : 0; PlayerBotSpellTrainingPlan spellTraining; std::deque spellTrainingSteps; - const bool spellTrainingCoolingDown = goalArbiter.isCoolingDown(TopLevelGoal::LearnSpell, now); + const bool spellTrainingCoolingDown = progressionRuntime.isCoolingDown(TopLevelGoal::LearnSpell, now); const bool spellTrainingFound = !spellTrainingCoolingDown && findSpellTraining(player, position, spellTraining, spellTrainingSteps); - const bool equipmentPurchaseCoolingDown = goalArbiter.isCoolingDown(TopLevelGoal::BuyEquipment, now); + const bool equipmentPurchaseCoolingDown = progressionRuntime.isCoolingDown(TopLevelGoal::BuyEquipment, now); const std::optional equipment = equipmentPurchaseCoolingDown ? std::nullopt : evaluateEquipmentOffers(player, position); const bool equipmentFound = fixtureDriver.observeEquipmentOffer(equipment.has_value()).available; - const bool magicTrainingCoolingDown = goalArbiter.isCoolingDown(TopLevelGoal::MagicTraining, now); + const bool magicTrainingCoolingDown = progressionRuntime.isCoolingDown(TopLevelGoal::MagicTraining, now); const char* magicTrainingReason = magicTrainingCoolingDown ? "cooldown" : - survivalRuntime.magicTrainingReason(player, survivalSnapshot(player)); + survivalRuntime.magicTrainingReason(survivalSnapshot(player)); const uint32_t potionCount = inventoryPolicy.inventoryItemCount(player, smallHealthPotionItemId); const uint32_t missingPotions = potionCount <= smallHealthPotionReturnThreshold ? smallHealthPotionRestockTarget - potionCount : 0; @@ -804,7 +841,7 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos equipmentFound ? PlayerBotEquipmentPolicy::decisionRuleName(equipment->rule) : "", magicTrainingCoolingDown, magicTrainingReason ? magicTrainingReason : "", }; - const PlayerBotGoalArbiter::GoalDecision decision = progressionRuntime.decide(snapshot); + const PlayerBotGoalArbiter::GoalDecision decision = progressionRuntime.selectGoal(snapshot); emitGoalCandidate(player, decision.candidate(TopLevelGoal::Departure), decision.id, position, decisionReason, nullptr, departureFound ? &departure : nullptr); emitGoalCandidate(player, decision.candidate(TopLevelGoal::Service), decision.id, position, decisionReason); @@ -826,7 +863,6 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos return false; } const GoalCandidate& selected = *decision.selected; - progressionRuntime.apply(decision); std::ostringstream fields; fields << "\"decision_id\":" << decision.id << ",\"decision_reason\":" << jsonString(decisionReason) << ",\"from_goal\":" << jsonString(PlayerBotGoalArbiter::goalName(decision.previousGoal)) @@ -866,23 +902,23 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos const char* PlayerBotController::objectiveName() const { - return progressionSession.active(PlayerBotProgressionProcedure::OracleDeparture) ? "oracle_departure" : - progressionSession.active(PlayerBotProgressionProcedure::PickupReward) ? "pickup_reward" : - progressionSession.active(PlayerBotProgressionProcedure::LearnSpell) ? "learn_spell" : - progressionSession.active(PlayerBotProgressionProcedure::BuyEquipment) ? "buy_equipment" : cyclePhaseName(); + return progressionRuntime.session().active(PlayerBotProgressionProcedure::OracleDeparture) ? "oracle_departure" : + progressionRuntime.session().active(PlayerBotProgressionProcedure::PickupReward) ? "pickup_reward" : + progressionRuntime.session().active(PlayerBotProgressionProcedure::LearnSpell) ? "learn_spell" : + progressionRuntime.session().active(PlayerBotProgressionProcedure::BuyEquipment) ? "buy_equipment" : cyclePhaseName(); } void PlayerBotController::finishProgressionObjective(Player* player, const Position& position, const char* result, const char* reason, bool scheduleNext) { - const auto& reward = rewardSession.plan(); + const auto& reward = progressionRuntime.reward().plan(); std::ostringstream fields; fields << "\"goal\":\"pickup_reward\",\"acquisition_source\":\"map_reward\",\"candidate_id\":" << reward.uniqueId << ",\"item_id\":" << reward.itemId << ",\"result\":" << jsonString(result) << ",\"reason\":" << jsonString(reason); emit("strategy_objective_result", position, fields.str()); emit("goal_result", position, - "\"decision_id\":" + std::to_string(goalArbiter.decisionId()) + + "\"decision_id\":" + std::to_string(progressionRuntime.decisionId()) + ",\"goal\":\"pickup_reward\",\"acquisition_source\":\"map_reward\",\"result\":" + jsonString(result) + ",\"reason\":" + jsonString(reason)); if (player) { @@ -890,17 +926,15 @@ void PlayerBotController::finishProgressionObjective(Player* player, const Posit } progressionRuntime.finish(); clearNavigation(); - serviceWorkflow.reset(); - serviceWorkflow.resetNpc(); cyclePhase = CyclePhase::Service; - progressionRuntime.setCooldown(TopLevelGoal::PickupReward, std::strcmp(result, "success") == 0 ? pickupRewardSuccessCooldown : - pickupRewardFailureCooldown); + progressionRuntime.completeReward(std::strcmp(result, "success") == 0, + std::strcmp(result, "success") == 0 ? pickupRewardSuccessCooldown : pickupRewardFailureCooldown); if (player && fixtureDriver.goalLoop(true).selectGoal) { const char* decisionReason = std::strcmp(result, "success") == 0 ? "pickup_complete" : std::strcmp(result, "interrupted") == 0 ? "pickup_interrupted" : "pickup_failed"; selectTopLevelGoal(*player, position, decisionReason); } else { - progressionRuntime.setActiveGoal(TopLevelGoal::Service); + progressionRuntime.enterService(); emit("objective_transition", position, "\"from\":\"pickup_reward\",\"to\":\"service\",\"reason\":" + jsonString(reason)); } @@ -911,10 +945,10 @@ void PlayerBotController::finishProgressionObjective(Player* player, const Posit void PlayerBotController::processPickupReward(Player* player, const Position& currentPosition) { - const auto& pickupReward = rewardSession.plan(); + const auto& pickupReward = progressionRuntime.reward().plan(); PlayerBotRewardObservation observation; observation.actionAvailable = player->canDoAction(); - if (rewardSession.stage() == PlayerBotRewardStage::Travel) { + if (progressionRuntime.reward().stage() == PlayerBotRewardStage::Travel) { observation.navigationReached = processNavigation(player, currentPosition, pickupReward.approachPosition); observation.navigationFailed = navigationRuntime.fixedTargetRouteFailureCount() >= maximumProgressionAttempts; } else { @@ -971,9 +1005,9 @@ void PlayerBotController::processPickupReward(Player* player, const Position& cu emit("action_result", currentPosition, "\"action\":\"claim_reward\",\"result\":\"success\",\"candidate_id\":" + std::to_string(pickupReward.uniqueId) + ",\"item_id\":" + std::to_string(pickupReward.itemId) + ",\"root_item_id\":" + std::to_string(pickupReward.rootItemId) + ",\"inventory_before\":" + - std::to_string(rewardSession.claimSnapshot().itemCount) + ",\"inventory_after\":" + + std::to_string(progressionRuntime.reward().claimSnapshot().itemCount) + ",\"inventory_after\":" + std::to_string(observation.currentClaim.itemCount) + ",\"root_count_before\":" + - std::to_string(rewardSession.claimSnapshot().rootCount) + ",\"root_count_after\":" + + std::to_string(progressionRuntime.reward().claimSnapshot().rootCount) + ",\"root_count_after\":" + std::to_string(observation.currentClaim.rootCount) + ",\"top_level_root_count\":" + std::to_string(pickupReward.rootSignatures.size()) + ",\"all_roots_verified\":true"); } @@ -1036,13 +1070,13 @@ void PlayerBotController::processPickupReward(Player* player, const Position& cu void PlayerBotController::processProgression(Player* player, const Position& currentPosition) { - if (progressionSession.active(PlayerBotProgressionProcedure::OracleDeparture)) { + if (progressionRuntime.session().active(PlayerBotProgressionProcedure::OracleDeparture)) { processOracleDeparture(player, currentPosition); - } else if (progressionSession.active(PlayerBotProgressionProcedure::PickupReward)) { + } else if (progressionRuntime.session().active(PlayerBotProgressionProcedure::PickupReward)) { processPickupReward(player, currentPosition); - } else if (progressionSession.active(PlayerBotProgressionProcedure::LearnSpell)) { + } else if (progressionRuntime.session().active(PlayerBotProgressionProcedure::LearnSpell)) { processSpellTraining(player, currentPosition); - } else if (progressionSession.active(PlayerBotProgressionProcedure::BuyEquipment)) { + } else if (progressionRuntime.session().active(PlayerBotProgressionProcedure::BuyEquipment)) { processEquipmentPurchase(player, currentPosition); } } diff --git a/server/src/playerbotprogressionplanners.cpp b/server/src/playerbotprogressionplanners.cpp index 0b784023..e56242aa 100644 --- a/server/src/playerbotprogressionplanners.cpp +++ b/server/src/playerbotprogressionplanners.cpp @@ -3,10 +3,6 @@ #include "playerbotprogressionplanners.h" -#include "container.h" -#include "item.h" -#include "player.h" - #include #include #include @@ -35,7 +31,8 @@ std::optional PlayerBotDeparturePlanner::select(co PlayerBotSpellTrainingDecision PlayerBotSpellTrainingPlanner::select(const PlayerBotSpellTrainingPlannerSnapshot& snapshot) const { PlayerBotSpellTrainingDecision decision; - for (const auto& offer : snapshot.offers) { + for (size_t offerIndex = 0; offerIndex < snapshot.offers.size(); ++offerIndex) { + const auto& offer = snapshot.offers[offerIndex]; const char* rejection = !offer.inScope ? "outside_thais_scope" : !offer.registryMatches ? "spell_registry_mismatch" : !offer.vocationEligible ? "vocation_ineligible" : !offer.levelEligible ? "level_ineligible" : !offer.premiumEligible ? "premium_ineligible" : offer.known ? "already_learned" : @@ -43,7 +40,7 @@ PlayerBotSpellTrainingDecision PlayerBotSpellTrainingPlanner::select(const Playe snapshot.totalMoney < snapshot.reserve + offer.price ? "unaffordable_after_reserves" : !offer.route.reachable ? "trainer_unreachable" : nullptr; if (rejection) { - decision.rejections.push_back(rejection); + decision.rejections.push_back({offerIndex, rejection}); continue; } PlayerBotSpellTrainingPlan candidate{offer.npcId, offer.npcPosition, offer.route.approachPosition, offer.spellName, @@ -52,6 +49,7 @@ PlayerBotSpellTrainingDecision PlayerBotSpellTrainingPlanner::select(const Playe (candidate.price == decision.selected->price && (candidate.travelSteps < decision.selected->travelSteps || (candidate.travelSteps == decision.selected->travelSteps && candidate.spellName < decision.selected->spellName)))) { decision.selected = std::move(candidate); + decision.selectedOfferIndex = offerIndex; } } return decision; @@ -61,7 +59,9 @@ PlayerBotEquipmentProviderDecision PlayerBotEquipmentProviderPlanner::select(con { PlayerBotEquipmentProviderDecision decision; if (!snapshot.enabled) return decision; - for (const auto& offer : snapshot.offers) { + decision.evaluated = true; + for (size_t offerIndex = 0; offerIndex < snapshot.offers.size(); ++offerIndex) { + const auto& offer = snapshot.offers[offerIndex]; PlayerBotEquipmentOfferEvaluation evaluation = offer.evaluation; const char* rejection = !evaluation.rejection.empty() ? evaluation.rejection.c_str() : !evaluation.carried && offer.itemWeight > snapshot.freeCapacity ? "insufficient_capacity" : nullptr; @@ -73,131 +73,92 @@ PlayerBotEquipmentProviderDecision PlayerBotEquipmentProviderPlanner::select(con } const uint32_t requiredSlots = displacedSlots + (evaluation.carried ? 0 : 1); if (!rejection && (!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"; if (!rejection && !evaluation.carried && !offer.route.reachable) rejection = offer.routeBudgetExhausted ? "provider_evaluation_budget_exhausted" : offer.route.nodeLimitReached ? "provider_route_node_budget_exhausted" : "provider_unreachable"; if (rejection) { - decision.rejections.push_back(rejection); + decision.rejections.push_back({offerIndex, rejection}); continue; } if (!evaluation.carried) { evaluation.approachPosition = offer.route.approachPosition; evaluation.travelSteps = offer.route.steps; } else evaluation.travelSteps = 0; - if (!decision.selected || PlayerBotEquipmentPolicy::prefers(evaluation, *decision.selected)) decision.selected = std::move(evaluation); - } - return decision; -} - -std::string PlayerBotRewardPlanner::itemSignature(const Item& item) -{ - std::ostringstream signature; - signature << item.getID() << ':' << item.getSubType(); - if (const Container* container = item.getContainer()) { - signature << '['; - bool first = true; - for (const Item* child : container->getItemList()) { - if (!first) signature << ','; - first = false; - signature << itemSignature(*child); + if (!decision.selected || PlayerBotEquipmentPolicy::prefers(evaluation, *decision.selected)) { + decision.selected = std::move(evaluation); + decision.selectedOfferIndex = offerIndex; } - signature << ']'; } - return signature.str(); + return decision; } -void PlayerBotRewardPlanner::inspectItem(const Item& item, uint16_t rootOrdinal, std::vector& path, - const std::string& rootSignature, - const PlayerBotRewardInspectionContext& context, - PlayerBotRewardInspection& inspection) const +PlayerBotRewardInspection PlayerBotRewardPlanner::inspect(const PlayerBotRewardInspectionSnapshot& snapshot, + const PlayerBotRewardInspectionContext& context) const { - PlayerBotRewardItemInspection inspected{item.getID(), item.getItemCount(), static_cast(path.size()), rootOrdinal, path}; - ++inspection.itemCount; - if (item.getContainer()) { - inspected.classes.emplace_back("container"); - ++inspection.containerCount; - } - - PlayerBotEquipmentOfferEvaluation equipment; - bool evaluatedEquipment = false; - if (context.equipmentPolicy.isCombatEquipment(item)) { - evaluatedEquipment = true; - const auto cacheKey = std::make_pair(item.getID(), context.additionalWeight); - if (const auto cached = context.equipmentEvaluations.find(cacheKey); cached != context.equipmentEvaluations.end()) { - equipment = cached->second; - } else { - equipment = context.equipmentPolicy.evaluateCandidate( - context.player, item.getID(), context.currentLoadout, context.currentProfile, context.currentHunts, - context.currentReady, context.readiness, context.additionalWeight, - context.simulatedItems < context.maximumEquipmentCandidateSimulations, context.huntSummary); - if (equipment.simulated) ++context.simulatedItems; - context.equipmentEvaluations.emplace(cacheKey, equipment); + PlayerBotRewardInspection inspection; + inspection.rootItemIds = snapshot.rootItemIds; + inspection.rootSignatures = snapshot.rootSignatures; + inspection.nonStackableRootSignatures = snapshot.nonStackableRootSignatures; + inspection.stackableRootCounts = snapshot.stackableRootCounts; + for (const PlayerBotRewardItemObservation& item : snapshot.items) { + PlayerBotRewardItemInspection inspected{item.itemId, item.count, item.depth, item.rootOrdinal, item.path}; + ++inspection.itemCount; + if (item.container) { + inspected.classes.emplace_back("container"); + ++inspection.containerCount; } - } - if (evaluatedEquipment && equipment.rejection.empty()) { - const ItemType& candidateType = Item::items[item.getID()]; - const uint16_t currentItemId = context.currentLoadout.itemIds[equipment.slot]; - const ItemType* currentType = currentItemId == 0 ? nullptr : &Item::items[currentItemId]; - const bool armorSlot = equipment.slot == CONST_SLOT_HEAD || equipment.slot == CONST_SLOT_ARMOR || - equipment.slot == CONST_SLOT_LEGS || equipment.slot == CONST_SLOT_FEET; - const bool shield = candidateType.weaponType == WEAPON_SHIELD; - const char* metric = armorSlot ? "armor" : shield ? "defense" : "attack"; - const int32_t candidateValue = armorSlot ? candidateType.armor : shield ? candidateType.defense : candidateType.attack; - const int32_t currentValue = !currentType ? 0 : armorSlot ? currentType->armor : - shield ? currentType->defense : currentType->attack; - const PlayerBotEquipmentUpgrade upgrade{equipment.slot, std::max(1, candidateValue - currentValue), metric, - currentValue, candidateValue}; + const PlayerBotEquipmentOfferEvaluation equipment = item.equipment.value_or(PlayerBotEquipmentOfferEvaluation{}); + if (item.equipmentCandidate && item.equipment && equipment.rejection.empty()) { + const PlayerBotEquipmentUpgrade upgrade{equipment.slot, std::max(1, item.candidateValue - item.currentValue), item.metric, + item.currentValue, item.candidateValue}; inspected.classes.emplace_back("equipment_upgrade"); ++inspection.equipmentUpgradeCount; if (!inspection.bestEquipment || equipment.rule > inspection.bestEquipment->rule || (equipment.rule == inspection.bestEquipment->rule && upgrade.benefit > inspection.bestUpgrade->benefit)) { inspection.bestUpgrade = upgrade; inspection.bestEquipment = equipment; - inspection.bestItemId = item.getID(); - inspection.bestRootOrdinal = rootOrdinal; - inspection.bestItemPath = path; - inspection.bestRootSignature = rootSignature; + inspection.bestItemId = item.itemId; + inspection.bestRootOrdinal = item.rootOrdinal; + inspection.bestItemPath = item.path; + inspection.bestRootSignature = item.rootSignature; } - } else if (evaluatedEquipment && inspection.equipmentRejection.empty()) { + } else if (item.equipmentCandidate && item.equipment && inspection.equipmentRejection.empty()) { inspection.equipmentRejection = equipment.rejection; - } + } - inspected.worth = item.getWorth(); + inspected.worth = item.worth; if (inspected.worth != 0) { inspected.classes.emplace_back("currency"); inspection.currencyValue += inspected.worth; } - if (item.getID() == context.potionItemId) { + if (item.potion) { inspected.classes.emplace_back("required_supply"); - inspection.potionCount += item.getItemCount(); - } else if (playerbot::PlayerBotInventoryPolicy::isFoodItem(item.getID())) { + inspection.potionCount += item.count; + } else if (item.food) { inspected.classes.emplace_back("food"); - inspection.foodCount += item.getItemCount(); - } else if (item.getID() == context.ropeItemId) { + inspection.foodCount += item.count; + } else if (item.rope) { inspected.classes.emplace_back("tool"); - inspection.ropeCount += item.getItemCount(); - } else if (item.getID() == context.shovelItemId) { + inspection.ropeCount += item.count; + } else if (item.shovel) { inspected.classes.emplace_back("tool"); - inspection.shovelCount += item.getItemCount(); + inspection.shovelCount += item.count; } - const uint32_t learnedSellValue = context.economyCatalog.sellValue(item.getID()); - const ItemType& itemType = Item::items[item.getID()]; - const bool unsupportedTwoHandedWeapon = (itemType.slotPosition & SLOTP_TWO_HAND) != 0 && itemType.weaponType != WEAPON_NONE; - const uint32_t sellPrice = inspected.worth == 0 && !unsupportedTwoHandedWeapon ? learnedSellValue : 0; - if (sellPrice != 0) { + if (item.sellValue != 0) { inspected.classes.emplace_back("sellable"); - inspected.sellValue = sellPrice * item.getItemCount(); + inspected.sellValue = item.sellValue; inspection.sellValue += inspected.sellValue; } uint32_t itemUtility = inspected.worth + inspected.sellValue; - if (item.getID() == context.potionItemId) itemUtility += context.missingPotionUtility * item.getItemCount(); - else if (playerbot::PlayerBotInventoryPolicy::isFoodItem(item.getID())) itemUtility += context.foodPreferenceUtility * item.getItemCount(); - else if (item.getID() == context.ropeItemId || item.getID() == context.shovelItemId) itemUtility += 100; + if (item.potion) itemUtility += context.missingPotionUtility * item.count; + else if (item.food) itemUtility += context.foodPreferenceUtility * item.count; + else if (item.rope || item.shovel) itemUtility += 100; if (itemUtility > inspection.primaryKnownItemUtility) { - inspection.primaryKnownItemId = item.getID(); - inspection.primaryKnownRootOrdinal = rootOrdinal; - inspection.primaryKnownRootSignature = rootSignature; + inspection.primaryKnownItemId = item.itemId; + inspection.primaryKnownRootOrdinal = item.rootOrdinal; + inspection.primaryKnownRootSignature = item.rootSignature; inspection.primaryKnownItemUtility = itemUtility; } if (inspected.classes.empty()) { @@ -205,15 +166,9 @@ void PlayerBotRewardPlanner::inspectItem(const Item& item, uint16_t rootOrdinal, ++inspection.unknownCount; } inspection.items.push_back(std::move(inspected)); - - if (const Container* container = item.getContainer()) { - uint16_t childOrdinal = 0; - for (const Item* child : container->getItemList()) { - path.push_back(childOrdinal++); - inspectItem(*child, rootOrdinal, path, rootSignature, context, inspection); - path.pop_back(); - } } + finalizeInspection(context, inspection); + return inspection; } void PlayerBotRewardPlanner::finalizeInspection(const PlayerBotRewardInspectionContext& context, @@ -221,47 +176,12 @@ void PlayerBotRewardPlanner::finalizeInspection(const PlayerBotRewardInspectionC { if (inspection.bestUpgrade) inspection.knownUtility += inspection.bestUpgrade->benefit * 20; inspection.knownUtility += static_cast(inspection.currencyValue + inspection.sellValue); - const uint32_t heldPotions = context.inventoryPolicy.inventoryItemCount(context.player, context.potionItemId); - const uint32_t heldFood = context.inventoryPolicy.foodInventory(context.player).count; - const uint32_t potionNeed = heldPotions < context.potionRestockTarget ? context.potionRestockTarget - heldPotions : 0; - const uint32_t foodNeed = heldFood < context.preferredFoodCount ? context.preferredFoodCount - heldFood : 0; + const uint32_t potionNeed = context.heldPotions < context.potionRestockTarget ? context.potionRestockTarget - context.heldPotions : 0; + const uint32_t foodNeed = context.heldFood < context.preferredFoodCount ? context.preferredFoodCount - context.heldFood : 0; inspection.knownUtility += static_cast(std::min(inspection.potionCount, potionNeed) * context.missingPotionUtility + std::min(inspection.foodCount, foodNeed) * context.foodPreferenceUtility); - if (inspection.ropeCount != 0 && !context.ownsItem(context.ropeItemId)) inspection.knownUtility += 100; - if (inspection.shovelCount != 0 && !context.ownsItem(context.shovelItemId)) inspection.knownUtility += 100; -} - -PlayerBotRewardInspection PlayerBotRewardPlanner::inspectBundle(const Container& contents, - const PlayerBotRewardInspectionContext& context) const -{ - PlayerBotRewardInspection inspection; - uint16_t rootOrdinal = 0; - for (const Item* root : contents.getItemList()) { - const std::string signature = itemSignature(*root); - inspection.rootItemIds.push_back(root->getID()); - inspection.rootSignatures.push_back(signature); - if (root->isStackable()) inspection.stackableRootCounts[root->getID()] += root->getItemCount(); - else inspection.nonStackableRootSignatures.push_back(signature); - std::vector path; - inspectItem(*root, rootOrdinal++, path, signature, context, inspection); - } - finalizeInspection(context, inspection); - return inspection; -} - -PlayerBotRewardInspection PlayerBotRewardPlanner::inspectKnownReward(const Item& item, - const PlayerBotRewardInspectionContext& context) const -{ - PlayerBotRewardInspection inspection; - const std::string signature = itemSignature(item); - inspection.rootItemIds.push_back(item.getID()); - inspection.rootSignatures.push_back(signature); - if (item.isStackable()) inspection.stackableRootCounts[item.getID()] += item.getItemCount(); - else inspection.nonStackableRootSignatures.push_back(signature); - std::vector path; - inspectItem(item, 0, path, signature, context, inspection); - finalizeInspection(context, inspection); - return inspection; + if (inspection.ropeCount != 0 && !context.ownsRope) inspection.knownUtility += 100; + if (inspection.shovelCount != 0 && !context.ownsShovel) inspection.knownUtility += 100; } std::optional PlayerBotRewardPlanner::plan(uint16_t uniqueId, const Position& itemPosition, @@ -323,8 +243,31 @@ int32_t PlayerBotRewardPlanner::estimatedUtility(const PlayerBotRewardPlan& plan return std::max(0, base + static_cast(plan.knownUtility) - static_cast(plan.estimatedDistance)); } -PlayerBotRewardDecision PlayerBotRewardPlanner::select(const PlayerBotRewardPlannerSnapshot& snapshot, - const RouteEstimator& route) const +std::vector PlayerBotRewardPlanner::routeCandidates(const PlayerBotRewardPlannerSnapshot& snapshot) const +{ + std::vector candidates; + for (size_t index = 0; index < snapshot.candidates.size(); ++index) { + if (!snapshot.candidates[index].claimed) candidates.push_back(index); + } + std::sort(candidates.begin(), candidates.end(), [this, &snapshot](size_t left, size_t right) { + const PlayerBotRewardPlan& leftPlan = snapshot.candidates[left].plan; + const PlayerBotRewardPlan& rightPlan = snapshot.candidates[right].plan; + const int32_t leftUtility = estimatedUtility(leftPlan, snapshot); + const int32_t rightUtility = estimatedUtility(rightPlan, snapshot); + if (leftUtility != rightUtility) return leftUtility > rightUtility; + if (leftPlan.estimatedDistance != rightPlan.estimatedDistance) return leftPlan.estimatedDistance < rightPlan.estimatedDistance; + return leftPlan.uniqueId < rightPlan.uniqueId; + }); + candidates.erase(std::remove_if(candidates.begin(), candidates.end(), [&snapshot, this](size_t index) { + const PlayerBotRewardCandidateSnapshot& source = snapshot.candidates[index]; + return source.totalWeight > snapshot.freeCapacity || !source.backpackAvailable || + source.freeBackpackSlots < source.plan.requiredBackpackSlots || + estimatedUtility(source.plan, snapshot) <= snapshot.huntUtility; + }), candidates.end()); + return candidates; +} + +PlayerBotRewardDecision PlayerBotRewardPlanner::select(const PlayerBotRewardPlannerSnapshot& snapshot) const { PlayerBotRewardDecision decision; for (const auto& source : snapshot.candidates) { @@ -360,7 +303,7 @@ PlayerBotRewardDecision PlayerBotRewardPlanner::select(const PlayerBotRewardPlan if (source.totalWeight > snapshot.freeCapacity) { decision.outcomes.push_back({candidate, "rejected", "insufficient_capacity"}); continue; } if (!source.backpackAvailable || source.freeBackpackSlots < candidate.requiredBackpackSlots) { decision.outcomes.push_back({candidate, "rejected", "insufficient_inventory_space"}); continue; } if (estimatedUtility(candidate, snapshot) <= snapshot.huntUtility) { decision.outcomes.push_back({candidate, "rejected", "utility_below_hunt"}); continue; } - const PlayerBotRouteEstimate routeEstimate = route(candidate); + const PlayerBotRouteEstimate& routeEstimate = source.route; if (!routeEstimate.reachable) { decision.outcomes.push_back({candidate, "rejected", "simple_route_unavailable"}); continue; } candidate.approachPosition = routeEstimate.approachPosition; candidate.travelSteps = routeEstimate.steps; diff --git a/server/src/playerbotprogressionplanners.h b/server/src/playerbotprogressionplanners.h index 5ab66a8e..ce087dc1 100644 --- a/server/src/playerbotprogressionplanners.h +++ b/server/src/playerbotprogressionplanners.h @@ -9,16 +9,11 @@ #include "playerbotinventorypolicy.h" #include "playerbotprogressionsession.h" -#include #include #include #include #include -class Container; -class Item; -class Player; - struct PlayerBotRouteEstimate { bool reachable = false; bool nodeLimitReached = false; @@ -69,17 +64,11 @@ struct PlayerBotRewardInspection { }; struct PlayerBotRewardInspectionContext { - Player& player; - const PlayerBotEquipmentPolicy& equipmentPolicy; - const playerbot::PlayerBotInventoryPolicy& inventoryPolicy; - const PlayerBotEconomyCatalog& economyCatalog; const PlayerBotEquipmentLoadout& currentLoadout; - const PlayerBotCombatProfile& currentProfile; - const PlayerBotEquipmentHuntSummary& currentHunts; - const PlayerBotEquipmentReadinessInput& readiness; - bool currentReady; - uint32_t additionalWeight; - uint32_t maximumEquipmentCandidateSimulations; + uint32_t heldPotions = 0; + uint32_t heldFood = 0; + bool ownsRope = false; + bool ownsShovel = false; uint16_t potionItemId; uint32_t potionRestockTarget; uint32_t preferredFoodCount; @@ -87,10 +76,35 @@ struct PlayerBotRewardInspectionContext { uint16_t shovelItemId; int32_t missingPotionUtility; int32_t foodPreferenceUtility; - PlayerBotEquipmentPolicy::HuntSummaryEvaluator huntSummary; - std::function ownsItem; - std::map, PlayerBotEquipmentOfferEvaluation>& equipmentEvaluations; - size_t& simulatedItems; +}; + +struct PlayerBotRewardItemObservation { + uint16_t itemId = 0; + uint32_t count = 0; + uint32_t depth = 0; + uint16_t rootOrdinal = 0; + std::vector path; + std::string rootSignature; + uint32_t worth = 0; + uint32_t sellValue = 0; + bool container = false; + bool equipmentCandidate = false; + std::optional equipment; + int32_t currentValue = 0; + int32_t candidateValue = 0; + const char* metric = nullptr; + bool potion = false; + bool food = false; + bool rope = false; + bool shovel = false; +}; + +struct PlayerBotRewardInspectionSnapshot { + std::vector rootItemIds; + std::vector rootSignatures; + std::vector nonStackableRootSignatures; + std::map stackableRootCounts; + std::vector items; }; struct PlayerBotDepartureProviderSnapshot { @@ -143,9 +157,15 @@ struct PlayerBotSpellTrainingPlannerSnapshot { std::vector offers; }; +struct PlayerBotPlannerOfferRejection { + size_t offerIndex = 0; + std::string reason; +}; + struct PlayerBotSpellTrainingDecision { std::optional selected; - std::vector rejections; + std::optional selectedOfferIndex; + std::vector rejections; }; class PlayerBotSpellTrainingPlanner @@ -159,6 +179,7 @@ struct PlayerBotEquipmentProviderOfferSnapshot { uint32_t itemWeight = 0; uint32_t freeBackpackSlots = 0; bool backpackAvailable = false; + bool purchaseAvailable = false; bool routeBudgetExhausted = false; PlayerBotRouteEstimate route; }; @@ -173,8 +194,10 @@ struct PlayerBotEquipmentProviderPlannerSnapshot { }; struct PlayerBotEquipmentProviderDecision { + bool evaluated = false; std::optional selected; - std::vector rejections; + std::optional selectedOfferIndex; + std::vector rejections; }; class PlayerBotEquipmentProviderPlanner @@ -214,20 +237,15 @@ struct PlayerBotRewardDecision { class PlayerBotRewardPlanner { public: - using RouteEstimator = std::function; - - static std::string itemSignature(const Item& item); - PlayerBotRewardInspection inspectBundle(const Container& contents, const PlayerBotRewardInspectionContext& context) const; - PlayerBotRewardInspection inspectKnownReward(const Item& item, const PlayerBotRewardInspectionContext& context) const; + PlayerBotRewardInspection inspect(const PlayerBotRewardInspectionSnapshot& snapshot, + const PlayerBotRewardInspectionContext& context) const; std::optional plan(uint16_t uniqueId, const Position& itemPosition, uint32_t estimatedDistance, const PlayerBotRewardInspection& inspection) const; int32_t utility(const PlayerBotRewardPlan& plan, const PlayerBotRewardPlannerSnapshot& snapshot) const; - PlayerBotRewardDecision select(const PlayerBotRewardPlannerSnapshot& snapshot, const RouteEstimator& route) const; + std::vector routeCandidates(const PlayerBotRewardPlannerSnapshot& snapshot) const; + PlayerBotRewardDecision select(const PlayerBotRewardPlannerSnapshot& snapshot) const; private: - void inspectItem(const Item& item, uint16_t rootOrdinal, std::vector& path, - const std::string& rootSignature, const PlayerBotRewardInspectionContext& context, - PlayerBotRewardInspection& inspection) const; void finalizeInspection(const PlayerBotRewardInspectionContext& context, PlayerBotRewardInspection& inspection) const; int32_t estimatedUtility(const PlayerBotRewardPlan& plan, diff --git a/server/src/playerbotprogressionruntime.cpp b/server/src/playerbotprogressionruntime.cpp index e027ac74..ea8fb66e 100644 --- a/server/src/playerbotprogressionruntime.cpp +++ b/server/src/playerbotprogressionruntime.cpp @@ -5,31 +5,64 @@ #include "playerbotprogressionruntime.h" -PlayerBotGoalArbiter::GoalDecision PlayerBotProgressionRuntime::decide(const PlayerBotGoalPlannerSnapshot& snapshot) +PlayerBotGoalArbiter::GoalDecision PlayerBotProgressionRuntime::selectGoal(const PlayerBotGoalPlannerSnapshot& snapshot) { - return planner.decide(snapshot, arbiter); + PlayerBotGoalArbiter::GoalDecision decision = arbiter.decide(planner.candidates(snapshot)); + arbiter.apply(decision); + return decision; } -PlayerBotGoalArbiter::GoalDecision PlayerBotProgressionRuntime::force(PlayerBotGoalArbiter::GoalCandidate candidate) +PlayerBotGoalArbiter::GoalDecision PlayerBotProgressionRuntime::interruptForDeparture(bool feasible, int32_t utility, std::string reason) { - return arbiter.force(std::move(candidate)); + PlayerBotGoalArbiter::GoalDecision decision = arbiter.force({PlayerBotGoalArbiter::TopLevelGoal::Departure, feasible, utility, std::move(reason)}); + arbiter.apply(decision); + return decision; } -void PlayerBotProgressionRuntime::apply(const PlayerBotGoalArbiter::GoalDecision& decision) +PlayerBotGoalArbiter::GoalDecision PlayerBotProgressionRuntime::interruptHuntForService(std::string reason) { + PlayerBotGoalArbiter::GoalDecision decision = arbiter.force(planner.forcedServiceCandidate(std::move(reason))); arbiter.apply(decision); + return decision; } -bool PlayerBotProgressionRuntime::isCoolingDown(PlayerBotGoalArbiter::TopLevelGoal goal, - std::chrono::steady_clock::time_point now) const +void PlayerBotProgressionRuntime::enterService() { - return arbiter.isCoolingDown(goal, now); + arbiter.setActiveGoal(PlayerBotGoalArbiter::TopLevelGoal::Service); +} + +void PlayerBotProgressionRuntime::enterHunt() +{ + arbiter.setActiveGoal(PlayerBotGoalArbiter::TopLevelGoal::Hunt); +} + +void PlayerBotProgressionRuntime::completeReward(bool succeeded, std::chrono::steady_clock::duration cooldown) +{ + arbiter.setCooldown(PlayerBotGoalArbiter::TopLevelGoal::PickupReward, cooldown); + if (!succeeded) enterService(); +} + +void PlayerBotProgressionRuntime::completeSpellTraining(bool succeeded, std::chrono::steady_clock::duration cooldown) +{ + arbiter.setCooldown(PlayerBotGoalArbiter::TopLevelGoal::LearnSpell, cooldown); + if (!succeeded) enterService(); +} + +void PlayerBotProgressionRuntime::completeEquipmentPurchase(bool succeeded, std::chrono::steady_clock::duration cooldown) +{ + arbiter.setCooldown(PlayerBotGoalArbiter::TopLevelGoal::BuyEquipment, cooldown); + if (!succeeded) enterService(); } -void PlayerBotProgressionRuntime::setCooldown(PlayerBotGoalArbiter::TopLevelGoal goal, - std::chrono::steady_clock::duration duration) +void PlayerBotProgressionRuntime::completeMagicTraining(std::chrono::steady_clock::duration cooldown) { - arbiter.setCooldown(goal, duration); + arbiter.setCooldown(PlayerBotGoalArbiter::TopLevelGoal::MagicTraining, cooldown); +} + +bool PlayerBotProgressionRuntime::isCoolingDown(PlayerBotGoalArbiter::TopLevelGoal goal, + std::chrono::steady_clock::time_point now) const +{ + return arbiter.isCoolingDown(goal, now); } void PlayerBotProgressionRuntime::beginReward(PlayerBotRewardPlan plan, std::map displacedCounts) @@ -43,6 +76,7 @@ void PlayerBotProgressionRuntime::beginReward(PlayerBotRewardPlan plan, std::map void PlayerBotProgressionRuntime::beginDeparture(PlayerBotOracleDeparturePlan plan) { finish(); + beginNpcConversation(plan.npcId); departureSession.begin(std::move(plan)); progression.begin(PlayerBotProgressionProcedure::OracleDeparture); } @@ -50,6 +84,7 @@ void PlayerBotProgressionRuntime::beginDeparture(PlayerBotOracleDeparturePlan pl void PlayerBotProgressionRuntime::beginSpellTraining(PlayerBotSpellTrainingPlan plan) { finish(); + beginNpcConversation(plan.npcId); spellTrainingSession.begin(std::move(plan)); progression.begin(PlayerBotProgressionProcedure::LearnSpell); } @@ -57,6 +92,7 @@ void PlayerBotProgressionRuntime::beginSpellTraining(PlayerBotSpellTrainingPlan void PlayerBotProgressionRuntime::beginEquipmentPurchase(PlayerBotEquipmentOfferEvaluation plan) { finish(); + if (!plan.carried) beginNpcConversation(plan.npcId); equipmentPurchaseSession.begin(std::move(plan)); progression.begin(PlayerBotProgressionProcedure::BuyEquipment); } @@ -71,6 +107,38 @@ void PlayerBotProgressionRuntime::finish() case PlayerBotProgressionProcedure::None: break; } progression.reset(); + finishNpcConversation(); +} + +void PlayerBotProgressionRuntime::beginNpcConversation(uint32_t npcId) +{ + npcSession.reset(npcId); + equipmentTransaction.reset(); +} + +void PlayerBotProgressionRuntime::finishNpcConversation() +{ + npcSession.reset(); + equipmentTransaction.reset(); +} + +bool PlayerBotProgressionRuntime::reportNpcReply(uint32_t playerId, uint32_t replyingPlayerId, uint32_t npcId, uint8_t type) +{ + return npcSession.acceptReply(playerId, replyingPlayerId, npcId, type); +} + +PlayerBotEquipmentShopCommand PlayerBotProgressionRuntime::advanceEquipmentShop(const PlayerBotNpcShopObservation& observation, + uint32_t maximumRetries) +{ + PlayerBotNpcShopObservation current = observation; + current.greetingAcknowledged = npcSession.isGreetingAcknowledged(); + const PlayerBotNpcSessionOutcome outcome = npcSession.advanceShop(current, maximumRetries); + const char* speech = nullptr; + if (outcome.actionsIssued != 0) speech = npcSession.step() == PlayerBotNpcConversationStep::Request ? "hi" : "trade"; + const char* failureReason = outcome.result == PlayerBotNpcSessionResult::Failed ? + npcSession.step() == PlayerBotNpcConversationStep::Request ? "npc_focus_unconfirmed" : "shop_window_unavailable" : nullptr; + return {outcome.result, speech, observation.otherShopOpen && speech && npcSession.step() == PlayerBotNpcConversationStep::Request, + npcSession.nextDelay(), failureReason}; } PlayerBotProgressionCommand PlayerBotProgressionRuntime::command(PlayerBotProgressionCommandType type, const char* reason) const @@ -78,6 +146,55 @@ PlayerBotProgressionCommand PlayerBotProgressionRuntime::command(PlayerBotProgre return {type, progression.active(), 0, 0, reason}; } +PlayerBotReadinessEquipmentCommand PlayerBotProgressionRuntime::beginReadinessEquipment( + const PlayerBotReadinessEquipmentObservation& observation, bool resumeService, uint32_t maximumRetries) +{ + readinessEquipment.resumeService = readinessEquipment.resumeService || resumeService; + if (!observation.upgradeAvailable || !observation.actionAvailable) return {}; + if (readinessEquipment.itemId != observation.itemId || readinessEquipment.slot != observation.slot) { + readinessEquipment = {}; + readinessEquipment.itemId = observation.itemId; + readinessEquipment.slot = observation.slot; + readinessEquipment.resumeService = resumeService; + } + if (observation.openContainerRequired) { + if (!observation.containerAccessAvailable || ++readinessEquipment.attempts >= maximumRetries) { + const PlayerBotReadinessEquipmentCommand command{PlayerBotReadinessEquipmentCommandType::ServiceFallback, 0, + CONST_SLOT_WHEREEVER, readinessEquipment.attempts, "access_attempts_exhausted"}; + readinessEquipment = {}; + return command; + } + return {PlayerBotReadinessEquipmentCommandType::OpenContainer, observation.itemId, observation.slot, + readinessEquipment.attempts, "open_readiness_container"}; + } + readinessEquipment.pending = true; + return {PlayerBotReadinessEquipmentCommandType::Equip, observation.itemId, observation.slot, + readinessEquipment.attempts, "equip_readiness"}; +} + +PlayerBotReadinessEquipmentCommand PlayerBotProgressionRuntime::advanceReadinessEquipment( + const PlayerBotReadinessEquipmentObservation& observation, uint32_t maximumRetries) +{ + if (!readinessEquipment.pending) return {}; + if (observation.equipmentVerified) { + const PlayerBotReadinessEquipmentCommandType type = readinessEquipment.resumeService ? + PlayerBotReadinessEquipmentCommandType::ResumeService : observation.combatReady ? + PlayerBotReadinessEquipmentCommandType::StartHunt : PlayerBotReadinessEquipmentCommandType::Retry; + const PlayerBotReadinessEquipmentCommand command{type, readinessEquipment.itemId, readinessEquipment.slot, + readinessEquipment.attempts, type == PlayerBotReadinessEquipmentCommandType::Retry ? "readiness_upgrade_incomplete" : nullptr}; + readinessEquipment = {}; + return command; + } + if (++readinessEquipment.attempts >= maximumRetries) { + const PlayerBotReadinessEquipmentCommand command{PlayerBotReadinessEquipmentCommandType::ServiceFallback, + readinessEquipment.itemId, readinessEquipment.slot, readinessEquipment.attempts, "move_not_verified"}; + readinessEquipment = {}; + return command; + } + return {PlayerBotReadinessEquipmentCommandType::Retry, readinessEquipment.itemId, readinessEquipment.slot, + readinessEquipment.attempts, "readiness_retry"}; +} + PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceDeparture(const PlayerBotDepartureObservation& observation) { const auto outcome = [this](PlayerBotProgressionCommandType type, PlayerBotProgressionOutcomeType result, const char* reason) { @@ -268,7 +385,8 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceReward(const Pla return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "invalid_reward_stage"); } -PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceEquipmentPurchase(const PlayerBotEquipmentPurchaseObservation& observation) +PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceEquipmentPurchase( + const PlayerBotEquipmentPurchaseObservation& observation, uint32_t maximumRetries) { const auto outcome = [this](PlayerBotProgressionCommandType type, PlayerBotProgressionOutcomeType result, const char* reason) { return PlayerBotProgressionOutcome{{type, progression.active(), static_cast(equipmentPurchaseSession.stage()), equipmentPurchaseSession.retries(), reason}, @@ -286,19 +404,31 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceEquipmentPurchas 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"); + equipmentTransaction.beginShopTransaction({equipmentPurchaseSession.plan().itemId, 1, observation.itemCount, + observation.money, observation.bankBalance}); equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::VerifyPurchase); return outcome(PlayerBotProgressionCommandType::Shop, PlayerBotProgressionOutcomeType::Pending, "purchase_equipment"); case PlayerBotEquipmentPurchaseStage::VerifyPurchase: - if (observation.transactionMismatch) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "transaction_delta_mismatch"); - if (observation.transactionRejected) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "transaction_rejected"); - if (!observation.transactionSucceeded) { + { + const PlayerBotServiceVerification verification = equipmentTransaction.verifyShopTransaction(observation.itemCount, + observation.money, observation.bankBalance, true, equipmentPurchaseSession.plan().price, 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}; + } equipmentPurchaseSession.incrementRetries(); equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::Purchase); + npcSession.setStep(PlayerBotNpcConversationStep::Ready); return outcome(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "purchase_equipment"); } - equipmentPurchaseSession.resetRetries(); - equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::Equip); - return outcome(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); case PlayerBotEquipmentPurchaseStage::Equip: if (observation.equipmentVerified) { equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::VerifyEquipment); diff --git a/server/src/playerbotprogressionruntime.h b/server/src/playerbotprogressionruntime.h index 1a98084f..1d89a9f1 100644 --- a/server/src/playerbotprogressionruntime.h +++ b/server/src/playerbotprogressionruntime.h @@ -6,7 +6,9 @@ #define FS_PLAYERBOTPROGRESSIONRUNTIME_H #include "playerbotgoalplanner.h" +#include "playerbotnpcsession.h" #include "playerbotprogressionsession.h" +#include "playerbotservicesession.h" enum class PlayerBotProgressionCommandType : uint8_t { None, Navigate, Use, Open, Equip, Speak, Shop, Finish, ServiceFallback, @@ -29,6 +31,7 @@ struct PlayerBotProgressionOutcome { PlayerBotProgressionOutcomeType type = PlayerBotProgressionOutcomeType::Pending; uint32_t attempts = 0; const char* reason = nullptr; + PlayerBotServiceTransaction transaction; }; struct PlayerBotRewardObservation { @@ -77,10 +80,11 @@ struct PlayerBotEquipmentPurchaseObservation { bool providerInRange = true; bool offerAvailable = true; bool shopReady = false; + bool otherShopOpen = false; bool fundingAvailable = true; - bool transactionSucceeded = false; - bool transactionRejected = false; - bool transactionMismatch = false; + uint32_t itemCount = 0; + uint64_t money = 0; + uint64_t bankBalance = 0; bool equipmentAvailable = false; bool equipmentPositionAvailable = true; bool actionAvailable = false; @@ -92,20 +96,61 @@ struct PlayerBotEquipmentPurchaseObservation { std::map displacedCounts; }; +struct PlayerBotReadinessEquipmentObservation { + bool actionAvailable = false; + bool upgradeAvailable = false; + uint16_t itemId = 0; + slots_t slot = CONST_SLOT_WHEREEVER; + bool openContainerRequired = false; + bool containerAccessAvailable = true; + bool equipmentVerified = false; + bool combatReady = false; +}; + +enum class PlayerBotReadinessEquipmentCommandType : uint8_t { + None, OpenContainer, Equip, Retry, ResumeService, StartHunt, ServiceFallback, +}; + +struct PlayerBotReadinessEquipmentCommand { + PlayerBotReadinessEquipmentCommandType type = PlayerBotReadinessEquipmentCommandType::None; + uint16_t itemId = 0; + slots_t slot = CONST_SLOT_WHEREEVER; + uint32_t attempts = 0; + const char* reason = nullptr; +}; + +struct PlayerBotReadinessEquipmentSnapshot { + uint16_t itemId = 0; + slots_t slot = CONST_SLOT_WHEREEVER; + uint32_t attempts = 0; + bool pending = false; +}; + +struct PlayerBotEquipmentShopCommand { + PlayerBotNpcSessionResult result = PlayerBotNpcSessionResult::Pending; + const char* speech = nullptr; + bool closeOtherShop = false; + uint32_t delay = 0; + const char* failureReason = nullptr; +}; + class PlayerBotProgressionRuntime { public: - PlayerBotGoalArbiter::GoalDecision decide(const PlayerBotGoalPlannerSnapshot& snapshot); - PlayerBotGoalArbiter::GoalDecision force(PlayerBotGoalArbiter::GoalCandidate candidate); - void apply(const PlayerBotGoalArbiter::GoalDecision& decision); + PlayerBotGoalArbiter::GoalDecision selectGoal(const PlayerBotGoalPlannerSnapshot& snapshot); + PlayerBotGoalArbiter::GoalDecision interruptForDeparture(bool feasible, int32_t utility, std::string reason); + PlayerBotGoalArbiter::GoalDecision interruptHuntForService(std::string reason); + void enterService(); + void enterHunt(); + void completeReward(bool succeeded, std::chrono::steady_clock::duration cooldown); + void completeSpellTraining(bool succeeded, std::chrono::steady_clock::duration cooldown); + void completeEquipmentPurchase(bool succeeded, std::chrono::steady_clock::duration cooldown); + void completeMagicTraining(std::chrono::steady_clock::duration cooldown); PlayerBotProgressionProcedure active() const { return progression.active(); } bool active(PlayerBotProgressionProcedure procedure) const { return progression.active(procedure); } PlayerBotGoalArbiter::TopLevelGoal activeGoal() const { return arbiter.activeGoal(); } - void setActiveGoal(PlayerBotGoalArbiter::TopLevelGoal goal) { arbiter.setActiveGoal(goal); } uint64_t decisionId() const { return arbiter.decisionId(); } bool isCoolingDown(PlayerBotGoalArbiter::TopLevelGoal goal, std::chrono::steady_clock::time_point now) const; - void setCooldown(PlayerBotGoalArbiter::TopLevelGoal goal, std::chrono::steady_clock::duration duration); - const PlayerBotGoalArbiter& goalArbiter() const { return arbiter; } const PlayerBotProgressionSession& session() const { return progression; } void beginReward(PlayerBotRewardPlan plan, std::map displacedCounts); @@ -113,6 +158,20 @@ class PlayerBotProgressionRuntime { void beginSpellTraining(PlayerBotSpellTrainingPlan plan); void beginEquipmentPurchase(PlayerBotEquipmentOfferEvaluation plan); void finish(); + bool reportNpcReply(uint32_t playerId, uint32_t replyingPlayerId, uint32_t npcId, uint8_t type); + bool greetingAcknowledged() const { return npcSession.isGreetingAcknowledged(); } + void clearGreetingAcknowledgement() { npcSession.resetGreetingAcknowledgement(); } + PlayerBotEquipmentShopCommand advanceEquipmentShop(const PlayerBotNpcShopObservation& observation, + uint32_t maximumRetries); + bool readinessEquipmentPending() const { return readinessEquipment.pending; } + PlayerBotReadinessEquipmentSnapshot readinessEquipmentSnapshot() const + { + return {readinessEquipment.itemId, readinessEquipment.slot, readinessEquipment.attempts, readinessEquipment.pending}; + } + PlayerBotReadinessEquipmentCommand beginReadinessEquipment(const PlayerBotReadinessEquipmentObservation& observation, + bool resumeService, uint32_t maximumRetries); + PlayerBotReadinessEquipmentCommand advanceReadinessEquipment(const PlayerBotReadinessEquipmentObservation& observation, + uint32_t maximumRetries); const PlayerBotRewardSession& reward() const { return rewardSession; } const PlayerBotOracleDepartureSession& departure() const { return departureSession; } @@ -122,7 +181,8 @@ class PlayerBotProgressionRuntime { PlayerBotProgressionOutcome advanceReward(const PlayerBotRewardObservation& observation); PlayerBotProgressionOutcome advanceDeparture(const PlayerBotDepartureObservation& observation); PlayerBotProgressionOutcome advanceSpellTraining(const PlayerBotSpellTrainingObservation& observation); - PlayerBotProgressionOutcome advanceEquipmentPurchase(const PlayerBotEquipmentPurchaseObservation& observation); + PlayerBotProgressionOutcome advanceEquipmentPurchase(const PlayerBotEquipmentPurchaseObservation& observation, + uint32_t maximumRetries); PlayerBotProgressionCommand command(PlayerBotProgressionCommandType type, const char* reason = nullptr) const; PlayerBotProgressionCommand navigate(const char* reason = nullptr) const { return command(PlayerBotProgressionCommandType::Navigate, reason); } PlayerBotProgressionCommand use(const char* reason = nullptr) const { return command(PlayerBotProgressionCommandType::Use, reason); } @@ -139,6 +199,8 @@ class PlayerBotProgressionRuntime { } private: + void beginNpcConversation(uint32_t npcId); + void finishNpcConversation(); PlayerBotGoalPlanner planner; PlayerBotGoalArbiter arbiter; PlayerBotProgressionSession progression; @@ -146,6 +208,15 @@ class PlayerBotProgressionRuntime { PlayerBotOracleDepartureSession departureSession; PlayerBotSpellTrainingSession spellTrainingSession; PlayerBotEquipmentPurchaseSession equipmentPurchaseSession; + PlayerBotNpcSession npcSession; + PlayerBotServiceSession equipmentTransaction; + struct { + uint16_t itemId = 0; + slots_t slot = CONST_SLOT_WHEREEVER; + uint32_t attempts = 0; + bool pending = false; + bool resumeService = false; + } readinessEquipment; }; #endif diff --git a/server/src/playerbotprogressionsession.h b/server/src/playerbotprogressionsession.h index 29e829cd..dff5c4c0 100644 --- a/server/src/playerbotprogressionsession.h +++ b/server/src/playerbotprogressionsession.h @@ -11,9 +11,12 @@ #include #include +#include "creature.h" #include "playerbotequipmentpolicy.h" #include "position.h" +class PlayerBotProgressionRuntime; + enum class PlayerBotProgressionProcedure : uint8_t { None, PickupReward, @@ -79,9 +82,6 @@ struct PlayerBotNestedContainerAccessState { class PlayerBotRewardSession { public: - void begin(PlayerBotRewardPlan reward); - void reset(); - const PlayerBotRewardPlan& plan() const { return reward; } PlayerBotRewardStage stage() const { return currentStage; } uint32_t retries() const { return attempts; } @@ -89,6 +89,8 @@ class PlayerBotRewardSession private: friend class PlayerBotProgressionRuntime; + void begin(PlayerBotRewardPlan reward); + void reset(); void setStage(PlayerBotRewardStage stage) { currentStage = stage; } uint32_t incrementRetries() { return ++attempts; } void resetRetries() { attempts = 0; } @@ -126,15 +128,14 @@ enum class PlayerBotOracleDepartureStage : uint8_t { class PlayerBotOracleDepartureSession { public: - void begin(PlayerBotOracleDeparturePlan departure); - void reset(); - const PlayerBotOracleDeparturePlan& plan() const { return departure; } PlayerBotOracleDepartureStage stage() const { return currentStage; } uint32_t retries() const { return attempts; } private: friend class PlayerBotProgressionRuntime; + void begin(PlayerBotOracleDeparturePlan departure); + void reset(); void setStage(PlayerBotOracleDepartureStage stage) { currentStage = stage; } uint32_t incrementRetries() { return ++attempts; } void resetRetries() { attempts = 0; } @@ -166,9 +167,6 @@ enum class PlayerBotSpellTrainingStage : uint8_t { class PlayerBotSpellTrainingSession { public: - void begin(PlayerBotSpellTrainingPlan training); - void reset(); - const PlayerBotSpellTrainingPlan& plan() const { return training; } PlayerBotSpellTrainingStage stage() const { return currentStage; } uint32_t retries() const { return attempts; } @@ -176,6 +174,8 @@ class PlayerBotSpellTrainingSession private: friend class PlayerBotProgressionRuntime; + void begin(PlayerBotSpellTrainingPlan training); + void reset(); void setStage(PlayerBotSpellTrainingStage stage) { currentStage = stage; } uint32_t incrementRetries() { return ++attempts; } void resetRetries() { attempts = 0; } @@ -197,15 +197,14 @@ enum class PlayerBotEquipmentPurchaseStage : uint8_t { class PlayerBotEquipmentPurchaseSession { public: - void begin(PlayerBotEquipmentOfferEvaluation purchase); - void reset(); - const PlayerBotEquipmentOfferEvaluation& plan() const { return purchase; } PlayerBotEquipmentPurchaseStage stage() const { return currentStage; } uint32_t retries() const { return attempts; } private: friend class PlayerBotProgressionRuntime; + void begin(PlayerBotEquipmentOfferEvaluation purchase); + void reset(); void setStage(PlayerBotEquipmentPurchaseStage stage) { currentStage = stage; } uint32_t incrementRetries() { return ++attempts; } void resetRetries() { attempts = 0; } @@ -225,10 +224,11 @@ class PlayerBotProgressionSession public: PlayerBotProgressionProcedure active() const { return procedure; } bool active(PlayerBotProgressionProcedure value) const { return procedure == value; } - void begin(PlayerBotProgressionProcedure value) { procedure = value; } - void reset() { procedure = PlayerBotProgressionProcedure::None; } private: + friend class PlayerBotProgressionRuntime; + void begin(PlayerBotProgressionProcedure value) { procedure = value; } + void reset() { procedure = PlayerBotProgressionProcedure::None; } PlayerBotProgressionProcedure procedure = PlayerBotProgressionProcedure::None; }; diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index 1a7184bb..eae01c9b 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -60,7 +60,6 @@ void PlayerBotController::beginReturn(Player* player, const Position& position, clearNavigation(); lootWorkflow.reset(); depotWorkflow.reset(); - navigationRuntime.resetFixedTargetRouteFailures(); player->closeContainer(corpseContainerId); setStage(ScenarioStage::Traverse, position); setCyclePhase(CyclePhase::ReturnToDepot, position, reason); @@ -73,7 +72,9 @@ void PlayerBotController::beginReturn(Player* player, const Position& position, void PlayerBotController::onNpcReply(uint32_t replyingPlayerId, uint32_t npcId, uint8_t type, const std::string& text) { - if (!serviceWorkflow.acceptNpcReply(playerId, replyingPlayerId, npcId, type)) { + const bool serviceAccepted = serviceWorkflow.reportNpcReply(playerId, replyingPlayerId, npcId, type); + const bool progressionAccepted = progressionRuntime.reportNpcReply(playerId, replyingPlayerId, npcId, type); + if (!serviceAccepted && !progressionAccepted) { return; } Npc* npc = g_game.getNpcByID(npcId); @@ -83,23 +84,21 @@ void PlayerBotController::onNpcReply(uint32_t replyingPlayerId, uint32_t npcId, void PlayerBotController::beginService(Player* player, const Position& position, const char* reason) { - const bool interruptedHunt = fixtureDriver.goalLoop(true).selectGoal && goalArbiter.activeGoal() == TopLevelGoal::Hunt && + const bool interruptedHunt = fixtureDriver.goalLoop(true).selectGoal && progressionRuntime.activeGoal() == TopLevelGoal::Hunt && !departurePlanner.hasCompleted(departureSnapshot(*player)); finishHuntRegion(*player, position, reason); if (interruptedHunt) { emit("goal_result", position, - "\"decision_id\":" + std::to_string(goalArbiter.decisionId()) + + "\"decision_id\":" + std::to_string(progressionRuntime.decisionId()) + ",\"goal\":\"hunt\",\"result\":\"interrupted\",\"reason\":" + jsonString(reason)); - const PlayerBotGoalArbiter::GoalDecision decision = progressionRuntime.force( - {TopLevelGoal::Service, true, criticalHealingServiceUtility, "forced_interrupt"}); - progressionRuntime.apply(decision); + const PlayerBotGoalArbiter::GoalDecision decision = progressionRuntime.interruptHuntForService("forced_interrupt"); emit("goal_selection", position, "\"decision_id\":" + std::to_string(decision.id) + ",\"decision_reason\":" + jsonString(reason) + ",\"from_goal\":\"hunt\",\"to_goal\":\"service\",\"utility\":" + - std::to_string(criticalHealingServiceUtility) + ',' + + std::to_string(decision.candidate(TopLevelGoal::Service).utility) + ',' + "\"reason\":\"forced_interrupt\",\"forced\":true"); } - progressionRuntime.setActiveGoal(TopLevelGoal::Service); + progressionRuntime.enterService(); g_game.playerCancelAttackAndFollow(playerId); clearTraversalTarget(position, reason); clearNavigation(); @@ -121,129 +120,12 @@ void PlayerBotController::finishHuntAndSelectGoal(Player* player, const Position setStage(ScenarioStage::Traverse, position); setCyclePhase(CyclePhase::Idle, position, reason); emit("goal_result", position, - "\"decision_id\":" + std::to_string(goalArbiter.decisionId()) + + "\"decision_id\":" + std::to_string(progressionRuntime.decisionId()) + ",\"goal\":\"hunt\",\"result\":\"success\",\"reason\":" + jsonString(reason)); selectTopLevelGoal(*player, position, reason); schedule(SCHEDULER_MINTICKS); } -void PlayerBotController::discoverServices(const Position& position) -{ - std::vector shops; - std::vector bankers; - Player* player = g_game.getPlayerByID(playerId); - for (const auto& entry : g_game.getNpcs()) { - Npc* npc = entry.second; - const std::string* capability = npc && !npc->isRemoved() ? npc->getParameter("playerbot_service") : nullptr; - if (!capability || !player || - serviceDistance(player->getTemplePosition(), {npc->getID(), npc->getPosition()}) > maximumServiceDistanceFromTemple) { - continue; - } - std::vector* services = *capability == "shop" ? &shops : - (*capability == "banker" ? &bankers : nullptr); - if (!services) { - continue; - } - ServiceNpc provider{npc->getID(), npc->getPosition()}; - for (const ShopInfo& offer : npc->getShopOffers()) { - const ItemType& type = Item::items[offer.itemId]; - if (!type.isFluidContainer() && !type.isSplash() && - fixtureDriver.observeProvider(true, offer.itemId, offer.sellPrice == 0).available) { - provider.offers.push_back({offer.itemId, offer.buyPrice, offer.sellPrice, static_cast(offer.subType)}); - } - } - services->push_back(std::move(provider)); - emit("service_discovered", position, "\"capability\":" + jsonString(*capability) + - ",\"npc_id\":" + std::to_string(npc->getID()) + ",\"npc_name\":" + jsonString(npc->getName()) + - ",\"offers\":" + std::to_string(npc->getShopOffers().size())); - } - if (shops.empty() || bankers.empty()) { - stop("service_npc_unavailable", position); - return; - } - PlayerBotServiceObservation observation; - observation.currentPosition = position; - observation.discoveryObserved = true; - observation.shops = std::move(shops); - observation.bankers = std::move(bankers); - if (serviceWorkflow.advance(observation, economyCatalog, dispositionPolicy).type == PlayerBotServiceCommandType::Fail) { - stop("service_npc_unavailable", position); - return; - } - economyCatalog.learn(serviceWorkflow.shops()); -} - -bool PlayerBotController::approachServiceNpc(Player* player, const ServiceNpc& service, const Position& currentPosition) -{ - Npc* npc = g_game.getNpcByID(service.id); - if (!npc || npc->isRemoved()) { - stop("service_npc_unavailable", currentPosition); - return false; - } - const Position servicePosition = npc->getPosition(); - if (Position::areInRange<3, 3, 0>(currentPosition, servicePosition)) { - serviceWorkflow.clearApproach(); - return true; - } - if (serviceWorkflow.approachTarget() != Position()) { - if (currentPosition == serviceWorkflow.approachTarget()) { - serviceWorkflow.clearApproach(); - clearNavigation(); - schedule(SCHEDULER_MINTICKS); - return false; - } - return processNavigation(player, currentPosition, serviceWorkflow.approachTarget()); - } - - std::vector candidates; - candidates.reserve(48); - for (int32_t xOffset = -3; xOffset <= 3; ++xOffset) { - for (int32_t yOffset = -3; yOffset <= 3; ++yOffset) { - if (xOffset != 0 || yOffset != 0) { - candidates.emplace_back(servicePosition.x + xOffset, servicePosition.y + yOffset, servicePosition.z); - } - } - } - std::sort(candidates.begin(), candidates.end(), [¤tPosition](const Position& left, const Position& right) { - const int32_t leftDistance = std::max(Position::getDistanceX(currentPosition, left), Position::getDistanceY(currentPosition, left)); - const int32_t rightDistance = std::max(Position::getDistanceX(currentPosition, right), Position::getDistanceY(currentPosition, right)); - return leftDistance == rightDistance ? left < right : leftDistance < rightDistance; - }); - for (const Position& candidate : candidates) { - if (serviceWorkflow.isApproachRejected(candidate)) { - continue; - } - Tile* tile = g_game.map.getTile(candidate); - if (!tile || tile->queryAdd(0, *player, 1, 0) != RETURNVALUE_NOERROR) { - continue; - } - std::deque candidateSteps; - uint64_t expandedNodes = 0; - const auto startedAt = std::chrono::steady_clock::now(); - PlayerBotNavigationRoutePlan routePlan = navigationRuntime.plan(*player, candidate); - const bool planned = routePlan.metrics.result == PlayerBotNavigationResult::Reached; - candidateSteps = std::move(routePlan.steps); - expandedNodes = routePlan.metrics.expandedNodes; - telemetry.recordPathfinding(std::chrono::duration_cast( - std::chrono::steady_clock::now() - startedAt), planned && !candidateSteps.empty()); - if (!planned || candidateSteps.empty()) { - serviceWorkflow.rejectApproach(candidate); - schedule(SCHEDULER_MINTICKS); - return false; - } - serviceWorkflow.setApproachTarget(candidate); - navigationRuntime.adopt(candidate, std::move(candidateSteps)); - std::ostringstream fields; - fields << "\"action\":\"plan\",\"result\":\"success\",\"steps\":" << navigationRuntime.routeSize() - << ",\"expanded_nodes\":" << expandedNodes << ",\"destination\":{\"x\":" << candidate.x - << ",\"y\":" << candidate.y << ",\"z\":" << static_cast(candidate.z) << '}'; - emit("action_result", currentPosition, fields.str()); - return processNavigation(player, currentPosition, candidate); - } - stop("service_approach_unavailable", currentPosition); - return false; -} - void PlayerBotController::refreshItemValues() { std::vector providers; @@ -256,10 +138,11 @@ void PlayerBotController::refreshItemValues() PlayerBotEconomyProvider provider{npc->getID(), npc->getPosition()}; for (const ShopInfo& offer : npc->getShopOffers()) { const ItemType& type = Item::items[offer.itemId]; - if (offer.sellPrice != 0 && !type.isFluidContainer() && !type.isSplash() && - fixtureDriver.observeProvider(true, offer.itemId, false).available) { - provider.offers.push_back({offer.itemId, offer.buyPrice, offer.sellPrice, static_cast(offer.subType)}); - } + if (type.isFluidContainer() || type.isSplash()) continue; + const bool buyAvailable = offer.buyPrice != 0 && fixtureDriver.observeProvider(true, offer.itemId, true).available; + const bool sellAvailable = offer.sellPrice != 0 && fixtureDriver.observeProvider(true, offer.itemId, false).available; + if (buyAvailable || sellAvailable) provider.offers.push_back({offer.itemId, buyAvailable ? offer.buyPrice : 0, + sellAvailable ? offer.sellPrice : 0, static_cast(offer.subType)}); } providers.push_back(std::move(provider)); } @@ -288,421 +171,188 @@ uint32_t PlayerBotController::serviceDistance(const Position& from, const Servic (from.z == service.position.z ? 0 : 32 * Position::getDistanceZ(from, service.position)); } -const PlayerBotController::ServiceNpc* PlayerBotController::findNearestService(const std::vector& services, const Position& position) const -{ - auto it = std::min_element(services.begin(), services.end(), [this, &position](const ServiceNpc& left, const ServiceNpc& right) { - return serviceDistance(position, left) < serviceDistance(position, right); - }); - return it == services.end() ? nullptr : &*it; -} - -const PlayerBotController::ServiceNpc* PlayerBotController::findShopFor(uint16_t itemId, bool buying, const Position& position) const -{ - return economyCatalog.rankedProvider(serviceWorkflow.shops(), itemId, buying, position); -} - -const PlayerBotController::ServiceNpc* PlayerBotController::findLootSeller(Player* player, const Position& position, uint16_t& itemId) const +void PlayerBotController::processService(Player* player, const Position& currentPosition) { - const ServiceNpc* nearest = nullptr; - uint32_t selectedSellPrice = 0; - for (const ServiceNpc& service : serviceWorkflow.shops()) { - Npc* npc = g_game.getNpcByID(service.id); - if (!npc || npc->isRemoved()) { - continue; - } + PlayerBotServiceObservation observation; + observation.currentPosition = currentPosition; + observation.freeCapacity = player->getFreeCapacity(); + observation.money = player->getMoney(); + observation.bankBalance = player->getBankBalance(); + observation.goldCoinWeight = Item::items[ITEM_GOLD_COIN].weight; + observation.smallHealthPotionWeight = Item::items[PlayerBotDispositionPolicy::smallHealthPotionItemId].weight; + observation.maximumAttempts = maximumServiceAttempts; + observation.slottedSaleCooldownMs = static_cast( + std::chrono::duration_cast(unavailableDispositionCooldown).count()); + for (const auto& entry : g_game.getNpcs()) { + Npc* npc = entry.second; + const std::string* capability = npc && !npc->isRemoved() ? npc->getParameter("playerbot_service") : nullptr; + if (!capability || serviceDistance(player->getTemplePosition(), {npc->getID(), npc->getPosition()}) > maximumServiceDistanceFromTemple) continue; + if (*capability != "shop" && *capability != "banker") continue; + PlayerBotEconomyProvider provider{npc->getID(), npc->getPosition()}; for (const ShopInfo& offer : npc->getShopOffers()) { - if (offer.sellPrice != 0 && fixtureDriver.observeProvider(true, offer.itemId, false).available && - getSaleItemCount(*player, offer.itemId) > 0 && - (!nearest || offer.sellPrice > selectedSellPrice || - (offer.sellPrice == selectedSellPrice && serviceDistance(position, service) < serviceDistance(position, *nearest)))) { - itemId = offer.itemId; - nearest = &service; - selectedSellPrice = offer.sellPrice; + const ItemType& type = Item::items[offer.itemId]; + if (type.isFluidContainer() || type.isSplash()) continue; + const bool buyAvailable = offer.buyPrice != 0 && fixtureDriver.observeProvider(true, offer.itemId, true).available; + const bool sellAvailable = offer.sellPrice != 0 && fixtureDriver.observeProvider(true, offer.itemId, false).available; + if (!buyAvailable && !sellAvailable) continue; + provider.offers.push_back({offer.itemId, buyAvailable ? offer.buyPrice : 0, + sellAvailable ? offer.sellPrice : 0, static_cast(offer.subType)}); + observation.inventoryCounts.emplace(offer.itemId, inventoryPolicy.inventoryItemCount(*player, offer.itemId)); + observation.backpackSaleCounts.emplace(offer.itemId, inventoryPolicy.backpackSaleItemCount(*player, offer.itemId)); + } + int32_t onBuy; + int32_t onSell; + PlayerBotServiceProviderObservation providerObservation{ + true, Position::areInRange<3, 3, 0>(currentPosition, npc->getPosition()), + player->getShopOwner(onBuy, onSell) == npc && !player->getShopItemList().empty()}; + if (!providerObservation.inRange) { + for (int32_t xOffset = -3; xOffset <= 3; ++xOffset) for (int32_t yOffset = -3; yOffset <= 3; ++yOffset) { + if (xOffset == 0 && yOffset == 0) continue; + const Position approach(npc->getPosition().x + xOffset, npc->getPosition().y + yOffset, npc->getPosition().z); + Tile* tile = g_game.map.getTile(approach); + if (tile && tile->queryAdd(0, *player, 1, 0) == RETURNVALUE_NOERROR) { + providerObservation.approaches.push_back({approach, static_cast( + std::max(Position::getDistanceX(currentPosition, approach), Position::getDistanceY(currentPosition, approach)))}); + } } + std::sort(providerObservation.approaches.begin(), providerObservation.approaches.end(), [](const auto& left, + const auto& right) { + return left.distance != right.distance ? left.distance < right.distance : left.position < right.position; + }); + } + observation.providers[npc->getID()] = std::move(providerObservation); + observation.discoveries.push_back({npc->getID(), npc->getName(), *capability, + static_cast(npc->getShopOffers().size())}); + if (*capability == "shop" && !provider.offers.empty()) observation.shops.push_back(std::move(provider)); + else if (*capability == "banker") observation.bankers.push_back(std::move(provider)); + } + observation.now = std::chrono::steady_clock::now(); + for (int32_t slot = CONST_SLOT_FIRST; slot <= CONST_SLOT_LAST; ++slot) { + Item* item = player->getInventoryItem(static_cast(slot)); + if (item && inventoryPolicy.isActionableSlottedItem(*player, *item, static_cast(slot), 0)) { + observation.slottedSaleItems.push_back({item->getID(), static_cast(slot), item->getItemCount()}); + } + } + PlayerBotServiceCommand command = serviceWorkflow.advance(observation, economyCatalog, dispositionPolicy); + const std::vector discoveries = command.discoveries; + std::deque approachSteps; + while (command.type == PlayerBotServiceCommandType::ValidateProviderRoute) { + PlayerBotServiceObservation routeObservation = observation; + routeObservation.approachRoute.providerId = command.providerId; + routeObservation.approachRoute.destination = command.destination; + const auto startedAt = std::chrono::steady_clock::now(); + PlayerBotNavigationRoutePlan routePlan; + if (command.destination != currentPosition) routePlan = planNavigationRoute(*player, command.destination); + const bool reached = command.destination == currentPosition || + (routePlan.metrics.result == PlayerBotNavigationResult::Reached && !routePlan.steps.empty()); + telemetry.recordPathfinding(std::chrono::duration_cast(std::chrono::steady_clock::now() - startedAt), reached); + routeObservation.approachRoute.result = reached ? PlayerBotServiceRouteResult::Reached : PlayerBotServiceRouteResult::Unreachable; + routeObservation.approachRoute.steps = static_cast(routePlan.steps.size()); + routeObservation.approachRoute.expandedNodes = routePlan.metrics.expandedNodes; + if (reached) approachSteps = std::move(routePlan.steps); + command = serviceWorkflow.advance(routeObservation, economyCatalog, dispositionPolicy); + } + for (const PlayerBotServiceDiscovery& discovery : discoveries) { + emit("service_discovered", currentPosition, "\"capability\":" + jsonString(discovery.capability) + + ",\"npc_id\":" + std::to_string(discovery.npcId) + ",\"npc_name\":" + jsonString(discovery.npcName) + + ",\"offers\":" + std::to_string(discovery.offers)); + } + if (command.verification && command.verification->result == PlayerBotServiceVerificationResult::Success && command.transaction) { + const PlayerBotServiceTransaction& transaction = *command.transaction; + if (transaction.itemId != 0) { + const char* action = serviceWorkflow.stage() == PlayerBotServiceStage::SellLoot ? "sell" : "buy_potions"; + emit("action_result", currentPosition, "\"action\":" + jsonString(action) + ",\"result\":\"success\",\"item_id\":" + + std::to_string(transaction.itemId) + ",\"count\":" + std::to_string(transaction.amount) + + ",\"carried_before\":" + std::to_string(transaction.money) + ",\"carried_after\":" + + std::to_string(observation.money) + ",\"bank_before\":" + std::to_string(transaction.balance) + + ",\"bank_after\":" + std::to_string(observation.bankBalance)); + const ItemType& itemType = Item::items[transaction.itemId]; + say(*player, std::string(action) == "sell" ? "Sold " + std::to_string(transaction.amount) + " " + + (transaction.amount == 1 ? itemType.name : itemType.getPluralName()) + '.' : "Bought " + + std::to_string(transaction.amount) + " " + (transaction.amount == 1 ? itemType.name : itemType.getPluralName()) + '.'); + } else if (command.type == PlayerBotServiceCommandType::Complete) { + emit("action_result", currentPosition, "\"action\":\"bank_withdraw\",\"result\":\"success\",\"count\":" + + std::to_string(transaction.amount) + ",\"bank_before\":" + std::to_string(transaction.balance) + + ",\"bank_after\":" + std::to_string(observation.bankBalance)); + } else { + emit("action_result", currentPosition, "\"action\":\"bank_deposit\",\"result\":\"success\",\"count\":" + + std::to_string(transaction.money) + ",\"bank_before\":" + std::to_string(transaction.balance) + + ",\"bank_after\":" + std::to_string(observation.bankBalance)); + say(*player, "Deposited " + std::to_string(transaction.money) + " gold. Bank: " + + std::to_string(observation.bankBalance) + '.'); } } - return nearest; -} - -bool PlayerBotController::prepareSlottedSaleItem(Player* player, uint16_t itemId, const Position& position) -{ - Item* backpackItem = player->getInventoryItem(CONST_SLOT_BACKPACK); - Container* backpack = backpackItem ? backpackItem->getContainer() : nullptr; - if (!backpack) { - return false; + if (command.type == PlayerBotServiceCommandType::Fail) { + stop(command.outcome == PlayerBotServiceOutcome::InsufficientFunds ? "insufficient_potion_funds" : "required_shop_offer_unavailable", currentPosition); + return; } - if (const auto pending = serviceWorkflow.pendingSlottedSale()) { - Item* sourceAfter = player->getInventoryItem(pending->sourceSlot); - const uint32_t backpackAfter = backpack->getItemTypeCount(pending->itemId); - const bool moved = (!sourceAfter || sourceAfter->getID() != pending->itemId) && backpackAfter > pending->backpackCount; - const PlayerBotSlottedSaleObservation observation = serviceWorkflow.observeSlottedSale( - moved, maximumServiceAttempts, std::chrono::steady_clock::now(), unavailableDispositionCooldown); - if (observation == PlayerBotSlottedSaleObservation::Moved) { - emit("action_result", position, + if (command.type == PlayerBotServiceCommandType::Wait && command.itemId != 0 && + command.sourceSlot != CONST_SLOT_WHEREEVER) { + if (command.outcome == PlayerBotServiceOutcome::Success) { + emit("action_result", currentPosition, "\"action\":\"item_disposition\",\"result\":\"success\",\"disposition\":\"sell\",\"item_id\":" + - std::to_string(pending->itemId) + ",\"source_slot\":" + - std::to_string(pending->sourceSlot) + ",\"provider_available\":true"); - schedule(SCHEDULER_MINTICKS); - return true; - } - const uint16_t failedItemId = pending->itemId; - const slots_t failedSlot = pending->sourceSlot; - if (observation == PlayerBotSlottedSaleObservation::Deferred) { - emit("action_result", position, + std::to_string(command.itemId) + ",\"source_slot\":" + std::to_string(command.sourceSlot) + + ",\"provider_available\":" + (command.providerAvailable ? "true" : "false")); + } else if (command.outcome == PlayerBotServiceOutcome::Unavailable) { + emit("action_result", currentPosition, "\"action\":\"item_disposition\",\"result\":\"deferred\",\"reason\":\"move_not_verified\",\"disposition\":\"sell\",\"item_id\":" + - std::to_string(failedItemId) + ",\"source_slot\":" + std::to_string(failedSlot) + - ",\"provider_available\":true,\"cooldown_ms\":" + - std::to_string(std::chrono::duration_cast(unavailableDispositionCooldown).count())); - serviceWorkflow.skipSelling(); - schedule(SCHEDULER_MINTICKS); - return true; - } - schedule(navigationDecisionDelay(*player)); - return true; - } - - slots_t sourceSlot = CONST_SLOT_WHEREEVER; - Item* item = findActionableSlottedItem(*player, itemId, sourceSlot); - if (!item) { - return false; - } - const int8_t backpackId = player->getContainerID(backpack); - if (backpackId < 0) { - if (!player->canDoAction()) { - schedule(navigationDecisionDelay(*player)); - return true; - } - telemetry.recordActionAttempt(); - g_game.playerUseItem(playerId, Position(0xFFFF, CONST_SLOT_BACKPACK, 0), 0, - depotSourceContainerId, backpack->getClientID()); - schedule(navigationDecisionDelay(*player)); - return true; - } - if (!player->canDoAction()) { - schedule(navigationDecisionDelay(*player)); - return true; - } - Position sourcePosition; - uint8_t sourceIndex = 0; - g_game.internalGetPosition(item, sourcePosition, sourceIndex); - serviceWorkflow.beginSlottedSale(itemId, sourceSlot, backpack->getItemTypeCount(itemId)); - telemetry.recordActionAttempt(); - g_game.playerMoveItem(player, sourcePosition, item->getClientID(), sourceIndex, - Position(0xFFFF, 0x40 | static_cast(backpackId), - containerDestinationIndex(*backpack, *item)), - static_cast(item->getItemCount()), item, backpack); - emit("action_result", position, - "\"action\":\"item_disposition\",\"result\":\"requested\",\"disposition\":\"sell\",\"item_id\":" + - std::to_string(itemId) + ",\"source_slot\":" + std::to_string(sourceSlot) + - ",\"provider_available\":true,\"attempt\":" + std::to_string(serviceWorkflow.slottedSaleAttempts())); - schedule(navigationDecisionDelay(*player)); - return true; -} - -void PlayerBotController::completeServiceAction(Player* player, const char* action, - const PlayerBotServiceTransaction& transaction, const Position& position) -{ - std::ostringstream fields; - fields << "\"action\":" << jsonString(action) << ",\"result\":\"success\",\"item_id\":" << transaction.itemId - << ",\"count\":" << transaction.amount << ",\"carried_before\":" << transaction.money - << ",\"carried_after\":" << player->getMoney() << ",\"bank_before\":" << transaction.balance - << ",\"bank_after\":" << player->getBankBalance(); - emit("action_result", position, fields.str()); - const ItemType& itemType = Item::items[transaction.itemId]; - const std::string itemName = transaction.amount == 1 ? itemType.name : itemType.getPluralName(); - say(*player, std::string(action) == "sell" ? - "Sold " + std::to_string(transaction.amount) + " " + itemName + '.' : - "Bought " + std::to_string(transaction.amount) + " " + itemName + '.'); - serviceWorkflow.setNpcStep(PlayerBotNpcConversationStep::Ready); - serviceWorkflow.resetNpcRetries(); - schedule(SCHEDULER_MINTICKS); -} - -void PlayerBotController::processServiceShop(Player* player, const Position& currentPosition, const ServiceNpc& service, const char* action, - uint16_t itemId, uint32_t amount, bool purchase) -{ - if (!approachServiceNpc(player, service, currentPosition)) { - return; - } - Npc* npc = g_game.getNpcByID(service.id); - if (!npc || npc->isRemoved()) { - stop("service_npc_unavailable", currentPosition); - return; - } - const PlayerBotNpcSessionOutcome sessionOutcome = serviceWorkflow.openNpcShop(*player, *npc, maximumServiceAttempts); - for (uint8_t action = 0; action < sessionOutcome.actionsIssued; ++action) { - telemetry.recordActionAttempt(); - } - if (sessionOutcome.result != PlayerBotNpcSessionResult::Ready) { - if (sessionOutcome.result == PlayerBotNpcSessionResult::Failed) { - logActionFailure("shop", serviceWorkflow.npcStep() == PlayerBotNpcConversationStep::Request ? - "npc_focus_unconfirmed" : "shop_window_unavailable", currentPosition); - stop("shop_transaction_unavailable", currentPosition); - } else { - schedule(serviceWorkflow.npcNextDelay() == 0 ? SCHEDULER_MINTICKS : serviceWorkflow.npcNextDelay()); + std::to_string(command.itemId) + ",\"source_slot\":" + std::to_string(command.sourceSlot) + + ",\"provider_available\":" + (command.providerAvailable ? "true" : "false") + + ",\"cooldown_ms\":" + std::to_string(command.cooldownMs)); } - return; } - const ShopInfo* offer = findOffer(service, itemId, purchase); - if (!offer || amount == 0 || amount > 100) { - stop("shop_offer_unavailable", currentPosition); + Npc* provider = command.providerId == 0 ? nullptr : g_game.getNpcByID(command.providerId); + if (command.type == PlayerBotServiceCommandType::NavigateProvider && provider) { + if (!approachSteps.empty()) observeNavigationPlan(command.destination, std::move(approachSteps)); + processNavigation(player, currentPosition, command.destination); return; } - if (serviceWorkflow.npcStep() == PlayerBotNpcConversationStep::Ready) { - if (!serviceWorkflow.hasShopTransaction()) { - serviceWorkflow.beginShopTransaction({itemId, amount, inventoryPolicy.inventoryItemCount(*player, itemId), - player->getMoney(), player->getBankBalance()}); - } - serviceWorkflow.setNpcStep(PlayerBotNpcConversationStep::Verify); + if (command.type == PlayerBotServiceCommandType::Speak && provider) { telemetry.recordActionAttempt(); - if (purchase) { - g_game.playerPurchaseItem(playerId, Item::items[itemId].clientId, static_cast(offer->subType), - static_cast(amount), false, false); - } else { - g_game.playerSellItem(playerId, Item::items[itemId].clientId, static_cast(offer->subType), - static_cast(amount), true); - } - schedule(navigationDecisionDelay(*player)); - return; - } - - const PlayerBotServiceTransaction* transaction = serviceWorkflow.shopTransaction(); - if (!transaction) { - stop("shop_transaction_missing", currentPosition); - return; - } - const PlayerBotServiceVerification verification = serviceWorkflow.verifyShopTransaction( - inventoryPolicy.inventoryItemCount(*player, transaction->itemId), player->getMoney(), player->getBankBalance(), purchase, - purchase ? offer->buyPrice : offer->sellPrice, maximumServiceAttempts); - if (verification.result == PlayerBotServiceVerificationResult::Success) { - completeServiceAction(player, action, verification.before, currentPosition); - return; - } - if (verification.result == PlayerBotServiceVerificationResult::Mismatch) { - logActionFailure(action, "transaction_delta_mismatch", currentPosition); - stop("shop_transaction_delta_mismatch", currentPosition); - return; - } - if (verification.result == PlayerBotServiceVerificationResult::Rejected) { - logActionFailure(action, "transaction_not_verified", currentPosition); - stop("shop_transaction_not_verified", currentPosition); - return; - } - serviceWorkflow.setNpcStep(PlayerBotNpcConversationStep::Ready); - schedule(navigationDecisionDelay(*player)); -} - -void PlayerBotController::processBank(Player* player, const Position& currentPosition, const ServiceNpc& banker) -{ - if (!approachServiceNpc(player, banker, currentPosition)) { - return; - } - Npc* npc = g_game.getNpcByID(banker.id); - if (!npc || npc->isRemoved()) { - stop("banker_unavailable", currentPosition); + provider->receiveSpeech(player, TALKTYPE_PRIVATE_PN, command.speech); + schedule(1000); return; } - if (serviceWorkflow.npcStep() == PlayerBotNpcConversationStep::Greet || - serviceWorkflow.npcStep() == PlayerBotNpcConversationStep::Request) { - const PlayerBotNpcSessionOutcome focus = serviceWorkflow.establishNpcFocus(*player, *npc, maximumServiceAttempts); - for (uint8_t action = 0; action < focus.actionsIssued; ++action) { + if (command.type == PlayerBotServiceCommandType::MoveSlottedSale) { + Item* item = player->getInventoryItem(command.sourceSlot); + Item* backpackItem = player->getInventoryItem(CONST_SLOT_BACKPACK); + Container* backpack = backpackItem ? backpackItem->getContainer() : nullptr; + if (!item || !backpack || !player->canDoAction()) { schedule(navigationDecisionDelay(*player)); return; } + const int8_t backpackId = player->getContainerID(backpack); + if (backpackId < 0) { telemetry.recordActionAttempt(); - } - if (focus.result == PlayerBotNpcSessionResult::Failed) { - logActionFailure("bank", "npc_focus_unconfirmed", currentPosition); - stop("banker_focus_unconfirmed", currentPosition); - return; - } - if (focus.result == PlayerBotNpcSessionResult::Pending) { - schedule(serviceWorkflow.npcNextDelay()); - return; - } - } - if (serviceWorkflow.npcStep() == PlayerBotNpcConversationStep::Request) { - if (!serviceWorkflow.hasBankDeposit()) { - serviceWorkflow.beginBankDeposit(player->getMoney(), player->getBankBalance()); - } - if (serviceWorkflow.bankTransaction().money == 0) { - serviceWorkflow.markBankDepositComplete(); - serviceWorkflow.setNpcStep(PlayerBotNpcConversationStep::Ready); - schedule(SCHEDULER_MINTICKS); + g_game.playerUseItem(playerId, Position(0xFFFF, CONST_SLOT_BACKPACK, 0), 0, depotSourceContainerId, backpack->getClientID()); + schedule(navigationDecisionDelay(*player)); return; } + Position source; uint8_t index = 0; + g_game.internalGetPosition(item, source, index); telemetry.recordActionAttempt(); - npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, "deposit all"); - serviceWorkflow.setNpcStep(PlayerBotNpcConversationStep::Confirm); - schedule(SCHEDULER_MINTICKS); - return; - } - if (serviceWorkflow.npcStep() == PlayerBotNpcConversationStep::Confirm) { - telemetry.recordActionAttempt(); - npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, "yes"); - serviceWorkflow.setNpcStep(PlayerBotNpcConversationStep::Verify); - schedule(SCHEDULER_MINTICKS); + g_game.playerMoveItem(player, source, item->getClientID(), index, + Position(0xFFFF, 0x40 | static_cast(backpackId), containerDestinationIndex(*backpack, *item)), + static_cast(item->getItemCount()), item, backpack); + emit("action_result", currentPosition, + "\"action\":\"item_disposition\",\"result\":\"requested\",\"disposition\":\"sell\",\"item_id\":" + + std::to_string(command.itemId) + ",\"source_slot\":" + std::to_string(command.sourceSlot) + + ",\"provider_available\":true,\"attempt\":" + std::to_string(command.attempt)); + schedule(navigationDecisionDelay(*player)); return; } - if (serviceWorkflow.npcStep() == PlayerBotNpcConversationStep::Verify && !serviceWorkflow.bankDepositComplete()) { - const PlayerBotServiceVerification verification = serviceWorkflow.verifyBankDeposit( - player->getMoney(), player->getBankBalance(), maximumServiceAttempts); - if (verification.result != PlayerBotServiceVerificationResult::Success) { - if (verification.result == PlayerBotServiceVerificationResult::Rejected) { - logActionFailure("bank_deposit", "transaction_not_verified", currentPosition); - stop("bank_deposit_not_verified", currentPosition); - return; - } - serviceWorkflow.setNpcStep(PlayerBotNpcConversationStep::Request); - schedule(SCHEDULER_MINTICKS); - return; - } - emit("action_result", currentPosition, "\"action\":\"bank_deposit\",\"result\":\"success\",\"count\":" + - std::to_string(verification.before.money) + ",\"bank_before\":" + std::to_string(verification.before.balance) + - ",\"bank_after\":" + std::to_string(player->getBankBalance())); - say(*player, "Deposited " + std::to_string(verification.before.money) + " gold. Bank: " + - std::to_string(player->getBankBalance()) + '.'); - serviceWorkflow.markBankDepositComplete(); - serviceWorkflow.setNpcStep(PlayerBotNpcConversationStep::Ready); - } - if (serviceWorkflow.npcStep() == PlayerBotNpcConversationStep::Ready) { - if (!serviceWorkflow.hasBankWithdrawal()) { - const uint32_t amount = dispositionPolicy.bankWithdrawal( - {0, player->getFreeCapacity(), player->getMoney(), player->getBankBalance()}, Item::items[ITEM_GOLD_COIN].weight); - if (amount == 0) { - serviceWorkflow.complete(); - schedule(SCHEDULER_MINTICKS); - return; - } - serviceWorkflow.beginBankWithdrawal(player->getBankBalance(), amount); - } + if (command.type == PlayerBotServiceCommandType::Sell || command.type == PlayerBotServiceCommandType::Buy) { + if (!provider || !command.transaction) { stop("shop_offer_unavailable", currentPosition); return; } telemetry.recordActionAttempt(); - npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, "withdraw " + std::to_string(serviceWorkflow.bankTransaction().amount)); - serviceWorkflow.setNpcStep(PlayerBotNpcConversationStep::Confirm); - schedule(SCHEDULER_MINTICKS); + if (command.type == PlayerBotServiceCommandType::Buy) g_game.playerPurchaseItem(playerId, Item::items[command.itemId].clientId, + command.subType, static_cast(command.amount), false, false); + else g_game.playerSellItem(playerId, Item::items[command.itemId].clientId, command.subType, + static_cast(command.amount), true); + schedule(navigationDecisionDelay(*player)); return; } - if (serviceWorkflow.npcStep() == PlayerBotNpcConversationStep::Confirm) { + if ((command.type == PlayerBotServiceCommandType::DepositAll || command.type == PlayerBotServiceCommandType::Withdraw) && provider) { telemetry.recordActionAttempt(); - npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, "yes"); - serviceWorkflow.setNpcStep(PlayerBotNpcConversationStep::Verify); - schedule(SCHEDULER_MINTICKS); - return; - } - const PlayerBotServiceVerification verification = serviceWorkflow.verifyBankWithdrawal( - player->getMoney(), player->getBankBalance(), maximumServiceAttempts); - if (verification.result == PlayerBotServiceVerificationResult::Success) { - emit("action_result", currentPosition, "\"action\":\"bank_withdraw\",\"result\":\"success\",\"count\":" + - std::to_string(verification.before.amount) + ",\"bank_before\":" + - std::to_string(verification.before.balance) + ",\"bank_after\":" + std::to_string(player->getBankBalance())); - serviceWorkflow.complete(); - schedule(SCHEDULER_MINTICKS); - return; - } - if (verification.result == PlayerBotServiceVerificationResult::Rejected) { - logActionFailure("bank_withdraw", "transaction_not_verified", currentPosition); - stop("bank_withdraw_not_verified", currentPosition); - return; - } - serviceWorkflow.setNpcStep(PlayerBotNpcConversationStep::Ready); - schedule(SCHEDULER_MINTICKS); -} - -void PlayerBotController::processService(Player* player, const Position& currentPosition) -{ - PlayerBotServiceObservation observation; - observation.currentPosition = currentPosition; - observation.freeCapacity = player->getFreeCapacity(); - observation.money = player->getMoney(); - observation.bankBalance = player->getBankBalance(); - for (const ServiceNpc& shop : serviceWorkflow.shops()) { - for (const auto& offer : shop.offers) { - observation.inventoryCounts.emplace(offer.itemId, inventoryPolicy.inventoryItemCount(*player, offer.itemId)); - observation.backpackSaleCounts.emplace(offer.itemId, inventoryPolicy.backpackSaleItemCount(*player, offer.itemId)); - } - } - if (const auto item = observation.inventoryCounts.find(2398); item != observation.inventoryCounts.end()) { - item->second = fixtureDriver.observeProvider(true, 2398, false, item->second).inventoryCount; - } - const PlayerBotServiceCommand command = serviceWorkflow.advance(observation, economyCatalog, dispositionPolicy); - if (command.type == PlayerBotServiceCommandType::RequestDiscoverySnapshot) { - discoverServices(currentPosition); - schedule(SCHEDULER_MINTICKS); - return; - } - if (command.type == PlayerBotServiceCommandType::Fail) { - stop(command.outcome == PlayerBotServiceOutcome::InsufficientFunds ? "insufficient_potion_funds" : "required_shop_offer_unavailable", currentPosition); - return; - } - if (serviceWorkflow.npcStep() == PlayerBotNpcConversationStep::Verify && serviceWorkflow.hasShopTransaction() && - (serviceWorkflow.stage() == PlayerBotServiceStage::SellLoot || serviceWorkflow.stage() == PlayerBotServiceStage::BuyPotions)) { - auto service = std::find_if(serviceWorkflow.shops().begin(), serviceWorkflow.shops().end(), [this](const ServiceNpc& candidate) { - return candidate.id == serviceWorkflow.npcTargetId(); - }); - if (service == serviceWorkflow.shops().end()) { - stop("shop_transaction_service_unavailable", currentPosition); - return; - } - const bool purchase = serviceWorkflow.stage() != PlayerBotServiceStage::SellLoot; - const char* action = serviceWorkflow.stage() == PlayerBotServiceStage::SellLoot ? "sell" : "buy_potions"; - const PlayerBotServiceTransaction& transaction = *serviceWorkflow.shopTransaction(); - processServiceShop(player, currentPosition, *service, action, transaction.itemId, transaction.amount, purchase); - return; - } - if (command.type == PlayerBotServiceCommandType::Sell || - (command.type == PlayerBotServiceCommandType::Wait && command.itemId != 0 && serviceWorkflow.stage() == PlayerBotServiceStage::SellLoot)) { - if (const auto pending = serviceWorkflow.pendingSlottedSale(); pending && - prepareSlottedSaleItem(player, pending->itemId, currentPosition)) { - return; - } - const uint16_t itemId = command.itemId; - const ServiceNpc* seller = serviceWorkflow.provider(command.providerId, true); - if (!seller) { - stop("shop_transaction_service_unavailable", currentPosition); - return; - } - if (!serviceWorkflow.npcTargets(seller->id)) { - serviceWorkflow.resetNpc(seller->id); - serviceWorkflow.clearApproach(); - serviceWorkflow.clearRejectedApproaches(); - clearNavigation(); - } - const uint32_t backpackSaleCount = command.amount; - if (backpackSaleCount == 0 && prepareSlottedSaleItem(player, itemId, currentPosition)) { - return; - } - if (backpackSaleCount != 0) { - processServiceShop(player, currentPosition, *seller, "sell", itemId, backpackSaleCount, false); - } else { - schedule(navigationDecisionDelay(*player)); - } - return; - } - if (command.type == PlayerBotServiceCommandType::Buy) { - const ServiceNpc* seller = serviceWorkflow.provider(command.providerId, true); - if (!seller) { - stop("required_shop_offer_unavailable", currentPosition); - return; - } - if (!serviceWorkflow.npcTargets(seller->id)) { - serviceWorkflow.resetNpc(seller->id); - serviceWorkflow.clearApproach(); - serviceWorkflow.clearRejectedApproaches(); - clearNavigation(); - } - processServiceShop(player, currentPosition, *seller, "buy_potions", command.itemId, command.amount, true); - return; - } - if (command.type == PlayerBotServiceCommandType::DepositAll || command.type == PlayerBotServiceCommandType::Withdraw) { - const ServiceNpc* banker = serviceWorkflow.provider(command.providerId, false); - if (!banker) { - stop("banker_unavailable", currentPosition); - return; - } - if (!serviceWorkflow.npcTargets(banker->id)) { - serviceWorkflow.resetNpc(banker->id); - serviceWorkflow.clearApproach(); - serviceWorkflow.clearRejectedApproaches(); - clearNavigation(); - } - processBank(player, currentPosition, *banker); + provider->receiveSpeech(player, TALKTYPE_PRIVATE_PN, command.type == PlayerBotServiceCommandType::DepositAll ? "deposit all" : + "withdraw " + std::to_string(command.amount)); + schedule(1000); return; } if (command.type == PlayerBotServiceCommandType::Complete) { @@ -754,178 +404,122 @@ bool PlayerBotController::findDepotLocker(const Position& position, uint16_t exp return false; } -void PlayerBotController::clearDepotDiscovery() -{ - depotWorkflow.clearDiscovery(); -} - bool PlayerBotController::discoverDepot(Player& player, const Position& currentPosition) { const auto now = std::chrono::steady_clock::now(); const PlayerBotFixtureDepotEndpoint fixtureDepot = fixtureDriver.depotEndpoint(); - if (fixtureDepot.synthetic) { - if (!depotWorkflow.hasSelectedDepot()) { - depotWorkflow.select({fixtureDepot.depotId, fixtureDepot.lockerItemId, fixtureDepot.lockerPosition, - fixtureDepot.approachPosition, 0}); - } - return true; - } - depotWorkflow.expireRejectedApproaches(now); - if (!depotWorkflow.hasSelectedDepot() && depotWorkflow.candidatesPrepared() && - depotWorkflow.discoveryAnchor() != currentPosition) { - clearDepotDiscovery(); - depotWorkflow.resetAttempts(); - } - uint16_t lockerItemId = 0; - if (depotWorkflow.hasSelectedDepot() && depotWorkflow.approachPosition() != Position() && - playerbot::isInsideLocalPlanningArea(currentPosition, depotWorkflow.lockerPosition()) && - findDepotLocker(depotWorkflow.lockerPosition(), depotWorkflow.depotId(), lockerItemId) && - lockerItemId == depotWorkflow.lockerItemId()) { - return true; - } - if (depotWorkflow.hasSelectedDepot()) { - clearDepotDiscovery(); - depotWorkflow.resetAttempts(); - } - auto finishUnavailable = [&](const char* reason) { - const uint32_t attempts = depotWorkflow.incrementAttempts(); - const uint32_t retryDelay = std::min(depotRetryMaximumInterval, - depotRetryInitialInterval << std::min(attempts - 1, 2)); - if (shouldEmitRepeated(std::string("depot_discover:") + reason)) { - emit("action_result", currentPosition, - std::string("\"action\":\"depot_discover\",\"result\":\"unavailable\",\"reason\":") + jsonString(reason) + - ",\"indexed\":" + std::to_string(depotWorkflow.indexedCandidateCount()) + - ",\"in_scope\":" + std::to_string(depotWorkflow.inScopeCandidateCount()) + - ",\"standable\":" + std::to_string(depotWorkflow.standableCandidateCount()) + - ",\"suppressed\":" + std::to_string(depotWorkflow.suppressedApproachCount()) + - ",\"attempt\":" + std::to_string(attempts)); - } - clearDepotDiscovery(); - if (attempts >= maximumDepotDiscoveryAttempts) { - logActionFailure("depot_discover", reason, currentPosition); - stop("depot_unavailable", currentPosition); - return; - } - schedule(retryDelay); + auto advance = [&](PlayerBotDepotObservation observation) { + observation.currentPosition = currentPosition; + observation.now = now; + observation.fixtureSynthetic = fixtureDepot.synthetic; + return depotWorkflow.advance(observation, depotRouteValidationsPerDecision, maximumDepotDiscoveryAttempts, + depotApproachSuppression); }; - - if (!depotWorkflow.candidatesPrepared()) { - depotWorkflow.beginDiscovery(currentPosition); - for (const auto& entry : g_game.map.getDepotLockerPositions()) { - for (const Position& lockerPosition : entry.second) { - depotWorkflow.recordIndexedCandidate(); - if (!playerbot::isInsideLocalPlanningArea(currentPosition, lockerPosition)) { - continue; - } - uint16_t indexedLockerItemId = 0; - if (!findDepotLocker(lockerPosition, entry.first, indexedLockerItemId)) { - continue; - } - depotWorkflow.recordInScopeCandidate(); - for (int32_t xOffset = -1; xOffset <= 1; ++xOffset) { - for (int32_t yOffset = -1; yOffset <= 1; ++yOffset) { - if (xOffset == 0 && yOffset == 0) { - continue; - } - const Position approach(lockerPosition.x + xOffset, lockerPosition.y + yOffset, lockerPosition.z); - Tile* approachTile = g_game.map.getTile(approach); - if (!approachTile || approachTile->queryAdd(0, player, 1, FLAG_IGNOREBLOCKCREATURE) != RETURNVALUE_NOERROR) { - continue; - } - depotWorkflow.recordStandableCandidate(); - if (depotWorkflow.isApproachRejected(approach)) { - depotWorkflow.recordSuppressedApproach(); - continue; - } - depotWorkflow.recordCandidate({entry.first, indexedLockerItemId, lockerPosition, approach, - playerbot::localPlanningDistance(currentPosition, approach)}); - } - } + auto scan = [&] { + PlayerBotDepotScan result; + result.observed = true; + if (fixtureDepot.synthetic) { + result.indexedCandidates = result.inScopeCandidates = result.standableCandidates = 1; + result.candidates.push_back({fixtureDepot.depotId, fixtureDepot.lockerItemId, fixtureDepot.lockerPosition, + fixtureDepot.approachPosition, 0}); + return result; + } + for (const auto& entry : g_game.map.getDepotLockerPositions()) for (const Position& lockerPosition : entry.second) { + ++result.indexedCandidates; + if (!playerbot::isInsideLocalPlanningArea(currentPosition, lockerPosition)) continue; + uint16_t lockerItemId = 0; + if (!findDepotLocker(lockerPosition, entry.first, lockerItemId)) continue; + ++result.inScopeCandidates; + for (int32_t xOffset = -1; xOffset <= 1; ++xOffset) for (int32_t yOffset = -1; yOffset <= 1; ++yOffset) { + if (xOffset == 0 && yOffset == 0) continue; + const Position approach(lockerPosition.x + xOffset, lockerPosition.y + yOffset, lockerPosition.z); + Tile* tile = g_game.map.getTile(approach); + if (!tile || tile->queryAdd(0, player, 1, FLAG_IGNOREBLOCKCREATURE) != RETURNVALUE_NOERROR) continue; + ++result.standableCandidates; + result.candidates.push_back({entry.first, lockerItemId, lockerPosition, approach, + playerbot::localPlanningDistance(currentPosition, approach)}); } } - depotWorkflow.sortCandidates(); - } + return result; + }; - if (!depotWorkflow.hasCandidates()) { - if (depotWorkflow.suppressedApproachCount() != 0) { - const auto earliestExpiry = depotWorkflow.earliestRejectedApproachExpiry(); - if (!earliestExpiry) { - return false; - } - const uint32_t retryDelay = static_cast(std::max( - 1, std::chrono::duration_cast(*earliestExpiry - now).count())); - clearDepotDiscovery(); - schedule(retryDelay); - return false; + PlayerBotDepotCommand command = advance({}); + if (command.snapshot.hasSelectedDepot && !fixtureDepot.synthetic) { + uint16_t lockerItemId = 0; + if (!findDepotLocker(command.snapshot.selected.lockerPosition, command.snapshot.selected.depotId, lockerItemId) || + lockerItemId != command.snapshot.selected.lockerItemId) { + PlayerBotDepotObservation observation; + observation.actionResult = PlayerBotDepotActionResult::SelectedLockerUnavailable; + command = advance(observation); } - finishUnavailable(depotWorkflow.inScopeCandidateCount() == 0 ? "no_local_locker" : - depotWorkflow.standableCandidateCount() == 0 ? "no_standable_approach" : "no_reachable_locker"); - return false; + } + if (command.type == PlayerBotDepotCommandType::Scan) { + PlayerBotDepotObservation observation; + observation.scan = scan(); + command = advance(observation); } + std::deque steps; uint32_t routeValidations = 0; - while (depotWorkflow.hasNextCandidate() && routeValidations < depotRouteValidationsPerDecision) { - const std::optional nextCandidate = depotWorkflow.takeNextCandidate(); - if (!nextCandidate) { - break; - } - const PlayerBotDepotCandidate candidate = *nextCandidate; - uint16_t candidateLockerItemId = 0; - Tile* approachTile = g_game.map.getTile(candidate.approachPosition); - if (!playerbot::isInsideLocalPlanningArea(currentPosition, candidate.lockerPosition) || - !findDepotLocker(candidate.lockerPosition, candidate.depotId, candidateLockerItemId) || - candidateLockerItemId != candidate.lockerItemId || !approachTile || - approachTile->queryAdd(0, player, 1, FLAG_IGNOREBLOCKCREATURE) != RETURNVALUE_NOERROR) { - continue; - } - std::deque steps; - uint64_t expandedNodes = 0; + while (command.type == PlayerBotDepotCommandType::ValidateRoute && command.snapshot.hasRouteCandidate && + routeValidations < depotRouteValidationsPerDecision) { ++routeValidations; + const PlayerBotDepotCandidate& candidate = command.snapshot.routeCandidate; + PlayerBotDepotObservation observation; + uint16_t lockerItemId = 0; + Tile* tile = g_game.map.getTile(candidate.approachPosition); + const bool valid = fixtureDepot.synthetic || + (playerbot::isInsideLocalPlanningArea(currentPosition, candidate.lockerPosition) && + findDepotLocker(candidate.lockerPosition, candidate.depotId, lockerItemId) && lockerItemId == candidate.lockerItemId && + tile && tile->queryAdd(0, player, 1, FLAG_IGNOREBLOCKCREATURE) == RETURNVALUE_NOERROR); const auto startedAt = std::chrono::steady_clock::now(); - const PlayerBotNavigationRoutePlan routePlan = candidate.approachPosition == currentPosition ? PlayerBotNavigationRoutePlan{} : - navigationRuntime.plan(player, candidate.approachPosition); - const PlayerBotNavigationResult result = candidate.approachPosition == currentPosition ? PlayerBotNavigationResult::Reached : routePlan.metrics.result; - if (candidate.approachPosition != currentPosition) { - steps = routePlan.steps; - expandedNodes = routePlan.metrics.expandedNodes; - } - telemetry.recordPathfinding(std::chrono::duration_cast( - std::chrono::steady_clock::now() - startedAt), result == PlayerBotNavigationResult::Reached && - (candidate.approachPosition == currentPosition || !steps.empty())); - if (result != PlayerBotNavigationResult::Reached || - (candidate.approachPosition != currentPosition && steps.empty())) { - depotWorkflow.rejectApproach(candidate.approachPosition, now + depotApproachSuppression); - continue; - } - depotWorkflow.select(candidate); - navigationRuntime.resetFixedTargetRouteFailures(); - const size_t routeSteps = steps.size(); - adoptNavigationPlan(depotWorkflow.approachPosition(), std::move(steps)); + PlayerBotNavigationRoutePlan routePlan; + if (valid && candidate.approachPosition != currentPosition) routePlan = planNavigationRoute(player, candidate.approachPosition); + const bool reached = valid && (candidate.approachPosition == currentPosition || + (routePlan.metrics.result == PlayerBotNavigationResult::Reached && !routePlan.steps.empty())); + telemetry.recordPathfinding(std::chrono::duration_cast(std::chrono::steady_clock::now() - startedAt), reached); + observation.routeResult = reached ? PlayerBotDepotRouteResult::Reached : PlayerBotDepotRouteResult::Unreachable; + observation.routeSteps = static_cast(routePlan.steps.size()); + observation.expandedNodes = routePlan.metrics.expandedNodes; + if (reached) steps = std::move(routePlan.steps); + command = advance(observation); + } + if (command.type == PlayerBotDepotCommandType::ValidateRoute) { + emit("action_result", currentPosition, + "\"action\":\"depot_discover\",\"result\":\"continuing\",\"reason\":\"route_validation_budget_exhausted\",\"indexed\":" + + std::to_string(command.snapshot.indexedCandidates) + ",\"in_scope\":" + + std::to_string(command.snapshot.inScopeCandidates) + ",\"standable\":" + + std::to_string(command.snapshot.standableCandidates) + ",\"route_validations\":" + + std::to_string(routeValidations)); + schedule(blockedRouteRetryInterval); + return false; + } + if (command.type == PlayerBotDepotCommandType::Navigate && command.snapshot.hasSelectedDepot) { + observeNavigationPlan(command.snapshot.selected.approachPosition, std::move(steps)); + const PlayerBotDepotCandidate& depot = command.snapshot.selected; std::ostringstream fields; - fields << "\"action\":\"depot_discover\",\"result\":\"success\",\"depot_id\":" << depotWorkflow.depotId() - << ",\"locker_item_id\":" << depotWorkflow.lockerItemId() << ",\"locker\":{\"x\":" << depotWorkflow.lockerPosition().x - << ",\"y\":" << depotWorkflow.lockerPosition().y << ",\"z\":" << static_cast(depotWorkflow.lockerPosition().z) - << "},\"approach\":{\"x\":" << depotWorkflow.approachPosition().x << ",\"y\":" << depotWorkflow.approachPosition().y - << ",\"z\":" << static_cast(depotWorkflow.approachPosition().z) << "},\"distance\":" << candidate.distance - << ",\"route_steps\":" << routeSteps << ",\"expanded_nodes\":" << expandedNodes - << ",\"indexed\":" << depotWorkflow.indexedCandidateCount() << ",\"in_scope\":" << depotWorkflow.inScopeCandidateCount() - << ",\"standable\":" << depotWorkflow.standableCandidateCount(); + fields << "\"action\":\"depot_discover\",\"result\":\"success\",\"depot_id\":" << depot.depotId + << ",\"locker_item_id\":" << depot.lockerItemId << ",\"locker\":{\"x\":" << depot.lockerPosition.x + << ",\"y\":" << depot.lockerPosition.y << ",\"z\":" << static_cast(depot.lockerPosition.z) + << "},\"approach\":{\"x\":" << depot.approachPosition.x << ",\"y\":" << depot.approachPosition.y + << ",\"z\":" << static_cast(depot.approachPosition.z) << "},\"distance\":" << depot.distance + << ",\"route_steps\":" << command.telemetry.routeSteps + << ",\"expanded_nodes\":" << command.telemetry.expandedNodes << ",\"indexed\":" << command.snapshot.indexedCandidates + << ",\"in_scope\":" << command.snapshot.inScopeCandidates << ",\"standable\":" << command.snapshot.standableCandidates; emit("action_result", currentPosition, fields.str()); return true; } - - if (depotWorkflow.hasNextCandidate()) { - emit("action_result", currentPosition, - std::string("\"action\":\"depot_discover\",\"result\":\"continuing\",\"reason\":\"route_validation_budget_exhausted\"") + - ",\"indexed\":" + std::to_string(depotWorkflow.indexedCandidateCount()) + - ",\"in_scope\":" + std::to_string(depotWorkflow.inScopeCandidateCount()) + - ",\"standable\":" + std::to_string(depotWorkflow.standableCandidateCount()) + - ",\"route_validations\":" + std::to_string(routeValidations)); - schedule(blockedRouteRetryInterval); - return false; + if (command.type == PlayerBotDepotCommandType::Fail) { + const char* reason = command.snapshot.inScopeCandidates == 0 ? "no_local_locker" : + command.snapshot.standableCandidates == 0 ? "no_standable_approach" : "no_reachable_locker"; + logActionFailure("depot_discover", reason, currentPosition); + stop("depot_unavailable", currentPosition); + return false; } - - finishUnavailable("no_reachable_locker"); + const uint32_t retryDelay = command.snapshot.retryAt ? static_cast(std::max(1, + std::chrono::duration_cast(*command.snapshot.retryAt - now).count())) : + std::min(depotRetryMaximumInterval, depotRetryInitialInterval << std::min(command.snapshot.attempts, 2)); + schedule(command.type == PlayerBotDepotCommandType::Wait ? retryDelay : blockedRouteRetryInterval); return false; } @@ -969,23 +563,18 @@ bool PlayerBotController::openContainer(Player& player, Container& container, ui return false; } -bool PlayerBotController::openDepotLocker(Player& player, const Position& currentPosition) +bool PlayerBotController::openDepotLocker(Player& player, const PlayerBotDepotSnapshot& depot, const Position& currentPosition) { if (fixtureDriver.depotEndpoint().synthetic) { - depotWorkflow.resetAttempts(); - depotWorkflow.lockerOpened(); return true; } Container* opened = player.getContainerByID(depotLockerContainerId); - if (opened && opened->getDepotLocker() && opened->getDepotLocker()->getDepotId() == depotWorkflow.depotId()) { - depotWorkflow.resetAttempts(); - depotWorkflow.lockerOpened(); + if (opened && opened->getDepotLocker() && opened->getDepotLocker()->getDepotId() == depot.selected.depotId) { return true; } - Tile* tile = g_game.map.getTile(depotWorkflow.lockerPosition()); + Tile* tile = g_game.map.getTile(depot.selected.lockerPosition); TileItemVector* items = tile ? tile->getItemList() : nullptr; if (!items) { - clearDepotDiscovery(); setCyclePhase(CyclePhase::ReturnToDepot, currentPosition, "depot_locker_tile_unavailable"); schedule(blockedRouteRetryInterval); return false; @@ -996,26 +585,20 @@ bool PlayerBotController::openDepotLocker(Player& player, const Position& curren } for (Item* item : *items) { Container* container = item->getContainer(); - if (!container || !container->getDepotLocker() || container->getDepotLocker()->getDepotId() != depotWorkflow.depotId()) { + if (!container || !container->getDepotLocker() || container->getDepotLocker()->getDepotId() != depot.selected.depotId) { continue; } const int32_t stackPosition = tile->getThingIndex(item); if (stackPosition < 0 || stackPosition > UINT8_MAX) { break; } - if (depotWorkflow.attempts() >= maximumDepotAttempts) { - logActionFailure("depot_open_locker", "open_not_verified", currentPosition); - stop("depot_locker_open_failed", currentPosition); - return false; - } - const uint32_t attempts = depotWorkflow.incrementAttempts(); player.closeContainer(depotLockerContainerId); telemetry.recordActionAttempt(); - g_game.playerUseItem(playerId, depotWorkflow.lockerPosition(), static_cast(stackPosition), depotLockerContainerId, + g_game.playerUseItem(playerId, depot.selected.lockerPosition, static_cast(stackPosition), depotLockerContainerId, item->getClientID()); emit("action_result", currentPosition, "\"action\":\"depot_open_locker\",\"result\":\"requested\",\"depot_id\":" + - std::to_string(depotWorkflow.depotId()) + ",\"container_id\":" + std::to_string(depotLockerContainerId) + - ",\"attempt\":" + std::to_string(attempts)); + std::to_string(depot.selected.depotId) + ",\"container_id\":" + std::to_string(depotLockerContainerId) + + ",\"attempt\":" + std::to_string(depot.attempts)); if (pauseDepotFixtureForRestart(player, DepotRestartCheckpoint::Locker, currentPosition)) { return false; } @@ -1023,27 +606,23 @@ bool PlayerBotController::openDepotLocker(Player& player, const Position& curren return false; } logActionFailure("depot_open_locker", "locker_identity_changed", currentPosition); - clearDepotDiscovery(); clearNavigation(); setCyclePhase(CyclePhase::ReturnToDepot, currentPosition, "depot_locker_identity_changed"); schedule(blockedRouteRetryInterval); return false; } -bool PlayerBotController::openDepotChest(Player& player, const Position& currentPosition) +bool PlayerBotController::openDepotChest(Player& player, const PlayerBotDepotSnapshot& depot, const Position& currentPosition) { if (fixtureDriver.depotEndpoint().synthetic) { - depotWorkflow.resetAttempts(); - depotWorkflow.chestOpened(); return true; } Container* locker = player.getContainerByID(depotLockerContainerId); - if (!locker || !locker->getDepotLocker() || locker->getDepotLocker()->getDepotId() != depotWorkflow.depotId()) { - depotWorkflow.reopenLocker(); + if (!locker || !locker->getDepotLocker() || locker->getDepotLocker()->getDepotId() != depot.selected.depotId) { schedule(blockedRouteRetryInterval); return false; } - DepotChest* chest = player.getDepotChest(depotWorkflow.depotId(), false); + DepotChest* chest = player.getDepotChest(depot.selected.depotId, false); if (!chest) { logActionFailure("depot_open_chest", "player_chest_missing", currentPosition); stop("depot_chest_missing", currentPosition); @@ -1051,8 +630,6 @@ bool PlayerBotController::openDepotChest(Player& player, const Position& current } fixtureDriver.prepareDepotMoveDestination(*chest); if (player.getContainerByID(depotChestContainerId) == chest) { - depotWorkflow.resetAttempts(); - depotWorkflow.chestOpened(); return true; } const int32_t index = locker->getThingIndex(chest); @@ -1065,19 +642,13 @@ bool PlayerBotController::openDepotChest(Player& player, const Position& current schedule(navigationDecisionDelay(player)); return false; } - if (depotWorkflow.attempts() >= maximumDepotAttempts) { - logActionFailure("depot_open_chest", "open_not_verified", currentPosition); - stop("depot_chest_open_failed", currentPosition); - return false; - } - const uint32_t attempts = depotWorkflow.incrementAttempts(); player.closeContainer(depotChestContainerId); telemetry.recordActionAttempt(); g_game.playerUseItem(playerId, Position(0xFFFF, 0x40 | depotLockerContainerId, static_cast(index)), static_cast(index), depotChestContainerId, chest->getClientID()); emit("action_result", currentPosition, "\"action\":\"depot_open_chest\",\"result\":\"requested\",\"depot_id\":" + - std::to_string(depotWorkflow.depotId()) + ",\"container_id\":" + std::to_string(depotChestContainerId) + - ",\"attempt\":" + std::to_string(attempts)); + std::to_string(depot.selected.depotId) + ",\"container_id\":" + std::to_string(depotChestContainerId) + + ",\"attempt\":" + std::to_string(depot.attempts)); if (pauseDepotFixtureForRestart(player, DepotRestartCheckpoint::Chest, currentPosition)) { return false; } @@ -1122,99 +693,139 @@ void PlayerBotController::processDeposit(Player* player, const Position& current stop("depot_destination_unavailable", currentPosition); return; } - Item* backpackItem = player->getInventoryItem(CONST_SLOT_BACKPACK); - Container* backpack = backpackItem ? backpackItem->getContainer() : nullptr; - if (!backpack) { - stop("depot_backpack_unavailable", currentPosition); - return; - } - if (depotWorkflow.hasPendingMove()) { - Container* chest = player->getContainerByID(depotChestContainerId); - if (!chest && !fixtureDepot.synthetic) { - depotWorkflow.reopenChest(); - schedule(navigationDecisionDelay(*player)); - return; + auto advance = [&](PlayerBotDepotObservation observation) { + observation.currentPosition = currentPosition; + observation.now = std::chrono::steady_clock::now(); + observation.fixtureSynthetic = fixtureDepot.synthetic; + return depotWorkflow.advance(observation, depotRouteValidationsPerDecision, maximumDepotAttempts, depotApproachSuppression); + }; + PlayerBotDepotCommand command = advance({}); + PlayerBotDepotObservation observation; + if (command.snapshot.hasSelectedDepot) { + observation.atApproach = fixtureDepot.synthetic || Position::areInRange<1, 1, 0>(currentPosition, command.snapshot.selected.lockerPosition); + observation.lockerOpen = fixtureDepot.synthetic || player->getContainerByID(depotLockerContainerId) != nullptr; + observation.chestOpen = fixtureDepot.synthetic || player->getContainerByID(depotChestContainerId) != nullptr; + observation.canDoAction = player->canDoAction(); + if (command.snapshot.hasPendingMove) { + Container* chest = player->getContainerByID(depotChestContainerId); + if (!chest && !fixtureDepot.synthetic) { + observation.atApproach = true; + observation.lockerOpen = player->getContainerByID(depotLockerContainerId) != nullptr; + observation.chestOpen = false; + observation.canDoAction = player->canDoAction(); + observation.actionResult = PlayerBotDepotActionResult::MoveDestinationUnavailable; + command = advance(observation); + if (command.type == PlayerBotDepotCommandType::OpenLocker && + !openDepotLocker(*player, command.snapshot, currentPosition)) return; + if (command.type == PlayerBotDepotCommandType::OpenChest && + !openDepotChest(*player, command.snapshot, currentPosition)) return; + schedule(navigationDecisionDelay(*player)); + return; + } + observation.move = {true, inventoryPolicy.inventoryItemCount(*player, command.snapshot.pendingMove.itemId), + fixtureDepot.synthetic ? fixtureDestination->getItemTypeCount(command.snapshot.pendingMove.itemId) : + chest->getItemTypeCount(command.snapshot.pendingMove.itemId)}; } - const PlayerBotDepotMoveVerification verification = depotWorkflow.verifyMove( - inventoryPolicy.inventoryItemCount(*player, depotWorkflow.move().itemId), - fixtureDepot.synthetic ? fixtureDestination->getItemTypeCount(depotWorkflow.move().itemId) : - chest->getItemTypeCount(depotWorkflow.move().itemId), maximumDepotAttempts); + command = advance(observation); + } + if (command.telemetry.moveVerification) { + const PlayerBotDepotMoveVerification& verification = *command.telemetry.moveVerification; const PlayerBotDepotMove& move = verification.before; - if (verification.result == PlayerBotDepotMoveResult::Moved) { - std::ostringstream fields; - fields << "\"action\":\"deposit\",\"result\":" << jsonString(verification.movedCount == move.requestedCount ? "success" : "partial") - << ",\"policy\":\"known_loot\",\"depot_id\":" << depotWorkflow.depotId() << ",\"container_id\":" - << static_cast(depotChestContainerId) << ",\"item_id\":" << move.itemId - << ",\"requested\":" << static_cast(move.requestedCount) - << ",\"verified\":" << verification.movedCount << ",\"inventory_before\":" << move.inventoryCount - << ",\"inventory_after\":" << verification.inventoryCount << ",\"depot_before\":" << move.destinationCount - << ",\"depot_after\":" << verification.destinationCount - << ",\"source_slot\":" << (move.sourceSlot == CONST_SLOT_WHEREEVER ? "null" : std::to_string(move.sourceSlot)) - << ",\"provider_available\":false,\"disposition\":\"deposit\""; - emit("action_result", currentPosition, fields.str()); - } else if (verification.result == PlayerBotDepotMoveResult::Mismatch) { + if (verification.result == PlayerBotDepotMoveResult::Mismatch) { logActionFailure("deposit", "move_delta_mismatch", currentPosition); stop("depot_move_delta_mismatch", currentPosition); return; - } else if (verification.result == PlayerBotDepotMoveResult::Deferred) { - const uint16_t failedItemId = move.itemId; - const slots_t failedSlot = move.sourceSlot; - serviceWorkflow.deferSlottedSale(failedItemId, failedSlot, - std::chrono::steady_clock::now() + unavailableDispositionCooldown); - emit("action_result", currentPosition, - "\"action\":\"deposit\",\"result\":\"deferred\",\"reason\":\"move_not_verified\",\"policy\":\"known_loot\",\"depot_id\":" + - std::to_string(depotWorkflow.depotId()) + ",\"container_id\":" + std::to_string(depotChestContainerId) + - ",\"item_id\":" + std::to_string(failedItemId) + ",\"source_slot\":" + std::to_string(failedSlot) + - ",\"provider_available\":false,\"disposition\":\"deposit\",\"cooldown_ms\":" + - std::to_string(std::chrono::duration_cast(unavailableDispositionCooldown).count())); - schedule(SCHEDULER_MINTICKS); - return; - } else if (verification.result == PlayerBotDepotMoveResult::Rejected) { + } + if (verification.result == PlayerBotDepotMoveResult::Rejected) { emit("action_result", currentPosition, "\"action\":\"deposit\",\"result\":\"failed\",\"reason\":\"no_slot_or_move_rejected\",\"policy\":\"known_loot\",\"depot_id\":" + - std::to_string(depotWorkflow.depotId()) + ",\"container_id\":" + std::to_string(depotChestContainerId) + - ",\"item_id\":" + std::to_string(move.itemId) + ",\"requested\":" + - std::to_string(move.requestedCount) + ",\"verified\":0,\"inventory_before\":" + - std::to_string(move.inventoryCount) + ",\"inventory_after\":" + std::to_string(verification.inventoryCount) + - ",\"depot_before\":" + std::to_string(move.destinationCount) + ",\"depot_after\":" + - std::to_string(verification.destinationCount) + ",\"retry\":" + std::to_string(verification.attempts)); + std::to_string(command.snapshot.selected.depotId) + ",\"container_id\":" + std::to_string(depotChestContainerId) + + ",\"item_id\":" + std::to_string(move.itemId) + ",\"requested\":" + std::to_string(move.requestedCount) + + ",\"verified\":0,\"inventory_before\":" + std::to_string(move.inventoryCount) + ",\"inventory_after\":" + + std::to_string(verification.inventoryCount) + ",\"depot_before\":" + std::to_string(move.destinationCount) + + ",\"depot_after\":" + std::to_string(verification.destinationCount) + ",\"retry\":" + std::to_string(verification.attempts)); stop("depot_no_slot_or_move_rejected", currentPosition); return; - } else { - emit("action_result", currentPosition, "\"action\":\"deposit\",\"result\":\"retry\",\"reason\":\"not_verified\",\"policy\":\"known_loot\",\"depot_id\":" + - std::to_string(depotWorkflow.depotId()) + ",\"container_id\":" + std::to_string(depotChestContainerId) + - ",\"item_id\":" + std::to_string(move.itemId) + ",\"requested\":" + - std::to_string(move.requestedCount) + ",\"verified\":0,\"inventory_before\":" + - std::to_string(move.inventoryCount) + ",\"inventory_after\":" + std::to_string(verification.inventoryCount) + - ",\"depot_before\":" + std::to_string(move.destinationCount) + ",\"depot_after\":" + - std::to_string(verification.destinationCount) + ",\"retry\":" + std::to_string(verification.attempts)); + } + std::ostringstream fields; + fields << "\"action\":\"deposit\",\"result\":" << jsonString(verification.result == PlayerBotDepotMoveResult::Moved ? + (verification.movedCount == move.requestedCount ? "success" : "partial") : + verification.result == PlayerBotDepotMoveResult::Deferred ? "deferred" : "retry") << ",\"policy\":\"known_loot\",\"depot_id\":" + << command.snapshot.selected.depotId << ",\"container_id\":" << static_cast(depotChestContainerId) + << ",\"item_id\":" << move.itemId << ",\"requested\":" << static_cast(move.requestedCount) + << ",\"verified\":" << verification.movedCount; + if (verification.result == PlayerBotDepotMoveResult::Moved && verification.movedCount == move.requestedCount) { + fields << ",\"count\":" << verification.movedCount; + } + fields << ",\"inventory_before\":" << move.inventoryCount + << ",\"inventory_after\":" << verification.inventoryCount << ",\"depot_before\":" << move.destinationCount + << ",\"depot_after\":" << verification.destinationCount << ",\"source_slot\":" + << (move.sourceSlot == CONST_SLOT_WHEREEVER ? "null" : std::to_string(move.sourceSlot)) + << ",\"provider_available\":false,\"disposition\":\"deposit\",\"retry\":" << verification.attempts; + emit("action_result", currentPosition, fields.str()); + if (verification.result == PlayerBotDepotMoveResult::Deferred) { + const int64_t delay = command.snapshot.deferredDepositRetryAt ? + std::chrono::duration_cast( + *command.snapshot.deferredDepositRetryAt - std::chrono::steady_clock::now()).count() : 1; + schedule(static_cast(std::max(1, delay))); + return; + } + if (verification.result == PlayerBotDepotMoveResult::Retry) { schedule(navigationDecisionDelay(*player)); return; } } - - PlayerBotDepotObservation depotObservation; - depotObservation.currentPosition = currentPosition; - depotObservation.now = std::chrono::steady_clock::now(); - depotObservation.atApproach = fixtureDepot.synthetic || Position::areInRange<1, 1, 0>(currentPosition, depotWorkflow.lockerPosition()); - depotObservation.lockerOpen = fixtureDepot.synthetic || player->getContainerByID(depotLockerContainerId) != nullptr; - depotObservation.chestOpen = fixtureDepot.synthetic || player->getContainerByID(depotChestContainerId) != nullptr; - depotObservation.canDoAction = player->canDoAction(); - // Selection remains an adapter concern because Item pointers are needed to build - // the normal move request. The workflow still owns the surrounding phases. - depotObservation.hasDepositableItem = true; - const PlayerBotDepotCommand depotCommand = depotWorkflow.advance(depotObservation, depotRouteValidationsPerDecision, - maximumDepotDiscoveryAttempts, depotApproachSuppression); - if ((depotCommand.type == PlayerBotDepotCommandType::OpenLocker || depotWorkflow.stage() == PlayerBotDepotStage::OpenLocker) && - !openDepotLocker(*player, currentPosition)) { + if (command.type == PlayerBotDepotCommandType::OpenLocker) { + if (!openDepotLocker(*player, command.snapshot, currentPosition)) return; + schedule(navigationDecisionDelay(*player)); return; } - if ((depotCommand.type == PlayerBotDepotCommandType::OpenChest || depotWorkflow.stage() == PlayerBotDepotStage::OpenChest) && - !openDepotChest(*player, currentPosition)) { + if (command.type == PlayerBotDepotCommandType::OpenChest) { + if (!openDepotChest(*player, command.snapshot, currentPosition)) return; + schedule(navigationDecisionDelay(*player)); + return; + } + if (command.type == PlayerBotDepotCommandType::Wait) { + if (command.outcome == PlayerBotDepotOutcome::Deferred && command.snapshot.deferredDepositRetryAt) { + const int64_t delay = std::chrono::duration_cast( + *command.snapshot.deferredDepositRetryAt - std::chrono::steady_clock::now()).count(); + schedule(static_cast(std::max(1, delay))); + } else { + schedule(navigationDecisionDelay(*player)); + } + return; + } + if (command.type == PlayerBotDepotCommandType::Fail) { + stop("depot_action_failed", currentPosition); + return; + } + if (command.type == PlayerBotDepotCommandType::Depart) { + std::ostringstream fields; + fields << "\"action\":\"deposit\",\"result\":\"complete\",\"depot_id\":" << command.snapshot.selected.depotId + << ",\"container_id\":" << static_cast(depotChestContainerId) << ",\"cycle\":" << huntRuntime.completedCycles(); + emit("action_result", currentPosition, fields.str()); + player->closeContainer(depotChestContainerId); + player->closeContainer(depotLockerContainerId); + if (pauseDepotFixtureForRestart(*player, DepotRestartCheckpoint::Depart, currentPosition)) return; + if (fixtureDriver.goalLoop(true).selectGoal) { + emit("goal_result", currentPosition, + "\"decision_id\":" + std::to_string(progressionRuntime.decisionId()) + + ",\"goal\":\"service\",\"result\":\"success\",\"reason\":\"service_complete\""); + selectTopLevelGoal(*player, currentPosition, "service_complete"); + } + else startHunt(player, currentPosition, "deposit_complete"); + schedule(navigationInterval); + return; + } + if (command.type != PlayerBotDepotCommandType::SelectDeposit) return; + + Item* backpackItem = player->getInventoryItem(CONST_SLOT_BACKPACK); + Container* backpack = backpackItem ? backpackItem->getContainer() : nullptr; + if (!backpack) { + stop("depot_backpack_unavailable", currentPosition); return; } Container* chest = player->getContainerByID(depotChestContainerId); - if (!fixtureDepot.synthetic && (!chest || player->getDepotChest(depotWorkflow.depotId(), false) != chest)) { - depotWorkflow.reopenChest(); + if (!fixtureDepot.synthetic && (!chest || player->getDepotChest(command.snapshot.selected.depotId, false) != chest)) { schedule(blockedRouteRetryInterval); return; } @@ -1226,9 +837,8 @@ void PlayerBotController::processDeposit(Player* player, const Position& current refreshItemValues(); Item* upgrade = nullptr; EquipmentUpgrade upgradeInfo{}; - if (equipmentPolicy.findCarriedUpgrade(*player, upgrade, upgradeInfo)) { - readinessResumeService = true; - if (!beginReadinessEquipment(player, currentPosition, "depot_carried_upgrade")) { + if (PlayerBotEquipmentAdapter::findCarriedUpgrade(equipmentPolicy, *player, upgrade, upgradeInfo)) { + if (!beginReadinessEquipment(player, currentPosition, "depot_carried_upgrade", true)) { schedule(navigationDecisionDelay(*player)); } return; @@ -1243,38 +853,37 @@ void PlayerBotController::processDeposit(Player* player, const Position& current count = static_cast(std::min(movable, UINT8_MAX)); } } - if (!depositItem || count == 0) { - if (inventoryPolicy.effectiveFreeCapacity(*player) < returnCapacityThreshold) { - const auto now = std::chrono::steady_clock::now(); - if (const auto deferred = serviceWorkflow.nextDeferredSlottedSale(now)) { - schedule(static_cast(std::chrono::duration_cast( - *deferred - now).count())); - return; - } - stop("depot_capacity_not_recovered", currentPosition); - return; - } - std::ostringstream fields; - fields << "\"action\":\"deposit\",\"result\":\"complete\",\"depot_id\":" << depotWorkflow.depotId() - << ",\"container_id\":" << static_cast(depotChestContainerId) << ",\"cycle\":" << huntRuntime.completedCycles(); - emit("action_result", currentPosition, fields.str()); - player->closeContainer(depotChestContainerId); - player->closeContainer(depotLockerContainerId); - depotWorkflow.depart(); - if (pauseDepotFixtureForRestart(*player, DepotRestartCheckpoint::Depart, currentPosition)) { - return; - } - if (fixtureDriver.goalLoop(true).selectGoal) { - emit("goal_result", currentPosition, - "\"decision_id\":" + std::to_string(goalArbiter.decisionId()) + - ",\"goal\":\"service\",\"result\":\"success\",\"reason\":\"service_complete\""); - selectTopLevelGoal(*player, currentPosition, "service_complete"); + if ((!depositItem || count == 0) && inventoryPolicy.effectiveFreeCapacity(*player) < returnCapacityThreshold) { + stop("depot_capacity_not_recovered", currentPosition); + return; + } + PlayerBotDepotObservation depositObservation; + depositObservation.atApproach = true; + depositObservation.lockerOpen = true; + depositObservation.chestOpen = true; + depositObservation.canDoAction = player->canDoAction(); + depositObservation.deposit.observed = true; + depositObservation.deposit.hasDepositableItem = depositItem && count != 0; + if (depositObservation.deposit.hasDepositableItem) { + depositObservation.deposit.move = {depositItem->getID(), fixtureDepot.synthetic ? fixtureDestination->getItemTypeCount(depositItem->getID()) : chest->getItemTypeCount(depositItem->getID()), + inventoryPolicy.inventoryItemCount(*player, depositItem->getID()), count, sourceSlot}; + } + command = advance(depositObservation); + if (command.type == PlayerBotDepotCommandType::Depart) { + processDeposit(player, currentPosition); + return; + } + if (command.type == PlayerBotDepotCommandType::Wait) { + if (command.outcome == PlayerBotDepotOutcome::Deferred && command.snapshot.deferredDepositRetryAt) { + const int64_t delay = std::chrono::duration_cast( + *command.snapshot.deferredDepositRetryAt - std::chrono::steady_clock::now()).count(); + schedule(static_cast(std::max(1, delay))); } else { - startHunt(player, currentPosition, "deposit_complete"); + schedule(navigationDecisionDelay(*player)); } - schedule(navigationInterval); return; } + if (command.type != PlayerBotDepotCommandType::MoveDeposit || !depositItem) return; if (source && !openContainer(*player, *source, depotSourceContainerId, currentPosition)) { return; @@ -1301,13 +910,6 @@ void PlayerBotController::processDeposit(Player* player, const Position& current return; } } - if (!player->canDoAction()) { - schedule(navigationDecisionDelay(*player)); - return; - } - - depotWorkflow.beginMove({depositItem->getID(), fixtureDepot.synthetic ? fixtureDestination->getItemTypeCount(depositItem->getID()) : chest->getItemTypeCount(depositItem->getID()), - inventoryPolicy.inventoryItemCount(*player, depositItem->getID()), count, sourceSlot}); const PlayerBotFixtureEngineCommand moveCommand = fixtureDriver.depotMoveCommand(count); const uint8_t submittedCount = moveCommand.count; telemetry.recordActionAttempt(); @@ -1316,10 +918,10 @@ void PlayerBotController::processDeposit(Player* player, const Position& current Position(0xFFFF, 0x40 | depotChestContainerId, containerDestinationIndex(*chest, *depositItem)), submittedCount, depositItem, fixtureDepot.synthetic ? static_cast(fixtureDestination) : static_cast(chest)); emit("action_result", currentPosition, "\"action\":\"deposit\",\"result\":\"requested\",\"policy\":\"known_loot\",\"depot_id\":" + - std::to_string(depotWorkflow.depotId()) + ",\"container_id\":" + std::to_string(depotChestContainerId) + ",\"item_id\":" + - std::to_string(depotWorkflow.move().itemId) + ",\"requested\":" + std::to_string(count) + ",\"submitted\":" + + std::to_string(command.snapshot.selected.depotId) + ",\"container_id\":" + std::to_string(depotChestContainerId) + ",\"item_id\":" + + std::to_string(command.snapshot.pendingMove.itemId) + ",\"requested\":" + std::to_string(count) + ",\"submitted\":" + std::to_string(submittedCount) + ",\"inventory_before\":" + - std::to_string(depotWorkflow.move().inventoryCount) + ",\"depot_before\":" + std::to_string(depotWorkflow.move().destinationCount) + + std::to_string(command.snapshot.pendingMove.inventoryCount) + ",\"depot_before\":" + std::to_string(command.snapshot.pendingMove.destinationCount) + ",\"source_slot\":" + (sourceSlot == CONST_SLOT_WHEREEVER ? "null" : std::to_string(sourceSlot)) + ",\"provider_available\":false,\"disposition\":\"deposit\""); if (pauseDepotFixtureForRestart(*player, DepotRestartCheckpoint::Deposit, currentPosition)) { diff --git a/server/src/playerbotservicesession.h b/server/src/playerbotservicesession.h index ad708aff..5e6df299 100644 --- a/server/src/playerbotservicesession.h +++ b/server/src/playerbotservicesession.h @@ -33,8 +33,12 @@ class PlayerBotServiceSession public: void reset(); - bool hasShopTransaction() const; - const PlayerBotServiceTransaction* shopTransaction() const; + bool hasShopTransaction() const; + const PlayerBotServiceTransaction* shopTransaction() const; + + private: + friend class PlayerBotServiceWorkflow; + friend class PlayerBotProgressionRuntime; void beginShopTransaction(PlayerBotServiceTransaction transaction); PlayerBotServiceVerification verifyShopTransaction(uint32_t itemCount, uint64_t money, uint64_t balance, bool purchase, uint32_t unitPrice, uint32_t maximumRetries); @@ -49,7 +53,6 @@ class PlayerBotServiceSession const PlayerBotServiceTransaction& bankTransaction() const { return bank; } PlayerBotServiceVerification verifyBankWithdrawal(uint64_t money, uint64_t balance, uint32_t maximumRetries); - private: PlayerBotServiceTransaction shop; PlayerBotServiceTransaction bank; uint32_t shopRetries = 0; diff --git a/server/src/playerbotserviceworkflow.cpp b/server/src/playerbotserviceworkflow.cpp index 6b41c1ae..be409eb9 100644 --- a/server/src/playerbotserviceworkflow.cpp +++ b/server/src/playerbotserviceworkflow.cpp @@ -2,6 +2,10 @@ #include "playerbotserviceworkflow.h" +namespace { + constexpr uint32_t npcReplyDelay = 1000; +} + void PlayerBotServiceWorkflow::reset() { npcSession.reset(); @@ -9,44 +13,22 @@ void PlayerBotServiceWorkflow::reset() serviceStage = PlayerBotServiceStage::Discover; shopProviders.clear(); bankProviders.clear(); - pendingApproach = Position(); + pendingSlottedItem = 0; + pendingSlottedSlot = CONST_SLOT_WHEREEVER; + pendingSlottedBackpackItems = 0; + slottedMoveAttempts = 0; + unavailableSlottedSales.clear(); + providerApproaches.clear(); + pendingApproachRoute.reset(); + selectedApproach.reset(); rejectedApproaches.clear(); - clearPendingSlottedSale(); } -bool PlayerBotServiceWorkflow::acceptNpcReply(uint32_t playerId, uint32_t replyingPlayerId, uint32_t npcId, uint8_t type) +bool PlayerBotServiceWorkflow::reportNpcReply(uint32_t playerId, uint32_t replyingPlayerId, uint32_t npcId, uint8_t type) { return npcSession.acceptReply(playerId, replyingPlayerId, npcId, type); } -PlayerBotNpcSessionOutcome PlayerBotServiceWorkflow::establishNpcFocus(Player& player, Npc& npc, uint32_t maximumRetries) -{ - return npcSession.establishFocus(player, npc, maximumRetries); -} - -PlayerBotNpcSessionOutcome PlayerBotServiceWorkflow::openNpcShop(Player& player, Npc& npc, uint32_t maximumRetries) -{ - return npcSession.openShop(player, npc, maximumRetries); -} - -PlayerBotServiceVerification PlayerBotServiceWorkflow::verifyShopTransaction(uint32_t itemCount, uint64_t money, - uint64_t balance, bool purchase, uint32_t unitPrice, uint32_t maximumRetries) -{ - return serviceSession.verifyShopTransaction(itemCount, money, balance, purchase, unitPrice, maximumRetries); -} - -PlayerBotServiceVerification PlayerBotServiceWorkflow::verifyBankDeposit(uint64_t money, uint64_t balance, - uint32_t maximumRetries) -{ - return serviceSession.verifyBankDeposit(money, balance, maximumRetries); -} - -PlayerBotServiceVerification PlayerBotServiceWorkflow::verifyBankWithdrawal(uint64_t money, uint64_t balance, - uint32_t maximumRetries) -{ - return serviceSession.verifyBankWithdrawal(money, balance, maximumRetries); -} - void PlayerBotServiceWorkflow::observeProviders(std::vector shops, std::vector bankers) { @@ -75,146 +57,329 @@ const PlayerBotEconomyProvider* PlayerBotServiceWorkflow::nearestBanker(const Po return it == bankProviders.end() ? nullptr : &*it; } -PlayerBotServiceCommand PlayerBotServiceWorkflow::advance(const PlayerBotServiceObservation& observation, - const PlayerBotEconomyCatalog& catalog, const PlayerBotDispositionPolicy& disposition) +const PlayerBotEconomyProvider* PlayerBotServiceWorkflow::offerProvider(uint16_t itemId, bool purchase, const Position& position, + const PlayerBotEconomyCatalog& catalog) const { - if (serviceStage == PlayerBotServiceStage::Failed) return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Rejected}; - if (serviceStage == PlayerBotServiceStage::Complete) return {PlayerBotServiceCommandType::Complete, PlayerBotServiceOutcome::Success}; - if (serviceSession.hasShopTransaction()) { - const PlayerBotServiceTransaction& transaction = *serviceSession.shopTransaction(); - return {serviceStage == PlayerBotServiceStage::SellLoot ? PlayerBotServiceCommandType::Sell : PlayerBotServiceCommandType::Buy, - PlayerBotServiceOutcome::Pending, npcSession.targetId(), transaction.itemId, transaction.amount}; - } - if (serviceStage == PlayerBotServiceStage::Discover) { - if (!observation.discoveryObserved) return {PlayerBotServiceCommandType::RequestDiscoverySnapshot}; - observeProviders(observation.shops, observation.bankers); - if (shopProviders.empty() || bankProviders.empty()) { - serviceStage = PlayerBotServiceStage::Failed; - return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; - } - serviceStage = PlayerBotServiceStage::SellLoot; - } - if (serviceStage == PlayerBotServiceStage::SellLoot) { - const PlayerBotEconomyProvider* selected = nullptr; - uint16_t itemId = 0; - uint32_t price = 0; - for (const auto& provider : shopProviders) for (const auto& offer : provider.offers) { - auto count = observation.inventoryCounts.find(offer.itemId); - if (offer.sellPrice == 0 || count == observation.inventoryCounts.end() || count->second == 0) continue; - if (!selected || offer.sellPrice > price || (offer.sellPrice == price && - (std::max(Position::getDistanceX(observation.currentPosition, provider.position), Position::getDistanceY(observation.currentPosition, provider.position)) < - std::max(Position::getDistanceX(observation.currentPosition, selected->position), Position::getDistanceY(observation.currentPosition, selected->position))))) { - selected = &provider; itemId = offer.itemId; price = offer.sellPrice; - } - } - if (!selected) { serviceStage = PlayerBotServiceStage::BuyPotions; return advance(observation, catalog, disposition); } - const uint32_t backpack = observation.backpackSaleCounts.count(itemId) ? observation.backpackSaleCounts.at(itemId) : 0; - return {backpack == 0 ? PlayerBotServiceCommandType::Wait : PlayerBotServiceCommandType::Sell, - PlayerBotServiceOutcome::Pending, selected->id, itemId, std::min(100, backpack)}; - } - if (serviceStage == PlayerBotServiceStage::BuyPotions) { - const uint16_t itemId = PlayerBotDispositionPolicy::smallHealthPotionItemId; - const uint32_t count = observation.inventoryCounts.count(itemId) ? observation.inventoryCounts.at(itemId) : 0; - if (count >= PlayerBotDispositionPolicy::potionRestockTarget) { serviceStage = PlayerBotServiceStage::Bank; return advance(observation, catalog, disposition); } - const PlayerBotEconomyProvider* provider = catalog.rankedProvider(shopProviders, itemId, true, observation.currentPosition); - if (!provider) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } - auto offer = std::find_if(provider->offers.begin(), provider->offers.end(), [itemId](const auto& candidate) { return candidate.itemId == itemId && candidate.buyPrice != 0; }); - if (offer == provider->offers.end()) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } - const auto restock = disposition.restock({count, observation.freeCapacity, observation.money, observation.bankBalance}, offer->buyPrice, Item::items[itemId].weight); - if (restock.insufficientFunds) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::InsufficientFunds}; } - if (restock.amount == 0) { serviceStage = PlayerBotServiceStage::Bank; return advance(observation, catalog, disposition); } - return {PlayerBotServiceCommandType::Buy, PlayerBotServiceOutcome::Pending, provider->id, itemId, restock.amount}; - } - const PlayerBotEconomyProvider* banker = nearestBanker(observation.currentPosition); - if (!banker) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } - if (!serviceSession.bankDepositComplete()) return {PlayerBotServiceCommandType::DepositAll, PlayerBotServiceOutcome::Pending, banker->id}; - if (!serviceSession.hasBankWithdrawal()) { - const uint32_t amount = disposition.bankWithdrawal({0, observation.freeCapacity, observation.money, observation.bankBalance}, Item::items[ITEM_GOLD_COIN].weight); - if (amount == 0) { serviceStage = PlayerBotServiceStage::Complete; return {PlayerBotServiceCommandType::Complete, PlayerBotServiceOutcome::Success}; } - return {PlayerBotServiceCommandType::Withdraw, PlayerBotServiceOutcome::Pending, banker->id, 0, amount}; - } - return {PlayerBotServiceCommandType::Withdraw, PlayerBotServiceOutcome::Pending, banker->id}; + return catalog.rankedProvider(shopProviders, itemId, purchase, position); } -bool PlayerBotServiceWorkflow::isApproachRejected(const Position& position) const +void PlayerBotServiceWorkflow::targetProvider(uint32_t id) { - return rejectedApproaches.find(position) != rejectedApproaches.end(); + if (!npcSession.targets(id)) { + npcSession.reset(id); + serviceSession.reset(); + providerApproaches.clear(); + pendingApproachRoute.reset(); + selectedApproach.reset(); + rejectedApproaches.clear(); + } } -void PlayerBotServiceWorkflow::rejectApproach(const Position& position) +PlayerBotServiceCommand PlayerBotServiceWorkflow::approachProvider(const PlayerBotServiceObservation& observation, + const PlayerBotServiceProviderObservation& provider) { - rejectedApproaches.insert(position); + if (provider.inRange) { + pendingApproachRoute.reset(); + selectedApproach.reset(); + return {}; + } + if (selectedApproach) { + PlayerBotServiceCommand command{PlayerBotServiceCommandType::NavigateProvider, PlayerBotServiceOutcome::Pending, + npcSession.targetId()}; + command.destination = *selectedApproach; + return command; + } + if (providerApproaches.empty()) { + providerApproaches = provider.approaches; + } + if (pendingApproachRoute) { + if (observation.approachRoute.providerId != npcSession.targetId() || + observation.approachRoute.destination != *pendingApproachRoute || + observation.approachRoute.result == PlayerBotServiceRouteResult::NotObserved) { + PlayerBotServiceCommand command{PlayerBotServiceCommandType::ValidateProviderRoute, PlayerBotServiceOutcome::Pending, + npcSession.targetId()}; + command.destination = *pendingApproachRoute; + return command; + } + if (observation.approachRoute.result == PlayerBotServiceRouteResult::Reached) { + selectedApproach = *pendingApproachRoute; + pendingApproachRoute.reset(); + PlayerBotServiceCommand command{PlayerBotServiceCommandType::NavigateProvider, PlayerBotServiceOutcome::Pending, + npcSession.targetId()}; + command.destination = *selectedApproach; + return command; + } + rejectedApproaches.insert(*pendingApproachRoute); + pendingApproachRoute.reset(); + } + for (const auto& candidate : providerApproaches) { + if (rejectedApproaches.find(candidate.position) != rejectedApproaches.end()) continue; + pendingApproachRoute = candidate.position; + PlayerBotServiceCommand command{PlayerBotServiceCommandType::ValidateProviderRoute, PlayerBotServiceOutcome::Pending, + npcSession.targetId()}; + command.destination = candidate.position; + return command; + } + serviceStage = PlayerBotServiceStage::Failed; + return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } -void PlayerBotServiceWorkflow::clearRejectedApproaches() +PlayerBotServiceCommand PlayerBotServiceWorkflow::establishNpc(const PlayerBotServiceObservation& observation, bool shop) { - rejectedApproaches.clear(); + const auto state = observation.providers.find(npcSession.targetId()); + if (state == observation.providers.end() || !state->second.available) { + serviceStage = PlayerBotServiceStage::Failed; + return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; + } + if (PlayerBotServiceCommand approach = approachProvider(observation, state->second); approach.type != PlayerBotServiceCommandType::None) { + return approach; + } + if (npcSession.step() == PlayerBotNpcConversationStep::Greet) { + npcSession.setStep(PlayerBotNpcConversationStep::Request); + return {PlayerBotServiceCommandType::Speak, PlayerBotServiceOutcome::Pending, npcSession.targetId(), 0, 0, + CONST_SLOT_WHEREEVER, "hi"}; + } + if (npcSession.step() == PlayerBotNpcConversationStep::Request) { + if (!npcSession.isGreetingAcknowledged()) { + if (npcSession.retryLimitReached(observation.maximumAttempts)) { + serviceStage = PlayerBotServiceStage::Failed; + return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Rejected}; + } + npcSession.setStep(PlayerBotNpcConversationStep::Greet); + return {PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Retry}; + } + npcSession.setStep(shop ? PlayerBotNpcConversationStep::Confirm : PlayerBotNpcConversationStep::Ready); + if (!shop) return {PlayerBotServiceCommandType::Wait}; + return {PlayerBotServiceCommandType::Speak, PlayerBotServiceOutcome::Pending, npcSession.targetId(), 0, 0, + CONST_SLOT_WHEREEVER, "trade"}; + } + if (shop && npcSession.step() == PlayerBotNpcConversationStep::Confirm) { + if (!state->second.shopOpen) { + if (npcSession.retryLimitReached(observation.maximumAttempts)) { + serviceStage = PlayerBotServiceStage::Failed; + return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Rejected}; + } + npcSession.resetGreetingAcknowledgement(); + npcSession.setStep(PlayerBotNpcConversationStep::Greet); + return {PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Retry}; + } + npcSession.setStep(PlayerBotNpcConversationStep::Ready); + npcSession.resetRetries(); + } + return {PlayerBotServiceCommandType::None}; } -void PlayerBotServiceWorkflow::beginSlottedSale(uint16_t itemId, slots_t slot, uint32_t backpackCount) +bool PlayerBotServiceWorkflow::hasSlottedItem(const PlayerBotServiceObservation& observation, uint16_t itemId, slots_t slot) const { - pendingSlottedItem = itemId; - pendingSlottedSlot = slot; - pendingSlottedBackpackItems = backpackCount; - ++slottedMoveAttempts; + return std::any_of(observation.slottedSaleItems.begin(), observation.slottedSaleItems.end(), [itemId, slot](const auto& item) { + return item.itemId == itemId && item.slot == slot && item.count != 0; + }); } -void PlayerBotServiceWorkflow::clearPendingSlottedSale() +PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceSlottedSale(const PlayerBotServiceObservation& observation, uint16_t itemId) { - pendingSlottedItem = 0; - pendingSlottedSlot = CONST_SLOT_WHEREEVER; - pendingSlottedBackpackItems = 0; - slottedMoveAttempts = 0; + if (pendingSlottedItem != 0) { + const uint32_t backpack = observation.backpackSaleCounts.count(pendingSlottedItem) ? observation.backpackSaleCounts.at(pendingSlottedItem) : 0; + const bool moved = !hasSlottedItem(observation, pendingSlottedItem, pendingSlottedSlot) && backpack > pendingSlottedBackpackItems; + if (moved) { + PlayerBotServiceCommand command{PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Success}; + command.itemId = pendingSlottedItem; + command.sourceSlot = pendingSlottedSlot; + command.providerAvailable = true; + pendingSlottedItem = 0; + pendingSlottedSlot = CONST_SLOT_WHEREEVER; + slottedMoveAttempts = 0; + return command; + } + if (slottedMoveAttempts >= observation.maximumAttempts) { + PlayerBotServiceCommand command{PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Unavailable}; + command.itemId = pendingSlottedItem; + command.sourceSlot = pendingSlottedSlot; + command.cooldownMs = observation.slottedSaleCooldownMs; + command.providerAvailable = true; + unavailableSlottedSales[{pendingSlottedItem, pendingSlottedSlot}] = observation.now + + std::chrono::milliseconds(observation.slottedSaleCooldownMs); + pendingSlottedItem = 0; + pendingSlottedSlot = CONST_SLOT_WHEREEVER; + slottedMoveAttempts = 0; + serviceStage = PlayerBotServiceStage::BuyPotions; + return command; + } + } + for (const auto& item : observation.slottedSaleItems) { + if (item.itemId != itemId || item.count == 0) continue; + auto unavailable = unavailableSlottedSales.find({item.itemId, item.slot}); + if (unavailable != unavailableSlottedSales.end() && unavailable->second > observation.now) continue; + pendingSlottedItem = item.itemId; + pendingSlottedSlot = item.slot; + pendingSlottedBackpackItems = observation.backpackSaleCounts.count(itemId) ? observation.backpackSaleCounts.at(itemId) : 0; + ++slottedMoveAttempts; + PlayerBotServiceCommand command{PlayerBotServiceCommandType::MoveSlottedSale, PlayerBotServiceOutcome::Pending, 0, + item.itemId, item.count, item.slot}; + command.attempt = slottedMoveAttempts; + command.providerAvailable = true; + return command; + } + return {PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Unavailable}; } -std::optional PlayerBotServiceWorkflow::pendingSlottedSale() const +PlayerBotServiceCommand PlayerBotServiceWorkflow::verifyShop(const PlayerBotServiceObservation& observation, bool purchase) { - if (!hasPendingSlottedSale()) { - return std::nullopt; + const PlayerBotServiceTransaction* transaction = serviceSession.shopTransaction(); + if (!transaction) return {PlayerBotServiceCommandType::None}; + const PlayerBotEconomyProvider* shop = provider(npcSession.targetId(), true); + if (!shop) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } + auto offer = std::find_if(shop->offers.begin(), shop->offers.end(), [transaction](const auto& value) { return value.itemId == transaction->itemId; }); + if (offer == shop->offers.end()) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } + const uint32_t count = observation.inventoryCounts.count(transaction->itemId) ? observation.inventoryCounts.at(transaction->itemId) : 0; + const PlayerBotServiceVerification result = serviceSession.verifyShopTransaction(count, observation.money, observation.bankBalance, + purchase, purchase ? offer->buyPrice : offer->sellPrice, observation.maximumAttempts); + if (result.result == PlayerBotServiceVerificationResult::Success) { + npcSession.setStep(PlayerBotNpcConversationStep::Ready); + npcSession.resetRetries(); + PlayerBotServiceCommand command{PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Success}; + command.transaction = result.before; + command.verification = result; + return command; } - return PlayerBotSlottedSaleState{pendingSlottedItem, pendingSlottedSlot, pendingSlottedBackpackItems, slottedMoveAttempts}; + if (result.result == PlayerBotServiceVerificationResult::Retry) return {PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Retry}; + serviceStage = PlayerBotServiceStage::Failed; + return {PlayerBotServiceCommandType::Fail, result.result == PlayerBotServiceVerificationResult::Mismatch ? + PlayerBotServiceOutcome::Rejected : PlayerBotServiceOutcome::Unavailable}; } -PlayerBotSlottedSaleObservation PlayerBotServiceWorkflow::observeSlottedSale(bool moved, uint32_t maximumAttempts, - std::chrono::steady_clock::time_point now, std::chrono::steady_clock::duration cooldown) +PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceBank(const PlayerBotServiceObservation& observation, + const PlayerBotDispositionPolicy& disposition) { - if (!hasPendingSlottedSale()) { - return PlayerBotSlottedSaleObservation::Pending; + const PlayerBotEconomyProvider* banker = nearestBanker(observation.currentPosition); + if (!banker) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } + targetProvider(banker->id); + PlayerBotServiceCommand focus = establishNpc(observation, false); + if (focus.type != PlayerBotServiceCommandType::None) return focus; + if (!serviceSession.bankDepositComplete()) { + if (!serviceSession.hasBankDeposit()) serviceSession.beginBankDeposit(observation.money, observation.bankBalance); + if (serviceSession.bankTransaction().money == 0) { + serviceSession.markBankDepositComplete(); + return {PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Success}; + } + if (npcSession.step() == PlayerBotNpcConversationStep::Ready) { + npcSession.setStep(PlayerBotNpcConversationStep::Confirm); + PlayerBotServiceCommand command{PlayerBotServiceCommandType::DepositAll, PlayerBotServiceOutcome::Pending, banker->id}; + command.transaction = serviceSession.bankTransaction(); + return command; + } + if (npcSession.step() == PlayerBotNpcConversationStep::Confirm) { + npcSession.setStep(PlayerBotNpcConversationStep::Verify); + return {PlayerBotServiceCommandType::Speak, PlayerBotServiceOutcome::Pending, banker->id, 0, 0, CONST_SLOT_WHEREEVER, "yes"}; + } + const PlayerBotServiceVerification result = serviceSession.verifyBankDeposit(observation.money, observation.bankBalance, observation.maximumAttempts); + if (result.result == PlayerBotServiceVerificationResult::Success) { + serviceSession.markBankDepositComplete(); + npcSession.setStep(PlayerBotNpcConversationStep::Ready); + PlayerBotServiceCommand command{PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Success}; + command.transaction = result.before; + command.verification = result; + return command; + } + if (result.result == PlayerBotServiceVerificationResult::Rejected) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Rejected}; } + return {PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Retry}; } - if (moved) { - clearPendingSlottedSale(); - return PlayerBotSlottedSaleObservation::Moved; + if (!serviceSession.hasBankWithdrawal()) { + const uint32_t amount = disposition.bankWithdrawal({0, observation.freeCapacity, observation.money, observation.bankBalance}, observation.goldCoinWeight); + if (amount == 0) { serviceStage = PlayerBotServiceStage::Complete; return {PlayerBotServiceCommandType::Complete, PlayerBotServiceOutcome::Success}; } + serviceSession.beginBankWithdrawal(observation.bankBalance, amount); } - const PlayerBotSlottedSaleState pending = *pendingSlottedSale(); - clearPendingSlottedSale(); - if (pending.attempts >= maximumAttempts) { - deferSlottedSale(pending.itemId, pending.sourceSlot, now + cooldown); - return PlayerBotSlottedSaleObservation::Deferred; + if (npcSession.step() == PlayerBotNpcConversationStep::Ready) { + npcSession.setStep(PlayerBotNpcConversationStep::Confirm); + PlayerBotServiceCommand command{PlayerBotServiceCommandType::Withdraw, PlayerBotServiceOutcome::Pending, banker->id, 0, + serviceSession.bankTransaction().amount}; + command.transaction = serviceSession.bankTransaction(); + return command; } - return PlayerBotSlottedSaleObservation::Retry; -} - -bool PlayerBotServiceWorkflow::slottedSaleUnavailable(uint16_t itemId, slots_t slot, - std::chrono::steady_clock::time_point now) const -{ - auto it = unavailableSlottedSales.find({itemId, slot}); - return it != unavailableSlottedSales.end() && it->second > now; + if (npcSession.step() == PlayerBotNpcConversationStep::Confirm) { + npcSession.setStep(PlayerBotNpcConversationStep::Verify); + return {PlayerBotServiceCommandType::Speak, PlayerBotServiceOutcome::Pending, banker->id, 0, 0, CONST_SLOT_WHEREEVER, "yes"}; + } + const PlayerBotServiceVerification result = serviceSession.verifyBankWithdrawal(observation.money, observation.bankBalance, observation.maximumAttempts); + if (result.result == PlayerBotServiceVerificationResult::Success) { + serviceStage = PlayerBotServiceStage::Complete; + PlayerBotServiceCommand command{PlayerBotServiceCommandType::Complete, PlayerBotServiceOutcome::Success}; + command.transaction = result.before; + command.verification = result; + return command; + } + if (result.result == PlayerBotServiceVerificationResult::Rejected) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Rejected}; } + return {PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Retry}; } -void PlayerBotServiceWorkflow::deferSlottedSale(uint16_t itemId, slots_t slot, - std::chrono::steady_clock::time_point expires) +PlayerBotServiceCommand PlayerBotServiceWorkflow::advance(const PlayerBotServiceObservation& observation, + const PlayerBotEconomyCatalog& catalog, const PlayerBotDispositionPolicy& disposition) { - unavailableSlottedSales[{itemId, slot}] = expires; + const bool discovered = serviceStage == PlayerBotServiceStage::Discover; + PlayerBotServiceCommand command = advanceImpl(observation, catalog, disposition); + if (discovered) command.discoveries = observation.discoveries; + return command; } -std::optional PlayerBotServiceWorkflow::nextDeferredSlottedSale( - std::chrono::steady_clock::time_point now) const +PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceImpl(const PlayerBotServiceObservation& observation, + const PlayerBotEconomyCatalog& catalog, const PlayerBotDispositionPolicy& disposition) { - std::optional earliest; - for (const auto& entry : unavailableSlottedSales) { - if (entry.second > now && (!earliest || entry.second < *earliest)) { - earliest = entry.second; + if (serviceStage == PlayerBotServiceStage::Failed) return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Rejected}; + if (serviceStage == PlayerBotServiceStage::Complete) return {PlayerBotServiceCommandType::Complete, PlayerBotServiceOutcome::Success}; + observeProviders(observation.shops, observation.bankers); + if (serviceStage == PlayerBotServiceStage::Discover) { + if (shopProviders.empty() || bankProviders.empty()) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } + serviceStage = PlayerBotServiceStage::SellLoot; + } + if (serviceStage == PlayerBotServiceStage::SellLoot) { + const PlayerBotEconomyProvider* selected = nullptr; + uint16_t itemId = 0; + uint32_t price = 0; + for (const auto& shop : shopProviders) for (const auto& offer : shop.offers) { + auto count = observation.inventoryCounts.find(offer.itemId); + if (offer.sellPrice == 0 || count == observation.inventoryCounts.end() || count->second == 0) continue; + if (!selected || offer.sellPrice > price) { selected = &shop; itemId = offer.itemId; price = offer.sellPrice; } } + if (!selected) { serviceStage = PlayerBotServiceStage::BuyPotions; return advanceImpl(observation, catalog, disposition); } + targetProvider(selected->id); + PlayerBotServiceCommand focus = establishNpc(observation, true); + if (focus.type != PlayerBotServiceCommandType::None) return focus; + const uint32_t backpack = observation.backpackSaleCounts.count(itemId) ? observation.backpackSaleCounts.at(itemId) : 0; + if (backpack == 0) return advanceSlottedSale(observation, itemId); + if (serviceSession.hasShopTransaction()) return verifyShop(observation, false); + auto offer = std::find_if(selected->offers.begin(), selected->offers.end(), [itemId](const auto& value) { + return value.itemId == itemId && value.sellPrice != 0; + }); + if (offer == selected->offers.end()) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } + const PlayerBotServiceTransaction transaction{itemId, std::min(100, backpack), observation.inventoryCounts.at(itemId), observation.money, observation.bankBalance}; + serviceSession.beginShopTransaction(transaction); + PlayerBotServiceCommand command{PlayerBotServiceCommandType::Sell, PlayerBotServiceOutcome::Pending, selected->id, itemId, transaction.amount}; + command.subType = offer->subType; + command.transaction = transaction; + return command; + } + if (serviceStage == PlayerBotServiceStage::BuyPotions) { + const uint16_t itemId = PlayerBotDispositionPolicy::smallHealthPotionItemId; + const uint32_t count = observation.inventoryCounts.count(itemId) ? observation.inventoryCounts.at(itemId) : 0; + if (count >= PlayerBotDispositionPolicy::potionRestockTarget) { serviceStage = PlayerBotServiceStage::Bank; return advanceImpl(observation, catalog, disposition); } + const PlayerBotEconomyProvider* selected = offerProvider(itemId, true, observation.currentPosition, catalog); + if (!selected) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } + auto offer = std::find_if(selected->offers.begin(), selected->offers.end(), [itemId](const auto& value) { return value.itemId == itemId && value.buyPrice != 0; }); + if (offer == selected->offers.end()) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } + const auto restock = disposition.restock({count, observation.freeCapacity, observation.money, observation.bankBalance}, offer->buyPrice, + observation.smallHealthPotionWeight); + if (restock.insufficientFunds) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::InsufficientFunds}; } + if (restock.amount == 0) { serviceStage = PlayerBotServiceStage::Bank; return advanceImpl(observation, catalog, disposition); } + targetProvider(selected->id); + PlayerBotServiceCommand focus = establishNpc(observation, true); + if (focus.type != PlayerBotServiceCommandType::None) return focus; + if (serviceSession.hasShopTransaction()) return verifyShop(observation, true); + const PlayerBotServiceTransaction transaction{itemId, restock.amount, count, observation.money, observation.bankBalance}; + serviceSession.beginShopTransaction(transaction); + PlayerBotServiceCommand command{PlayerBotServiceCommandType::Buy, PlayerBotServiceOutcome::Pending, selected->id, itemId, restock.amount}; + command.subType = offer->subType; + command.transaction = transaction; + return command; } - return earliest; + return advanceBank(observation, disposition); } diff --git a/server/src/playerbotserviceworkflow.h b/server/src/playerbotserviceworkflow.h index 78c11819..c0bb3b88 100644 --- a/server/src/playerbotserviceworkflow.h +++ b/server/src/playerbotserviceworkflow.h @@ -1,4 +1,4 @@ -/** Service state machine. The controller supplies immutable engine observations and executes commands. */ +/** Service state machine. The controller supplies value observations and dispatches commands. */ #ifndef FS_PLAYERBOTSERVICEWORKFLOW_H #define FS_PLAYERBOTSERVICEWORKFLOW_H @@ -6,28 +6,52 @@ #include "playerbotnpcsession.h" #include "playerbotservicesession.h" -#include "creature.h" -#include "item.h" +#include "player.h" #include #include +#include #include +#include enum class PlayerBotServiceStage : uint8_t { Discover, SellLoot, BuyPotions, Bank, Complete, Failed }; enum class PlayerBotServiceCommandType : uint8_t { - None, - RequestDiscoverySnapshot, - NavigateProvider, - OpenShop, - Sell, - Buy, - DepositAll, - Withdraw, - Complete, - Fail, - Wait, + None, ValidateProviderRoute, NavigateProvider, Speak, Sell, Buy, DepositAll, Withdraw, MoveSlottedSale, Complete, Fail, Wait, }; enum class PlayerBotServiceOutcome : uint8_t { Pending, Success, Partial, Retry, Rejected, Unavailable, InsufficientFunds }; +enum class PlayerBotServiceRouteResult : uint8_t { NotObserved, Reached, Unreachable }; + +struct PlayerBotServiceSlottedItem { + uint16_t itemId = 0; + slots_t slot = CONST_SLOT_WHEREEVER; + uint32_t count = 0; +}; + +struct PlayerBotServiceProviderObservation { + bool available = false; + bool inRange = false; + bool shopOpen = false; + struct Approach { + Position position; + uint32_t distance = 0; + }; + std::vector approaches; +}; + +struct PlayerBotServiceRouteObservation { + uint32_t providerId = 0; + Position destination; + PlayerBotServiceRouteResult result = PlayerBotServiceRouteResult::NotObserved; + uint32_t steps = 0; + uint64_t expandedNodes = 0; +}; + +struct PlayerBotServiceDiscovery { + uint32_t npcId = 0; + std::string npcName; + std::string capability; + uint32_t offers = 0; +}; struct PlayerBotServiceCommand { PlayerBotServiceCommandType type = PlayerBotServiceCommandType::None; @@ -35,29 +59,43 @@ struct PlayerBotServiceCommand { uint32_t providerId = 0; uint16_t itemId = 0; uint32_t amount = 0; + slots_t sourceSlot = CONST_SLOT_WHEREEVER; + const char* speech = nullptr; + uint8_t subType = 0; + uint32_t attempt = 0; + uint32_t cooldownMs = 0; + Position destination; + bool providerAvailable = false; + std::optional transaction; + std::optional verification; + std::vector discoveries; }; // This is deliberately a value snapshot. It contains no Item, Container, Npc, // or Player pointers, so workflow decisions remain reproducible in fixtures. struct PlayerBotServiceObservation { Position currentPosition; - bool discoveryObserved = false; std::vector shops; std::vector bankers; std::map inventoryCounts; std::map backpackSaleCounts; + std::vector slottedSaleItems; uint32_t freeCapacity = 0; uint64_t money = 0; uint64_t bankBalance = 0; - bool providerInRange = false; + uint32_t goldCoinWeight = 0; + uint32_t smallHealthPotionWeight = 0; + std::map providers; + std::vector discoveries; + uint32_t maximumAttempts = 0; + uint32_t slottedSaleCooldownMs = 0; + std::chrono::steady_clock::time_point now; + PlayerBotServiceRouteObservation approachRoute; }; -enum class PlayerBotSlottedSaleObservation : uint8_t { Pending, Moved, Retry, Deferred }; -struct PlayerBotSlottedSaleState { - uint16_t itemId = 0; - slots_t sourceSlot = CONST_SLOT_WHEREEVER; - uint32_t backpackCount = 0; - uint32_t attempts = 0; +struct PlayerBotServiceSnapshot { + PlayerBotServiceStage stage = PlayerBotServiceStage::Discover; + uint32_t npcId = 0; }; class PlayerBotServiceWorkflow @@ -65,80 +103,45 @@ class PlayerBotServiceWorkflow public: void reset(); PlayerBotServiceStage stage() const { return serviceStage; } + PlayerBotServiceSnapshot snapshot() const { return {serviceStage, npcSession.targetId()}; } + bool reportNpcReply(uint32_t playerId, uint32_t replyingPlayerId, uint32_t npcId, uint8_t type); PlayerBotServiceCommand advance(const PlayerBotServiceObservation& observation, const PlayerBotEconomyCatalog& catalog, const PlayerBotDispositionPolicy& disposition); - void fail() { serviceStage = PlayerBotServiceStage::Failed; } - void complete() { serviceStage = PlayerBotServiceStage::Complete; } - void skipSelling() { serviceStage = PlayerBotServiceStage::BuyPotions; } - - void resetNpc(uint32_t targetId = 0) { npcSession.reset(targetId); } - void resetGreetingAcknowledgement() { npcSession.resetGreetingAcknowledgement(); } - bool isGreetingAcknowledged() const { return npcSession.isGreetingAcknowledged(); } - bool npcTargets(uint32_t npcId) const { return npcSession.targets(npcId); } - uint32_t npcTargetId() const { return npcSession.targetId(); } - PlayerBotNpcConversationStep npcStep() const { return npcSession.step(); } - void setNpcStep(PlayerBotNpcConversationStep step) { npcSession.setStep(step); } - void resetNpcRetries() { npcSession.resetRetries(); } - bool acceptNpcReply(uint32_t playerId, uint32_t replyingPlayerId, uint32_t npcId, uint8_t type); - PlayerBotNpcSessionOutcome establishNpcFocus(Player& player, Npc& npc, uint32_t maximumRetries); - PlayerBotNpcSessionOutcome openNpcShop(Player& player, Npc& npc, uint32_t maximumRetries); - uint32_t npcNextDelay() const { return npcSession.nextDelay(); } - - bool hasShopTransaction() const { return serviceSession.hasShopTransaction(); } - void resetTransactions() { serviceSession.reset(); } - const PlayerBotServiceTransaction* shopTransaction() const { return serviceSession.shopTransaction(); } - void beginShopTransaction(PlayerBotServiceTransaction transaction) { serviceSession.beginShopTransaction(transaction); } - PlayerBotServiceVerification verifyShopTransaction(uint32_t itemCount, uint64_t money, uint64_t balance, - bool purchase, uint32_t unitPrice, uint32_t maximumRetries); - bool bankDepositComplete() const { return serviceSession.bankDepositComplete(); } - void beginBankDeposit(uint64_t money, uint64_t balance) { serviceSession.beginBankDeposit(money, balance); } - void markBankDepositComplete() { serviceSession.markBankDepositComplete(); } - bool hasBankDeposit() const { return serviceSession.hasBankDeposit(); } - PlayerBotServiceVerification verifyBankDeposit(uint64_t money, uint64_t balance, uint32_t maximumRetries); - bool hasBankWithdrawal() const { return serviceSession.hasBankWithdrawal(); } - const PlayerBotServiceTransaction& bankTransaction() const { return serviceSession.bankTransaction(); } - void beginBankWithdrawal(uint64_t balance, uint32_t amount) { serviceSession.beginBankWithdrawal(balance, amount); } - PlayerBotServiceVerification verifyBankWithdrawal(uint64_t money, uint64_t balance, uint32_t maximumRetries); - - const std::vector& shops() const { return shopProviders; } - const std::vector& bankers() const { return bankProviders; } - const PlayerBotEconomyProvider* provider(uint32_t id, bool shop) const; - - const Position& approachTarget() const { return pendingApproach; } - void setApproachTarget(Position target) { pendingApproach = target; } - void clearApproach() { pendingApproach = Position(); } - bool isApproachRejected(const Position& position) const; - void rejectApproach(const Position& position); - void clearRejectedApproaches(); - - std::optional pendingSlottedSale() const; - bool hasPendingSlottedSale() const { return pendingSlottedItem != 0; } - uint32_t slottedSaleAttempts() const { return slottedMoveAttempts; } - void beginSlottedSale(uint16_t itemId, slots_t slot, uint32_t backpackCount); - void clearPendingSlottedSale(); - PlayerBotSlottedSaleObservation observeSlottedSale(bool moved, uint32_t maximumAttempts, - std::chrono::steady_clock::time_point now, - std::chrono::steady_clock::duration cooldown); - bool slottedSaleUnavailable(uint16_t itemId, slots_t slot, std::chrono::steady_clock::time_point now) const; - void deferSlottedSale(uint16_t itemId, slots_t slot, std::chrono::steady_clock::time_point expires); - std::optional nextDeferredSlottedSale(std::chrono::steady_clock::time_point now) const; private: void observeProviders(std::vector shops, std::vector bankers); + const PlayerBotEconomyProvider* provider(uint32_t id, bool shop) const; const PlayerBotEconomyProvider* nearestBanker(const Position& position) const; + const PlayerBotEconomyProvider* offerProvider(uint16_t itemId, bool purchase, const Position& position, + const PlayerBotEconomyCatalog& catalog) const; + void targetProvider(uint32_t id); + PlayerBotServiceCommand establishNpc(const PlayerBotServiceObservation& observation, bool shop); + PlayerBotServiceCommand verifyShop(const PlayerBotServiceObservation& observation, bool purchase); + PlayerBotServiceCommand advanceImpl(const PlayerBotServiceObservation& observation, + const PlayerBotEconomyCatalog& catalog, + const PlayerBotDispositionPolicy& disposition); + PlayerBotServiceCommand advanceBank(const PlayerBotServiceObservation& observation, + const PlayerBotDispositionPolicy& disposition); + PlayerBotServiceCommand advanceSlottedSale(const PlayerBotServiceObservation& observation, uint16_t itemId); + bool hasSlottedItem(const PlayerBotServiceObservation& observation, uint16_t itemId, slots_t slot) const; + PlayerBotServiceCommand approachProvider(const PlayerBotServiceObservation& observation, + const PlayerBotServiceProviderObservation& provider); + PlayerBotNpcSession npcSession; PlayerBotServiceSession serviceSession; PlayerBotServiceStage serviceStage = PlayerBotServiceStage::Discover; std::vector shopProviders; std::vector bankProviders; - Position pendingApproach; - std::set rejectedApproaches; uint16_t pendingSlottedItem = 0; slots_t pendingSlottedSlot = CONST_SLOT_WHEREEVER; uint32_t pendingSlottedBackpackItems = 0; uint32_t slottedMoveAttempts = 0; std::map, std::chrono::steady_clock::time_point> unavailableSlottedSales; + std::vector providerApproaches; + std::optional pendingApproachRoute; + std::optional selectedApproach; + std::set rejectedApproaches; }; #endif diff --git a/server/src/playerbotspells.cpp b/server/src/playerbotspells.cpp index 36a7d25d..0a778065 100644 --- a/server/src/playerbotspells.cpp +++ b/server/src/playerbotspells.cpp @@ -95,7 +95,7 @@ void PlayerBotController::emitSpellCastEvent(const Position& position, const cha << ",\"haste_ticks_observed\":" << pending->hasteTicksObserved << ",\"haste_duration_measured\":" << pending->hasteDurationMeasured; } - if (const PlayerBotSpellProfile* profile = survivalRuntime.calibrationProfile(*pending)) { + if (const auto profile = survivalRuntime.calibrationProfile(*pending)) { fields << ",\"calibration\":{\"accepted\":" << profile->accepted << ",\"rejected\":" << profile->rejected << ",\"ambiguous\":" << profile->ambiguous << ",\"minimum\":" << profile->minimum << ",\"maximum\":" << profile->maximum << ",\"conservative\":" << profile->conservative @@ -115,26 +115,35 @@ void PlayerBotController::emitSpellCastEvent(const Position& position, const cha bool PlayerBotController::dispatchSpellCommand(Player& player, const Position& position, PlayerBotSurvivalCommand command) { if (command.type != PlayerBotSurvivalCommandType::CastSpell || !command.spell) { - if (!command.reason) return false; - const PlayerBotSpellDescriptor* descriptor = command.candidate; + if (command.reason.empty()) return false; + const PlayerBotSpellDescriptor* descriptor = command.candidateName.empty() ? nullptr : + playerBotSpellDescriptor(command.candidateName.c_str()); const std::string key = std::string("cast_spell:") + command.reason + ':' + (descriptor ? descriptor->name : "unknown"); if (!descriptor || shouldEmitRepeated(key)) { emitSpellCastEvent(position, descriptor ? descriptor->name : nullptr, descriptor ? descriptor->words : nullptr, - descriptor ? playerBotSpellRoleName(descriptor->role) : nullptr, command.need ? command.need : "unknown", - "skipped", "not_attempted", command.reason, nullptr, &player, - descriptor ? fallbackForRole(descriptor->role) : fallbackForNeed(command.need ? command.need : "")); + descriptor ? playerBotSpellRoleName(descriptor->role) : nullptr, command.need.empty() ? "unknown" : command.need.c_str(), + "skipped", "not_attempted", command.reason.c_str(), nullptr, &player, + descriptor ? fallbackForRole(descriptor->role) : fallbackForNeed(command.need.c_str())); } return false; } const auto& cast = *command.spell; + InstantSpell* spell = g_spells ? g_spells->getInstantSpellByName(cast.name) : nullptr; + if (!spell || spell->getWords() != cast.words) { + telemetry.recordActionFailure(); + emitSpellCastEvent(position, cast.name.c_str(), cast.words.c_str(), playerBotSpellRoleName(cast.role), + cast.pending.need.c_str(), "failed", "rejected", "unsupported_metadata", &cast.pending, &player, + fallbackForRole(cast.role)); + return false; + } telemetry.recordActionAttempt(); - emitSpellCastEvent(position, cast.descriptor->name, cast.descriptor->words, playerBotSpellRoleName(cast.descriptor->role), + emitSpellCastEvent(position, cast.name.c_str(), cast.words.c_str(), playerBotSpellRoleName(cast.role), cast.pending.need.c_str(), "requested", "unchecked", nullptr, &cast.pending, &player, nullptr); // The normal speech handler remains the authoritative spell engine. survivalRuntime.beginEngineSpellCast(); - g_game.playerSay(playerId, 0, TALKTYPE_SAY, "", cast.spell->getWords()); + g_game.playerSay(playerId, 0, TALKTYPE_SAY, "", cast.words); survivalRuntime.endEngineSpellCast(); - if (cast.descriptor->role == PlayerBotSpellRole::Support) { + if (cast.role == PlayerBotSpellRole::Support) { if (Condition* haste = player.getCondition(CONDITION_HASTE)) survivalRuntime.observeHasteAfterCast(haste->getTicks(), haste->getEndTime()); } return true; @@ -143,7 +152,7 @@ bool PlayerBotController::dispatchSpellCommand(Player& player, const Position& p void PlayerBotController::verifySpellCast(Player& player, const Position& position) { // The runtime owns pending-cast verification and calibration; the controller only supplies live observations. - const PlayerBotSpellPendingCast* pending = survivalRuntime.pendingSpell(); + const auto pending = survivalRuntime.pendingSpell(); if (!pending) return; Creature* target = g_game.getCreatureByID(pending->targetId); Condition* haste = player.getCondition(CONDITION_HASTE); @@ -176,9 +185,9 @@ void PlayerBotController::verifySpellCast(Player& player, const Position& positi void PlayerBotController::finishMagicTraining(Player& player, const Position& position, const char* result, const char* reason) { - progressionRuntime.setCooldown(TopLevelGoal::MagicTraining, magicTrainingRetryDelay); - if (goalArbiter.activeGoal() == TopLevelGoal::MagicTraining) { - emit("goal_result", position, "\"decision_id\":" + std::to_string(goalArbiter.decisionId()) + + progressionRuntime.completeMagicTraining(magicTrainingRetryDelay); + if (progressionRuntime.activeGoal() == TopLevelGoal::MagicTraining) { + emit("goal_result", position, "\"decision_id\":" + std::to_string(progressionRuntime.decisionId()) + ",\"goal\":\"magic_training\",\"result\":" + jsonString(result) + ",\"reason\":" + jsonString(reason)); if (selectTopLevelGoal(player, position, "magic_training_complete")) { schedule(SCHEDULER_MINTICKS); @@ -189,8 +198,8 @@ void PlayerBotController::finishMagicTraining(Player& player, const Position& po bool PlayerBotController::processMagicTraining(Player& player, const Position& position) { const PlayerBotSurvivalSnapshot snapshot = survivalSnapshot(player); - const char* reason = survivalRuntime.magicTrainingReason(player, snapshot); - const std::optional selected = !reason ? survivalRuntime.decideMagicTraining(player, snapshot) : std::nullopt; + const char* reason = survivalRuntime.magicTrainingReason(snapshot); + const std::optional selected = !reason ? survivalRuntime.decideMagicTraining(snapshot) : std::nullopt; if (!selected) { finishMagicTraining(player, position, "skipped", reason ? reason : "opportunity_lost"); return false; @@ -203,15 +212,21 @@ bool PlayerBotController::processMagicTraining(Player& player, const Position& p telemetry.recordActionAttempt(); std::ostringstream request; request << "\"action\":\"magic_training\",\"result\":\"requested\",\"source\":\"engine_path\"" - << ",\"spell\":" << jsonString(selected->descriptor->name) << ",\"audited_priority\":" - << static_cast(selected->descriptor->magicTrainingPriority) << ",\"refresh\":" + << ",\"spell\":" << jsonString(selected->name) << ",\"audited_priority\":" + << static_cast(selected->priority) << ",\"refresh\":" << (selected->refresh ? "true" : "false") << ",\"mana_before\":" << manaBefore << ",\"mana_max\":" << player.getMaxMana() << ",\"mana_gain\":" << selected->manaGain << ",\"mana_tick_interval\":" << selected->manaTickInterval << ",\"mana_tick_remaining\":" << selected->manaTickRemaining << ",\"predicted_mana\":" << predictedMana << ",\"wasted_mana\":" << wastedMana << ",\"mana_cost\":" << selected->cost << ",\"emergency_reserve\":" << magicTrainingEmergencyReserve; emit("action_result", position, request.str()); - g_game.playerSay(playerId, 0, TALKTYPE_SAY, "", selected->spell->getWords()); + InstantSpell* spell = g_spells ? g_spells->getInstantSpellByName(selected->name) : nullptr; + if (!spell || spell->getWords() != selected->words) { + telemetry.recordActionFailure(); + finishMagicTraining(player, position, "failed", "unsupported_metadata"); + return false; + } + g_game.playerSay(playerId, 0, TALKTYPE_SAY, "", selected->words); uint64_t manaAfter = player.getMana(); const uint64_t manaSpentAfter = player.getSpentMana(); const uint32_t magicLevelAfter = player.getBaseMagicLevel(); @@ -223,7 +238,7 @@ bool PlayerBotController::processMagicTraining(Player& player, const Position& p std::ostringstream result; result << "\"action\":\"magic_training\",\"result\":" << jsonString(verified ? "success" : "failed") << ",\"source\":\"engine_verification\",\"engine_result\":" << jsonString(verified ? "accepted" : "rejected") - << ",\"spell\":" << jsonString(selected->descriptor->name) << ",\"mana_before\":" << manaBefore + << ",\"spell\":" << jsonString(selected->name) << ",\"mana_before\":" << manaBefore << ",\"mana_after\":" << manaAfter << ",\"mana_cost\":" << selected->cost << ",\"mana_delta\":" << manaDelta << ",\"mana_spent_before\":" << manaSpentBefore << ",\"mana_spent_after\":" << manaSpentAfter << ",\"magic_level_before\":" << magicLevelBefore << ",\"magic_level_after\":" << magicLevelAfter @@ -240,7 +255,7 @@ bool PlayerBotController::trySupportSpell(Player* player, const Position& curren { if (!player) return false; return dispatchSpellCommand(*player, currentPosition, - survivalRuntime.decideSupportSpell(*player, survivalSnapshot(*player), currentPosition, std::chrono::steady_clock::now())); + survivalRuntime.decideSupportSpell(survivalSnapshot(*player), std::chrono::steady_clock::now())); } bool PlayerBotController::tryOffensiveSpell(Player* player, const Position& currentPosition) @@ -249,7 +264,7 @@ bool PlayerBotController::tryOffensiveSpell(Player* player, const Position& curr const auto traversalTarget = combatRuntime.traversalTarget(); Creature* target = traversalTarget ? g_game.getCreatureByID(traversalTarget->id) : nullptr; return dispatchSpellCommand(*player, currentPosition, - survivalRuntime.decideOffensiveSpell(*player, survivalSnapshot(*player), currentPosition, target, std::chrono::steady_clock::now())); + survivalRuntime.decideOffensiveSpell(survivalSnapshot(*player, target), std::chrono::steady_clock::now())); } uint64_t PlayerBotController::spellTrainingReserve(const Player& player) const @@ -303,8 +318,8 @@ bool PlayerBotController::findSpellTraining(Player& player, const Position& posi const uint16_t baseVocationId = player.getVocation()->getFromVocation() == 0 ? vocationId : player.getVocation()->getFromVocation(); const bool suppliesReady = inventoryPolicy.inventoryItemCount(player, smallHealthPotionItemId) > smallHealthPotionReturnThreshold; - std::vector feasibleOffers; - std::vector> feasibleRoutes; + std::vector offers; + std::vector> routes; for (const auto& entry : g_game.getNpcs()) { Npc* npc = entry.second; @@ -355,8 +370,8 @@ bool PlayerBotController::findSpellTraining(Player& player, const Position& posi std::deque steps; uint64_t expandedNodes = 0; const auto startedAt = std::chrono::steady_clock::now(); - const PlayerBotNavigationRoutePlan routePlan = approach == position ? PlayerBotNavigationRoutePlan{} : - navigationRuntime.plan(player, approach); + const PlayerBotNavigationRoutePlan routePlan = approach == position ? PlayerBotNavigationRoutePlan{} : + planNavigationRoute(player, approach); const PlayerBotNavigationResult result = approach == position ? PlayerBotNavigationResult::Reached : routePlan.metrics.result; if (approach != position) { steps = routePlan.steps; @@ -375,79 +390,47 @@ bool PlayerBotController::findSpellTraining(Player& player, const Position& posi return false; }; for (const NpcSpellOffer& offer : npc->getSpellOffers()) { - if (!inScope) { - emitSpellCandidate(*npc, offer, position, "rejected", "outside_thais_scope", reserve); - continue; - } Spell* spell = g_spells ? g_spells->getSpellByName(offer.spellName) : nullptr; - if (!spell || !spell->isInstant() || !spell->isLearnable() || spell->getLevel() != offer.level || - spell->isPremium() != offer.premium) { - emitSpellCandidate(*npc, offer, position, "rejected", "spell_registry_mismatch", reserve); - continue; - } - if (std::find(offer.vocationIds.begin(), offer.vocationIds.end(), baseVocationId) == offer.vocationIds.end() || - spell->getVocMap().find(vocationId) == spell->getVocMap().end()) { - emitSpellCandidate(*npc, offer, position, "rejected", "vocation_ineligible", reserve); - continue; - } - if (player.getLevel() < offer.level) { - emitSpellCandidate(*npc, offer, position, "rejected", "level_ineligible", reserve); - continue; - } - if (offer.premium && !player.isPremium()) { - emitSpellCandidate(*npc, offer, position, "rejected", "premium_ineligible", reserve); - continue; - } - if (player.hasLearnedInstantSpell(offer.spellName)) { - emitSpellCandidate(*npc, offer, position, "rejected", "already_learned", reserve); - continue; - } - if (!suppliesReady) { - emitSpellCandidate(*npc, offer, position, "rejected", "supply_reserve_unmet", reserve); - continue; - } - if (reserve == std::numeric_limits::max()) { - emitSpellCandidate(*npc, offer, position, "rejected", "recovery_reserve_unavailable", reserve); - continue; - } - if (totalMoney < reserve + offer.price) { - emitSpellCandidate(*npc, offer, position, "rejected", "unaffordable_after_reserves", reserve); - continue; - } - - if (!findTrainerApproach()) { - emitSpellCandidate(*npc, offer, position, "rejected", "trainer_unreachable", reserve); - continue; - } - emitSpellCandidate(*npc, offer, position, "feasible", nullptr, reserve, static_cast(trainerSteps.size())); - feasibleOffers.push_back({npc->getID(), npc->getPosition(), npc->getName(), offer.spellName, offer.keyword, - offer.price, offer.level, offer.premium, true, true, true, true, true, false, true, - {true, false, trainerApproach, static_cast(trainerSteps.size()), 0}}); - feasibleRoutes.push_back(trainerSteps); + const bool registryMatches = spell && spell->isInstant() && spell->isLearnable() && + spell->getLevel() == offer.level && spell->isPremium() == offer.premium; + const bool vocationEligible = registryMatches && + std::find(offer.vocationIds.begin(), offer.vocationIds.end(), baseVocationId) != offer.vocationIds.end() && + spell->getVocMap().find(vocationId) != spell->getVocMap().end(); + const bool routeReachable = findTrainerApproach(); + offers.push_back({npc->getID(), npc->getPosition(), npc->getName(), offer.spellName, offer.keyword, + offer.price, offer.level, offer.premium, inScope, registryMatches, vocationEligible, + player.getLevel() >= offer.level, !offer.premium || player.isPremium(), + player.hasLearnedInstantSpell(offer.spellName), suppliesReady, + {routeReachable, false, trainerApproach, static_cast(trainerSteps.size()), 0}}); + routes.push_back(trainerSteps); } } const PlayerBotSpellTrainingDecision decision = spellTrainingPlanner.select({reserve, totalMoney, - reserve != std::numeric_limits::max(), feasibleOffers}); + reserve != std::numeric_limits::max(), offers}); + for (size_t offerIndex = 0; offerIndex < offers.size(); ++offerIndex) { + const auto& offer = offers[offerIndex]; + const auto rejection = std::find_if(decision.rejections.begin(), decision.rejections.end(), + [offerIndex](const PlayerBotPlannerOfferRejection& result) { + return result.offerIndex == offerIndex; + }); + Npc* npc = g_game.getNpcByID(offer.npcId); + if (npc) emitSpellCandidate(*npc, {offer.spellName, offer.keyword, offer.price, offer.level, offer.premium, {}}, position, + rejection == decision.rejections.end() ? "feasible" : "rejected", + rejection == decision.rejections.end() ? nullptr : rejection->reason.c_str(), reserve, offer.route.steps); + } if (!decision.selected) return false; plan = *decision.selected; - for (size_t index = 0; index < feasibleRoutes.size(); ++index) { - // The planner's stable price/route/name ordering identifies this immutable offer. - if (plan.npcId == feasibleOffers[index].npcId && plan.spellName == feasibleOffers[index].spellName && - plan.price == feasibleOffers[index].price && plan.travelSteps == feasibleOffers[index].route.steps) { - selectedSteps = std::move(feasibleRoutes[index]); - return true; - } - } - return false; + if (!decision.selectedOfferIndex || *decision.selectedOfferIndex >= routes.size()) return false; + selectedSteps = std::move(routes[*decision.selectedOfferIndex]); + return true; } void PlayerBotController::beginSpellTraining(Player& player, const Position& position, PlayerBotSpellTrainingPlan plan, std::deque steps) { progressionRuntime.beginSpellTraining(std::move(plan)); - const auto& training = spellTrainingSession.plan(); - serviceWorkflow.resetNpc(training.npcId); - navigationRuntime.adopt(training.approachPosition, std::move(steps)); + const auto& training = progressionRuntime.spellTraining().plan(); + observeNavigationPlan(training.approachPosition, std::move(steps)); emit("strategy_selection", position, "\"goal\":\"learn_spell\",\"npc_id\":" + std::to_string(training.npcId) + ",\"spell\":" + jsonString(training.spellName) + ",\"keyword\":" + jsonString(training.keyword) + ",\"price\":" + @@ -459,18 +442,17 @@ void PlayerBotController::beginSpellTraining(Player& player, const Position& pos void PlayerBotController::finishSpellTraining(Player* player, const Position& position, const char* result, const char* reason) { emit("strategy_objective_result", position, "\"goal\":\"learn_spell\",\"spell\":" + - jsonString(spellTrainingSession.plan().spellName) + ",\"result\":" + jsonString(result) + ",\"reason\":" + + jsonString(progressionRuntime.spellTraining().plan().spellName) + ",\"result\":" + jsonString(result) + ",\"reason\":" + jsonString(reason)); - emit("goal_result", position, "\"decision_id\":" + std::to_string(goalArbiter.decisionId()) + + emit("goal_result", position, "\"decision_id\":" + std::to_string(progressionRuntime.decisionId()) + ",\"goal\":\"learn_spell\",\"result\":" + jsonString(result) + ",\"reason\":" + jsonString(reason)); if (player) { say(*player, "Spell training " + std::string(result) + ": " + reason + '.'); } progressionRuntime.finish(); - serviceWorkflow.resetNpc(); clearNavigation(); - progressionRuntime.setCooldown(TopLevelGoal::LearnSpell, - std::strcmp(result, "success") == 0 ? spellTrainingSuccessCooldown : spellTrainingFailureCooldown); + progressionRuntime.completeSpellTraining(std::strcmp(result, "success") == 0, + std::strcmp(result, "success") == 0 ? spellTrainingSuccessCooldown : spellTrainingFailureCooldown); if (player && fixtureDriver.goalLoop(true).selectGoal) { selectTopLevelGoal(*player, position, std::strcmp(result, "success") == 0 ? "spell_training_complete" : "spell_training_failed"); } @@ -479,23 +461,23 @@ void PlayerBotController::finishSpellTraining(Player* player, const Position& po void PlayerBotController::processSpellTraining(Player* player, const Position& currentPosition) { - const auto& training = spellTrainingSession.plan(); + const auto& training = progressionRuntime.spellTraining().plan(); Npc* trainer = g_game.getNpcByID(training.npcId); PlayerBotSpellTrainingObservation observation; observation.totalMoney = player->getMoney() + player->getBankBalance(); - if (spellTrainingSession.stage() == PlayerBotSpellTrainingStage::Travel) { + if (progressionRuntime.spellTraining().stage() == PlayerBotSpellTrainingStage::Travel) { observation.navigationReached = processNavigation(player, currentPosition, training.approachPosition); observation.navigationFailed = navigationRuntime.fixedTargetRouteFailureCount() >= maximumProgressionAttempts; } else { observation.npcAvailable = trainer && !trainer->isRemoved() && Position::areInRange<3, 3, 0>(currentPosition, trainer->getPosition()); - observation.greetingAcknowledged = serviceWorkflow.isGreetingAcknowledged(); + observation.greetingAcknowledged = progressionRuntime.greetingAcknowledged(); observation.learned = player->hasLearnedInstantSpell(training.spellName); } const PlayerBotProgressionOutcome result = progressionRuntime.advanceSpellTraining(observation); if (result.type == PlayerBotProgressionOutcomeType::Succeeded) { emit("action_result", currentPosition, "\"action\":\"learn_spell\",\"result\":\"success\",\"spell\":" + jsonString(training.spellName) + ",\"price\":" + std::to_string(training.price) + - ",\"money_before\":" + std::to_string(spellTrainingSession.moneyBefore()) + ",\"money_after\":" + + ",\"money_before\":" + std::to_string(progressionRuntime.spellTraining().moneyBefore()) + ",\"money_after\":" + std::to_string(observation.totalMoney)); finishSpellTraining(player, currentPosition, "success", result.reason); return; @@ -507,7 +489,7 @@ void PlayerBotController::processSpellTraining(Player* player, const Position& c if (result.command.type == PlayerBotProgressionCommandType::Speak) { if (!trainer || trainer->isRemoved()) return; const char* words = std::strcmp(result.command.reason, "request") == 0 ? training.keyword.c_str() : result.command.reason; - if (std::strcmp(words, "hi") == 0) serviceWorkflow.resetGreetingAcknowledgement(); + if (std::strcmp(words, "hi") == 0) progressionRuntime.clearGreetingAcknowledgement(); telemetry.recordActionAttempt(); trainer->receiveSpeech(player, TALKTYPE_PRIVATE_PN, words); } diff --git a/server/src/playerbotsurvivalruntime.cpp b/server/src/playerbotsurvivalruntime.cpp index 74be01f9..a4a046b5 100644 --- a/server/src/playerbotsurvivalruntime.cpp +++ b/server/src/playerbotsurvivalruntime.cpp @@ -7,12 +7,9 @@ #include "playerbotsurvivalruntime.h" -#include "condition.h" -#include "player.h" #include "playerbotinventorypolicy.h" -#include "spells.h" -extern Spells* g_spells; +#include namespace { constexpr int32_t healingHealthPercent = 60; @@ -26,36 +23,12 @@ namespace { constexpr auto retryDelay = std::chrono::seconds(2); constexpr auto foodCooldown = std::chrono::minutes(5); - std::string targetClass(const Creature* target) + const PlayerBotSurvivalSpellObservation* spellObservation(const PlayerBotSurvivalSnapshot& snapshot, const char* name) { - if (!target) return "self"; - if (!target->getMonster()) return "creature"; - std::string name = target->getName(); - name.resize(std::min(name.size(), 48)); - return "monster:" + name; - } - - bool magicTrainingEffectUseful(const Player& player, PlayerBotTrainingEffect effect) - { - return effect == PlayerBotTrainingEffect::Haste ? !player.hasCondition(CONDITION_HASTE) : - effect == PlayerBotTrainingEffect::Light ? !player.hasCondition(CONDITION_LIGHT) : false; - } - - bool magicTrainingSpellLegal(const Player& player, const PlayerBotSpellDescriptor& descriptor, InstantSpell*& spell, - uint64_t& cost) - { - if (!descriptor.magicTrainingSafe || descriptor.magicTrainingPriority == 0 || - descriptor.magicTrainingEffect == PlayerBotTrainingEffect::None || !player.hasLearnedInstantSpell(descriptor.name)) return false; - spell = g_spells ? g_spells->getInstantSpellByName(descriptor.name) : nullptr; - if (!spell || spell->getWords() != descriptor.words || !spell->isLearnable() || !spell->isEnabled() || - player.getLevel() < spell->getLevel() || player.getMagicLevel() < spell->getMagicLevel() || - player.getSoul() < spell->getSoulCost() || (spell->isPremium() && !player.isPremium()) || - (spell->getNeedWeapon() && !player.getWeapon(true)) || player.hasCondition(CONDITION_EXHAUST_HEAL) || - spell->getAggressive() || !spell->getSelfTarget() || spell->getNeedTarget() || spell->getHasParam() || - spell->getHasPlayerNameParam() || spell->getNeedDirection() || spell->getNeedCasterTargetOrDirection()) return false; - cost = spell->getManaCost(&player); - return cost != 0 && cost <= static_cast(player.getMana()) - - std::min(player.getMana(), magicTrainingEmergencyReserve); + const auto found = std::find_if(snapshot.spells.begin(), snapshot.spells.end(), [name](const auto& spell) { + return spell.name == name; + }); + return found == snapshot.spells.end() ? nullptr : &*found; } } @@ -75,8 +48,8 @@ uint16_t PlayerBotSurvivalRuntime::pendingFoodItemId() const return pending ? pending->itemId : 0; } -PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideHealing(Player& player, const PlayerBotSurvivalSnapshot& snapshot, - const Position& position, std::chrono::steady_clock::time_point now) +PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideHealing(const PlayerBotSurvivalSnapshot& snapshot, + std::chrono::steady_clock::time_point now) { PlayerBotSurvivalCommand command; PlayerBotSurvivalCommand spellAttempt; @@ -89,7 +62,7 @@ PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideHealing(Player& player, return command; } if (snapshot.healthMaximum - snapshot.health <= smallHealthPotionMaximumHealing || snapshot.potionCount == 0) { - spellAttempt = decideSpell(player, snapshot, position, "Light Healing", "recovery", nullptr, now); + spellAttempt = decideSpell(snapshot, "Light Healing", "recovery", now); if (spellAttempt.type == PlayerBotSurvivalCommandType::CastSpell) return spellAttempt; } if (snapshot.potionCount == 0) { @@ -98,7 +71,8 @@ PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideHealing(Player& player, return command; } command.type = PlayerBotSurvivalCommandType::UsePotion; - command.candidate = spellAttempt.candidate; + command.itemId = playerbot::smallHealthPotionItemId; + command.candidateName = spellAttempt.candidateName; command.need = spellAttempt.need; command.reason = spellAttempt.reason; return command; @@ -126,78 +100,80 @@ PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideFood(const PlayerBotSur } recovery.beginFood({snapshot.foodItemId, snapshot.foodCount, snapshot.foodTicks}); command.type = PlayerBotSurvivalCommandType::UseFood; + command.itemId = snapshot.foodItemId; command.itemClientId = snapshot.foodClientId; return command; } -PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideSpell(Player& player, const PlayerBotSurvivalSnapshot& snapshot, - const Position& position, const char* spellName, const char* need, Creature* target, std::chrono::steady_clock::time_point now) +PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideSpell(const PlayerBotSurvivalSnapshot& snapshot, + const char* spellName, const char* need, std::chrono::steady_clock::time_point now) { PlayerBotSurvivalCommand command; command.need = need; const PlayerBotSpellDescriptor* descriptor = playerBotSpellDescriptor(spellName); if (!descriptor) { command.reason = "unsupported_descriptor"; return command; } - command.candidate = descriptor; - InstantSpell* spell = g_spells ? g_spells->getInstantSpellByName(descriptor->name) : nullptr; - if (!spell || spell->getWords() != descriptor->words || !spell->isLearnable()) { command.reason = "unsupported_metadata"; return command; } - if (!player.hasLearnedInstantSpell(descriptor->name)) { command.reason = "unlearned"; return command; } + command.candidateName = descriptor->name; + const PlayerBotSurvivalSpellObservation* spell = spellObservation(snapshot, descriptor->name); + if (!spell || !spell->metadataMatches) { command.reason = "unsupported_metadata"; return command; } + if (!spell->learned) { command.reason = "unlearned"; return command; } if (!snapshot.canDoAction || spells.hasPending() || !spells.canRetry(now)) return command; const bool healingGroup = descriptor->role == PlayerBotSpellRole::Healing || descriptor->role == PlayerBotSpellRole::Support; - if (player.hasCondition(healingGroup ? CONDITION_EXHAUST_HEAL : CONDITION_EXHAUST_COMBAT)) { command.reason = "cooldown"; return command; } + if (healingGroup ? snapshot.healingExhausted : snapshot.combatExhausted) { command.reason = "cooldown"; return command; } if ((descriptor->role == PlayerBotSpellRole::MeleeOffense || descriptor->role == PlayerBotSpellRole::RangedOffense) && - (!target || target->isRemoved() || target->isDead() || player.getAttackedCreature() != target || !player.canSeeCreature(target) || - !player.canSee(target->getPosition()) || !Position::areInRange<1, 1, 0>(position, target->getPosition()))) { command.reason = "lost_target"; return command; } - if (spell->getNeedTarget() && !spell->canThrowSpell(&player, target)) { command.reason = "target_unreachable"; return command; } - const uint32_t manaCost = spell->getManaCost(&player); + !snapshot.target.valid) { command.reason = "lost_target"; return command; } + if (!spell->targetReachable) { command.reason = "target_unreachable"; return command; } + const uint32_t manaCost = spell->manaCost; const uint32_t reserve = descriptor->role == PlayerBotSpellRole::Healing ? 0 : higherPriorityRecoveryManaReserve; - if (player.getMana() < manaCost + reserve) { command.reason = "insufficient_mana_reserve"; return command; } + if (snapshot.mana < manaCost + reserve) { command.reason = "insufficient_mana_reserve"; return command; } PlayerBotSpellPendingCast pending; pending.name = descriptor->name; pending.role = descriptor->role; pending.need = need; - pending.manaBefore = player.getMana(); + pending.manaBefore = snapshot.mana; pending.manaReserve = reserve; - pending.healthBefore = player.getHealth(); - pending.targetId = target ? target->getID() : 0; - pending.targetHealthBefore = target ? target->getHealth() : 0; - pending.missingHealth = player.getMaxHealth() - player.getHealth(); - pending.hasteTicksBefore = snapshot.hasteActive ? player.getCondition(CONDITION_HASTE)->getTicks() : 0; - pending.envelope = playerBotSpellEnvelope(player, *descriptor); - pending.targetClass = targetClass(target); - pending.otherRecovery = descriptor->role == PlayerBotSpellRole::Healing && player.hasCondition(CONDITION_REGENERATION); + pending.healthBefore = snapshot.health; + pending.targetId = snapshot.target.id; + pending.targetHealthBefore = snapshot.target.health; + pending.missingHealth = snapshot.healthMaximum - snapshot.health; + pending.hasteTicksBefore = snapshot.hasteTicks; + pending.envelope = spell->envelope; + pending.targetClass = snapshot.target.targetClass; + pending.otherRecovery = descriptor->role == PlayerBotSpellRole::Healing && snapshot.regenerationActive; pending.observedAt = now; spells.begin(pending); command.type = PlayerBotSurvivalCommandType::CastSpell; - command.spell = PlayerBotSurvivalSpellCommand{descriptor, spell, target, std::move(pending)}; + command.spell = PlayerBotSurvivalSpellCommand{descriptor->name, descriptor->words, descriptor->role, snapshot.target.id, std::move(pending)}; return command; } -PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideSupportSpell(Player& player, const PlayerBotSurvivalSnapshot& snapshot, - const Position& position, std::chrono::steady_clock::time_point now) +PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideSupportSpell(const PlayerBotSurvivalSnapshot& snapshot, + std::chrono::steady_clock::time_point now) { if (snapshot.hasteActive || snapshot.routeSteps < minimumHasteRouteSteps || needsHealing(snapshot)) return {}; - return decideSpell(player, snapshot, position, "Haste", "safe_route", nullptr, now); + return decideSpell(snapshot, "Haste", "safe_route", now); } -PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideOffensiveSpell(Player& player, const PlayerBotSurvivalSnapshot& snapshot, - const Position& position, Creature* target, std::chrono::steady_clock::time_point now) +PlayerBotSurvivalCommand PlayerBotSurvivalRuntime::decideOffensiveSpell(const PlayerBotSurvivalSnapshot& snapshot, + std::chrono::steady_clock::time_point now) { if (needsHealing(snapshot)) return {}; - if (player.hasLearnedInstantSpell("Berserk") && player.getLevel() >= 35) { + const PlayerBotSurvivalSpellObservation* berserk = spellObservation(snapshot, "Berserk"); + if (berserk && berserk->learned && snapshot.level >= 35) { const PlayerBotSpellDescriptor* ranged = playerBotSpellDescriptor("Whirlwind Throw"); const PlayerBotSpellDescriptor* melee = playerBotSpellDescriptor("Berserk"); - const std::string kind = targetClass(target); - if (ranged && melee) { + const PlayerBotSurvivalSpellObservation* rangedSpell = spellObservation(snapshot, "Whirlwind Throw"); + const std::string& kind = snapshot.target.targetClass; + if (ranged && melee && rangedSpell) { const PlayerBotSpellProfile* profile = calibration.find(ranged->name, kind); if (profile && profile->confidence >= 1.0 && - calibration.ranking(ranged->name, kind, playerBotSpellEnvelope(player, *ranged)) > - calibration.ranking(melee->name, kind, playerBotSpellEnvelope(player, *melee))) { - return decideSpell(player, snapshot, position, ranged->name, "offense", target, now); + calibration.ranking(ranged->name, kind, rangedSpell->envelope) > + calibration.ranking(melee->name, kind, berserk->envelope)) { + return decideSpell(snapshot, ranged->name, "offense", now); } } - return decideSpell(player, snapshot, position, "Berserk", "offense", target, now); + return decideSpell(snapshot, "Berserk", "offense", now); } - return decideSpell(player, snapshot, position, "Whirlwind Throw", "offense", target, now); + return decideSpell(snapshot, "Whirlwind Throw", "offense", now); } std::optional PlayerBotSurvivalRuntime::verifySpell(const PlayerBotSpellVerificationInput& input) @@ -220,20 +196,21 @@ void PlayerBotSurvivalRuntime::observeHealthDrain(bool controlledPlayer) { spell void PlayerBotSurvivalRuntime::observeCombatDamage(uint32_t attackerId, uint32_t targetId, uint32_t playerId, uint32_t damage) { spells.observeCombatDamage(attackerId, targetId, playerId, damage); } void PlayerBotSurvivalRuntime::observeHealthGain(bool controlledHealer, bool controlledTarget, uint32_t gain) { spells.observeHealthGain(controlledHealer, controlledTarget, gain); } bool PlayerBotSurvivalRuntime::canRetrySpell(std::chrono::steady_clock::time_point now) const { return spells.canRetry(now); } -const PlayerBotSpellPendingCast* PlayerBotSurvivalRuntime::pendingSpell() const { return spells.pending(); } +std::optional PlayerBotSurvivalRuntime::pendingSpell() const +{ + if (const PlayerBotSpellPendingCast* pending = spells.pending()) return *pending; + return std::nullopt; +} void PlayerBotSurvivalRuntime::deferSpellRetry(std::chrono::steady_clock::time_point now) { spells.deferRetry(now, retryDelay); } -const PlayerBotSpellProfile* PlayerBotSurvivalRuntime::calibrationProfile(const PlayerBotSpellPendingCast& pending) const { return calibration.find(pending.name, pending.targetClass); } -double PlayerBotSurvivalRuntime::calibrationRanking(const PlayerBotSpellPendingCast& pending) const { return calibration.ranking(pending.name, pending.targetClass, pending.envelope); } -size_t PlayerBotSurvivalRuntime::calibrationSize() const { return calibration.size(); } -const PlayerBotSpellProfile& PlayerBotSurvivalRuntime::observeCalibrationFixture(const std::string& spell, const std::string& targetClass, - const PlayerBotSpellEnvelope& envelope, PlayerBotSpellEvidence evidence, int32_t value) +std::optional PlayerBotSurvivalRuntime::calibrationProfile(const PlayerBotSpellPendingCast& pending) const { - return calibration.observe(spell, targetClass, envelope, evidence, value); + if (const PlayerBotSpellProfile* profile = calibration.find(pending.name, pending.targetClass)) return *profile; + return std::nullopt; } -std::optional PlayerBotSurvivalRuntime::takeCalibrationEviction() { return calibration.takeEvictedProfile(); } -void PlayerBotSurvivalRuntime::clearCalibration() { calibration.clear(); } +double PlayerBotSurvivalRuntime::calibrationRanking(const PlayerBotSpellPendingCast& pending) const { return calibration.ranking(pending.name, pending.targetClass, pending.envelope); } +size_t PlayerBotSurvivalRuntime::calibrationSize() const { return calibration.size(); } -const char* PlayerBotSurvivalRuntime::magicTrainingReason(const Player& player, const PlayerBotSurvivalSnapshot& snapshot) const +const char* PlayerBotSurvivalRuntime::magicTrainingReason(const PlayerBotSurvivalSnapshot& snapshot) const { if (snapshot.hunting) return "hunting"; if (snapshot.progressionActive) return "progression_objective"; @@ -241,32 +218,32 @@ const char* PlayerBotSurvivalRuntime::magicTrainingReason(const Player& player, if (snapshot.navigationPending) return "pending_navigation"; if (hasPendingDefensiveWork() || needsHealing(snapshot)) return "defensive_work"; if (!snapshot.canDoAction || snapshot.healingExhausted) return "spell_cooldown"; - if (player.getZone() == ZONE_PROTECTION) return "regeneration_paused"; - const auto forecast = player.getManaRegenerationForecast(); - if (!forecast) return "no_active_regeneration_forecast"; - if (static_cast(player.getMana()) + forecast->gain <= player.getMaxMana()) return "next_tick_not_overflow"; - return decideMagicTraining(player, snapshot) ? nullptr : "no_audited_safe_spell"; + if (snapshot.protectionZone) return "regeneration_paused"; + if (!snapshot.regenerationForecastActive) return "no_active_regeneration_forecast"; + if (static_cast(snapshot.mana) + snapshot.regenerationManaGain <= snapshot.manaMaximum) return "next_tick_not_overflow"; + return decideMagicTraining(snapshot) ? nullptr : "no_audited_safe_spell"; } -std::optional PlayerBotSurvivalRuntime::decideMagicTraining(const Player& player, - const PlayerBotSurvivalSnapshot&) const +std::optional PlayerBotSurvivalRuntime::decideMagicTraining(const PlayerBotSurvivalSnapshot& snapshot) const { - const auto forecast = player.getManaRegenerationForecast(); - if (!forecast) return std::nullopt; + if (!snapshot.regenerationForecastActive) return std::nullopt; std::optional useful; std::optional refresh; for (const PlayerBotSpellDescriptor& descriptor : playerBotSpellDescriptors()) { - InstantSpell* spell = nullptr; - uint64_t cost = 0; - if (!magicTrainingSpellLegal(player, descriptor, spell, cost)) continue; - PlayerBotMagicTrainingCommand candidate{&descriptor, spell, cost, false, player.getMana(), player.getSpentMana(), - player.getBaseMagicLevel(), forecast->gain, forecast->interval, forecast->remaining, - static_cast(player.getMana()) + forecast->gain, - static_cast(player.getMana()) + forecast->gain - player.getMaxMana()}; - if (magicTrainingEffectUseful(player, descriptor.magicTrainingEffect)) { - if (!useful || descriptor.magicTrainingPriority > useful->descriptor->magicTrainingPriority) useful = candidate; - } else if (descriptor.magicTrainingRefreshSafe && (!refresh || cost < refresh->cost || - (cost == refresh->cost && descriptor.magicTrainingPriority > refresh->descriptor->magicTrainingPriority))) { + const PlayerBotSurvivalSpellObservation* spell = spellObservation(snapshot, descriptor.name); + if (!spell || !spell->magicTrainingEligible || spell->manaCost == 0 || + spell->manaCost > static_cast(snapshot.mana) - std::min(snapshot.mana, magicTrainingEmergencyReserve)) continue; + PlayerBotMagicTrainingCommand candidate{descriptor.name, descriptor.words, descriptor.magicTrainingPriority, spell->manaCost, + false, snapshot.mana, snapshot.manaSpent, snapshot.magicLevel, snapshot.regenerationManaGain, + snapshot.regenerationTickInterval, snapshot.regenerationTickRemaining, + static_cast(snapshot.mana) + snapshot.regenerationManaGain, + static_cast(snapshot.mana) + snapshot.regenerationManaGain - snapshot.manaMaximum}; + const bool usefulEffect = descriptor.magicTrainingEffect == PlayerBotTrainingEffect::Haste ? !snapshot.hasteActive : + descriptor.magicTrainingEffect == PlayerBotTrainingEffect::Light ? !snapshot.lightActive : false; + if (usefulEffect) { + if (!useful || descriptor.magicTrainingPriority > useful->priority) useful = candidate; + } else if (descriptor.magicTrainingRefreshSafe && (!refresh || spell->manaCost < refresh->cost || + (spell->manaCost == refresh->cost && descriptor.magicTrainingPriority > refresh->priority))) { candidate.refresh = true; refresh = candidate; } diff --git a/server/src/playerbotsurvivalruntime.h b/server/src/playerbotsurvivalruntime.h index dd2a6c9f..14b0645c 100644 --- a/server/src/playerbotsurvivalruntime.h +++ b/server/src/playerbotsurvivalruntime.h @@ -9,19 +9,36 @@ #include "playerbotspellruntime.h" #include +#include #include #include +#include -class Player; -class Creature; -class InstantSpell; -struct Position; +struct PlayerBotSurvivalTargetObservation { + uint32_t id = 0; + int32_t health = 0; + std::string targetClass = "self"; + bool valid = false; +}; + +struct PlayerBotSurvivalSpellObservation { + std::string name; + bool metadataMatches = false; + bool learned = false; + bool targetReachable = false; + bool magicTrainingEligible = false; + uint32_t manaCost = 0; + PlayerBotSpellEnvelope envelope; +}; struct PlayerBotSurvivalSnapshot { int32_t health = 0; int32_t healthMaximum = 0; uint32_t mana = 0; uint32_t manaMaximum = 0; + uint64_t manaSpent = 0; + uint32_t level = 0; + uint32_t magicLevel = 0; uint32_t potionCount = 0; uint32_t foodCount = 0; uint32_t foodInventoryCount = 0; @@ -38,8 +55,19 @@ struct PlayerBotSurvivalSnapshot { bool combatOrPursuit = false; bool navigationPending = false; bool healingExhausted = false; + bool combatExhausted = false; bool hasteActive = false; + int32_t hasteTicks = 0; + bool lightActive = false; + bool regenerationActive = false; + bool protectionZone = false; + bool regenerationForecastActive = false; + uint64_t regenerationManaGain = 0; + uint32_t regenerationTickInterval = 0; + uint32_t regenerationTickRemaining = 0; uint32_t routeSteps = 0; + PlayerBotSurvivalTargetObservation target; + std::vector spells; }; enum class PlayerBotSurvivalCommandType : uint8_t { @@ -52,21 +80,23 @@ enum class PlayerBotSurvivalCommandType : uint8_t { }; struct PlayerBotSurvivalSpellCommand { - const PlayerBotSpellDescriptor* descriptor = nullptr; - InstantSpell* spell = nullptr; - Creature* target = nullptr; + std::string name; + std::string words; + PlayerBotSpellRole role = PlayerBotSpellRole::Healing; + uint32_t targetId = 0; PlayerBotSpellPendingCast pending; }; struct PlayerBotSurvivalCommand { PlayerBotSurvivalCommandType type = PlayerBotSurvivalCommandType::None; + uint16_t itemId = 0; uint16_t itemClientId = 0; - const PlayerBotSpellDescriptor* candidate = nullptr; - const char* need = nullptr; + std::string candidateName; + std::string need; std::optional potionVerification; std::optional foodVerification; std::optional spell; - const char* reason = nullptr; + std::string reason; }; struct PlayerBotSurvivalSpellVerification { @@ -77,8 +107,9 @@ struct PlayerBotSurvivalSpellVerification { }; struct PlayerBotMagicTrainingCommand { - const PlayerBotSpellDescriptor* descriptor = nullptr; - InstantSpell* spell = nullptr; + std::string name; + std::string words; + uint8_t priority = 0; uint64_t cost = 0; bool refresh = false; uint64_t manaBefore = 0; @@ -98,17 +129,13 @@ class PlayerBotSurvivalRuntime bool hasPendingDefensiveWork() const; uint16_t pendingFoodItemId() const; void beginPotion(const PlayerBotSurvivalSnapshot& snapshot); - PlayerBotSurvivalCommand decideHealing(Player& player, const PlayerBotSurvivalSnapshot& snapshot, - const Position& position, std::chrono::steady_clock::time_point now); + PlayerBotSurvivalCommand decideHealing(const PlayerBotSurvivalSnapshot& snapshot, + std::chrono::steady_clock::time_point now); PlayerBotSurvivalCommand decideFood(const PlayerBotSurvivalSnapshot& snapshot, std::chrono::steady_clock::time_point now); - PlayerBotSurvivalCommand decideSpell(Player& player, const PlayerBotSurvivalSnapshot& snapshot, - const Position& position, const char* spellName, const char* need, - Creature* target, std::chrono::steady_clock::time_point now); - PlayerBotSurvivalCommand decideSupportSpell(Player& player, const PlayerBotSurvivalSnapshot& snapshot, - const Position& position, std::chrono::steady_clock::time_point now); - PlayerBotSurvivalCommand decideOffensiveSpell(Player& player, const PlayerBotSurvivalSnapshot& snapshot, - const Position& position, Creature* target, + PlayerBotSurvivalCommand decideSupportSpell(const PlayerBotSurvivalSnapshot& snapshot, + std::chrono::steady_clock::time_point now); + PlayerBotSurvivalCommand decideOffensiveSpell(const PlayerBotSurvivalSnapshot& snapshot, std::chrono::steady_clock::time_point now); std::optional verifySpell(const PlayerBotSpellVerificationInput& input); void beginEngineSpellCast(); @@ -118,21 +145,17 @@ class PlayerBotSurvivalRuntime void observeCombatDamage(uint32_t attackerId, uint32_t targetId, uint32_t playerId, uint32_t damage); void observeHealthGain(bool controlledHealer, bool controlledTarget, uint32_t gain); bool canRetrySpell(std::chrono::steady_clock::time_point now) const; - const PlayerBotSpellPendingCast* pendingSpell() const; + std::optional pendingSpell() const; void deferSpellRetry(std::chrono::steady_clock::time_point now); - const PlayerBotSpellProfile* calibrationProfile(const PlayerBotSpellPendingCast& pending) const; + std::optional calibrationProfile(const PlayerBotSpellPendingCast& pending) const; double calibrationRanking(const PlayerBotSpellPendingCast& pending) const; size_t calibrationSize() const; - const PlayerBotSpellProfile& observeCalibrationFixture(const std::string& spell, const std::string& targetClass, - const PlayerBotSpellEnvelope& envelope, - PlayerBotSpellEvidence evidence, int32_t value); - std::optional takeCalibrationEviction(); - void clearCalibration(); - const char* magicTrainingReason(const Player& player, const PlayerBotSurvivalSnapshot& snapshot) const; - std::optional decideMagicTraining(const Player& player, - const PlayerBotSurvivalSnapshot& snapshot) const; + const char* magicTrainingReason(const PlayerBotSurvivalSnapshot& snapshot) const; + std::optional decideMagicTraining(const PlayerBotSurvivalSnapshot& snapshot) const; private: + PlayerBotSurvivalCommand decideSpell(const PlayerBotSurvivalSnapshot& snapshot, const char* spellName, + const char* need, std::chrono::steady_clock::time_point now); PlayerBotRecoverySession recovery; PlayerBotSpellRuntime spells; PlayerBotSpellCalibration calibration; From 73790e865c8ef8b8f64cd416b0b3bfd989b264b3 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:33:43 +0200 Subject: [PATCH 2/8] infra: bound Docker build disk usage --- server/.dockerignore | 2 ++ server/Dockerfile | 1 + 2 files changed, 3 insertions(+) diff --git a/server/.dockerignore b/server/.dockerignore index f345e851..ac103dcd 100644 --- a/server/.dockerignore +++ b/server/.dockerignore @@ -3,3 +3,5 @@ build vc14 vc16 db-volume +data/logs/*.log +data/logs/stats/*.log diff --git a/server/Dockerfile b/server/Dockerfile index facd7376..dce43947 100644 --- a/server/Dockerfile +++ b/server/Dockerfile @@ -32,6 +32,7 @@ RUN --mount=type=cache,target=/root/.cache/ccache \ -DCMAKE_C_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_COMPILER_LAUNCHER=ccache \ -DCMAKE_CXX_FLAGS=-fpch-preprocess \ + -DDISABLE_STATS=1 \ -DSKIP_GIT=ON \ -DUSE_LUAJIT=ON \ && cmake --build build --parallel 2 \ From fd40ce60143042bbeb4cf1cd6129d7636f1a3ce5 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:45:08 +0200 Subject: [PATCH 3/8] testing: inventory gameplay scenario failures --- .gitignore | 1 + scripts/playerbot-gameplay/runtime.ps1 | 142 +++++++++++++++++- .../playerbot-gameplay/scenarios-service.ps1 | 23 +-- scripts/test-playerbot-gameplay.ps1 | 72 ++++++++- 4 files changed, 216 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 820d9648..cebc5ff4 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ # Local configuration and build output .env /server/build/ +/artifacts/ diff --git a/scripts/playerbot-gameplay/runtime.ps1 b/scripts/playerbot-gameplay/runtime.ps1 index 0f80ca0d..34f547a8 100644 --- a/scripts/playerbot-gameplay/runtime.ps1 +++ b/scripts/playerbot-gameplay/runtime.ps1 @@ -197,7 +197,7 @@ function Throw-WaitTimeout { $status = & docker @composeArguments ps --all 2>&1 $logs = try { Get-ServerLogs } catch { "Server logs unavailable: $($_.Exception.Message)" } $tail = (($logs -split "`r?`n") | Select-Object -Last 80) -join "`n" - throw "$Message`nScenario: $currentScenario`n--- compose status ---`n$($status -join "`n")`n--- server log tail ---`n$tail" + throw [System.TimeoutException]::new("$Message`nScenario: $currentScenario`n--- compose status ---`n$($status -join "`n")`n--- server log tail ---`n$tail") } function Wait-ForLog { @@ -305,6 +305,128 @@ function Invoke-TimedStep { } } +function Add-ScenarioResult { + param( + [string]$Name, + [string]$Status, + [string]$ErrorMessage, + [string]$ArtifactPath + ) + + $duration = $timings[$Name] + $durationMilliseconds = if ($duration) { [int64]$duration.TotalMilliseconds } else { 0 } + [void]$script:scenarioResults.Add([pscustomobject][ordered]@{ + Name = $Name + Status = $Status + DurationMilliseconds = $durationMilliseconds + Error = $ErrorMessage + ArtifactPath = $ArtifactPath + }) +} + +function Save-ScenarioFailureArtifacts { + param( + [string]$Name, + [string]$Status, + [System.Exception]$Exception, + [DateTime]$StartedAt + ) + + $safeName = $Name -replace '[^A-Za-z0-9_.-]', '_' + $directory = Join-Path (Join-Path $FailureArtifactsPath $scenarioRunId) $safeName + $collectionErrors = [System.Collections.Generic.List[string]]::new() + try { + [void][System.IO.Directory]::CreateDirectory($directory) + } + catch { + return "Artifact directory unavailable: $($_.Exception.Message)" + } + + $serverLogs = "" + try { + Update-ServerLogs + $serverLogs = ((& docker @composeArguments logs --no-log-prefix server 2>&1) -join "`n") + if ($LASTEXITCODE -ne 0) { + throw "docker compose logs exited with code $LASTEXITCODE" + } + [System.IO.File]::WriteAllText((Join-Path $directory "server.log"), $serverLogs) + } + catch { + [void]$collectionErrors.Add("server.log: $($_.Exception.Message)") + try { + $serverLogs = Get-ServerLogs + [System.IO.File]::WriteAllText((Join-Path $directory "server.log"), $serverLogs) + } + catch { + [void]$collectionErrors.Add("server.log fallback: $($_.Exception.Message)") + } + } + + try { + $composeStatus = (& docker @composeArguments ps --all 2>&1) -join "`n" + [System.IO.File]::WriteAllText((Join-Path $directory "compose-ps.txt"), $composeStatus) + } + catch { + [void]$collectionErrors.Add("compose-ps.txt: $($_.Exception.Message)") + } + + try { + $eventLines = foreach ($event in ConvertFrom-PlayerbotLogs -Logs $serverLogs) { + $event | ConvertTo-Json -Compress -Depth 20 + } + [System.IO.File]::WriteAllText((Join-Path $directory "playerbot-events.jsonl"), ($eventLines -join "`n")) + } + catch { + [void]$collectionErrors.Add("playerbot-events.jsonl: $($_.Exception.Message)") + } + + try { + [System.IO.File]::WriteAllText((Join-Path $directory "failure.txt"), $Exception.ToString()) + } + catch { + [void]$collectionErrors.Add("failure.txt: $($_.Exception.Message)") + } + $metadata = [pscustomobject][ordered]@{ + Scenario = $Name + Status = $Status + StartedAtUtc = $StartedAt.ToUniversalTime().ToString("o") + FinishedAtUtc = [DateTime]::UtcNow.ToString("o") + TimeoutSeconds = $currentWaitTimeoutSeconds + ExceptionType = $Exception.GetType().FullName + ExceptionMessage = $Exception.Message + ContinueOnFailure = [bool]$ContinueOnFailure + KeepStack = [bool]$KeepStack + CollectionErrors = @($collectionErrors) + } + try { + [System.IO.File]::WriteAllText((Join-Path $directory "metadata.json"), ($metadata | ConvertTo-Json -Depth 10)) + } + catch { + [void]$collectionErrors.Add("metadata.json: $($_.Exception.Message)") + } + return $directory +} + +function Write-ScenarioSummary { + $passed = @($script:scenarioResults | Where-Object { $_.Status -eq "pass" }).Count + $failed = @($script:scenarioResults | Where-Object { $_.Status -eq "fail" }).Count + $timedOut = @($script:scenarioResults | Where-Object { $_.Status -eq "timeout" }).Count + $skipped = @($script:scenarioResults | Where-Object { $_.Status -eq "skipped" }).Count + "PLAYERBOT_GAMEPLAY_TEST SUMMARY pass=$passed fail=$failed timeout=$timedOut skipped=$skipped" + foreach ($result in $script:scenarioResults) { + $fields = "name=$($result.Name) status=$($result.Status) duration_ms=$($result.DurationMilliseconds)" + if ($result.ArtifactPath) { + $fields += " artifact=$($result.ArtifactPath)" + } + "PLAYERBOT_GAMEPLAY_TEST RESULT $fields" + } + if ($failed -gt 0 -or $timedOut -gt 0) { + "PLAYERBOT_GAMEPLAY_TEST FAIL" + } elseif ($passed -gt 0) { + "PLAYERBOT_GAMEPLAY_TEST PASS" + } +} + function Invoke-Scenario { param( [string]$Name, @@ -312,8 +434,24 @@ function Invoke-Scenario { [scriptblock]$Body ) + if ($exactScenarioSelection -and -not $selectedScenarios.Contains($Name)) { + Add-ScenarioResult -Name $Name -Status "skipped" + return + } $script:currentScenario = $Name $script:currentWaitTimeoutSeconds = if ($timeoutOverridden) { $TimeoutSeconds } else { $DefaultTimeoutSeconds } $script:currentScenarioDeadline = [DateTime]::UtcNow.AddSeconds($currentWaitTimeoutSeconds) - Invoke-TimedStep -Name $Name -Body $Body + $startedAt = [DateTime]::UtcNow + try { + Invoke-TimedStep -Name $Name -Body $Body + Add-ScenarioResult -Name $Name -Status "pass" + } + catch { + $status = if ($_.Exception -is [System.TimeoutException]) { "timeout" } else { "fail" } + $artifactPath = Save-ScenarioFailureArtifacts -Name $Name -Status $status -Exception $_.Exception -StartedAt $startedAt + Add-ScenarioResult -Name $Name -Status $status -ErrorMessage $_.Exception.Message -ArtifactPath $artifactPath + if (-not $ContinueOnFailure) { + throw + } + } } diff --git a/scripts/playerbot-gameplay/scenarios-service.ps1 b/scripts/playerbot-gameplay/scenarios-service.ps1 index 3e53cbe0..00a9204d 100644 --- a/scripts/playerbot-gameplay/scenarios-service.ps1 +++ b/scripts/playerbot-gameplay/scenarios-service.ps1 @@ -10,13 +10,8 @@ Invoke-Compose stop server Invoke-Compose up --detach server - $restartLogs = "" - for ($attempt = 0; $attempt -lt 300; $attempt++) { - Start-Sleep -Seconds 1 - $restartLogs = Get-LatestServerGenerationLogs -Logs (Get-ServerLogs) - if ($restartLogs -match '"action":"deposit","result":"complete","depot_id":2' -and - $restartLogs -match '"action":"hunt_cycle","result":"started","cycle":1') { break } - } + Wait-ForLatestServerGenerationLog -Pattern '"action":"deposit","result":"complete","depot_id":2' | Out-Null + $restartLogs = Wait-ForLatestServerGenerationLog -Pattern '"action":"hunt_cycle","result":"started","cycle":1' Assert-MainlandLoopEvents -Logs $restartLogs -MinimumCycles 1 -MinimumDeposits 1 } } @@ -72,12 +67,7 @@ Invoke-Compose stop server Invoke-Compose up --detach server - $secondCycleLogs = "" - for ($attempt = 0; $attempt -lt 180; $attempt++) { - Start-Sleep -Seconds 1 - $secondCycleLogs = Get-LatestServerGenerationLogs -Logs (Get-ServerLogs) - if ($secondCycleLogs -match '"action":"deposit","result":"complete"') { break } - } + $secondCycleLogs = Wait-ForLatestServerGenerationLog -Pattern '"action":"deposit","result":"complete"' Assert-DepotEvents -Logs $secondCycleLogs -ExpectedDepositedCount 1 -ExpectedEquipmentDeposits 0 $sentinelCount = Invoke-DatabaseScalar -Query "SELECT COALESCE(SUM(count), 0) FROM player_depotitems JOIN players ON players.id = player_depotitems.player_id WHERE players.name = 'Rook Tester' AND pid = 2 AND itemtype = 2684" if ($sentinelCount -ne 7) { @@ -103,12 +93,7 @@ } Invoke-Compose stop server Invoke-Compose up --detach server - $recoveryLogs = "" - for ($attempt = 0; $attempt -lt 150; $attempt++) { - Start-Sleep -Seconds 1 - $recoveryLogs = Get-LatestServerGenerationLogs -Logs (Get-ServerLogs) - if ($recoveryLogs -match '"action":"deposit","result":"complete"') { break } - } + $recoveryLogs = Wait-ForLatestServerGenerationLog -Pattern '"action":"deposit","result":"complete"' Assert-DepotRecoveryEvents -Logs $recoveryLogs -Phase $phase } } diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index 3eddb849..7e5459f1 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -9,6 +9,12 @@ fixtures, and removes the scenario stack unless -KeepStack is set. .PARAMETER SlottedLoot Runs seller, no-eligible-seller depot fallback, and interrupted-deposit restart fixtures for policy-approved loot carried in an invalid equipment slot. + +.PARAMETER Scenario +Runs only the named scenarios. Names must match the gameplay scenario catalog. + +.PARAMETER ContinueOnFailure +Captures diagnostics and continues with the next selected scenario after a failure. #> param( [ValidateRange(30, 3600)] @@ -43,6 +49,9 @@ param( "magic_training_post_hunt", "magic_training_post_hunt_no_overflow", "magic_training_restart", "magic_training_hunt" )] [string]$MagicTrainingCase, + [string[]]$Scenario, + [switch]$ContinueOnFailure, + [string]$FailureArtifactsPath, [switch]$Focused, [switch]$SkipBuild, [switch]$KeepStack @@ -54,6 +63,54 @@ $projectRoot = Split-Path -Parent $PSScriptRoot $composeFile = Join-Path $projectRoot "server\compose.yaml" $gameplayComposeFile = Join-Path $projectRoot "server\compose.playerbot-gameplay.yaml" $composeArguments = @("compose", "-f", $composeFile, "-f", $gameplayComposeFile) +$scenarioCatalog = @( + "cycle", + "mainland_loop", "slotted_loot_seller", "slotted_loot_no_seller", "slotted_loot_deposit_restart", + "real_depot", "real_depot_restart_approach", "real_depot_restart_locker", "real_depot_restart_chest", + "real_depot_restart_deposit", "real_depot_restart_depart", "real_depot_partial_move", "real_depot_rejected_move", + "pickup_progression", "pickup_progression_bundle", "pickup_progression_nested", "pickup_progression_resume", + "pickup_progression_nested_resume", "pickup_progression_space", "goal_arbitration", "goal_arbitration_interrupt", + "stamina_bonus_projection", "stamina_boundary_projection", "stamina_normal_projection", "hunt_region_planning", + "combat_readiness_ready", "combat_readiness_upgrade", "combat_readiness_missing_weapon", "combat_readiness_supplies", + "combat_readiness_no_food", "combat_readiness_low_wealth", "combat_readiness_food_capacity", + "combat_readiness_retention", "equipment_offer_shadow_upgrade", "equipment_offer_shadow_unaffordable", + "equipment_offer_shadow_no_upgrade", "equipment_purchase", "equipment_purchase_resume", "equipment_purchase_space", + "equipment_purchase_rejected", "adaptive_challenge", "mainland_equipment_reward", "oracle_departure", + "oracle_level_eight_interrupt", "oracle_level_eight_recovery", + "navigation", "navigation_recovery", "patrol_recovery", "target_pursuit", "target_pursuit_abandon", + "spell_training", "spell_use", "spell_calibration", "magic_training_haste", "magic_training_great_light", + "magic_training_light", "magic_training_refresh", "magic_training_reserve", "magic_training_exact_full", + "magic_training_pz", "magic_training_absent", "magic_training_expired", "magic_training_failed", + "magic_training_service", "magic_training_progression", "magic_training_post_hunt", + "magic_training_post_hunt_no_overflow", "magic_training_restart", "magic_training_hunt", + "corpse", "corpse_inaccessible", "death", "healing", "healing_resupply", "value" +) +$scenarioCatalogSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($scenarioName in $scenarioCatalog) { + if (-not $scenarioCatalogSet.Add($scenarioName)) { + throw "Duplicate gameplay scenario name: $scenarioName" + } +} +if ($scenarioCatalog.Count -ne 75) { + throw "The gameplay scenario catalog must contain 75 scenarios; found $($scenarioCatalog.Count)." +} +$requestedScenarioNames = @($Scenario | ForEach-Object { $_ -split ',' } | Where-Object { $_ }) +$exactScenarioSelection = $requestedScenarioNames.Count -gt 0 +$selectedScenarios = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) +foreach ($scenarioName in $requestedScenarioNames) { + if (-not $scenarioCatalogSet.Contains($scenarioName)) { + throw "Unknown gameplay scenario '$scenarioName'." + } + [void]$selectedScenarios.Add($scenarioName) +} +if ($exactScenarioSelection -and ($Focused -or $MagicTrainingCase)) { + throw "-Scenario cannot be combined with -Focused or -MagicTrainingCase." +} +if (-not $FailureArtifactsPath) { + $FailureArtifactsPath = Join-Path $projectRoot "artifacts\playerbot-gameplay" +} +$scenarioResults = [System.Collections.Generic.List[object]]::new() +$scenarioRunId = [DateTime]::UtcNow.ToString("yyyyMMddTHHmmssZ") $previousDuration = $env:PLAYERBOT_HUNT_DURATION_SECONDS $previousMode = $env:PLAYERBOT_GAMEPLAY_MODE $previousRelogDelay = $env:PLAYERBOT_RELOG_DELAY_SECONDS @@ -137,7 +194,19 @@ try { . $PSScriptRoot/playerbot-gameplay/scenarios-spells.ps1 . $PSScriptRoot/playerbot-gameplay/scenarios-combat-loot.ps1 - "PLAYERBOT_GAMEPLAY_TEST PASS" + if (-not $Focused) { + $resultNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($result in $scenarioResults) { + [void]$resultNames.Add($result.Name) + } + if ($scenarioResults.Count -ne $scenarioCatalog.Count -or -not $resultNames.SetEquals($scenarioCatalogSet)) { + throw "The gameplay scenario catalog does not match the scenarios enumerated by the suite." + } + } + $failedScenarios = @($scenarioResults | Where-Object { $_.Status -in @("fail", "timeout") }) + if ($failedScenarios.Count -gt 0) { + throw "$($failedScenarios.Count) gameplay scenario(s) failed." + } } finally { try { @@ -159,5 +228,6 @@ finally { foreach ($timing in $timings.GetEnumerator()) { "PLAYERBOT_GAMEPLAY_TIMING $($timing.Key)=$([Math]::Round($timing.Value.TotalSeconds, 2))s" } + Write-ScenarioSummary } } From bd64a613bb5040095a8b55ca2f104c94db28527b Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sat, 22 Aug 2026 20:14:03 +0200 Subject: [PATCH 4/8] server: restore playerbot workflow contracts --- .../scenarios-progression.ps1 | 2 +- server/src/playerbotcontroller.cpp | 4 +- server/src/playerbotdepotsession.cpp | 2 + server/src/playerbotdepotsession.h | 3 +- server/src/playerbotdepotworkflow.cpp | 2 +- server/src/playerbotequipment.cpp | 2 +- server/src/playerbotfixturedriver.cpp | 5 ++ server/src/playerbotfixturedriver.h | 2 + server/src/playerbotprogression.cpp | 24 +++++---- server/src/playerbotprogressionruntime.cpp | 2 +- server/src/playerbotservice.cpp | 50 +++++++++++++------ server/src/playerbotserviceworkflow.cpp | 18 ++++++- server/src/playerbotserviceworkflow.h | 5 +- server/src/playerbotspells.cpp | 2 +- server/src/playerbottestpolicy.cpp | 1 + server/src/playerbottestpolicy.h | 1 + 16 files changed, 89 insertions(+), 36 deletions(-) diff --git a/scripts/playerbot-gameplay/scenarios-progression.ps1 b/scripts/playerbot-gameplay/scenarios-progression.ps1 index 11921f2e..9bc5e8c9 100644 --- a/scripts/playerbot-gameplay/scenarios-progression.ps1 +++ b/scripts/playerbot-gameplay/scenarios-progression.ps1 @@ -1,5 +1,5 @@ if ($PickupProgression) { - Invoke-Scenario -Name "pickup_progression" -DefaultTimeoutSeconds 180 -Body { + Invoke-Scenario -Name "pickup_progression" -DefaultTimeoutSeconds 300 -Body { Invoke-Compose down --volumes --remove-orphans $env:PLAYERBOT_GAMEPLAY_MODE = "progression" $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index 0ce6e8a9..f7e29054 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -36,7 +36,7 @@ void PlayerBotController::start(const Position& position, bool recovered, uint32 const bool departureComplete = controlledPlayer && departurePlanner.hasCompleted(departureSnapshot(*controlledPlayer)); const bool departureRequired = controlledPlayer && departurePlanner.required(departureSnapshot(*controlledPlayer)); const bool useGoalSelector = controlledPlayer && !startInHunt && - (departureRequired || (!recovered && fixtureDriver.goalLoop(true).selectGoal)); + (departureRequired || (!recovered && fixtureDriver.startWithGoalSelection())); if (!fixtureDriver.magicTrainingScenario() && !fixtureDriver.deferInitialization() && useGoalSelector && !selectTopLevelGoal(*controlledPlayer, position, "startup")) { return; @@ -493,7 +493,7 @@ void PlayerBotController::navigate() } emitFixtureEvents(fixtureDriver.runMagicTraining(*player), currentPosition); const bool useGoalSelector = !fixtureDriver.startInHunt() && - (departurePlanner.required(departureSnapshot(*player)) || fixtureDriver.goalLoop(true).selectGoal); + (departurePlanner.required(departureSnapshot(*player)) || fixtureDriver.startWithGoalSelection()); if (useGoalSelector) { if (!selectTopLevelGoal(*player, currentPosition, "startup")) { return; diff --git a/server/src/playerbotdepotsession.cpp b/server/src/playerbotdepotsession.cpp index f19d5071..56218846 100644 --- a/server/src/playerbotdepotsession.cpp +++ b/server/src/playerbotdepotsession.cpp @@ -20,6 +20,7 @@ void PlayerBotDepotSession::reset() void PlayerBotDepotSession::clearDiscovery() { selectedDepotId = 0; + depotSelected = false; selectedLockerItemId = 0; selectedLockerPosition = Position(); selectedApproachPosition = Position(); @@ -29,6 +30,7 @@ void PlayerBotDepotSession::clearDiscovery() void PlayerBotDepotSession::select(PlayerBotDepotCandidate candidate) { selectedDepotId = candidate.depotId; + depotSelected = true; selectedLockerItemId = candidate.lockerItemId; selectedLockerPosition = candidate.lockerPosition; selectedApproachPosition = candidate.approachPosition; diff --git a/server/src/playerbotdepotsession.h b/server/src/playerbotdepotsession.h index 3b84606e..6150588c 100644 --- a/server/src/playerbotdepotsession.h +++ b/server/src/playerbotdepotsession.h @@ -80,7 +80,7 @@ class PlayerBotDepotSession uint16_t lockerItemId() const { return selectedLockerItemId; } const Position& lockerPosition() const { return selectedLockerPosition; } const Position& approachPosition() const { return selectedApproachPosition; } - bool hasSelectedDepot() const { return selectedDepotId != 0; } + bool hasSelectedDepot() const { return depotSelected; } void select(PlayerBotDepotCandidate candidate); bool candidatesPrepared() const { return candidatesReady; } @@ -112,6 +112,7 @@ class PlayerBotDepotSession PlayerBotDepotStage depotStage = PlayerBotDepotStage::Discover; uint32_t depotAttempts = 0; uint16_t selectedDepotId = 0; + bool depotSelected = false; uint16_t selectedLockerItemId = 0; Position selectedLockerPosition; Position selectedApproachPosition; diff --git a/server/src/playerbotdepotworkflow.cpp b/server/src/playerbotdepotworkflow.cpp index 4c123d2f..800d5e9a 100644 --- a/server/src/playerbotdepotworkflow.cpp +++ b/server/src/playerbotdepotworkflow.cpp @@ -64,7 +64,7 @@ PlayerBotDepotCommand PlayerBotDepotWorkflow::advance(const PlayerBotDepotObserv verification.result == PlayerBotDepotMoveResult::Moved ? PlayerBotDepotOutcome::Moved : PlayerBotDepotOutcome::Deferred, telemetry); } - if (session.stage() == PlayerBotDepotStage::Discover) { + if (session.stage() == PlayerBotDepotStage::Discover && !routeCandidate && discoveryCandidates.empty()) { if (!observation.scan.observed) return command(PlayerBotDepotCommandType::Scan, PlayerBotDepotOutcome::Pending); clearDiscovery(); session.prepareCandidates(observation.currentPosition); diff --git a/server/src/playerbotequipment.cpp b/server/src/playerbotequipment.cpp index ddbd673f..0dfce1fd 100644 --- a/server/src/playerbotequipment.cpp +++ b/server/src/playerbotequipment.cpp @@ -309,7 +309,7 @@ void PlayerBotController::finishEquipmentPurchase(Player* player, const Position if (succeeded && player && fixtureDriver.equipmentPurchaseCompletion(*player).pause) { return; } - if (player && fixtureDriver.goalLoop(true).selectGoal) { + if (player && fixtureDriver.progressionGoalLoop(true).selectGoal) { selectTopLevelGoal(*player, position, succeeded ? "equipment_purchase_complete" : "equipment_purchase_failed"); } else { progressionRuntime.enterService(); diff --git a/server/src/playerbotfixturedriver.cpp b/server/src/playerbotfixturedriver.cpp index 8694a42f..77214ac4 100644 --- a/server/src/playerbotfixturedriver.cpp +++ b/server/src/playerbotfixturedriver.cpp @@ -32,6 +32,11 @@ playerbot::PlayerBotFixtureStorageObservation playerbot::PlayerBotFixtureDriver: return {false, engineSelectGoal && policy.progressionEnabled}; } +playerbot::PlayerBotFixtureStorageObservation playerbot::PlayerBotFixtureDriver::progressionGoalLoop(bool engineSelectGoal) const +{ + return {false, engineSelectGoal && policy.continuousGoalSelection}; +} + playerbot::PlayerBotFixtureHuntObservation playerbot::PlayerBotFixtureDriver::huntObservation() const { return {!policy.fixedFixtureRoute}; diff --git a/server/src/playerbotfixturedriver.h b/server/src/playerbotfixturedriver.h index 7b44ef24..8b260636 100644 --- a/server/src/playerbotfixturedriver.h +++ b/server/src/playerbotfixturedriver.h @@ -52,6 +52,8 @@ namespace playerbot { public: explicit PlayerBotFixtureDriver(const PlayerBotTestPolicy& policy); PlayerBotFixtureStorageObservation goalLoop(bool engineSelectGoal) const; + PlayerBotFixtureStorageObservation progressionGoalLoop(bool engineSelectGoal) const; + bool startWithGoalSelection() const { return policy.progressionEnabled; } PlayerBotFixtureHuntObservation huntObservation() const; bool startInHunt() const { return policy.startInHunt; } bool depotScenario() const { return policy.depotFixture; } diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index 821b3869..054d541e 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -929,7 +929,7 @@ void PlayerBotController::finishProgressionObjective(Player* player, const Posit cyclePhase = CyclePhase::Service; progressionRuntime.completeReward(std::strcmp(result, "success") == 0, std::strcmp(result, "success") == 0 ? pickupRewardSuccessCooldown : pickupRewardFailureCooldown); - if (player && fixtureDriver.goalLoop(true).selectGoal) { + if (player && fixtureDriver.progressionGoalLoop(true).selectGoal) { const char* decisionReason = std::strcmp(result, "success") == 0 ? "pickup_complete" : std::strcmp(result, "interrupted") == 0 ? "pickup_interrupted" : "pickup_failed"; selectTopLevelGoal(*player, position, decisionReason); @@ -992,6 +992,18 @@ void PlayerBotController::processPickupReward(Player* player, const Position& cu } } const PlayerBotProgressionOutcome result = progressionRuntime.advanceReward(observation); + // Report claims verified by this controller; persisted claims resume without replaying prior telemetry. + if (result.reason && (std::strcmp(result.reason, "reward_claimed") == 0 || + std::strcmp(result.reason, "reward_bundle_claimed") == 0)) { + emit("action_result", currentPosition, "\"action\":\"claim_reward\",\"result\":\"success\",\"candidate_id\":" + + std::to_string(pickupReward.uniqueId) + ",\"item_id\":" + std::to_string(pickupReward.itemId) + + ",\"root_item_id\":" + std::to_string(pickupReward.rootItemId) + ",\"inventory_before\":" + + std::to_string(progressionRuntime.reward().claimSnapshot().itemCount) + ",\"inventory_after\":" + + std::to_string(observation.currentClaim.itemCount) + ",\"root_count_before\":" + + std::to_string(progressionRuntime.reward().claimSnapshot().rootCount) + ",\"root_count_after\":" + + std::to_string(observation.currentClaim.rootCount) + ",\"top_level_root_count\":" + + std::to_string(pickupReward.rootSignatures.size()) + ",\"all_roots_verified\":true"); + } if (result.type == PlayerBotProgressionOutcomeType::Succeeded || result.type == PlayerBotProgressionOutcomeType::Failed) { if (result.type == PlayerBotProgressionOutcomeType::Succeeded && result.reason && std::strcmp(result.reason, "reward_equipped") == 0) { std::ostringstream fields; @@ -1001,16 +1013,6 @@ void PlayerBotController::processPickupReward(Player* player, const Position& cu << ",\"value_after\":" << pickupReward.candidateValue; emit("action_result", currentPosition, fields.str()); } - if (result.type == PlayerBotProgressionOutcomeType::Succeeded && result.reason && std::strcmp(result.reason, "reward_bundle_claimed") == 0) { - emit("action_result", currentPosition, "\"action\":\"claim_reward\",\"result\":\"success\",\"candidate_id\":" + - std::to_string(pickupReward.uniqueId) + ",\"item_id\":" + std::to_string(pickupReward.itemId) + - ",\"root_item_id\":" + std::to_string(pickupReward.rootItemId) + ",\"inventory_before\":" + - std::to_string(progressionRuntime.reward().claimSnapshot().itemCount) + ",\"inventory_after\":" + - std::to_string(observation.currentClaim.itemCount) + ",\"root_count_before\":" + - std::to_string(progressionRuntime.reward().claimSnapshot().rootCount) + ",\"root_count_after\":" + - std::to_string(observation.currentClaim.rootCount) + ",\"top_level_root_count\":" + - std::to_string(pickupReward.rootSignatures.size()) + ",\"all_roots_verified\":true"); - } finishProgressionObjective(player, currentPosition, result.type == PlayerBotProgressionOutcomeType::Succeeded ? "success" : "failed", result.reason); return; } diff --git a/server/src/playerbotprogressionruntime.cpp b/server/src/playerbotprogressionruntime.cpp index ea8fb66e..ff664717 100644 --- a/server/src/playerbotprogressionruntime.cpp +++ b/server/src/playerbotprogressionruntime.cpp @@ -331,7 +331,7 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceReward(const Pla rewardSession.resetRetries(); if (rewardSession.plan().slot == CONST_SLOT_WHEREEVER) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Succeeded, "reward_bundle_claimed"); rewardSession.setStage(PlayerBotRewardStage::EquipReward); - return outcome(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, nullptr); + return outcome(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Pending, "reward_claimed"); case PlayerBotRewardStage::EquipReward: if (observation.equipmentVerified) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Succeeded, "reward_equipped"); if (observation.rootRelocationRequired || observation.displacedMoveRequired) { diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index eae01c9b..81169bfd 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -84,7 +84,7 @@ void PlayerBotController::onNpcReply(uint32_t replyingPlayerId, uint32_t npcId, void PlayerBotController::beginService(Player* player, const Position& position, const char* reason) { - const bool interruptedHunt = fixtureDriver.goalLoop(true).selectGoal && progressionRuntime.activeGoal() == TopLevelGoal::Hunt && + const bool interruptedHunt = fixtureDriver.progressionGoalLoop(true).selectGoal && progressionRuntime.activeGoal() == TopLevelGoal::Hunt && !departurePlanner.hasCompleted(departureSnapshot(*player)); finishHuntRegion(*player, position, reason); if (interruptedHunt) { @@ -139,10 +139,8 @@ void PlayerBotController::refreshItemValues() for (const ShopInfo& offer : npc->getShopOffers()) { const ItemType& type = Item::items[offer.itemId]; if (type.isFluidContainer() || type.isSplash()) continue; - const bool buyAvailable = offer.buyPrice != 0 && fixtureDriver.observeProvider(true, offer.itemId, true).available; - const bool sellAvailable = offer.sellPrice != 0 && fixtureDriver.observeProvider(true, offer.itemId, false).available; - if (buyAvailable || sellAvailable) provider.offers.push_back({offer.itemId, buyAvailable ? offer.buyPrice : 0, - sellAvailable ? offer.sellPrice : 0, static_cast(offer.subType)}); + if (offer.buyPrice != 0 || offer.sellPrice != 0) provider.offers.push_back( + {offer.itemId, offer.buyPrice, offer.sellPrice, static_cast(offer.subType)}); } providers.push_back(std::move(provider)); } @@ -180,6 +178,11 @@ void PlayerBotController::processService(Player* player, const Position& current observation.bankBalance = player->getBankBalance(); observation.goldCoinWeight = Item::items[ITEM_GOLD_COIN].weight; observation.smallHealthPotionWeight = Item::items[PlayerBotDispositionPolicy::smallHealthPotionItemId].weight; + Item* serviceBackpackItem = player->getInventoryItem(CONST_SLOT_BACKPACK); + Container* serviceBackpack = serviceBackpackItem ? serviceBackpackItem->getContainer() : nullptr; + observation.actionAvailable = player->canDoAction(); + observation.backpackAvailable = serviceBackpack != nullptr; + observation.backpackOpen = serviceBackpack && player->getContainerID(serviceBackpack) >= 0; observation.maximumAttempts = maximumServiceAttempts; observation.slottedSaleCooldownMs = static_cast( std::chrono::duration_cast(unavailableDispositionCooldown).count()); @@ -304,7 +307,7 @@ void PlayerBotController::processService(Player* player, const Position& current Npc* provider = command.providerId == 0 ? nullptr : g_game.getNpcByID(command.providerId); if (command.type == PlayerBotServiceCommandType::NavigateProvider && provider) { if (!approachSteps.empty()) observeNavigationPlan(command.destination, std::move(approachSteps)); - processNavigation(player, currentPosition, command.destination); + if (processNavigation(player, currentPosition, command.destination)) schedule(SCHEDULER_MINTICKS); return; } if (command.type == PlayerBotServiceCommandType::Speak && provider) { @@ -313,6 +316,15 @@ void PlayerBotController::processService(Player* player, const Position& current schedule(1000); return; } + if (command.type == PlayerBotServiceCommandType::OpenBackpack) { + if (!serviceBackpack) { stop("service_backpack_unavailable", currentPosition); return; } + if (!player->canDoAction()) { schedule(navigationDecisionDelay(*player)); return; } + telemetry.recordActionAttempt(); + g_game.playerUseItem(playerId, Position(0xFFFF, CONST_SLOT_BACKPACK, 0), 0, depotSourceContainerId, + serviceBackpack->getClientID()); + schedule(navigationDecisionDelay(*player)); + return; + } if (command.type == PlayerBotServiceCommandType::MoveSlottedSale) { Item* item = player->getInventoryItem(command.sourceSlot); Item* backpackItem = player->getInventoryItem(CONST_SLOT_BACKPACK); @@ -366,9 +378,16 @@ bool PlayerBotController::findDepositableItem(const Player& player, Container* c Item*& depositItem, uint8_t& count) const { for (Item* item : container->getItemList()) { - if (Container* nested = item->getContainer(); nested && - findDepositableItem(player, nested, source, depositItem, count)) { - return true; + if (Container* nested = item->getContainer()) { + if (findDepositableItem(player, nested, source, depositItem, count)) { + return true; + } + if (nested->empty() && item->getID() == ITEM_BAG) { + source = container; + depositItem = item; + count = 1; + return true; + } } if (inventoryPolicy.isProtectedDepositItem(player, *item)) { continue; @@ -495,7 +514,9 @@ bool PlayerBotController::discoverDepot(Player& player, const Position& currentP return false; } if (command.type == PlayerBotDepotCommandType::Navigate && command.snapshot.hasSelectedDepot) { - observeNavigationPlan(command.snapshot.selected.approachPosition, std::move(steps)); + if (!steps.empty()) { + observeNavigationPlan(command.snapshot.selected.approachPosition, std::move(steps)); + } const PlayerBotDepotCandidate& depot = command.snapshot.selected; std::ostringstream fields; fields << "\"action\":\"depot_discover\",\"result\":\"success\",\"depot_id\":" << depot.depotId @@ -806,7 +827,7 @@ void PlayerBotController::processDeposit(Player* player, const Position& current player->closeContainer(depotChestContainerId); player->closeContainer(depotLockerContainerId); if (pauseDepotFixtureForRestart(*player, DepotRestartCheckpoint::Depart, currentPosition)) return; - if (fixtureDriver.goalLoop(true).selectGoal) { + if (fixtureDriver.progressionGoalLoop(true).selectGoal) { emit("goal_result", currentPosition, "\"decision_id\":" + std::to_string(progressionRuntime.decisionId()) + ",\"goal\":\"service\",\"result\":\"success\",\"reason\":\"service_complete\""); @@ -857,6 +878,10 @@ void PlayerBotController::processDeposit(Player* player, const Position& current stop("depot_capacity_not_recovered", currentPosition); return; } + if (depositItem && count != 0 && source && + !openContainer(*player, *source, depotSourceContainerId, currentPosition)) { + return; + } PlayerBotDepotObservation depositObservation; depositObservation.atApproach = true; depositObservation.lockerOpen = true; @@ -885,9 +910,6 @@ void PlayerBotController::processDeposit(Player* player, const Position& current } if (command.type != PlayerBotDepotCommandType::MoveDeposit || !depositItem) return; - if (source && !openContainer(*player, *source, depotSourceContainerId, currentPosition)) { - return; - } Position sourcePosition; uint8_t sourceIndex = 0; if (source) { diff --git a/server/src/playerbotserviceworkflow.cpp b/server/src/playerbotserviceworkflow.cpp index be409eb9..44bff6e4 100644 --- a/server/src/playerbotserviceworkflow.cpp +++ b/server/src/playerbotserviceworkflow.cpp @@ -210,6 +210,12 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceSlottedSale(const Playe if (item.itemId != itemId || item.count == 0) continue; auto unavailable = unavailableSlottedSales.find({item.itemId, item.slot}); if (unavailable != unavailableSlottedSales.end() && unavailable->second > observation.now) continue; + if (!observation.backpackAvailable) { + serviceStage = PlayerBotServiceStage::Failed; + return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; + } + if (!observation.actionAvailable) return {PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Pending}; + if (!observation.backpackOpen) return {PlayerBotServiceCommandType::OpenBackpack, PlayerBotServiceOutcome::Pending}; pendingSlottedItem = item.itemId; pendingSlottedSlot = item.slot; pendingSlottedBackpackItems = observation.backpackSaleCounts.count(itemId) ? observation.backpackSaleCounts.at(itemId) : 0; @@ -332,12 +338,20 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceImpl(const PlayerBotSer serviceStage = PlayerBotServiceStage::SellLoot; } if (serviceStage == PlayerBotServiceStage::SellLoot) { + if (serviceSession.hasShopTransaction()) return verifyShop(observation, false); const PlayerBotEconomyProvider* selected = nullptr; uint16_t itemId = 0; uint32_t price = 0; for (const auto& shop : shopProviders) for (const auto& offer : shop.offers) { auto count = observation.inventoryCounts.find(offer.itemId); if (offer.sellPrice == 0 || count == observation.inventoryCounts.end() || count->second == 0) continue; + const uint32_t backpackCount = observation.backpackSaleCounts.count(offer.itemId) ? + observation.backpackSaleCounts.at(offer.itemId) : 0; + const bool slotted = std::any_of(observation.slottedSaleItems.begin(), observation.slottedSaleItems.end(), + [&offer](const PlayerBotServiceSlottedItem& item) { + return item.itemId == offer.itemId && item.count != 0; + }); + if (backpackCount == 0 && !slotted) continue; if (!selected || offer.sellPrice > price) { selected = &shop; itemId = offer.itemId; price = offer.sellPrice; } } if (!selected) { serviceStage = PlayerBotServiceStage::BuyPotions; return advanceImpl(observation, catalog, disposition); } @@ -345,8 +359,8 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceImpl(const PlayerBotSer PlayerBotServiceCommand focus = establishNpc(observation, true); if (focus.type != PlayerBotServiceCommandType::None) return focus; const uint32_t backpack = observation.backpackSaleCounts.count(itemId) ? observation.backpackSaleCounts.at(itemId) : 0; + if (pendingSlottedItem != 0) return advanceSlottedSale(observation, itemId); if (backpack == 0) return advanceSlottedSale(observation, itemId); - if (serviceSession.hasShopTransaction()) return verifyShop(observation, false); auto offer = std::find_if(selected->offers.begin(), selected->offers.end(), [itemId](const auto& value) { return value.itemId == itemId && value.sellPrice != 0; }); @@ -359,6 +373,7 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceImpl(const PlayerBotSer return command; } if (serviceStage == PlayerBotServiceStage::BuyPotions) { + if (serviceSession.hasShopTransaction()) return verifyShop(observation, true); const uint16_t itemId = PlayerBotDispositionPolicy::smallHealthPotionItemId; const uint32_t count = observation.inventoryCounts.count(itemId) ? observation.inventoryCounts.at(itemId) : 0; if (count >= PlayerBotDispositionPolicy::potionRestockTarget) { serviceStage = PlayerBotServiceStage::Bank; return advanceImpl(observation, catalog, disposition); } @@ -373,7 +388,6 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceImpl(const PlayerBotSer targetProvider(selected->id); PlayerBotServiceCommand focus = establishNpc(observation, true); if (focus.type != PlayerBotServiceCommandType::None) return focus; - if (serviceSession.hasShopTransaction()) return verifyShop(observation, true); const PlayerBotServiceTransaction transaction{itemId, restock.amount, count, observation.money, observation.bankBalance}; serviceSession.beginShopTransaction(transaction); PlayerBotServiceCommand command{PlayerBotServiceCommandType::Buy, PlayerBotServiceOutcome::Pending, selected->id, itemId, restock.amount}; diff --git a/server/src/playerbotserviceworkflow.h b/server/src/playerbotserviceworkflow.h index c0bb3b88..cac5ee59 100644 --- a/server/src/playerbotserviceworkflow.h +++ b/server/src/playerbotserviceworkflow.h @@ -16,7 +16,7 @@ enum class PlayerBotServiceStage : uint8_t { Discover, SellLoot, BuyPotions, Bank, Complete, Failed }; enum class PlayerBotServiceCommandType : uint8_t { - None, ValidateProviderRoute, NavigateProvider, Speak, Sell, Buy, DepositAll, Withdraw, MoveSlottedSale, Complete, Fail, Wait, + None, ValidateProviderRoute, NavigateProvider, Speak, Sell, Buy, DepositAll, Withdraw, OpenBackpack, MoveSlottedSale, Complete, Fail, Wait, }; enum class PlayerBotServiceOutcome : uint8_t { Pending, Success, Partial, Retry, Rejected, Unavailable, InsufficientFunds }; enum class PlayerBotServiceRouteResult : uint8_t { NotObserved, Reached, Unreachable }; @@ -80,6 +80,9 @@ struct PlayerBotServiceObservation { std::map inventoryCounts; std::map backpackSaleCounts; std::vector slottedSaleItems; + bool actionAvailable = false; + bool backpackAvailable = false; + bool backpackOpen = false; uint32_t freeCapacity = 0; uint64_t money = 0; uint64_t bankBalance = 0; diff --git a/server/src/playerbotspells.cpp b/server/src/playerbotspells.cpp index 0a778065..3757e0bd 100644 --- a/server/src/playerbotspells.cpp +++ b/server/src/playerbotspells.cpp @@ -453,7 +453,7 @@ void PlayerBotController::finishSpellTraining(Player* player, const Position& po clearNavigation(); progressionRuntime.completeSpellTraining(std::strcmp(result, "success") == 0, std::strcmp(result, "success") == 0 ? spellTrainingSuccessCooldown : spellTrainingFailureCooldown); - if (player && fixtureDriver.goalLoop(true).selectGoal) { + if (player && fixtureDriver.progressionGoalLoop(true).selectGoal) { selectTopLevelGoal(*player, position, std::strcmp(result, "success") == 0 ? "spell_training_complete" : "spell_training_failed"); } schedule(SCHEDULER_MINTICKS); diff --git a/server/src/playerbottestpolicy.cpp b/server/src/playerbottestpolicy.cpp index deeb9afa..735ce06c 100644 --- a/server/src/playerbottestpolicy.cpp +++ b/server/src/playerbottestpolicy.cpp @@ -93,6 +93,7 @@ const playerbot::PlayerBotTestPolicy& playerbot::playerBotTestPolicyFromEnvironm DepotMoveFixture::Rejected : DepotMoveFixture::Normal; return PlayerBotTestPolicy{ !regressionMode && (!gameplayMode || progressionMode), + !regressionMode && (!gameplayMode || progressionMode || std::strcmp(gameplayMode, "mainland") == 0), startInHunt, fixedFixtureRoute, gameplayMode && std::strcmp(gameplayMode, "depot") == 0, diff --git a/server/src/playerbottestpolicy.h b/server/src/playerbottestpolicy.h index 0c8be2a9..ae18d891 100644 --- a/server/src/playerbottestpolicy.h +++ b/server/src/playerbottestpolicy.h @@ -32,6 +32,7 @@ namespace playerbot { struct PlayerBotTestPolicy { bool progressionEnabled; + bool continuousGoalSelection; bool startInHunt; bool fixedFixtureRoute; bool depotFixture; From 4e1c9ee0f5d8bd15c100d186f937c2ac38623e28 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:31:54 +0200 Subject: [PATCH 5/8] testing: calibrate mainland reward timeout --- scripts/playerbot-gameplay/scenarios-progression.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/playerbot-gameplay/scenarios-progression.ps1 b/scripts/playerbot-gameplay/scenarios-progression.ps1 index 9bc5e8c9..8785713f 100644 --- a/scripts/playerbot-gameplay/scenarios-progression.ps1 +++ b/scripts/playerbot-gameplay/scenarios-progression.ps1 @@ -333,7 +333,7 @@ } if ($MainlandRewards) { - Invoke-Scenario -Name "mainland_equipment_reward" -DefaultTimeoutSeconds 240 -Body { + Invoke-Scenario -Name "mainland_equipment_reward" -DefaultTimeoutSeconds 300 -Body { Invoke-Compose down --volumes --remove-orphans $env:PLAYERBOT_GAMEPLAY_MODE = "mainland_reward" $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" From 591d191f87e269051936065bb3288269836f73d8 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 23 Aug 2026 08:38:02 +0200 Subject: [PATCH 6/8] server: stabilize playerbot boundary workflows --- server/src/playerbotcombat.cpp | 19 ++++++++++--- server/src/playerbotcontroller.cpp | 4 +-- server/src/playerbotloot.cpp | 16 ++++++----- server/src/playerbotnavigationruntime.cpp | 5 ++++ server/src/playerbotnavigationruntime.h | 1 + server/src/playerbotprogressionruntime.cpp | 18 ++++-------- server/src/playerbotservice.cpp | 3 +- server/src/playerbotservicesession.cpp | 4 +-- server/src/playerbotservicesession.h | 4 ++- server/src/playerbotserviceworkflow.cpp | 33 ++++++++++++++-------- server/src/playerbotserviceworkflow.h | 1 + 11 files changed, 67 insertions(+), 41 deletions(-) diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index cd801dba..586e6ada 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -796,11 +796,11 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit plan.metrics.expandedNodes, static_cast(plan.steps.size()), travelSeconds, projectedHuntStaminaMultiplier(player, availableHuntSeconds)}); } - if (const auto planning = huntRuntime.planningSession()) { + if (const auto planning = huntRuntime.planningSession(); + planning && outcome.command != PlayerBotHuntRuntimeCommand::RegionSelected) { const char* phase = outcome.command == PlayerBotHuntRuntimeCommand::PlanningStarted ? "scoring_started" : outcome.command == PlayerBotHuntRuntimeCommand::PlanningYield ? "scoring_yield" : outcome.command == PlayerBotHuntRuntimeCommand::PlanningScored ? "scored" : - outcome.command == PlayerBotHuntRuntimeCommand::RegionSelected ? "selected" : outcome.routeWork ? "reachability_yield" : "planning_yield"; emitHuntRegionPlanning(*planning, position, phase); } @@ -814,6 +814,7 @@ bool PlayerBotController::selectHuntRegion(Player& player, const Position& posit if (!outcome.selectedRegion) return false; const PlayerBotHuntRegion& selected = *outcome.selectedRegion; emit("hunt_region_selection", position, "\"result\":\"selected\",\"region_id\":" + std::to_string(selected.id) + ",\"reason\":" + jsonString(reason) + ",\"center\":{\"x\":" + std::to_string(selected.center.x) + ",\"y\":" + std::to_string(selected.center.y) + ",\"z\":" + std::to_string(selected.center.z) + "}"); + if (const auto planning = huntRuntime.planningSession()) emitHuntRegionPlanning(*planning, position, "selected"); std::ostringstream speech; speech << "Going hunting. Expecting: "; for (size_t index = 0; index < selected.monsters.size(); ++index) { if (index != 0) speech << ", "; speech << selected.monsters[index].name; } @@ -998,11 +999,19 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre processTargetPursuit(player, currentPosition); return; } + if (attackVisibleMonster(player, currentPosition)) { + schedule(navigationInterval); + return; + } + if (trySupportSpell(player, currentPosition)) { + schedule(navigationDecisionDelay(*player)); + return; + } const PlayerBotHuntPatrolOutcome patrol = huntRuntime.patrolTarget(); const auto now = std::chrono::steady_clock::now(); PlayerBotNavigationRuntimeOutcome navigation; if (!processNavigation(player, currentPosition, patrol.destination, &navigation)) { - if (fixtureDriver.navigationRecovery(navigation.routeUnavailable).pause) navigationRuntime.resetPatrolRecovery(); + if (fixtureDriver.navigationRecovery(navigation.routeUnavailable).pause) navigationRuntime.clearBlockedPositions(); const PlayerBotHuntPatrolOutcome recovery = huntRuntime.observePatrolNavigation(navigation, now, maximumRepeatedNavigationStepFailures, maximumPatrolRouteFailures); applyHuntCooldown(recovery.cooldown, now); @@ -1010,7 +1019,9 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre emit("hunt_region_patrol", currentPosition, "\"result\":\"skipped\",\"reason\":" + jsonString(recovery.reason) + ",\"step_failures\":" + std::to_string(recovery.stepFailures) + ",\"route_failures\":" + std::to_string(recovery.routeFailures) + ",\"elapsed_ms\":" + std::to_string(recovery.elapsedMs) + ",\"expanded_nodes\":" + std::to_string(recovery.expandedNodes) + - ",\"region_id\":" + (recovery.regionId ? std::to_string(*recovery.regionId) : "null")); + ",\"region_id\":" + (recovery.regionId ? std::to_string(*recovery.regionId) : "null") + + ",\"destination\":{\"x\":" + std::to_string(recovery.destination.x) + ",\"y\":" + + std::to_string(recovery.destination.y) + ",\"z\":" + std::to_string(recovery.destination.z) + "}"); if (recovery.stepFailures != 0 || recovery.routeFailures != 0) telemetry.recordStuckEvent(); navigationRuntime.resetPatrolRecovery(); clearNavigation(); diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index f7e29054..fb6e97b1 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -355,9 +355,9 @@ bool PlayerBotController::processNavigation(Player* player, const Position& curr routePlan = planNavigationRoute(*player, outcome.routeRequest->destination, outcome.routeRequest->blockedPositions, outcome.routeRequest->maximumExpandedNodes); } - const std::optional pendingWorldChange = outcome.pendingWorldChange; + const PlayerBotPendingMovementResult movementResult = outcome.movementResult; outcome = navigationRuntime.observePlan({destination, std::move(routePlan), player->canDoAction(), false, now}); - outcome.pendingWorldChange = pendingWorldChange; + outcome.movementResult = movementResult; } if (navigationOutcome) *navigationOutcome = outcome; fixtureDriver.observeNavigationPlan(outcome.plan.attempted); diff --git a/server/src/playerbotloot.cpp b/server/src/playerbotloot.cpp index acd2dae7..cd86abb1 100644 --- a/server/src/playerbotloot.cpp +++ b/server/src/playerbotloot.cpp @@ -21,7 +21,7 @@ namespace { Position position; }; - std::optional findOwnedCorpse(Player& player, const Position& searchPosition) + std::optional findOwnedCorpse(Player& player, const Position& searchPosition, uint16_t expectedItemId) { std::optional fallback; for (int32_t offsetX = -1; offsetX <= 1; ++offsetX) { @@ -33,7 +33,7 @@ namespace { for (auto it = items->getBeginDownItem(); it != items->getEndDownItem(); ++it) { Item* item = *it; Container* corpse = item->getContainer(); - if (!corpse || Item::items[item->getID()].corpseType == RACE_NONE) continue; + if (!corpse || item->getID() != expectedItemId || Item::items[item->getID()].corpseType == RACE_NONE) continue; const uint32_t owner = corpse->getCorpseOwner(); if (owner != 0 && !player.canOpenCorpse(owner)) continue; if (position == searchPosition) return {{corpse, position}}; @@ -151,10 +151,13 @@ void PlayerBotController::lootCorpse(Player* player, const Position& currentPosi } Container* openedCorpse = player->getContainerByID(corpseContainerId); - if (openedCorpse && Item::items[openedCorpse->getID()].corpseType == RACE_NONE) openedCorpse = nullptr; + Tile* openedCorpseTile = openedCorpse ? openedCorpse->getTile() : nullptr; + if (openedCorpse && (openedCorpse->getID() != lootWorkflow.expectedCorpse().itemId || + Item::items[openedCorpse->getID()].corpseType == RACE_NONE || !openedCorpseTile || + !Position::areInRange<1, 1, 0>(openedCorpseTile->getPosition(), lootWorkflow.corpsePosition()))) openedCorpse = nullptr; std::optional discovery; - if (openedCorpse) discovery = {{openedCorpse, lootWorkflow.corpsePosition()}}; - else discovery = findOwnedCorpse(*player, lootWorkflow.corpsePosition()); + if (openedCorpse) discovery = {{openedCorpse, openedCorpseTile->getPosition()}}; + else discovery = findOwnedCorpse(*player, lootWorkflow.corpsePosition(), lootWorkflow.expectedCorpse().itemId); PlayerBotLootWorkflowSnapshot snapshot; snapshot.currentPosition = currentPosition; @@ -243,9 +246,9 @@ void PlayerBotController::lootCorpse(Player* player, const Position& currentPosi << ",\"free_capacity\":" << player->getFreeCapacity(); emit("action_result", currentPosition, fields.str()); } + schedule(navigationInterval); if (command.type == PlayerBotLootCommandType::Fail) { finishLootFailure(player, currentPosition, command.outcome == PlayerBotLootOutcome::CorpseExpired ? "corpse_expired" : "corpse_inaccessible"); - schedule(navigationInterval); return; } if (command.type == PlayerBotLootCommandType::Finish) { @@ -271,7 +274,6 @@ void PlayerBotController::lootCorpse(Player* player, const Position& currentPosi return; } - schedule(navigationInterval); if (command.type == PlayerBotLootCommandType::OpenCorpse && discovery) { player->closeContainer(corpseContainerId); Tile* tile = g_game.map.getTile(discovery->position); diff --git a/server/src/playerbotnavigationruntime.cpp b/server/src/playerbotnavigationruntime.cpp index 9a284bf8..f48197e2 100644 --- a/server/src/playerbotnavigationruntime.cpp +++ b/server/src/playerbotnavigationruntime.cpp @@ -40,6 +40,11 @@ PlayerBotNavigationRuntimeOutcome PlayerBotNavigationRuntime::process(const Play } outcome.pendingWorldChange = session.takeWorldChange(); + if (outcome.pendingWorldChange) { + outcome.command = PlayerBotNavigationRuntimeCommand::Retry; + outcome.stepFailureCount = session.stepFailureCount(); + return outcome; + } session.prepareDestination(input.destination); if (session.routeEmpty()) { outcome.blockedPositions = session.activeBlockedPositions(input.timing.now); diff --git a/server/src/playerbotnavigationruntime.h b/server/src/playerbotnavigationruntime.h index c249666e..d93146d7 100644 --- a/server/src/playerbotnavigationruntime.h +++ b/server/src/playerbotnavigationruntime.h @@ -113,6 +113,7 @@ class PlayerBotNavigationRuntime void reset() { session.clear(); fixedTargetRouteFailures = 0; } void resetPatrolRecovery() { session.clearBlockedPositions(); session.resetStepFailures(); } + void clearBlockedPositions() { session.clearBlockedPositions(); } size_t routeSize() const { return session.routeSize(); } bool hasPendingWork() const { return session.hasPendingWork(); } diff --git a/server/src/playerbotprogressionruntime.cpp b/server/src/playerbotprogressionruntime.cpp index ff664717..7489f080 100644 --- a/server/src/playerbotprogressionruntime.cpp +++ b/server/src/playerbotprogressionruntime.cpp @@ -39,19 +39,16 @@ void PlayerBotProgressionRuntime::enterHunt() void PlayerBotProgressionRuntime::completeReward(bool succeeded, std::chrono::steady_clock::duration cooldown) { arbiter.setCooldown(PlayerBotGoalArbiter::TopLevelGoal::PickupReward, cooldown); - if (!succeeded) enterService(); } void PlayerBotProgressionRuntime::completeSpellTraining(bool succeeded, std::chrono::steady_clock::duration cooldown) { arbiter.setCooldown(PlayerBotGoalArbiter::TopLevelGoal::LearnSpell, cooldown); - if (!succeeded) enterService(); } void PlayerBotProgressionRuntime::completeEquipmentPurchase(bool succeeded, std::chrono::steady_clock::duration cooldown) { arbiter.setCooldown(PlayerBotGoalArbiter::TopLevelGoal::BuyEquipment, cooldown); - if (!succeeded) enterService(); } void PlayerBotProgressionRuntime::completeMagicTraining(std::chrono::steady_clock::duration cooldown) @@ -205,7 +202,7 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceDeparture(const case PlayerBotOracleDepartureStage::Travel: if (observation.navigationFailed) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "route_unavailable"); if (!observation.navigationReached) return outcome(PlayerBotProgressionCommandType::Navigate, PlayerBotProgressionOutcomeType::Pending, nullptr); - departureSession.setStage(PlayerBotOracleDepartureStage::Greet); + departureSession.setStage(PlayerBotOracleDepartureStage::ConfirmReady); return outcome(PlayerBotProgressionCommandType::Speak, PlayerBotProgressionOutcomeType::Pending, "hi"); case PlayerBotOracleDepartureStage::Greet: if (!observation.npcAvailable) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "oracle_unavailable"); @@ -215,7 +212,7 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceDeparture(const if (!observation.npcAvailable) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "oracle_unavailable"); if (!observation.greetingAcknowledged) { if (departureSession.incrementRetries() >= 3) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "oracle_focus_unconfirmed"); - departureSession.setStage(PlayerBotOracleDepartureStage::Greet); + departureSession.setStage(PlayerBotOracleDepartureStage::ConfirmReady); return outcome(PlayerBotProgressionCommandType::Speak, PlayerBotProgressionOutcomeType::Retry, "hi"); } departureSession.setStage(PlayerBotOracleDepartureStage::ChooseTown); @@ -247,7 +244,7 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceSpellTraining(co case PlayerBotSpellTrainingStage::Travel: if (observation.navigationFailed) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "route_unavailable"); if (!observation.navigationReached) return outcome(PlayerBotProgressionCommandType::Navigate, PlayerBotProgressionOutcomeType::Pending, nullptr); - spellTrainingSession.setStage(PlayerBotSpellTrainingStage::Greet); + spellTrainingSession.setStage(PlayerBotSpellTrainingStage::Request); return outcome(PlayerBotProgressionCommandType::Speak, PlayerBotProgressionOutcomeType::Pending, "hi"); case PlayerBotSpellTrainingStage::Greet: if (!observation.npcAvailable) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "trainer_unavailable"); @@ -257,7 +254,7 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceSpellTraining(co if (!observation.npcAvailable) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "trainer_unavailable"); if (!observation.greetingAcknowledged) { if (spellTrainingSession.incrementRetries() >= 3) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "trainer_focus_unconfirmed"); - spellTrainingSession.setStage(PlayerBotSpellTrainingStage::Greet); + spellTrainingSession.setStage(PlayerBotSpellTrainingStage::Request); return outcome(PlayerBotProgressionCommandType::Speak, PlayerBotProgressionOutcomeType::Retry, "hi"); } spellTrainingSession.setStage(PlayerBotSpellTrainingStage::Confirm); @@ -405,13 +402,13 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceEquipmentPurchas if (!observation.shopReady) return outcome(PlayerBotProgressionCommandType::Shop, PlayerBotProgressionOutcomeType::Pending, "open_shop"); if (!observation.fundingAvailable) return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "reserve_changed"); equipmentTransaction.beginShopTransaction({equipmentPurchaseSession.plan().itemId, 1, observation.itemCount, - observation.money, observation.bankBalance}); + 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, equipmentPurchaseSession.plan().price, maximumRetries); + observation.money, observation.bankBalance, true, maximumRetries); if (verification.result == PlayerBotServiceVerificationResult::Mismatch) { return outcome(PlayerBotProgressionCommandType::Finish, PlayerBotProgressionOutcomeType::Failed, "transaction_delta_mismatch"); } @@ -424,9 +421,6 @@ PlayerBotProgressionOutcome PlayerBotProgressionRuntime::advanceEquipmentPurchas return {command(PlayerBotProgressionCommandType::None), PlayerBotProgressionOutcomeType::Pending, 0, nullptr, verification.before}; } - equipmentPurchaseSession.incrementRetries(); - equipmentPurchaseSession.setStage(PlayerBotEquipmentPurchaseStage::Purchase); - npcSession.setStep(PlayerBotNpcConversationStep::Ready); return outcome(PlayerBotProgressionCommandType::None, PlayerBotProgressionOutcomeType::Retry, "purchase_equipment"); } case PlayerBotEquipmentPurchaseStage::Equip: diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index 81169bfd..f454a822 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -370,7 +370,8 @@ void PlayerBotController::processService(Player* player, const Position& current if (command.type == PlayerBotServiceCommandType::Complete) { beginReturn(player, currentPosition, "service_complete"); } else { - schedule(SCHEDULER_MINTICKS); + schedule(command.outcome == PlayerBotServiceOutcome::Retry && command.cooldownMs != 0 ? + command.cooldownMs : SCHEDULER_MINTICKS); } } diff --git a/server/src/playerbotservicesession.cpp b/server/src/playerbotservicesession.cpp index 5b19aab7..b71de30e 100644 --- a/server/src/playerbotservicesession.cpp +++ b/server/src/playerbotservicesession.cpp @@ -37,10 +37,10 @@ void PlayerBotServiceSession::beginShopTransaction(PlayerBotServiceTransaction t } PlayerBotServiceVerification PlayerBotServiceSession::verifyShopTransaction( - uint32_t itemCount, uint64_t money, uint64_t balance, bool purchase, uint32_t unitPrice, uint32_t maximumRetries) + uint32_t itemCount, uint64_t money, uint64_t balance, bool purchase, uint32_t maximumRetries) { const PlayerBotServiceTransaction before = shop; - const uint64_t moneyDelta = static_cast(shop.amount) * unitPrice; + const uint64_t moneyDelta = static_cast(shop.amount) * shop.unitPrice; const bool itemChanged = purchase ? itemCount == shop.itemCount + shop.amount : itemCount + shop.amount == shop.itemCount; const uint64_t expectedMoney = purchase ? (shop.money > moneyDelta ? shop.money - moneyDelta : 0) : diff --git a/server/src/playerbotservicesession.h b/server/src/playerbotservicesession.h index 5e6df299..8343d156 100644 --- a/server/src/playerbotservicesession.h +++ b/server/src/playerbotservicesession.h @@ -14,6 +14,8 @@ struct PlayerBotServiceTransaction { uint32_t itemCount = 0; uint64_t money = 0; uint64_t balance = 0; + uint32_t unitPrice = 0; + uint8_t subType = 0; }; enum class PlayerBotServiceVerificationResult : uint8_t { @@ -41,7 +43,7 @@ class PlayerBotServiceSession friend class PlayerBotProgressionRuntime; void beginShopTransaction(PlayerBotServiceTransaction transaction); PlayerBotServiceVerification verifyShopTransaction(uint32_t itemCount, uint64_t money, uint64_t balance, - bool purchase, uint32_t unitPrice, uint32_t maximumRetries); + bool purchase, uint32_t maximumRetries); bool bankDepositComplete() const { return depositComplete; } void markBankDepositComplete() { depositComplete = true; depositPending = false; } diff --git a/server/src/playerbotserviceworkflow.cpp b/server/src/playerbotserviceworkflow.cpp index 44bff6e4..56223929 100644 --- a/server/src/playerbotserviceworkflow.cpp +++ b/server/src/playerbotserviceworkflow.cpp @@ -17,6 +17,7 @@ void PlayerBotServiceWorkflow::reset() pendingSlottedSlot = CONST_SLOT_WHEREEVER; pendingSlottedBackpackItems = 0; slottedMoveAttempts = 0; + shopOpenAttempts = 0; unavailableSlottedSales.clear(); providerApproaches.clear(); pendingApproachRoute.reset(); @@ -68,6 +69,7 @@ void PlayerBotServiceWorkflow::targetProvider(uint32_t id) if (!npcSession.targets(id)) { npcSession.reset(id); serviceSession.reset(); + shopOpenAttempts = 0; providerApproaches.clear(); pendingApproachRoute.reset(); selectedApproach.reset(); @@ -146,8 +148,11 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::establishNpc(const PlayerBotSe return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Rejected}; } npcSession.setStep(PlayerBotNpcConversationStep::Greet); - return {PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Retry}; + PlayerBotServiceCommand command{PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Retry}; + command.cooldownMs = npcReplyDelay; + return command; } + npcSession.resetRetries(); npcSession.setStep(shop ? PlayerBotNpcConversationStep::Confirm : PlayerBotNpcConversationStep::Ready); if (!shop) return {PlayerBotServiceCommandType::Wait}; return {PlayerBotServiceCommandType::Speak, PlayerBotServiceOutcome::Pending, npcSession.targetId(), 0, 0, @@ -155,14 +160,17 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::establishNpc(const PlayerBotSe } if (shop && npcSession.step() == PlayerBotNpcConversationStep::Confirm) { if (!state->second.shopOpen) { - if (npcSession.retryLimitReached(observation.maximumAttempts)) { + if (++shopOpenAttempts >= observation.maximumAttempts) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Rejected}; } npcSession.resetGreetingAcknowledgement(); npcSession.setStep(PlayerBotNpcConversationStep::Greet); - return {PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Retry}; + PlayerBotServiceCommand command{PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Retry}; + command.cooldownMs = npcReplyDelay; + return command; } + shopOpenAttempts = 0; npcSession.setStep(PlayerBotNpcConversationStep::Ready); npcSession.resetRetries(); } @@ -233,13 +241,9 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::verifyShop(const PlayerBotServ { const PlayerBotServiceTransaction* transaction = serviceSession.shopTransaction(); if (!transaction) return {PlayerBotServiceCommandType::None}; - const PlayerBotEconomyProvider* shop = provider(npcSession.targetId(), true); - if (!shop) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } - auto offer = std::find_if(shop->offers.begin(), shop->offers.end(), [transaction](const auto& value) { return value.itemId == transaction->itemId; }); - if (offer == shop->offers.end()) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } const uint32_t count = observation.inventoryCounts.count(transaction->itemId) ? observation.inventoryCounts.at(transaction->itemId) : 0; const PlayerBotServiceVerification result = serviceSession.verifyShopTransaction(count, observation.money, observation.bankBalance, - purchase, purchase ? offer->buyPrice : offer->sellPrice, observation.maximumAttempts); + purchase, observation.maximumAttempts); if (result.result == PlayerBotServiceVerificationResult::Success) { npcSession.setStep(PlayerBotNpcConversationStep::Ready); npcSession.resetRetries(); @@ -250,8 +254,11 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::verifyShop(const PlayerBotServ } if (result.result == PlayerBotServiceVerificationResult::Retry) return {PlayerBotServiceCommandType::Wait, PlayerBotServiceOutcome::Retry}; serviceStage = PlayerBotServiceStage::Failed; - return {PlayerBotServiceCommandType::Fail, result.result == PlayerBotServiceVerificationResult::Mismatch ? - PlayerBotServiceOutcome::Rejected : PlayerBotServiceOutcome::Unavailable}; + PlayerBotServiceCommand command{PlayerBotServiceCommandType::Fail, + result.result == PlayerBotServiceVerificationResult::Mismatch ? PlayerBotServiceOutcome::Rejected : PlayerBotServiceOutcome::Unavailable}; + command.transaction = result.before; + command.verification = result; + return command; } PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceBank(const PlayerBotServiceObservation& observation, @@ -365,7 +372,8 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceImpl(const PlayerBotSer return value.itemId == itemId && value.sellPrice != 0; }); if (offer == selected->offers.end()) { serviceStage = PlayerBotServiceStage::Failed; return {PlayerBotServiceCommandType::Fail, PlayerBotServiceOutcome::Unavailable}; } - const PlayerBotServiceTransaction transaction{itemId, std::min(100, backpack), observation.inventoryCounts.at(itemId), observation.money, observation.bankBalance}; + const PlayerBotServiceTransaction transaction{itemId, std::min(100, backpack), observation.inventoryCounts.at(itemId), + observation.money, observation.bankBalance, offer->sellPrice, offer->subType}; serviceSession.beginShopTransaction(transaction); PlayerBotServiceCommand command{PlayerBotServiceCommandType::Sell, PlayerBotServiceOutcome::Pending, selected->id, itemId, transaction.amount}; command.subType = offer->subType; @@ -388,7 +396,8 @@ PlayerBotServiceCommand PlayerBotServiceWorkflow::advanceImpl(const PlayerBotSer targetProvider(selected->id); PlayerBotServiceCommand focus = establishNpc(observation, true); if (focus.type != PlayerBotServiceCommandType::None) return focus; - const PlayerBotServiceTransaction transaction{itemId, restock.amount, count, observation.money, observation.bankBalance}; + const PlayerBotServiceTransaction transaction{itemId, restock.amount, count, observation.money, observation.bankBalance, + offer->buyPrice, offer->subType}; serviceSession.beginShopTransaction(transaction); PlayerBotServiceCommand command{PlayerBotServiceCommandType::Buy, PlayerBotServiceOutcome::Pending, selected->id, itemId, restock.amount}; command.subType = offer->subType; diff --git a/server/src/playerbotserviceworkflow.h b/server/src/playerbotserviceworkflow.h index cac5ee59..20ebe8c4 100644 --- a/server/src/playerbotserviceworkflow.h +++ b/server/src/playerbotserviceworkflow.h @@ -140,6 +140,7 @@ class PlayerBotServiceWorkflow slots_t pendingSlottedSlot = CONST_SLOT_WHEREEVER; uint32_t pendingSlottedBackpackItems = 0; uint32_t slottedMoveAttempts = 0; + uint32_t shopOpenAttempts = 0; std::map, std::chrono::steady_clock::time_point> unavailableSlottedSales; std::vector providerApproaches; std::optional pendingApproachRoute; From 5b3c9a5e2e0157e3f4f22bed86471fdc3e3bd0ec Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:56:36 +0200 Subject: [PATCH 7/8] docs: define navigation runtime protocol --- server/src/playerbotnavigationruntime.h | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/server/src/playerbotnavigationruntime.h b/server/src/playerbotnavigationruntime.h index d93146d7..0b6e5ae4 100644 --- a/server/src/playerbotnavigationruntime.h +++ b/server/src/playerbotnavigationruntime.h @@ -103,6 +103,13 @@ struct PlayerBotNavigationWorldChangeObservation { std::chrono::steady_clock::duration suppression; }; +// process() owns route and pending-step transitions and returns at most one +// command. Supply a completed Plan through observePlan(), and report the exact +// nextStep from a Move or Use through observeStep(). A dispatched move remains +// pending until a later process() observes its result. A dispatched Use becomes +// pendingWorldChange; inspect that change and pass it to observeWorldChange(). +// Rejected steps drop the route, while unchanged world changes suppress their +// target before the runtime replans. class PlayerBotNavigationRuntime { public: From 9180caf1693b5bac495214ca90c451963701d928 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:58:53 +0200 Subject: [PATCH 8/8] docs: correct navigation use contract --- server/src/playerbotnavigationruntime.h | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/playerbotnavigationruntime.h b/server/src/playerbotnavigationruntime.h index 0b6e5ae4..52cebc79 100644 --- a/server/src/playerbotnavigationruntime.h +++ b/server/src/playerbotnavigationruntime.h @@ -106,8 +106,9 @@ struct PlayerBotNavigationWorldChangeObservation { // process() owns route and pending-step transitions and returns at most one // command. Supply a completed Plan through observePlan(), and report the exact // nextStep from a Move or Use through observeStep(). A dispatched move remains -// pending until a later process() observes its result. A dispatched Use becomes -// pendingWorldChange; inspect that change and pass it to observeWorldChange(). +// pending until a later process() observes its result. A dispatched UseDoor or +// UseShovel becomes pendingWorldChange; inspect that change and pass it to +// observeWorldChange(). // Rejected steps drop the route, while unchanged world changes suppress their // target before the runtime replans. class PlayerBotNavigationRuntime