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,11 +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, and spell training.
reward claiming, Oracle departure, spell training, and spell use.

```powershell
pwsh -File scripts/test-playerbot-gameplay.ps1 -FullNavigation -CorpseLoot
pwsh -File scripts/test-playerbot-gameplay.ps1 -Focused -SpellTraining
pwsh -File scripts/test-playerbot-gameplay.ps1 -Focused -SpellUse
```

Gameplay fixtures use fixed destinations and do not prove whole-map navigation
Expand Down
14 changes: 14 additions & 0 deletions docs/playerbots.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,19 @@ dialogue. Completion requires both learned state and the exact total-money
delta. Learned-spell persistence reconstructs completion after restart and
prevents a repurchase.

The casting policy has four audited descriptors: `healing`, `support`,
`melee_offense`, and `ranged_offense`. It currently uses Light Healing for
recovery, Haste on a safe route with at least 20 remaining steps, Berserk at
level 35, and Whirlwind Throw before then against a visible adjacent combat
target. Casts use the normal player speech spell path. The live spell
engine remains authoritative for eligibility, costs, targeting, line of sight,
weapon requirements, aggression, cooldowns, and action constraints. Support
and offense retain 20 mana for recovery. Recovery runs before discretionary
casting; when missing at most 90 health, Light Healing avoids a potentially
wasteful small health potion. If potions are unavailable, it also attempts
Light Healing for larger deficits. Each cast verifies mana plus health, haste
condition, or target damage before falling back to a potion or normal melee.

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
configured duration or below 30 oz free capacity. Remaining top-level backpack
Expand Down Expand Up @@ -204,6 +217,7 @@ States, actions, results, statuses, and reasons use stable lowercase values.
| 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. |
| 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. |
| 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
2 changes: 2 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@ the changed behavior:
| `-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. |
| `-SpellUse` | Light Healing preemption, Haste, Whirlwind Throw, unlearned-spell potion fallback, and mana-reserve melee fallback. |

Navigation or looting changes require at least:

```powershell
pwsh -File scripts/test-playerbot-gameplay.ps1 -FullNavigation -CorpseLoot
pwsh -File scripts/test-playerbot-gameplay.ps1 -TargetPursuit -Focused
pwsh -File scripts/test-playerbot-gameplay.ps1 -SpellUse -Focused
```

`-TargetPursuit` runs successful `target_pursuit` reacquisition and bounded
Expand Down
77 changes: 76 additions & 1 deletion scripts/test-playerbot-gameplay.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ param(
[switch]$Depot,
[switch]$MainlandLoop,
[switch]$SpellTraining,
[switch]$SpellUse,
[switch]$Focused,
[switch]$SkipBuild,
[switch]$KeepStack
Expand Down Expand Up @@ -1241,13 +1242,76 @@ function Assert-SpellTrainingEvents {
}
}

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

$events = @(ConvertFrom-PlayerbotLogs -Logs $Logs)
$casts = @($events | Where-Object { $_.event -eq "action_result" -and $_.action -eq "cast_spell" -and $_.result -eq "success" })
$healing = @($casts | Where-Object {
$_.policy_candidate.spell -eq "Light Healing" -and $_.policy_candidate.role -eq "healing" -and $_.need -eq "recovery" -and
$_.mana_after -eq ($_.mana_before - 20) -and $_.health_after -gt $_.health_before
})
$support = @($casts | Where-Object {
$_.policy_candidate.spell -eq "Haste" -and $_.policy_candidate.role -eq "support" -and $_.need -eq "safe_route" -and
$_.mana_after -eq ($_.mana_before - 60) -and $_.mana_after -ge $_.mana_reserve -and $_.reserve_survives
})
$offense = @($casts | Where-Object {
$_.policy_candidate.spell -eq "Whirlwind Throw" -and $_.policy_candidate.role -eq "ranged_offense" -and $_.need -eq "offense" -and
$_.mana_after -eq ($_.mana_before - 40) -and $_.target_id -gt 0
})
$unlearned = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "cast_spell" -and $_.result -eq "skipped" -and
$_.policy_candidate.spell -eq "Light Healing" -and $_.reason -eq "unlearned" -and $_.engine_result -eq "not_attempted" -and
$_.fallback -eq "small_health_potion" -and @($_.legal_candidates).Count -eq 0
})
$fallbackPotion = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "heal" -and $_.result -eq "success" -and
$_.method -eq "small_health_potion" -and $_.resource_before -eq 6 -and $_.resource_after -eq 5
})
$manaFallback = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "cast_spell" -and $_.result -eq "skipped" -and
$_.policy_candidate.spell -eq "Whirlwind Throw" -and $_.reason -eq "insufficient_mana_reserve" -and
$_.fallback -eq "normal_melee" -and @($_.legal_candidates).Count -eq 0
})
$invalidLegalCandidates = @($events | Where-Object {
$_.event -eq "action_result" -and $_.action -eq "cast_spell" -and $_.engine_result -ne "accepted" -and
@($_.legal_candidates).Count -ne 0
})
$failed = @($events | Where-Object { $_.event -eq "action_result" -and $_.action -eq "cast_spell" -and $_.result -eq "failed" })
$terminal = @($events | Where-Object { $_.event -eq "terminal" })
$targetIndex = -1
$preemptingHealIndex = -1
$offenseIndex = -1
for ($index = 0; $index -lt $events.Count; $index++) {
$event = $events[$index]
if ($targetIndex -lt 0 -and $event.event -eq "target_changed" -and $event.target_name -eq "Playerbot Spell Target") {
$targetIndex = $index
} elseif ($targetIndex -ge 0 -and $preemptingHealIndex -lt 0 -and $event.event -eq "action_result" -and
$event.action -eq "cast_spell" -and $event.result -eq "success" -and
$event.policy_candidate.spell -eq "Light Healing" -and $event.need -eq "recovery") {
$preemptingHealIndex = $index
} elseif ($preemptingHealIndex -ge 0 -and $offenseIndex -lt 0 -and $event.event -eq "action_result" -and
$event.action -eq "cast_spell" -and $event.result -eq "success" -and
$event.policy_candidate.spell -eq "Whirlwind Throw") {
$offenseIndex = $index
}
}
$preempted = $targetIndex -ge 0 -and $preemptingHealIndex -gt $targetIndex -and $offenseIndex -gt $preemptingHealIndex
if ($healing.Count -ge 2 -and $support.Count -eq 1 -and $offense.Count -eq 1 -and $unlearned.Count -eq 1 -and
$fallbackPotion.Count -eq 1 -and $manaFallback.Count -ge 1 -and $preempted -and $invalidLegalCandidates.Count -eq 0 -and
$failed.Count -eq 0 -and $terminal.Count -eq 0) {
return
}
throw "Spell use failed. healing=$($healing.Count), support=$($support.Count), offense=$($offense.Count), unlearned=$($unlearned.Count), fallbackPotion=$($fallbackPotion.Count), manaFallback=$($manaFallback.Count), preempted=$preempted, invalidLegalCandidates=$($invalidLegalCandidates.Count), failed=$($failed.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 -or $SpellTraining
$CombatReadiness -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 @@ -1682,6 +1746,17 @@ try {
}
}

if ($SpellUse) {
Invoke-Scenario -Name "spell_use" -DefaultTimeoutSeconds 90 -Body {
Invoke-Compose down --volumes --remove-orphans
$env:PLAYERBOT_GAMEPLAY_MODE = "spell_use"
$env:PLAYERBOT_HUNT_DURATION_SECONDS = "900"
Invoke-Compose up --detach
$spellLogs = Wait-ForLog -Pattern 'PLAYERBOT_GAMEPLAY_TEST SPELL_USE_PASS'
Assert-SpellUseEvents -Logs $spellLogs
}
}

if ($CorpseLoot) {
Invoke-Scenario -Name "corpse" -DefaultTimeoutSeconds 60 -Body {
Invoke-Compose down --volumes --remove-orphans
Expand Down
11 changes: 11 additions & 0 deletions server/src/playerbotcombat.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ bool PlayerBotController::handleHealing(Player* player, const Position& currentP
if (now < healRetryAfter || !player->canDoAction()) {
return true;
}
if (handleSpellHealing(player, currentPosition)) {
return true;
}

const uint32_t potionCount = getInventoryItemCount(*player, smallHealthPotionItemId);
if (potionCount == 0) {
Expand Down Expand Up @@ -476,6 +479,10 @@ void PlayerBotController::processTraversalCombat(Player* player, const Position&
finishTraversalCombat(player, currentPosition, "combat_timeout");
} else {
ratPosition = target->getPosition();
if (tryOffensiveSpell(player, currentPosition)) {
schedule(navigationDecisionDelay(*player));
return;
}
}
schedule(navigationInterval);
}
Expand Down Expand Up @@ -969,6 +976,10 @@ void PlayerBotController::processTraversal(Player* player, const Position& curre
schedule(navigationInterval);
return;
}
if (trySupportSpell(player, currentPosition)) {
schedule(navigationDecisionDelay(*player));
return;
}

const std::vector<Position>* patrolPoints = activeHuntRegion ? &activeHuntRegion->patrolPoints : nullptr;
const Position& target = patrolPoints && !patrolPoints->empty() ?
Expand Down
5 changes: 3 additions & 2 deletions server/src/playerbotcontroller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ const PlayerBotTestPolicy& playerbot::testPolicyFromEnvironment()
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, "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);
std::strcmp(gameplayMode, "readiness_missing_weapon") == 0 || std::strcmp(gameplayMode, "readiness_supplies") == 0 ||
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 &&
Expand Down Expand Up @@ -671,6 +671,7 @@ void PlayerBotController::navigate()
const Position currentPosition = player->getPosition();
lastPosition = currentPosition;
maybeLogSummary(currentPosition);
verifySpellCast(*player, currentPosition);
if (activeHuntRegion && cyclePhase == CyclePhase::Hunt) {
if (huntRegionDamageTaken >= static_cast<uint32_t>(player->getMaxHealth()) &&
std::chrono::steady_clock::now() - huntRegionStarted < std::chrono::minutes(2)) {
Expand Down
22 changes: 22 additions & 0 deletions server/src/playerbotcontroller.h
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,17 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl
uint64_t moneyBefore = 0;
};

struct PendingSpellCast {
std::string name;
std::string role;
std::string need;
uint32_t manaBefore = 0;
uint32_t manaReserve = 0;
int32_t healthBefore = 0;
uint32_t targetId = 0;
int32_t targetHealthBefore = 0;
};

enum class ScenarioStage : uint8_t {
LootCorpse,
Traverse,
Expand Down Expand Up @@ -485,6 +496,15 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl
uint32_t potionCountAfter, const Position& position);

bool handleHealing(Player* player, const Position& currentPosition);
bool handleSpellHealing(Player* player, const Position& currentPosition);
bool trySupportSpell(Player* player, const Position& currentPosition);
bool tryOffensiveSpell(Player* player, const Position& currentPosition);
bool startSpellCast(Player& player, const Position& position, const char* spellName, const char* need,
Creature* target = nullptr);
void verifySpellCast(Player& player, const Position& position);
void emitSpellCastEvent(const Position& position, const char* spellName, const char* words, const char* role,
const char* need, const char* result, const char* engineResult, const char* reason,
const PendingSpellCast* pending, const Player* player, const char* fallback) const;

void logEatSuccess(uint32_t inventoryCount, int32_t foodTicks, const Position& position);

Expand Down Expand Up @@ -754,6 +774,8 @@ class PlayerBotController : public std::enable_shared_from_this<PlayerBotControl
int32_t pendingHealHealthMax = 0;
uint32_t pendingHealPotionCount = 0;
std::chrono::steady_clock::time_point healRetryAfter;
PendingSpellCast pendingSpellCast;
std::chrono::steady_clock::time_point spellRetryAfter;
bool pendingEat = false;
uint32_t pendingEatInventoryCount = 0;
int32_t pendingEatFoodTicks = 0;
Expand Down
Loading