Merge pull request #820 from nolt/feature-bots

(cherry picked from commit b10de0645a869485fbb5771a739abd06b5708c2d)
This commit is contained in:
sven-n
2026-07-16 22:01:57 +02:00
committed by Acentech Dev
parent 8baf329654
commit d04cbecae3
58 changed files with 14856 additions and 62 deletions

View File

@@ -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()

View File

@@ -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))
{

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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;