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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,12 @@ 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, Oracle departure, spell training, and spell use.
reward claiming, Oracle departure, equipment purchasing, spell training, and
spell use.

```powershell
pwsh -File scripts/test-playerbot-gameplay.ps1 -FullNavigation -CorpseLoot
pwsh -File scripts/test-playerbot-gameplay.ps1 -Focused -EquipmentPurchases
pwsh -File scripts/test-playerbot-gameplay.ps1 -Focused -SpellTraining
pwsh -File scripts/test-playerbot-gameplay.ps1 -Focused -SpellUse
```
Expand Down
2 changes: 1 addition & 1 deletion docs/playerbots.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +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. |
| Equipment | `equipment_offer_candidate` and `equipment_offer_shadow` expose loaded tagged-shop offers, loadout and hunt deltas, reserve and route checks, and non-mutating `would_buy` or `would_equip` decisions. Live `buy_equipment` goals use `strategy_selection`, `action_result`, `strategy_objective_result`, and `goal_result` to record purchase, carried-item recovery, displacement, equip verification, and fallback. |
| 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
1 change: 1 addition & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ the changed behavior:
| `-StaminaProjection` | Premium bonus, low-stamina penalty, and ordinary stamina projections. |
| `-HuntRegionPlanning` | Cached scanner batching, threat rejection, reachability, cooldowns, and observed correction. |
| `-CombatReadiness` | Equipment, supplies, capacity, service recovery, upgrades, and restart reconstruction. |
| `-EquipmentPurchases` | Justified purchase and equip verification, clean restart persistence, carried-upgrade recovery, displaced-item-space rejection, and rejected transactions. |
| `-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. |
Expand Down
133 changes: 132 additions & 1 deletion scripts/test-playerbot-gameplay.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ param(
[switch]$HuntRegionPlanning,
[switch]$CombatReadiness,
[switch]$EquipmentOffers,
[switch]$EquipmentPurchases,
[switch]$Depot,
[switch]$MainlandLoop,
[switch]$SpellTraining,
Expand Down Expand Up @@ -968,6 +969,64 @@ function Assert-EquipmentOfferEvents {
}
}

function Assert-EquipmentPurchaseEvents {
param([string]$Logs, [switch]$Rejected, [switch]$Restart, [switch]$Resume)

$events = @(ConvertFrom-PlayerbotLogs -Logs $Logs)
$purchases = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "buy_equipment" -and $_.result -eq "success"
})
$equips = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "equip_equipment" -and $_.result -eq "success"
})
$results = @($events | Where-Object {
$_.event -eq "goal_result" -and $_.goal -eq "buy_equipment"
})
$terminal = @($events | Where-Object { $_.event -eq "terminal" })
if ($Restart) {
$online = @($events | Where-Object {
$_.event -eq "lifecycle" -and $_.status -eq "online" -and -not $_.recovered -and $_.objective -eq "service"
})
if ($online.Count -ne 1 -or $purchases.Count -ne 0 -or $equips.Count -ne 0 -or $terminal.Count -ne 0) {
throw "Equipment purchase restart reconstruction failed. online=$($online.Count), purchases=$($purchases.Count), equips=$($equips.Count), terminal=$($terminal.Count)."
}
return
}
if ($Rejected) {
$fallback = @($events | Where-Object {
$_.event -eq "goal_selection" -and $_.decision_reason -eq "equipment_purchase_failed" -and
$_.from_goal -eq "buy_equipment" -and $_.to_goal -ne "buy_equipment"
})
if ($results.Count -ne 1 -or $results[0].result -ne "failed" -or
$results[0].reason -ne "transaction_rejected" -or $fallback.Count -ne 1 -or
$purchases.Count -ne 0 -or $equips.Count -ne 0 -or $terminal.Count -ne 0) {
throw "Rejected equipment transaction did not preserve state and return to a valid goal."
}
return
}
if ($Resume) {
$selections = @($events | Where-Object {
$_.event -eq "strategy_selection" -and $_.goal -eq "buy_equipment" -and
$_.item_id -eq 2379 -and $_.acquisition -eq "carried"
})
if ($selections.Count -ne 1 -or $purchases.Count -ne 0 -or $equips.Count -ne 1 -or
$results.Count -ne 1 -or $results[0].result -ne "success" -or $terminal.Count -ne 0) {
throw "Persisted equipment purchase state was not reconstructed as an equip-only goal."
}
return
}
$selections = @($events | Where-Object {
$_.event -eq "goal_selection" -and $_.to_goal -eq "buy_equipment" -and $_.item_id -eq 2379 -and $_.price -eq 5
})
if ($selections.Count -ne 1 -or $purchases.Count -ne 1 -or $equips.Count -ne 1 -or
$results.Count -ne 1 -or $results[0].result -ne "success" -or
$purchases[0].carried_before -ne 110 -or $purchases[0].carried_after -ne 105 -or
$purchases[0].bank_before -ne 100 -or $purchases[0].bank_after -ne 100 -or
-not $equips[0].combat_ready -or -not $equips[0].displaced_items_preserved -or $terminal.Count -ne 0) {
throw "Justified equipment purchase was not selected, paid, equipped, and verified exactly once."
}
}

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

Expand Down Expand Up @@ -1371,7 +1430,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 $EquipmentOffers -or $Depot -or $MainlandLoop -or $SpellTraining -or $SpellUse
$CombatReadiness -or $EquipmentOffers -or $EquipmentPurchases -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 @@ -1744,6 +1803,78 @@ try {
}
}

if ($EquipmentPurchases) {
Invoke-Scenario -Name "equipment_purchase" -DefaultTimeoutSeconds 240 -Body {
Invoke-Compose down --volumes --remove-orphans
$env:PLAYERBOT_GAMEPLAY_MODE = "equipment_buy"
$env:PLAYERBOT_HUNT_DURATION_SECONDS = "900"
Invoke-Compose up --detach
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_PASS' | Out-Null
$purchaseLogs = Wait-ForPlayerbotEvent {
$_.event -eq "goal_result" -and $_.goal -eq "buy_equipment" -and $_.result -eq "success"
}
Assert-EquipmentPurchaseEvents -Logs $purchaseLogs

$restartLineCount = @((Get-ServerLogs) -split "`r?`n").Count
Invoke-Compose stop server
Invoke-Compose up --detach server
$restartLogs = ""
while ([DateTime]::UtcNow -lt $currentScenarioDeadline) {
$restartLogs = ((Get-ServerLogs) -split "`r?`n" | Select-Object -Skip $restartLineCount) -join "`n"
$restartOnline = @(ConvertFrom-PlayerbotLogs -Logs $restartLogs | Where-Object {
$_.event -eq "lifecycle" -and $_.status -eq "online" -and -not $_.recovered -and $_.objective -eq "service"
}).Count -gt 0
if ($restartLogs -match 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_RESTART_PASS' -and $restartOnline) { break }
Start-Sleep -Seconds 1
}
if ($restartLogs -notmatch 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_RESTART_PASS' -or -not $restartOnline) {
Throw-WaitTimeout "Timed out waiting for equipment purchase restart reconstruction."
}
Assert-EquipmentPurchaseEvents -Logs $restartLogs -Restart
}
Invoke-Scenario -Name "equipment_purchase_resume" -DefaultTimeoutSeconds 240 -Body {
Invoke-Compose down --volumes --remove-orphans
$env:PLAYERBOT_GAMEPLAY_MODE = "equipment_buy_resume"
$env:PLAYERBOT_HUNT_DURATION_SECONDS = "900"
Invoke-Compose up --detach
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_RESUME_PASS' | Out-Null
$resumeLogs = Wait-ForPlayerbotEvent {
$_.event -eq "goal_result" -and $_.goal -eq "buy_equipment" -and $_.result -eq "success"
}
Assert-EquipmentPurchaseEvents -Logs $resumeLogs -Resume
}
Invoke-Scenario -Name "equipment_purchase_space" -DefaultTimeoutSeconds 240 -Body {
Invoke-Compose down --volumes --remove-orphans
$env:PLAYERBOT_GAMEPLAY_MODE = "equipment_buy_space"
$env:PLAYERBOT_HUNT_DURATION_SECONDS = "900"
Invoke-Compose up --detach
$spaceLogs = Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_SPACE_PASS'
$spaceEvents = @(ConvertFrom-PlayerbotLogs -Logs $spaceLogs)
$spaceRejections = @($spaceEvents | Where-Object {
$_.event -eq "equipment_offer_candidate" -and $_.item_id -eq 2379 -and
$_.reason -eq "insufficient_displaced_item_space"
})
$spaceActions = @($spaceEvents | Where-Object {
$_.event -eq "action_result" -and $_.action -in @("buy_equipment", "equip_equipment")
})
if ($spaceRejections.Count -lt 1 -or $spaceActions.Count -ne 0) {
throw "Equipment purchase did not reject insufficient displaced-item storage before payment."
}
}
Invoke-Scenario -Name "equipment_purchase_rejected" -DefaultTimeoutSeconds 240 -Body {
Invoke-Compose down --volumes --remove-orphans
$env:PLAYERBOT_GAMEPLAY_MODE = "equipment_buy_rejected"
$env:PLAYERBOT_HUNT_DURATION_SECONDS = "900"
Invoke-Compose up --detach
Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST EQUIPMENT_BUY_REJECTED_PASS' | Out-Null
$rejectedLogs = Wait-ForPlayerbotEvent {
$_.event -eq "goal_selection" -and $_.decision_reason -eq "equipment_purchase_failed" -and
$_.from_goal -eq "buy_equipment" -and $_.to_goal -ne "buy_equipment"
}
Assert-EquipmentPurchaseEvents -Logs $rejectedLogs -Rejected
}
}

if ($OracleDeparture) {
Invoke-Scenario -Name "oracle_departure" -DefaultTimeoutSeconds 180 -Body {
Invoke-Compose down --volumes --remove-orphans
Expand Down
10 changes: 9 additions & 1 deletion server/src/playerbotcontroller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment()
std::strcmp(gameplayMode, "departure_recovery") == 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);
std::strcmp(gameplayMode, "equipment_shadow_no_upgrade") == 0 ||
std::strcmp(gameplayMode, "equipment_buy") == 0 ||
std::strcmp(gameplayMode, "equipment_buy_resume") == 0 ||
std::strcmp(gameplayMode, "equipment_buy_space") == 0 ||
std::strcmp(gameplayMode, "equipment_buy_rejected") == 0);
const bool startInHunt = gameplayMode &&
(std::strcmp(gameplayMode, "navigation") == 0 || std::strcmp(gameplayMode, "navigation_recovery") == 0 ||
std::strcmp(gameplayMode, "corpse") == 0 ||
Expand Down Expand Up @@ -77,6 +81,10 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment()
gameplayMode && std::strcmp(gameplayMode, "hunt_planning") == 0,
gameplayMode && std::strcmp(gameplayMode, "hunt_planning") == 0,
gameplayMode && std::strcmp(gameplayMode, "navigation_recovery") == 0,
!gameplayMode || (std::strcmp(gameplayMode, "equipment_shadow") != 0 &&
std::strcmp(gameplayMode, "equipment_shadow_unaffordable") != 0 &&
std::strcmp(gameplayMode, "equipment_shadow_no_upgrade") != 0),
gameplayMode && std::strcmp(gameplayMode, "equipment_buy_rejected") == 0,
};
}();
return policy;
Expand Down
32 changes: 29 additions & 3 deletions server/src/playerbotcontroller.h
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,16 @@ namespace playerbot {
inline constexpr std::chrono::seconds pickupRewardFailureCooldown(60);
inline constexpr std::chrono::minutes spellTrainingSuccessCooldown(5);
inline constexpr std::chrono::seconds spellTrainingFailureCooldown(60);
inline constexpr std::chrono::minutes equipmentPurchaseSuccessCooldown(5);
inline constexpr std::chrono::seconds equipmentPurchaseFailureCooldown(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 equipmentPurchaseGoalUtility = 500;
inline constexpr int32_t economicPickupBaseUtility = 250;
inline constexpr int32_t huntGoalUtility = 300;
inline constexpr int32_t oracleDepartureUtility = 950;
Expand Down Expand Up @@ -147,6 +150,8 @@ namespace playerbot {
bool forceSecondHuntCandidateNodeLimit;
bool cancelHuntPlanningAtScoreBarrier;
bool forceRepeatedNavigationStepFailures;
bool equipmentPurchasesEnabled;
bool forceEquipmentPurchaseRejected;
};

std::string jsonString(const std::string& value);
Expand Down Expand Up @@ -197,13 +202,15 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl
PickupReward,
OracleDeparture,
LearnSpell,
BuyEquipment,
};

enum class TopLevelGoal : uint8_t {
Departure,
Service,
PickupReward,
LearnSpell,
BuyEquipment,
Hunt,
};

Expand Down Expand Up @@ -270,20 +277,31 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl
struct EquipmentOfferEvaluation {
uint32_t npcId = 0;
Position npcPosition;
Position approachPosition;
uint16_t itemId = 0;
uint32_t price = 0;
slots_t slot = CONST_SLOT_WHEREEVER;
uint16_t replacedItemId = 0;
uint16_t displacedLeftItemId = 0;
uint16_t displacedRightItemId = 0;
PlayerBotCombatProfile profile;
EquipmentHuntSummary hunts;
bool currentReady = false;
bool candidateReady = false;
bool carried = false;
std::string rejection;
EquipmentDecisionRule rule = EquipmentDecisionRule::None;
uint32_t travelSteps = 0;
};

enum class EquipmentPurchaseStage : uint8_t {
Travel,
Purchase,
VerifyPurchase,
Equip,
VerifyEquipment,
};

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

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,
bool applyEquipmentOffer(const Player& player, EquipmentLoadout& loadout, uint16_t itemId, slots_t& slot,
uint16_t& replacedItemId, uint16_t& displacedLeftItemId, uint16_t& displacedRightItemId,
std::string& rejection) const;
PlayerBotCombatProfile equipmentCombatProfile(const Player& player, const EquipmentLoadout& loadout) const;
Expand All @@ -587,7 +605,10 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl
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::optional<EquipmentOfferEvaluation> evaluateEquipmentOffers(Player& player, const Position& position);
void beginEquipmentPurchase(Player& player, const Position& position, EquipmentOfferEvaluation evaluation);
void processEquipmentPurchase(Player* player, const Position& position);
void finishEquipmentPurchase(Player* player, const Position& position, const char* result, const char* reason);

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

Expand Down Expand Up @@ -662,7 +683,8 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl
GoalCandidate serviceGoalCandidate(const Player& player) const;

void emitGoalCandidate(const Player& player, const GoalCandidate& candidate, const Position& position, const char* decisionReason,
const PickupReward* reward = nullptr, const DeparturePlan* departure = nullptr) const;
const PickupReward* reward = nullptr, const DeparturePlan* departure = nullptr,
const EquipmentOfferEvaluation* equipment = nullptr) const;

void beginPickupReward(Player& player, const Position& position, PickupReward reward,
std::deque<PlayerBotNavigationStep> rewardSteps);
Expand Down Expand Up @@ -849,10 +871,13 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl
PickupReward pickupReward;
DeparturePlan departurePlan;
SpellTrainingPlan spellTrainingPlan;
EquipmentOfferEvaluation equipmentPurchase;
EquipmentPurchaseStage equipmentPurchaseStage = EquipmentPurchaseStage::Travel;
TopLevelGoal activeGoal = TopLevelGoal::Service;
uint64_t goalDecisionId = 0;
std::chrono::steady_clock::time_point pickupRewardCooldownUntil;
std::chrono::steady_clock::time_point spellTrainingCooldownUntil;
std::chrono::steady_clock::time_point equipmentPurchaseCooldownUntil;
uint32_t progressionAttempts = 0;
uint32_t pendingRewardItemCount = 0;
uint32_t pendingRewardRootCount = 0;
Expand All @@ -863,6 +888,7 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl
std::map<uint16_t, std::string> rewardInspectionFingerprints;
uint16_t pendingEquipmentItemId = 0;
uint32_t pendingEquipmentItemCount = 0;
std::map<uint16_t, uint32_t> pendingEquipmentDisplacedCounts;
uint16_t pendingReadinessItemId = 0;
slots_t pendingReadinessSlot = CONST_SLOT_WHEREEVER;
uint32_t pendingReadinessAttempts = 0;
Expand Down
Loading