From 8639b125fec2e74284d89e184b6026b94208966c Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:03:39 +0200 Subject: [PATCH] server: learn eligible Knight spells from trainers --- README.md | 3 +- docs/playerbots.md | 27 +- docs/testing.md | 1 + scripts/test-playerbot-gameplay.ps1 | 52 ++- server/data/npc/Gregor.xml | 3 + .../data/npc/lib/npcsystem/keywordhandler.lua | 8 + server/src/CMakeLists.txt | 1 + server/src/npc.cpp | 29 ++ server/src/npc.h | 14 + server/src/playerbotcombat.cpp | 4 +- server/src/playerbotcontroller.cpp | 6 +- server/src/playerbotcontroller.h | 39 +++ server/src/playerbotprogression.cpp | 25 +- server/src/playerbotservice.cpp | 4 +- server/src/playerbotspells.cpp | 313 ++++++++++++++++++ .../playerbot-gameplay/playerbot_gameplay.lua | 48 ++- 16 files changed, 557 insertions(+), 20 deletions(-) create mode 100644 server/src/playerbotspells.cpp diff --git a/README.md b/README.md index 62d7981..cadf3e1 100644 --- a/README.md +++ b/README.md @@ -130,10 +130,11 @@ provisioning, startup, lifecycle telemetry, and local game ports. The gameplay suite is local and PowerShell-based. It boots scenario worlds with fixture monsters and asserts on the event stream, covering corpse handling, value looting, healing, death recovery, goal arbitration and interruption, -reward claiming, and Oracle departure. +reward claiming, Oracle departure, and spell training. ```powershell pwsh -File scripts/test-playerbot-gameplay.ps1 -FullNavigation -CorpseLoot +pwsh -File scripts/test-playerbot-gameplay.ps1 -Focused -SpellTraining ``` Gameplay fixtures use fixed destinations and do not prove whole-map navigation diff --git a/docs/playerbots.md b/docs/playerbots.md index be3aba8..702b335 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -47,14 +47,15 @@ Utilities are deterministic arbitration scores, not probabilities: | Oracle departure | 950 | | Capacity service | 900 | | Equipment reward | 650 | +| Spell training | 550 | | Ordinary service | 400 | | Hunt | 300 | | Economic reward | 250 | Service needs and reward value adjust these baselines, so candidates can cross nominal tiers. Equal scores keep declaration order: departure, service, pickup, -then hunt. Successful pickup families cool down for five minutes; failed or -interrupted families cool down for 60 seconds. +spell training, then hunt. Successful pickup and spell-training families cool +down for five minutes; failed or interrupted families cool down for 60 seconds. ## Navigation and hunting @@ -107,11 +108,22 @@ stock redirects it to service. Food use preserves one meat and respects the fullness limit. Service NPCs require an exact `playerbot_service` XML tag of `shop`, `banker`, -or `oracle`. Shops publish their loaded offers to the bot; untagged shops and -tagged shops without offers are ignored. Providers must remain within 200 -weighted tiles of the registered town temple. The bot greets the selected NPC, -treats a private reply as focus acknowledgement, and opens the normal trade -window. Reply text is not interpreted. +`oracle`, or `spell_trainer`. Shops publish their loaded offers to the bot; +trainers publish each spell registered through `addSpellKeyword`. Untagged +providers and tagged providers without offers are ignored. Providers must remain +within 200 weighted tiles of the registered town temple. The bot greets the +selected NPC, treats a private reply as focus acknowledgement, and opens the +normal trade window. Reply text is not interpreted. + +Spell training currently considers tagged providers within the Thais temple +scope; Gregor is the initial tag. It derives trainer offers from loaded NPC +scripts and rejects offers with a registry mismatch, wrong vocation, level, +premium status, learned state, missing supply reserve, insufficient funds after +the 100 gp carried reserve plus five potion and one meat replacement costs, or +an unavailable route. A selected spell uses normal `hi`, keyword, and `yes` +dialogue. Completion requires both learned state and the exact total-money +delta. Learned-spell persistence reconstructs completion after restart and +prevents a repurchase. The service cycle sells known surplus, restores five small health potions and one meat, deposits carried money, and withdraws 100 gp. Hunting ends after the @@ -191,6 +203,7 @@ States, actions, results, statuses, and reasons use stable lowercase values. | Lifecycle | `lifecycle`, `state_transition`, `objective_transition`, `terminal` record ownership and controller state. | | Goals | `goal_candidate`, `goal_selection`, `goal_result` expose arbitration evidence and decision IDs. | | Rewards | `strategy_candidate`, `reward_inspection`, `strategy_selection`, `strategy_objective_result` expose bundle selection and verification. | +| Spell training | `spell_trainer_discovered`, `spell_candidate`, `strategy_selection`, `action_result`, and `goal_result` expose loaded offers, eligibility rejections, provider/route choice, and exact payment verification. | | Actions | `action_result`, `target_changed`, `service_discovered`, `npc_reply`, `stuck` record externally relevant attempts and outcomes. | | Hunting | `hunt_region_candidate`, `hunt_region_scan`, `hunt_region_selection`, `hunt_region_outcome`, `hunt_region_patrol` expose planner inputs and results. | | Navigation | `navigation_progress` records bounded recovery such as oscillation suppression. | diff --git a/docs/testing.md b/docs/testing.md index 1a593d3..88b228c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -50,6 +50,7 @@ the changed behavior: | `-CombatReadiness` | Equipment, supplies, capacity, service recovery, upgrades, and restart reconstruction. | | `-Depot` | Real locker/chest discovery, nested deposits, move verification, retries, and restart checkpoints. | | `-MainlandLoop` | Two real Thais hunt/depot cycles, local services, restart recovery, and teleport exclusion. | +| `-SpellTraining` | Tagged trainer discovery, reserve-backed affordability rejection, normal spell dialogue/payment, and restart persistence. | Navigation or looting changes require at least: diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index 94bb11a..8d669e7 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -15,6 +15,7 @@ param( [switch]$CombatReadiness, [switch]$Depot, [switch]$MainlandLoop, + [switch]$SpellTraining, [switch]$Focused, [switch]$SkipBuild, [switch]$KeepStack @@ -1208,13 +1209,45 @@ function Assert-MainlandLoopEvents { } } +function Assert-SpellTrainingEvents { + param([string]$Logs, [switch]$Restart) + + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $discovery = @($events | Where-Object { $_.event -eq "spell_trainer_discovered" -and $_.offers -gt 0 -and $_.in_scope }) + $selected = @($events | Where-Object { + $_.event -eq "goal_selection" -and $_.to_goal -eq "learn_spell" -and $_.spell -eq "Find Person" -and $_.price -eq 80 + }) + $rejected = @($events | Where-Object { + $_.event -eq "spell_candidate" -and $_.spell -eq "Light" -and $_.result -eq "rejected" -and + $_.reason -eq "unaffordable_after_reserves" + }) + $purchase = @($events | Where-Object { + $_.event -eq "action_result" -and $_.action -eq "learn_spell" -and $_.result -eq "success" -and + $_.spell -eq "Find Person" -and $_.price -eq 80 -and $_.money_before -eq 300 -and $_.money_after -eq 220 + }) + $completed = @($events | Where-Object { + $_.event -eq "goal_result" -and $_.goal -eq "learn_spell" -and $_.result -eq "success" + }) + $terminal = @($events | Where-Object { $_.event -eq "terminal" }) + if ($Restart) { + if ($purchase.Count -ne 1 -or $terminal.Count -ne 0) { + throw "Spell training restart repeated or failed the completed purchase. purchases=$($purchase.Count), terminal=$($terminal.Count)." + } + return + } + if ($discovery.Count -lt 1 -or $selected.Count -ne 1 -or $rejected.Count -lt 1 -or $purchase.Count -ne 1 -or + $completed.Count -ne 1 -or $terminal.Count -ne 0) { + throw "Spell training failed. discovery=$($discovery.Count), selected=$($selected.Count), rejected=$($rejected.Count), purchases=$($purchase.Count), completed=$($completed.Count), terminal=$($terminal.Count)." + } +} + if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { throw "Docker is required to run the playerbot gameplay suite." } $focusedScenarioRequested = $FullNavigation -or $TargetPursuit -or $CorpseLoot -or $DeathTelemetry -or $Healing -or $ValueLoot -or $PickupProgression -or $GoalArbitration -or $OracleDeparture -or $StaminaProjection -or $HuntRegionPlanning -or - $CombatReadiness -or $Depot -or $MainlandLoop + $CombatReadiness -or $Depot -or $MainlandLoop -or $SpellTraining if ($Focused -and -not $focusedScenarioRequested) { throw "-Focused requires at least one focused scenario switch." } @@ -1632,6 +1665,23 @@ try { } } + if ($SpellTraining) { + Invoke-Scenario -Name "spell_training" -DefaultTimeoutSeconds 180 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "spell_training" + $env:PLAYERBOT_HUNT_DURATION_SECONDS = "900" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_PASS' | Out-Null + $trainingLogs = Wait-ForLog -Pattern '"action":"learn_spell","result":"success"' + Assert-SpellTrainingEvents -Logs $trainingLogs + Invoke-Compose stop server + Invoke-Compose up --detach server + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_RESTART_PASS' | Out-Null + $restartLogs = Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_RESTART_PASS' + Assert-SpellTrainingEvents -Logs $restartLogs -Restart + } + } + if ($CorpseLoot) { Invoke-Scenario -Name "corpse" -DefaultTimeoutSeconds 60 -Body { Invoke-Compose down --volumes --remove-orphans diff --git a/server/data/npc/Gregor.xml b/server/data/npc/Gregor.xml index 134fee5..e808c22 100644 --- a/server/data/npc/Gregor.xml +++ b/server/data/npc/Gregor.xml @@ -1,5 +1,8 @@ + + + diff --git a/server/data/npc/lib/npcsystem/keywordhandler.lua b/server/data/npc/lib/npcsystem/keywordhandler.lua index fcfc11e..7bf7ab1 100644 --- a/server/data/npc/lib/npcsystem/keywordhandler.lua +++ b/server/data/npc/lib/npcsystem/keywordhandler.lua @@ -244,6 +244,14 @@ if not KeywordHandler then keys.callback = FocusModule.messageMatcherDefault local npcHandler, spellName, price, vocationId = parameters.npcHandler, parameters.spellName, parameters.price, parameters.vocation + local keyword = table.concat(keys, ' ') + if type(vocationId) == 'table' then + for _, vocation in ipairs(vocationId) do + Npc():addSpellOffer(spellName, keyword, price, parameters.level, parameters.premium or false, vocation) + end + else + Npc():addSpellOffer(spellName, keyword, price, parameters.level, parameters.premium or false, vocationId) + end local spellKeyword = self:addKeyword(keys, StdModule.say, {npcHandler = npcHandler, spellName = spellName, text = string.format("Do you want to learn the spell %s for %s?", spellName, price > 0 and price .. " gold" or "free")}, function(player) local baseVocationId = player:getVocation():getBase():getId() diff --git a/server/src/CMakeLists.txt b/server/src/CMakeLists.txt index d19c27b..9d8413f 100644 --- a/server/src/CMakeLists.txt +++ b/server/src/CMakeLists.txt @@ -50,6 +50,7 @@ set(tfs_SRC ${CMAKE_CURRENT_LIST_DIR}/playerbotdeparture.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotloot.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotprogression.cpp + ${CMAKE_CURRENT_LIST_DIR}/playerbotspells.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotservice.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbothuntregions.cpp ${CMAKE_CURRENT_LIST_DIR}/playerbotnavigation.cpp diff --git a/server/src/npc.cpp b/server/src/npc.cpp index 3393683..e76e893 100644 --- a/server/src/npc.cpp +++ b/server/src/npc.cpp @@ -112,6 +112,7 @@ void Npc::reset() parameters.clear(); shopOffers.clear(); + spellOffers.clear(); shopPlayerSet.clear(); spectators.clear(); } @@ -394,6 +395,22 @@ void Npc::addShopOffer(uint16_t itemId, int32_t subType, uint32_t buyPrice, uint it->sellPrice = sellPrice; } +void Npc::addSpellOffer(std::string spellName, std::string keyword, uint32_t price, uint32_t level, bool premium, + uint16_t vocationId) +{ + auto it = std::find_if(spellOffers.begin(), spellOffers.end(), [&spellName, &keyword, price, level, premium](const NpcSpellOffer& offer) { + return offer.spellName == spellName && offer.keyword == keyword && offer.price == price && offer.level == level && + offer.premium == premium; + }); + if (it == spellOffers.end()) { + spellOffers.push_back({std::move(spellName), std::move(keyword), price, level, premium, {vocationId}}); + return; + } + if (std::find(it->vocationIds.begin(), it->vocationIds.end(), vocationId) == it->vocationIds.end()) { + it->vocationIds.push_back(vocationId); + } +} + void Npc::onPlayerTrade(Player* player, int32_t callback, uint16_t itemId, uint8_t count, uint8_t amount, bool ignore/* = false*/, bool inBackpacks/* = false*/) { @@ -658,6 +675,7 @@ void NpcScriptInterface::registerFunctions() registerMethod("Npc", "getParameter", NpcScriptInterface::luaNpcGetParameter); registerMethod("Npc", "setFocus", NpcScriptInterface::luaNpcSetFocus); registerMethod("Npc", "addShopOffer", NpcScriptInterface::luaNpcAddShopOffer); + registerMethod("Npc", "addSpellOffer", NpcScriptInterface::luaNpcAddSpellOffer); registerMethod("Npc", "openShopWindow", NpcScriptInterface::luaNpcOpenShopWindow); registerMethod("Npc", "closeShopWindow", NpcScriptInterface::luaNpcCloseShopWindow); @@ -1044,6 +1062,17 @@ int NpcScriptInterface::luaNpcAddShopOffer(lua_State* L) return 0; } +int NpcScriptInterface::luaNpcAddSpellOffer(lua_State* L) +{ + // npc:addSpellOffer(spellName, keyword, price, level, premium, vocationId) + Npc* npc = getUserdata(L, 1); + if (npc) { + npc->addSpellOffer(getString(L, 2), getString(L, 3), getNumber(L, 4), getNumber(L, 5), + getBoolean(L, 6), getNumber(L, 7)); + } + return 0; +} + int NpcScriptInterface::luaNpcOpenShopWindow(lua_State* L) { // npc:openShopWindow(cid, items, buyCallback, sellCallback) diff --git a/server/src/npc.h b/server/src/npc.h index 8de9d54..9aca384 100644 --- a/server/src/npc.h +++ b/server/src/npc.h @@ -29,6 +29,15 @@ class Npc; class Player; +struct NpcSpellOffer { + std::string spellName; + std::string keyword; + uint32_t price; + uint32_t level; + bool premium; + std::vector vocationIds; +}; + class Npcs { public: @@ -62,6 +71,7 @@ class NpcScriptInterface final : public LuaScriptInterface static int luaNpcGetParameter(lua_State* L); static int luaNpcSetFocus(lua_State* L); static int luaNpcAddShopOffer(lua_State* L); + static int luaNpcAddSpellOffer(lua_State* L); static int luaNpcOpenShopWindow(lua_State* L); static int luaNpcCloseShopWindow(lua_State* L); @@ -156,6 +166,8 @@ class Npc final : public Creature onCreatureSay(creature, type, text); } void addShopOffer(uint16_t itemId, int32_t subType, uint32_t buyPrice, uint32_t sellPrice); + void addSpellOffer(std::string spellName, std::string keyword, uint32_t price, uint32_t level, bool premium, + uint16_t vocationId); bool doMoveTo(const Position& pos, int32_t minTargetDist = 1, int32_t maxTargetDist = 1, bool fullPathSearch = true, bool clearSight = true, int32_t maxSearchDist = 0); @@ -187,6 +199,7 @@ class Npc final : public Creature const auto& getSpectators() { return spectators; } const std::vector& getShopOffers() const { return shopOffers; } + const std::vector& getSpellOffers() const { return spellOffers; } const std::string* getParameter(const std::string& key) const { auto it = parameters.find(key); return it == parameters.end() ? nullptr : &it->second; @@ -229,6 +242,7 @@ class Npc final : public Creature std::map parameters; std::vector shopOffers; + std::vector spellOffers; std::set shopPlayerSet; std::set spectators; diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index e5e4286..61224e2 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -860,7 +860,7 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre processReadinessEquipment(player, currentPosition); return; } - if (cyclePhase != CyclePhase::Hunt || progressionObjective == ProgressionObjective::OracleDeparture) { + if (cyclePhase != CyclePhase::Hunt || progressionObjective != ProgressionObjective::None) { if (defensiveTargetId != 0) { processDefensiveCombat(player, currentPosition); return; @@ -896,7 +896,7 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre if (cyclePhase == CyclePhase::Hunt && (std::chrono::steady_clock::now() >= huntDeadline || player->getFreeCapacity() < returnCapacityThreshold)) { const char* reason = player->getFreeCapacity() < returnCapacityThreshold ? "capacity" : "hunt_deadline"; - if (testPolicy.progressionEnabled && !hasCompletedRookgaardDeparture(*player)) { + if (testPolicy.progressionEnabled) { finishHuntAndSelectGoal(player, currentPosition, reason); return; } else { diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index 93e47e1..784b598 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -30,7 +30,8 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment() std::strcmp(gameplayMode, "arbitration") == 0 || std::strcmp(gameplayMode, "arbitration_interrupt") == 0 || std::strcmp(gameplayMode, "departure") == 0 || - std::strcmp(gameplayMode, "departure_recovery") == 0); + std::strcmp(gameplayMode, "departure_recovery") == 0 || + std::strcmp(gameplayMode, "spell_training") == 0); const bool startInHunt = gameplayMode && (std::strcmp(gameplayMode, "navigation") == 0 || std::strcmp(gameplayMode, "corpse") == 0 || (std::strcmp(gameplayMode, "target_pursuit") == 0 || std::strcmp(gameplayMode, "target_pursuit_abandon") == 0) || @@ -89,8 +90,7 @@ void PlayerBotController::start(const Position& position, bool recovered, uint32 Player* controlledPlayer = g_game.getPlayerByID(playerId); const bool departureComplete = controlledPlayer && hasCompletedRookgaardDeparture(*controlledPlayer); const bool departureRequired = controlledPlayer && requiresRookgaardDeparture(*controlledPlayer); - const bool useGoalSelector = controlledPlayer && !departureComplete && - (departureRequired || (!recovered && testPolicy.progressionEnabled)); + const bool useGoalSelector = controlledPlayer && (departureRequired || (!recovered && testPolicy.progressionEnabled)); if (useGoalSelector && !selectTopLevelGoal(*controlledPlayer, position, "startup")) { return; } diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index aea10d5..8a5af76 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -76,12 +76,15 @@ namespace playerbot { inline constexpr std::chrono::minutes huntRegionCooldown(10); inline constexpr std::chrono::minutes pickupRewardSuccessCooldown(5); inline constexpr std::chrono::seconds pickupRewardFailureCooldown(60); + inline constexpr std::chrono::minutes spellTrainingSuccessCooldown(5); + inline constexpr std::chrono::seconds spellTrainingFailureCooldown(60); // Top-level utilities are comparable arbitration scores. Baselines encode the default priority: // critical healing > departure > capacity service > useful rewards > ordinary service > hunting > // economic pickup. Dynamic service and reward adjustments may cross these baselines. Equal scores // 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 economicPickupBaseUtility = 250; inline constexpr int32_t huntGoalUtility = 300; inline constexpr int32_t oracleDepartureUtility = 950; @@ -191,12 +194,14 @@ class PlayerBotController : public std::enable_shared_from_this& steps); + void beginSpellTraining(Player& player, const Position& position, SpellTrainingPlan plan, + std::deque steps); + void finishSpellTraining(Player* player, const Position& position, const char* result, const char* reason); + void processSpellTraining(Player* player, const Position& currentPosition); + const char* topLevelGoalName(TopLevelGoal goal) const; uint32_t saleableItemCount(const Player& player) const; @@ -734,11 +770,14 @@ class PlayerBotController : public std::enable_shared_from_this(reward.knownUtility) - static_cast(reward.travelSteps)) : 0; const GoalCandidate pickup{TopLevelGoal::PickupReward, pickupFound, pickupUtility, pickupCoolingDown ? "cooldown" : pickupFound ? "useful_reachable_reward" : "no_useful_reward"}; + SpellTrainingPlan spellTraining; + std::deque spellTrainingSteps; + const bool spellTrainingCoolingDown = spellTrainingCooldownUntil > now; + const bool spellTrainingFound = !spellTrainingCoolingDown && findSpellTraining(player, position, spellTraining, spellTrainingSteps); + const GoalCandidate learnSpell{TopLevelGoal::LearnSpell, spellTrainingFound, + spellTrainingFound ? spellTrainingGoalUtility : 0, + spellTrainingCoolingDown ? "cooldown" : spellTrainingFound ? "eligible_reachable_spell" : + "no_eligible_spell"}; const bool higherUtilityGoal = (departureCandidate.feasible && departureCandidate.utility > huntGoalUtility) || (service.feasible && service.utility > huntGoalUtility) || - (pickup.feasible && pickup.utility > huntGoalUtility); + (pickup.feasible && pickup.utility > huntGoalUtility) || + (learnSpell.feasible && learnSpell.utility > huntGoalUtility); const bool huntFeasible = !higherUtilityGoal; const GoalCandidate hunt{TopLevelGoal::Hunt, huntFeasible, huntGoalUtility, higherUtilityGoal ? "deferred_lower_utility" : @@ -1123,10 +1133,11 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos departureFound ? &departure : nullptr); emitGoalCandidate(player, service, position, decisionReason); emitGoalCandidate(player, pickup, position, decisionReason, pickupFound ? &reward : nullptr); + emitGoalCandidate(player, learnSpell, position, decisionReason); emitGoalCandidate(player, hunt, position, decisionReason); const GoalCandidate* selected = nullptr; - const std::array candidates = {&departureCandidate, &service, &pickup, &hunt}; + const std::array candidates = {&departureCandidate, &service, &pickup, &learnSpell, &hunt}; for (const GoalCandidate* candidate : candidates) { if (candidate->feasible && (!selected || candidate->utility > selected->utility)) { selected = candidate; @@ -1151,12 +1162,17 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos << ",\"vocation_id\":" << oracleVocationId; } else if (selected->goal == TopLevelGoal::PickupReward) { fields << ",\"candidate_id\":" << reward.uniqueId << ",\"item_id\":" << reward.itemId; + } else if (selected->goal == TopLevelGoal::LearnSpell) { + fields << ",\"npc_id\":" << spellTraining.npcId << ",\"spell\":" << jsonString(spellTraining.spellName) + << ",\"price\":" << spellTraining.price; } emit("goal_selection", position, fields.str()); if (selected->goal == TopLevelGoal::Departure) { beginOracleDeparture(player, position, std::move(departure), std::move(departureRoute)); } else if (selected->goal == TopLevelGoal::PickupReward) { beginPickupReward(player, position, std::move(reward), std::move(rewardSteps)); + } else if (selected->goal == TopLevelGoal::LearnSpell) { + beginSpellTraining(player, position, std::move(spellTraining), std::move(spellTrainingSteps)); } else if (selected->goal == TopLevelGoal::Service) { beginService(&player, position, "goal_selected"); } else { @@ -1168,7 +1184,8 @@ bool PlayerBotController::selectTopLevelGoal(Player& player, const Position& pos const char* PlayerBotController::objectiveName() const { return progressionObjective == ProgressionObjective::OracleDeparture ? "oracle_departure" : - progressionObjective == ProgressionObjective::PickupReward ? "pickup_reward" : cyclePhaseName(); + progressionObjective == ProgressionObjective::PickupReward ? "pickup_reward" : + progressionObjective == ProgressionObjective::LearnSpell ? "learn_spell" : cyclePhaseName(); } void PlayerBotController::finishProgressionObjective(Player* player, const Position& position, const char* result, const char* reason, @@ -1363,5 +1380,7 @@ void PlayerBotController::processProgression(Player* player, const Position& cur processOracleDeparture(player, currentPosition); } else if (progressionObjective == ProgressionObjective::PickupReward) { processPickupReward(player, currentPosition); + } else if (progressionObjective == ProgressionObjective::LearnSpell) { + processSpellTraining(player, currentPosition); } } diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index c46acf3..a8e35e6 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -968,7 +968,7 @@ void PlayerBotController::processFixtureDeposit(Player* player, const Position& } emit("action_result", currentPosition, "\"action\":\"deposit\",\"result\":\"complete\",\"fixture\":true,\"cycle\":" + std::to_string(completedCycles)); - if (testPolicy.progressionEnabled && !hasCompletedRookgaardDeparture(*player)) { + if (testPolicy.progressionEnabled) { selectTopLevelGoal(*player, currentPosition, "fixture_deposit_complete"); } else { startHunt(player, currentPosition, "fixture_deposit_complete"); @@ -1085,7 +1085,7 @@ void PlayerBotController::processDeposit(Player* player, const Position& current if (pauseDepotFixtureForRestart(*player, DepotRestartCheckpoint::Depart, currentPosition)) { return; } - if (testPolicy.progressionEnabled && !hasCompletedRookgaardDeparture(*player)) { + if (testPolicy.progressionEnabled) { emit("goal_result", currentPosition, "\"decision_id\":" + std::to_string(goalDecisionId) + ",\"goal\":\"service\",\"result\":\"success\",\"reason\":\"service_complete\""); diff --git a/server/src/playerbotspells.cpp b/server/src/playerbotspells.cpp new file mode 100644 index 0000000..2a55fdb --- /dev/null +++ b/server/src/playerbotspells.cpp @@ -0,0 +1,313 @@ +/** + * The Forgotten Server - a free and open-source MMORPG server emulator + * Copyright (C) 2019 Mark Samman + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + */ + +#include "otpch.h" + +#include "playerbotcontroller.h" +#include "spells.h" + +// Runtime spell-trainer discovery and normal NPC learning dialogue. +using namespace playerbot; + +extern Spells* g_spells; + +namespace { + constexpr uint32_t maximumSpellTrainerDistanceFromTemple = 200; +} + +uint64_t PlayerBotController::spellTrainingReserve(const Player& player) const +{ + uint32_t potionPrice = std::numeric_limits::max(); + uint32_t foodPrice = std::numeric_limits::max(); + 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 || *capability != "shop") { + continue; + } + for (const ShopInfo& offer : npc->getShopOffers()) { + if (offer.itemId == smallHealthPotionItemId && offer.buyPrice != 0) { + potionPrice = std::min(potionPrice, offer.buyPrice); + } else if (offer.itemId == meatItemId && offer.buyPrice != 0) { + foodPrice = std::min(foodPrice, offer.buyPrice); + } + } + } + if (potionPrice == std::numeric_limits::max() || foodPrice == std::numeric_limits::max()) { + return std::numeric_limits::max(); + } + return carriedGoldReserve + static_cast(minimumSmallHealthPotions) * potionPrice + + static_cast(minimumMeat) * foodPrice; +} + +void PlayerBotController::emitSpellCandidate(const Npc& npc, const NpcSpellOffer& offer, const Position& position, + const char* result, const char* reason, uint64_t reserve, + uint32_t travelSteps) const +{ + std::ostringstream fields; + fields << "\"goal\":\"learn_spell\",\"result\":" << jsonString(result) + << ",\"npc_id\":" << npc.getID() << ",\"npc_name\":" << jsonString(npc.getName()) + << ",\"spell\":" << jsonString(offer.spellName) << ",\"keyword\":" << jsonString(offer.keyword) + << ",\"price\":" << offer.price << ",\"level\":" << offer.level + << ",\"premium\":" << (offer.premium ? "true" : "false") << ",\"reserve\":" << reserve + << ",\"travel_steps\":" << travelSteps << ",\"provider_position\":{\"x\":" << npc.getPosition().x + << ",\"y\":" << npc.getPosition().y << ",\"z\":" << static_cast(npc.getPosition().z) << '}'; + if (reason) { + fields << ",\"reason\":" << jsonString(reason); + } + emit("spell_candidate", position, fields.str()); +} + +bool PlayerBotController::findSpellTraining(Player& player, const Position& position, SpellTrainingPlan& plan, + std::deque& selectedSteps) +{ + const uint64_t reserve = spellTrainingReserve(player); + const uint64_t totalMoney = player.getMoney() + player.getBankBalance(); + const uint16_t vocationId = player.getVocationId(); + const uint16_t baseVocationId = player.getVocation()->getFromVocation() == 0 ? vocationId : + player.getVocation()->getFromVocation(); + const bool suppliesReady = getInventoryItemCount(player, smallHealthPotionItemId) >= minimumSmallHealthPotions && + getInventoryItemCount(player, meatItemId) >= minimumMeat; + bool found = false; + + 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 || *capability != "spell_trainer") { + continue; + } + const bool inScope = serviceDistance(player.getTemplePosition(), {npc->getID(), npc->getPosition()}) <= + maximumSpellTrainerDistanceFromTemple; + emit("spell_trainer_discovered", position, "\"npc_id\":" + std::to_string(npc->getID()) + + ",\"npc_name\":" + jsonString(npc->getName()) + ",\"offers\":" + + std::to_string(npc->getSpellOffers().size()) + ",\"in_scope\":" + (inScope ? "true" : "false")); + bool routeEvaluated = false; + bool routeReachable = false; + Position trainerApproach; + std::deque trainerSteps; + auto findTrainerApproach = [&]() { + if (routeEvaluated) { + return routeReachable; + } + routeEvaluated = true; + std::vector approaches; + for (int32_t xOffset = -3; xOffset <= 3; ++xOffset) { + for (int32_t yOffset = -3; yOffset <= 3; ++yOffset) { + if (xOffset != 0 || yOffset != 0) { + approaches.emplace_back(npc->getPosition().x + xOffset, npc->getPosition().y + yOffset, + npc->getPosition().z); + } + } + } + std::sort(approaches.begin(), approaches.end(), [&position](const Position& left, const Position& right) { + const int32_t leftDistance = std::max(Position::getDistanceX(position, left), Position::getDistanceY(position, left)); + const int32_t rightDistance = std::max(Position::getDistanceX(position, right), Position::getDistanceY(position, right)); + return leftDistance == rightDistance ? left < right : leftDistance < rightDistance; + }); + for (const Position& approach : approaches) { + Tile* tile = g_game.map.getTile(approach); + if (!tile || tile->queryAdd(0, player, 1, 0) != RETURNVALUE_NOERROR) { + continue; + } + std::deque steps; + uint64_t expandedNodes = 0; + ++counters.pathfindingCalls; + const auto startedAt = std::chrono::steady_clock::now(); + const PlayerBotNavigationResult result = approach == position ? PlayerBotNavigationResult::Reached : + navigator.plan(player, approach, {}, steps, expandedNodes); + counters.pathfindingTimeUs += std::chrono::duration_cast( + std::chrono::steady_clock::now() - startedAt).count(); + if (result != PlayerBotNavigationResult::Reached || (approach != position && steps.empty())) { + ++counters.pathfindingFailures; + continue; + } + trainerApproach = approach; + trainerSteps = std::move(steps); + routeReachable = true; + return true; + } + 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; + } + SpellTrainingPlan candidate{npc->getID(), npc->getPosition(), trainerApproach, offer.spellName, offer.keyword, + offer.price, offer.level, static_cast(trainerSteps.size()), reserve}; + emitSpellCandidate(*npc, offer, position, "feasible", nullptr, reserve, candidate.travelSteps); + if (!found || candidate.price < plan.price || + (candidate.price == plan.price && (candidate.travelSteps < plan.travelSteps || + (candidate.travelSteps == plan.travelSteps && candidate.spellName < plan.spellName)))) { + plan = std::move(candidate); + selectedSteps = trainerSteps; + found = true; + } + } + } + return found; +} + +void PlayerBotController::beginSpellTraining(Player& player, const Position& position, SpellTrainingPlan plan, + std::deque steps) +{ + spellTrainingPlan = std::move(plan); + progressionObjective = ProgressionObjective::LearnSpell; + spellTrainingStage = SpellTrainingStage::Travel; + progressionAttempts = 0; + serviceTargetId = spellTrainingPlan.npcId; + serviceGreetingAcknowledged = false; + navigationTarget = spellTrainingPlan.approachPosition; + navigationSteps = std::move(steps); + emit("strategy_selection", position, "\"goal\":\"learn_spell\",\"npc_id\":" + + std::to_string(spellTrainingPlan.npcId) + ",\"spell\":" + jsonString(spellTrainingPlan.spellName) + + ",\"keyword\":" + jsonString(spellTrainingPlan.keyword) + ",\"price\":" + + std::to_string(spellTrainingPlan.price) + ",\"reserve\":" + std::to_string(spellTrainingPlan.reserve) + + ",\"travel_steps\":" + std::to_string(spellTrainingPlan.travelSteps)); + say(player, "Going to learn " + spellTrainingPlan.spellName + "."); +} + +void PlayerBotController::finishSpellTraining(Player* player, const Position& position, const char* result, const char* reason) +{ + emit("strategy_objective_result", position, "\"goal\":\"learn_spell\",\"spell\":" + + jsonString(spellTrainingPlan.spellName) + ",\"result\":" + jsonString(result) + ",\"reason\":" + + jsonString(reason)); + emit("goal_result", position, "\"decision_id\":" + std::to_string(goalDecisionId) + + ",\"goal\":\"learn_spell\",\"result\":" + jsonString(result) + ",\"reason\":" + jsonString(reason)); + if (player) { + say(*player, "Spell training " + std::string(result) + ": " + reason + '.'); + } + progressionObjective = ProgressionObjective::None; + spellTrainingStage = SpellTrainingStage::Travel; + spellTrainingPlan = SpellTrainingPlan{}; + serviceTargetId = 0; + clearNavigation(); + spellTrainingCooldownUntil = std::chrono::steady_clock::now() + + (std::strcmp(result, "success") == 0 ? spellTrainingSuccessCooldown : spellTrainingFailureCooldown); + if (player && testPolicy.progressionEnabled) { + selectTopLevelGoal(*player, position, std::strcmp(result, "success") == 0 ? "spell_training_complete" : "spell_training_failed"); + } + schedule(SCHEDULER_MINTICKS); +} + +void PlayerBotController::processSpellTraining(Player* player, const Position& currentPosition) +{ + if (spellTrainingStage == SpellTrainingStage::Travel) { + if (!processNavigation(player, currentPosition, spellTrainingPlan.approachPosition)) { + if (fixedTargetRouteFailureCount >= maximumProgressionAttempts) { + finishSpellTraining(player, currentPosition, "failed", "route_unavailable"); + } + return; + } + spellTrainingStage = SpellTrainingStage::Greet; + schedule(SCHEDULER_MINTICKS); + return; + } + + Npc* trainer = g_game.getNpcByID(spellTrainingPlan.npcId); + if (!trainer || trainer->isRemoved() || !Position::areInRange<3, 3, 0>(currentPosition, trainer->getPosition())) { + finishSpellTraining(player, currentPosition, "failed", "trainer_unavailable"); + return; + } + if (spellTrainingStage == SpellTrainingStage::Greet) { + serviceGreetingAcknowledged = false; + ++counters.actionsAttempted; + trainer->receiveSpeech(player, TALKTYPE_PRIVATE_PN, "hi"); + spellTrainingStage = SpellTrainingStage::Request; + schedule(1000); + return; + } + if (spellTrainingStage == SpellTrainingStage::Request) { + if (!serviceGreetingAcknowledged) { + if (++progressionAttempts >= maximumProgressionAttempts) { + finishSpellTraining(player, currentPosition, "failed", "trainer_focus_unconfirmed"); + return; + } + spellTrainingStage = SpellTrainingStage::Greet; + schedule(1000); + return; + } + ++counters.actionsAttempted; + trainer->receiveSpeech(player, TALKTYPE_PRIVATE_PN, spellTrainingPlan.keyword); + spellTrainingStage = SpellTrainingStage::Confirm; + schedule(1000); + return; + } + if (spellTrainingStage == SpellTrainingStage::Confirm) { + spellTrainingPlan.moneyBefore = player->getMoney() + player->getBankBalance(); + ++counters.actionsAttempted; + trainer->receiveSpeech(player, TALKTYPE_PRIVATE_PN, "yes"); + spellTrainingStage = SpellTrainingStage::Verify; + schedule(1000); + return; + } + + const uint64_t totalMoney = player->getMoney() + player->getBankBalance(); + const bool learned = player->hasLearnedInstantSpell(spellTrainingPlan.spellName); + if (learned && spellTrainingPlan.moneyBefore >= spellTrainingPlan.price && + totalMoney == spellTrainingPlan.moneyBefore - spellTrainingPlan.price) { + emit("action_result", currentPosition, "\"action\":\"learn_spell\",\"result\":\"success\",\"spell\":" + + jsonString(spellTrainingPlan.spellName) + ",\"price\":" + std::to_string(spellTrainingPlan.price) + + ",\"money_before\":" + std::to_string(spellTrainingPlan.moneyBefore) + ",\"money_after\":" + + std::to_string(totalMoney)); + finishSpellTraining(player, currentPosition, "success", "learned_state_and_payment_verified"); + return; + } + if (learned || totalMoney != spellTrainingPlan.moneyBefore) { + finishSpellTraining(player, currentPosition, "failed", "transaction_delta_mismatch"); + return; + } + if (++progressionAttempts >= maximumProgressionAttempts) { + finishSpellTraining(player, currentPosition, "failed", "learning_not_verified"); + return; + } + spellTrainingStage = SpellTrainingStage::Greet; + schedule(1000); +} diff --git a/server/tests/playerbot-gameplay/playerbot_gameplay.lua b/server/tests/playerbot-gameplay/playerbot_gameplay.lua index 3a01dec..e9d7dba 100644 --- a/server/tests/playerbot-gameplay/playerbot_gameplay.lua +++ b/server/tests/playerbot-gameplay/playerbot_gameplay.lua @@ -27,6 +27,7 @@ local nestedRewardShieldId = 2512 local economicRewardStorage = 50082 local departureRecoveryStorage = 50090 local depotFixtureStorage = 50095 +local spellTrainingStorage = 50097 local deathLoginCount = 0 local removeAll @@ -102,6 +103,22 @@ local function verifyOracleDeparture(playerId, attempts) print("PLAYERBOT_GAMEPLAY_TEST ORACLE_DEPARTURE_PASS") end +local function verifySpellTraining(playerId, attempts) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared during spell training") + local complete = player:hasLearnedSpell("Find Person") and player:getMoney() + player:getBankBalance() == 220 + if not complete and attempts > 0 then + addEvent(verifySpellTraining, 500, playerId, attempts - 1) + return + end + assert(player:hasLearnedSpell("Find Person"), "spell training did not learn Find Person") + assert(player:getMoney() + player:getBankBalance() == 220, "spell training charged an unexpected amount") + assert(player:getItemCount(potionItemId) >= 5 and player:getItemCount(meatItemId) >= 1, + "spell training spent the recovery supplies") + assert(player:setStorageValue(spellTrainingStorage, 1), "spell training completion marker could not persist") + print("PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_PASS") +end + local function removeNearbyMonsters(player) for _, creature in ipairs(Game.getSpectators(player:getPosition(), true, false, 10, 10, 10, 10)) do if creature:isMonster() then @@ -438,7 +455,7 @@ function login.onLogin(player) mode == "progression_resume" or mode == "progression_nested_resume" or mode == "progression_space" or mode == "arbitration" or mode == "arbitration_interrupt" or mode == "departure" or mode == "departure_interrupt" or - mode == "departure_recovery" or mode == "stamina_bonus" or mode == "stamina_boundary" or + mode == "departure_recovery" or mode == "spell_training" or mode == "stamina_bonus" or mode == "stamina_boundary" or mode == "stamina_normal" or mode == "hunt_planning" or mode == "readiness_ready" or mode == "readiness_upgrade" or mode == "readiness_missing_weapon" or mode == "readiness_supplies" or mode == "readiness_retention", @@ -607,6 +624,35 @@ function login.onLogin(player) print("PLAYERBOT_GAMEPLAY_TEST PICKUP_PROGRESSION_BUNDLE_START") return true end + if mode == "spell_training" then + if player:getStorageValue(spellTrainingStorage) == 1 then + assert(player:hasLearnedSpell("Find Person"), "spell training did not persist across restart") + print("PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_RESTART_PASS") + return true + end + local thais = Town(thaisTownId) + assert(thais and player:setTown(thais), "spell training fixture could not select Thais") + assert(player:setVocation(4), "spell training fixture could not select Knight") + local requiredExperience = Game.getExperienceForLevel(8) - player:getExperience() + if requiredExperience > 0 then player:addExperience(requiredExperience) end + assert(player:getLevel() == 8, "spell training fixture could not select level 8") + assert(player:teleportTo(thais:getTemplePosition()), "spell training fixture could not reach the Thais temple") + for _, spellName in ipairs({"Find Person", "Light", "Light Healing", "Cure Poison", "Great Light"}) do + player:forgetSpell(spellName) + end + removeAll(player, potionItemId) + removeAll(player, meatItemId) + assert(player:addItem(potionItemId, 5), "spell training fixture could not supply potions") + assert(player:addItem(meatItemId, 1), "spell training fixture could not supply food") + local totalMoney = player:getMoney() + player:getBankBalance() + if totalMoney < 300 then assert(player:addMoney(300 - totalMoney), "spell training fixture could not fund Bot One") end + if totalMoney > 300 then assert(player:removeTotalMoney(totalMoney - 300), "spell training fixture could not normalize funding") end + assert(player:getMoney() + player:getBankBalance() == 300, "spell training fixture has the wrong starting money") + suppressNearbyMonsters(player:getId()) + addEvent(verifySpellTraining, 500, player:getId(), 360) + print("PLAYERBOT_GAMEPLAY_TEST SPELL_TRAINING_START") + return true + end if mode == "progression_nested" then assert(player:setStorageValue(50082, 1), "nested progression fixture could not suppress the nearby currency reward")