diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index 981799d..c72ae3e 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -10,7 +10,8 @@ param( [switch]$GoalArbitration, [switch]$OracleDeparture, [switch]$StaminaProjection, - [switch]$HuntRegionPlanning, + [switch]$HuntRegionPlanning, + [switch]$CombatReadiness, [switch]$Focused, [switch]$SkipBuild, [switch]$KeepStack @@ -495,7 +496,7 @@ function Assert-HealingResupplyEvents { $_.reason -eq "transaction_delta_mismatch" -or $_.reason -eq "shop_transaction_delta_mismatch" }) if ($missingSupply.Count -ne 1 -or $flaskSales.Count -ne 1 -or $purchases.Count -lt 1 -or $heals.Count -lt 1) { - throw "The bot did not refill and consume potions after the missing-supply healing outcome." + throw "The bot did not refill and consume potions after the missing-supply healing outcome: missing=$($missingSupply.Count), flaskSales=$($flaskSales.Count), purchases=$($purchases.Count), heals=$($heals.Count)." } if ($serviceResumed.Count -lt 1) { throw "The bot did not resume service after healing with newly purchased potions." @@ -866,6 +867,47 @@ function Assert-HuntRegionPlanningEvents { } } +function Assert-CombatReadinessEvents { + param([string]$Logs, [string]$Mode) + + $events = @(ConvertFrom-PlayerbotLogs -Logs $Logs) + $readiness = @($events | Where-Object { + $_.event -eq "combat_readiness" -and $_.vocation_id -eq 4 -and $_.requirements.Count -eq 5 + }) + if ($readiness.Count -lt 1) { + throw "Combat readiness emitted no complete Knight requirement evidence for $Mode." + } + $latest = $readiness[-1] + if ($Mode -eq "missing_weapon") { + $terminal = @($events | Where-Object { + $_.event -eq "terminal" -and $_.reason -eq "combat_readiness_missing_legal_melee_weapon" + }) + $hunts = @($events | Where-Object { $_.action -eq "hunt_cycle" -or $_.reason -eq "visible_monster" }) + if ($terminal.Count -ne 1 -or $hunts.Count -ne 0 -or $latest.terminal_reason -ne "missing_legal_melee_weapon") { + throw "Missing weapon did not produce the terminal no-naked-hunt state." + } + return + } + $ready = @($readiness | Where-Object { $_.result -eq "ready" -and @($_.requirements | Where-Object { -not $_.ready }).Count -eq 0 }) + if ($ready.Count -lt 1) { + throw "Combat readiness did not reach a fully evidenced ready state for $Mode." + } + if ($Mode -eq "upgrade") { + $equip = @($events | Where-Object { $_.event -eq "action_result" -and $_.action -eq "equip_readiness" -and $_.result -eq "success" -and $_.item_id -eq 2384 }) + if ($equip.Count -ne 1) { throw "Carried legal weapon was not equipped and verified." } + } + if ($Mode -eq "supplies") { + $service = @($events | Where-Object { $_.event -eq "combat_readiness" -and $_.selected_recovery -eq "service" }) + $potions = @($events | Where-Object { $_.action -eq "buy_potions" -and $_.result -eq "success" }) + $food = @($events | Where-Object { $_.action -eq "buy_meat" -and $_.result -eq "success" }) + if ($service.Count -lt 1 -or $potions.Count -lt 1 -or $food.Count -lt 1) { throw "Missing supplies did not select and complete service recovery." } + } + if ($Mode -eq "retention") { + $depositedUnknown = @($events | Where-Object { $_.action -eq "deposit" -and $_.item_id -eq 2050 }) + if ($depositedUnknown.Count -ne 0) { throw "Depot policy deposited an unknown retained item." } + } +} + function Assert-GoalArbitrationInterruptEvents { param([string]$Logs) @@ -978,7 +1020,7 @@ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { } $focusedScenarioRequested = $FullNavigation -or $CorpseLoot -or $DeathTelemetry -or $Healing -or $ValueLoot -or - $PickupProgression -or $GoalArbitration -or $OracleDeparture -or $StaminaProjection -or $HuntRegionPlanning + $PickupProgression -or $GoalArbitration -or $OracleDeparture -or $StaminaProjection -or $HuntRegionPlanning -or $CombatReadiness if ($Focused -and -not $focusedScenarioRequested) { throw "-Focused requires at least one focused scenario switch." } @@ -1138,6 +1180,58 @@ try { } } + if ($CombatReadiness) { + Invoke-Scenario -Name "combat_readiness_ready" -DefaultTimeoutSeconds 60 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "readiness_ready" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST READINESS_READY_PASS' | Out-Null + $readyLogs = Wait-ForLog -Pattern '"event":"combat_readiness".*"result":"ready"' + Assert-CombatReadinessEvents -Logs $readyLogs -Mode "ready" + $restartLineCount = @((Get-ServerLogs) -split "`r?`n").Count + Invoke-Compose stop server + Invoke-Compose up --detach server + $restartLogs = "" + for ($attempt = 0; $attempt -lt 30; $attempt++) { + Start-Sleep -Seconds 1 + $restartLogs = ((Get-ServerLogs) -split "`r?`n" | Select-Object -Skip $restartLineCount) -join "`n" + if ($restartLogs -match '"event":"combat_readiness".*"vocation_id":4') { break } + } + Assert-CombatReadinessEvents -Logs $restartLogs -Mode "ready" + } + Invoke-Scenario -Name "combat_readiness_upgrade" -DefaultTimeoutSeconds 60 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "readiness_upgrade" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST READINESS_UPGRADE_PASS' | Out-Null + Assert-CombatReadinessEvents -Logs (Get-ServerLogs) -Mode "upgrade" + } + Invoke-Scenario -Name "combat_readiness_missing_weapon" -DefaultTimeoutSeconds 45 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "readiness_missing_weapon" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST READINESS_MISSING_WEAPON_START' | Out-Null + $missingWeaponLogs = Wait-ForLog -Pattern '"reason":"combat_readiness_missing_legal_melee_weapon"' + Assert-CombatReadinessEvents -Logs $missingWeaponLogs -Mode "missing_weapon" + } + Invoke-Scenario -Name "combat_readiness_supplies" -DefaultTimeoutSeconds 120 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "readiness_supplies" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST READINESS_SUPPLIES_PASS' | Out-Null + $supplyLogs = Wait-ForLog -Pattern '"event":"combat_readiness".*"result":"ready"' + Assert-CombatReadinessEvents -Logs $supplyLogs -Mode "supplies" + } + Invoke-Scenario -Name "combat_readiness_retention" -DefaultTimeoutSeconds 120 -Body { + Invoke-Compose down --volumes --remove-orphans + $env:PLAYERBOT_GAMEPLAY_MODE = "readiness_retention" + Invoke-Compose up --detach + Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST READINESS_RETENTION_PASS' | Out-Null + $retentionLogs = Wait-ForLog -Pattern '"event":"combat_readiness".*"result":"ready"' + Assert-CombatReadinessEvents -Logs $retentionLogs -Mode "retention" + } + } + if ($OracleDeparture) { Invoke-Scenario -Name "oracle_departure" -DefaultTimeoutSeconds 180 -Body { Invoke-Compose down --volumes --remove-orphans diff --git a/server/schema/insertPlayerbots.sql b/server/schema/insertPlayerbots.sql index 1e37b3f..dbe0ca6 100644 --- a/server/schema/insertPlayerbots.sql +++ b/server/schema/insertPlayerbots.sql @@ -2,8 +2,8 @@ INSERT INTO `accounts` (`name`, `password`, `type`, `premium_ends_at`, `email`, VALUES ('bot-one', SHA1('bot-one'), 1, 0, '', 0) ON DUPLICATE KEY UPDATE `id` = `id`; --- These local-development defaults start normal Rookgaard progression. Focused --- gameplay fixtures add any stronger equipment their scenario requires. +-- This idempotent kit remains valid through the deterministic Rookgaard-to-Knight +-- transition. Focused gameplay fixtures add any stronger equipment they need. CREATE TABLE IF NOT EXISTS `player_bots` ( `player_id` int NOT NULL, @@ -60,10 +60,10 @@ SET @bot_next_sid = ( ); INSERT INTO `player_items` (`player_id`, `pid`, `sid`, `itemtype`, `count`, `attributes`) -SELECT @bot_player_id, `loadout`.`pid`, @bot_next_sid + `loadout`.`offset`, `loadout`.`itemtype`, 1, '' +SELECT @bot_player_id, `loadout`.`pid`, @bot_next_sid + `loadout`.`offset`, `loadout`.`itemtype`, `loadout`.`count`, '' FROM ( - SELECT 4 AS `pid`, 1 AS `offset`, 2650 AS `itemtype` - UNION ALL SELECT 6, 2, 2382 + SELECT 4 AS `pid`, 1 AS `offset`, 2650 AS `itemtype`, 1 AS `count` + UNION ALL SELECT 6, 2, 2382, 1 ) AS `loadout` WHERE NOT EXISTS ( SELECT 1 FROM `player_items` @@ -83,9 +83,29 @@ FROM ( WHERE @bot_backpack_sid IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM `player_items` - WHERE `player_id` = @bot_player_id AND `itemtype` = `tools`.`itemtype` + WHERE `player_id` = @bot_player_id AND `itemtype` = `tools`.`itemtype` ); +SET @bot_next_sid = ( + SELECT COALESCE(MAX(`sid`), 100) FROM `player_items` WHERE `player_id` = @bot_player_id +); + +INSERT INTO `player_items` (`player_id`, `pid`, `sid`, `itemtype`, `count`, `attributes`) +SELECT @bot_player_id, @bot_backpack_sid, @bot_next_sid + `supplies`.`offset`, `supplies`.`itemtype`, `supplies`.`count`, '' +FROM ( + SELECT 1 AS `offset`, 8704 AS `itemtype`, 1 AS `count` + UNION ALL SELECT 2, 8704, 1 + UNION ALL SELECT 3, 8704, 1 + UNION ALL SELECT 4, 8704, 1 + UNION ALL SELECT 5, 8704, 1 + UNION ALL SELECT 6, 2666, 1 +) AS `supplies` +WHERE @bot_backpack_sid IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM `player_items` + WHERE `player_id` = @bot_player_id AND `itemtype` = `supplies`.`itemtype` + ); + -- Seed the initial purse once; later provisioning runs preserve spent money. SET @bot_next_sid = ( SELECT COALESCE(MAX(`sid`), 100) FROM `player_items` WHERE `player_id` = @bot_player_id diff --git a/server/src/items.h b/server/src/items.h index 70a7d17..500267f 100644 --- a/server/src/items.h +++ b/server/src/items.h @@ -25,6 +25,8 @@ #include "itemloader.h" #include "position.h" +#include + enum SlotPositionBits : uint32_t { SLOTP_WHEREEVER = 0xFFFFFFFF, SLOTP_HEAD = 1 << 0, @@ -311,6 +313,7 @@ class ItemType std::string description; std::string runeSpellName; std::string vocationString; + std::set vocationIds; std::unique_ptr abilities; std::unique_ptr conditionDamage; diff --git a/server/src/luascript.cpp b/server/src/luascript.cpp index 85389f5..b3dae6f 100644 --- a/server/src/luascript.cpp +++ b/server/src/luascript.cpp @@ -16309,6 +16309,10 @@ int LuaScriptInterface::luaWeaponRegister(lua_State* L) it.vocationString = weapon->getVocationString(); it.minReqLevel = weapon->getReqLevel(); it.minReqMagicLevel = weapon->getReqMagLv(); + it.vocationIds.clear(); + for (const auto& vocation : weapon->vocWeaponMap) { + it.vocationIds.insert(vocation.first); + } } weapon->configureWeapon(it); diff --git a/server/src/movement.cpp b/server/src/movement.cpp index 3050beb..96d5e19 100644 --- a/server/src/movement.cpp +++ b/server/src/movement.cpp @@ -131,6 +131,10 @@ bool MoveEvents::registerEvent(Event_ptr event, const pugi::xml_node& node) it.minReqLevel = moveEvent->getReqLevel(); it.minReqMagicLevel = moveEvent->getReqMagLv(); it.vocationString = moveEvent->getVocationString(); + it.vocationIds.clear(); + for (const auto& vocation : moveEvent->getVocEquipMap()) { + it.vocationIds.insert(vocation.first); + } } addEvent(std::move(*moveEvent), id, itemIdMap); } @@ -146,6 +150,10 @@ bool MoveEvents::registerEvent(Event_ptr event, const pugi::xml_node& node) it.minReqLevel = moveEvent->getReqLevel(); it.minReqMagicLevel = moveEvent->getReqMagLv(); it.vocationString = moveEvent->getVocationString(); + it.vocationIds.clear(); + for (const auto& vocation : moveEvent->getVocEquipMap()) { + it.vocationIds.insert(vocation.first); + } while (++id <= endId) { addEvent(*moveEvent, id, itemIdMap); @@ -155,6 +163,10 @@ bool MoveEvents::registerEvent(Event_ptr event, const pugi::xml_node& node) tit.minReqLevel = moveEvent->getReqLevel(); tit.minReqMagicLevel = moveEvent->getReqMagLv(); tit.vocationString = moveEvent->getVocationString(); + tit.vocationIds.clear(); + for (const auto& vocation : moveEvent->getVocEquipMap()) { + tit.vocationIds.insert(vocation.first); + } } } else { while (++id <= endId) { @@ -231,6 +243,10 @@ bool MoveEvents::registerLuaFunction(MoveEvent* event) it.minReqLevel = moveEvent->getReqLevel(); it.minReqMagicLevel = moveEvent->getReqMagLv(); it.vocationString = moveEvent->getVocationString(); + it.vocationIds.clear(); + for (const auto& vocation : moveEvent->getVocEquipMap()) { + it.vocationIds.insert(vocation.first); + } } } else { uint32_t iterId = 0; @@ -241,6 +257,10 @@ bool MoveEvents::registerLuaFunction(MoveEvent* event) it.minReqLevel = moveEvent->getReqLevel(); it.minReqMagicLevel = moveEvent->getReqMagLv(); it.vocationString = moveEvent->getVocationString(); + it.vocationIds.clear(); + for (const auto& vocation : moveEvent->getVocEquipMap()) { + it.vocationIds.insert(vocation.first); + } } addEvent(*moveEvent, moveEvent->getItemIdRange().at(iterId), itemIdMap); } @@ -281,6 +301,10 @@ bool MoveEvents::registerLuaEvent(MoveEvent* event) it.minReqLevel = moveEvent->getReqLevel(); it.minReqMagicLevel = moveEvent->getReqMagLv(); it.vocationString = moveEvent->getVocationString(); + it.vocationIds.clear(); + for (const auto& vocation : moveEvent->getVocEquipMap()) { + it.vocationIds.insert(vocation.first); + } } } else { auto v = moveEvent->getItemIdRange(); @@ -291,6 +315,10 @@ bool MoveEvents::registerLuaEvent(MoveEvent* event) it.minReqLevel = moveEvent->getReqLevel(); it.minReqMagicLevel = moveEvent->getReqMagLv(); it.vocationString = moveEvent->getVocationString(); + it.vocationIds.clear(); + for (const auto& vocation : moveEvent->getVocEquipMap()) { + it.vocationIds.insert(vocation.first); + } } addEvent(*moveEvent, *i, itemIdMap); } diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index 150df81..7765e28 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -747,6 +747,9 @@ void PlayerBotController::startHunt(Player* player, const Position& position, co stop("controlled_player_not_found", position); return; } + if (!ensureCombatReady(player, position, reason)) { + return; + } activeGoal = TopLevelGoal::Hunt; setCyclePhase(CyclePhase::Hunt, position, reason); huntRouteIndex = 0; @@ -761,6 +764,13 @@ void PlayerBotController::startHunt(Player* player, const Position& position, co void PlayerBotController::processTraversal(Player* player, const Position& currentPosition) { + if (readinessEquipmentPending) { + processReadinessEquipment(player, currentPosition); + return; + } + if (cyclePhase == CyclePhase::Hunt && !ensureCombatReady(player, currentPosition, "readiness_continuous_check")) { + return; + } if (cyclePhase != CyclePhase::Hunt || progressionObjective == ProgressionObjective::OracleDeparture) { if (defensiveTargetId != 0) { processDefensiveCombat(player, currentPosition); diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index f244da8..4e76db3 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -36,7 +36,10 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment() std::strcmp(gameplayMode, "healing") == 0 || std::strcmp(gameplayMode, "healing_resupply") == 0 || std::strcmp(gameplayMode, "value") == 0 || std::strcmp(gameplayMode, "departure_interrupt") == 0 || std::strcmp(gameplayMode, "stamina_bonus") == 0 || std::strcmp(gameplayMode, "stamina_boundary") == 0 || - std::strcmp(gameplayMode, "stamina_normal") == 0 || std::strcmp(gameplayMode, "hunt_planning") == 0); + std::strcmp(gameplayMode, "stamina_normal") == 0 || std::strcmp(gameplayMode, "hunt_planning") == 0 || + std::strcmp(gameplayMode, "readiness_ready") == 0 || std::strcmp(gameplayMode, "readiness_upgrade") == 0 || + std::strcmp(gameplayMode, "readiness_missing_weapon") == 0 || std::strcmp(gameplayMode, "readiness_supplies") == 0 || + std::strcmp(gameplayMode, "readiness_retention") == 0); const bool fixedFixtureRoute = gameplayMode && std::strcmp(gameplayMode, "stamina_bonus") != 0 && std::strcmp(gameplayMode, "stamina_boundary") != 0 && std::strcmp(gameplayMode, "stamina_normal") != 0 && @@ -219,7 +222,9 @@ uint32_t PlayerBotController::protectedItemReserve(uint16_t itemId) const uint32_t PlayerBotController::getSaleItemCount(const Player& player, uint16_t itemId) const { const ItemType& type = Item::items[itemId]; - if ((type.isContainer() && type.corpseType == RACE_NONE) || type.isFluidContainer() || type.isSplash()) { + if ((type.isContainer() && type.corpseType == RACE_NONE) || type.isFluidContainer() || type.isSplash() || + type.weaponType != WEAPON_NONE || type.armor > 0 || type.defense > 0 || + (type.slotPosition & (SLOTP_HEAD | SLOTP_ARMOR | SLOTP_LEGS | SLOTP_FEET)) != 0) { return 0; } Item* backpackItem = player.getInventoryItem(CONST_SLOT_BACKPACK); diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index 107bbc0..9c369d4 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -404,6 +404,18 @@ class PlayerBotController : public std::enable_shared_from_this rewardInspectionFingerprints; uint16_t pendingEquipmentItemId = 0; uint32_t pendingEquipmentItemCount = 0; + uint16_t pendingReadinessItemId = 0; + slots_t pendingReadinessSlot = CONST_SLOT_WHEREEVER; + uint32_t pendingReadinessAttempts = 0; + bool readinessEquipmentPending = false; std::vector serviceShops; std::vector serviceBankers; uint32_t serviceTargetId = 0; diff --git a/server/src/playerbotloot.cpp b/server/src/playerbotloot.cpp index 6249417..91b8520 100644 --- a/server/src/playerbotloot.cpp +++ b/server/src/playerbotloot.cpp @@ -116,9 +116,8 @@ uint8_t PlayerBotController::backpackDestinationIndex(const Container& backpack, bool PlayerBotController::isReplaceableCargo(const Item& item) const { const ItemType& type = Item::items[item.getID()]; - const Container* container = item.getContainer(); - return (!container || (type.corpseType != RACE_NONE && container->empty())) && item.getWorth() == 0 && protectedItemReserve(item.getID()) == 0 && - itemUnitValue(item.getID()) != 0 && item.getBaseWeight() != 0; + return !isProtectedInventoryItem(item) && type.corpseType == RACE_NONE && itemUnitValue(item.getID()) != 0 && + item.getBaseWeight() != 0; } bool PlayerBotController::chooseCargoReplacement(const Container& backpack, const Item& incoming, uint32_t freeCapacity, diff --git a/server/src/playerbotprogression.cpp b/server/src/playerbotprogression.cpp index d973fa9..55aa662 100644 --- a/server/src/playerbotprogression.cpp +++ b/server/src/playerbotprogression.cpp @@ -18,6 +18,9 @@ using namespace playerbot; std::optional PlayerBotController::evaluateEquipmentUpgrade(const Player& player, const Item& candidate) const { const ItemType& type = Item::items[candidate.getID()]; + if (!isLegalEquipmentItem(player, candidate)) { + return std::nullopt; + } slots_t slot = CONST_SLOT_WHEREEVER; const char* metric = nullptr; int32_t candidateValue = 0; @@ -47,7 +50,7 @@ std::optional PlayerBotController::evalua metric = "attack"; candidateValue = candidate.getAttack(); } - if (slot == CONST_SLOT_WHEREEVER || candidateValue <= 0 || player.getLevel() < type.minReqLevel) { + if (slot == CONST_SLOT_WHEREEVER || candidateValue <= 0) { return std::nullopt; } @@ -68,6 +71,284 @@ std::optional PlayerBotController::evalua return EquipmentUpgrade{slot, candidateValue - currentValue, metric, currentValue, candidateValue}; } +bool PlayerBotController::requiresKnightCombatReadiness(const Player& player) const +{ + return player.getVocationId() == oracleVocationId; +} + +bool PlayerBotController::isLegalEquipmentItem(const Player& player, const Item& item) const +{ + const ItemType& type = Item::items[item.getID()]; + if (!item.isPickupable() || player.getLevel() < type.minReqLevel || + player.getMagicLevel() < type.minReqMagicLevel || + ((type.wieldInfo & WIELDINFO_PREMIUM) != 0 && !player.isPremium())) { + return false; + } + return type.vocationIds.empty() || type.vocationIds.find(player.getVocationId()) != type.vocationIds.end(); +} + +bool PlayerBotController::isKnightMeleeWeapon(const Player& player, const Item& item) const +{ + const WeaponType_t weaponType = item.getWeaponType(); + const int32_t slots = item.getSlotPosition(); + return isLegalEquipmentItem(player, item) && item.getAttack() > 0 && + (weaponType == WEAPON_SWORD || weaponType == WEAPON_CLUB || weaponType == WEAPON_AXE) && + (slots & (SLOTP_LEFT | SLOTP_RIGHT)) != 0; +} + +bool PlayerBotController::isCombatEquipment(const Item& item) const +{ + const ItemType& type = Item::items[item.getID()]; + return (type.slotPosition & (SLOTP_HEAD | SLOTP_ARMOR | SLOTP_LEGS | SLOTP_FEET)) != 0 || + type.weaponType != WEAPON_NONE || item.getArmor() > 0 || item.getDefense() > 0; +} + +bool PlayerBotController::isProtectedInventoryItem(const Item& item) const +{ + const ItemType& type = Item::items[item.getID()]; + return type.isContainer() || isCombatEquipment(item) || item.getID() == ropeItemId || item.getID() == 2554 || + item.getID() == meatItemId || item.getID() == smallHealthPotionItemId || item.getWorth() != 0 || + itemSellValues.find(item.getID()) == itemSellValues.end(); +} + +bool PlayerBotController::isCombatReady(const Player& player, std::string& recovery, std::string& terminalReason) const +{ + recovery.clear(); + terminalReason.clear(); + if (!requiresKnightCombatReadiness(player)) { + return true; + } + Item* left = player.getInventoryItem(CONST_SLOT_LEFT); + Item* right = player.getInventoryItem(CONST_SLOT_RIGHT); + Item* armor = player.getInventoryItem(CONST_SLOT_ARMOR); + Item* backpack = player.getInventoryItem(CONST_SLOT_BACKPACK); + const bool weaponReady = (left && isKnightMeleeWeapon(player, *left)) || (right && isKnightMeleeWeapon(player, *right)); + const bool armorReady = armor && isLegalEquipmentItem(player, *armor) && + (armor->getSlotPosition() & SLOTP_ARMOR) != 0 && armor->getArmor() > 0; + const bool loadoutReady = backpack && backpack->getContainer(); + const bool suppliesReady = getInventoryItemCount(player, smallHealthPotionItemId) >= minimumSmallHealthPotions && + getInventoryItemCount(player, meatItemId) >= minimumMeat; + const bool capacityReady = player.getFreeCapacity() >= returnCapacityThreshold; + Item* upgrade = nullptr; + EquipmentUpgrade upgradeInfo{}; + if (findCarriedEquipmentUpgrade(const_cast(player), upgrade, upgradeInfo)) { + recovery = "equip_carried"; + return false; + } + if (weaponReady && armorReady && loadoutReady && suppliesReady && capacityReady) { + return true; + } + if (!weaponReady) { + terminalReason = "missing_legal_melee_weapon"; + } else if (!armorReady) { + terminalReason = "missing_legal_armor"; + } else if (!loadoutReady) { + terminalReason = "missing_backpack"; + } else { + recovery = "service"; + } + return false; +} + +void PlayerBotController::emitCombatReadiness(const Player& player, const Position& position, const char* result, + const std::string& recovery, const std::string& terminalReason) const +{ + Item* left = player.getInventoryItem(CONST_SLOT_LEFT); + Item* right = player.getInventoryItem(CONST_SLOT_RIGHT); + Item* armor = player.getInventoryItem(CONST_SLOT_ARMOR); + Item* backpack = player.getInventoryItem(CONST_SLOT_BACKPACK); + const bool weaponReady = (left && isKnightMeleeWeapon(player, *left)) || (right && isKnightMeleeWeapon(player, *right)); + const bool armorReady = armor && isLegalEquipmentItem(player, *armor) && armor->getArmor() > 0; + std::ostringstream fields; + fields << "\"result\":" << jsonString(result) + << ",\"vocation_id\":" << player.getVocationId() + << ",\"requirements\":[{\"name\":\"legal_melee_weapon\",\"ready\":" << (weaponReady ? "true" : "false") + << ",\"left_item_id\":" << (left ? std::to_string(left->getID()) : "null") + << ",\"right_item_id\":" << (right ? std::to_string(right->getID()) : "null") << '}' + << ",{\"name\":\"armor_loadout\",\"ready\":" << (armorReady && backpack && backpack->getContainer() ? "true" : "false") + << ",\"armor_item_id\":" << (armor ? std::to_string(armor->getID()) : "null") + << ",\"armor\":" << (armor ? armor->getArmor() : 0) << '}' + << ",{\"name\":\"small_health_potions\",\"ready\":" << + (getInventoryItemCount(player, smallHealthPotionItemId) >= minimumSmallHealthPotions ? "true" : "false") + << ",\"count\":" << getInventoryItemCount(player, smallHealthPotionItemId) + << ",\"minimum\":" << minimumSmallHealthPotions << '}' + << ",{\"name\":\"food\",\"ready\":" << (getInventoryItemCount(player, meatItemId) >= minimumMeat ? "true" : "false") + << ",\"count\":" << getInventoryItemCount(player, meatItemId) << ",\"minimum\":" << minimumMeat << '}' + << ",{\"name\":\"free_capacity\",\"ready\":" << (player.getFreeCapacity() >= returnCapacityThreshold ? "true" : "false") + << ",\"current\":" << player.getFreeCapacity() << ",\"minimum\":" << returnCapacityThreshold << "}]" + << ",\"selected_recovery\":" << (recovery.empty() ? "null" : jsonString(recovery)) + << ",\"terminal_reason\":" << (terminalReason.empty() ? "null" : jsonString(terminalReason)); + emit("combat_readiness", position, fields.str()); +} + +bool PlayerBotController::findCarriedEquipmentUpgrade(Player& player, Item*& selectedItem, EquipmentUpgrade& selectedUpgrade) const +{ + selectedItem = nullptr; + for (int32_t slot = CONST_SLOT_FIRST; slot <= CONST_SLOT_LAST; ++slot) { + Item* root = player.getInventoryItem(static_cast(slot)); + Container* container = root ? root->getContainer() : nullptr; + if (!container) { + continue; + } + for (ContainerIterator it = container->iterator(); it.hasNext(); it.advance()) { + Item* candidate = *it; + std::optional upgrade = evaluateEquipmentUpgrade(player, *candidate); + if (!upgrade || (requiresKnightCombatReadiness(player) && + candidate->getWeaponType() != WEAPON_NONE && !isKnightMeleeWeapon(player, *candidate))) { + continue; + } + Position source; + uint8_t index = 0; + g_game.internalGetPosition(candidate, source, index); + if (source.x != 0xFFFF || (source.y & 0x40) == 0 || + (selectedItem && upgrade->benefit <= selectedUpgrade.benefit)) { + continue; + } + selectedItem = candidate; + selectedUpgrade = *upgrade; + } + } + return selectedItem != nullptr; +} + +bool PlayerBotController::beginReadinessEquipment(Player* player, const Position& position, const char* reason) +{ + Item* item = nullptr; + EquipmentUpgrade upgrade{}; + if (!player || !findCarriedEquipmentUpgrade(*player, item, upgrade) || !player->canDoAction()) { + return false; + } + Position source; + uint8_t sourceIndex = 0; + g_game.internalGetPosition(item, source, sourceIndex); + Container* sourceContainer = dynamic_cast(item->getParent()); + if (sourceContainer && player->getContainerID(sourceContainer) < 0) { + Container* containerToOpen = sourceContainer; + while (Container* parent = dynamic_cast(containerToOpen->getParent())) { + if (player->getContainerID(parent) >= 0) { + break; + } + containerToOpen = parent; + } + Position containerPosition; + uint8_t containerIndex = 0; + Item* containerItem = static_cast(containerToOpen); + g_game.internalGetPosition(containerItem, containerPosition, containerIndex); + uint8_t containerId = rewardContainerIdBase; + while (containerId <= maximumContainerId && player->getContainerByID(containerId)) { + ++containerId; + } + ++pendingReadinessAttempts; + if (containerId > maximumContainerId || containerPosition.x != 0xFFFF || + pendingReadinessAttempts > maximumProgressionAttempts) { + return false; + } + ++counters.actionsAttempted; + g_game.playerUseItem(playerId, containerPosition, containerIndex, containerId, containerItem->getClientID()); + emit("action_result", position, "\"action\":\"open_readiness_container\",\"result\":\"requested\",\"item_id\":" + + std::to_string(containerItem->getID()) + ",\"container_id\":" + std::to_string(containerId)); + schedule(navigationDecisionDelay(*player)); + return true; + } + pendingReadinessItemId = item->getID(); + pendingReadinessSlot = upgrade.slot; + readinessEquipmentPending = true; + pendingReadinessAttempts = 0; + ++counters.actionsAttempted; + g_game.playerMoveItem(player, source, item->getClientID(), sourceIndex, + Position(0xFFFF, upgrade.slot, 0), item->getItemCount(), item, nullptr); + emit("action_result", position, "\"action\":\"equip_readiness\",\"result\":\"requested\",\"item_id\":" + + std::to_string(item->getID()) + ",\"slot\":" + std::to_string(upgrade.slot) + + ",\"reason\":" + jsonString(reason)); + schedule(navigationDecisionDelay(*player)); + return true; +} + +void PlayerBotController::processReadinessEquipment(Player* player, const Position& position) +{ + Item* equipped = pendingReadinessSlot == CONST_SLOT_WHEREEVER ? nullptr : player->getInventoryItem(pendingReadinessSlot); + if (equipped && equipped->getID() == pendingReadinessItemId) { + std::string recovery; + std::string terminalReason; + const bool ready = isCombatReady(*player, recovery, terminalReason); + emit("action_result", position, "\"action\":\"equip_readiness\",\"result\":\"success\",\"item_id\":" + + std::to_string(pendingReadinessItemId) + ",\"slot\":" + std::to_string(pendingReadinessSlot)); + emitCombatReadiness(*player, position, ready ? "ready" : "recovery", recovery, terminalReason); + readinessEquipmentPending = false; + pendingReadinessAttempts = 0; + if (ready) { + startHunt(player, position, "readiness_carried_upgrade"); + return; + } + ensureCombatReady(player, position, "readiness_upgrade_incomplete"); + return; + } + if (++pendingReadinessAttempts >= maximumProgressionAttempts) { + std::string recovery; + std::string terminalReason; + isCombatReady(*player, recovery, terminalReason); + readinessEquipmentPending = false; + emit("action_result", position, "\"action\":\"equip_readiness\",\"result\":\"failed\",\"reason\":\"move_not_verified\""); + emitCombatReadiness(*player, position, "failed", recovery, terminalReason); + if (!terminalReason.empty()) { + stop(("combat_readiness_" + terminalReason).c_str(), position); + } else { + beginService(player, position, "readiness_equipment_move_failed"); + schedule(navigationInterval); + } + return; + } + readinessEquipmentPending = false; + if (!beginReadinessEquipment(player, position, "readiness_retry")) { + if (pendingReadinessAttempts >= maximumProgressionAttempts) { + emit("action_result", position, + "\"action\":\"open_readiness_container\",\"result\":\"failed\",\"reason\":\"access_attempts_exhausted\""); + pendingReadinessAttempts = 0; + beginService(player, position, "readiness_container_access_failed"); + schedule(navigationInterval); + } else { + schedule(navigationDecisionDelay(*player)); + } + } +} + +bool PlayerBotController::ensureCombatReady(Player* player, const Position& position, const char* reason) +{ + if (!player || !requiresKnightCombatReadiness(*player)) { + return true; + } + std::string recovery; + std::string terminalReason; + if (isCombatReady(*player, recovery, terminalReason)) { + if (std::strcmp(reason, "readiness_continuous_check") != 0) { + emitCombatReadiness(*player, position, "ready", {}, {}); + } + return true; + } + emitCombatReadiness(*player, position, "recovery", recovery, terminalReason); + if (recovery == "equip_carried") { + if (!beginReadinessEquipment(player, position, reason)) { + if (pendingReadinessAttempts >= maximumProgressionAttempts) { + emit("action_result", position, + "\"action\":\"open_readiness_container\",\"result\":\"failed\",\"reason\":\"access_attempts_exhausted\""); + pendingReadinessAttempts = 0; + beginService(player, position, "readiness_container_access_failed"); + schedule(navigationInterval); + } else { + schedule(navigationDecisionDelay(*player)); + } + } + return false; + } + if (recovery == "service") { + beginService(player, position, "combat_readiness_service"); + schedule(navigationInterval); + return false; + } + stop(("combat_readiness_" + terminalReason).c_str(), position); + return false; +} + std::string PlayerBotController::rewardItemSignature(const Item& item) const { std::ostringstream signature; diff --git a/server/src/playerbotservice.cpp b/server/src/playerbotservice.cpp index 4100ffd..5a3c01d 100644 --- a/server/src/playerbotservice.cpp +++ b/server/src/playerbotservice.cpp @@ -225,6 +225,7 @@ bool PlayerBotController::approachServiceNpc(Player* player, ServiceNpc& service void PlayerBotController::resetConversation(uint32_t targetId) { serviceTargetId = targetId; + serviceGreetingAcknowledged = false; conversationStep = ConversationStep::Greet; serviceAttempts = 0; serviceApproachTarget = Position(); @@ -240,7 +241,6 @@ bool PlayerBotController::openServiceShop(Player* player, ServiceNpc& service, c return false; } if (conversationStep == ConversationStep::Greet) { - serviceGreetingAcknowledged = false; ++counters.actionsAttempted; npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, "hi"); conversationStep = ConversationStep::Request; @@ -451,7 +451,6 @@ void PlayerBotController::processBank(Player* player, const Position& currentPos return; } if (conversationStep == ConversationStep::Greet) { - serviceGreetingAcknowledged = false; ++counters.actionsAttempted; npc->receiveSpeech(player, TALKTYPE_PRIVATE_PN, "hi"); conversationStep = ConversationStep::Request; @@ -616,19 +615,12 @@ void PlayerBotController::processService(Player* player, const Position& current bool PlayerBotController::isProtectedDepositItem(const Item& item) const { - return item.getID() == ropeItemId || item.getID() == 2554 || item.getID() == meatItemId || - item.getID() == smallHealthPotionItemId || item.getWorth() != 0; + return isProtectedInventoryItem(item); } bool PlayerBotController::findDepositableItem(Container* container, Container*& source, Item*& depositItem) const { for (Item* item : container->getItemList()) { - if (Container* child = item->getContainer()) { - (void)child; - source = container; - depositItem = item; - return true; - } if (!isProtectedDepositItem(*item)) { source = container; depositItem = item; diff --git a/server/src/weapons.cpp b/server/src/weapons.cpp index 6ed1d3a..b5ee00b 100644 --- a/server/src/weapons.cpp +++ b/server/src/weapons.cpp @@ -264,6 +264,10 @@ bool Weapon::configureEvent(const pugi::xml_node& node) it.vocationString = vocationString; it.minReqLevel = getReqLevel(); it.minReqMagicLevel = getReqMagLv(); + it.vocationIds.clear(); + for (const auto& vocation : vocWeaponMap) { + it.vocationIds.insert(vocation.first); + } } configureWeapon(Item::items[id]); diff --git a/server/tests/playerbot-gameplay/playerbot_gameplay.lua b/server/tests/playerbot-gameplay/playerbot_gameplay.lua index bbe5a61..1c1dc88 100644 --- a/server/tests/playerbot-gameplay/playerbot_gameplay.lua +++ b/server/tests/playerbot-gameplay/playerbot_gameplay.lua @@ -26,6 +26,31 @@ local economicRewardStorage = 50082 local departureRecoveryStorage = 50090 local deathLoginCount = 0 +local function removeAll(player, itemId) + local count = player:getItemCount(itemId) + if count > 0 then + assert(player:removeItem(itemId, count), "fixture could not remove item " .. itemId) + end +end + +local function verifyReadiness(playerId, mode, attempts) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared during readiness fixture") + local ready = player:getVocation():getId() == 4 and player:getSlotItem(CONST_SLOT_ARMOR) and + player:getSlotItem(CONST_SLOT_LEFT) and player:getItemCount(potionItemId) >= 5 and player:getItemCount(meatItemId) >= 1 + if mode == "upgrade" then ready = ready and player:getSlotItem(CONST_SLOT_LEFT):getId() == pickupRewardId end + if mode == "retention" then ready = ready and player:getItemCount(2050) >= 1 and player:getItemCount(starterWeaponId) >= 1 end + if not ready and attempts > 0 then + addEvent(verifyReadiness, 500, playerId, mode, attempts - 1) + return + end + assert(ready, "combat readiness fixture did not reach its required state: " .. mode) + if mode == "supplies" or mode == "retention" then + assert(player:teleportTo(depotPosition), "readiness fixture could not return to the depot") + end + print("PLAYERBOT_GAMEPLAY_TEST READINESS_" .. string.upper(mode) .. "_PASS") +end + local function verifyOracleDeparture(playerId, attempts) local player = Player(playerId) assert(player and not player:isRemoved(), "Bot One disappeared during Oracle departure") @@ -331,15 +356,46 @@ function login.onLogin(player) end local mode = os.getenv("PLAYERBOT_GAMEPLAY_MODE") or "cycle" - assert(mode == "cycle" or mode == "navigation" or mode == "corpse" or mode == "death" or mode == "healing" or + assert(mode == "cycle" or mode == "navigation" or mode == "corpse" or mode == "death" or mode == "healing" or mode == "healing_resupply" or mode == "value" or mode == "progression" or mode == "progression_bundle" or mode == "progression_nested" or 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 == "stamina_normal" or mode == "hunt_planning", + 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", "unknown PLAYERBOT_GAMEPLAY_MODE: " .. mode) + if mode == "readiness_ready" or mode == "readiness_upgrade" or mode == "readiness_missing_weapon" or + mode == "readiness_supplies" or mode == "readiness_retention" then + assert(player:setVocation(4), "readiness fixture could not select Knight vocation") + assert(player:teleportTo(depotPosition), "readiness fixture could not reach the depot") + suppressNearbyMonsters(player:getId()) + if mode == "readiness_upgrade" then + local backpack = player:getSlotItem(CONST_SLOT_BACKPACK) + assert(backpack and backpack:addItem(pickupRewardId, 1), "readiness upgrade fixture could not add carried weapon") + addEvent(verifyReadiness, 500, player:getId(), "upgrade", 120) + elseif mode == "readiness_missing_weapon" then + removeAll(player, starterWeaponId) + removeAll(player, pickupRewardId) + print("PLAYERBOT_GAMEPLAY_TEST READINESS_MISSING_WEAPON_START") + elseif mode == "readiness_supplies" then + removeAll(player, potionItemId) + removeAll(player, meatItemId) + addEvent(verifyReadiness, 500, player:getId(), "supplies", 240) + elseif mode == "readiness_retention" then + removeAll(player, meatItemId) + local backpack = player:getSlotItem(CONST_SLOT_BACKPACK) + assert(backpack and backpack:addItem(2050, 1), "retention fixture could not add unknown item") + assert(backpack:addItem(saleItemId, 1), "retention fixture could not add sale item") + addEvent(verifyReadiness, 500, player:getId(), "retention", 240) + else + addEvent(verifyReadiness, 500, player:getId(), "ready", 120) + end + print("PLAYERBOT_GAMEPLAY_TEST READINESS_" .. string.upper(mode:gsub("readiness_", "")) .. "_START") + return true + end if mode == "hunt_planning" then print("PLAYERBOT_GAMEPLAY_TEST HUNT_PLANNING_START") return true @@ -524,6 +580,8 @@ function login.onLogin(player) end if mode == "healing" then suppressNearbyMonsters(player:getId()) + local potions = player:getItemCount(potionItemId) + if potions > 0 then assert(player:removeItem(potionItemId, potions), "healing fixture could not remove starter potions") end assert(player:getItemCount(potionItemId) == 0, "healing fixture expected no seeded potions") assert(player:addItem(potionItemId, healingPotionCount), "healing fixture could not add small health potions") assert(player:setHealth(math.floor(player:getMaxHealth() * 0.5)), "healing fixture could not lower Bot One's health") @@ -533,8 +591,12 @@ function login.onLogin(player) end if mode == "healing_resupply" then suppressNearbyMonsters(player:getId()) + local potions = player:getItemCount(potionItemId) + if potions > 0 then assert(player:removeItem(potionItemId, potions), "healing resupply fixture could not remove starter potions") end + removeAll(player, meatItemId) assert(player:getItemCount(potionItemId) == 0, "healing resupply fixture expected no seeded potions") - assert(player:addItem(7636, 1), "healing resupply fixture could not add an empty potion flask") + local backpack = player:getSlotItem(CONST_SLOT_BACKPACK) + assert(backpack and backpack:addItem(7636, 1), "healing resupply fixture could not add an empty potion flask") assert(player:setHealth(math.floor(player:getMaxHealth() * 0.5)), "healing resupply fixture could not lower Bot One's health") addEvent(verifyHealingResupply, 250, player:getId(), 240) print("PLAYERBOT_GAMEPLAY_TEST HEALING_RESUPPLY_START") @@ -570,6 +632,10 @@ function login.onLogin(player) assert(player:getMoney() == 200, "Bot One initial backpack purse was not 200 gp") assert(player:getBankBalance() == 100, "Bot One initial bank balance was not 100 gp") local backpack = player:getSlotItem(CONST_SLOT_BACKPACK) + local potions = player:getItemCount(potionItemId) + if potions > 0 then assert(player:removeItem(potionItemId, potions), "cycle fixture could not remove starter potions") end + local meat = player:getItemCount(meatItemId) + if meat > 0 then assert(player:removeItem(meatItemId, meat), "cycle fixture could not remove starter meat") end assert(backpack and backpack:getId() == ITEM_BACKPACK, "seeded backpack is missing") local lootBag = backpack:addItem(ITEM_BAG, 1) assert(lootBag, "test loot bag could not be added")