Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 20 additions & 7 deletions docs/playerbots.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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. |
Expand Down
1 change: 1 addition & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
52 changes: 51 additions & 1 deletion scripts/test-playerbot-gameplay.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ param(
[switch]$CombatReadiness,
[switch]$Depot,
[switch]$MainlandLoop,
[switch]$SpellTraining,
[switch]$Focused,
[switch]$SkipBuild,
[switch]$KeepStack
Expand Down Expand Up @@ -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."
}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions server/data/npc/Gregor.xml
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<npc name="Gregor" script="Gregor.lua" walkinterval="2000" floorchange="0">
<parameters>
<parameter key="playerbot_service" value="spell_trainer" />
</parameters>
<health now="100" max="100" />
<look type="131" head="38" body="38" legs="38" feet="38" addons="3" />
</npc>
8 changes: 8 additions & 0 deletions server/data/npc/lib/npcsystem/keywordhandler.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
1 change: 1 addition & 0 deletions server/src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions server/src/npc.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ void Npc::reset()

parameters.clear();
shopOffers.clear();
spellOffers.clear();
shopPlayerSet.clear();
spectators.clear();
}
Expand Down Expand Up @@ -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*/)
{
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<Npc>(L, 1);
if (npc) {
npc->addSpellOffer(getString(L, 2), getString(L, 3), getNumber<uint32_t>(L, 4), getNumber<uint32_t>(L, 5),
getBoolean(L, 6), getNumber<uint16_t>(L, 7));
}
return 0;
}

int NpcScriptInterface::luaNpcOpenShopWindow(lua_State* L)
{
// npc:openShopWindow(cid, items, buyCallback, sellCallback)
Expand Down
14 changes: 14 additions & 0 deletions server/src/npc.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<uint16_t> vocationIds;
};

class Npcs
{
public:
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -187,6 +199,7 @@ class Npc final : public Creature

const auto& getSpectators() { return spectators; }
const std::vector<ShopInfo>& getShopOffers() const { return shopOffers; }
const std::vector<NpcSpellOffer>& 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;
Expand Down Expand Up @@ -229,6 +242,7 @@ class Npc final : public Creature

std::map<std::string, std::string> parameters;
std::vector<ShopInfo> shopOffers;
std::vector<NpcSpellOffer> spellOffers;

std::set<Player*> shopPlayerSet;
std::set<Player*> spectators;
Expand Down
4 changes: 2 additions & 2 deletions server/src/playerbotcombat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions server/src/playerbotcontroller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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) ||
Expand Down Expand Up @@ -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;
}
Expand Down
Loading