Merge pull request #820 from nolt/feature-bots
(cherry picked from commit b10de0645a869485fbb5771a739abd06b5708c2d)
This commit is contained in:
@@ -25,6 +25,8 @@ public sealed class BuffHandler
|
||||
private int _nextSlotIndex;
|
||||
private bool _buffTimerTriggered;
|
||||
private DateTime? _nextPeriodicBuffTime;
|
||||
private IList<int>? _cachedAutoBuffIds;
|
||||
private int _cachedAutoBuffSkillCount = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="BuffHandler"/> class.
|
||||
@@ -38,7 +40,9 @@ public sealed class BuffHandler
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the configured buff skill IDs from the settings.
|
||||
/// Gets the configured buff skill IDs from the settings. With <see cref="IMuHelperSettings.AutoSelectBuffs"/>
|
||||
/// enabled (server-side bots), the character's learned buff skills are used instead of the explicitly
|
||||
/// configured slots, so each class keeps its own buffs up without any per-character configuration.
|
||||
/// </summary>
|
||||
public IList<int> ConfiguredBuffIds
|
||||
{
|
||||
@@ -49,6 +53,34 @@ public sealed class BuffHandler
|
||||
return [];
|
||||
}
|
||||
|
||||
if (this._config.AutoSelectBuffs && this._player.SkillList is { } skillList)
|
||||
{
|
||||
// The learned buffs only change when a new skill is learned, so the list is cached and
|
||||
// only rebuilt when the skill count changes - building it fresh with LINQ on every
|
||||
// 500ms tick of hundreds of bots was measurable CPU for no benefit.
|
||||
var skillCount = skillList.Skills.Count();
|
||||
if (this._cachedAutoBuffIds is null || skillCount != this._cachedAutoBuffSkillCount)
|
||||
{
|
||||
var learnedBuffs = skillList.Skills
|
||||
.Where(s => s.Skill is { SkillType: SkillType.Buff, MagicEffectDef: not null })
|
||||
.Select(s => (int)s.Skill!.Number)
|
||||
.OrderBy(n => n)
|
||||
.Take(BuffSlotCount)
|
||||
.ToList();
|
||||
|
||||
// Pad to the fixed slot count - the caller indexes all three slots; 0 means "slot empty".
|
||||
while (learnedBuffs.Count < BuffSlotCount)
|
||||
{
|
||||
learnedBuffs.Add(0);
|
||||
}
|
||||
|
||||
this._cachedAutoBuffIds = learnedBuffs;
|
||||
this._cachedAutoBuffSkillCount = skillCount;
|
||||
}
|
||||
|
||||
return this._cachedAutoBuffIds;
|
||||
}
|
||||
|
||||
return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id];
|
||||
}
|
||||
}
|
||||
@@ -246,8 +278,21 @@ public sealed class BuffHandler
|
||||
return false;
|
||||
}
|
||||
|
||||
return target.MagicEffectList.ActiveEffects.Values
|
||||
.Any(e => e.Definition == effectDef);
|
||||
try
|
||||
{
|
||||
// Eager snapshot, like the other readers of ActiveEffects (see MagicEffectsList): the
|
||||
// list is mutated by the effect expiry timers, and a lazy enumeration from this
|
||||
// (unsynchronized) helper tick raced them regularly at scale. A list shrinking in the
|
||||
// middle of the copy can still leave null holes in the snapshot (hence the tolerant
|
||||
// predicate) or throw out of the copy itself (hence the catch-all around this pure
|
||||
// read) - any torn read simply counts as "active", and the next tick retries.
|
||||
var activeEffects = target.MagicEffectList.ActiveEffects.Values.ToArray();
|
||||
return activeEffects.Any(e => e?.Definition == effectDef);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdatePeriodicBuffTimer()
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
|
||||
namespace MUnique.OpenMU.GameLogic.Offline;
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using MUnique.OpenMU.AttributeSystem;
|
||||
using MUnique.OpenMU.GameLogic.Attributes;
|
||||
using MUnique.OpenMU.GameLogic.Bots;
|
||||
using MUnique.OpenMU.GameLogic.MuHelper;
|
||||
using MUnique.OpenMU.GameLogic.NPC;
|
||||
using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
|
||||
@@ -19,26 +22,82 @@ public sealed class CombatHandler
|
||||
{
|
||||
private const byte DefaultRange = 1;
|
||||
private const byte BowRange = 6;
|
||||
|
||||
/// <summary>
|
||||
/// See <see cref="IsSafeTarget"/>: the largest share of the bot's maximum health a single average
|
||||
/// monster hit may take for the monster to count as safe. Sized so the bot survives several hits
|
||||
/// even when a few monsters aggro at once, with the healing handler (potions at 60%) keeping up.
|
||||
/// Tightened from 0.20 with the player-meta stat builds: their small health pools mean melee bots
|
||||
/// (which stand inside the monster pack) need a bigger margin per hit to survive a swarm.
|
||||
/// </summary>
|
||||
private const float SafeHitHealthShare = 0.15f;
|
||||
|
||||
/// <summary>
|
||||
/// See <see cref="IsSafeTarget"/>: the bot's attack power must exceed the monster's defense by this
|
||||
/// factor. Without it a bot picks fights it can barely scratch - e.g. the Vulcanus tank monsters
|
||||
/// (defense ~340, health ~100k) shrug off a modestly geared bot's hits, the "fight" lasts minutes,
|
||||
/// and the accumulated damage kills the bot even though every single hit it takes looks survivable.
|
||||
/// </summary>
|
||||
private const float MinAttackAdvantage = 1.2f;
|
||||
|
||||
/// <summary>
|
||||
/// See <see cref="IsSafeTarget"/>: the monster must die within this many net hits of the bot.
|
||||
/// The per-hit checks alone let a fighter "safely" besiege a 100k-health tank monster for ten
|
||||
/// minutes, until its potions ran dry and it died anyway - the fight length itself is the risk.
|
||||
/// At the offline AI's attack pace this bounds a kill to roughly a minute or two.
|
||||
/// </summary>
|
||||
private const int MaxHitsToKill = 100;
|
||||
|
||||
/// <summary>
|
||||
/// See <see cref="IsSafeTarget"/>: how much longer a mastered bot may take to kill a monster which
|
||||
/// pays master experience. Master experience is only granted for monsters of at least
|
||||
/// <c>GameConfiguration.MinimumMonsterLevelForMasterExperience</c>, and those hold 40.000+ health -
|
||||
/// out of reach of the regular hit budget for a bot in the gear it collects from drops, which left
|
||||
/// mastered bots hunting monsters that pay them nothing at all. Since a character at the maximum
|
||||
/// level earns nothing else either, a long fight it survives beats a quick one worth zero: the
|
||||
/// budget is stretched for those monsters only, while the survivability check below is NOT - a bot
|
||||
/// still refuses a monster whose hits it cannot take.
|
||||
/// </summary>
|
||||
private const int MasterHitBudgetFactor = 3;
|
||||
private const int ComboFinisherDelayTicks = 3;
|
||||
private const int InterSkillDelayTicks = 1;
|
||||
private const int MinComboSkillCount = 3;
|
||||
|
||||
/// <summary>After this many consecutive failed approaches the target counts as unreachable.</summary>
|
||||
private const int MaxApproachFailures = 3;
|
||||
|
||||
private const short DrainLifeBaseSkillId = 214;
|
||||
private const short DrainLifeStrengthenerSkillId = 458;
|
||||
private const short DrainLifeMasterySkillId = 462;
|
||||
|
||||
/// <summary>How long an unreachable target is ignored before it may be considered again.</summary>
|
||||
private static readonly TimeSpan UnreachableTargetBlacklistDuration = TimeSpan.FromSeconds(10);
|
||||
|
||||
private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new();
|
||||
|
||||
/// <summary>
|
||||
/// Cache of the combat-relevant stats of monster definitions (config data, immutable at runtime),
|
||||
/// so the safety checks of hundreds of bots don't re-scan the attribute lists every tick. Keyed by
|
||||
/// the monster number rather than the definition instance, so a configuration reload (which builds
|
||||
/// new <see cref="MonsterDefinition"/> instances) reuses the entries instead of orphaning them.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<short, (int Level, float AverageDamage, float Defense, float Health, float AttackRate)> MonsterStatsCache = new();
|
||||
|
||||
private readonly OfflinePlayer _player;
|
||||
private readonly IMuHelperSettings? _config;
|
||||
private readonly MovementHandler _movementHandler;
|
||||
private readonly Point _originPosition;
|
||||
private readonly ConditionalSkillSlot[] _conditionalSkillSlots;
|
||||
|
||||
private IAttackable? _currentTarget;
|
||||
private int _nearbyMonsterCount;
|
||||
private int _currentComboStep;
|
||||
private int _skillCooldownTicks;
|
||||
private int _approachFailures;
|
||||
private ushort _unreachableTargetId;
|
||||
private DateTime _unreachableTargetUntilUtc = DateTime.MinValue;
|
||||
private SkillEntry? _tickBestSkill;
|
||||
private bool _tickBestSkillComputed;
|
||||
private DateTime _engageAtUtc = DateTime.MinValue;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CombatHandler"/> class.
|
||||
@@ -46,13 +105,11 @@ public sealed class CombatHandler
|
||||
/// <param name="player">The offline player.</param>
|
||||
/// <param name="config">The MU helper settings.</param>
|
||||
/// <param name="movementHandler">The movement handler.</param>
|
||||
/// <param name="originPosition">The original position to hunt around.</param>
|
||||
public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHandler movementHandler, Point originPosition)
|
||||
public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHandler movementHandler)
|
||||
{
|
||||
this._player = player;
|
||||
this._config = config;
|
||||
this._movementHandler = movementHandler;
|
||||
this._originPosition = originPosition;
|
||||
this._conditionalSkillSlots = config is null ? [] :
|
||||
[
|
||||
new ConditionalSkillSlot(config.ActivationSkill1Id, config.Skill1UseTimer, config.DelayMinSkill1, config.Skill1UseCondition, config.Skill1ConditionAttacking, config.Skill1SubCondition),
|
||||
@@ -70,6 +127,19 @@ public sealed class CombatHandler
|
||||
/// </summary>
|
||||
public byte HuntingRange => CalculateHuntingRange(this._config);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds.
|
||||
/// </summary>
|
||||
private Point OriginPosition => this._player.HuntingOrigin;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this session animates a server-side bot rather than the offline
|
||||
/// session of a real player. Bots trade a bit of hunting efficiency for looking human (reaction
|
||||
/// delay, target spread); a player's offline session must behave exactly as it did before the bots
|
||||
/// moved into this handler.
|
||||
/// </summary>
|
||||
private bool IsBot => this._player.Account?.IsBot == true;
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the hunting range in tiles from the specified configuration.
|
||||
/// </summary>
|
||||
@@ -85,6 +155,72 @@ public sealed class CombatHandler
|
||||
return (byte)Math.Max(DefaultRange, config.HuntingRange);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether the monster is one the bot can fight without dying, judged by the monster's
|
||||
/// REAL combat stats instead of its nominal level: the average hit it lands (its base damage minus
|
||||
/// the bot's PvM defense, the same subtraction the damage formula applies) must not exceed
|
||||
/// <see cref="SafeHitHealthShare"/> of the bot's maximum health. A monster's level says nothing
|
||||
/// about its punch - the high-end maps (Swamp of Calmness, LaCleon, the event fortresses) field
|
||||
/// "level ~120" monsters which hit for 1000-2300 base damage, several times what regular maps'
|
||||
/// monsters of the same level deal - so a level cap sent high-level bots in modest gear straight
|
||||
/// into a death loop there. Judging by damage also scales naturally with equipment: better armor
|
||||
/// raises the bot's defense and unlocks tougher maps, exactly like it does for a real player.
|
||||
/// The bot's own offense must in turn exceed the monster's defense (<see cref="MinAttackAdvantage"/>),
|
||||
/// so it never besieges a tank monster it can barely scratch, and the monster's level must not
|
||||
/// exceed the bot's own (on reset servers: its reset-aware effective level, see
|
||||
/// <see cref="BotResetHandler.GetEffectiveLevel"/>).
|
||||
/// Shared by the combat AI and the bot navigator, so a bot never stops travelling for (or engages)
|
||||
/// a monster it should not fight.
|
||||
/// </summary>
|
||||
/// <param name="player">The bot player.</param>
|
||||
/// <param name="monster">The monster definition.</param>
|
||||
public static bool IsSafeTarget(Player player, MonsterDefinition monster)
|
||||
{
|
||||
var (monsterLevel, averageDamage, monsterDefense, monsterHealth, monsterAttackRate) = GetMonsterCombatStats(monster);
|
||||
if (monsterLevel <= 0 || player.Attributes is not { } attributes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reset-aware: on servers with the reset feature a freshly reset character is nominally a
|
||||
// low level again but keeps the strength of its resets - the effective level keeps it from
|
||||
// being locked out of the maps it just hunted on.
|
||||
if (monsterLevel > BotResetHandler.GetEffectiveLevel(player))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var netHit = Math.Max(0f, averageDamage - attributes[Stats.DefensePvm]) * GetExpectedHitShare(player, monsterAttackRate);
|
||||
if (netHit > SafeHitHealthShare * Math.Max(1f, attributes[Stats.MaximumHealth]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var attackPower = GetAttackPower(player);
|
||||
if (attackPower <= monsterDefense * MinAttackAdvantage)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return (attackPower - monsterDefense) * GetHitBudget(player, monsterLevel) >= monsterHealth;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The number of net hits the bot may take to kill the monster (see <see cref="MaxHitsToKill"/>),
|
||||
/// stretched by <see cref="MasterHitBudgetFactor"/> for a mastered bot fighting a monster which
|
||||
/// actually pays it master experience (see <see cref="MasterHitBudgetFactor"/>).
|
||||
/// </summary>
|
||||
private static int GetHitBudget(Player player, int monsterLevel)
|
||||
{
|
||||
var configuration = player.GameContext.Configuration;
|
||||
var isMastered = player.SelectedCharacter?.CharacterClass?.IsMasterClass == true
|
||||
&& (player.Attributes?[Stats.Level] ?? 0) >= configuration.MaximumLevel;
|
||||
|
||||
return isMastered && monsterLevel >= configuration.MinimumMonsterLevelForMasterExperience
|
||||
? MaxHitsToKill * MasterHitBudgetFactor
|
||||
: MaxHitsToKill;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decrements the skill cooldown counter by one tick.
|
||||
/// </summary>
|
||||
@@ -101,6 +237,7 @@ public sealed class CombatHandler
|
||||
/// </summary>
|
||||
public async ValueTask PerformAttackAsync()
|
||||
{
|
||||
var previousTarget = this._currentTarget;
|
||||
this.RefreshTarget();
|
||||
|
||||
if (this._currentTarget is null)
|
||||
@@ -108,13 +245,47 @@ public sealed class CombatHandler
|
||||
return;
|
||||
}
|
||||
|
||||
// A human doesn't strike the very same instant a new target appears: give each fresh target a
|
||||
// small randomized reaction delay (the bot faces it, then engages) - the perfectly metronomic
|
||||
// instant-strike cadence is one of the clearest bot giveaways. Only bots pay for the disguise:
|
||||
// a player's own offline session must hunt exactly as fast as it always did.
|
||||
if (this.IsBot)
|
||||
{
|
||||
if (!ReferenceEquals(previousTarget, this._currentTarget))
|
||||
{
|
||||
this._engageAtUtc = DateTime.UtcNow.AddMilliseconds(Rand.NextInt(250, 900));
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow < this._engageAtUtc)
|
||||
{
|
||||
this._player.Rotation = this._player.GetDirectionTo(this._currentTarget);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
byte attackRange = this.GetEffectiveAttackRange();
|
||||
if (!this.IsTargetInAttackRange(this._currentTarget, attackRange))
|
||||
{
|
||||
await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false);
|
||||
if (await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false))
|
||||
{
|
||||
this._approachFailures = 0;
|
||||
}
|
||||
else if (++this._approachFailures >= MaxApproachFailures)
|
||||
{
|
||||
// The target is in (Euclidean) range but no walkable path leads to it - e.g. a monster
|
||||
// across a wall or river. Blacklist it briefly and drop it, so the bot picks another
|
||||
// target (or moves on) instead of standing in front of the obstacle forever.
|
||||
this._unreachableTargetId = this._currentTarget.Id;
|
||||
this._unreachableTargetUntilUtc = DateTime.UtcNow + UnreachableTargetBlacklistDuration;
|
||||
this._currentTarget = null;
|
||||
this._approachFailures = 0;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this._approachFailures = 0;
|
||||
|
||||
if (this._config?.UseCombo == true)
|
||||
{
|
||||
await this.ExecuteComboAttackAsync().ConfigureAwait(false);
|
||||
@@ -157,6 +328,75 @@ public sealed class CombatHandler
|
||||
}
|
||||
}
|
||||
|
||||
private static (int Level, float AverageDamage, float Defense, float Health, float AttackRate) GetMonsterCombatStats(MonsterDefinition monster)
|
||||
{
|
||||
return MonsterStatsCache.GetOrAdd(
|
||||
monster.Number,
|
||||
static (_, m) =>
|
||||
{
|
||||
float GetValue(AttributeDefinition attribute)
|
||||
=> m.Attributes.FirstOrDefault(a => a.AttributeDefinition == attribute)?.Value ?? 0f;
|
||||
|
||||
var level = (int)GetValue(Stats.Level);
|
||||
var averageDamage = (GetValue(Stats.MinimumPhysBaseDmg) + GetValue(Stats.MaximumPhysBaseDmg)) / 2f;
|
||||
return (level, averageDamage, GetValue(Stats.DefenseBase), GetValue(Stats.MaximumHealth), GetValue(Stats.AttackRatePvm));
|
||||
},
|
||||
monster);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How much of the monster's average hit actually lands on the bot, over time: the engine rolls
|
||||
/// every monster swing against the bot's defense rate (see the hit chance in
|
||||
/// <see cref="AttackableExtensions"/>), so an agility-based character tanks by DODGING, not by
|
||||
/// soaking. Judging its safety by the raw hit alone declared every such build too squishy for
|
||||
/// anything past the starter maps - and left the whole caster population stuck on Lorencia.
|
||||
/// The dodge credit is capped (a bot must not bet its life on a lucky evade streak).
|
||||
/// </summary>
|
||||
private static float GetExpectedHitShare(Player player, float monsterAttackRate)
|
||||
{
|
||||
const float minimumAssumedHitChance = 0.25f;
|
||||
if (monsterAttackRate <= 0f || player.Attributes is not { } attributes)
|
||||
{
|
||||
return 1f;
|
||||
}
|
||||
|
||||
var defenseRate = attributes[Stats.DefenseRatePvm];
|
||||
var hitChance = defenseRate < monsterAttackRate ? 1f - (defenseRate / monsterAttackRate) : 0.03f;
|
||||
return Math.Clamp(hitChance, minimumAssumedHitChance, 1f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A rough estimate of the bot's punch: its best base damage kind (physical for fighters, wizardry
|
||||
/// for casters, curse for summoners) plus the strongest attack skill it has learned - enough to tell
|
||||
/// apart "kills this monster at a reasonable pace" from "barely scratches it".
|
||||
/// </summary>
|
||||
private static float GetAttackPower(Player player)
|
||||
{
|
||||
if (player.Attributes is not { } attributes)
|
||||
{
|
||||
return 0f;
|
||||
}
|
||||
|
||||
var physical = (attributes[Stats.MinimumPhysBaseDmg] + attributes[Stats.MaximumPhysBaseDmg]) / 2f;
|
||||
|
||||
// The Min/Max wizardry damage is what a caster actually hits with (energy feeds it, see the
|
||||
// class attribute relations); Stats.WizardryBaseDmg is only the bonus channel of the staff's
|
||||
// rise - reading it made every caster look like it had no offense at all, so it never passed
|
||||
// the checks below for anything but the starter maps and stayed there forever.
|
||||
var wizardry = (attributes[Stats.MinimumWizBaseDmg] + attributes[Stats.MaximumWizBaseDmg]) / 2f;
|
||||
var curse = (attributes[Stats.MinimumCurseBaseDmg] + attributes[Stats.MaximumCurseBaseDmg]) / 2f;
|
||||
var skillDamage = 0;
|
||||
foreach (var entry in player.SkillList?.Skills ?? [])
|
||||
{
|
||||
if (entry.Skill is { AttackDamage: > 0 } skill && skill.AttackDamage > skillDamage)
|
||||
{
|
||||
skillDamage = skill.AttackDamage;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.Max(physical, Math.Max(wizardry, curse)) + skillDamage;
|
||||
}
|
||||
|
||||
private async ValueTask ExecuteAttackAsync(IAttackable target)
|
||||
{
|
||||
var skill = this.SelectAttackSkill();
|
||||
@@ -170,6 +410,13 @@ public sealed class CombatHandler
|
||||
|
||||
private async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skillEntry, bool isCombo)
|
||||
{
|
||||
// Last line of defense for the "bot must never become an outlaw" invariant: no strike ever
|
||||
// leaves this handler against a player who isn't a legal PvP target right now.
|
||||
if (target is Player playerTarget && !BotPvpRules.IsLegalPvpTarget(this._player, playerTarget))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
this._player.Rotation = this._player.GetDirectionTo(target);
|
||||
|
||||
if (skillEntry?.Skill is not { } skill)
|
||||
@@ -190,6 +437,26 @@ public sealed class CombatHandler
|
||||
|
||||
private void RefreshTarget()
|
||||
{
|
||||
// The best-skill choice is cached for the duration of one tick (it is needed for both the range
|
||||
// check and the actual attack); a new tick starts with a fresh choice.
|
||||
this._tickBestSkill = null;
|
||||
this._tickBestSkillComputed = false;
|
||||
|
||||
// Self-defense has priority over farming: a player who recently attacked this bot becomes the
|
||||
// target, as long as they are still viable and anywhere near. Without this the bot placidly
|
||||
// keeps hitting monsters while a player kills it. The aggressor memory only sets the PRIORITY,
|
||||
// though - whether the bot may actually strike is decided by BotPvpRules per attack: the grudge
|
||||
// outlives the game's self-defense window, and striking outside of it would turn the bot into
|
||||
// an outlaw (see BotPvpRules.IsLegalPvpTarget).
|
||||
if (this._config?.UseSelfDefense == true
|
||||
&& this._player.RecentAggressor is { } aggressor
|
||||
&& aggressor.IsInRange(this._player.Position, this.HuntingRange * 2)
|
||||
&& BotPvpRules.IsLegalPvpTarget(this._player, aggressor))
|
||||
{
|
||||
this._currentTarget = aggressor;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._currentTarget is { } t && !this.IsTargetStillValid(t))
|
||||
{
|
||||
this._currentTarget = null;
|
||||
@@ -197,13 +464,24 @@ public sealed class CombatHandler
|
||||
|
||||
if (this._currentTarget is null)
|
||||
{
|
||||
var monsters = this.GetAttackableMonstersInHuntingRange().ToList();
|
||||
this._currentTarget = monsters.MinBy(m => m.GetDistanceTo(this._player));
|
||||
this._nearbyMonsterCount = monsters.Count;
|
||||
var targets = this.GetAttackableTargetsInHuntingRange().ToList();
|
||||
var candidates = targets
|
||||
.Where(m => m.Id != this._unreachableTargetId || DateTime.UtcNow >= this._unreachableTargetUntilUtc)
|
||||
.OrderBy(m => m.GetDistanceTo(this._player))
|
||||
.Take(this.IsBot ? 2 : 1)
|
||||
.ToList();
|
||||
|
||||
// A bot chooses randomly among the two nearest candidates instead of strictly the nearest
|
||||
// one: with many bots on one ground, deterministic nearest-first makes them all dogpile the
|
||||
// same monster and roam as a pack, which looks distinctly bot-like and wastes damage on
|
||||
// overkill. A player's own offline session keeps hitting the nearest monster - it is his
|
||||
// character's hunting efficiency, not a crowd to camouflage.
|
||||
this._currentTarget = candidates.SelectRandom();
|
||||
this._nearbyMonsterCount = targets.Count;
|
||||
}
|
||||
else
|
||||
{
|
||||
this._nearbyMonsterCount = this.GetAttackableMonstersInHuntingRange().Count();
|
||||
this._nearbyMonsterCount = this.GetAttackableTargetsInHuntingRange().Count();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,11 +492,42 @@ public sealed class CombatHandler
|
||||
return [];
|
||||
}
|
||||
|
||||
return map.GetAttackablesInRange(this._originPosition, this.HuntingRange)
|
||||
return map.GetAttackablesInRange(this.OriginPosition, this.HuntingRange)
|
||||
.OfType<Monster>()
|
||||
.Where(this.IsMonsterAttackable);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The regular target pool are the attackable monsters; inside a mini game which allows player
|
||||
/// killing (Chaos Castle) the other participants join it - there everyone is opposition, and a
|
||||
/// bot which placidly farms monsters while being cut down would be the obvious odd one out.
|
||||
/// </summary>
|
||||
private IEnumerable<IAttackable> GetAttackableTargetsInHuntingRange()
|
||||
{
|
||||
if (this._player.CurrentMap is not { } map)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var freeForAll = this._player.CurrentMiniGame is { AllowPlayerKilling: true };
|
||||
return map.GetAttackablesInRange(this.OriginPosition, this.HuntingRange)
|
||||
.Where(attackable => attackable switch
|
||||
{
|
||||
Monster monster => this.IsMonsterAttackable(monster),
|
||||
Player player => freeForAll && this.IsEventRivalAttackable(player),
|
||||
_ => false,
|
||||
});
|
||||
}
|
||||
|
||||
private bool IsEventRivalAttackable(Player target)
|
||||
{
|
||||
return !ReferenceEquals(target, this._player)
|
||||
&& target.IsAlive
|
||||
&& !target.IsAtSafezone()
|
||||
&& !target.IsTeleporting
|
||||
&& BotPvpRules.IsLegalPvpTarget(this._player, target);
|
||||
}
|
||||
|
||||
private bool IsTargetInAttackRange(IAttackable target, byte range)
|
||||
{
|
||||
return target.IsInRange(this._player.Position, range);
|
||||
@@ -226,17 +535,51 @@ public sealed class CombatHandler
|
||||
|
||||
private bool IsTargetStillValid(IAttackable target)
|
||||
{
|
||||
// A player target must stay legal for the whole fight: the self-defense window can expire
|
||||
// mid-fight (the player stopped hitting back and ran), and every further strike past that
|
||||
// point would be an unprovoked attack that escalates the bot's own hero state.
|
||||
if (target is Player playerTarget && !BotPvpRules.IsLegalPvpTarget(this._player, playerTarget))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return target.IsAlive
|
||||
&& !target.IsAtSafezone()
|
||||
&& !target.IsTeleporting
|
||||
&& target.IsInRange(this._originPosition, this.HuntingRange);
|
||||
&& target.IsInRange(this.OriginPosition, this.HuntingRange);
|
||||
}
|
||||
|
||||
private bool IsMonsterAttackable(Monster monster)
|
||||
{
|
||||
return monster.IsAlive
|
||||
&& !monster.IsAtSafezone()
|
||||
&& monster.Definition.ObjectKind == NpcObjectKind.Monster;
|
||||
&& monster.Definition.ObjectKind == NpcObjectKind.Monster
|
||||
&& this.IsWithinSafeHuntLevel(monster);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// With <see cref="IMuHelperSettings.OnlyHuntSafeMonsters"/> (server-side bots), the combat AI only
|
||||
/// engages monsters which pass the same <see cref="IsSafeTarget"/> check the bot navigator hunts by.
|
||||
/// Without this, a bot travelling through hostile territory picks a fight with any monster that
|
||||
/// comes within range - including ones far too strong - and dies. Human offline sessions keep the
|
||||
/// unrestricted behavior, since the player chose their hunting spot deliberately.
|
||||
/// </summary>
|
||||
private bool IsWithinSafeHuntLevel(Monster monster)
|
||||
{
|
||||
if (this._config?.OnlyHuntSafeMonsters != true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._player.CurrentMiniGame is not null)
|
||||
{
|
||||
// Inside a mini game event the opposition is not the bot's choice - it fights what
|
||||
// the event throws at it, like every other participant. Refusing "unsafe" waves
|
||||
// would leave the bot idling in the middle of a Blood Castle.
|
||||
return true;
|
||||
}
|
||||
|
||||
return IsSafeTarget(this._player, monster.Definition);
|
||||
}
|
||||
|
||||
private async ValueTask ExecutePhysicalAttackAsync(IAttackable target)
|
||||
@@ -274,6 +617,36 @@ public sealed class CombatHandler
|
||||
{
|
||||
await monster.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// A player target is hit by the area skill as well. Outside of free-for-all events ONLY the
|
||||
// target itself (the self-defense aggressor): any bystanding player in the blast radius is
|
||||
// deliberately spared - a bot's self-defense must never splash uninvolved players, no
|
||||
// matter what it casts. Inside a mini game with free player killing (Chaos Castle) there
|
||||
// are no uninvolved players, so the skill splashes the other participants like any area
|
||||
// skill would. The legality re-check right at the strike closes the last race: the target
|
||||
// was legal when it was picked, but the situation may have changed in the meantime.
|
||||
IEnumerable<Player> playerTargets;
|
||||
if (this._player.CurrentMiniGame is { AllowPlayerKilling: true })
|
||||
{
|
||||
playerTargets = this._player.CurrentMap?
|
||||
.GetAttackablesInRange(target.Position, skill.Range)
|
||||
.OfType<Player>()
|
||||
.Where(p => !ReferenceEquals(p, this._player))
|
||||
?? [];
|
||||
}
|
||||
else
|
||||
{
|
||||
playerTargets = target is Player playerTarget ? [playerTarget] : [];
|
||||
}
|
||||
|
||||
foreach (var player in playerTargets)
|
||||
{
|
||||
if (player.IsAlive && !player.IsAtSafezone()
|
||||
&& BotPvpRules.IsLegalPvpTarget(this._player, player))
|
||||
{
|
||||
await player.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skill skill)
|
||||
@@ -290,10 +663,12 @@ public sealed class CombatHandler
|
||||
return null;
|
||||
}
|
||||
|
||||
// If no skills are configured at all, don't attack.
|
||||
// If no skills are configured at all, don't attack - unless the AI is allowed to pick a skill
|
||||
// on its own (bots), in which case we fall through to the automatic selection below.
|
||||
if (this._config.BasicSkillId == 0
|
||||
&& this._config.ActivationSkill1Id == 0
|
||||
&& this._config.ActivationSkill2Id == 0)
|
||||
&& this._config.ActivationSkill2Id == 0
|
||||
&& !this._config.AutoSelectBestSkill)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -316,9 +691,64 @@ public sealed class CombatHandler
|
||||
}
|
||||
}
|
||||
|
||||
// No explicitly configured skill fired: let the AI pick the strongest affordable learned attack
|
||||
// skill. This scales with the character's level and mana pool, so higher-level bots naturally cast
|
||||
// stronger spells, and drop back to a basic attack (via FallbackBasicAttack) only when out of mana.
|
||||
if (this._config.AutoSelectBestSkill)
|
||||
{
|
||||
return this.SelectBestAffordableSkill();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the strongest attack skill the character has learned and can currently afford (enough mana
|
||||
/// and ability). Only attack skills (direct hit or area damage) are considered; learned skills are
|
||||
/// always class-qualified, so this can never cast a skill the class is not entitled to.
|
||||
/// </summary>
|
||||
private SkillEntry? SelectBestAffordableSkill()
|
||||
{
|
||||
if (this._tickBestSkillComputed)
|
||||
{
|
||||
// Computed once per tick: both the attack-range check and the attack itself need it.
|
||||
return this._tickBestSkill;
|
||||
}
|
||||
|
||||
this._tickBestSkillComputed = true;
|
||||
if (this._player.SkillList is not { } skillList)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
SkillEntry? best = null;
|
||||
var bestDamage = 0;
|
||||
foreach (var entry in skillList.Skills)
|
||||
{
|
||||
if (entry.Skill is not { } skill || skill.AttackDamage <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (skill.SkillType is not (SkillType.DirectHit
|
||||
or SkillType.AreaSkillAutomaticHits
|
||||
or SkillType.AreaSkillExplicitHits
|
||||
or SkillType.AreaSkillExplicitTarget))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (skill.AttackDamage > bestDamage && this.HasEnoughResources(entry))
|
||||
{
|
||||
best = entry;
|
||||
bestDamage = skill.AttackDamage;
|
||||
}
|
||||
}
|
||||
|
||||
this._tickBestSkill = best;
|
||||
return best;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Evaluates whether the skill in the given slot should fire this tick.
|
||||
/// </summary>
|
||||
@@ -514,6 +944,15 @@ public sealed class CombatHandler
|
||||
}
|
||||
}
|
||||
|
||||
// Bots have no configured skill IDs but auto-select their attack skill; use the range of the skill
|
||||
// they would actually cast now, so ranged casters attack from a distance instead of closing to melee.
|
||||
if (this._config.AutoSelectBestSkill
|
||||
&& this.SelectBestAffordableSkill()?.Skill?.Range is { } autoRange
|
||||
&& autoRange > 0)
|
||||
{
|
||||
return (byte)autoRange;
|
||||
}
|
||||
|
||||
if (this._player.Attributes is { } attributes
|
||||
&& (attributes[Stats.IsBowEquipped] > 0 || attributes[Stats.IsCrossBowEquipped] > 0))
|
||||
{
|
||||
|
||||
@@ -18,6 +18,9 @@ using MUnique.OpenMU.Interfaces;
|
||||
/// </summary>
|
||||
public sealed class HealingHandler
|
||||
{
|
||||
/// <summary>Drink a mana potion once mana falls below this share, so casters can keep casting.</summary>
|
||||
private const int ManaThresholdPercent = 30;
|
||||
|
||||
private static readonly ItemConsumeAction ConsumeAction = new();
|
||||
|
||||
private static readonly ItemIdentifier[] HealthPotionPriority =
|
||||
@@ -28,6 +31,13 @@ public sealed class HealingHandler
|
||||
ItemConstants.Apple,
|
||||
];
|
||||
|
||||
private static readonly ItemIdentifier[] ManaPotionPriority =
|
||||
[
|
||||
ItemConstants.LargeManaPotion,
|
||||
ItemConstants.MediumManaPotion,
|
||||
ItemConstants.SmallManaPotion,
|
||||
];
|
||||
|
||||
private readonly OfflinePlayer _player;
|
||||
private readonly IMuHelperSettings? _config;
|
||||
|
||||
@@ -75,7 +85,25 @@ public sealed class HealingHandler
|
||||
if (this._config!.UseHealPotion && this.IsHealthBelowThreshold(this._player, this._config.PotionThresholdPercent))
|
||||
{
|
||||
await this.UseHealthPotionAsync().ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._config.UseManaPotion && this.IsManaBelowThreshold())
|
||||
{
|
||||
await this.UsePotionAsync(ManaPotionPriority).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsManaBelowThreshold()
|
||||
{
|
||||
if (this._player.Attributes is not { } attributes)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
double mana = attributes[Stats.CurrentMana];
|
||||
double maxMana = attributes[Stats.MaximumMana];
|
||||
return maxMana > 0 && (mana * 100.0 / maxMana) <= ManaThresholdPercent;
|
||||
}
|
||||
|
||||
private async ValueTask PerformPartyHealingAsync()
|
||||
@@ -126,14 +154,16 @@ public sealed class HealingHandler
|
||||
return maxHp > 0 && (hp * 100.0 / maxHp) <= thresholdPercent;
|
||||
}
|
||||
|
||||
private async ValueTask UseHealthPotionAsync()
|
||||
private ValueTask UseHealthPotionAsync() => this.UsePotionAsync(HealthPotionPriority);
|
||||
|
||||
private async ValueTask UsePotionAsync(ItemIdentifier[] priority)
|
||||
{
|
||||
if (this._player.Inventory is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var identifier in HealthPotionPriority)
|
||||
foreach (var identifier in priority)
|
||||
{
|
||||
var potion = this._player.Inventory.Items
|
||||
.FirstOrDefault(i => i.Definition?.Group == identifier.Group
|
||||
|
||||
@@ -120,13 +120,22 @@ public sealed class ItemPickupHandler
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._config.PickAncient && item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0))
|
||||
var isAncient = item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0);
|
||||
var isExcellent = item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent);
|
||||
if ((this._config.PickAncient && isAncient) || (this._config.PickExcellent && isExcellent))
|
||||
{
|
||||
return true;
|
||||
// A human's helper hoards every excellent/ancient piece - its owner sorts the treasure
|
||||
// out later. A bot has no later: it cannot trade, so it only takes what it can actually
|
||||
// wear as an upgrade; everything else would silt up its backpack until the loot pickup
|
||||
// stops.
|
||||
return this._player.Account?.IsBot != true
|
||||
|| Bots.BotEquipmentHandler.IsUpgradeFor(this._player, item);
|
||||
}
|
||||
|
||||
if (this._config.PickExcellent && item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent))
|
||||
if (this._config.PickUpgradeItems && Bots.BotEquipmentHandler.IsUpgradeFor(this._player, item))
|
||||
{
|
||||
// The item is class-qualified gear which beats what the bot currently wears - worth picking
|
||||
// up; the BotEquipmentHandler will equip it on one of its next passes.
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ public sealed class MovementHandler
|
||||
|
||||
private readonly OfflinePlayer _player;
|
||||
private readonly IMuHelperSettings? _config;
|
||||
private readonly Point _originPosition;
|
||||
|
||||
private DateTime? _outOfRangeSince;
|
||||
|
||||
@@ -25,14 +24,17 @@ public sealed class MovementHandler
|
||||
/// </summary>
|
||||
/// <param name="player">The offline player.</param>
|
||||
/// <param name="config">The MU Helper configuration.</param>
|
||||
/// <param name="originPosition">The original spawn position.</param>
|
||||
public MovementHandler(OfflinePlayer player, IMuHelperSettings? config, Point originPosition)
|
||||
public MovementHandler(OfflinePlayer player, IMuHelperSettings? config)
|
||||
{
|
||||
this._player = player;
|
||||
this._config = config;
|
||||
this._originPosition = originPosition;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds.
|
||||
/// </summary>
|
||||
private Point OriginPosition => this._player.HuntingOrigin;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the hunting range in tiles.
|
||||
/// </summary>
|
||||
@@ -51,7 +53,7 @@ public sealed class MovementHandler
|
||||
|
||||
if (this.ShouldRegroup(out var distance))
|
||||
{
|
||||
await this.WalkToAsync(this._originPosition).ConfigureAwait(false);
|
||||
await this.WalkToAsync(this.OriginPosition).ConfigureAwait(false);
|
||||
this._outOfRangeSince = null;
|
||||
return false;
|
||||
}
|
||||
@@ -69,13 +71,43 @@ public sealed class MovementHandler
|
||||
/// </summary>
|
||||
/// <param name="target">The target to move closer to.</param>
|
||||
/// <param name="range">The range to stop within.</param>
|
||||
public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range)
|
||||
/// <returns>True, if a walk towards the target was started; false, if no path exists or walking is not possible.</returns>
|
||||
public async ValueTask<bool> MoveCloserToTargetAsync(IAttackable target, byte range)
|
||||
{
|
||||
if (this._player.CurrentMap is { } map && target.IsInRange(this._originPosition, this.HuntingRange))
|
||||
if (this._player.CurrentMap is { } map && target.IsInRange(this.OriginPosition, this.HuntingRange))
|
||||
{
|
||||
var walkTarget = map.Terrain.GetRandomCoordinate(target.Position, range);
|
||||
await this.WalkToAsync(walkTarget).ConfigureAwait(false);
|
||||
var walkTarget = GetApproachPoint(map, this._player.Position, target.Position, range);
|
||||
return await this.WalkToAsync(walkTarget).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks the point to walk to when closing in on a target: straight along the line towards it,
|
||||
/// stopping at attack range. The previous behavior re-randomized a point around the target every
|
||||
/// tick, which made the character zig-zag visibly towards its prey and re-path constantly.
|
||||
/// Falls back to a random point near the target when the straight-line point is not walkable.
|
||||
/// </summary>
|
||||
private static Point GetApproachPoint(GameMap map, Point from, Point to, byte stopRange)
|
||||
{
|
||||
var dx = to.X - from.X;
|
||||
var dy = to.Y - from.Y;
|
||||
var distance = Math.Max(Math.Abs(dx), Math.Abs(dy));
|
||||
if (distance <= stopRange)
|
||||
{
|
||||
return from;
|
||||
}
|
||||
|
||||
var factor = (double)(distance - stopRange) / distance;
|
||||
var x = (byte)Math.Clamp(from.X + (int)Math.Round(dx * factor), 0, 255);
|
||||
var y = (byte)Math.Clamp(from.Y + (int)Math.Round(dy * factor), 0, 255);
|
||||
if (map.Terrain.WalkMap[x, y] && !map.Terrain.SafezoneMap[x, y])
|
||||
{
|
||||
return new Point(x, y);
|
||||
}
|
||||
|
||||
return map.Terrain.GetRandomCoordinate(to, stopRange);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -120,7 +152,7 @@ public sealed class MovementHandler
|
||||
|
||||
private bool ShouldRegroup(out double distance)
|
||||
{
|
||||
distance = this._player.GetDistanceTo(this._originPosition);
|
||||
distance = this._player.GetDistanceTo(this.OriginPosition);
|
||||
if (distance <= RegroupDistanceThreshold)
|
||||
{
|
||||
return false;
|
||||
|
||||
@@ -7,16 +7,72 @@ namespace MUnique.OpenMU.GameLogic.Offline;
|
||||
using MUnique.OpenMU.DataModel.Entities;
|
||||
using MUnique.OpenMU.GameLogic.MuHelper;
|
||||
using MUnique.OpenMU.GameLogic.Views;
|
||||
using MUnique.OpenMU.Pathfinding;
|
||||
using MUnique.OpenMU.PlugIns;
|
||||
|
||||
/// <summary>
|
||||
/// An offline player that continues leveling after the real client disconnects.
|
||||
/// </summary>
|
||||
public sealed class OfflinePlayer : Player
|
||||
public class OfflinePlayer : Player
|
||||
{
|
||||
/// <summary>
|
||||
/// A player who killed this bot this many times gets no (further) revenge: walking back a third
|
||||
/// time into the same lost fight would just be a death loop feeding the killer free kills.
|
||||
/// </summary>
|
||||
private const int RepeatedKillThreshold = 2;
|
||||
|
||||
/// <summary>
|
||||
/// How long an attack by a player stays "hot" as a self-defense target, counted from the LAST hit
|
||||
/// (every attack refreshes it). Long enough to hold a grudge: an attacker who breaks off and comes
|
||||
/// back within this window stays the bot's priority target instead of being forgiven after
|
||||
/// seconds - whether it may actually be struck is decided per attack by <see cref="Bots.BotPvpRules"/>.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan AggressionMemory = TimeSpan.FromMinutes(5);
|
||||
|
||||
/// <summary>
|
||||
/// How long a revenge stays armed after the respawn. One attempt only: if the bot has not reached
|
||||
/// its death site within this time (long routes, fights on the way), it gives up and hunts normally.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan RevengeDuration = TimeSpan.FromMinutes(3);
|
||||
|
||||
/// <summary>
|
||||
/// How long the bot keeps away from hunting grounds near its death site after the same player
|
||||
/// killed it repeatedly (see <see cref="RepeatedKillThreshold"/>).
|
||||
/// </summary>
|
||||
private static readonly TimeSpan DeathSiteAvoidanceDuration = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// How long a death counts toward <see cref="RepeatedKillThreshold"/>. A kill by a player the bot
|
||||
/// has not seen for this long counts as a fresh grudge again, not as a repeated one.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan DeathCountMemory = TimeSpan.FromMinutes(30);
|
||||
|
||||
/// <summary>
|
||||
/// How often each (human) player killed this bot recently, keyed by character name. Written by the
|
||||
/// death plugin and read by the AI ticks, hence concurrent.
|
||||
/// </summary>
|
||||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DeathRecord> _deathsByKiller = new();
|
||||
|
||||
private OfflinePlayerMuHelper? _intelligence;
|
||||
private Task? _intelligenceDisposeTask;
|
||||
|
||||
/// <summary>
|
||||
/// The player who most recently attacked this bot, with the time of that attack. Written from the
|
||||
/// attack path and read from the AI tick; immutable and written atomically (a single reference
|
||||
/// store), so the two can access it without a lock and without a torn <see cref="DateTime"/> read.
|
||||
/// </summary>
|
||||
private volatile Aggression? _aggression;
|
||||
|
||||
/// <summary>
|
||||
/// The pending (not yet armed, <see cref="RevengeState.ExpiresAtUtc"/> is null) or armed revenge.
|
||||
/// The state object is immutable and the field is written atomically, so the death plugin and the
|
||||
/// AI ticks can access it without a lock.
|
||||
/// </summary>
|
||||
private volatile RevengeState? _revenge;
|
||||
|
||||
/// <summary>See <see cref="TryGetDeathSiteToAvoid"/>; immutable and written atomically, like <see cref="_revenge"/>.</summary>
|
||||
private volatile DeathSite? _deathSiteToAvoid;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="OfflinePlayer"/> class.
|
||||
/// </summary>
|
||||
@@ -36,6 +92,71 @@ public sealed class OfflinePlayer : Player
|
||||
/// </summary>
|
||||
public DateTime StartTimestamp { get; internal set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the position the intelligence hunts around. For a plain offline player this is
|
||||
/// the spawn position and never changes. Bots update it to roam between hunting grounds.
|
||||
/// </summary>
|
||||
public Point HuntingOrigin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the player should keep playing after dying and respawning.
|
||||
/// A normal offline session ends on death; bots override this to keep running forever.
|
||||
/// </summary>
|
||||
public virtual bool RespawnAndContinue => false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets actions queued from outside the AI tick (e.g. skill learning on level-up), which the
|
||||
/// <see cref="OfflinePlayerMuHelper"/> drains at the start of each tick. This serializes such
|
||||
/// mutations with the combat handler, so e.g. the skill list is never modified while combat is
|
||||
/// enumerating it.
|
||||
/// </summary>
|
||||
internal System.Collections.Concurrent.ConcurrentQueue<Func<ValueTask>> PendingBotActions { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the player who most recently attacked this bot (self-defense target), if the aggression
|
||||
/// is recent enough and the aggressor is still a viable target.
|
||||
/// </summary>
|
||||
internal Player? RecentAggressor
|
||||
{
|
||||
get
|
||||
{
|
||||
if (this._aggression is { } aggression
|
||||
&& DateTime.UtcNow - aggression.AtUtc <= AggressionMemory
|
||||
&& aggression.Aggressor.IsAlive
|
||||
&& !aggression.Aggressor.IsAtSafezone())
|
||||
{
|
||||
return aggression.Aggressor;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the pending party invitation from a player, scheduled by
|
||||
/// <see cref="Bots.BotPartyHandler"/> and executed with a human-like delay in the bot's tick.
|
||||
/// </summary>
|
||||
internal Bots.PendingPartyInvite? PendingPartyInvite { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the time at which the bot gets bored of its current party with a human player
|
||||
/// and politely leaves it (managed by <see cref="Bots.BotPartyHandler"/>).
|
||||
/// </summary>
|
||||
internal DateTime? PartyBoredomAtUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the bot is currently on a shopping trip (walking to
|
||||
/// or trading with a merchant), maintained by <see cref="Bots.BotNavigator"/>. While on an errand
|
||||
/// the bot declines party invitations, like a busy player would.
|
||||
/// </summary>
|
||||
internal bool IsOnShoppingTrip { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether a revenge against a player killer is pending or armed - the
|
||||
/// bot has unfinished business and is in no mood to group up.
|
||||
/// </summary>
|
||||
internal bool HasRevengeIntent => this._revenge is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the offline player by loading the account fresh from the database.
|
||||
/// </summary>
|
||||
@@ -70,6 +191,8 @@ public sealed class OfflinePlayer : Player
|
||||
|
||||
await this.ClientReadyAfterMapChangeAsync().ConfigureAwait(false);
|
||||
|
||||
this.HuntingOrigin = this.Position;
|
||||
|
||||
this.StartIntelligence();
|
||||
|
||||
this.Logger.LogDebug(
|
||||
@@ -90,11 +213,188 @@ public sealed class OfflinePlayer : Player
|
||||
/// <summary>
|
||||
/// Stops the offline player and removes it from the world.
|
||||
/// </summary>
|
||||
public async ValueTask StopAsync()
|
||||
public virtual async ValueTask StopAsync()
|
||||
{
|
||||
await this.DisconnectAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a player who attacked this bot, so the combat AI can defend itself.
|
||||
/// </summary>
|
||||
/// <param name="aggressor">The player who attacked this bot.</param>
|
||||
internal void RegisterAggressor(Player aggressor)
|
||||
{
|
||||
this._aggression = new Aggression(aggressor, DateTime.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers that a (human) player killed this bot. The first kill makes a revenge pending: after
|
||||
/// respawning on the same map, the bot marches back to the place of its death (driven by the
|
||||
/// <see cref="Bots.BotNavigator"/>) with re-armed aggressor memory, so it attacks the killer on
|
||||
/// sight. A repeated kill by the same player (see <see cref="RepeatedKillThreshold"/>) cancels
|
||||
/// revenge instead and makes the bot avoid hunting grounds near the death site for a while.
|
||||
/// </summary>
|
||||
/// <param name="killer">The player who killed this bot.</param>
|
||||
internal void RegisterDeathByPlayer(Player killer)
|
||||
{
|
||||
if (this.CurrentMap?.Definition is not { } deathMap)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var deathPosition = this.Position;
|
||||
var record = this._deathsByKiller.AddOrUpdate(
|
||||
killer.Name,
|
||||
_ => new DeathRecord(1, now),
|
||||
(_, existing) => now - existing.LastDeathUtc > DeathCountMemory
|
||||
? new DeathRecord(1, now)
|
||||
: new DeathRecord(existing.Count + 1, now));
|
||||
|
||||
if (record.Count >= RepeatedKillThreshold)
|
||||
{
|
||||
this._revenge = null;
|
||||
this._deathSiteToAvoid = new DeathSite(deathPosition, deathMap, now + DeathSiteAvoidanceDuration);
|
||||
this.Logger.LogInformation(
|
||||
"Bot '{Name}' was killed by '{Killer}' again; giving up on revenge and avoiding the area around {Position} for a while.",
|
||||
this.Name,
|
||||
killer.Name,
|
||||
deathPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
this._revenge = new RevengeState(killer, deathPosition, deathMap, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arms a pending revenge once the bot respawned, called by the <see cref="OfflinePlayerMuHelper"/>
|
||||
/// when a bot resumes after death. Only a respawn on the map the bot died on qualifies (from any
|
||||
/// other map the march back would be meaningless); the aggressor memory is re-armed, so the combat
|
||||
/// AI keeps the killer prioritized (struck only when legal, see <see cref="Bots.BotPvpRules"/>),
|
||||
/// and the revenge gets its time-to-live.
|
||||
/// </summary>
|
||||
internal void ArmRevengeAfterRespawn()
|
||||
{
|
||||
if (this._revenge is not { ExpiresAtUtc: null } revenge)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!object.Equals(this.CurrentMap?.Definition, revenge.DeathMap))
|
||||
{
|
||||
this._revenge = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this._revenge = revenge with { ExpiresAtUtc = DateTime.UtcNow + RevengeDuration };
|
||||
this.RegisterAggressor(revenge.Killer);
|
||||
this.Logger.LogInformation("Bot '{Name}' returns to avenge its death against '{Killer}'.", this.Name, revenge.Killer.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the destination of an armed, still running revenge. Expires the revenge when its
|
||||
/// time-to-live ran out or the bot is no longer on the map it died on (e.g. it warped away).
|
||||
/// </summary>
|
||||
/// <param name="currentMap">The map the bot is currently on.</param>
|
||||
/// <param name="deathSite">The place of the bot's death to march back to.</param>
|
||||
/// <returns><c>true</c> if a revenge is active and <paramref name="deathSite"/> was set.</returns>
|
||||
internal bool TryGetRevengeDestination(GameMapDefinition currentMap, out Point deathSite)
|
||||
{
|
||||
deathSite = default;
|
||||
if (this._revenge is not { ExpiresAtUtc: { } expiresAt } revenge)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow > expiresAt)
|
||||
{
|
||||
this.ExpireRevenge("it timed out before the bot reached the death site");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!object.Equals(currentMap, revenge.DeathMap))
|
||||
{
|
||||
this.ExpireRevenge("the bot left the map it died on");
|
||||
return false;
|
||||
}
|
||||
|
||||
deathSite = revenge.DeathPosition;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends an active revenge - the single attempt is spent, the bot returns to its normal routine.
|
||||
/// The aggressor memory is deliberately left armed: if the killer is still around, the combat AI
|
||||
/// engages it, and if it strikes again, self-defense re-arms the memory anyway.
|
||||
/// </summary>
|
||||
/// <param name="reason">Why the revenge ended, for the log.</param>
|
||||
internal void ExpireRevenge(string reason)
|
||||
{
|
||||
if (this._revenge is { } revenge)
|
||||
{
|
||||
this._revenge = null;
|
||||
this.Logger.LogInformation("Bot '{Name}' revenge against '{Killer}' ended: {Reason}.", this.Name, revenge.Killer.Name, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the death site the bot should keep away from when picking a hunting ground - set after the
|
||||
/// same player killed it repeatedly, so it stops walking back into the same lost fight.
|
||||
/// </summary>
|
||||
/// <param name="currentMap">The map the bot is currently on.</param>
|
||||
/// <param name="deathSite">The place of the repeated deaths.</param>
|
||||
/// <returns><c>true</c> if an avoidance is active on the given map and <paramref name="deathSite"/> was set.</returns>
|
||||
internal bool TryGetDeathSiteToAvoid(GameMapDefinition currentMap, out Point deathSite)
|
||||
{
|
||||
deathSite = default;
|
||||
if (this._deathSiteToAvoid is not { } site
|
||||
|| DateTime.UtcNow > site.AvoidUntilUtc
|
||||
|| !object.Equals(currentMap, site.Map))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
deathSite = site.Position;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes and removes all queued <see cref="PendingBotActions"/>.
|
||||
/// </summary>
|
||||
internal async ValueTask DrainPendingBotActionsAsync()
|
||||
{
|
||||
while (this.PendingBotActions.TryDequeue(out var action))
|
||||
{
|
||||
try
|
||||
{
|
||||
await action().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this.Logger.LogError(ex, "Queued bot action failed for {Account}.", this.AccountLoginName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when an AI tick of this player finished without an exception. Does nothing here - a bot
|
||||
/// uses it to forget earlier failures (see <see cref="Bots.BotPlayer"/>).
|
||||
/// </summary>
|
||||
internal virtual void OnAiTickSucceeded()
|
||||
{
|
||||
// Nothing to do for a plain offline player.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when an AI tick of this player threw. Does nothing here, so the human offline mode keeps
|
||||
/// behaving exactly as before; a bot counts the failures and asks for a restart when they don't stop
|
||||
/// (see <see cref="Bots.BotPlayer"/>).
|
||||
/// </summary>
|
||||
internal virtual void OnAiTickFailed()
|
||||
{
|
||||
// Nothing to do for a plain offline player.
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async ValueTask InternalDisconnectAsync()
|
||||
{
|
||||
@@ -132,6 +432,15 @@ public sealed class OfflinePlayer : Player
|
||||
protected override ICustomPlugInContainer<IViewPlugIn> CreateViewPlugInContainer()
|
||||
=> new OfflineViewPlugInContainer(this);
|
||||
|
||||
/// <summary>
|
||||
/// Starts the intelligence which drives this offline player. Overridden by bots to also run navigation.
|
||||
/// </summary>
|
||||
protected virtual void StartIntelligence()
|
||||
{
|
||||
this._intelligence = new OfflinePlayerMuHelper(this);
|
||||
this._intelligence.Start();
|
||||
}
|
||||
|
||||
private async ValueTask AdvanceToCharacterSelectionStateAsync()
|
||||
{
|
||||
// Advance state to allow the intelligence to perform actions.
|
||||
@@ -146,9 +455,24 @@ public sealed class OfflinePlayer : Player
|
||||
await this.SetSelectedCharacterAsync(character).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void StartIntelligence()
|
||||
{
|
||||
this._intelligence = new OfflinePlayerMuHelper(this);
|
||||
this._intelligence.Start();
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// How often (and how recently) a specific player killed this bot.
|
||||
/// </summary>
|
||||
private sealed record DeathRecord(int Count, DateTime LastDeathUtc);
|
||||
|
||||
/// <summary>
|
||||
/// A revenge for a death by a player's hand: pending while <see cref="ExpiresAtUtc"/> is null
|
||||
/// (the bot has not respawned yet), armed and running once it is set.
|
||||
/// </summary>
|
||||
private sealed record RevengeState(Player Killer, Point DeathPosition, GameMapDefinition DeathMap, DateTime? ExpiresAtUtc);
|
||||
|
||||
/// <summary>
|
||||
/// A death site the bot avoids when picking hunting grounds, after repeated deaths there.
|
||||
/// </summary>
|
||||
private sealed record DeathSite(Point Position, GameMapDefinition Map, DateTime AvoidUntilUtc);
|
||||
|
||||
/// <summary>
|
||||
/// The most recent aggression against this bot: who attacked and when.
|
||||
/// </summary>
|
||||
private sealed record Aggression(Player Aggressor, DateTime AtUtc);
|
||||
}
|
||||
|
||||
@@ -46,14 +46,13 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
|
||||
public OfflinePlayerMuHelper(OfflinePlayer player)
|
||||
{
|
||||
this._player = player;
|
||||
var originalPosition = player.Position;
|
||||
var config = player.MuHelperSettings;
|
||||
|
||||
this._buffHandler = new BuffHandler(player, config);
|
||||
this._healingHandler = new HealingHandler(player, config);
|
||||
this._itemPickupHandler = new ItemPickupHandler(player, config);
|
||||
this._movementHandler = new MovementHandler(player, config, originalPosition);
|
||||
this._combatHandler = new CombatHandler(player, config, this._movementHandler, originalPosition);
|
||||
this._movementHandler = new MovementHandler(player, config);
|
||||
this._combatHandler = new CombatHandler(player, config, this._movementHandler);
|
||||
this._repairHandler = new RepairHandler(player, config);
|
||||
this._zenHandler = new ZenConsumptionHandler(player);
|
||||
this._petHandler = new PetHandler(player, config);
|
||||
@@ -120,10 +119,17 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
|
||||
{
|
||||
this._player.Logger.LogDebug("Offline player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName);
|
||||
this._isDead = true;
|
||||
|
||||
// Do not cancel the loop here: a bot needs to keep ticking so it can resume after respawning.
|
||||
// For a normal offline session the tick stops the session on respawn, which disposes (and cancels) this helper.
|
||||
}
|
||||
|
||||
private async Task RunLoopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Randomize the loop phase, so hundreds of concurrently started players don't all tick on the
|
||||
// same 500ms boundary - smoother server load and less robotic synchrony between them.
|
||||
await Task.Delay(Rand.NextInt(0, 500), cancellationToken).ConfigureAwait(false);
|
||||
|
||||
while (await this._timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
@@ -136,6 +142,7 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
|
||||
try
|
||||
{
|
||||
await this.TickAsync(cancellationToken).ConfigureAwait(false);
|
||||
this._player.OnAiTickSucceeded();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -144,11 +151,16 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._player.Logger.LogError(ex, "Error in offline player helper tick for {AccountLoginName}.", this._player.AccountLoginName);
|
||||
this._player.OnAiTickFailed();
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask TickAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Actions queued from outside the tick (e.g. skill learning on level-up) run here, serialized
|
||||
// with the combat handler - so nothing mutates the skill list while combat is enumerating it.
|
||||
await this._player.DrainPendingBotActionsAsync().ConfigureAwait(false);
|
||||
|
||||
if (await this.HandleDeathAsync().ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
@@ -215,6 +227,19 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this._player.RespawnAndContinue)
|
||||
{
|
||||
// Bots keep playing: reset the death state and re-anchor the hunting origin to the respawn
|
||||
// position so the navigator picks a fresh hunting ground from where the bot came back to life.
|
||||
// A death by a player's hand may have left a pending revenge - arm it now (the navigator
|
||||
// then marches the bot back to its death site instead of picking a hunting ground).
|
||||
this._isDead = false;
|
||||
this._player.HuntingOrigin = this._player.Position;
|
||||
this._player.ArmRevengeAfterRespawn();
|
||||
this._player.Logger.LogInformation("Bot '{Name}' respawned; resuming.", this._player.Name);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this._player.Account?.LoginName is { } loginName)
|
||||
{
|
||||
this._player.Logger.LogInformation("Offline player died and successfully respawned. Stopping session for {0}.", loginName);
|
||||
|
||||
@@ -34,6 +34,14 @@ internal sealed class ZenConsumptionHandler
|
||||
/// <returns><c>true</c> if the player can continue; <c>false</c> if insufficient Zen.</returns>
|
||||
public async ValueTask<bool> DeductZenAsync()
|
||||
{
|
||||
// Bots are exempt from the PC-Cafe fee: they don't accumulate Zen fast enough
|
||||
// to cover it and would otherwise go bankrupt and stop. Human offline-leveling
|
||||
// players (IsBot == false) keep paying as before.
|
||||
if (this._player.Account?.IsBot == true)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow - this._lastPayTimestamp < this._configuration.PayInterval)
|
||||
{
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user