From 4ae46a14dc7dbae9045525044a3d53b2c7e0c8b7 Mon Sep 17 00:00:00 2001 From: adrunkhuman <16039109+adrunkhuman@users.noreply.github.com> Date: Tue, 4 Aug 2026 19:41:33 +0200 Subject: [PATCH] server: use audited Knight spells in combat --- README.md | 3 +- docs/playerbots.md | 14 ++ docs/testing.md | 2 + scripts/test-playerbot-gameplay.ps1 | 77 +++++- server/src/playerbotcombat.cpp | 11 + server/src/playerbotcontroller.cpp | 5 +- server/src/playerbotcontroller.h | 22 ++ server/src/playerbotspells.cpp | 229 ++++++++++++++++++ .../playerbot-gameplay/playerbot_gameplay.lua | 131 +++++++++- server/tests/playerbot-monsters/corpse.lua | 1 + 10 files changed, 489 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index cadf3e1..e9cedb0 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/playerbots.md b/docs/playerbots.md index 702b335..cb4855f 100644 --- a/docs/playerbots.md +++ b/docs/playerbots.md @@ -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 @@ -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. | diff --git a/docs/testing.md b/docs/testing.md index 88b228c..f23c515 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/scripts/test-playerbot-gameplay.ps1 b/scripts/test-playerbot-gameplay.ps1 index 8d669e7..8518d18 100644 --- a/scripts/test-playerbot-gameplay.ps1 +++ b/scripts/test-playerbot-gameplay.ps1 @@ -16,6 +16,7 @@ param( [switch]$Depot, [switch]$MainlandLoop, [switch]$SpellTraining, + [switch]$SpellUse, [switch]$Focused, [switch]$SkipBuild, [switch]$KeepStack @@ -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." } @@ -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 diff --git a/server/src/playerbotcombat.cpp b/server/src/playerbotcombat.cpp index 61224e2..8433c89 100644 --- a/server/src/playerbotcombat.cpp +++ b/server/src/playerbotcombat.cpp @@ -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) { @@ -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); } @@ -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* patrolPoints = activeHuntRegion ? &activeHuntRegion->patrolPoints : nullptr; const Position& target = patrolPoints && !patrolPoints->empty() ? diff --git a/server/src/playerbotcontroller.cpp b/server/src/playerbotcontroller.cpp index 784b598..4e27d6b 100644 --- a/server/src/playerbotcontroller.cpp +++ b/server/src/playerbotcontroller.cpp @@ -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 && @@ -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(player->getMaxHealth()) && std::chrono::steady_clock::now() - huntRegionStarted < std::chrono::minutes(2)) { diff --git a/server/src/playerbotcontroller.h b/server/src/playerbotcontroller.h index 8a5af76..9fa1598 100644 --- a/server/src/playerbotcontroller.h +++ b/server/src/playerbotcontroller.h @@ -341,6 +341,17 @@ class PlayerBotController : public std::enable_shared_from_this auditedKnightSpells = {{ + {"Light Healing", "exura", KnightSpellRole::Healing}, + {"Haste", "utani hur", KnightSpellRole::Support}, + {"Berserk", "exori", KnightSpellRole::MeleeOffense}, + {"Whirlwind Throw", "exori hur", KnightSpellRole::RangedOffense}, + }}; + + const AuditedKnightSpellDescriptor* findAuditedKnightSpell(const char* name) + { + auto it = std::find_if(auditedKnightSpells.begin(), auditedKnightSpells.end(), [name](const auto& descriptor) { + return std::strcmp(descriptor.name, name) == 0; + }); + return it == auditedKnightSpells.end() ? nullptr : &*it; + } + + const char* roleName(KnightSpellRole role) + { + switch (role) { + case KnightSpellRole::Healing: return "healing"; + case KnightSpellRole::Support: return "support"; + case KnightSpellRole::MeleeOffense: return "melee_offense"; + case KnightSpellRole::RangedOffense: return "ranged_offense"; + } + return "unsupported"; + } + + const char* fallbackForRole(KnightSpellRole role) + { + return role == KnightSpellRole::Healing ? "small_health_potion" : + role == KnightSpellRole::Support ? "continue_route" : "normal_melee"; + } + + const char* fallbackForNeed(const char* need) + { + return std::strcmp(need, "recovery") == 0 ? "small_health_potion" : + std::strcmp(need, "safe_route") == 0 ? "continue_route" : "normal_melee"; + } + +} + +void PlayerBotController::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 +{ + std::ostringstream fields; + fields << "\"action\":\"cast_spell\",\"result\":" << jsonString(result) + << ",\"need\":" << jsonString(need) + << ",\"selected_method\":" << jsonString(spellName ? "spell" : "none") + << ",\"policy_candidate\":"; + if (spellName) { + fields << "{\"spell\":" << jsonString(spellName) << ",\"words\":" << jsonString(words) + << ",\"role\":" << jsonString(role) << '}'; + } else { + fields << "null"; + } + fields << ",\"legal_candidates\":["; + if (spellName && std::strcmp(engineResult, "accepted") == 0) { + fields << jsonString(spellName); + } + fields << "],\"engine_result\":" << jsonString(engineResult); + if (pending) { + fields << ",\"mana_before\":" << pending->manaBefore + << ",\"mana_after\":" << (player ? player->getMana() : pending->manaBefore) + << ",\"mana_reserve\":" << pending->manaReserve + << ",\"reserve_survives\":" << ((player && player->getMana() >= pending->manaReserve) ? "true" : "false") + << ",\"health_before\":" << pending->healthBefore + << ",\"health_after\":" << (player ? player->getHealth() : pending->healthBefore); + if (pending->targetId != 0) { + fields << ",\"target_id\":" << pending->targetId + << ",\"target_health_before\":" << pending->targetHealthBefore; + } + } + if (reason) { + fields << ",\"reason\":" << jsonString(reason); + } + fields << ",\"fallback\":" << (fallback ? jsonString(fallback) : "null"); + emit("action_result", position, fields.str()); +} + +bool PlayerBotController::startSpellCast(Player& player, const Position& position, const char* spellName, const char* need, + Creature* target) +{ + const AuditedKnightSpellDescriptor* descriptor = findAuditedKnightSpell(spellName); + if (!descriptor) { + emitSpellCastEvent(position, nullptr, nullptr, nullptr, need, "skipped", "not_attempted", "unsupported_descriptor", nullptr, + &player, fallbackForNeed(need)); + return false; + } + InstantSpell* spell = g_spells ? g_spells->getInstantSpellByName(descriptor->name) : nullptr; + if (!spell || spell->getWords() != descriptor->words || !spell->isLearnable()) { + emitSpellCastEvent(position, descriptor->name, descriptor->words, roleName(descriptor->role), need, "skipped", + "not_attempted", "unsupported_metadata", nullptr, &player, fallbackForRole(descriptor->role)); + return false; + } + if (!player.hasLearnedInstantSpell(descriptor->name)) { + if (shouldEmitRepeated("cast_spell:unlearned:" + std::string(descriptor->name))) { + emitSpellCastEvent(position, descriptor->name, descriptor->words, roleName(descriptor->role), need, "skipped", + "not_attempted", "unlearned", nullptr, &player, fallbackForRole(descriptor->role)); + } + return false; + } + if (!player.canDoAction() || !pendingSpellCast.name.empty()) { + return false; + } + const bool healingGroup = descriptor->role == KnightSpellRole::Healing || descriptor->role == KnightSpellRole::Support; + if (player.hasCondition(healingGroup ? CONDITION_EXHAUST_HEAL : CONDITION_EXHAUST_COMBAT)) { + if (shouldEmitRepeated("cast_spell:cooldown:" + std::string(descriptor->name))) { + emitSpellCastEvent(position, descriptor->name, descriptor->words, roleName(descriptor->role), need, "skipped", + "not_attempted", "cooldown", nullptr, &player, fallbackForRole(descriptor->role)); + } + return false; + } + if ((descriptor->role == KnightSpellRole::MeleeOffense || descriptor->role == KnightSpellRole::RangedOffense) && + (!target || target->isRemoved() || target->isDead() || player.getAttackedCreature() != target || + !player.canSeeCreature(target) || !player.canSee(target->getPosition()) || + !Position::areInRange<1, 1, 0>(position, target->getPosition()))) { + if (shouldEmitRepeated("cast_spell:lost_target:" + std::string(descriptor->name))) { + emitSpellCastEvent(position, descriptor->name, descriptor->words, roleName(descriptor->role), need, "skipped", + "not_attempted", "lost_target", nullptr, &player, "normal_melee"); + } + return false; + } + if (spell->getNeedTarget() && !spell->canThrowSpell(&player, target)) { + if (shouldEmitRepeated("cast_spell:target_unreachable:" + std::string(descriptor->name))) { + emitSpellCastEvent(position, descriptor->name, descriptor->words, roleName(descriptor->role), need, "skipped", + "not_attempted", "target_unreachable", nullptr, &player, "normal_melee"); + } + return false; + } + const uint32_t manaCost = spell->getManaCost(&player); + const uint32_t reserve = descriptor->role == KnightSpellRole::Healing ? 0 : higherPriorityRecoveryManaReserve; + if (player.getMana() < manaCost + reserve) { + if (shouldEmitRepeated("cast_spell:insufficient_mana_reserve:" + std::string(descriptor->name))) { + emitSpellCastEvent(position, descriptor->name, descriptor->words, roleName(descriptor->role), need, "skipped", + "not_attempted", "insufficient_mana_reserve", nullptr, &player, fallbackForRole(descriptor->role)); + } + return false; + } + + pendingSpellCast = {descriptor->name, roleName(descriptor->role), need, player.getMana(), reserve, player.getHealth(), + target ? target->getID() : 0, target ? target->getHealth() : 0}; + ++counters.actionsAttempted; + emitSpellCastEvent(position, descriptor->name, descriptor->words, roleName(descriptor->role), need, "requested", "unchecked", + nullptr, &pendingSpellCast, &player, nullptr); + // Route through the normal player speech handler so the live spell engine owns legality and costs. + g_game.playerSay(playerId, 0, TALKTYPE_SAY, "", spell->getWords()); + return true; +} + +void PlayerBotController::verifySpellCast(Player& player, const Position& position) +{ + if (pendingSpellCast.name.empty()) { + return; + } + const AuditedKnightSpellDescriptor* descriptor = findAuditedKnightSpell(pendingSpellCast.name.c_str()); + const bool manaSpent = player.getMana() < pendingSpellCast.manaBefore; + bool observed = false; + if (pendingSpellCast.role == "healing") { + observed = player.getHealth() > pendingSpellCast.healthBefore; + } else if (pendingSpellCast.role == "support") { + observed = player.hasCondition(CONDITION_HASTE); + } else { + Creature* target = g_game.getCreatureByID(pendingSpellCast.targetId); + observed = !target || target->isRemoved() || target->isDead() || target->getHealth() < pendingSpellCast.targetHealthBefore; + } + const bool success = manaSpent && observed; + const char* reason = success ? nullptr : !manaSpent ? "cast_not_verified" : "ineffective_result"; + const char* fallback = success ? nullptr : pendingSpellCast.role == "healing" ? "small_health_potion" : + pendingSpellCast.role == "support" ? "continue_route" : "normal_melee"; + emitSpellCastEvent(position, descriptor ? descriptor->name : nullptr, descriptor ? descriptor->words : nullptr, + descriptor ? roleName(descriptor->role) : nullptr, pendingSpellCast.need.c_str(), + success ? "success" : "failed", manaSpent ? "accepted" : "rejected", reason, + &pendingSpellCast, &player, fallback); + if (!success) { + ++counters.actionsFailed; + spellRetryAfter = std::chrono::steady_clock::now() + healingRetryInterval; + } + pendingSpellCast = PendingSpellCast{}; +} + +bool PlayerBotController::handleSpellHealing(Player* player, const Position& currentPosition) +{ + if (!player || !pendingSpellCast.name.empty() || std::chrono::steady_clock::now() < spellRetryAfter) { + return false; + } + const int32_t missingHealth = player->getMaxHealth() - player->getHealth(); + if (missingHealth > smallHealthPotionMaximumHealing && getInventoryItemCount(*player, smallHealthPotionItemId) != 0) { + return false; + } + return startSpellCast(*player, currentPosition, "Light Healing", "recovery"); +} + +bool PlayerBotController::trySupportSpell(Player* player, const Position& currentPosition) +{ + if (!player || !pendingSpellCast.name.empty() || player->hasCondition(CONDITION_HASTE) || + std::chrono::steady_clock::now() < spellRetryAfter || navigationSteps.size() < minimumHasteRouteSteps || + needsHealing(*player)) { + return false; + } + return startSpellCast(*player, currentPosition, "Haste", "safe_route"); +} + +bool PlayerBotController::tryOffensiveSpell(Player* player, const Position& currentPosition) +{ + if (!player || !pendingSpellCast.name.empty() || std::chrono::steady_clock::now() < spellRetryAfter || needsHealing(*player)) { + return false; + } + Creature* target = g_game.getCreatureByID(ratId); + if (player->hasLearnedInstantSpell("Berserk") && player->getLevel() >= 35) { + return startSpellCast(*player, currentPosition, "Berserk", "offense", target); + } + return startSpellCast(*player, currentPosition, "Whirlwind Throw", "offense", target); } uint64_t PlayerBotController::spellTrainingReserve(const Player& player) const diff --git a/server/tests/playerbot-gameplay/playerbot_gameplay.lua b/server/tests/playerbot-gameplay/playerbot_gameplay.lua index e9d7dba..98a62a4 100644 --- a/server/tests/playerbot-gameplay/playerbot_gameplay.lua +++ b/server/tests/playerbot-gameplay/playerbot_gameplay.lua @@ -15,6 +15,7 @@ local containerDeathItemMonsterName = "Playerbot Container Death Item" local defensiveMonsterName = "Playerbot Defensive Threat" local levelEightMonsterName = "Playerbot Level Eight Target" local deathMonsterName = "Playerbot Death Threat" +local spellTargetMonsterName = "Playerbot Spell Target" local valueMonsterName = "Playerbot Value Corpse" local healingPotionCount = 3 local starterArmorId = 2650 @@ -29,6 +30,8 @@ local departureRecoveryStorage = 50090 local depotFixtureStorage = 50095 local spellTrainingStorage = 50097 local deathLoginCount = 0 +local spellTargetId = 0 +local spellSupportObserved = false local removeAll local function restoreRookgaardBaseline(player) @@ -135,7 +138,7 @@ local function suppressNearbyMonsters(playerId) for _, creature in ipairs(Game.getSpectators(player:getPosition(), true, false, 10, 10, 10, 10)) do if creature:isMonster() and creature:getName() ~= emptyMonsterName and creature:getName() ~= lootMonsterName and creature:getName() ~= nonlootableMonsterName and creature:getName() ~= containerDeathItemMonsterName and - creature:getName() ~= valueMonsterName then + creature:getName() ~= valueMonsterName and creature:getName() ~= spellTargetMonsterName then creature:remove() end end @@ -208,6 +211,109 @@ local function spawnDeathMonster(playerId) error("no adjacent tile was available for death telemetry test monster") end +local function spawnSpellTarget(playerId) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared before spell target spawn") + local origin = player:getPosition() + for _, position in ipairs({ + Position(origin.x + 1, origin.y, origin.z), + Position(origin.x, origin.y + 1, origin.z), + Position(origin.x - 1, origin.y, origin.z), + Position(origin.x, origin.y - 1, origin.z), + }) do + local tile = Tile(position) + if tile and tile:isWalkable() then + local monster = Game.createMonster(spellTargetMonsterName, position, true, true) + assert(monster, "spell-use fixture could not create a target") + spellTargetId = monster:getId() + print("PLAYERBOT_GAMEPLAY_TEST SPELL_TARGET_SPAWNED " .. spellTargetId) + return + end + end + error("no adjacent tile was available for the spell-use target") +end + +local function triggerSpellRecoveryPreemption(playerId) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared before spell recovery preemption") + assert(player:addMana(200 - player:getMana()), "spell-use fixture could not restore recovery mana") + assert(player:setHealth(110), "spell-use fixture could not trigger emergency recovery") + print("PLAYERBOT_GAMEPLAY_TEST SPELL_RECOVERY_PREEMPTION_TRIGGERED") +end + +local function waitForSpellAttackTarget(playerId, attempts) + local player = Player(playerId) + local target = spellTargetId ~= 0 and Monster(spellTargetId) or nil + local attacked = player and player:getTarget() or nil + assert(player and not player:isRemoved(), "Bot One disappeared before spell target acquisition") + if target and attacked and attacked:getId() == target:getId() then + addEvent(triggerSpellRecoveryPreemption, 1800, playerId) + return + end + if attempts > 0 then + addEvent(waitForSpellAttackTarget, 250, playerId, attempts - 1) + return + end + error("spell-use fixture did not establish an attacked target") +end + +local function prepareSpellRecoveryPreemption(playerId) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared before spell target preparation") + assert(player:addMana(50 - player:getMana()), "spell-use fixture could not reserve too little mana for offense") + spawnSpellTarget(playerId) + addEvent(waitForSpellAttackTarget, 250, playerId, 120) +end + +local function waitForSpellSupport(playerId, attempts) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared before spell support") + if not player:hasCondition(CONDITION_HASTE) and attempts > 0 then + addEvent(waitForSpellSupport, 250, playerId, attempts - 1) + return + end + assert(player:hasCondition(CONDITION_HASTE), "spell-use fixture did not apply Haste before low-mana combat") + spellSupportObserved = true + prepareSpellRecoveryPreemption(playerId) +end + +local function restoreLightHealing(playerId) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared before Light Healing restoration") + assert(player:learnSpell("Light Healing"), "spell-use fixture could not restore Light Healing") +end + +local function triggerSpellFallback(playerId) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared before spell fallback") + assert(player:forgetSpell("Light Healing"), "spell-use fixture could not forget Light Healing") + assert(player:addItem(potionItemId, 1), "spell-use fixture could not preserve the potion reserve") + assert(player:setHealth(110), "spell-use fixture could not trigger spell fallback") + print("PLAYERBOT_GAMEPLAY_TEST SPELL_FALLBACK_TRIGGERED") +end + +local function verifySpellUse(playerId, attempts) + local player = Player(playerId) + assert(player and not player:isRemoved(), "Bot One disappeared during spell-use fixture") + local target = spellTargetId ~= 0 and Monster(spellTargetId) or nil + local recovered = player:getHealth() > 110 + local hasted = spellSupportObserved + local damaged = spellTargetId ~= 0 and (not target or target:isRemoved() or target:getHealth() < 1000) + local offenseCast = player:getMana() == 140 + if (not recovered or not hasted or not damaged or not offenseCast) and attempts > 0 then + addEvent(verifySpellUse, 500, playerId, attempts - 1) + return + end + assert(recovered, "Light Healing did not restore Bot One") + assert(hasted, "Haste did not apply its normal condition") + assert(damaged, "Whirlwind Throw did not damage its visible target") + assert(offenseCast, "Whirlwind Throw did not consume its normal mana cost") + if target then + target:remove() + end + print("PLAYERBOT_GAMEPLAY_TEST SPELL_USE_PASS") +end + local function prepareDeath(playerId) local player = Player(playerId) assert(player and not player:isRemoved(), "Bot One disappeared before death recovery setup") @@ -449,7 +555,7 @@ function login.onLogin(player) end local mode = os.getenv("PLAYERBOT_GAMEPLAY_MODE") or "cycle" - assert(mode == "mainland" or mode == "cycle" or mode == "depot" or mode == "navigation" or mode == "target_pursuit" or mode == "target_pursuit_abandon" or mode == "corpse" or mode == "death" or mode == "healing" or + assert(mode == "mainland" or mode == "cycle" or mode == "depot" or mode == "navigation" or mode == "target_pursuit" or mode == "target_pursuit_abandon" or mode == "corpse" or mode == "death" or mode == "healing" or mode == "spell_use" 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 @@ -471,6 +577,27 @@ function login.onLogin(player) print("PLAYERBOT_GAMEPLAY_TEST MAINLAND_START") return true end + if mode == "spell_use" then + assert(player:setVocation(4), "spell-use fixture could not select Knight") + local requiredExperience = Game.getExperienceForLevel(20) - player:getExperience() + if requiredExperience > 0 then player:addExperience(requiredExperience) end + assert(player:getLevel() == 20, "spell-use fixture could not select level 20") + assert(player:teleportTo(depotPosition), "spell-use fixture could not reach the depot") + for _, spellName in ipairs({"Light Healing", "Haste", "Whirlwind Throw"}) do + assert(player:learnSpell(spellName), "spell-use fixture could not prelearn " .. spellName) + end + assert(player:setMaxHealth(200), "spell-use fixture could not normalize health") + assert(player:setHealth(110), "spell-use fixture could not lower health") + assert(player:setMaxMana(200), "spell-use fixture could not normalize mana") + assert(player:addMana(player:getMaxMana() - player:getMana()), "spell-use fixture could not restore mana") + suppressNearbyMonsters(player:getId()) + addEvent(triggerSpellFallback, 4000, player:getId()) + addEvent(restoreLightHealing, 6000, player:getId()) + addEvent(waitForSpellSupport, 6000, player:getId(), 120) + addEvent(verifySpellUse, 500, player:getId(), 120) + print("PLAYERBOT_GAMEPLAY_TEST SPELL_USE_START") + return true + end restoreRookgaardBaseline(player) if mode == "readiness_ready" or mode == "readiness_upgrade" or mode == "readiness_missing_weapon" or mode == "readiness_supplies" or mode == "readiness_retention" then diff --git a/server/tests/playerbot-monsters/corpse.lua b/server/tests/playerbot-monsters/corpse.lua index f1288f8..66e9f65 100644 --- a/server/tests/playerbot-monsters/corpse.lua +++ b/server/tests/playerbot-monsters/corpse.lua @@ -39,6 +39,7 @@ registerCorpseTestMonster("Playerbot Level Eight Target", {}, nil, false, 1, nil registerCorpseTestMonster("Playerbot Value Corpse", { {id = 2826, chance = 100000, maxCount = 1}, }) +registerCorpseTestMonster("Playerbot Spell Target", {}, nil, false, 1000) registerCorpseTestMonster("Playerbot Death Threat", {}, nil, true, 100000, { {name = "combat", type = COMBAT_PHYSICALDAMAGE, interval = 100, chance = 100, minDamage = -10000, maxDamage = -10000, target = true, range = 1},