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")