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
1 change: 1 addition & 0 deletions docs/playerbots.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,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. |
| Equipment offers | `equipment_offer_candidate` and `equipment_offer_shadow` expose loaded tagged-shop offers, loadout and hunt deltas, reserve and route checks, and the non-mutating shadow decision. |
| 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. |
| Spell casting | `action_result` with `action="cast_spell"` records the need, semantic `policy_candidate`, selected method, mana reserve, normal-path request, engine result, observed outcome, and fallback. `legal_candidates` contains only normal-path casts confirmed by resource evidence. |
| Actions | `action_result`, `target_changed`, `service_discovered`, `npc_reply`, `stuck` record externally relevant attempts and outcomes. |
Expand Down
69 changes: 67 additions & 2 deletions scripts/test-playerbot-gameplay.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ param(
[switch]$GoalArbitration,
[switch]$OracleDeparture,
[switch]$StaminaProjection,
[switch]$HuntRegionPlanning,
[switch]$HuntRegionPlanning,
[switch]$CombatReadiness,
[switch]$EquipmentOffers,
[switch]$Depot,
[switch]$MainlandLoop,
[switch]$SpellTraining,
Expand Down Expand Up @@ -933,6 +934,40 @@ function Assert-CombatReadinessEvents {
}
}

function Assert-EquipmentOfferEvents {
param([string]$Logs, [string]$Mode)

$events = @(ConvertFrom-PlayerbotLogs -Logs $Logs)
$shadow = @($events | Where-Object { $_.event -eq "equipment_offer_shadow" })
$candidates = @($events | Where-Object { $_.event -eq "equipment_offer_candidate" })
$purchases = @($events | Where-Object { $_.event -eq "action_result" -and $_.action -eq "buy_equipment" })
$equipmentMoves = @($events | Where-Object { $_.event -eq "action_result" -and $_.action -eq "equip_equipment" })
if ($shadow.Count -ne 1 -or $candidates.Count -lt 1 -or $purchases.Count -ne 0 -or $equipmentMoves.Count -ne 0) {
throw "Equipment shadow telemetry was incomplete or mutated player state. shadow=$($shadow.Count), candidates=$($candidates.Count), purchases=$($purchases.Count), equipmentMoves=$($equipmentMoves.Count)."
}
if ($Mode -eq "upgrade") {
$selected = @($candidates | Where-Object {
$_.result -eq "feasible" -and $_.npc_id -eq $shadow[0].npc_id -and $_.item_id -eq $shadow[0].item_id
})
if ($shadow[0].result -ne "would_buy" -or $selected.Count -ne 1 -or $selected[0].replaced_item_id -ne 2382 -or
-not $selected[0].current -or -not $selected[0].candidate -or $selected[0].rule -notin @("pareto_improvement", "unlocks_suitable_hunt")) {
throw "Equipment shadow did not select a loaded strict weapon improvement."
}
} elseif ($Mode -eq "unaffordable") {
$unaffordable = @($candidates | Where-Object { $_.result -eq "rejected" -and $_.reason -eq "unaffordable_after_reserves" })
if ($shadow[0].result -ne "no_decision" -or $unaffordable.Count -lt 1) {
throw "Equipment shadow did not preserve the supply reserve before evaluating a purchase."
}
} else {
$nonImproving = @($candidates | Where-Object { $_.result -eq "rejected" -and $_.reason -eq "non_improving" })
$illegal = @($candidates | Where-Object { $_.result -eq "rejected" -and $_.reason -eq "unsupported_weapon_type" })
$affordable = @($candidates | Where-Object { $_.reason -eq "unaffordable_after_reserves" })
if ($shadow[0].result -ne "no_decision" -or $nonImproving.Count -lt 1 -or $illegal.Count -lt 1 -or $affordable.Count -ne 0) {
throw "Equipment shadow did not abstain from non-improving or two-handed tradeoff offers."
}
}
}

function Assert-GoalArbitrationInterruptEvents {
param([string]$Logs)

Expand Down Expand Up @@ -1311,7 +1346,7 @@ if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {

$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 -or $SpellTraining -or $SpellUse
$CombatReadiness -or $EquipmentOffers -or $Depot -or $MainlandLoop -or $SpellTraining -or $SpellUse
if ($Focused -and -not $focusedScenarioRequested) {
throw "-Focused requires at least one focused scenario switch."
}
Expand Down Expand Up @@ -1654,6 +1689,36 @@ try {
}
}

if ($EquipmentOffers) {
Invoke-Scenario -Name "equipment_offer_shadow_upgrade" -DefaultTimeoutSeconds 90 -Body {
Invoke-Compose down --volumes --remove-orphans
$env:PLAYERBOT_GAMEPLAY_MODE = "equipment_shadow"
Invoke-Compose up --detach
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_SHADOW_START' | Out-Null
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_SHADOW_PASS' | Out-Null
$upgradeLogs = Wait-ForPlayerbotEvent { $_.event -eq "equipment_offer_shadow" }
Assert-EquipmentOfferEvents -Logs $upgradeLogs -Mode "upgrade"
}
Invoke-Scenario -Name "equipment_offer_shadow_unaffordable" -DefaultTimeoutSeconds 90 -Body {
Invoke-Compose down --volumes --remove-orphans
$env:PLAYERBOT_GAMEPLAY_MODE = "equipment_shadow_unaffordable"
Invoke-Compose up --detach
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_SHADOW_UNAFFORDABLE_START' | Out-Null
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_SHADOW_UNAFFORDABLE_PASS' | Out-Null
$unaffordableLogs = Wait-ForPlayerbotEvent { $_.event -eq "equipment_offer_shadow" }
Assert-EquipmentOfferEvents -Logs $unaffordableLogs -Mode "unaffordable"
}
Invoke-Scenario -Name "equipment_offer_shadow_no_upgrade" -DefaultTimeoutSeconds 90 -Body {
Invoke-Compose down --volumes --remove-orphans
$env:PLAYERBOT_GAMEPLAY_MODE = "equipment_shadow_no_upgrade"
Invoke-Compose up --detach
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_SHADOW_NO_UPGRADE_START' | Out-Null
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_SHADOW_NO_UPGRADE_PASS' | Out-Null
$noUpgradeLogs = Wait-ForPlayerbotEvent { $_.event -eq "equipment_offer_shadow" }
Assert-EquipmentOfferEvents -Logs $noUpgradeLogs -Mode "no_upgrade"
}
}

if ($OracleDeparture) {
Invoke-Scenario -Name "oracle_departure" -DefaultTimeoutSeconds 180 -Body {
Invoke-Compose down --volumes --remove-orphans
Expand Down
1 change: 1 addition & 0 deletions server/src/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ set(tfs_SRC
${CMAKE_CURRENT_LIST_DIR}/playerbotcombat.cpp
${CMAKE_CURRENT_LIST_DIR}/playerbotcontroller.cpp
${CMAKE_CURRENT_LIST_DIR}/playerbotdeparture.cpp
${CMAKE_CURRENT_LIST_DIR}/playerbotequipment.cpp
${CMAKE_CURRENT_LIST_DIR}/playerbotloot.cpp
${CMAKE_CURRENT_LIST_DIR}/playerbotprogression.cpp
${CMAKE_CURRENT_LIST_DIR}/playerbotspells.cpp
Expand Down
13 changes: 9 additions & 4 deletions server/src/playerbotcontroller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment()
std::strcmp(gameplayMode, "arbitration_interrupt") == 0 ||
std::strcmp(gameplayMode, "departure") == 0 ||
std::strcmp(gameplayMode, "departure_recovery") == 0 ||
std::strcmp(gameplayMode, "spell_training") == 0);
std::strcmp(gameplayMode, "spell_training") == 0 || std::strcmp(gameplayMode, "equipment_shadow") == 0 ||
std::strcmp(gameplayMode, "equipment_shadow_unaffordable") == 0 ||
std::strcmp(gameplayMode, "equipment_shadow_no_upgrade") == 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 All @@ -44,9 +46,12 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment()
std::strcmp(gameplayMode, "readiness_retention") == 0 || std::strcmp(gameplayMode, "spell_use") == 0);
const bool fixedFixtureRoute = gameplayMode && 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, "mainland") != 0 &&
std::strcmp(gameplayMode, "stamina_normal") != 0 &&
std::strcmp(gameplayMode, "hunt_planning") != 0 &&
std::strcmp(gameplayMode, "equipment_shadow") != 0 &&
std::strcmp(gameplayMode, "equipment_shadow_unaffordable") != 0 &&
std::strcmp(gameplayMode, "equipment_shadow_no_upgrade") != 0 &&
std::strcmp(gameplayMode, "mainland") != 0 &&
std::strcmp(gameplayMode, "depot") != 0;
const char* depotRestartPhase = std::getenv("PLAYERBOT_DEPOT_RESTART_PHASE");
const DepotRestartCheckpoint depotRestartCheckpoint = !depotRestartPhase ? DepotRestartCheckpoint::None :
Expand Down
50 changes: 50 additions & 0 deletions server/src/playerbotcontroller.h
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,42 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl
int32_t candidateValue;
};

struct EquipmentLoadout {
std::array<uint16_t, CONST_SLOT_LAST + 1> itemIds{};
};

struct EquipmentHuntSummary {
uint32_t suitableRegions = 0;
double bestProjectedExperience = 0;
double lowestThreatRatio = 0;
uint32_t evaluatedRegions = 0;
bool truncated = false;
};

enum class EquipmentDecisionRule : uint8_t {
None,
ParetoImprovement,
UnlocksHunt,
ReadinessRepair,
};

struct EquipmentOfferEvaluation {
uint32_t npcId = 0;
Position npcPosition;
uint16_t itemId = 0;
uint32_t price = 0;
uint16_t replacedItemId = 0;
uint16_t displacedLeftItemId = 0;
uint16_t displacedRightItemId = 0;
PlayerBotCombatProfile profile;
EquipmentHuntSummary hunts;
bool currentReady = false;
bool candidateReady = false;
std::string rejection;
EquipmentDecisionRule rule = EquipmentDecisionRule::None;
uint32_t travelSteps = 0;
};

struct RewardItemInspection {
uint16_t itemId;
uint32_t count;
Expand Down Expand Up @@ -480,6 +516,7 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl

bool needsHealing(const Player& player) const;
bool requiresKnightCombatReadiness(const Player& player) const;
bool isLegalEquipmentType(const Player& player, const ItemType& type) const;
bool isLegalEquipmentItem(const Player& player, const Item& item) const;
bool isKnightMeleeWeapon(const Player& player, const Item& item) const;
bool isCombatEquipment(const Item& item) const;
Expand Down Expand Up @@ -536,6 +573,19 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl
void processTargetPursuit(Player* player, const Position& currentPosition);

std::optional<EquipmentUpgrade> evaluateEquipmentUpgrade(const Player& player, const Item& candidate) const;
EquipmentLoadout equipmentLoadout(const Player& player) const;
bool applyEquipmentOffer(const Player& player, EquipmentLoadout& loadout, uint16_t itemId,
uint16_t& replacedItemId, uint16_t& displacedLeftItemId, uint16_t& displacedRightItemId,
std::string& rejection) const;
PlayerBotCombatProfile equipmentCombatProfile(const Player& player, const EquipmentLoadout& loadout) const;
bool equipmentLoadoutReady(const Player& player, const EquipmentLoadout& loadout,
uint32_t additionalWeight = 0) const;
EquipmentHuntSummary equipmentHuntSummary(Player& player, const PlayerBotCombatProfile& profile) const;
const char* equipmentDecisionRuleName(EquipmentDecisionRule rule) const;
void emitEquipmentOffer(const Player& player, const EquipmentOfferEvaluation& evaluation,
const PlayerBotCombatProfile& currentProfile, const EquipmentHuntSummary& currentHunts,
uint64_t reserve, const Position& position, const char* result, const char* reason) const;
void evaluateEquipmentOffers(Player& player, const Position& position);

std::string rewardItemSignature(const Item& item) const;

Expand Down
Loading